package main import ( "bytes" "encoding/json" "os" "os/exec" "path/filepath" "testing" "time" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/graph/store_sqlite" "github.com/zzet/gortex/internal/persistence" ) // gitInitRepo creates a real git repository at dir with one commit and // returns its HEAD SHA. A real repo (not a bare .git dir) is required // because runRepos shells out to `git rev-parse HEAD`. func gitInitRepo(t *testing.T, dir string) (headSHA string) { t.Helper() require.NoError(t, os.MkdirAll(dir, 0o755)) run := func(args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir // Keep the commit deterministic and independent of the // developer's global git identity. cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") out, err := cmd.CombinedOutput() require.NoErrorf(t, err, "git %v: %s", args, out) } run("init") run("checkout", "-b", "main") require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("x"), 0o644)) run("add", "README.md") run("commit", "-m", "initial") return gitCommitHash(dir) } // gitCommitMore stages a new file and commits it, advancing HEAD. // Returns the new HEAD SHA. func gitCommitMore(t *testing.T, dir, file string) (headSHA string) { t.Helper() run := func(args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") out, err := cmd.CombinedOutput() require.NoErrorf(t, err, "git %v: %s", args, out) } require.NoError(t, os.WriteFile(filepath.Join(dir, file), []byte("y"), 0o644)) run("add", file) run("commit", "-m", "more") return gitCommitHash(dir) } // reposTestEnv writes a temp global config tracking the given repo // entries, points the package-level cfgFile at it, and routes the // persistence store the `repos` command reads at an isolated temp dir. // Both package globals are restored on cleanup. func reposTestEnv(t *testing.T, repos []config.RepoEntry) { t.Helper() root := t.TempDir() gc := &config.GlobalConfig{Repos: repos} gcPath := filepath.Join(root, "config.yaml") gc.SetConfigPath(gcPath) require.NoError(t, gc.Save()) prevCfg := cfgFile cfgFile = gcPath prevCache := reposCacheDir reposCacheDir = filepath.Join(root, "cache") prevBackend := reposBackendPath // Isolate the SQLite freshness store at a per-test path so the // command never reads the developer's real ~/.gortex/store. Tests // that exercise the repo_index_state path seed this file; the rest // leave it absent so describeRepo falls back to the snapshot store. reposBackendPath = filepath.Join(root, "store", "store.sqlite") t.Cleanup(func() { cfgFile = prevCfg reposCacheDir = prevCache reposBackendPath = prevBackend }) } // seedIndexState writes a repo_index_state freshness row into the test's // isolated SQLite backend store — the same table the daemon writes when it tracks or warms up // a repo, and the authoritative source describeRepo reads first. func seedIndexState(t *testing.T, prefix, sha string, dirty bool, indexedAt time.Time) { t.Helper() require.NoError(t, os.MkdirAll(filepath.Dir(reposBackendPath), 0o755)) st, err := store_sqlite.Open(reposBackendPath) require.NoError(t, err) require.NoError(t, st.SetRepoIndexState(graph.RepoIndexState{ RepoPrefix: prefix, IndexedSHA: sha, Dirty: dirty, IndexedAt: indexedAt.Unix(), })) require.NoError(t, st.Close()) } // seedSnapshot writes a persisted index snapshot for (repoPath, branch, // commit) into the test's persistence cache, so runRepos sees the repo // as indexed at that commit. The snapshot is keyed under the canonical // repo path — the same key describeRepo reads with. func seedSnapshot(t *testing.T, repoPath, branch, commit string, indexedAt time.Time) { t.Helper() store, err := persistence.NewFileStore(reposCacheDir, version) require.NoError(t, err) require.NoError(t, store.Save(&persistence.Snapshot{ Version: version, RepoPath: canonicalRepo(repoPath), CommitHash: commit, Branch: branch, IndexedAt: indexedAt, })) require.NoError(t, store.Close()) } func newReposCmd() (*cobra.Command, *bytes.Buffer) { buf := &bytes.Buffer{} c := &cobra.Command{} c.SetOut(buf) c.SetErr(buf) return c, buf } // TestRunRepos_JSON_FreshAndStale covers the JSON shape, the head-commit // field, and the freshness/staleness flag across the three states: a // repo whose index matches HEAD (fresh), one whose HEAD has advanced // past the indexed commit (stale), and one never indexed. func TestRunRepos_JSON_FreshAndStale(t *testing.T) { base := t.TempDir() freshDir := filepath.Join(base, "fresh-repo") staleDir := filepath.Join(base, "stale-repo") neverDir := filepath.Join(base, "never-repo") freshHead := gitInitRepo(t, freshDir) oldHead := gitInitRepo(t, staleDir) neverHead := gitInitRepo(t, neverDir) reposTestEnv(t, []config.RepoEntry{ {Path: freshDir, Name: "fresh-repo", Workspace: "ws-fresh"}, {Path: staleDir, Name: "stale-repo"}, {Path: neverDir, Name: "never-repo"}, }) indexedAt := time.Now().Add(-time.Hour).Truncate(time.Second) // fresh-repo: snapshot at the exact current HEAD. seedSnapshot(t, freshDir, "main", freshHead, indexedAt) // stale-repo: snapshot at the old HEAD, then advance HEAD. seedSnapshot(t, staleDir, "main", oldHead, indexedAt) newHead := gitCommitMore(t, staleDir, "second.txt") require.NotEqual(t, oldHead, newHead) // never-repo: no snapshot seeded. reposJSON = true t.Cleanup(func() { reposJSON = false }) cmd, buf := newReposCmd() require.NoError(t, runRepos(cmd, nil)) var got []repoStatus require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) require.Len(t, got, 3) // Output is sorted by name: fresh, never, stale. byName := map[string]repoStatus{} for _, e := range got { byName[e.Name] = e } fresh := byName["fresh-repo"] assert.Equal(t, freshHead, fresh.HeadCommit, "head commit must be the current git HEAD") assert.Equal(t, freshHead, fresh.IndexedCommit) assert.Equal(t, "main", fresh.Branch) assert.Equal(t, "ws-fresh", fresh.Workspace) assert.True(t, fresh.Indexed) assert.False(t, fresh.Stale, "index matches HEAD → not stale") require.NotNil(t, fresh.LastIndexed) assert.Equal(t, indexedAt.UTC(), fresh.LastIndexed.UTC()) stale := byName["stale-repo"] assert.Equal(t, newHead, stale.HeadCommit) assert.Equal(t, oldHead, stale.IndexedCommit) assert.True(t, stale.Indexed) assert.True(t, stale.Stale, "HEAD advanced past the indexed commit → stale") require.NotNil(t, stale.LastIndexed) never := byName["never-repo"] assert.Equal(t, neverHead, never.HeadCommit, "head commit reported even without an index") assert.Empty(t, never.IndexedCommit) assert.False(t, never.Indexed) assert.True(t, never.Stale, "never indexed → stale") assert.Nil(t, never.LastIndexed) } // TestRunRepos_Table renders the default (non-JSON) form and asserts // the freshness vocabulary appears for each state. func TestRunRepos_Table(t *testing.T) { base := t.TempDir() freshDir := filepath.Join(base, "alpha") neverDir := filepath.Join(base, "beta") freshHead := gitInitRepo(t, freshDir) gitInitRepo(t, neverDir) reposTestEnv(t, []config.RepoEntry{ {Path: freshDir, Name: "alpha"}, {Path: neverDir, Name: "beta"}, }) seedSnapshot(t, freshDir, "main", freshHead, time.Now().Truncate(time.Second)) reposJSON = false cmd, buf := newReposCmd() require.NoError(t, runRepos(cmd, nil)) out := buf.String() assert.Contains(t, out, "alpha") assert.Contains(t, out, "beta") assert.Contains(t, out, "fresh") assert.Contains(t, out, "not indexed") // The short-SHA prefix of the fresh repo's HEAD must be in the table. assert.Contains(t, out, freshHead[:12]) // The never-indexed repo shows the placeholder timestamp. assert.Contains(t, out, "(never)") } // TestRunRepos_NoTrackedRepos exercises the empty-config path for both // output modes. func TestRunRepos_NoTrackedRepos(t *testing.T) { reposTestEnv(t, nil) reposJSON = false cmd, buf := newReposCmd() require.NoError(t, runRepos(cmd, nil)) assert.Contains(t, buf.String(), "(no tracked repos)") reposJSON = true t.Cleanup(func() { reposJSON = false }) cmd, buf = newReposCmd() require.NoError(t, runRepos(cmd, nil)) var got []repoStatus require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) assert.Empty(t, got) } // TestRunRepos_StaleWhenBranchSlotMissing proves that a repo on a branch // with no persisted snapshot at all reports as not-indexed and stale — // the snapshot for a different branch must not count. func TestRunRepos_StaleWhenBranchSlotMissing(t *testing.T) { base := t.TempDir() dir := filepath.Join(base, "repo") head := gitInitRepo(t, dir) reposTestEnv(t, []config.RepoEntry{{Path: dir, Name: "repo"}}) // Snapshot stored under a different branch slot. seedSnapshot(t, dir, "other-branch", head, time.Now().Truncate(time.Second)) reposJSON = true t.Cleanup(func() { reposJSON = false }) cmd, buf := newReposCmd() require.NoError(t, runRepos(cmd, nil)) var got []repoStatus require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) require.Len(t, got, 1) assert.False(t, got[0].Indexed, "a snapshot on a different branch must not count") assert.True(t, got[0].Stale) } // TestRunRepos_IndexStateFreshness covers the primary freshness source: // the daemon's repo_index_state rows (keyed by repo prefix). This is the // path that was dead before — `gortex repos` read only the embedded-server // snapshot store, which the daemon never writes, so every daemon-indexed // repo wrongly reported "never indexed". func TestRunRepos_IndexStateFreshness(t *testing.T) { base := t.TempDir() freshDir := filepath.Join(base, "alpha") staleDir := filepath.Join(base, "beta") dirtyDir := filepath.Join(base, "gamma") neverDir := filepath.Join(base, "delta") freshHead := gitInitRepo(t, freshDir) oldHead := gitInitRepo(t, staleDir) dirtyHead := gitInitRepo(t, dirtyDir) gitInitRepo(t, neverDir) reposTestEnv(t, []config.RepoEntry{ {Path: freshDir, Name: "alpha"}, {Path: staleDir, Name: "beta"}, {Path: dirtyDir, Name: "gamma"}, {Path: neverDir, Name: "delta"}, }) indexedAt := time.Now().Add(-30 * time.Minute).Truncate(time.Second) // alpha: indexed at the exact current HEAD → fresh. seedIndexState(t, "alpha", freshHead, false, indexedAt) // beta: indexed at the old HEAD, then HEAD advances → stale. seedIndexState(t, "beta", oldHead, false, indexedAt) newHead := gitCommitMore(t, staleDir, "second.txt") require.NotEqual(t, oldHead, newHead) // gamma: indexed at the current HEAD but from a dirty tree → fresh + dirty. seedIndexState(t, "gamma", dirtyHead, true, indexedAt) // delta: no row → never indexed. reposJSON = true t.Cleanup(func() { reposJSON = false }) cmd, buf := newReposCmd() require.NoError(t, runRepos(cmd, nil)) var got []repoStatus require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) require.Len(t, got, 4) byName := map[string]repoStatus{} for _, e := range got { byName[e.Name] = e } alpha := byName["alpha"] assert.True(t, alpha.Indexed) assert.False(t, alpha.Stale, "index commit matches HEAD → fresh") assert.Equal(t, freshHead, alpha.IndexedCommit) assert.False(t, alpha.IndexedDirty) require.NotNil(t, alpha.LastIndexed) assert.Equal(t, indexedAt.Unix(), alpha.LastIndexed.Unix()) beta := byName["beta"] assert.True(t, beta.Indexed) assert.True(t, beta.Stale, "HEAD advanced past the indexed commit → stale") assert.Equal(t, oldHead, beta.IndexedCommit) assert.Equal(t, newHead, beta.HeadCommit) gamma := byName["gamma"] assert.True(t, gamma.Indexed) assert.False(t, gamma.Stale, "a dirty-tree index whose commit matches HEAD is still fresh") assert.True(t, gamma.IndexedDirty, "dirty provenance is surfaced") delta := byName["delta"] assert.False(t, delta.Indexed, "no repo_index_state row → never indexed") assert.True(t, delta.Stale) assert.Nil(t, delta.LastIndexed) } // TestRunRepos_IndexStateLoneRepoEmptyPrefix covers single-repo (lone) mode, // where the index is keyed under the empty repo prefix. A single tracked // repo must match that "" row even though its resolved prefix is its name. func TestRunRepos_IndexStateLoneRepoEmptyPrefix(t *testing.T) { base := t.TempDir() dir := filepath.Join(base, "solo") head := gitInitRepo(t, dir) reposTestEnv(t, []config.RepoEntry{{Path: dir, Name: "solo"}}) indexedAt := time.Now().Add(-time.Minute).Truncate(time.Second) // Lone-repo mode writes the freshness row under the empty prefix. seedIndexState(t, "", head, false, indexedAt) reposJSON = true t.Cleanup(func() { reposJSON = false }) cmd, buf := newReposCmd() require.NoError(t, runRepos(cmd, nil)) var got []repoStatus require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) require.Len(t, got, 1) assert.True(t, got[0].Indexed, "the empty-prefix lone-repo row must count for a single tracked repo") assert.False(t, got[0].Stale) assert.Equal(t, head, got[0].IndexedCommit) } // TestRunRepos_IndexStateBeatsSnapshot proves the repo_index_state row is the // authoritative source: when both a freshness row and a (stale) snapshot // exist, the row wins. func TestRunRepos_IndexStateBeatsSnapshot(t *testing.T) { base := t.TempDir() dirA := filepath.Join(base, "one") dirB := filepath.Join(base, "two") headA := gitInitRepo(t, dirA) gitInitRepo(t, dirB) reposTestEnv(t, []config.RepoEntry{ {Path: dirA, Name: "one"}, {Path: dirB, Name: "two"}, }) indexedAt := time.Now().Add(-time.Minute).Truncate(time.Second) // Snapshot says an old/wrong commit; the index-state row says HEAD. seedSnapshot(t, dirA, "main", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", indexedAt) seedIndexState(t, "one", headA, false, indexedAt) reposJSON = true t.Cleanup(func() { reposJSON = false }) cmd, buf := newReposCmd() require.NoError(t, runRepos(cmd, nil)) var got []repoStatus require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) byName := map[string]repoStatus{} for _, e := range got { byName[e.Name] = e } one := byName["one"] assert.True(t, one.Indexed) assert.Equal(t, headA, one.IndexedCommit, "repo_index_state row wins over the snapshot store") assert.False(t, one.Stale) } // TestShortSHA covers the table SHA abbreviation helper. func TestShortSHA(t *testing.T) { assert.Equal(t, "(none)", shortSHA("")) assert.Equal(t, "abc", shortSHA("abc")) assert.Equal(t, "0123456789ab", shortSHA("0123456789abcdef0123")) } // TestRunWorkspaceList_JSON covers the --json flag added to // `gortex workspace list`: the JSON array carries each repo's resolved // workspace, project, and source. func TestRunWorkspaceList_JSON(t *testing.T) { root := t.TempDir() repoGlobal := filepath.Join(root, "with-global") repoYAML := filepath.Join(root, "with-yaml") repoDefault := filepath.Join(root, "plain") for _, d := range []string{repoGlobal, repoYAML, repoDefault} { require.NoError(t, os.MkdirAll(d, 0o755)) } // with-yaml declares its workspace in .gortex.yaml. require.NoError(t, os.WriteFile( filepath.Join(repoYAML, ".gortex.yaml"), []byte("workspace: yaml-ws\nproject: yaml-proj\n"), 0o644)) gc := &config.GlobalConfig{Repos: []config.RepoEntry{ {Path: repoGlobal, Name: "with-global", Workspace: "global-ws", Project: "global-proj"}, {Path: repoYAML, Name: "with-yaml"}, {Path: repoDefault, Name: "plain"}, }} gcPath := filepath.Join(root, "config.yaml") gc.SetConfigPath(gcPath) require.NoError(t, gc.Save()) prevCfg := cfgFile cfgFile = gcPath t.Cleanup(func() { cfgFile = prevCfg }) workspaceListJSON = true t.Cleanup(func() { workspaceListJSON = false }) cmd, buf := newReposCmd() require.NoError(t, runWorkspaceList(cmd, nil)) var got []workspaceListEntry require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) require.Len(t, got, 3) byRepo := map[string]workspaceListEntry{} for _, e := range got { byRepo[e.Repo] = e } g := byRepo["with-global"] assert.Equal(t, "global-ws", g.Workspace) assert.Equal(t, "global-proj", g.Project) assert.Equal(t, "global", g.Source) y := byRepo["with-yaml"] assert.Equal(t, "yaml-ws", y.Workspace) assert.Equal(t, "yaml-proj", y.Project) assert.Equal(t, ".gortex.yaml", y.Source) p := byRepo["plain"] assert.Equal(t, "default", p.Source) assert.Contains(t, p.Workspace, "default") }