Files
zzet--gortex/internal/indexer/worktree_instance_test.go
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

329 lines
13 KiB
Go

package indexer
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/search"
)
// gitInitRepo initialises a fresh git repo at dir with deterministic
// commit settings, independent of the developer's global git config.
func gitInitRepo(t *testing.T, dir string) {
t.Helper()
require.NoError(t, os.MkdirAll(dir, 0o755))
runGit(t, dir, "init", "-q", "-b", "main")
runGit(t, dir, "config", "user.email", "test@example.com")
runGit(t, dir, "config", "user.name", "Test")
runGit(t, dir, "config", "commit.gpgsign", "false")
}
// setupCollidingWorktree builds a canonical checkout and a linked git
// worktree that SHARE A BASENAME (the issue's `oas-orm` collision) so
// both would otherwise resolve to the same repo prefix. The worktree is
// checked out on `branch`; when wtWorkspace is non-empty it gets a
// `.gortex.yaml` declaring that workspace. Returns (canonPath, wtPath).
func setupCollidingWorktree(t *testing.T, basename, branch, wtWorkspace string) (string, string) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git binary not available in PATH")
}
canon := filepath.Join(t.TempDir(), basename)
gitInitRepo(t, canon)
writeFile(t, filepath.Join(canon, "lib.go"), "package lib\n\nfunc Canonical() {}\n")
runGit(t, canon, "add", ".")
runGit(t, canon, "commit", "-q", "-m", "init")
wt := filepath.Join(t.TempDir(), basename)
runGit(t, canon, "worktree", "add", "-q", "-b", branch, wt)
writeFile(t, filepath.Join(wt, "feature.go"), "package lib\n\nfunc Feature() {}\n")
if wtWorkspace != "" {
writeFile(t, filepath.Join(wt, ".gortex.yaml"), "workspace: "+wtWorkspace+"\n")
}
return canon, wt
}
func newWorktreeTestIndexer(t *testing.T, repos ...config.RepoEntry) (*graph.Graph, *MultiIndexer, *config.ConfigManager) {
t.Helper()
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{Repos: repos}
gc.SetConfigPath(cfgPath)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(cfgPath)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
return g, mi, cm
}
// TestWorktreeInstance_IndexAll_AutoSeparatesByWorkspace is the core
// regression for issue #47: a worktree of an already-tracked repo, whose
// `.gortex.yaml` declares a different workspace, must be indexed as an
// INDEPENDENT instance instead of silently coalescing into the canonical.
func TestWorktreeInstance_IndexAll_AutoSeparatesByWorkspace(t *testing.T) {
canon, wt := setupCollidingWorktree(t, "oas-orm", "task", "task-ws")
g, mi, _ := newWorktreeTestIndexer(t,
config.RepoEntry{Path: canon}, // canonical, base prefix oas-orm
config.RepoEntry{Path: wt}, // worktree declaring workspace task-ws
)
results, err := mi.IndexAll()
require.NoError(t, err)
// Two independent instances, not one coalesced entry.
require.Len(t, results, 2, "the worktree must not coalesce into the canonical")
canonMeta := mi.GetMetadata("oas-orm")
wtMeta := mi.GetMetadata("oas-orm@task-ws")
require.NotNil(t, canonMeta, "canonical keeps the base prefix")
require.NotNil(t, wtMeta, "worktree is tracked under a derived prefix")
assert.False(t, canonMeta.IsWorktree)
assert.True(t, wtMeta.IsWorktree)
assert.Equal(t, realpath(t, canon), realpath(t, canonMeta.RootPath))
assert.Equal(t, realpath(t, wt), realpath(t, wtMeta.RootPath))
// Each instance's files are namespaced under its own prefix and do
// not bleed into the other.
assert.NotEmpty(t, g.GetFileNodes("oas-orm/lib.go"))
assert.NotEmpty(t, g.GetFileNodes("oas-orm@task-ws/feature.go"))
assert.Empty(t, g.GetFileNodes("oas-orm/feature.go"),
"the worktree's branch-only file must not appear under the canonical")
// Per-instance workspace membership.
inTask := mi.ReposInWorkspace("task-ws")
assert.True(t, inTask["oas-orm@task-ws"], "worktree joins task-ws")
assert.False(t, inTask["oas-orm"], "canonical does not join task-ws")
// A session launched from inside the worktree resolves to its
// workspace, not the canonical's.
ws, _, prefix, ok := mi.ScopeForCWD(wt)
require.True(t, ok)
assert.Equal(t, "task-ws", ws)
assert.Equal(t, "oas-orm@task-ws", prefix)
// ResolveFilePath disambiguates the two overlapping prefixes
// (`oas-orm` vs `oas-orm@task-ws`) by longest match, mapping each
// prefixed graph path back to its own checkout.
assert.Equal(t, realpath(t, filepath.Join(wt, "feature.go")),
realpath(t, mi.ResolveFilePath("oas-orm@task-ws/feature.go")))
assert.Equal(t, realpath(t, filepath.Join(canon, "lib.go")),
realpath(t, mi.ResolveFilePath("oas-orm/lib.go")))
}
// TestWorktreeInstance_PlainWorktreeStillCoalesces guards the opposite
// case: a worktree that declares NO distinct workspace (and is not
// flagged) keeps the legacy behaviour and coalesces into the canonical —
// we don't want to double-index every incidental worktree.
func TestWorktreeInstance_PlainWorktreeStillCoalesces(t *testing.T) {
canon, wt := setupCollidingWorktree(t, "oas-orm", "task", "") // no .gortex.yaml
_, mi, _ := newWorktreeTestIndexer(t,
config.RepoEntry{Path: canon},
config.RepoEntry{Path: wt},
)
results, err := mi.IndexAll()
require.NoError(t, err)
// Same basename, same (default) workspace → one instance wins; there
// is exactly one "oas-orm" and no "oas-orm@..." sibling.
assert.NotNil(t, mi.GetMetadata("oas-orm"))
for prefix := range mi.AllMetadata() {
assert.NotContains(t, prefix, "@",
"a plain worktree must not spawn a separate instance, got %q", prefix)
}
_ = results
}
// TestWorktreeInstance_TrackCtx_NoCoalesce_PersistsName exercises the
// interactive runtime path: track the canonical, then track the worktree
// as a brand-new path. The derived prefix must be returned and persisted
// as the entry Name so a daemon restart reproduces it.
func TestWorktreeInstance_TrackCtx_NoCoalesce_PersistsName(t *testing.T) {
canon, wt := setupCollidingWorktree(t, "oas-orm", "task", "task-ws")
// Pre-list canon plus a placeholder so multi-repo prefixing is on
// from the first track (single-repo mode would skip prefixes).
placeholder := filepath.Join(t.TempDir(), "placeholder")
require.NoError(t, os.MkdirAll(placeholder, 0o755))
_, mi, cm := newWorktreeTestIndexer(t,
config.RepoEntry{Path: canon},
config.RepoEntry{Path: placeholder},
)
rCanon, err := mi.TrackRepoCtx(testCtx(), config.RepoEntry{Path: canon})
require.NoError(t, err)
require.NotNil(t, rCanon)
assert.Equal(t, "oas-orm", rCanon.RepoPrefix)
rWt, err := mi.TrackRepoCtx(testCtx(), config.RepoEntry{Path: wt})
require.NoError(t, err)
require.NotNil(t, rWt, "worktree must not coalesce into the canonical")
assert.Equal(t, "oas-orm@task-ws", rWt.RepoPrefix)
// The derived prefix is persisted as the entry Name (wt was a fresh
// path, so AddRepo recorded it).
entry := cm.Global().FindRepoByPrefix("oas-orm@task-ws")
require.NotNil(t, entry, "the worktree instance must be persisted to config")
assert.Equal(t, "oas-orm@task-ws", entry.Name)
assert.Equal(t, realpath(t, wt), realpath(t, mustAbs(t, entry.Path)))
// Re-tracking the same worktree path is an idempotent no-op.
again, err := mi.TrackRepoCtx(testCtx(), config.RepoEntry{Path: wt})
require.NoError(t, err)
assert.Nil(t, again, "re-tracking the worktree must be already-tracked")
}
// TestWorktreeInstance_DeterministicAcrossRestart verifies the derived
// prefix is stable: a fresh MultiIndexer over the same config reproduces
// the same instance prefixes (the auto rule is intrinsic, so it does not
// depend on a persisted Name or on tracking order).
func TestWorktreeInstance_DeterministicAcrossRestart(t *testing.T) {
canon, wt := setupCollidingWorktree(t, "oas-orm", "task", "task-ws")
for _, pass := range []string{"first", "second"} {
_, mi, _ := newWorktreeTestIndexer(t,
config.RepoEntry{Path: canon},
config.RepoEntry{Path: wt},
)
_, err := mi.IndexAll()
require.NoError(t, err, pass)
require.NotNil(t, mi.GetMetadata("oas-orm"), pass)
require.NotNil(t, mi.GetMetadata("oas-orm@task-ws"), pass)
}
}
// TestWorktreeInstance_TwoWorktreesSameWorkspaceDisambiguated covers the
// degenerate collision: two different worktrees of the same repo that
// both declare the same workspace must still get distinct prefixes.
func TestWorktreeInstance_TwoWorktreesSameWorkspaceDisambiguated(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git binary not available in PATH")
}
canon := filepath.Join(t.TempDir(), "oas-orm")
gitInitRepo(t, canon)
writeFile(t, filepath.Join(canon, "lib.go"), "package lib\n\nfunc Canonical() {}\n")
runGit(t, canon, "add", ".")
runGit(t, canon, "commit", "-q", "-m", "init")
wtA := filepath.Join(t.TempDir(), "oas-orm")
runGit(t, canon, "worktree", "add", "-q", "-b", "a", wtA)
writeFile(t, filepath.Join(wtA, ".gortex.yaml"), "workspace: shared\n")
writeFile(t, filepath.Join(wtA, "a.go"), "package lib\n\nfunc A() {}\n")
wtB := filepath.Join(t.TempDir(), "oas-orm")
runGit(t, canon, "worktree", "add", "-q", "-b", "b", wtB)
writeFile(t, filepath.Join(wtB, ".gortex.yaml"), "workspace: shared\n")
writeFile(t, filepath.Join(wtB, "b.go"), "package lib\n\nfunc B() {}\n")
_, mi, _ := newWorktreeTestIndexer(t,
config.RepoEntry{Path: canon},
config.RepoEntry{Path: wtA},
config.RepoEntry{Path: wtB},
)
_, err := mi.IndexAll()
require.NoError(t, err)
// Both worktrees want "oas-orm@shared"; one keeps it, the other gets
// a path-hash suffix. Exactly three distinct instances total.
assert.Len(t, mi.AllMetadata(), 3)
shared := 0
for prefix := range mi.AllMetadata() {
if prefix == "oas-orm@shared" || (len(prefix) > len("oas-orm@shared-") && prefix[:len("oas-orm@shared-")] == "oas-orm@shared-") {
shared++
}
}
assert.Equal(t, 2, shared, "two distinct worktree instances under the shared workspace")
}
// TestEffectiveRepoPrefix_MatchesRegisteredPrefix guarantees the warm-
// restart keying helper agrees with the prefix the indexer actually
// registers — otherwise a disk-backed reconcile would key snapshot
// mtimes by the wrong prefix and re-index the worktree every restart.
func TestEffectiveRepoPrefix_MatchesRegisteredPrefix(t *testing.T) {
canon, wt := setupCollidingWorktree(t, "oas-orm", "task", "task-ws")
_, mi, cm := newWorktreeTestIndexer(t,
config.RepoEntry{Path: canon},
config.RepoEntry{Path: wt},
)
_, err := mi.IndexAll()
require.NoError(t, err)
// The helper reproduces the registered prefix for both checkouts.
assert.Equal(t, "oas-orm", EffectiveRepoPrefix(cm, config.RepoEntry{Path: canon}))
assert.Equal(t, "oas-orm@task-ws", EffectiveRepoPrefix(cm, config.RepoEntry{Path: wt}))
require.NotNil(t, mi.GetMetadata(EffectiveRepoPrefix(cm, config.RepoEntry{Path: wt})))
require.NotNil(t, mi.GetMetadata(EffectiveRepoPrefix(cm, config.RepoEntry{Path: canon})))
// An explicit Name is honoured verbatim (the interactive-track case).
assert.Equal(t, "custom", EffectiveRepoPrefix(cm, config.RepoEntry{Path: wt, Name: "custom"}))
}
func mustAbs(t *testing.T, p string) string {
t.Helper()
abs, err := filepath.Abs(p)
require.NoError(t, err)
return abs
}
func TestSanitizeInstanceTag(t *testing.T) {
cases := map[string]string{
"task-ws": "task-ws",
"feature/foo": "feature-foo", // branch slashes can't reach a node-ID
"a@b": "a-b",
" spaced ": "spaced",
"--edges--": "edges",
"keep.dots_1": "keep.dots_1",
"weird*chars!": "weird-chars",
"": "",
"///": "",
"release/1.2.3": "release-1.2.3",
}
for in, want := range cases {
assert.Equal(t, want, sanitizeInstanceTag(in), "sanitizeInstanceTag(%q)", in)
}
}
func TestWorktreeInstanceName_Unit(t *testing.T) {
nonGit := t.TempDir() // not a git working tree
// No declared workspace and no flag → keep the base prefix.
name, sep := WorktreeInstanceName(nonGit, "lib", "", false)
assert.False(t, sep)
assert.Equal(t, "lib", name)
// Declared workspace equal to the base prefix is not a separation
// signal.
name, sep = WorktreeInstanceName(nonGit, "lib", "lib", false)
assert.False(t, sep)
assert.Equal(t, "lib", name)
// A declared workspace on a path that is NOT a linked worktree does
// not auto-separate (only real worktrees do).
name, sep = WorktreeInstanceName(nonGit, "lib", "other-ws", false)
assert.False(t, sep)
assert.Equal(t, "lib", name)
// The explicit flag forces separation; the declared workspace is the
// tag.
name, sep = WorktreeInstanceName(nonGit, "lib", "task-ws", true)
assert.True(t, sep)
assert.Equal(t, "lib@task-ws", name)
// Forced separation with no declared workspace and no resolvable
// branch falls back to a stable path hash.
name, sep = WorktreeInstanceName(nonGit, "lib", "", true)
assert.True(t, sep)
assert.True(t, len(name) > len("lib@"))
assert.Equal(t, "lib@"+shortPathHash(nonGit), name)
// Deterministic for the same path.
name2, _ := WorktreeInstanceName(nonGit, "lib", "", true)
assert.Equal(t, name, name2)
}