Download
285 lines 10.2 KiBGoModified 2026-10-28 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
package admin

import (
	"bytes"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"code.bruce-hill.com/repo-renderer/internal/config"
	"code.bruce-hill.com/repo-renderer/internal/gitx"
	"code.bruce-hill.com/repo-renderer/internal/mirror"
)

type env struct {
	t      *testing.T
	root   string
	cfg    string
	keys   string
	mirror string
}

func newEnv(t *testing.T) *env {
	e := &env{t: t, root: t.TempDir()}
	e.cfg = e.p("etc/repo-render.yaml")
	e.keys = e.p("etc/keys")
	// new-repo records the repo-render on PATH as the program to run.
	bin := e.p("bin")
	if err := os.MkdirAll(bin, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(bin, "repo-render"), []byte("#!/bin/sh\necho \"$*\" >> "+e.p("renders")+"\n"), 0o755); err != nil {
		t.Fatal(err)
	}
	t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
	t.Setenv("GIT_CONFIG_GLOBAL", e.p("nogitconfig"))
	t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
	e.write("etc/repo-render.yaml", "host: code.example.com\n# Where things are.\ngit_dir: "+e.p("git")+"   # the repositories\nhtdocs: /x\nstate_dir: /z\n\nsections:\n  - name: tools\n    title: Tools\n  - name: libs\n    title: Libraries\n")
	if err := os.MkdirAll(e.p("git"), 0o755); err != nil {
		t.Fatal(err)
	}
	e.mirror = e.p("mirror.git")
	e.git("init", "-q", "--bare", e.mirror)
	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) read(name string) string {
	b, _ := os.ReadFile(e.p(name))
	return string(b)
}

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

func (e *env) add(o Options) (string, error) {
	e.t.Helper()
	cfg, err := config.Load(e.cfg)
	if err != nil {
		e.t.Fatal(err)
	}
	newRepo, err := filepath.Abs("../../contrib/new-repo")
	if err != nil {
		e.t.Fatal(err)
	}
	o.Keys, o.KnownHosts, o.NewRepo, o.Renderer = e.keys, e.p("known_hosts"), newRepo, e.p("bin/repo-render")
	var out bytes.Buffer
	err = Add(&out, cfg, o)
	return out.String(), err
}

func (e *env) info(repo string) string {
	e.t.Helper()
	cfg, err := config.Load(e.cfg)
	if err != nil {
		e.t.Fatal(err)
	}
	var out bytes.Buffer
	if err := Info(&out, cfg, repo, e.keys); err != nil {
		e.t.Fatal(err)
	}
	return out.String()
}

