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