Gitfed
bastien-mrq/gitfed / internal / gitexec / gitexec_test.go
package gitexec

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

func run(t *testing.T, dir, name string, args ...string) {
	t.Helper()
	cmd := exec.Command(name, args...)
	cmd.Dir = dir
	cmd.Env = append(os.Environ(),
		"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
		"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
	)
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("%s %v: %v\n%s", name, args, err, out)
	}
}

func TestReadFileAtHEADAndListTags(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}

	work := filepath.Join(tmp, "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "init", "-q", "-b", "main")
	if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# Hello\n"), 0644); err != nil {
		t.Fatal(err)
	}
	if err := os.Mkdir(filepath.Join(work, "cmd"), 0755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(work, "cmd", "main.go"), []byte("package main\n"), 0644); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "add", "README.md", "cmd/main.go")
	run(t, work, "git", "commit", "-q", "-m", "initial")
	run(t, work, "git", "tag", "v1.0.0")
	run(t, work, "git", "remote", "add", "origin", barePath)
	run(t, work, "git", "push", "-q", "origin", "main", "--tags")

	content, found, err := ReadFileAtHEAD(barePath, "README.md")
	if err != nil {
		t.Fatalf("ReadFileAtHEAD: %v", err)
	}
	if !found {
		t.Fatal("README.md should be found at HEAD")
	}
	if content != "# Hello\n" {
		t.Fatalf("content = %q, want %q", content, "# Hello\n")
	}

	_, found, err = ReadFileAtHEAD(barePath, "LICENSE")
	if err != nil {
		t.Fatalf("ReadFileAtHEAD(LICENSE): %v", err)
	}
	if found {
		t.Fatal("LICENSE should not be found (never committed)")
	}

	tags, err := ListTags(barePath)
	if err != nil {
		t.Fatalf("ListTags: %v", err)
	}
	if len(tags) != 1 || tags[0] != "v1.0.0" {
		t.Fatalf("tags = %v, want [v1.0.0]", tags)
	}

	root, found, err := ListTree(barePath, "")
	if err != nil {
		t.Fatalf("ListTree root: %v", err)
	}
	if !found {
		t.Fatal("root tree should be found")
	}
	if len(root) != 2 || root[0].Name != "cmd" || root[0].Type != "tree" || root[1].Name != "README.md" || root[1].Type != "blob" {
		t.Fatalf("root tree = %+v, want [cmd(tree) README.md(blob)]", root)
	}

	sub, found, err := ListTree(barePath, "cmd")
	if err != nil {
		t.Fatalf("ListTree cmd: %v", err)
	}
	if !found || len(sub) != 1 || sub[0].Name != "main.go" || sub[0].Type != "blob" {
		t.Fatalf("cmd tree = %+v found=%v, want [main.go(blob)]", sub, found)
	}

	_, found, err = ListTree(barePath, "nope")
	if err != nil {
		t.Fatalf("ListTree nonexistent path: %v", err)
	}
	if found {
		t.Fatal("nonexistent path should not be found")
	}
}

func TestReadFileAtHEADEmptyRepo(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}

	_, found, err := ReadFileAtHEAD(barePath, "README.md")
	if err != nil {
		t.Fatalf("ReadFileAtHEAD on empty repo: %v", err)
	}
	if found {
		t.Fatal("empty repo should never report a file as found")
	}

	tags, err := ListTags(barePath)
	if err != nil {
		t.Fatalf("ListTags on empty repo: %v", err)
	}
	if len(tags) != 0 {
		t.Fatalf("tags = %v, want none", tags)
	}

	_, found, err = ListTree(barePath, "")
	if err != nil {
		t.Fatalf("ListTree on empty repo: %v", err)
	}
	if found {
		t.Fatal("empty repo should never report a tree as found")
	}
}

