Artifact 28aeeb12cdf555c3a6a8b080d4f0fcec0de62faf9367898f7c4b3b779e19ca95:
- File
internal/zitcli/zitcli_test.go
— part of check-in
[78db482057]
at
2026-08-25 14:24:27
on branch trunk
— Ask for names when only names are used
zit ls --long adds size, permission and text/binary, and the last of those reads every listed blob. On a large store the annotated forms cost ~18ms per file where the plain two-field listing costs ~0.09ms: measured on tcl, 0.20s plain against 38s for --size and 41s for --long. The cost scales with the STORE, not the listing (filed as zit#148).
Four callers read only .Name and threw the rest away: the About marker scan, a root check-in's file set, the doc index, and CI's file list. They use LsNames now, which runs the plain listing and caches it beside the annotated one.
The tree page and its warm still need the columns they display, so they are unchanged; that cost is the upstream issue's to fix. (user: dmestas, size: 12292) [annotate] [blame] [check-ins using]
package zitcli import ( "context" "errors" "os" "os/exec" "path/filepath" "reflect" "strings" "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.) head, err := exec.Command("zit", "ls", "-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") first := strings.Fields(lines[len(lines)-1])[0] 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 if e.Blob == "" { t.Errorf("entry %q has no blob id", e.Name) } } for _, want := range []string{"plain.txt", "release notes.md", "a b c.txt"} { if !names[want] { t.Errorf("missing %q; got %v", want, names) } } }