a06f331eb8
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
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
329 lines
13 KiB
Go
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)
|
|
}
|