package zitcli
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
)
// testRepo builds a throwaway zit repository with n one-file commits and
// returns its path. Requires the zit binary; skips when absent.
func testRepo(t *testing.T, n int) string {
t.Helper()
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
dir := t.TempDir()
run(t, dir, "init")
for i := 0; i < n; i++ {
name := string(rune('a'+i)) + ".txt"
if err := os.WriteFile(filepath.Join(dir, name), []byte(name+" body\n"), 0o644); err != nil {
t.Fatal(err)
}
run(t, dir, "add", name)
run(t, dir, "commit", "-m", "add "+name)
}
return dir
}
func run(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("zit", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("zit %v: %v\n%s", args, err, out)
}
}
func TestFramesBootstrapAndIncremental(t *testing.T) {
repo := testRepo(t, 2)
z := New(repo)
// Bootstrap: full census.
batch, err := z.Frames(Cursor{})
if err != nil {
t.Fatal(err)
}
if len(batch.Artifacts) == 0 {
t.Fatal("bootstrap emitted nothing")
}
for _, a := range batch.Artifacts {
if len(a.Hash) != 64 {
t.Fatalf("bad hash %q", a.Hash)
}
if len(a.Bytes) == 0 {
t.Fatalf("%s: empty artifact", a.Hash)
}
}
// Current cursor: no new artifacts, cursor unchanged.
again, err := z.Frames(batch.Cursor)
if err != nil {
t.Fatal(err)
}
if len(again.Artifacts) != 0 {
t.Fatalf("current cursor re-emitted %d artifacts", len(again.Artifacts))
}
if again.Cursor != batch.Cursor {
t.Fatalf("cursor moved without appends: %v -> %v", batch.Cursor, again.Cursor)
}
// Append: only the new artifacts stream.
if err := os.WriteFile(filepath.Join(repo, "new.txt"), []byte("new\n"), 0o644); err != nil {
t.Fatal(err)
}
run(t, repo, "add", "new.txt")
run(t, repo, "commit", "-m", "add new")
delta, err := z.Frames(batch.Cursor)
if err != nil {
t.Fatal(err)
}
if len(delta.Artifacts) == 0 || len(delta.Artifacts) >= len(batch.Artifacts) {
t.Fatalf("delta emitted %d of %d total", len(delta.Artifacts), len(batch.Artifacts))
}
if delta.Cursor == batch.Cursor {
t.Fatal("cursor did not advance past appends")
}
}
func TestFramesCursorInvalidAfterStoreRewrite(t *testing.T) {
repo := testRepo(t, 3)
z := New(repo)
batch, err := z.Frames(Cursor{})
if err != nil {
t.Fatal(err)
}
// Shun a blob out of the store — the reclaim removes bytes from the
// consumed prefix, so the cursor must die. (A no-op `pack` on an
// already-optimal store rightly leaves cursors valid, which is why
// this test rewrites via shun instead.)
// --long, because zit 0.3.0's bare ls prints names only and shun
// wants the artifact hash.
head, err := exec.Command("zit", "ls", "--long", "-R", repo, "tip").Output()
if err != nil {
t.Fatal(err)
}
blob := strings.Fields(string(head))[0]
run(t, repo, "shun", blob, "--force")
_, err = z.Frames(batch.Cursor)
if !errors.Is(err, ErrCursorInvalid) {
t.Fatalf("want ErrCursorInvalid after pack, got %v", err)
}
// Recovery: bootstrap from zero succeeds.
if _, err := z.Frames(Cursor{}); err != nil {
t.Fatalf("bootstrap after invalidation failed: %v", err)
}
}
func TestFramesBytesMatchArtifactRaw(t *testing.T) {
repo := testRepo(t, 1)
z := New(repo)
batch, err := z.Frames(Cursor{})
if err != nil {
t.Fatal(err)
}
a := batch.Artifacts[0]
cmd := exec.Command("zit", "artifact", a.Hash, "--raw")
cmd.Dir = repo
raw, err := cmd.Output()
if err != nil {
t.Fatal(err)
}
if string(raw) != string(a.Bytes) {
t.Fatalf("frames bytes differ from artifact --raw for %s", a.Hash)
}
}
func TestFramesMissingRepo(t *testing.T) {
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
z := New(t.TempDir())
if _, err := z.Frames(Cursor{}); err == nil {
t.Fatal("want error for non-repository, got nil")
}
}
func TestLs(t *testing.T) {
repo := testRepo(t, 2)
z := New(repo)
files, err := z.Ls(t.Context(), "tip")
if err != nil {
t.Fatal(err)
}
if len(files) != 2 || files[0].Name != "a.txt" || files[1].Name != "b.txt" {
t.Fatalf("files = %+v", files)
}
if len(files[0].Blob) != 64 {
t.Fatalf("blob id = %q", files[0].Blob)
}
if _, err := z.Ls(t.Context(), "nope"); err == nil {
t.Fatal("want error for bad version")
}
}
func TestCat(t *testing.T) {
repo := testRepo(t, 2)
z := New(repo)
got, err := z.Cat(t.Context(), "a.txt", "tip")
if err != nil {
t.Fatal(err)
}
if string(got) != "a.txt body\n" {
t.Fatalf("content = %q", got)
}
if _, err := z.Cat(t.Context(), "missing.txt", "tip"); err == nil {
t.Fatal("want error for missing file")
}
}
func TestDiff(t *testing.T) {
repo := testRepo(t, 1)
commitFileZ(t, repo, "a.txt", "a.txt body\nplus a line\n", "grow a")
commitFileZ(t, repo, "new.txt", "fresh\n", "add new")
head, err := exec.Command("zit", "timeline", "-R", repo).Output()
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(head)), "\n")
// zit 0.3.0's timeline line is Fossil's — `(N) time [hash12] …` —
// and a bare time no longer names a check-in; take the bracketed
// hash.
last := lines[len(lines)-1]
first := last[strings.IndexByte(last, '[')+1 : strings.IndexByte(last, ']')]
unified, brief, err := New(repo).Diff(t.Context(), first, "tip")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(unified, "+plus a line") || !strings.Contains(unified, "+fresh") {
t.Fatalf("unified missing changes:\n%s", unified)
}
if len(brief) != 2 || brief[0].Status != "M" || brief[1].Status != "A" {
t.Fatalf("brief = %+v", brief)
}
}
func commitFileZ(t *testing.T, repo, name, body, msg string) {
t.Helper()
if err := os.WriteFile(filepath.Join(repo, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
run(t, repo, "add", name)
run(t, repo, "commit", "-m", msg)
}
// A listing may only be memoised under a version that cannot move. This
// is the guard on a cache that would otherwise serve a stale file tree
// forever after the next commit.
func TestOnlyHashVersionsAreCacheable(t *testing.T) {
for _, v := range []string{
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
} {
if !immutableVersion(v) {
t.Errorf("hash %q rejected as cacheable", v)
}
}
for _, v := range []string{
"trunk", "tip", "", "release-1.2", "2026-08-17",
"aaaaaaaa", // a prefix still resolves by search, not identity
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", // uppercase is not the stored form
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
} {
if immutableVersion(v) {
t.Errorf("%q accepted as cacheable", v)
}
}
}
// zit#131's probe is the ONLY way a forge may learn whether a landing merges:
// discovering it by attempting an accept is the one thing a server must not
// do, because on a server the attempt is the damage. So the parse of its
// answer is load-bearing, and these are the three shapes it emits — captured
// from real runs, not from the issue text.
func TestConflictPathsReadsWhatZitEmits(t *testing.T) {
// The probe, on stdout.
got := conflictPaths("landing c8d4\nbranch feat -> trunk\nmerges conflict (2)\nconflict f.txt\nconflict k/g.txt\n")
if want := []string{"f.txt", "k/g.txt"}; !reflect.DeepEqual(got, want) {
t.Errorf("probe paths = %v, want %v", got, want)
}
// A refused accept, on stderr, where every line carries the verb's
// own prefix and one line is prose rather than a path.
got = conflictPaths("zit landing: 87ba does not merge cleanly into 'trunk'; nothing was written\n" +
"zit landing: conflict f.txt\n" +
"zit landing: merge the target into the branch from a checkout, resolve, and push\n")
if want := []string{"f.txt"}; !reflect.DeepEqual(got, want) {
t.Errorf("refusal paths = %v, want %v — the prose lines are not paths", got, want)
}
// A clean landing names nothing.
if got := conflictPaths("merges clean\n"); len(got) != 0 {
t.Errorf("clean merge reported conflicts: %v", got)
}
}
// zit#134: the probe names the two tips, so it does not walk the store to find
// a record quarry already folded. These assert the contract quarry depends on:
// the answer parses, "has conflicts" is an ANSWER rather than an error, and
// asking changes nothing.
// writeZ drops a file into the checkout without committing it.
func writeZ(t *testing.T, repo, name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(repo, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestMergeProbeNamesTheTips(t *testing.T) {
repo := t.TempDir()
run(t, repo, "init", ".")
writeZ(t, repo, "f.txt", "top\nMID\nbottom\n")
run(t, repo, "add", "f.txt")
run(t, repo, "commit", "-m", "root", "--user", "u")
// A branch whose edit is in a different region of the same file: a
// three-way merge resolves it, a hash-level one would not.
writeZ(t, repo, "f.txt", "top\nMID\nBOTTOM\n")
run(t, repo, "commit", "-m", "b", "--branch", "feat/ok", "--user", "u")
run(t, repo, "update", "trunk")
writeZ(t, repo, "f.txt", "TOP\nMID\nbottom\n")
run(t, repo, "commit", "-m", "t", "--user", "u")
c := &Client{repo: repo}
state, conflicts, err := c.MergeProbe("feat/ok", "trunk", "")
if err != nil {
t.Fatalf("MergeProbe: %v", err)
}
if state != "clean" {
t.Fatalf("state = %q, want clean — a content-mergeable branch must not read as conflicted", state)
}
if len(conflicts) != 0 {
t.Fatalf("conflicts = %v, want none", conflicts)
}
}
func TestMergeProbeReportsConflictsAsAnAnswer(t *testing.T) {
repo := t.TempDir()
run(t, repo, "init", ".")
writeZ(t, repo, "g.txt", "base\n")
run(t, repo, "add", "g.txt")
run(t, repo, "commit", "-m", "root", "--user", "u")
writeZ(t, repo, "g.txt", "THEIRS\n")
run(t, repo, "commit", "-m", "b", "--branch", "feat/bad", "--user", "u")
run(t, repo, "update", "trunk")
writeZ(t, repo, "g.txt", "OURS\n")
run(t, repo, "commit", "-m", "t", "--user", "u")
c := &Client{repo: repo}
// Not an error: zit exits 0 and puts the verdict on stdout, precisely so a
// forge can render "blocked" without treating it as a failure to report.
state, conflicts, err := c.MergeProbe("feat/bad", "trunk", "")
if err != nil {
t.Fatalf("MergeProbe returned an error for a conflicting pair; conflicts are an answer: %v", err)
}
if state != "conflict" {
t.Fatalf("state = %q, want conflict", state)
}
if len(conflicts) == 0 || conflicts[0] != "g.txt" {
t.Fatalf("conflicts = %v, want [g.txt] — the forge has to name the files", conflicts)
}
}
func TestMergeProbeCachesOnTheTipPair(t *testing.T) {
repo := t.TempDir()
run(t, repo, "init", ".")
writeZ(t, repo, "f.txt", "a\n")
run(t, repo, "add", "f.txt")
run(t, repo, "commit", "-m", "root", "--user", "u")
writeZ(t, repo, "f.txt", "b\n")
run(t, repo, "commit", "-m", "b", "--branch", "feat", "--user", "u")
run(t, repo, "update", "trunk")
c := &Client{repo: repo}
if _, _, err := c.MergeProbe("feat", "trunk", "tipA:tipB"); err != nil {
t.Fatal(err)
}
// A second ask under the same key must not re-probe. Pointing the client
// at a repository that does not exist proves it: a cache miss would fail.
c.repo = filepath.Join(repo, "gone")
state, _, err := c.MergeProbe("feat", "trunk", "tipA:tipB")
if err != nil {
t.Fatalf("second ask re-probed instead of using the cache: %v", err)
}
if state == "" {
t.Fatal("cached answer came back empty")
}
}
// The plain listing must parse names containing spaces. The two-field
// form is `<blob-id> <filename>`, so the split is on the FIRST separator
// only — splitting on all of them silently truncates any path with a
// space in it, which is the kind of thing that looks fine until someone
// commits "release notes.md".
func TestLsNamesKeepsSpacesInFilenames(t *testing.T) {
repo := t.TempDir()
run(t, repo, "init")
for _, name := range []string{"plain.txt", "release notes.md", "a b c.txt"} {
if err := os.WriteFile(filepath.Join(repo, name), []byte("x\n"), 0o644); err != nil {
t.Fatal(err)
}
run(t, repo, "add", name)
}
run(t, repo, "commit", "-m", "files with spaces")
got, err := New(repo).LsNames(context.Background(), "trunk")
if err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, e := range got {
names[e.Name] = true
// zit 0.3.0's bare ls carries no blob ids; nothing here needs
// them — the names surviving their spaces is the point.
}
for _, want := range []string{"plain.txt", "release notes.md", "a b c.txt"} {
if !names[want] {
t.Errorf("missing %q; got %v", want, names)
}
}
}
// Concurrent readers of one immutable listing must share ONE subprocess.
//
// The cache alone did not give this: it was consulted, released, and
// only then did the caller shell out, so N readers arriving together on
// a cold key all missed and all spawned. On a store where the command
// takes tens of seconds that multiplied the cost by the number of people
// who happened to open the page at once.
func TestConcurrentLsSharesOneSubprocess(t *testing.T) {
repo := testRepo(t, 3)
c := New(repo)
tip, _, err := c.Info(context.Background(), "trunk")
if err != nil {
t.Fatal(err)
}
// Count real invocations by wrapping the computation the cache calls.
var calls atomic.Int64
compute := func() ([]LsEntry, error) {
calls.Add(1)
return c.lsUncached(context.Background(), tip, "")
}
const readers = 12
var wg sync.WaitGroup
results := make([][]LsEntry, readers)
errs := make([]error, readers)
start := make(chan struct{})
for i := 0; i < readers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
<-start // release them together, to actually contend
results[i], errs[i] = c.lsShared(context.Background(), "test\x00"+tip, compute)
}(i)
}
close(start)
wg.Wait()
if n := calls.Load(); n != 1 {
t.Errorf("%d readers caused %d subprocesses, want 1", readers, n)
}
for i := range results {
if errs[i] != nil {
t.Fatalf("reader %d: %v", i, errs[i])
}
if len(results[i]) != len(results[0]) {
t.Errorf("reader %d saw %d entries, reader 0 saw %d",
i, len(results[i]), len(results[0]))
}
}
// And a later reader is served from the cache, not a fourth process.
if _, err := c.lsShared(context.Background(), "test\x00"+tip, compute); err != nil {
t.Fatal(err)
}
if n := calls.Load(); n != 1 {
t.Errorf("a cached read spawned again: %d calls", n)
}
}
// RFC 0008 rung 8, unblocked by zit#153: settings and config read and
// write against a bare store through -R.
func TestSettingsReadWriteOnAStore(t *testing.T) {
z := New(testRepo(t, 1))
rows, err := z.Settings(context.Background())
if err != nil {
t.Fatalf("Settings: %v", err)
}
byName := map[string]Setting{}
for _, r := range rows {
byName[r.Name] = r
}
if _, ok := byName["autosync"]; !ok {
t.Fatalf("expected autosync among %d settings", len(rows))
}
if !byName["manifest"].Versionable {
t.Fatal("manifest should be flagged versionable")
}
if err := z.SetSetting(context.Background(), "manifest", "r"); err != nil {
t.Fatalf("SetSetting: %v", err)
}
rows, err = z.Settings(context.Background())
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if r.Name == "manifest" && r.Value != "r" {
t.Fatalf("manifest read back as %q", r.Value)
}
}
// A file that is not a configuration export is refused with a real
// error, not the no-checkout sentinel — proving -R reached the store.
err = z.ImportConfig(context.Background(), false, []byte("not a config export\n"))
if err == nil || errors.Is(err, ErrStoreNoCheckout) {
t.Fatalf("ImportConfig of junk: %v", err)
}
}