package zitcli
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// acceptFixture is a real store with feat one check-in ahead of trunk
// and a landing of feat open onto trunk. It answers the store, the
// landing id and feat's tip.
func acceptFixture(t *testing.T) (dir, id, tip string) {
t.Helper()
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
dir = t.TempDir()
zitIn(t, dir, "init")
writeIn(t, dir, "f.txt", "root\n")
zitIn(t, dir, "add", "f.txt")
zitIn(t, dir, "commit", "-m", "root", "--user", "u")
writeIn(t, dir, "g.txt", "work\n")
zitIn(t, dir, "add", "g.txt")
zitIn(t, dir, "commit", "-m", "work", "--branch", "feat", "--user", "u")
c := New(dir)
id, err := c.LandingOpen(testToken, "dan", "feat", "trunk", "land feat", "")
if err != nil {
t.Fatal(err)
}
tip, _, err = c.Info(t.Context(), "feat")
if err != nil {
t.Fatal(err)
}
return dir, id, tip
}
func zitIn(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("zit", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("zit %v: %v\n%s", args, err, out)
}
return string(out)
}
func writeIn(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
// The accept names the tip the caller checked, so zit merges that
// check-in or nothing.
func TestLandingAcceptPassesTheCheckedTip(t *testing.T) {
argv := filepath.Join(t.TempDir(), "argv")
fakeZit(t, `printf '%s\n' "$@" > '`+argv+`'; echo "accepted abc feat -> trunk"`)
c := &Client{repo: "store"}
const tip = "4e57933c87b08073006071b51eea5d89d873c467fd401f8fe132d1e424e66051"
if _, err := c.LandingAccept(testToken, "L1", "dan", "", "", tip); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(argv)
if err != nil {
t.Fatal(err)
}
args := strings.Split(strings.TrimSpace(string(b)), "\n")
found := false
for i := range args[:len(args)-1] {
if args[i] == "--expect-tip" && args[i+1] == tip {
found = true
}
}
if args[0] != "landing" || args[1] != "accept" || !found {
t.Fatalf("FAIL: argv %q; want landing accept ... --expect-tip %s", args, tip)
}
t.Log("ok")
}
// An accept without a checked tip would merge whatever zit finds. The
// client refuses it before spawning anything.
func TestLandingAcceptRefusesWithoutATip(t *testing.T) {
fakeZit(t, `echo "spawned" >&2; exit 99`)
c := &Client{repo: "store"}
_, err := c.LandingAccept(testToken, "L1", "dan", "", "", "")
if err == nil || strings.Contains(err.Error(), "spawned") {
t.Fatalf("FAIL: an accept with no tip = %v; want a refusal before zit runs", err)
}
t.Logf("ok: %v", err)
}
// accept goes through LandingAccept only, so no caller can merge by
// branch name without naming what it checked.
func TestLandingSayWillNotAccept(t *testing.T) {
fakeZit(t, `echo "accepted abc feat -> trunk"`)
c := &Client{repo: "store"}
if _, err := c.LandingSay(testToken, "accept", "L1", "dan", "", "", ""); err == nil {
t.Fatal("FAIL: LandingSay accepted without an expected tip")
}
t.Log("ok")
}
// A push after the check moved the branch: zit refuses with exit 6 and
// writes nothing. The client says so as its own error, naming both
// tips, distinct from a conflict and from a fault.
func TestLandingAcceptOnAMovedBranchIsBranchMoved(t *testing.T) {
dir, id, checked := acceptFixture(t)
writeIn(t, dir, "h.txt", "unchecked\n")
zitIn(t, dir, "add", "h.txt")
zitIn(t, dir, "commit", "-m", "late push", "--user", "u")
c := New(dir)
now, _, err := c.Info(t.Context(), "feat")
if err != nil {
t.Fatal(err)
}
_, err = c.LandingAccept(testToken, id, "dan", "", "", checked)
var moved *BranchMoved
if !errors.As(err, &moved) {
t.Fatalf("FAIL: accept on a moved branch = %v; want *BranchMoved", err)
}
var conflict *LandingConflict
if errors.As(err, &conflict) || errors.Is(err, ErrNoEntry) {
t.Fatalf("FAIL: a moved branch also reads as a conflict or a refusal: %v", err)
}
if moved.Branch != "feat" || moved.Current != now || moved.Expected != checked {
t.Fatalf("FAIL: BranchMoved = %+v; want feat, current %s, expected %s", moved, now, checked)
}
if out := zitIn(t, dir, "landing", "ls"); !strings.Contains(out, id[:10]) {
t.Fatalf("FAIL: the landing is not open after a refused accept:\n%s", out)
}
t.Logf("ok: %v", err)
}
// The tip it checked is the tip it merges.
func TestLandingAcceptAtTheCheckedTipMerges(t *testing.T) {
dir, id, checked := acceptFixture(t)
if _, err := New(dir).LandingAccept(testToken, id, "dan", "", "", checked); err != nil {
t.Fatalf("FAIL: accept at the checked tip: %v", err)
}
if out := zitIn(t, dir, "landing", "ls"); strings.Contains(out, id[:10]) {
t.Fatalf("FAIL: the landing is still open after its accept:\n%s", out)
}
t.Log("ok")
}
// A zit older than the flag rejects it as a usage error. That must
// never degrade into an unguarded accept, and the error names the zit
// the forge needs.
func TestLandingAcceptOnAnOldZitNamesTheVersionNeeded(t *testing.T) {
fakeZit(t, `echo "zit landing: unknown option '--expect-tip'" >&2; echo "try 'zit landing --help'" >&2; exit 2`)
c := &Client{repo: "store"}
_, err := c.LandingAccept(testToken, "L1", "dan", "", "",
"4e57933c87b08073006071b51eea5d89d873c467fd401f8fe132d1e424e66051")
if !errors.Is(err, ErrZitTooOld) || !strings.Contains(err.Error(), MinVersion) {
t.Fatalf("FAIL: old zit = %v; want ErrZitTooOld naming %s", err, MinVersion)
}
t.Logf("ok: %v", err)
}
func TestVersionAtLeast(t *testing.T) {
for _, c := range []struct {
have, want string
ok bool
}{
{"0.3.31", "0.3.31", true},
{"0.3.32", "0.3.31", true},
{"0.4.0", "0.3.31", true},
{"1.0.0", "0.3.31", true},
{"0.3.30", "0.3.31", false},
{"0.3.4", "0.3.31", false},
{"0.2.99", "0.3.31", false},
{"", "0.3.31", false},
{"garbage", "0.3.31", false},
} {
if got := VersionAtLeast(c.have, c.want); got != c.ok {
t.Errorf("FAIL: VersionAtLeast(%q, %q) = %v, want %v", c.have, c.want, got, c.ok)
}
}
}