// TestPushToMismatchedDefaultBranch reproduces a real bug: InitBareRepo
// points HEAD at refs/heads/main, but nothing stops a client from pushing
// under a different branch name (an older git installation still defaults
// to "master", or someone names their trunk something else entirely). HEAD
// then never resolves even though the repo plainly has content — this is
// the exact scenario that made every freshly pushed repo 404 in gitfed-web.
// TestDefaultBranchNameDanglingHeadPrefersMainOrMaster reproduces the real
// case found live: a bare repo whose HEAD points at "main" (InitBareRepo's
// default) but which only ever had "master" pushed to it — with more than
// one branch present so the old single-branch fallback in resolveDefaultRef
// didn't apply and the repo looked empty when browsing it.
func TestDefaultBranchNameDanglingHeadPrefersMainOrMaster(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}

	work := filepath.Join(tmp, "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "init", "-q", "-b", "master") // never "main"
	if err := os.WriteFile(filepath.Join(work, "f"), []byte("x"), 0644); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "add", "f")
	run(t, work, "git", "commit", "-q", "-m", "initial")
	run(t, work, "git", "remote", "add", "origin", barePath)
	run(t, work, "git", "push", "-q", "origin", "master")
	// A second branch, so the old "exactly one branch" fallback doesn't
	// mask what's being tested here.
	run(t, work, "git", "checkout", "-q", "-b", "some-feature")
	run(t, work, "git", "push", "-q", "origin", "some-feature")

	if exec.Command("git", "--git-dir="+barePath, "rev-parse", "--verify", "-q", "HEAD").Run() == nil {
		t.Fatal("test setup invalid: HEAD unexpectedly resolves, mismatch not reproduced")
	}

	branch, ok := DefaultBranchName(barePath)
	if !ok || branch != "master" {
		t.Fatalf("DefaultBranchName: got %q ok=%v, want \"master\" (preferred over the arbitrary \"some-feature\")", branch, ok)
	}

	root, found, err := ListTree(barePath, "")
	if err != nil {
		t.Fatalf("ListTree: %v", err)
	}
	if !found || len(root) != 1 || root[0].Name != "f" {
		t.Fatalf("ListTree: found=%v root=%+v, want [f] — the repo must not look empty", found, root)
	}
}

// TestDefaultBranchNameDanglingHeadNoConventionalName covers the remaining
// case: HEAD dangling, several branches, and none named "main" or
// "master" — an arbitrary but deterministic pick beats reporting the repo
// as empty.
func TestDefaultBranchNameDanglingHeadNoConventionalName(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}

	work := filepath.Join(tmp, "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "init", "-q", "-b", "zeta")
	if err := os.WriteFile(filepath.Join(work, "f"), []byte("x"), 0644); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "add", "f")
	run(t, work, "git", "commit", "-q", "-m", "initial")
	run(t, work, "git", "remote", "add", "origin", barePath)
	run(t, work, "git", "push", "-q", "origin", "zeta")
	run(t, work, "git", "checkout", "-q", "-b", "alpha")
	run(t, work, "git", "push", "-q", "origin", "alpha")

	branch, ok := DefaultBranchName(barePath)
	if !ok || branch != "alpha" {
		t.Fatalf("DefaultBranchName: got %q ok=%v, want \"alpha\" (alphabetically first of alpha/zeta)", branch, ok)
	}
}

func TestPushToMismatchedDefaultBranch(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}

	work := filepath.Join(tmp, "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "init", "-q", "-b", "trunk") // deliberately not "main"
	if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# Hello\n"), 0644); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "add", "README.md")
	run(t, work, "git", "commit", "-q", "-m", "initial")
	run(t, work, "git", "remote", "add", "origin", barePath)
	run(t, work, "git", "push", "-q", "origin", "trunk")

	// Sanity check this test actually exercises the mismatch: HEAD must
	// NOT resolve on its own, or this isn't testing the fallback at all.
	if exec.Command("git", "--git-dir="+barePath, "rev-parse", "--verify", "-q", "HEAD").Run() == nil {
		t.Fatal("test setup invalid: HEAD unexpectedly resolves, mismatch not reproduced")
	}

	content, found, err := ReadFileAtHEAD(barePath, "README.md")
	if err != nil {
		t.Fatalf("ReadFileAtHEAD: %v", err)
	}
	if !found || content != "# Hello\n" {
		t.Fatalf("ReadFileAtHEAD: found=%v content=%q, want found with content", found, content)
	}

	root, found, err := ListTree(barePath, "")
	if err != nil {
		t.Fatalf("ListTree: %v", err)
	}
	if !found || len(root) != 1 || root[0].Name != "README.md" {
		t.Fatalf("ListTree: found=%v root=%+v, want [README.md]", found, root)
	}

	branch, ok := DefaultBranchName(barePath)
	if !ok || branch != "trunk" {
		t.Fatalf("DefaultBranchName: got %q ok=%v, want \"trunk\"", branch, ok)
	}
}

