Skip to content
quarry Artifact [099b7c4195]
Login

Artifact [099b7c4195]

Artifact 099b7c41958818d66283732f245d12d0e6b698f8d0449765599df0a65796d627:


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

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