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