// TestCloneMirrorFixesDanglingHead reproduces the real bug found on
// production: a mirrored source whose own HEAD points at a branch that
// doesn't actually exist among what got mirrored (e.g. renamed master to
// main upstream, HEAD metadata never caught up) produces a bare repo git
// upload-pack refuses to advertise a symref for, breaking `git clone` for
// every downstream client even though the content is perfectly fine.
// CloneMirror must repoint HEAD at the one real branch, not just leave it
// dangling for resolveDefaultRef's read-time fallback to paper over.
func TestCloneMirrorFixesDanglingHead(t *testing.T) {
	tmp := t.TempDir()

	sourcePath := filepath.Join(tmp, "source.git")
	if err := InitBareRepo(sourcePath); err != nil {
		t.Fatalf("init source bare repo: %v", err)
	}

	work := filepath.Join(tmp, "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "init", "-q", "-b", "trunk") // deliberately not "main"
	if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# Hello\n"), 0644); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "add", "README.md")
	run(t, work, "git", "commit", "-q", "-m", "initial")
	run(t, work, "git", "remote", "add", "origin", sourcePath)
	run(t, work, "git", "push", "-q", "origin", "trunk")

	// Sanity check the source itself has the dangling-HEAD problem, or this
	// test isn't exercising the fix at all.
	if exec.Command("git", "--git-dir="+sourcePath, "rev-parse", "--verify", "-q", "HEAD").Run() == nil {
		t.Fatal("test setup invalid: source HEAD unexpectedly resolves, mismatch not reproduced")
	}

	mirrorPath := filepath.Join(tmp, "mirror.git")
	if err := CloneMirror(context.Background(), mirrorPath, sourcePath); err != nil {
		t.Fatalf("CloneMirror: %v", err)
	}

	if err := exec.Command("git", "--git-dir="+mirrorPath, "rev-parse", "--verify", "-q", "HEAD").Run(); err != nil {
		t.Fatal("mirrored repo's HEAD still doesn't resolve — a real git client's `git clone` against this repo would fail")
	}

	branch, ok := DefaultBranchName(mirrorPath)
	if !ok || branch != "trunk" {
		t.Fatalf("DefaultBranchName: got %q ok=%v, want \"trunk\"", branch, ok)
	}
}

func TestCountCommits(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}

	count, found, err := CountCommits(barePath)
	if err != nil {
		t.Fatalf("CountCommits on empty repo: %v", err)
	}
	if found {
		t.Fatalf("empty repo should report found=false, got count=%d", count)
	}

	work := filepath.Join(tmp, "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "init", "-q", "-b", "main")
	for i := 0; i < 3; i++ {
		if err := os.WriteFile(filepath.Join(work, "f"), []byte{byte('a' + i)}, 0644); err != nil {
			t.Fatal(err)
		}
		run(t, work, "git", "add", "f")
		run(t, work, "git", "commit", "-q", "-m", "commit")
	}
	run(t, work, "git", "remote", "add", "origin", barePath)
	run(t, work, "git", "push", "-q", "origin", "main")

	count, found, err = CountCommits(barePath)
	if err != nil {
		t.Fatalf("CountCommits: %v", err)
	}
	if !found || count != 3 {
		t.Fatalf("CountCommits: found=%v count=%d, want found=true count=3", found, count)
	}
}

// TestListTreeAtRefAndReadFileAtRef reproduces the actual use case: a repo
// with two branches whose content genuinely differs, confirming
// ListTreeAtRef/ReadFileAtRef read the requested branch — not just
// whatever the default branch happens to be (the bug it'd be easy to
// introduce by accidentally calling resolveDefaultRef internally again).
func TestListTreeAtRefAndReadFileAtRef(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}

	work := filepath.Join(tmp, "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "init", "-q", "-b", "main")
	if err := os.WriteFile(filepath.Join(work, "shared.txt"), []byte("on main\n"), 0644); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "add", "shared.txt")
	run(t, work, "git", "commit", "-q", "-m", "main commit")
	run(t, work, "git", "remote", "add", "origin", barePath)
	run(t, work, "git", "push", "-q", "origin", "main")

	run(t, work, "git", "checkout", "-q", "-b", "feature")
	if err := os.WriteFile(filepath.Join(work, "shared.txt"), []byte("on feature\n"), 0644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(work, "only-on-feature.txt"), []byte("x"), 0644); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "add", "shared.txt", "only-on-feature.txt")
	run(t, work, "git", "commit", "-q", "-m", "feature commit")
	run(t, work, "git", "push", "-q", "origin", "feature")

	// ListTreeAtRef on "feature" sees the file that only exists there.
	entries, found, err := ListTreeAtRef(barePath, "feature", "")
	if err != nil {
		t.Fatalf("ListTreeAtRef(feature): %v", err)
	}
	if !found {
		t.Fatal("expected found=true for feature branch root")
	}
	var sawFeatureFile bool
	for _, e := range entries {
		if e.Name == "only-on-feature.txt" {
			sawFeatureFile = true
		}
	}
	if !sawFeatureFile {
		t.Fatalf("ListTreeAtRef(feature) = %+v, expected only-on-feature.txt", entries)
	}

	// ListTree (default branch, "main") must NOT see it.
	entries, _, err = ListTree(barePath, "")
	if err != nil {
		t.Fatalf("ListTree: %v", err)
	}
	for _, e := range entries {
		if e.Name == "only-on-feature.txt" {
			t.Fatalf("ListTree (default branch) unexpectedly saw feature-only file: %+v", entries)
		}
	}

	// ReadFileAtRef reads the version of shared.txt specific to each branch.
	content, found, err := ReadFileAtRef(barePath, "feature", "shared.txt")
	if err != nil || !found {
		t.Fatalf("ReadFileAtRef(feature): found=%v err=%v", found, err)
	}
	if content != "on feature\n" {
		t.Fatalf("ReadFileAtRef(feature) = %q, want %q", content, "on feature\n")
	}

	content, found, err = ReadFileAtRef(barePath, "main", "shared.txt")
	if err != nil || !found {
		t.Fatalf("ReadFileAtRef(main): found=%v err=%v", found, err)
	}
	if content != "on main\n" {
		t.Fatalf("ReadFileAtRef(main) = %q, want %q", content, "on main\n")
	}
}

