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