Skip to content
quarry Artifact [d0139d3fdc]
Login

Artifact [d0139d3fdc]

Artifact d0139d3fdc2c1567e3f078df94ccf348b48c5cfe12684b4da07b1de48a181148:


package zitcli

import (
	"errors"
	"os"
	"os/exec"
	"path/filepath"
	"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)
		}
	}
}