func TestArchive(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}

	work := filepath.Join(tmp, "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "init", "-q", "-b", "main")
	if err := os.WriteFile(filepath.Join(work, "hello.txt"), []byte("hi\n"), 0644); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "add", "hello.txt")
	run(t, work, "git", "commit", "-q", "-m", "initial")
	run(t, work, "git", "tag", "v1.0.0")
	run(t, work, "git", "remote", "add", "origin", barePath)
	run(t, work, "git", "push", "-q", "origin", "main", "--tags")

	for _, format := range []string{"tar.gz", "zip"} {
		t.Run(format, func(t *testing.T) {
			for _, ref := range []string{"main", "v1.0.0"} {
				data, err := Archive(barePath, ref, format)
				if err != nil {
					t.Fatalf("Archive(%s, %s): %v", ref, format, err)
				}
				if len(data) == 0 {
					t.Fatalf("Archive(%s, %s) returned no data", ref, format)
				}
				// Real magic-byte check, not just "non-empty" — gzip
				// starts 0x1f 0x8b, zip starts "PK".
				switch format {
				case "tar.gz":
					if len(data) < 2 || data[0] != 0x1f || data[1] != 0x8b {
						t.Errorf("Archive(%s, tar.gz) doesn't look like gzip: % x", ref, data[:min(4, len(data))])
					}
				case "zip":
					if len(data) < 2 || data[0] != 'P' || data[1] != 'K' {
						t.Errorf("Archive(%s, zip) doesn't look like a zip: % x", ref, data[:min(4, len(data))])
					}
				}
			}
		})
	}
}

func TestArchiveUnsupportedFormat(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}
	if _, err := Archive(barePath, "main", "tar.bz2"); err == nil {
		t.Fatal("expected an error for an unsupported format")
	}
}

func TestCountContributors(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}

	count, found, err := CountContributors(barePath)
	if err != nil {
		t.Fatalf("CountContributors on empty repo: %v", err)
	}
	if found {
		t.Fatalf("empty repo should report found=false, got count=%d", count)
	}

	work := filepath.Join(tmp, "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "init", "-q", "-b", "main")
	commitAs := func(name, email, file string) {
		if err := os.WriteFile(filepath.Join(work, file), []byte("x"), 0644); err != nil {
			t.Fatal(err)
		}
		cmd := exec.Command("git", "add", file)
		cmd.Dir = work
		if out, err := cmd.CombinedOutput(); err != nil {
			t.Fatalf("git add: %v\n%s", err, out)
		}
		cmd = exec.Command("git", "commit", "-q", "-m", "commit by "+name,
			"--author="+name+" <"+email+">")
		cmd.Dir = work
		cmd.Env = append(os.Environ(),
			"GIT_AUTHOR_NAME="+name, "GIT_AUTHOR_EMAIL="+email,
			"GIT_COMMITTER_NAME="+name, "GIT_COMMITTER_EMAIL="+email,
		)
		if out, err := cmd.CombinedOutput(); err != nil {
			t.Fatalf("git commit: %v\n%s", err, out)
		}
	}
	commitAs("Alice", "alice@example.com", "a.txt")
	commitAs("Bob", "bob@example.com", "b.txt")
	commitAs("Alice", "alice@example.com", "a2.txt") // same author again
	run(t, work, "git", "remote", "add", "origin", barePath)
	run(t, work, "git", "push", "-q", "origin", "main")

	count, found, err = CountContributors(barePath)
	if err != nil {
		t.Fatalf("CountContributors: %v", err)
	}
	if !found || count != 2 {
		t.Fatalf("CountContributors: found=%v count=%d, want found=true count=2 (Alice x2, Bob x1)", found, count)
	}
}

