Download
446 lines 15.5 KiBGoModified 2026-10-28 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
// Package tools_test runs git-selfhost against a stand-in server: ssh and
// doas run the commands here, in a scratch directory, with the real
// repo-render, new-repo and post-receive hook.
package tools_test

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

// realRenderer is repo-render, built for the tests, in a directory with
// new-repo beside it, where repo-render add finds it.
var realRenderer string

func TestMain(m *testing.M) {
	dir, err := os.MkdirTemp("", "selfhost-test")
	if err != nil {
		panic(err)
	}
	realRenderer = filepath.Join(dir, "repo-render")
	if out, err := exec.Command("go", "build", "-o", realRenderer, "../cmd/repo-render").CombinedOutput(); err != nil {
		panic(fmt.Sprintf("building repo-render: %v\n%s", err, out))
	}
	newRepo, err := filepath.Abs("../contrib/new-repo")
	if err != nil {
		panic(err)
	}
	if err := os.WriteFile(filepath.Join(dir, "repo-render-new-repo"), []byte("#!/bin/sh\nexec "+newRepo+" \"$@\"\n"), 0o755); err != nil {
		panic(err)
	}
	code := m.Run()
	os.RemoveAll(dir)
	os.Exit(code)
}

type env struct {
	t    *testing.T
	root string
	path string
}

const serverConfig = `host: code.example.com
git_dir: GIT_DIR
htdocs: ROOT/htdocs
state_dir: ROOT/state
sections:
  - name: tools
    title: Tools
  - name: libs
    title: Libraries
`

func newEnv(t *testing.T) *env {
	e := &env{t: t, root: t.TempDir()}
	bin := e.p("bin")
	// ssh HOST CMD runs CMD in the git user's home, which is git_dir; so
	// does git's ssh HOST git-receive-pack 'NAME.git'.
	e.script(filepath.Join(bin, "ssh"), `while [ $# -gt 0 ]; do case $1 in -o|-p|-i|-l|-F|-O) shift 2 ;; -*) shift ;; *) break ;; esac; done
shift
cd `+e.p("git")+` && exec sh -c "$*"`)
	e.script(filepath.Join(bin, "doas"), `exec "$@"`)
	e.script(filepath.Join(bin, "logger"), `cat >> `+e.p("syslog"))
	// The hook's enqueue and kick are only noted; the rest is the real
	// repo-render.
	e.script(filepath.Join(bin, "repo-render"), `case "$1" in enqueue|kick) echo "$*" >> `+e.p("calls")+`; exit ;; esac
exec `+realRenderer+` "$@"`)
	e.path = bin + string(os.PathListSeparator) + os.Getenv("PATH")

	if err := os.MkdirAll(e.p("git"), 0o755); err != nil {
		t.Fatal(err)
	}
	e.write("etc/repo-render.yaml", strings.NewReplacer("GIT_DIR", e.p("git"), "ROOT", e.root).Replace(serverConfig))
	e.write("gitconfig", `[selfhost]
	server = me@server
	config = `+e.p("etc/repo-render.yaml")+`
	mirror = good
[selfhost "good"]
	url = `+e.p("{repo}-mirror.git")+`
	keys = https://example.com/{repo}/settings/keys
`)
	e.git("init", "-q", "--bare", e.p("proj-mirror.git"))
	work := e.p("proj")
	e.git("init", "-q", "-b", "trunk", work)
	e.git("-C", work, "commit", "-q", "--allow-empty", "-m", "first")
	e.git("-C", work, "tag", "v1")
	return e
}

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

func (e *env) write(name, body string) {
	e.t.Helper()
	if err := os.MkdirAll(filepath.Dir(e.p(name)), 0o755); err != nil {
		e.t.Fatal(err)
	}
	if err := os.WriteFile(e.p(name), []byte(body), 0o644); err != nil {
		e.t.Fatal(err)
	}
}

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(dir, name string, args ...string) (string, error) {
	cmd := exec.Command(name, args...)
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "PATH="+e.path, "HOME="+e.root,
		"GIT_CONFIG_GLOBAL="+e.p("gitconfig"), "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) git(args ...string) string {
	e.t.Helper()
	out, err := e.run(e.root, "git", args...)
	if err != nil {
		e.t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
	}
	return strings.TrimSpace(out)
}

func (e *env) selfhost(args ...string) (string, error) {
	self, err := filepath.Abs("git-selfhost")
	if err != nil {
		e.t.Fatal(err)
	}
	return e.run(e.p("proj"), self, args...)
}

func (e *env) read(name string) string {
	b, _ := os.ReadFile(e.p(name))
	return string(b)
}

