Artifact ed27ffc4bee9aa4f04f598b92c934f34ccdc1aaf7038f79e1393c069abab279e:
- File
internal/zitcli/zitcli_test.go
— part of check-in
[00e90b88cc]
at
2026-08-31 13:25:43
on branch trunk
— leaf set keys on zit's abbreviation, not one subprocess per leaf
Leaves expanded every twelve-character hash zit printed into a full one with a zit info call each: 473 leaves on sqlite meant 357 spawns and 6.1s in a single page-load window. The one consumer asks a membership question, which a twelve-hex-character key answers. Contract test now pins the prefix contract and that a full hash finds its leaf. (user: quarry, size: 16439) [annotate] [blame] [check-ins using]
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) } } // The leaf set is keyed by zit's own twelve-character abbreviation. // Expanding each to a full hash cost one `zit info` subprocess per // leaf: sqlite has 473, which showed up as 357 spawns and 6.1 seconds // in one page-load window. func TestLeafKeyMatchesZitsAbbreviation(t *testing.T) { const full = "c77ff6089de242d8cb7487ea36b094e403c9ed1a48a8ee713d64a0b87b334369" if got := LeafKey(full); got != "c77ff6089de2" { t.Errorf("LeafKey = %q, want the twelve zit prints", got) } // Already-short input is returned as-is rather than panicking. if got := LeafKey("abc"); got != "abc" { t.Errorf("LeafKey(short) = %q", got) } if got := LeafKey(""); got != "" { t.Errorf("LeafKey(empty) = %q", got) } }