func TestDominantLanguage(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}

	lang, found, err := DominantLanguage(barePath)
	if err != nil {
		t.Fatalf("DominantLanguage on empty repo: %v", err)
	}
	if found {
		t.Fatalf("empty repo should report found=false, got lang=%q", lang)
	}

	work := filepath.Join(tmp, "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "init", "-q", "-b", "main")
	files := []string{"main.go", "util.go", "helper.go", "README.md", "script.py"}
	for _, f := range files {
		if err := os.WriteFile(filepath.Join(work, f), []byte("x"), 0644); err != nil {
			t.Fatal(err)
		}
	}
	run(t, work, "git", "add", ".")
	run(t, work, "git", "commit", "-q", "-m", "initial")
	run(t, work, "git", "remote", "add", "origin", barePath)
	run(t, work, "git", "push", "-q", "origin", "main")

	lang, found, err = DominantLanguage(barePath)
	if err != nil {
		t.Fatalf("DominantLanguage: %v", err)
	}
	if !found || lang != "Go" {
		t.Fatalf("DominantLanguage: found=%v lang=%q, want found=true lang=\"Go\" (3 .go files vs 1 .py, README.md unrecognized)", found, lang)
	}
}

func TestDominantLanguageNoRecognizedFiles(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}
	work := filepath.Join(tmp, "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "init", "-q", "-b", "main")
	if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("x"), 0644); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "add", ".")
	run(t, work, "git", "commit", "-q", "-m", "initial")
	run(t, work, "git", "remote", "add", "origin", barePath)
	run(t, work, "git", "push", "-q", "origin", "main")

	lang, found, err := DominantLanguage(barePath)
	if err != nil {
		t.Fatalf("DominantLanguage: %v", err)
	}
	if found {
		t.Fatalf("expected no recognized language, got %q", lang)
	}
}

func TestDiskUsage(t *testing.T) {
	tmp := t.TempDir()
	if err := os.WriteFile(filepath.Join(tmp, "a"), make([]byte, 100), 0644); err != nil {
		t.Fatal(err)
	}
	sub := filepath.Join(tmp, "objects", "pack")
	if err := os.MkdirAll(sub, 0755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(sub, "b"), make([]byte, 250), 0644); err != nil {
		t.Fatal(err)
	}
	// A subdirectory itself has some size on most filesystems, but
	// DiskUsage must count regular files only — otherwise the total would
	// vary by filesystem/OS instead of being the exact byte count below.
	got, err := DiskUsage(tmp)
	if err != nil {
		t.Fatalf("DiskUsage: %v", err)
	}
	if got != 350 {
		t.Fatalf("DiskUsage = %d, want 350 (100 + 250 across a nested dir)", got)
	}
}

func TestDiskUsageMissingPath(t *testing.T) {
	if _, err := DiskUsage(filepath.Join(t.TempDir(), "does-not-exist")); err == nil {
		t.Fatal("DiskUsage on a missing path: want an error, got nil")
	}
}

func TestListCommitsPage(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}

	work := filepath.Join(tmp, "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "init", "-q", "-b", "main")
	// 5 commits, subjects "commit 0" (oldest) .. "commit 4" (newest).
	for i := 0; i < 5; i++ {
		if err := os.WriteFile(filepath.Join(work, "f"), []byte{byte(i)}, 0644); err != nil {
			t.Fatal(err)
		}
		run(t, work, "git", "add", "f")
		run(t, work, "git", "commit", "-q", "-m", fmt.Sprintf("commit %d", i))
	}
	run(t, work, "git", "remote", "add", "origin", barePath)
	run(t, work, "git", "push", "-q", "origin", "main")

	// Page 1 (limit 2, offset 0): newest two, most-recent-first.
	page1, found, err := ListCommitsPage(barePath, 2, 0)
	if err != nil || !found {
		t.Fatalf("page1: found=%v err=%v", found, err)
	}
	if len(page1) != 2 || page1[0].Subject != "commit 4" || page1[1].Subject != "commit 3" {
		t.Fatalf("page1 = %+v, want [commit 4, commit 3]", subjectsOf(page1))
	}

	// Page 2 (limit 2, offset 2): next two.
	page2, found, err := ListCommitsPage(barePath, 2, 2)
	if err != nil || !found {
		t.Fatalf("page2: found=%v err=%v", found, err)
	}
	if len(page2) != 2 || page2[0].Subject != "commit 2" || page2[1].Subject != "commit 1" {
		t.Fatalf("page2 = %+v, want [commit 2, commit 1]", subjectsOf(page2))
	}

	// Page 3 (limit 2, offset 4): only the oldest one left.
	page3, found, err := ListCommitsPage(barePath, 2, 4)
	if err != nil || !found {
		t.Fatalf("page3: found=%v err=%v", found, err)
	}
	if len(page3) != 1 || page3[0].Subject != "commit 0" {
		t.Fatalf("page3 = %+v, want [commit 0]", subjectsOf(page3))
	}

	// Past the end: found=true (repo has commits), just an empty page.
	page4, found, err := ListCommitsPage(barePath, 2, 10)
	if err != nil || !found {
		t.Fatalf("page4: found=%v err=%v", found, err)
	}
	if len(page4) != 0 {
		t.Fatalf("page4 = %+v, want none", subjectsOf(page4))
	}

	// ListCommits (no offset) must match ListCommitsPage(..., 0).
	plain, _, err := ListCommits(barePath, 2)
	if err != nil {
		t.Fatalf("ListCommits: %v", err)
	}
	if len(plain) != 2 || plain[0].Subject != page1[0].Subject || plain[1].Subject != page1[1].Subject {
		t.Fatalf("ListCommits = %+v, want same as ListCommitsPage(_, 2, 0) = %+v", subjectsOf(plain), subjectsOf(page1))
	}
}