func TestSelfhost(t *testing.T) {
	e := newEnv(t)
	work := e.p("proj")
	// The working copy's origin is the mirror, so it is renamed after it.
	e.git("-C", work, "remote", "add", "origin", e.p("proj-mirror.git"))
	out, err := e.selfhost("--yes", "-d", "It's my project", "--section", "Tools", "--tag", "go", "--tag", "cli",
		"--mirror", "good", "--mirror-url", "/nonexistent/broken.git")
	if err != nil {
		t.Fatalf("git-selfhost: %v\n%s", err, out)
	}
	dir := e.p("git/proj.git")
	// --section took the title; the repository has the section's name.
	if got := e.git("-C", dir, "config", "--get-regexp", `^repo-render\.(section|tag)$`); got != "repo-render.section tools\nrepo-render.tag go\nrepo-render.tag cli" {
		t.Errorf("placement:\n%s", got)
	}
	if cfg := e.read("etc/repo-render.yaml"); cfg != strings.NewReplacer("GIT_DIR", e.p("git"), "ROOT", e.root).Replace(serverConfig) {
		t.Errorf("the config changed:\n%s", cfg)
	}
	if got := e.read("git/proj.git/description"); got != "It's my project\n" {
		t.Errorf("description = %q", got)
	}
	if got := e.git("-C", dir, "symbolic-ref", "HEAD"); got != "refs/heads/trunk" {
		t.Errorf("HEAD = %s", got)
	}
	// A mirror with no repository is left out.
	if !strings.Contains(out, "there's no repository at /nonexistent/broken.git") {
		t.Errorf("the missing mirror was not reported:\n%s", out)
	}
	if got := e.git("-C", dir, "config", "--get-all", "repo-render.mirror"); got != e.p("proj-mirror.git") {
		t.Errorf("mirrors = %q", got)
	}
	key := e.p("etc/keys/proj")
	if got := e.git("-C", dir, "config", "core.sshCommand"); !strings.HasSuffix(got, " -i "+key) {
		t.Errorf("core.sshCommand = %q", got)
	}
	if pub := e.read("etc/keys/proj.pub"); !strings.HasPrefix(pub, "ssh-ed25519 ") || !strings.Contains(out, strings.TrimSpace(pub)) {
		t.Errorf("the key %q is not shown:\n%s", pub, out)
	}
	if !strings.Contains(out, "good: https://example.com/proj/settings/keys") {
		t.Errorf("no link to the mirror's key settings:\n%s", out)
	}
	// The check ran the hook's mirror push.
	if !strings.Contains(out, "proj: mirrored to "+e.p("proj-mirror.git")) {
		t.Errorf("the mirror was not checked:\n%s", out)
	}
	if got := e.git("-C", work, "remote", "get-url", "origin"); got != "git@server:"+e.p("git/proj.git") {
		t.Errorf("origin = %s", got)
	}
	if got := e.git("-C", work, "remote", "get-url", "good"); got != e.p("proj-mirror.git") {
		t.Errorf("the old origin, now good = %s", got)
	}
	head := e.git("-C", work, "rev-parse", "HEAD")
	if got := e.git("-C", dir, "rev-parse", "trunk", "v1"); got != head+"\n"+head {
		t.Errorf("the server has %s, want %s twice", got, head)
	}
	for deadline := time.Now().Add(10 * time.Second); ; time.Sleep(20 * time.Millisecond) {
		if out, err := e.run(e.root, "git", "-C", e.p("proj-mirror.git"), "rev-parse", "trunk"); err == nil && strings.TrimSpace(out) == head {
			break
		}
		if time.Now().After(deadline) {
			t.Fatal("the mirror never got trunk")
		}
	}
}

func TestSelfhostRefuses(t *testing.T) {
	e := newEnv(t)
	work := e.p("proj")
	empty := e.p("empty")
	e.git("init", "-q", "-b", "main", empty)
	self, _ := filepath.Abs("git-selfhost")
	if out, err := e.run(empty, self, "--yes"); err == nil || !strings.Contains(out, "no commits yet") {
		t.Errorf("a working copy with no commits: %v\n%s", err, out)
	}
	if out, err := e.selfhost("--yes", "--section", "Nope"); err == nil || !strings.Contains(out, "there's no section Nope on me@server: its sections are tools libs") {
		t.Errorf("an unknown section: %v\n%s", err, out)
	}
	e.git("-C", work, "remote", "add", "origin", "git@elsewhere:proj.git")
	if out, err := e.selfhost("--yes"); err == nil || !strings.Contains(out, "isn't one of the mirrors") {
		t.Errorf("an origin that is no mirror: %v\n%s", err, out)
	}
	e.git("-C", work, "remote", "remove", "origin")
	if out, err := e.selfhost("--yes", "--dry-run", "--no-mirrors"); err != nil || !strings.Contains(out, "(dry run: not run)") {
		t.Errorf("--dry-run: %v\n%s", err, out)
	}
	if _, err := os.Stat(e.p("git/proj.git")); err == nil {
		t.Error("a refused or dry run made the repository")
	}
	if _, err := os.Stat(e.p("git/empty.git")); err == nil {
		t.Error("a working copy with no commits made its repository")
	}
	if got := e.git("-C", work, "remote"); got != "" {
		t.Errorf("remotes: %s", got)
	}
}

