Skip to content
quarry Artifact [2c07fb7f4a]
Login

Artifact [2c07fb7f4a]

Artifact 2c07fb7f4a696c67cb036549e1e7dae5e84242cac8f3c14fed8967176d1de430:


package ingest

import (
	"fmt"
	"net"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/events"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/zitcli"
)

// treeRig is a Git history imported by `zit import-git` with
// sub-manifests on, pushed into a repository whose index is followed by
// a live pipeline, as the forge's poll follows it.
type treeRig struct {
	env      []string
	git      string // the Git working tree
	imported string // the store import-git writes and keeps current
	commits  int    // Git commits made so far
	repo     string // the forge's store
	ix       *index.Index
	p        *Pipeline
	cli      *zitcli.Client
	now      time.Time // the pipeline's clock: tests move it with later
}

// later moves the pipeline's clock on by d.
func (r *treeRig) later(d time.Duration) { r.now = r.now.Add(d) }

func newTreeRig(t *testing.T) *treeRig {
	t.Helper()
	if _, err := exec.LookPath("zit"); err != nil {
		t.Skip("zit not on PATH")
	}
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not on PATH")
	}
	if out, _ := exec.Command("zit", "import-git", "--help").CombinedOutput(); !strings.Contains(string(out), "--submanifest-k") {
		t.Skip("this zit's import-git writes no sub-manifests")
	}
	tmp := t.TempDir()
	r := &treeRig{
		env: append(os.Environ(), "HOME="+filepath.Join(tmp, "home"), "ZIT_PASSWORD=",
			"GIT_AUTHOR_NAME=Ana", "[email protected]",
			"GIT_COMMITTER_NAME=Ana", "[email protected]"),
		git:      filepath.Join(tmp, "git"),
		imported: filepath.Join(tmp, "imported.zit"),
		repo:     filepath.Join(tmp, "repo"),
	}
	for _, d := range []string{r.git, r.repo, filepath.Join(tmp, "home")} {
		if err := os.MkdirAll(d, 0o755); err != nil {
			t.Fatal(err)
		}
	}
	r.in(t, r.git, "git", "init", "-q", "-b", "main")
	r.in(t, r.repo, "zit", "init")
	ev, err := events.Start(filepath.Join(tmp, "nats"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(ev.Close)
	r.ix, err = index.Open(filepath.Join(tmp, "index.db"))
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { r.ix.Close() })
	r.cli = zitcli.New(r.repo)
	r.p = NewPipeline(r.cli, ev, r.ix, "vault", "n1")
	r.now = time.Date(2026, 11, 14, 9, 0, 0, 0, time.UTC)
	r.p.clock = func() time.Time { return r.now }
	// The repository as created: indexed empty, its cursor saved, and
	// then the poll's first tick, which always runs a round (its stamp
	// starts unset). That round spends the once-per-process check of the
	// store's tip on a store that has none, so nothing after it rebuilds
	// on its own: the round after a push is an ordinary live one.
	for range 2 {
		if _, err := r.p.Sync(); err != nil {
			t.Fatal(err)
		}
	}
	if n, err := r.ix.CheckinCount(); err != nil || n != 0 {
		t.Fatalf("rig: the empty repository indexed %d check-ins (%v)", n, err)
	}
	return r
}

func (r *treeRig) in(t *testing.T, dir, name string, args ...string) {
	t.Helper()
	c := exec.Command(name, args...)
	c.Dir, c.Env = dir, r.env
	if out, err := c.CombinedOutput(); err != nil {
		t.Fatalf("%s %v: %v\n%s", name, args, err, out)
	}
}

// grow makes n more Git commits, each adding a file in one of four
// directories; some also edit, delete, rename or chmod an earlier file
// or remove a whole directory. It imports them: afresh the first time,
// --into after.
// k=1 makes small leaves, so a tree forms after a few files whatever
// the names hash to; the rig checks that the tip is a tree check-in.
func (r *treeRig) grow(t *testing.T, n int) {
	t.Helper()
	first := r.commits == 0
	for range n {
		i := r.commits
		d := filepath.Join(r.git, fmt.Sprintf("d%d", i%4))
		if err := os.MkdirAll(d, 0o755); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(filepath.Join(d, fmt.Sprintf("f%d.txt", i)), []byte(fmt.Sprintln("line", i)), 0o644); err != nil {
			t.Fatal(err)
		}
		// Edits and deletions too, so a change set is a real diff of
		// two trees, not only an added leaf entry.
		if i%3 == 2 {
			old := filepath.Join(r.git, fmt.Sprintf("d%d", (i-2)%4), fmt.Sprintf("f%d.txt", i-2))
			if err := os.WriteFile(old, []byte(fmt.Sprintln("edited", i)), 0o644); err != nil {
				t.Fatal(err)
			}
		}
		if i%5 == 4 {
			if err := os.Remove(filepath.Join(r.git, fmt.Sprintf("d%d", (i-3)%4), fmt.Sprintf("f%d.txt", i-3))); err != nil && !os.IsNotExist(err) {
				t.Fatal(err)
			}
		}
		// A rename, a mode change and a whole directory removed.
		if i%7 == 6 {
			from := filepath.Join(r.git, fmt.Sprintf("d%d", (i-1)%4), fmt.Sprintf("f%d.txt", i-1))
			if _, err := os.Stat(from); err == nil {
				if err := os.MkdirAll(filepath.Join(r.git, "moved"), 0o755); err != nil {
					t.Fatal(err)
				}
				if err := os.Rename(from, filepath.Join(r.git, "moved", fmt.Sprintf("r%d.txt", i))); err != nil {
					t.Fatal(err)
				}
			}
		}
		if i%6 == 5 {
			if err := os.Chmod(filepath.Join(r.git, fmt.Sprintf("d%d", (i-4)%4), fmt.Sprintf("f%d.txt", i-4)), 0o755); err != nil && !os.IsNotExist(err) {
				t.Fatal(err)
			}
		}
		if i%11 == 10 {
			if err := os.RemoveAll(filepath.Join(r.git, fmt.Sprintf("d%d", (i+1)%4))); err != nil {
				t.Fatal(err)
			}
		}
		r.in(t, r.git, "git", "add", "-A")
		r.in(t, r.git, "git", "commit", "-qm", fmt.Sprint("commit ", i))
		r.commits++
	}
	if first {
		r.in(t, r.git, "zit", "import-git", r.git, r.imported, "--submanifest-k", "1", "--quiet")
	} else {
		r.in(t, r.git, "zit", "import-git", r.git, r.imported, "--into", "--quiet")
	}
	src := zitcli.New(r.imported)
	tip, _, err := src.Info(t.Context(), "trunk")
	if err != nil {
		t.Fatal(err)
	}
	raw, err := src.ArtifactRaw(t.Context(), tip, 1<<20)
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(raw), "\nS ") {
		t.Fatalf("rig: the imported tip is not a tree check-in:\n%s", raw)
	}
}

