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
485 lines
16 KiB
Go
485 lines
16 KiB
Go
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")
|
|
}
|