// The default branch is the mirror's, whichever is checked out, and is
// the one pushed.
func TestSelfhostBranch(t *testing.T) {
	e := newEnv(t)
	work, mirror := e.p("proj"), e.p("proj-mirror.git")
	e.git("-C", work, "push", "-q", mirror, "trunk")
	e.git("-C", mirror, "symbolic-ref", "HEAD", "refs/heads/trunk")
	e.git("-C", work, "checkout", "-q", "-b", "feature")
	e.git("-C", work, "commit", "-q", "--allow-empty", "-m", "half done")
	out, err := e.selfhost("--yes")
	if err != nil {
		t.Fatalf("git-selfhost: %v\n%s", err, out)
	}
	if !strings.Contains(out, "default branch: trunk (good's default branch)") {
		t.Errorf("output:\n%s", out)
	}
	dir := e.p("git/proj.git")
	if got := e.git("-C", dir, "symbolic-ref", "HEAD"); got != "refs/heads/trunk" {
		t.Errorf("HEAD = %s", got)
	}
	if got := e.git("-C", dir, "for-each-ref", "--format=%(refname)", "refs/heads/"); got != "refs/heads/trunk" {
		t.Errorf("the server has %s", got)
	}
}

// Each refusal changes nothing, here or on the server.
func TestSelfhostBranchRefuses(t *testing.T) {
	for _, c := range []struct {
		name, want string
		setup      func(e *env, work, mirror string)
		args       []string
	}{{
		name: "the mirror is ahead",
		want: "good's trunk has commits that your trunk doesn't",
		setup: func(e *env, work, mirror string) {
			e.git("-C", work, "commit", "-q", "--allow-empty", "-m", "theirs")
			e.git("-C", work, "push", "-q", mirror, "trunk")
			e.git("-C", mirror, "symbolic-ref", "HEAD", "refs/heads/trunk")
			e.git("-C", work, "reset", "-q", "--hard", "HEAD^")
		},
	}, {
		name: "the mirror has diverged",
		want: "good's trunk has commits that your trunk doesn't",
		setup: func(e *env, work, mirror string) {
			e.git("-C", work, "commit", "-q", "--allow-empty", "-m", "theirs")
			e.git("-C", work, "push", "-q", mirror, "trunk")
			e.git("-C", mirror, "symbolic-ref", "HEAD", "refs/heads/trunk")
			e.git("-C", work, "commit", "-q", "--amend", "--allow-empty", "-m", "ours")
		},
	}, {
		name: "the mirror's default is not here",
		want: "good's default branch is main, and there's no main here",
		setup: func(e *env, work, mirror string) {
			e.git("-C", work, "push", "-q", mirror, "trunk:main")
			e.git("-C", mirror, "symbolic-ref", "HEAD", "refs/heads/main")
		},
	}, {
		name: "no mirror and two branches",
		want: "which branch is the default?",
		setup: func(e *env, work, mirror string) {
			e.git("-C", work, "branch", "feature")
		},
		args: []string{"--no-mirrors"},
	}} {
		t.Run(c.name, func(t *testing.T) {
			e := newEnv(t)
			work, mirror := e.p("proj"), e.p("proj-mirror.git")
			c.setup(e, work, mirror)
			before := e.git("-C", mirror, "for-each-ref")
			out, err := e.selfhost(append([]string{"--yes"}, c.args...)...)
			if err == nil || !strings.Contains(out, c.want) {
				t.Errorf("want a refusal with %q: %v\n%s", c.want, err, out)
			}
			if _, err := os.Stat(e.p("git/proj.git")); err == nil {
				t.Error("it made the repository")
			}
			if got := e.git("-C", work, "remote"); got != "" {
				t.Errorf("remotes: %s", got)
			}
			if got := e.git("-C", mirror, "for-each-ref"); got != before {
				t.Errorf("the mirror changed:\n%s", got)
			}
		})
	}
}

