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

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")
}