Download
579 lines 17.8 KiBGoModified 2026-10-21 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
package gitx

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"slices"
	"strings"
	"testing"
	"time"
)

// testRepo is a repository with a work tree, under a temporary directory,
// whose commits are dated by when.
type testRepo struct {
	t *testing.T
	Repo
	home string
	when int64
}

func newRepo(t *testing.T, args ...string) *testRepo {
	t.Helper()
	r := &testRepo{t: t, Repo: Repo{Dir: t.TempDir()}, home: t.TempDir(), when: 1767323045}
	r.git("", append([]string{"init", "-q", "-b", "main"}, args...)...)
	return r
}

// git runs git in the repository with stdin and returns its output,
// trimmed.
func (r *testRepo) git(stdin string, args ...string) string {
	r.t.Helper()
	cmd := exec.Command("git", append([]string{"-C", r.Dir}, args...)...)
	cmd.Stdin = strings.NewReader(stdin)
	date := fmt.Sprintf("@%d +0000", r.when)
	cmd.Env = append(os.Environ(),
		"HOME="+r.home,
		"GIT_CONFIG_GLOBAL="+filepath.Join(r.home, "nogitconfig"),
		"GIT_CONFIG_NOSYSTEM=1",
		"GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.com",
		"GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.com",
		"GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date,
	)
	out, err := cmd.CombinedOutput()
	if err != nil {
		r.t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
	}
	return strings.TrimSpace(string(out))
}

// write writes each file of the work tree, a "->" prefix making a
// symbolic link and a "!" prefix an executable, and removes those mapped
// to "".
func (r *testRepo) write(files map[string]string) {
	r.t.Helper()
	for name, body := range files {
		p := filepath.Join(r.Dir, name)
		if err := os.RemoveAll(p); err != nil {
			r.t.Fatal(err)
		}
		if body == "" {
			continue
		}
		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
			r.t.Fatal(err)
		}
		var err error
		switch {
		case strings.HasPrefix(body, "->"):
			err = os.Symlink(body[2:], p)
		case strings.HasPrefix(body, "!"):
			err = os.WriteFile(p, []byte(body[1:]), 0o755)
		default:
			err = os.WriteFile(p, []byte(body), 0o644)
		}
		if err != nil {
			r.t.Fatal(err)
		}
	}
}

// commit writes files, commits everything a minute after the last commit
// and returns the commit.
func (r *testRepo) commit(msg string, files map[string]string) string {
	r.t.Helper()
	r.write(files)
	r.when += 60
	r.git("", "add", "-A")
	r.git("", "commit", "-q", "--allow-empty", "-m", msg)
	return r.git("", "rev-parse", "HEAD")
}

func TestIsSHA(t *testing.T) {
	sha1 := "0123456789abcdef0123456789abcdef01234567"
	sha256 := sha1 + "89abcdef0123456789abcdef"
	for s, want := range map[string]bool{
		sha1:                           true,
		sha256:                         true,
		strings.Repeat("0", 40):        true,
		"":                             false,
		sha1[1:]:                       false,
		sha1 + "8":                     false,
		sha256[1:]:                     false,
		sha256 + "0":                   false,
		strings.ToUpper(sha1):          false,
		sha1[:39] + "g":                false,
		sha1 + "\n":                    false,
		" " + sha1:                     false,
		sha1 + sha1[:24] + "\n":        false,
		strings.Repeat("a", 40+64):     false,
		strings.Repeat("0", 40) + "^0": false,
	} {
		if got := IsSHA(s); got != want {
			t.Errorf("IsSHA(%q) = %v, want %v", s, got, want)
		}
	}
}

// TestRun: Run returns git's whole output, and on failure an error that
// names the command and carries git's stderr and exit status.
func TestRun(t *testing.T) {
	r := newRepo(t)
	big := strings.Repeat("0123456789abcdef\n", 1<<16) // past a pipe's buffer
	sha := r.git(big, "hash-object", "-w", "--stdin")
	out, err := r.Run("cat-file", "blob", sha)
	if err != nil || string(out) != big {
		t.Errorf("Run cat-file: %d bytes, %v; want %d", len(out), err, len(big))
	}
	_, err = r.Run("rev-parse", "--verify", "nosuchref")
	var ee *exec.ExitError
	if err == nil || !errors.As(err, &ee) || ee.ExitCode() != 128 ||
		!strings.Contains(err.Error(), "git rev-parse --verify nosuchref:") ||
		!strings.Contains(err.Error(), "fatal: Needed a single revision") {
		t.Errorf("Run of a missing ref: %v", err)
	}
}