func subjectsOf(commits []Commit) []string {
	subjects := make([]string, len(commits))
	for i, c := range commits {
		subjects[i] = c.Subject
	}
	return subjects
}

func TestDefaultBranchNameViaHEAD(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}

	work := filepath.Join(tmp, "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "init", "-q", "-b", "main")
	if err := os.WriteFile(filepath.Join(work, "f"), []byte("x"), 0644); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "add", "f")
	run(t, work, "git", "commit", "-q", "-m", "initial")
	run(t, work, "git", "remote", "add", "origin", barePath)
	run(t, work, "git", "push", "-q", "origin", "main")

	branch, ok := DefaultBranchName(barePath)
	if !ok || branch != "main" {
		t.Fatalf("DefaultBranchName: got %q ok=%v, want \"main\"", branch, ok)
	}
}

func TestDefaultBranchNameEmptyRepo(t *testing.T) {
	tmp := t.TempDir()
	barePath := filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}
	if _, ok := DefaultBranchName(barePath); ok {
		t.Fatal("empty repo should not report a default branch")
	}
}

// setupMergeTestRepo builds a bare repo with three branches: main, a
// feature branch that merges into it cleanly, and a feature branch that
// conflicts with a change also made on main after the branch point.
func setupMergeTestRepo(t *testing.T) (barePath string) {
	t.Helper()
	tmp := t.TempDir()
	barePath = filepath.Join(tmp, "repo.git")
	if err := InitBareRepo(barePath); err != nil {
		t.Fatalf("init bare repo: %v", err)
	}

	work := filepath.Join(tmp, "work")
	if err := os.Mkdir(work, 0755); err != nil {
		t.Fatal(err)
	}
	run(t, work, "git", "init", "-q", "-b", "main")
	run(t, work, "git", "remote", "add", "origin", barePath)

	writeAndCommit := func(name, content, msg string) {
		t.Helper()
		if err := os.WriteFile(filepath.Join(work, name), []byte(content), 0644); err != nil {
			t.Fatal(err)
		}
		run(t, work, "git", "add", name)
		run(t, work, "git", "commit", "-q", "-m", msg)
	}

	writeAndCommit("shared.txt", "line one\n", "initial")
	run(t, work, "git", "push", "-q", "origin", "main")

	run(t, work, "git", "checkout", "-q", "-b", "feature-clean")
	writeAndCommit("clean.txt", "new file, no overlap\n", "feature-clean change")
	run(t, work, "git", "push", "-q", "origin", "feature-clean")

	run(t, work, "git", "checkout", "-q", "main")
	run(t, work, "git", "checkout", "-q", "-b", "feature-conflict")
	writeAndCommit("shared.txt", "feature-conflict's version\n", "feature-conflict change")
	run(t, work, "git", "push", "-q", "origin", "feature-conflict")

	run(t, work, "git", "checkout", "-q", "main")
	writeAndCommit("shared.txt", "main's own diverging version\n", "main diverges")
	run(t, work, "git", "push", "-q", "origin", "main")

	return barePath
}

func TestListBranches(t *testing.T) {
	bare := setupMergeTestRepo(t)
	branches, err := ListBranches(bare)
	if err != nil {
		t.Fatalf("ListBranches: %v", err)
	}
	want := []string{"feature-clean", "feature-conflict", "main"}
	if len(branches) != len(want) {
		t.Fatalf("branches = %v, want %v", branches, want)
	}
	for i, b := range branches {
		if b != want[i] {
			t.Fatalf("branches = %v, want %v", branches, want)
		}
	}
}

