Download
225 lines 6.9 KiBGoModified 2026-10-27 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
package render_test

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"code.bruce-hill.com/repo-renderer/internal/render"
)

func TestPubExportIsCloneable(t *testing.T) {
	e := newEnv(t, "proj", "")
	e.seed()
	e.render()
	e.git(e.work, "tag", "v1")
	e.git(e.work, "push", "-q", "origin", "v1")
	if err := e.site.ExportPub("proj", "main"); err != nil {
		t.Fatal(err)
	}
	pub := e.site.Lay.PubDir("proj")
	for _, f := range []string{"HEAD", "info/refs", "objects/info/packs", "refs/heads/main", "refs/tags/v1"} {
		if _, err := os.Stat(filepath.Join(pub, f)); err != nil {
			t.Errorf("pub missing %s", f)
		}
	}
	for _, f := range []string{"config", "hooks", "description"} {
		if _, err := os.Stat(filepath.Join(pub, f)); err == nil {
			t.Errorf("pub leaks %s", f)
		}
	}
	refs := mustReadFile(t, filepath.Join(pub, "info", "refs"))
	mustContain(t, "info/refs", refs, "refs/heads/main", "refs/tags/v1")
	clone := t.TempDir()
	e.git("", "clone", "-q", "file://"+pub, filepath.Join(clone, "c"))
	if _, err := os.Stat(filepath.Join(clone, "c", "src", "main.c")); err != nil {
		t.Error("clone from pub export is missing files")
	}

	// A branch that is pushed but not exported must not leak through the
	// pack: the hook accepts pushes to any refs/heads/*.
	e.git(e.work, "checkout", "-q", "-b", "wip")
	e.write("secret.txt", "do not publish\n")
	e.commit("wip")
	e.git(e.work, "push", "-q", "origin", "wip")
	secret := strings.TrimSpace(e.git(e.work, "rev-parse", "HEAD:secret.txt"))
	e.git(e.work, "checkout", "-q", "main")
	// Make it the repository's only pack, as git gc would.
	e.git(e.site.Lay.GitDir("proj"), "repack", "-a", "-d", "-q")
	if err := e.site.ExportPub("proj", "main"); err != nil {
		t.Fatal(err)
	}
	packs := packIndexes(t, pub)
	if len(packs) != 1 {
		t.Fatalf("expected one pack in pub, got %v", packs)
	}
	listing := e.git("", "verify-pack", "-v", packs[0])
	if strings.Contains(listing, secret) {
		t.Error("pub pack contains an object only reachable from a private branch")
	}
	if !strings.Contains(listing, strings.TrimSpace(e.git(e.work, "rev-parse", "main:src/main.c"))) {
		t.Error("pub pack is missing an exported object")
	}
	// Nothing exported changed: the pack is reused, not rebuilt.
	before := mustStat(t, packs[0])
	time.Sleep(20 * time.Millisecond)
	if err := e.site.ExportPub("proj", "main"); err != nil {
		t.Fatal(err)
	}
	after := mustStat(t, packs[0])
	if !after.ModTime().Equal(before.ModTime()) {
		t.Error("unchanged export rebuilt the pack")
	}
	// The export still clones, and only has main.
	clone2 := filepath.Join(t.TempDir(), "c2")
	e.git("", "clone", "-q", "file://"+pub, clone2)
	if out := e.git(clone2, "branch", "-r"); strings.Contains(out, "wip") {
		t.Errorf("clone sees private branch: %s", out)
	}
}