func TestExists(t *testing.T) {
	r := newRepo(t)
	bare := t.TempDir()
	r.git("", "init", "-q", "--bare", bare)
	file := filepath.Join(t.TempDir(), "file")
	if err := os.WriteFile(file, nil, 0o644); err != nil {
		t.Fatal(err)
	}
	for dir, want := range map[string]bool{
		r.Dir:                             true,
		bare:                              true,
		t.TempDir():                       false,
		file:                              false,
		filepath.Join(t.TempDir(), "nil"): false,
	} {
		if got := (Repo{Dir: dir}).Exists(); got != want {
			t.Errorf("Exists(%s) = %v, want %v", dir, got, want)
		}
	}
}

// TestRevParse: a missing ref, or one that is not a commit, is "" with no
// error; only git failing is an error.
func TestRevParse(t *testing.T) {
	r := newRepo(t)
	if sha, err := r.RevParse("main"); sha != "" || err != nil {
		t.Errorf("RevParse(main) before any commit = %q, %v", sha, err)
	}
	c1 := r.commit("one", map[string]string{"a": "a"})
	c2 := r.commit("two", map[string]string{"a": "b"})
	r.git("", "tag", "-a", "-m", "v1", "v1", c1)
	blob := r.git("", "rev-parse", "HEAD:a")
	for ref, want := range map[string]string{
		"main":                  c2,
		"HEAD":                  c2,
		"HEAD~":                 c1,
		"v1":                    c1, // peeled
		c1:                      c1,
		c1[:12]:                 c1,
		"nosuch":                "",
		"HEAD~5":                "",
		blob:                    "",
		"refs/v1":               "",
		"main:a":                "",
		"main..x":               "",
		strings.Repeat("0", 40): "",
	} {
		if got, err := r.RevParse(ref); got != want || err != nil {
			t.Errorf("RevParse(%q) = %q, %v; want %q", ref, got, err, want)
		}
	}
	if _, err := (Repo{Dir: filepath.Join(r.Dir, "nil")}).RevParse("main"); err == nil {
		t.Error("RevParse in a missing directory: no error")
	}
}

func TestHeadBranch(t *testing.T) {
	r := newRepo(t)
	if b, err := r.HeadBranch(); b != "main" || err != nil {
		t.Errorf("HeadBranch = %q, %v; want main", b, err)
	}
	c := r.commit("one", nil)
	r.git("", "symbolic-ref", "HEAD", "refs/heads/my/dev")
	if b, err := r.HeadBranch(); b != "my/dev" || err != nil {
		t.Errorf("HeadBranch = %q, %v; want my/dev", b, err)
	}
	r.git("", "checkout", "-q", "--detach", c)
	if b, err := r.HeadBranch(); err == nil {
		t.Errorf("HeadBranch of a detached HEAD = %q", b)
	}
}

func TestIsAncestor(t *testing.T) {
	r := newRepo(t)
	c1 := r.commit("one", nil)
	c2 := r.commit("two", nil)
	r.git("", "checkout", "-q", "-b", "side", c1)
	c3 := r.commit("three", nil)
	for _, c := range []struct {
		a, b string
		want bool
	}{
		{c1, c2, true}, {c2, c1, false}, {c1, c1, true},
		{c2, c3, false}, {c3, c2, false}, {c1, c3, true},
	} {
		if got, err := r.IsAncestor(c.a, c.b); got != c.want || err != nil {
			t.Errorf("IsAncestor(%.7s, %.7s) = %v, %v; want %v", c.a, c.b, got, err, c.want)
		}
	}
	if _, err := r.IsAncestor(c1, "nosuch"); err == nil {
		t.Error("IsAncestor of a missing ref: no error")
	}
}