func TestBranchDiff(t *testing.T) {
	bare := setupMergeTestRepo(t)

	files, diff, truncated, found, err := BranchDiff(bare, "main", "feature-clean")
	if err != nil {
		t.Fatalf("BranchDiff: %v", err)
	}
	if !found {
		t.Fatal("expected found=true for two real branches")
	}
	if truncated {
		t.Fatal("small diff should not be truncated")
	}
	if len(files) != 1 || files[0].Path != "clean.txt" || files[0].Status != "added" {
		t.Fatalf("files = %+v, want one added clean.txt", files)
	}
	if !strings.Contains(diff, "clean.txt") {
		t.Fatalf("diff missing clean.txt: %q", diff)
	}

	_, _, _, found, err = BranchDiff(bare, "main", "does-not-exist")
	if err != nil {
		t.Fatalf("BranchDiff nonexistent branch: %v", err)
	}
	if found {
		t.Fatal("nonexistent source branch should report found=false")
	}
}

func TestCheckMergeable(t *testing.T) {
	bare := setupMergeTestRepo(t)

	result, err := CheckMergeable(bare, "main", "feature-clean")
	if err != nil {
		t.Fatalf("CheckMergeable clean: %v", err)
	}
	if !result.Clean {
		t.Fatalf("feature-clean should merge cleanly, got conflicts: %v", result.ConflictFiles)
	}

	result, err = CheckMergeable(bare, "main", "feature-conflict")
	if err != nil {
		t.Fatalf("CheckMergeable conflict: %v", err)
	}
	if result.Clean {
		t.Fatal("feature-conflict should not merge cleanly")
	}
	if len(result.ConflictFiles) != 1 || result.ConflictFiles[0] != "shared.txt" {
		t.Fatalf("ConflictFiles = %v, want [shared.txt]", result.ConflictFiles)
	}

	// main's own ref must be untouched by a mergeability check.
	tip, err := revParse(bare, "refs/heads/main")
	if err != nil {
		t.Fatal(err)
	}
	branches, err := ListBranches(bare)
	if err != nil || len(branches) != 3 {
		t.Fatalf("branches after CheckMergeable = %v, err %v — checking mergeability must not create/delete branches", branches, err)
	}
	if tip == "" {
		t.Fatal("main should still resolve")
	}
}

func TestMergeBranchesClean(t *testing.T) {
	bare := setupMergeTestRepo(t)

	oldTip, err := revParse(bare, "refs/heads/main")
	if err != nil {
		t.Fatal(err)
	}

	commit, result, err := MergeBranches(bare, "main", "feature-clean", "Merge feature-clean", "Test User", "test@example.com")
	if err != nil {
		t.Fatalf("MergeBranches: %v", err)
	}
	if !result.Clean {
		t.Fatalf("expected clean merge, got conflicts: %v", result.ConflictFiles)
	}
	if commit == "" {
		t.Fatal("expected a merge commit hash")
	}

	newTip, err := revParse(bare, "refs/heads/main")
	if err != nil {
		t.Fatal(err)
	}
	if newTip != commit {
		t.Fatalf("refs/heads/main = %s, want it to point at the merge commit %s", newTip, commit)
	}
	if newTip == oldTip {
		t.Fatal("main did not move")
	}

	content, found, err := ReadFileAtHEAD(bare, "clean.txt")
	if err != nil || !found {
		t.Fatalf("clean.txt should exist on main after merge: found=%v err=%v", found, err)
	}
	if content != "new file, no overlap\n" {
		t.Fatalf("clean.txt content = %q", content)
	}
}

func TestMergeBranchesConflict(t *testing.T) {
	bare := setupMergeTestRepo(t)

	oldTip, err := revParse(bare, "refs/heads/main")
	if err != nil {
		t.Fatal(err)
	}

	commit, result, err := MergeBranches(bare, "main", "feature-conflict", "Merge feature-conflict", "Test User", "test@example.com")
	if err != nil {
		t.Fatalf("MergeBranches: %v", err)
	}
	if result.Clean {
		t.Fatal("expected a conflicted merge")
	}
	if commit != "" {
		t.Fatalf("commit = %q, want empty on conflict", commit)
	}
	if len(result.ConflictFiles) != 1 || result.ConflictFiles[0] != "shared.txt" {
		t.Fatalf("ConflictFiles = %v, want [shared.txt]", result.ConflictFiles)
	}

	newTip, err := revParse(bare, "refs/heads/main")
	if err != nil {
		t.Fatal(err)
	}
	if newTip != oldTip {
		t.Fatal("main must not move when the merge conflicts")
	}

	branches, err := ListBranches(bare)
	if err != nil || len(branches) != 3 {
		t.Fatalf("branches after a conflicted merge attempt = %v, err %v — no stray branch/worktree should be left behind", branches, err)
	}
}

