Skip to content
quarry Artifact [f719a0b187]
Login

Artifact [f719a0b187]

Artifact f719a0b18707a8d2cafa259bdc501c404b70d53c368f853de9d6de91603b15b5:


package zitcli

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