// TestLsTree lists every kind of entry, with paths no shell would like.
func TestLsTree(t *testing.T) {
	r := newRepo(t)
	sub := r.commit("for the submodule", nil)
	r.write(map[string]string{
		"README":         "hello\n",
		"bin/run":        "!#!/bin/sh\n",
		"link":           "->README",
		"d/e/f g\th\"i":  "x",
		"d/ünï\\cøde.md": "ü",
	})
	r.git("", "add", "-A")
	r.git("", "update-index", "--add", "--cacheinfo", "160000,"+sub+",sub")
	r.git("", "commit", "-q", "-m", "all")
	got, err := r.LsTree("HEAD")
	if err != nil {
		t.Fatal(err)
	}
	blob := func(p string) string { return r.git("", "rev-parse", "HEAD:"+p) }
	want := []Entry{
		{"100644", blob("README"), 6, "README"},
		{"100755", blob("bin/run"), 10, "bin/run"},
		{"100644", blob("d/e/f g\th\"i"), 1, "d/e/f g\th\"i"},
		{"100644", blob("d/ünï\\cøde.md"), 2, "d/ünï\\cøde.md"},
		{"120000", blob("link"), 6, "link"},
		{"160000", sub, -1, "sub"},
	}
	if !slices.Equal(got, want) {
		t.Errorf("LsTree =\n%+v\nwant\n%+v", got, want)
	}
	for _, e := range got {
		if e.IsSymlink() != (e.Path == "link") || e.IsSubmodule() != (e.Path == "sub") ||
			e.IsExecutable() != (e.Path == "bin/run") {
			t.Errorf("%s: symlink %v, submodule %v, executable %v",
				e.Path, e.IsSymlink(), e.IsSubmodule(), e.IsExecutable())
		}
	}
	if _, err := r.LsTree("nosuch"); err == nil {
		t.Error("LsTree of a missing commit: no error")
	}
}

// TestDiffTree: renames are a D and an A, and a type change is an M.
func TestDiffTree(t *testing.T) {
	r := newRepo(t)
	c1 := r.commit("one", map[string]string{
		"a": "a", "b": "b", "d": "d", "old": "same\n", "dir/x": "x", "keep": "k",
	})
	c2 := r.commit("two", map[string]string{
		"a": "A", "b": "", "c": "c", "d": "->a", "old": "", "new": "same\n", "dir/x": "!x",
	})
	got, err := r.DiffTree(c1, c2)
	if err != nil {
		t.Fatal(err)
	}
	want := []Change{
		{'M', "a"}, {'D', "b"}, {'A', "c"}, {'M', "d"}, {'M', "dir/x"}, {'A', "new"}, {'D', "old"},
	}
	if !slices.Equal(got, want) {
		t.Errorf("DiffTree =\n%v\nwant\n%v", got, want)
	}
	if got, err := r.DiffTree(c2, c2); len(got) != 0 || err != nil {
		t.Errorf("DiffTree of a commit with itself = %v, %v", got, err)
	}
	if _, err := r.DiffTree(c1, "nosuch"); err == nil {
		t.Error("DiffTree to a missing commit: no error")
	}
}

func TestCommit(t *testing.T) {
	r := newRepo(t)
	c := r.commit("The subject: of it all\n\nThe body.", nil)
	when, subject, err := r.Commit(c)
	if err != nil {
		t.Fatal(err)
	}
	if want := time.Unix(r.when, 0).UTC(); !when.Equal(want) || when.Location() != time.UTC {
		t.Errorf("Commit time = %v, want %v", when, want)
	}
	if subject != "The subject: of it all" {
		t.Errorf("Commit subject = %q", subject)
	}
	if _, _, err := r.Commit("nosuch"); err == nil {
		t.Error("Commit of a missing commit: no error")
	}
}

func TestDescription(t *testing.T) {
	r := Repo{Dir: t.TempDir()}
	for body, want := range map[string]string{
		"": "",
		"Unnamed repository; edit this file 'description' to name the repository.\n": "",
		"  A renderer.\n\n":            "A renderer.",
		"Two\nlines\n":                 "Two\nlines",
		"Unnamed? No: named.\n":        "Unnamed? No: named.",
		"An unnamed repository, yes\n": "An unnamed repository, yes",
	} {
		if err := os.WriteFile(filepath.Join(r.Dir, "description"), []byte(body), 0o644); err != nil {
			t.Fatal(err)
		}
		if got := r.Description(); got != want {
			t.Errorf("Description of %q = %q, want %q", body, got, want)
		}
	}
	if got := (Repo{Dir: filepath.Join(r.Dir, "nil")}).Description(); got != "" {
		t.Errorf("Description with no file = %q", got)
	}
}

// TestTags: newest first, an annotated tag by its tagger date, and peeled
// to its commit.
func TestTags(t *testing.T) {
	r := newRepo(t)
	if tags, err := r.Tags(); len(tags) != 0 || err != nil {
		t.Errorf("Tags with none = %+v, %v", tags, err)
	}
	c1 := r.commit("one", nil)
	c2 := r.commit("two", nil)
	r.git("", "tag", "light", c1)
	r.when += 3600
	r.git("", "tag", "-a", "-m", "release", "v1/rc", c1) // newer than c2
	r.git("", "tag", "middle", c2)
	obj := r.git("", "rev-parse", "v1/rc")
	got, err := r.Tags()
	if err != nil {
		t.Fatal(err)
	}
	want := []Tag{{"v1/rc", c1, obj}, {"middle", c2, c2}, {"light", c1, c1}}
	if !slices.Equal(got, want) || obj == c1 {
		t.Errorf("Tags =\n%+v\nwant\n%+v", got, want)
	}
}