func TestPubExportGrowsIncrementally(t *testing.T) {
	e := newEnv(t, "proj", "")
	e.seed()
	e.render()
	pub := e.site.Lay.PubDir("proj")
	export := func() []string {
		t.Helper()
		if err := e.site.ExportPub("proj", "main"); err != nil {
			t.Fatal(err)
		}
		idx := packIndexes(t, pub)
		list := mustReadFile(t, filepath.Join(pub, "objects", "info", "packs"))
		if n := strings.Count(list, "P pack-"); n != len(idx) {
			t.Fatalf("objects/info/packs lists %d packs, %d on disk:\n%s", n, len(idx), list)
		}
		return idx
	}
	inPub := func(sha string) bool {
		for _, idx := range export() {
			if strings.Contains(e.git("", "verify-pack", "-v", idx), sha) {
				return true
			}
		}
		return false
	}
	// Clones over dumb HTTP, as httpd serves it, and checks out file.
	srv := httptest.NewServer(http.FileServer(http.Dir(pub)))
	defer srv.Close()
	cloneHas := func(file string) {
		t.Helper()
		dst := filepath.Join(t.TempDir(), "c")
		e.git("", "clone", "-q", srv.URL, dst)
		if _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(file))); err != nil {
			t.Errorf("dumb-HTTP clone is missing %s", file)
		}
	}
	if n := len(export()); n != 1 {
		t.Fatalf("first export: %d packs, want 1", n)
	}

	// Fast-forwards and a new tag add a pack each; the old ones stay.
	e.write("one.txt", "one\n")
	e.commit("one")
	e.push(false)
	if n := len(export()); n != 2 {
		t.Fatalf("after a fast-forward: %d packs, want 2", n)
	}
	e.git(e.work, "tag", "-a", "-m", "v1", "v1")
	e.git(e.work, "push", "-q", "origin", "v1")
	if n := len(export()); n != 3 {
		t.Fatalf("after a new tag: %d packs, want 3", n)
	}
	cloneHas("one.txt")

	// A rewound branch rebuilds one pack, without the abandoned commit.
	e.write("oops.txt", "oops\n")
	e.commit("oops")
	e.push(false)
	oops := strings.TrimSpace(e.git(e.work, "rev-parse", "HEAD:oops.txt"))
	if !inPub(oops) {
		t.Fatal("pushed object not exported")
	}
	e.git(e.work, "reset", "-q", "--hard", "HEAD~")
	e.push(true)
	if n := len(export()); n != 1 {
		t.Fatalf("after a force push: %d packs, want 1", n)
	}
	if inPub(oops) {
		t.Error("export still holds an object only the rewound commit reached")
	}
	cloneHas("one.txt")

	// So does a moved tag, and so does reaching maxPubPacks.
	e.write("two.txt", "two\n")
	e.commit("two")
	e.push(false)
	if n := len(export()); n != 2 {
		t.Fatalf("after a fast-forward: %d packs, want 2", n)
	}
	e.git(e.work, "tag", "-f", "-a", "-m", "v1 again", "v1")
	e.git(e.work, "push", "-q", "-f", "origin", "v1")
	if n := len(export()); n != 1 {
		t.Fatalf("after moving a tag: %d packs, want 1", n)
	}
	for i := 2; i <= render.MaxPubPacks; i++ {
		e.write("n.txt", fmt.Sprintf("%d\n", i))
		e.commit(fmt.Sprint(i))
		e.push(false)
		if n := len(export()); n != i {
			t.Fatalf("after %d fast-forwards: %d packs, want %d", i-1, n, i)
		}
	}
	e.write("n.txt", "last\n")
	e.commit("last")
	e.push(false)
	if n := len(export()); n != 1 {
		t.Fatalf("at MaxPubPacks: %d packs, want 1", n)
	}
	cloneHas("n.txt")
}

// TestCloneOverHTTP clones from a plain file server of htdocs: the export
// must live inside the served root, and git must find nothing to complain
// about.
func TestCloneOverHTTP(t *testing.T) {
	e := newEnv(t, "proj", "")
	e.seed()
	e.render()
	if err := e.site.ExportPub("proj", "main"); err != nil {
		t.Fatal(err)
	}
	srv := httptest.NewServer(http.FileServer(http.Dir(e.site.Lay.Htdocs)))
	defer srv.Close()
	dst := filepath.Join(t.TempDir(), "c")
	if out := e.git("", "clone", "-q", srv.URL+"/git/proj.git", dst); out != "" {
		t.Errorf("clone over http printed:\n%s", out)
	}
	if b, err := os.ReadFile(filepath.Join(dst, "src", "main.c")); err != nil || !strings.Contains(string(b), "main") {
		t.Errorf("clone over http: %v %q", err, b)
	}
}

// packIndexes lists the export's pack indexes. It reads the directory
// rather than globbing its path, which holds the test's temporary
// directory and so whatever characters TMPDIR has.
func packIndexes(t *testing.T, pub string) []string {
	t.Helper()
	dir := filepath.Join(pub, "objects", "pack")
	ents, err := os.ReadDir(dir)
	if err != nil {
		t.Fatal(err)
	}
	var idx []string
	for _, e := range ents {
		if strings.HasSuffix(e.Name(), ".idx") {
			idx = append(idx, filepath.Join(dir, e.Name()))
		}
	}
	return idx
}