Download
258 lines 9.1 KiBGoModified 2026-10-28 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
// Package contrib_test runs new-repo and the post-receive hook it
// installs, for real: it creates repositories, pushes to them, and
// checks what the hook ran and pushed.
package contrib_test

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

// env is a scratch directory with a stand-in for repo-render and for
// logger, each of which logs how it was run.
type env struct {
	t    *testing.T
	root string
	path string // PATH, with the stand-ins first
}

func newEnv(t *testing.T) *env {
	e := &env{t: t, root: t.TempDir()}
	bin := filepath.Join(e.root, "bin")
	e.script(filepath.Join(bin, "repo-render"), `echo "$*" >> `+e.p("calls"))
	e.script(filepath.Join(bin, "logger"), `cat >> `+e.p("syslog"))
	e.path = bin + string(os.PathListSeparator) + os.Getenv("PATH")
	return e
}

func (e *env) p(name string) string { return filepath.Join(e.root, name) }

func (e *env) script(p, body string) {
	e.t.Helper()
	if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
		e.t.Fatal(err)
	}
	if err := os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
		e.t.Fatal(err)
	}
}

func (e *env) run(name string, args ...string) (string, error) {
	cmd := exec.Command(name, args...)
	cmd.Env = append(os.Environ(), "PATH="+e.path, "HOME="+e.root,
		"GIT_CONFIG_GLOBAL="+e.p("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")
	out, err := cmd.CombinedOutput()
	return string(out), err
}

func (e *env) newRepo(args ...string) (string, error) { return e.run("./new-repo", args...) }

func (e *env) git(args ...string) string {
	e.t.Helper()
	out, err := e.run("git", args...)
	if err != nil {
		e.t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
	}
	return strings.TrimSpace(out)
}

// wait waits for the lines of file to number n, and returns them.
func (e *env) wait(file string, n int) []string {
	e.t.Helper()
	for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(20 * time.Millisecond) {
		b, _ := os.ReadFile(e.p(file))
		lines := strings.Split(strings.TrimSpace(string(b)), "\n")
		if len(b) == 0 {
			lines = nil
		}
		if len(lines) >= n {
			return lines
		}
		if time.Now().After(deadline) {
			e.t.Fatalf("%s:\n%s\nwant %d lines", file, b, n)
		}
	}
}

func TestNewRepoRejects(t *testing.T) {
	e := newEnv(t)
	for _, args := range [][]string{
		{e.p("x")}, {e.p(".x.git")}, {e.p("a b.git")}, {},
		{"-c", e.p("missing.yaml"), e.p("x.git")},
		{"-m", "--upload-pack=x", e.p("x.git")},
		{"-m", "a b", e.p("x.git")},
	} {
		if out, err := e.newRepo(args...); err == nil {
			t.Errorf("new-repo %q succeeded:\n%s", args, out)
		}
	}
	if _, err := os.Stat(e.p("x.git")); err == nil {
		t.Error("a rejected new-repo created the repository")
	}
}

func TestNewRepoAndHook(t *testing.T) {
	e := newEnv(t)
	cfg := e.p("it's/repo-render.yaml")
	if err := os.MkdirAll(filepath.Dir(cfg), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(cfg, nil, 0o644); err != nil {
		t.Fatal(err)
	}
	broken, good := e.p("missing.git"), e.p("good.git")
	e.git("init", "-q", "--bare", good)
	dir := e.p("git/proj.git")
	out, err := e.newRepo("-c", cfg, "-b", "trunk", "-d", "My project", "-m", broken, "-m", good, dir)
	if err != nil {
		t.Fatalf("new-repo: %v\n%s", err, out)
	}
	if out != "created "+dir+"\n" {
		t.Errorf("new-repo said %q", out)
	}
	program := filepath.Join(e.root, "bin", "repo-render")
	for key, want := range map[string]string{
		"core.bare": "true", "receive.denyDeletes": "true", "receive.denyNonFastForwards": "false",
		"repo-render.config": cfg, "repo-render.program": program,
	} {
		if got := e.git("-C", dir, "config", key); got != want {
			t.Errorf("%s = %q, want %q", key, got, want)
		}
	}
	if got := e.git("-C", dir, "config", "--get-all", "repo-render.mirror"); got != broken+"\n"+good {
		t.Errorf("mirrors = %q", got)
	}
	if got := e.git("-C", dir, "symbolic-ref", "HEAD"); got != "refs/heads/trunk" {
		t.Errorf("HEAD = %s", got)
	}
	if b, _ := os.ReadFile(filepath.Join(dir, "description")); string(b) != "My project\n" {
		t.Errorf("description = %q", b)
	}
	hook := filepath.Join(dir, "hooks", "post-receive")
	shared, err := filepath.Abs("post-receive")
	if err != nil {
		t.Fatal(err)
	}
	// The link names the hook by its real path, as new-repo resolves it.
	if shared, err = filepath.EvalSymlinks(shared); err != nil {
		t.Fatal(err)
	}
	if got, err := os.Readlink(hook); err != nil || got != shared {
		t.Errorf("hook is %q (%v), want a link to %s", got, err, shared)
	}

	// The mirror has a branch of its own, which a push leaves alone.
	work := e.p("work")
	e.git("init", "-q", "-b", "trunk", work)
	e.git("-C", work, "commit", "-q", "--allow-empty", "-m", "theirs")
	e.git("-C", work, "push", "-q", good, "trunk:theirs")
	theirs := e.git("-C", work, "rev-parse", "HEAD")

	// A push queues a job per ref, kicks the renderer, and pushes the
	// branch and the tags to each mirror, past the one that fails.
	e.git("-C", work, "checkout", "-q", "--orphan", "next")
	e.git("-C", work, "commit", "-q", "--allow-empty", "-m", "first")
	e.git("-C", work, "branch", "-q", "-M", "trunk")
	e.git("-C", work, "tag", "v1")
	head := e.git("-C", work, "rev-parse", "HEAD")
	e.git("-C", work, "push", "-q", dir, "trunk", "v1")
	zero := strings.Repeat("0", len(head))
	calls := e.wait("calls", 3)
	want := []string{
		fmt.Sprintf("--config %s enqueue proj refs/heads/trunk %s %s", cfg, zero, head),
		fmt.Sprintf("--config %s enqueue proj refs/tags/v1 %s %s", cfg, zero, head),
		"--config " + cfg + " kick",
	}
	if !slices.Equal(calls, want) {
		t.Errorf("the hook ran:\n%s\nwant:\n%s", strings.Join(calls, "\n"), strings.Join(want, "\n"))
	}
	syslog := strings.Join(e.wait("syslog", 2), "\n")
	if !strings.Contains(syslog, "proj: mirror to "+broken+" failed") || !strings.Contains(syslog, "proj: mirrored to "+good) {
		t.Errorf("syslog:\n%s", syslog)
	}
	got := e.git("-C", good, "for-each-ref", "--format=%(refname) %(objectname)")
	if want := "refs/heads/theirs " + theirs + "\nrefs/heads/trunk " + head + "\nrefs/tags/v1 " + head; got != want {
		t.Errorf("mirror refs:\n%s\nwant:\n%s", got, want)
	}

	// The branch is forced on the mirror too.
	e.git("-C", work, "commit", "-q", "--amend", "--allow-empty", "-m", "first, again")
	e.git("-C", work, "push", "-q", "-f", dir, "trunk")
	e.wait("syslog", 4)
	if got, want := e.git("-C", good, "rev-parse", "trunk"), e.git("-C", work, "rev-parse", "HEAD"); got != want {
		t.Errorf("mirror's trunk = %s, want %s", got, want)
	}
	// Branch deletions are refused. (git's receive.denyDeletes leaves
	// tags alone.)
	if out, err := e.run("git", "-C", work, "push", "-q", dir, ":trunk"); err == nil {
		t.Errorf("deleting a branch succeeded:\n%s", out)
	}

	// Run again, it wires the repository up again: an old shared hooks
	// directory comes out, an older version's copy of the hook becomes the
	// link, and a mirror it has is not added twice.
	e.git("-C", dir, "config", "core.hooksPath", "/nonexistent")
	if err := os.Remove(hook); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(hook, []byte("#!/bin/sh\n# an old copy\n"), 0o755); err != nil {
		t.Fatal(err)
	}
	if out, err := e.newRepo("-b", "main", dir); err == nil {
		t.Errorf("new-repo -b over an existing repository succeeded:\n%s", out)
	}
	if out, err := e.newRepo("-m", good, dir); err != nil || out != "rewired "+dir+"\n" {
		t.Fatalf("new-repo again: %v\n%s", err, out)
	}
	if out, err := e.run("git", "-C", dir, "config", "core.hooksPath"); err == nil {
		t.Errorf("core.hooksPath still set: %s", out)
	}
	if got, err := os.Readlink(hook); err != nil || got != shared {
		t.Errorf("hook is %q (%v) after a rewire, want a link to %s", got, err, shared)
	}
	// And again, over the link it made.
	if out, err := e.newRepo(dir); err != nil {
		t.Fatalf("new-repo over its own link: %v\n%s", err, out)
	}
	if got, err := os.Readlink(hook); err != nil || got != shared {
		t.Errorf("hook is %q (%v) after a second rewire, want a link to %s", got, err, shared)
	}
	if ents, _ := os.ReadDir(filepath.Dir(hook)); slices.ContainsFunc(ents, func(d os.DirEntry) bool { return strings.HasPrefix(d.Name(), ".post-receive") }) {
		t.Error("a temporary link left in hooks/")
	}
	if got := e.git("-C", dir, "config", "--get-all", "repo-render.mirror"); got != broken+"\n"+good {
		t.Errorf("mirrors = %q", got)
	}
	if got := e.git("-C", dir, "config", "repo-render.config"); got != cfg {
		t.Errorf("repo-render.config = %q after a rewire without -c", got)
	}
}

// Without repo-render.config or mirrors, a push does nothing, and
// new-repo says so.
func TestHookDoesNothingUnasked(t *testing.T) {
	e := newEnv(t)
	dir := e.p("plain.git")
	out, err := e.newRepo(dir)
	if err != nil || !strings.Contains(out, "pushes are not rendered") {
		t.Fatalf("new-repo: %v\n%s", err, out)
	}
	work := e.p("work")
	e.git("init", "-q", "-b", "main", work)
	e.git("-C", work, "commit", "-q", "--allow-empty", "-m", "first")
	e.git("-C", work, "push", "-q", dir, "main")
	time.Sleep(200 * time.Millisecond)
	for _, f := range []string{"calls", "syslog"} {
		if b, err := os.ReadFile(e.p(f)); err == nil {
			t.Errorf("%s: %s", f, b)
		}
	}
}