// Run again, it picks up where it was, and changes only what it's told.
func TestSelfhostAgain(t *testing.T) {
	e := newEnv(t)
	work := e.p("proj")
	if out, err := e.selfhost("--yes", "--section", "Tools", "-d", "First"); err != nil {
		t.Fatalf("git-selfhost: %v\n%s", err, out)
	}
	key := e.read("etc/keys/proj")
	// As if it had stopped before the remote was added
	e.git("-C", work, "remote", "remove", "origin")
	e.git("-C", work, "commit", "-q", "--allow-empty", "-m", "more")
	out, err := e.selfhost("--yes", "--section", "Libraries")
	if err != nil {
		t.Fatalf("git-selfhost again: %v\n%s", err, out)
	}
	if !strings.Contains(out, "picking up where it was") {
		t.Errorf("output:\n%s", out)
	}
	if got := e.git("-C", e.p("git/proj.git"), "config", "repo-render.section"); got != "libs" {
		t.Errorf("proj wasn't moved to Libraries: %q", got)
	}
	if got := e.read("git/proj.git/description"); got != "First\n" {
		t.Errorf("description = %q", got)
	}
	if e.read("etc/keys/proj") != key {
		t.Error("the key changed")
	}
	if got, want := e.git("-C", e.p("git/proj.git"), "rev-parse", "trunk"), e.git("-C", work, "rev-parse", "HEAD"); got != want {
		t.Errorf("the server's trunk = %s, want %s", got, want)
	}
	// And again, with nothing to do: nothing is placed.
	out, err = e.selfhost("--yes")
	if err != nil || strings.Contains(out, "put it in") {
		t.Errorf("git-selfhost a third time: %v\n%s", err, out)
	}
}

func TestSelfhostGuards(t *testing.T) {
	for _, c := range []struct {
		name, want string
		setup      func(e *env, work, mirror string)
		args       []string
	}{{
		name: "another repository of the name",
		want: "with commits your trunk doesn't have",
		setup: func(e *env, work, mirror string) {
			other := e.p("unrelated")
			e.git("init", "-q", "-b", "trunk", other)
			e.git("-C", other, "commit", "-q", "--allow-empty", "-m", "someone else's")
			e.git("init", "-q", "--bare", "-b", "trunk", e.p("git/proj.git"))
			e.git("-C", other, "push", "-q", e.p("git/proj.git"), "trunk")
		},
	}, {
		name: "a shallow clone",
		want: "shallow clone",
		setup: func(e *env, work, mirror string) {
			e.git("-C", work, "commit", "-q", "--allow-empty", "-m", "second")
			e.git("clone", "-q", "--depth", "1", "file://"+work, e.p("shallow"))
			if err := os.RemoveAll(work); err != nil {
				e.t.Fatal(err)
			}
			if err := os.Rename(e.p("shallow"), work); err != nil {
				e.t.Fatal(err)
			}
			e.git("-C", work, "remote", "remove", "origin")
		},
	}, {
		name: "a tag that differs on the mirror",
		want: "tag v1 is different here and on good",
		setup: func(e *env, work, mirror string) {
			e.git("-C", work, "commit", "-q", "--allow-empty", "-m", "elsewhere")
			e.git("-C", work, "push", "-q", mirror, "trunk", "HEAD:refs/tags/v1")
		},
	}, {
		name: "an https mirror",
		want: "isn't an ssh URL",
		args: []string{"--mirror-url", "https://example.com/me/proj"},
	}} {
		t.Run(c.name, func(t *testing.T) {
			e := newEnv(t)
			work, mirror := e.p("proj"), e.p("proj-mirror.git")
			if c.setup != nil {
				c.setup(e, work, mirror)
			}
			before := e.read("etc/repo-render.yaml")
			out, err := e.selfhost(append([]string{"--yes"}, c.args...)...)
			if err == nil || !strings.Contains(out, c.want) {
				t.Errorf("want a refusal with %q: %v\n%s", c.want, err, out)
			}
			if got := e.read("etc/repo-render.yaml"); got != before {
				t.Errorf("the config changed:\n%s", got)
			}
			if got := e.git("-C", work, "remote"); got != "" {
				t.Errorf("remotes: %s", got)
			}
			if _, err := os.Stat(e.p("etc/keys/proj")); err == nil {
				t.Error("it made a key")
			}
		})
	}
}

// In a worktree, the name is the repository's.
func TestSelfhostWorktree(t *testing.T) {
	e := newEnv(t)
	work := e.p("proj")
	e.git("-C", work, "worktree", "add", "-q", e.p("proj-feature"), "-b", "feature")
	self, _ := filepath.Abs("git-selfhost")
	out, err := e.run(e.p("proj-feature"), self, "--yes", "--no-section", "--branch", "trunk")
	if err != nil {
		t.Fatalf("git-selfhost: %v\n%s", err, out)
	}
	if _, err := os.Stat(e.p("git/proj.git")); err != nil {
		t.Errorf("no proj.git:\n%s", out)
	}
}