Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

212 lines
7.6 KiB
Go

package indexer
import (
"context"
"errors"
"fmt"
"hash/fnv"
"os"
"path/filepath"
"strings"
"github.com/zzet/gortex/internal/gitcmd"
)
// WorktreeInfo describes a directory's relationship to its git
// repository.
type WorktreeInfo struct {
// IsWorktree is true when the directory is a linked git worktree
// rather than the repository's main checkout.
IsWorktree bool
// MainRepoPath is the main worktree's root — the shared base that
// every linked worktree of the repo descends from. It equals the
// queried path for a main checkout or a non-git directory.
MainRepoPath string
// GitCommonDir is the shared .git directory all of a repo's
// worktrees use. Empty when the directory is not a git repository.
GitCommonDir string
}
// ResolveWorktree reports whether path is a linked git worktree and
// resolves the main repository it shares a .git directory with.
//
// A linked worktree carries a `.git` *file* (`gitdir: <path>`) instead
// of a directory; the referenced per-worktree gitdir holds a
// `commondir` file pointing back at the shared .git, whose parent is
// the main checkout. A git submodule also uses a `.git` file but has
// no `commondir`, so it resolves to itself — a submodule is a separate
// repository, not a worktree. A main checkout or a non-git directory
// likewise resolves to itself.
//
// A worktree can be tracked either as part of its canonical repo (the
// default) or, via WorktreeInstanceName, as an independent instance
// keyed by its own working-directory path — letting one underlying
// repository have multiple checkouts, each assigned to its own
// workspace and indexed from its own branch / working tree.
func ResolveWorktree(path string) WorktreeInfo {
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
info := WorktreeInfo{MainRepoPath: abs}
gitPath := filepath.Join(abs, ".git")
st, err := os.Stat(gitPath)
if err != nil {
return info // not a git repository
}
if st.IsDir() {
info.GitCommonDir = gitPath
return info // the main checkout
}
// `.git` is a file: a linked worktree or a submodule. Read the
// gitdir indirection.
content, err := os.ReadFile(gitPath)
if err != nil {
return info
}
line := strings.TrimSpace(string(content))
wtGitDir := strings.TrimSpace(strings.TrimPrefix(line, "gitdir:"))
if wtGitDir == "" || wtGitDir == line {
return info // malformed .git file
}
if !filepath.IsAbs(wtGitDir) {
wtGitDir = filepath.Join(abs, wtGitDir)
}
// Only a worktree's gitdir carries a `commondir` file; a
// submodule's does not. Its absence means "not a worktree".
commonRaw, err := os.ReadFile(filepath.Join(wtGitDir, "commondir"))
if err != nil {
return info
}
common := strings.TrimSpace(string(commonRaw))
if !filepath.IsAbs(common) {
common = filepath.Join(wtGitDir, common)
}
common = filepath.Clean(common)
info.IsWorktree = true
info.GitCommonDir = common
// The main checkout is the directory that contains the shared .git.
if filepath.Base(common) == ".git" {
info.MainRepoPath = filepath.Dir(common)
}
return info
}
// WorktreeRootGone reports whether a directory that was previously
// tracked as a repository root has since disappeared from disk — the
// `git worktree remove` (or manual `rm -rf`) case. It returns true only
// when a Stat of the path fails with a not-exist error; a transient
// error (EACCES on a mounted volume, an NFS hiccup) returns false so a
// flaky filesystem can never trigger a destructive index eviction.
//
// This mirrors the deletion-detection rule IncrementalReindex already
// uses for individual files: only os.ErrNotExist counts as gone, every
// other Stat error is treated as "preserve, can't verify."
//
// The check is path existence, not git liveness: once the worktree
// directory is gone there is no `.git` file left to resolve, so the
// only signal available is "the root no longer exists." Callers that
// need to know the directory *was* a worktree must remember that fact
// from when it was still on disk (see RepoMetadata.IsWorktree).
func WorktreeRootGone(rootPath string) bool {
if rootPath == "" {
return false
}
_, err := os.Stat(rootPath)
if err == nil {
return false
}
return errors.Is(err, os.ErrNotExist)
}
// WorktreeInstanceName decides the repo prefix that absPath should be
// tracked under, given the base prefix it would otherwise take and the
// workspace it declares (via its own `.gortex.yaml` or a global-config
// override). It returns (name, separate): when separate is true the
// caller must register absPath as an INDEPENDENT repo instance under
// `name`, distinct from any canonical checkout that shares the same git
// identity; when false the caller keeps its single-instance behaviour.
//
// A separate instance is created when either:
// - asWorktree is set (the explicit `--as-worktree` directive), or
// - absPath is a linked git worktree that declares a workspace which
// differs from its base prefix. An explicit workspace on a worktree
// is the signal that the checkout means to join a workspace other
// than the canonical's, so we honour it automatically.
//
// The instance name is `<basePrefix>@<tag>`, where the tag is the
// declared workspace when present, else the checked-out branch, else a
// short hash of the path. The result is deterministic for a given
// (path, declared-workspace) pair so it is stable across daemon
// restarts. The tag is sanitised so the name never contains '/', which
// would break the `<prefix>/<relpath>` node-ID layout.
func WorktreeInstanceName(absPath, basePrefix, declaredWorkspace string, asWorktree bool) (string, bool) {
separate := asWorktree
if !separate && declaredWorkspace != "" && declaredWorkspace != basePrefix {
separate = ResolveWorktree(absPath).IsWorktree
}
if !separate {
return basePrefix, false
}
tag := ""
if declaredWorkspace != "" && declaredWorkspace != basePrefix {
tag = declaredWorkspace
}
if tag == "" {
tag = worktreeBranch(absPath)
}
if t := sanitizeInstanceTag(tag); t != "" {
return basePrefix + "@" + t, true
}
return basePrefix + "@" + shortPathHash(absPath), true
}
// worktreeBranch returns the short branch name checked out at absPath, or
// "" when the path is not a git working tree or is in detached-HEAD
// state. Used to disambiguate a forced worktree instance that declares
// no workspace of its own.
func worktreeBranch(absPath string) string {
b, err := gitcmd.Output(context.Background(), absPath, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return ""
}
if b == "" || b == "HEAD" { // detached HEAD has no branch name
return ""
}
return b
}
// sanitizeInstanceTag normalises a disambiguator (a workspace slug or a
// branch name) into a token safe to embed in a repo prefix. A repo
// prefix is the leading "<prefix>/" segment of every node ID, so the
// token must never contain '/'; any character outside [A-Za-z0-9._-] is
// folded to '-'. Leading/trailing '-' are trimmed; empty input maps to "".
func sanitizeInstanceTag(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '.', r == '_', r == '-':
b.WriteRune(r)
default:
b.WriteRune('-')
}
}
return strings.Trim(b.String(), "-")
}
// shortPathHash returns a short, stable hash of a filesystem path. It is
// the last-resort disambiguator when a forced worktree instance has
// neither a declared workspace nor a resolvable branch — two different
// checkouts under the same name still get distinct prefixes.
func shortPathHash(absPath string) string {
h := fnv.New32a()
_, _ = h.Write([]byte(filepath.Clean(absPath)))
return fmt.Sprintf("%08x", h.Sum32())
}