func TestArchive(t *testing.T) {
	r := newRepo(t)
	c := r.commit("one", map[string]string{"a": "aa", "d/b": "!bb", "l": "->a"})
	for _, level := range []int{0, 1, 9} {
		var buf bytes.Buffer
		if err := r.Archive(&buf, c, "r-1/", level); err != nil {
			t.Fatal(err)
		}
		zr, err := gzip.NewReader(&buf)
		if err != nil {
			t.Fatal(err)
		}
		got := map[string]string{}
		tr := tar.NewReader(zr)
		for {
			h, err := tr.Next()
			if err == io.EOF {
				break
			}
			if err != nil {
				t.Fatal(err)
			}
			body, _ := io.ReadAll(tr)
			switch h.Typeflag {
			case tar.TypeReg:
				got[h.Name] = fmt.Sprintf("%o %s", h.Mode&0o777, body)
			case tar.TypeSymlink:
				got[h.Name] = "-> " + h.Linkname
			case tar.TypeDir:
				got[h.Name] = "dir"
			}
		}
		want := map[string]string{
			"r-1/": "dir", "r-1/a": "664 aa", "r-1/d/": "dir", "r-1/d/b": "775 bb", "r-1/l": "-> a",
		}
		if fmt.Sprint(got) != fmt.Sprint(want) {
			t.Errorf("level %d: archive holds %q, want %q", level, got, want)
		}
	}
	if err := r.Archive(io.Discard, "nosuch", "x/", 0); err == nil ||
		!strings.Contains(err.Error(), "git archive:") || !strings.Contains(err.Error(), "nosuch") {
		t.Errorf("Archive of a missing commit: %v", err)
	}
}

// TestLastModified: each wanted path gets the time of the newest commit
// that touched it, once, and a path never touched is left out.
func TestLastModified(t *testing.T) {
	r := newRepo(t)
	r.commit("one", map[string]string{"a": "1", "b": "1", "d/c": "1", "e": "1"})
	t1 := r.when
	r.commit("two", map[string]string{"a": "2", "d/c": "2"})
	t2 := r.when
	r.commit("three", map[string]string{"a": "3", "e": ""})
	t3 := r.when
	r.commit("four", map[string]string{"x y\tz": "4"})
	t4 := r.when
	for _, c := range []struct {
		wanted []string
		want   map[string]int64
	}{
		{[]string{"a", "b", "d/c", "e", "x y\tz", "nosuch", "d"},
			map[string]int64{"a": t3, "b": t1, "d/c": t2, "e": t3, "x y\tz": t4}},
		{[]string{"a"}, map[string]int64{"a": t3}},
		{[]string{"b"}, map[string]int64{"b": t1}},
		{nil, map[string]int64{}},
	} {
		wanted := map[string]bool{}
		for _, p := range c.wanted {
			wanted[p] = true
		}
		got := map[string]int64{}
		err := r.LastModified("HEAD", wanted, func(p string, when int64) {
			if _, dup := got[p]; dup {
				t.Errorf("%s: found twice", p)
			}
			got[p] = when
		})
		if err != nil || fmt.Sprint(got) != fmt.Sprint(c.want) {
			t.Errorf("LastModified(%q) = %v, %v; want %v", c.wanted, got, err, c.want)
		}
	}
	found := func(string, int64) {}
	if err := r.LastModified("nosuch", map[string]bool{"a": true}, found); err == nil {
		t.Error("LastModified of a missing commit: no error")
	}
	if err := r.LastModified("nosuch", nil, found); err != nil {
		t.Errorf("LastModified of nothing: %v", err)
	}
}