func TestAdd(t *testing.T) {
	e := newEnv(t)
	before := e.read("etc/repo-render.yaml")
	o := Options{Repo: "proj", Branch: "trunk", Section: new("tools"), Tags: []string{"go", "two words"}, Description: "My project", Mirrors: []string{e.mirror}}

	out, err := e.add(Options{Repo: "proj", Branch: "trunk", Section: new("tools"), Mirrors: []string{e.mirror}, DryRun: true})
	if err != nil {
		t.Fatalf("dry run: %v\n%s", err, out)
	}
	if !strings.Contains(out, "would create "+e.p("git/proj.git")) || !strings.Contains(out, "would make its mirror key") {
		t.Errorf("dry run said:\n%s", out)
	}
	if _, err := os.Stat(e.p("git/proj.git")); err == nil || e.read("etc/repo-render.yaml") != before {
		t.Fatal("a dry run changed something")
	}

	out, err = e.add(o)
	if err != nil {
		t.Fatalf("add: %v\n%s", err, 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.read("git/proj.git/description"); got != "My project\n" {
		t.Errorf("description = %q", got)
	}
	if got := e.git("-C", dir, "config", "repo-render.mirror"); got != e.mirror {
		t.Errorf("mirror = %q", got)
	}
	key := filepath.Join(e.keys, "proj")
	if got := e.git("-C", dir, "config", "core.sshCommand"); got != SSHCommand(key) {
		t.Errorf("core.sshCommand = %q", got)
	}
	pub := e.read("etc/keys/proj.pub")
	if !strings.HasPrefix(pub, "ssh-ed25519 ") {
		t.Errorf("no key: %q", pub)
	}
	if got := e.git("-C", dir, "config", "repo-render.section"); got != "tools" {
		t.Errorf("section = %q", got)
	}
	if got := e.git("-C", dir, "config", "--get-all", "repo-render.tag"); got != "go\ntwo words" {
		t.Errorf("tags = %q", got)
	}
	if e.read("etc/repo-render.yaml") != before {
		t.Error("the config was written")
	}

	// No commits yet, so nothing to push, which the hook says is a failure.
	if err := Mirror(&bytes.Buffer{}, mustLoad(t, e.cfg), "proj", false); err == nil {
		t.Error("mirror with nothing to push succeeded")
	}
	work := e.p("work")
	e.git("init", "-q", "-b", "trunk", work)
	e.git("-C", work, "-c", "user.name=T", "-c", "user.email=t@e", "commit", "-q", "--allow-empty", "-m", "x")
	e.git("-C", work, "push", "-q", dir, "trunk")
	var mout bytes.Buffer
	if err := Mirror(&mout, mustLoad(t, e.cfg), "proj", false); err != nil || !strings.Contains(mout.String(), "mirrored to "+e.mirror) {
		t.Errorf("mirror: %v\n%s", err, mout.String())
	}
	if got, want := e.git("-C", e.mirror, "rev-parse", "trunk"), e.git("-C", work, "rev-parse", "HEAD"); got != want {
		t.Errorf("the mirror's trunk = %s, want %s", got, want)
	}

	info := e.info("proj")
	for _, l := range []string{"exists yes", "head trunk", "current tools", "tag go\ntag two words", "description My project", "mirror " + e.mirror, "pubkey " + strings.TrimSpace(pub), "section tools Tools\nsection libs Libraries"} {
		if !strings.Contains(info, l+"\n") {
			t.Errorf("info has no %q:\n%s", l, info)
		}
	}

	// Again, to move it: the key stays, and nothing else changes.
	out, err = e.add(Options{Repo: "proj", Branch: "trunk", Section: new("libs"), Mirrors: []string{e.mirror}})
	if err != nil {
		t.Fatalf("add again: %v\n%s", err, out)
	}
	if strings.Contains(out, "mirror key") || !strings.Contains(out, "rewire "+dir) {
		t.Errorf("add again said:\n%s", out)
	}
	// It has commits now, so the move is rendered.
	if got := e.read("renders"); !strings.Contains(got, "--config "+e.cfg+" render proj\n") {
		t.Errorf("renders: %q", got)
	}
	if e.read("etc/keys/proj.pub") != pub {
		t.Error("the key changed")
	}
	if got := e.git("-C", dir, "config", "repo-render.section"); got != "libs" {
		t.Errorf("section after a move = %q", got)
	}
	if got := e.git("-C", dir, "config", "--get-all", "repo-render.tag"); got != "go\ntwo words" {
		t.Errorf("tags after a move = %q", got)
	}
	// With nothing to change, nothing is placed or rendered.
	renders := e.read("renders")
	out, err = e.add(Options{Repo: "proj", Branch: "trunk", Section: new("libs"), Tags: []string{"go", "two words"}})
	if err != nil || strings.Contains(out, "put it") || strings.Contains(out, "tags") || e.read("renders") != renders {
		t.Errorf("add with nothing to change: %v\n%s", err, out)
	}
	// Out of every section, and no tags.
	if out, err = e.add(Options{Repo: "proj", Branch: "trunk", Section: new(""), Tags: []string{}}); err != nil {
		t.Fatalf("add to no section: %v\n%s", err, out)
	}
	if section, tags, err := (gitx.Repo{Dir: dir}).Placement(); err != nil || section != "" || tags != nil {
		t.Errorf("placement left: %q %q %v", section, tags, err)
	}
	if !strings.Contains(e.info("proj"), "exists yes\nhead trunk") {
		t.Errorf("info of a repository in no section:\n%s", e.info("proj"))
	}
	// Mirrors, by host, marked if the last push there failed
	e.git("-C", dir, "config", "--add", "repo-render.mirror", "git@github.com:me/proj.git")
	e.write("git/proj.git/"+mirror.FailedFile, "mirror to git@github.com:me/proj.git failed: no key\n")
	var list bytes.Buffer
	if err := List(&list, mustLoad(t, e.cfg)); err != nil || list.String() != "Tools (tools):\nLibraries (libs):\nIn no section, so private:\n    proj -> "+e.mirror+", github.com (last push failed)\n" {
		t.Errorf("list: %v\n%s", err, list.String())
	}
}

func TestAddInfoNew(t *testing.T) {
	e := newEnv(t)
	info := e.info("proj")
	for _, l := range []string{"exists no", "path " + e.p("git/proj.git"), "key " + filepath.Join(e.keys, "proj")} {
		if !strings.Contains(info, l+"\n") {
			t.Errorf("info has no %q:\n%s", l, info)
		}
	}
	if strings.Contains(info, "pubkey") || strings.Contains(info, "current") {
		t.Errorf("info:\n%s", info)
	}
}

func TestAddRefuses(t *testing.T) {
	e := newEnv(t)
	// A repository there already, with another default branch.
	dir := e.p("git/proj.git")
	work := e.p("work")
	e.git("init", "-q", "-b", "main", work)
	e.git("-C", work, "-c", "user.name=T", "-c", "user.email=t@e", "commit", "-q", "--allow-empty", "-m", "x")
	e.git("init", "-q", "--bare", "-b", "main", dir)
	e.git("-C", work, "push", "-q", dir, "main")
	before := e.read("etc/repo-render.yaml")
	for _, c := range []struct {
		o    Options
		want string
	}{
		{Options{Repo: "proj", Branch: "trunk"}, "default branch is main, not trunk"},
		{Options{Repo: "new", Branch: "main", Mirrors: []string{"https://github.com/me/new"}}, "isn't an ssh URL"},
		{Options{Repo: "new", Branch: "main", Mirrors: []string{"--upload-pack=x"}}, "not a usable git URL"},
		{Options{Repo: "new", Branch: "main", Section: new("nope")}, `no section named "nope" (the config has tools, libs)`},
		{Options{Repo: "new", Branch: "main", Tags: []string{" x"}}, "not a usable tag"},
		{Options{Repo: "new", Branch: "main", Tags: []string{"x", "x"}}, "given twice"},
		{Options{Repo: "new.git", Branch: "main"}, "invalid repository name"},
		{Options{Repo: "new", Branch: "a..b"}, "not a usable branch name"},
	} {
		if out, err := e.add(c.o); err == nil || !strings.Contains(err.Error(), c.want) {
			t.Errorf("%+v: err = %v, want %q\n%s", c.o, err, c.want, out)
		}
	}
	if _, err := os.Stat(e.p("git/new.git")); err == nil || e.read("etc/repo-render.yaml") != before {
		t.Error("a refused add changed something")
	}
}

// An empty repository there already gets the branch as its default.
func TestAddEmpty(t *testing.T) {
	e := newEnv(t)
	dir := e.p("git/proj.git")
	e.git("init", "-q", "--bare", "-b", "main", dir)
	if out, err := e.add(Options{Repo: "proj", Branch: "trunk"}); err != nil {
		t.Fatalf("add: %v\n%s", err, out)
	}
	if got := e.git("-C", dir, "symbolic-ref", "HEAD"); got != "refs/heads/trunk" {
		t.Errorf("HEAD = %s", got)
	}
}

func mustLoad(t *testing.T, path string) *config.Config {
	t.Helper()
	cfg, err := config.Load(path)
	if err != nil {
		t.Fatal(err)
	}
	return cfg
}