// TestArgumentInjectionIsBlocked is a regression test for a real,
// confirmed vulnerability: git's own porcelain commands (checkout -b,
// branch) refuse to create a ref whose name starts with "-" via
// check-ref-format, but that's only enforced client-side — a raw
// `update-ref` (or an equivalent low-level push) against the bare repo
// happily creates one anyway. Every gitexec function that took a
// hash/branch name as a trailing command-line argument was passing it
// straight to `git`, so a ref literally named e.g. "--output=/some/path"
// got interpreted as git's own -o/--output flag instead of a revision —
// confirmed to make `git diff`/`git show` write attacker-chosen content
// to an attacker-chosen path, reachable by an unauthenticated request to
// the commit-detail page's ?hash= parameter (no push access required at
// all) or by any write collaborator via a merge request's branch fields.
// The fix is `--end-of-options` before every such argument; this test
// proves it holds for every affected entry point.
func TestArgumentInjectionIsBlocked(t *testing.T) {
	bare := setupMergeTestRepo(t)

	mainTip, err := revParse(bare, "refs/heads/main")
	if err != nil {
		t.Fatal(err)
	}

	// A ref name that is also a real git flag which writes to a file —
	// bypasses git's client-side ref-name validation the same way a raw
	// push directly to the bare repo would.
	outFile := filepath.Join(t.TempDir(), "should-not-exist.txt")
	evilRef := "--output=" + outFile
	run(t, bare, "git", "update-ref", "refs/heads/"+evilRef, mainTip)

	assertNoFile := func(step string) {
		t.Helper()
		if _, err := os.Stat(outFile); err == nil {
			t.Fatalf("%s: attacker-controlled file was created at %s — argument injection succeeded", step, outFile)
		}
	}

	// ShowCommit/CommitDiff: the hash comes straight from an HTTP query
	// parameter with no validation that it even looks like a commit hash.
	// evilRef happens to also be a syntactically valid ref name (we just
	// created it), so git resolving it as an ordinary revision is correct,
	// safe behavior — the only thing that must never happen is the file
	// write.
	if _, _, err := CommitDiff(bare, evilRef); err != nil {
		t.Logf("CommitDiff(evilRef): %v (acceptable)", err)
	}
	assertNoFile("CommitDiff")

	if _, _, err := ShowCommit(bare, evilRef); err != nil {
		t.Logf("ShowCommit(evilRef): %v (acceptable)", err)
	}
	assertNoFile("ShowCommit")

	// BranchDiff/CheckMergeable/MergeBranches: the malicious ref is a real
	// branch (per ListBranches), so these take the "found" path rather
	// than erroring outright — what matters is still that nothing gets
	// written to outFile.
	branches, err := ListBranches(bare)
	if err != nil {
		t.Fatal(err)
	}
	if !containsBranchName(branches, evilRef) {
		t.Fatalf("expected %q to be a listed branch (proves check-ref-format was bypassed), got %v", evilRef, branches)
	}

	if _, _, _, _, err := BranchDiff(bare, "main", evilRef); err != nil {
		// An error here is fine (git may reject the malformed revision
		// outright) — a file write is not.
		t.Logf("BranchDiff with evilRef as source: %v (acceptable)", err)
	}
	assertNoFile("BranchDiff (evil source)")

	if _, _, _, _, err := BranchDiff(bare, evilRef, "feature-clean"); err != nil {
		t.Logf("BranchDiff with evilRef as target: %v (acceptable)", err)
	}
	assertNoFile("BranchDiff (evil target)")

	if _, err := CheckMergeable(bare, "main", evilRef); err != nil {
		t.Logf("CheckMergeable with evilRef as source: %v (acceptable)", err)
	}
	assertNoFile("CheckMergeable (evil source)")

	if _, err := CheckMergeable(bare, evilRef, "feature-clean"); err != nil {
		t.Logf("CheckMergeable with evilRef as target: %v (acceptable)", err)
	}
	assertNoFile("CheckMergeable (evil target)")

	if _, _, err := MergeBranches(bare, "main", evilRef, "msg", "Test", "test@example.com"); err != nil {
		t.Logf("MergeBranches with evilRef as source: %v (acceptable)", err)
	}
	assertNoFile("MergeBranches (evil source)")
}

func containsBranchName(branches []string, name string) bool {
	for _, b := range branches {
		if b == name {
			return true
		}
	}
	return false
}