// TestBatchMissing: a missing object is an error that leaves the batch
// usable, and a closed batch refuses requests.
func TestBatchMissing(t *testing.T) {
	r := newRepo(t)
	sha := r.git("x\n", "hash-object", "-w", "--stdin")
	b, err := r.NewBatch()
	if err != nil {
		t.Fatal(err)
	}
	if _, err := b.Get(strings.Repeat("1", 40)); err == nil || !strings.Contains(err.Error(), "object missing") {
		t.Errorf("Get of a missing object: %v", err)
	}
	if err := b.Copy("nosuch", io.Discard); err == nil {
		t.Error("Copy of a missing object: no error")
	}
	if got, err := b.Get(sha); string(got) != "x\n" || err != nil {
		t.Errorf("Get after a missing object = %q, %v", got, err)
	}
	if err := b.Close(); err != nil {
		t.Fatal(err)
	}
	if err := b.Close(); err != nil {
		t.Errorf("second Close: %v", err)
	}
	if _, err := b.Get(sha); err == nil {
		t.Error("Get after Close: no error")
	}
	if err := b.Copy(sha, io.Discard); err == nil {
		t.Error("Copy after Close: no error")
	}
}

// TestPackObjects: the pack holds what the refs reach, less what a ^ref
// reaches, and nothing else in the repository.
func TestPackObjects(t *testing.T) {
	r := newRepo(t)
	c1 := r.commit("one", map[string]string{"a": "one"})
	c2 := r.commit("two", map[string]string{"a": "two"})
	r.git("", "checkout", "-q", "-b", "side")
	side := r.commit("side", map[string]string{"a": "side"})
	dir := t.TempDir()
	objects := func(refs ...string) []string {
		t.Helper()
		prefix := filepath.Join(dir, "p")
		hash, err := r.PackObjects(prefix, refs)
		if err != nil {
			t.Fatal(err)
		}
		if !IsSHA(hash) {
			t.Fatalf("pack hash %q", hash)
		}
		if _, err := os.Stat(prefix + "-" + hash + ".pack"); err != nil {
			t.Error(err)
		}
		idx, err := os.ReadFile(prefix + "-" + hash + ".idx")
		if err != nil {
			t.Fatal(err)
		}
		var shas []string
		for line := range strings.Lines(r.git(string(idx), "show-index")) {
			shas = append(shas, strings.Fields(line)[1])
		}
		return shas
	}
	all := objects(c2)
	if len(all) != 6 || !slices.Contains(all, c1) || !slices.Contains(all, c2) || slices.Contains(all, side) {
		t.Errorf("pack of %.7s holds %q", c2, all)
	}
	newer := objects(c2, "^"+c1)
	if len(newer) != 3 || slices.Contains(newer, c1) || !slices.Contains(newer, c2) {
		t.Errorf("pack of %.7s less %.7s holds %q", c2, c1, newer)
	}
	if _, err := r.PackObjects(filepath.Join(dir, "q"), []string{"nosuch"}); err == nil {
		t.Error("PackObjects of a missing ref: no error")
	}
}

// TestPush: the branch is forced, tags follow, and branches only the
// remote has are kept.
func TestPush(t *testing.T) {
	r := newRepo(t)
	remote := newRepo(t, "--bare")
	other := r.commit("other", nil)
	r.git("", "push", "-q", remote.Dir, "HEAD:refs/heads/theirs")
	r.git("", "reset", "-q", "--hard", r.git("", "commit-tree", "-m", "root", r.git("", "write-tree")))
	c1 := r.commit("one", nil)
	r.git("", "tag", "v1")
	r.git("", "branch", "unpushed")
	var log bytes.Buffer
	if err := r.Push(context.Background(), remote.Dir, "main", SSH{}, &log); err != nil {
		t.Fatalf("Push: %v\n%s", err, &log)
	}
	r.git("", "commit", "-q", "--amend", "--allow-empty", "-m", "one, again")
	c2 := r.git("", "rev-parse", "HEAD")
	if err := r.Push(context.Background(), remote.Dir, "main", SSH{}, &log); err != nil {
		t.Fatalf("Push not fast-forward: %v\n%s", err, &log)
	}
	got := remote.git("", "for-each-ref", "--format=%(refname) %(objectname)")
	want := strings.Join([]string{
		"refs/heads/main " + c2,
		"refs/heads/theirs " + other,
		"refs/tags/v1 " + c1,
	}, "\n")
	if got != want {
		t.Errorf("remote refs:\n%s\nwant\n%s", got, want)
	}
	if err := r.Push(context.Background(), filepath.Join(t.TempDir(), "nil"), "main", SSH{}, io.Discard); err == nil {
		t.Error("Push to a missing remote: no error")
	}
}

func TestVersion(t *testing.T) {
	v, err := Version()
	if err != nil || !strings.HasPrefix(v, "git version ") || strings.HasSuffix(v, "\n") {
		t.Errorf("Version = %q, %v", v, err)
	}
}