// push sends the imported store to the repository through `zit serve`,
// with --adopt-project as a forge serves an empty repository's first
// push.
func (r *treeRig) push(t *testing.T, adopt bool) {
	t.Helper()
	l, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	port := l.Addr().(*net.TCPAddr).Port
	l.Close()
	args := []string{"serve", "--port", fmt.Sprint(port)}
	if adopt {
		args = append(args, "--adopt-project")
	}
	srv := exec.Command("zit", args...)
	srv.Dir, srv.Env = r.repo, r.env
	if err := srv.Start(); err != nil {
		t.Fatal(err)
	}
	defer func() { srv.Process.Kill(); srv.Wait() }()
	url := fmt.Sprintf("http://127.0.0.1:%d/xfer", port)
	var out []byte
	for range 50 {
		c := exec.Command("zit", "push", url, "-R", r.imported)
		c.Env = r.env
		if out, err = c.CombinedOutput(); err == nil {
			return
		}
		time.Sleep(100 * time.Millisecond)
	}
	t.Fatalf("push: %v\n%s", err, out)
}

// followed runs one live round and checks that the index holds every
// check-in and the store's tip, with the change sets a rebuild derives.
func (r *treeRig) followed(t *testing.T) {
	t.Helper()
	tip, _, err := r.cli.Info(t.Context(), "trunk")
	if err != nil {
		t.Fatal(err)
	}
	if _, err := r.p.Sync(); err != nil {
		t.Fatalf("FAIL: the round after the push: %v", err)
	}
	if n, err := r.ix.CheckinCount(); err != nil || n < r.commits {
		t.Errorf("FAIL: the live round left %d check-ins indexed (%v); the store holds at least %d", n, err, r.commits)
	}
	if _, _, ok, err := r.ix.CheckinByPrefix(tip); err != nil || !ok {
		t.Errorf("FAIL: the index lacks the pushed tip %.12s (%v): the forge would answer indexBehind", tip, err)
	}
	live := changeSets(t, r.ix)
	if _, err := r.p.Reindex(); err != nil {
		t.Fatal(err)
	}
	rebuilt := changeSets(t, r.ix)
	if len(rebuilt) < r.commits {
		t.Fatalf("rig: the rebuilt index records changes for %d check-ins", len(rebuilt))
	}
	for hash, want := range rebuilt {
		if got := live[hash]; got != want {
			t.Errorf("FAIL: check-in %.12s changed %+v live, %+v rebuilt", hash, got, want)
		}
	}
}

// changeSets is every check-in's changed files and lines as the index
// holds them.
func changeSets(t *testing.T, ix *index.Index) map[string]index.ChangeStat {
	t.Helper()
	tl, _, err := ix.Timeline(index.TimelineFilter{Limit: 1000, Kind: "ci"})
	if err != nil {
		t.Fatal(err)
	}
	var hashes []string
	for _, e := range tl {
		hashes = append(hashes, e.Hash)
	}
	out, err := ix.ChangeStats(hashes, "")
	if err != nil {
		t.Fatal(err)
	}
	return out
}

// quarry ticket 431: an empty repository's first push carried a Git
// import's tree check-ins, and the live round that followed indexed
// only the flat check-ins at the bottom of the history. A tree
// check-in's S cards failed the parser, so it was offered as content
// and never folded; only a reindex (crosslink) saw it. One live round
// must index every check-in and the store's tip, with the change sets
// a rebuild derives.
func TestALiveRoundIndexesAFirstPushOfTreeCheckins(t *testing.T) {
	r := newTreeRig(t)
	r.grow(t, 24)
	r.push(t, true)
	r.followed(t)
	if !t.Failed() {
		t.Log("ok")
	}
}

// The adoption is incidental: tree check-ins pushed into a repository
// whose index is already current were dropped the same way.
func TestALiveRoundIndexesTreeCheckinsPushedLater(t *testing.T) {
	r := newTreeRig(t)
	r.grow(t, 8)
	r.push(t, true)
	if _, err := r.p.Reindex(); err != nil {
		t.Fatal(err)
	}
	r.grow(t, 8)
	r.push(t, false)
	r.followed(t)
	if !t.Failed() {
		t.Log("ok")
	}
}