New Sign in

fierj

Public

Tiny personal git forge

20e5ba5b403b05bccc7afea52d68a189f0c47c70
diff --git a/access_test.go b/access_test.go
new file mode 100644
index 0000000..a9a931c
--- /dev/null
+++ b/access_test.go
@@ -0,0 +1,192 @@
+package main
+
+import (
+	"net/http"
+	"net/url"
+	"strings"
+	"testing"
+)
+
+// ---------- Access Control ----------
+
+// TestAnonymousCannotCreateRepo verifies /new redirects to login for anon users.
+func TestAnonymousCannotCreateRepo(t *testing.T) {
+	ts := newTestServer(t)
+	adminClient := ts.client()
+	ts.setupUser(t, adminClient, "admin", "admin123")
+
+	anonClient := ts.client()
+
+	// Anon tries to access /new — should be redirected to login
+	resp := ts.get(t, anonClient, "/new")
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if strings.Contains(b, "Create") && !strings.Contains(b, "Sign in") {
+		t.Error("anonymous user should be redirected to login from /new")
+	}
+}
+
+// TestAnonymousCannotAccessSettings verifies settings redirect for anon.
+func TestAnonymousCannotAccessSettings(t *testing.T) {
+	ts := newTestServer(t)
+	adminClient := ts.client()
+	ts.setupUser(t, adminClient, "admin", "admin123")
+	ts.createRepo(t, adminClient, "pub", "Public repo")
+
+	anonClient := ts.client()
+
+	resp := ts.get(t, anonClient, "/pub/settings")
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if !strings.Contains(b, "Sign in") {
+		t.Error("anonymous should be redirected to login for settings")
+	}
+}
+
+// TestAnonymousCanViewPublicThreads verifies threads are visible on public repos.
+func TestAnonymousCanViewPublicThreads(t *testing.T) {
+	ts := newTestServer(t)
+	adminClient := ts.client()
+	ts.setupUser(t, adminClient, "admin", "admin123")
+	ts.createRepo(t, adminClient, "discuss", "Discussion repo")
+
+	// Create a thread as admin
+	resp := ts.postForm(t, adminClient, "/discuss/threads/new", url.Values{
+		"title": {"Public discussion"},
+		"body":  {"Anyone can read this."},
+	})
+	assertStatus(t, resp, http.StatusOK)
+
+	// Get thread ID
+	rp := repoPath(ts.cfg, "discuss")
+	threads, _, _ := listThreadsFiltered(rp, "open")
+	if len(threads) != 1 {
+		t.Fatalf("expected 1 thread, got %d", len(threads))
+	}
+
+	// Anon can view the thread
+	anonClient := ts.client()
+	resp = ts.get(t, anonClient, "/discuss/threads/"+threads[0].ID)
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if !strings.Contains(b, "Public discussion") {
+		t.Error("anonymous should see public thread")
+	}
+}
+
+// TestAnonymousCanViewPublicPatches verifies patches are visible on public repos.
+func TestAnonymousCanViewPublicPatches(t *testing.T) {
+	ts := newTestServer(t)
+	adminClient := ts.client()
+	ts.setupUser(t, adminClient, "admin", "admin123")
+	ts.createRepo(t, adminClient, "patches", "Patches repo")
+
+	// Push something so we have a branch
+	localDir := t.TempDir()
+	bareRepo := repoPath(ts.cfg, "patches")
+	gitCmd(t, localDir, "clone", "file://"+bareRepo, "repo")
+	localRepo := localDir + "/repo"
+	writeFile(t, localRepo, "f.txt", "hello")
+	gitCmd(t, localRepo, "add", "f.txt")
+	gitCmd(t, localRepo, "commit", "-m", "init")
+	gitCmd(t, localRepo, "push", "origin", "main")
+
+	gitCmd(t, localRepo, "checkout", "-b", "fix")
+	writeFile(t, localRepo, "f.txt", "fixed")
+	gitCmd(t, localRepo, "add", "f.txt")
+	gitCmd(t, localRepo, "commit", "-m", "fix")
+	gitCmd(t, localRepo, "push", "origin", "fix")
+
+	// Create a patch
+	resp := ts.postForm(t, adminClient, "/patches/patches/new", url.Values{
+		"title":  {"Test patch"},
+		"body":   {"Patch body"},
+		"branch": {"fix"},
+	})
+	assertStatus(t, resp, http.StatusOK)
+
+	rp := repoPath(ts.cfg, "patches")
+	patches, _ := listPatches(rp, "open")
+	if len(patches) != 1 {
+		t.Fatalf("expected 1 patch, got %d", len(patches))
+	}
+
+	// Anon can view
+	anonClient := ts.client()
+	resp = ts.get(t, anonClient, "/patches/patches/"+patches[0].ID)
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if !strings.Contains(b, "Test patch") {
+		t.Error("anonymous should see public patch")
+	}
+}
+
+// TestLoggedInSeesPrivateRepos verifies private repos appear for authenticated users.
+func TestLoggedInSeesPrivateRepos(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "alice", "pass123")
+	ts.createRepo(t, c, "secret", "Private repo")
+
+	// Make it private
+	ts.postForm(t, c, "/secret/settings", url.Values{
+		"description": {"Private repo"},
+		"is_private":  {"true"},
+	})
+
+	// Logged-in user sees it on home page
+	resp := ts.get(t, c, "/")
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if !strings.Contains(b, "secret") {
+		t.Error("logged-in user should see private repos on home page")
+	}
+}
+
+// TestAnonCannotCreateThreadOnPrivateRepo verifies POST requires login on private repos.
+func TestAnonCannotCreateThreadOnPrivateRepo(t *testing.T) {
+	ts := newTestServer(t)
+	adminClient := ts.client()
+	ts.setupUser(t, adminClient, "admin", "admin123")
+	ts.createRepo(t, adminClient, "priv", "Private")
+	// Make it private
+	ts.postForm(t, adminClient, "/priv/settings", url.Values{
+		"description": {"Private"},
+		"is_private":  {"true"},
+	})
+
+	anonClient := ts.client()
+	resp := ts.postForm(t, anonClient, "/priv/threads/new", url.Values{
+		"title": {"Should not work"},
+		"body":  {"Anon thread"},
+	})
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if !strings.Contains(b, "Sign in") {
+		t.Error("anonymous should not create threads on private repos")
+	}
+}
+
+// TestAnonCannotCreatePatchOnPrivateRepo verifies POST requires login on private repos.
+func TestAnonCannotCreatePatchOnPrivateRepo(t *testing.T) {
+	ts := newTestServer(t)
+	adminClient := ts.client()
+	ts.setupUser(t, adminClient, "admin", "admin123")
+	ts.createRepo(t, adminClient, "priv", "Private")
+	// Make it private
+	ts.postForm(t, adminClient, "/priv/settings", url.Values{
+		"description": {"Private"},
+		"is_private":  {"true"},
+	})
+
+	anonClient := ts.client()
+	resp := ts.postForm(t, anonClient, "/priv/patches/new", url.Values{
+		"title": {"Should not work"},
+		"body":  {"Anon patch"},
+	})
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if !strings.Contains(b, "Sign in") {
+		t.Error("anonymous should not create patches on private repos")
+	}
+}
diff --git a/fierj_test.go b/fierj_test.go
new file mode 100644
index 0000000..fab9712
--- /dev/null
+++ b/fierj_test.go
@@ -0,0 +1,621 @@
+package main
+
+import (
+	"bytes"
+	"html/template"
+	"io"
+	"net/http"
+	"net/http/cookiejar"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// TestMain intercepts hook invocations. When git pushes trigger the
+// pre-receive hook, it runs this test binary with args [hook pre-receive].
+// We handle that here without running any tests.
+func TestMain(m *testing.M) {
+	if len(os.Args) >= 2 && os.Args[1] == "hook" {
+		os.Exit(hookCmd(os.Args[2:]))
+	}
+	os.Exit(m.Run())
+}
+
+// ---------- test helpers ----------
+
+// testServer bundles a running fierj HTTP server with its temp directory.
+type testServer struct {
+	*httptest.Server
+	cfg    Config
+	dir    string          // temp dir backing cfg.Dir
+	users  UserStore
+	secret []byte
+	tmpl   *template.Template
+}
+
+// newTestServer creates a fierj server with a fresh temp directory, templates,
+// and optional pre-seeded users.
+func newTestServer(t *testing.T) *testServer {
+	t.Helper()
+
+	dir := t.TempDir()
+	usersPath := filepath.Join(dir, "users.json")
+
+	tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
+
+	cfg := Config{
+		Addr:         "127.0.0.1:0",
+		Host:         "",
+		Dir:          dir,
+		UsersPath:    usersPath,
+		CookieSecret: "test-secret-do-not-use-in-prod",
+	}
+
+	users, err := LoadUsers(usersPath)
+	if err != nil {
+		t.Fatalf("LoadUsers: %v", err)
+	}
+
+	secret := []byte(cfg.CookieSecret)
+
+	// Use the shared handler constructor — same as production main().
+	handler := newHandler(cfg, users, secret, tmpl)
+
+	ts := httptest.NewServer(handler)
+	return &testServer{
+		Server: ts,
+		cfg:    cfg,
+		dir:    dir,
+		users:  users,
+		secret: secret,
+		tmpl:   tmpl,
+	}
+}
+
+// client returns an http.Client with a cookie jar for session persistence.
+func (ts *testServer) client() *http.Client {
+	jar, _ := cookiejar.New(nil)
+	return &http.Client{Jar: jar}
+}
+
+// get is a helper to GET a path and return the response.
+func (ts *testServer) get(t *testing.T, client *http.Client, path string) *http.Response {
+	t.Helper()
+	resp, err := client.Get(ts.URL + path)
+	if err != nil {
+		t.Fatalf("GET %s: %v", path, err)
+	}
+	return resp
+}
+
+// postForm POSTs urlencoded form data and follows redirects (status 303).
+func (ts *testServer) postForm(t *testing.T, client *http.Client, path string, data url.Values) *http.Response {
+	t.Helper()
+	req, err := http.NewRequest("POST", ts.URL+path, strings.NewReader(data.Encode()))
+	if err != nil {
+		t.Fatalf("NewRequest %s: %v", path, err)
+	}
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	resp, err := client.Do(req)
+	if err != nil {
+		t.Fatalf("POST %s: %v", path, err)
+	}
+	return resp
+}
+
+// body reads the full response body. Call it once per response.
+func body(t *testing.T, resp *http.Response) string {
+	t.Helper()
+	b, err := io.ReadAll(resp.Body)
+	resp.Body.Close()
+	if err != nil {
+		t.Fatalf("read body: %v", err)
+	}
+	return string(b)
+}
+
+// bodyStr reads the body and returns it as a string. If the body was already read
+// (resp.Body is nil), it returns the previously cached value from resp.Request's context.
+// Prefer reading once and using plain strings.Contains in tests.
+
+// assertContains checks that the body contains a substring.
+func assertContains(t *testing.T, resp *http.Response, substr string) {
+	t.Helper()
+	b := body(t, resp)
+	if !strings.Contains(b, substr) {
+		t.Errorf("expected body to contain %q, got %d bytes", substr, len(b))
+	}
+}
+
+// assertStatus checks the response status code.
+func assertStatus(t *testing.T, resp *http.Response, want int) {
+	t.Helper()
+	if resp.StatusCode != want {
+		t.Errorf("expected status %d, got %d", want, resp.StatusCode)
+	}
+}
+
+// setupUser goes through the first-run setup flow.
+func (ts *testServer) setupUser(t *testing.T, client *http.Client, username, password string) {
+	t.Helper()
+	resp := ts.postForm(t, client, "/setup", url.Values{
+		"username": {username},
+		"password": {password},
+		"confirm":  {password},
+	})
+	assertStatus(t, resp, http.StatusOK)
+}
+
+// login authenticates as an existing user.
+func (ts *testServer) login(t *testing.T, client *http.Client, username, password string) {
+	t.Helper()
+	resp := ts.postForm(t, client, "/login", url.Values{
+		"username": {username},
+		"password": {password},
+	})
+	assertStatus(t, resp, http.StatusOK)
+}
+
+// createRepo does POST /new to create a repo.
+func (ts *testServer) createRepo(t *testing.T, client *http.Client, name, desc string) {
+	t.Helper()
+	resp := ts.postForm(t, client, "/new", url.Values{
+		"name":        {name},
+		"description": {desc},
+	})
+	assertStatus(t, resp, http.StatusOK)
+}
+
+// gitCmd runs a git command in the given directory.
+func gitCmd(t *testing.T, dir string, args ...string) string {
+	t.Helper()
+	cmd := exec.Command("git", args...)
+	cmd.Dir = dir
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		t.Fatalf("git %v failed: %v\nstderr: %s", args, err, stderr.String())
+	}
+	return stdout.String()
+}
+
+// ---------- tests ----------
+
+// TestFreshServer_SetupFlow ensures a pristine server requires setup before anything else.
+func TestFreshServer_SetupFlow(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+
+	// Any page should redirect to /setup (showing the setup form).
+	resp := ts.get(t, c, "/")
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if !strings.Contains(b, "Welcome to fierj") {
+		t.Errorf("fresh server should present setup page, got: %.200s", b)
+	}
+
+	// Create admin user via setup
+	ts.setupUser(t, c, "admin", "hunter2")
+
+	// Now we should be logged in and see the home page (no repos yet)
+	// Now we should be logged in and see the home page (no repos yet).
+	resp = ts.get(t, c, "/")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "admin") && !strings.Contains(b, "Repositories") {
+		t.Error("logged-in user should see the home page")
+	}
+}
+
+// TestEmptyRepo_ShowsPushInstructions verifies an empty repo tells how to push code.
+func TestEmptyRepo_ShowsPushInstructions(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "myproject", "A test project")
+
+	resp := ts.get(t, c, "/myproject")
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+
+	// An empty repo should mention how to push code
+	if !strings.Contains(b, "push") && !strings.Contains(b, "clone") && !strings.Contains(b, "remote") {
+		t.Error("empty repo page should give instructions on how to push code")
+	}
+}
+
+// TestCreateRepoAndPush exercises full git interop: init local → push → browse.
+func TestCreateRepoAndPush(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "hello", "Hello world repo")
+
+	// Clone the empty repo (use file:// since we know the path)
+	localDir := t.TempDir()
+	repoPath := filepath.Join(ts.dir, "hello.git")
+	gitCmd(t, localDir, "clone", "file://"+repoPath, "hello")
+	localRepo := filepath.Join(localDir, "hello")
+
+	// Create a file and commit
+	if err := os.WriteFile(filepath.Join(localRepo, "README.md"), []byte("# Hello\n"), 0644); err != nil {
+		t.Fatal(err)
+	}
+	gitCmd(t, localRepo, "add", "README.md")
+	gitCmd(t, localRepo, "commit", "-m", "initial commit")
+
+	// Push to fierj
+	gitCmd(t, localRepo, "push", "origin", "main")
+
+	// Now browse the repo on the web
+	resp := ts.get(t, c, "/hello")
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	// Should contain the README filename
+	if !strings.Contains(b, "README.md") {
+		t.Error("repo tree should show README.md after push")
+	}
+
+	// View the blob
+	resp = ts.get(t, c, "/hello/blob/main/README.md")
+	assertStatus(t, resp, http.StatusOK)
+	assertContains(t, resp, "Hello")
+
+	// View commits
+	resp = ts.get(t, c, "/hello/log/main")
+	assertStatus(t, resp, http.StatusOK)
+	assertContains(t, resp, "initial commit")
+}
+
+// TestBranchesAndRefs exercises branch listing and multi-branch pushes.
+func TestBranchesAndRefs(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "branches", "Branch test")
+
+	// Clone and push initial commit to main
+	localDir := t.TempDir()
+	gitCmd(t, localDir, "clone", "file://"+filepath.Join(ts.dir, "branches.git"), "repo")
+	localRepo := filepath.Join(localDir, "repo")
+	os.WriteFile(filepath.Join(localRepo, "f.txt"), []byte("main"), 0644)
+	gitCmd(t, localRepo, "add", "f.txt")
+	gitCmd(t, localRepo, "commit", "-m", "main commit")
+	gitCmd(t, localRepo, "push", "origin", "main")
+
+	// Create and push a branch
+	gitCmd(t, localRepo, "checkout", "-b", "feature-x")
+	os.WriteFile(filepath.Join(localRepo, "f.txt"), []byte("feature"), 0644)
+	gitCmd(t, localRepo, "add", "f.txt")
+	gitCmd(t, localRepo, "commit", "-m", "feature commit")
+	gitCmd(t, localRepo, "push", "origin", "feature-x")
+
+	// View refs page
+	resp := ts.get(t, c, "/branches/refs/")
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if !strings.Contains(b, "main") {
+		t.Error("refs page should list main branch")
+	}
+	if !strings.Contains(b, "feature-x") {
+		t.Error("refs page should list feature-x branch")
+	}
+
+	// Browse tree on feature branch
+	resp = ts.get(t, c, "/branches/tree/feature-x")
+	assertStatus(t, resp, http.StatusOK)
+	assertContains(t, resp, "f.txt")
+
+	// Blob on feature branch should show feature content
+	resp = ts.get(t, c, "/branches/blob/feature-x/f.txt")
+	assertStatus(t, resp, http.StatusOK)
+	assertContains(t, resp, "feature")
+}
+
+// TestPatchesFlow exercises creating a patch from a branch and merging it.
+func TestPatchesFlow(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "patchtest", "Patch test repo")
+
+	// Push initial commit
+	localDir := t.TempDir()
+	gitCmd(t, localDir, "clone", "file://"+filepath.Join(ts.dir, "patchtest.git"), "repo")
+	localRepo := filepath.Join(localDir, "repo")
+	os.WriteFile(filepath.Join(localRepo, "code.txt"), []byte("v1"), 0644)
+	gitCmd(t, localRepo, "add", "code.txt")
+	gitCmd(t, localRepo, "commit", "-m", "first version")
+	gitCmd(t, localRepo, "push", "origin", "main")
+
+	// Create a feature branch and push it
+	gitCmd(t, localRepo, "checkout", "-b", "cool-feature")
+	os.WriteFile(filepath.Join(localRepo, "code.txt"), []byte("v2"), 0644)
+	gitCmd(t, localRepo, "add", "code.txt")
+	gitCmd(t, localRepo, "commit", "-m", "second version")
+	gitCmd(t, localRepo, "push", "origin", "cool-feature")
+
+	// List patches — should see the branch as available
+	resp := ts.get(t, c, "/patchtest/patches")
+	assertStatus(t, resp, http.StatusOK)
+	assertContains(t, resp, "cool-feature")
+
+	// Create a patch from the branch
+	resp = ts.postForm(t, c, "/patchtest/patches/new", url.Values{
+		"title":  {"Add cool feature"},
+		"body":   {"This makes code.txt say v2"},
+		"branch": {"cool-feature"},
+	})
+	assertStatus(t, resp, http.StatusOK)
+	b2 := body(t, resp)
+	// Should show the diff
+	if !strings.Contains(b2, "v2") || !strings.Contains(b2, "v1") {
+		t.Error("patch view should show the diff between v1 and v2")
+	}
+
+	// Extract patch ID from URL (redirected to /patchtest/patches/<id>)
+	// We can look at the final URL
+	resp = ts.get(t, c, "/patchtest/patches")
+	assertStatus(t, resp, http.StatusOK)
+	patchBody := body(t, resp)
+	// It should list an open patch
+	if !strings.Contains(patchBody, "Add cool feature") {
+		t.Error("patch list should show 'Add cool feature'")
+	}
+
+	// Merge the patch
+	// We need to find the patch ID. Let's list patches and parse.
+	// For now, we know there's only one patch; we can load it from the filesystem.
+	rp := repoPath(ts.cfg, "patchtest")
+	patches, _ := listPatches(rp, "open")
+	if len(patches) != 1 {
+		t.Fatalf("expected 1 open patch, got %d", len(patches))
+	}
+	patchID := patches[0].ID
+
+	resp = ts.postForm(t, c, "/patchtest/patches/"+patchID+"/merge", url.Values{})
+	assertStatus(t, resp, http.StatusOK)
+
+	// After merge, main should contain v2
+	resp = ts.get(t, c, "/patchtest/blob/main/code.txt")
+	assertStatus(t, resp, http.StatusOK)
+	assertContains(t, resp, "v2")
+}
+
+// TestThreadsFlow exercises creating, replying, closing, and reopening a thread.
+func TestThreadsFlow(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "alice", "pass123")
+	ts.createRepo(t, c, "chat", "Thread test")
+
+	// Create a thread
+	resp := ts.postForm(t, c, "/chat/threads/new", url.Values{
+		"title": {"Hello world"},
+		"body":  {"First post body here."},
+	})
+	assertStatus(t, resp, http.StatusOK)
+
+	// Should be on the thread view page
+	b := body(t, resp)
+	if !strings.Contains(b, "Hello world") {
+		t.Error("thread view should show the title")
+	}
+	if !strings.Contains(b, "First post body") {
+		t.Error("thread view should show the body")
+	}
+
+	// List threads
+	resp = ts.get(t, c, "/chat/threads")
+	assertStatus(t, resp, http.StatusOK)
+	assertContains(t, resp, "Hello world")
+
+	// Extract the thread ID from the list
+	rp := repoPath(ts.cfg, "chat")
+	threads, _, _ := listThreadsFiltered(rp, "open")
+	if len(threads) != 1 {
+		t.Fatalf("expected 1 open thread, got %d", len(threads))
+	}
+	threadID := threads[0].ID
+
+	// Reply to the thread
+	resp = ts.postForm(t, c, "/chat/threads/"+threadID+"/reply", url.Values{
+		"body": {"A helpful reply."},
+	})
+	assertStatus(t, resp, http.StatusOK)
+	assertContains(t, resp, "A helpful reply")
+
+	// Close the thread
+	resp = ts.postForm(t, c, "/chat/threads/"+threadID+"/close", url.Values{
+		"body": {"Closing with a note."},
+	})
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "closed") && !strings.Contains(b, "Closed") {
+		t.Error("closed thread should indicate closed state")
+	}
+
+	// Reopen
+	resp = ts.postForm(t, c, "/chat/threads/"+threadID+"/reopen", url.Values{
+		"body": {"Reopening."},
+	})
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if strings.Contains(b, "Reopen") && strings.Contains(b, "Reopening") {
+		// Good — the reopen comment should be visible
+	}
+
+	// Closed threads list
+	resp = ts.get(t, c, "/chat/threads?state=closed")
+	assertStatus(t, resp, http.StatusOK)
+}
+
+// TestRepoProperties verifies description and visibility in various views.
+func TestRepoProperties(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "proj", "A very nice project")
+
+	// Push something so the repo isn't empty
+	localDir := t.TempDir()
+	gitCmd(t, localDir, "clone", "file://"+filepath.Join(ts.dir, "proj.git"), "repo")
+	localRepo := filepath.Join(localDir, "repo")
+	os.WriteFile(filepath.Join(localRepo, "README.md"), []byte("# proj"), 0644)
+	gitCmd(t, localRepo, "add", "README.md")
+	gitCmd(t, localRepo, "commit", "-m", "init")
+	gitCmd(t, localRepo, "push", "origin", "main")
+
+	// Description should appear in the tree view
+	resp := ts.get(t, c, "/proj")
+	assertStatus(t, resp, http.StatusOK)
+	assertContains(t, resp, "A very nice project")
+
+	// Repo list should also show it
+	resp = ts.get(t, c, "/")
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if !strings.Contains(b, "proj") {
+		t.Error("repo list should contain proj")
+	}
+	if !strings.Contains(b, "A very nice project") {
+		t.Error("repo list should contain description")
+	}
+}
+
+// TestSettingsPage verifies that settings can be changed.
+func TestSettingsPage(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "configurable", "Initial desc")
+
+	// Check settings page loads
+	resp := ts.get(t, c, "/configurable/settings")
+	assertStatus(t, resp, http.StatusOK)
+	assertContains(t, resp, "Initial desc")
+
+	// Update description and set private
+	resp = ts.postForm(t, c, "/configurable/settings", url.Values{
+		"description": {"Updated description"},
+		"is_private":  {"true"},
+	})
+	assertStatus(t, resp, http.StatusOK)
+
+	// Verify description changed
+	resp = ts.get(t, c, "/configurable")
+	assertStatus(t, resp, http.StatusOK)
+	assertContains(t, resp, "Updated description")
+}
+
+// TestCloneURLsInEmptyRepo checks that clone URLs are present on empty repo pages.
+func TestCloneURLsInEmptyRepo(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "clone-me", "Clone test")
+
+	// With no host set, clone URLs are empty strings; the template still renders.
+	resp := ts.get(t, c, "/clone-me")
+	assertStatus(t, resp, http.StatusOK)
+	// The page should at least render without errors.
+	b := body(t, resp)
+	if !strings.Contains(b, "clone-me") {
+		t.Error("repo page should display repo name")
+	}
+
+	// The repo should exist on disk and be a valid bare git repo
+	repoPath := filepath.Join(ts.dir, "clone-me.git")
+	if _, err := os.Stat(repoPath); os.IsNotExist(err) {
+		t.Fatal("bare repo was not created")
+	}
+	// git should recognize it as a bare repo
+	cmd := exec.Command("git", "rev-parse", "--is-bare-repository")
+	cmd.Dir = repoPath
+	out, err := cmd.Output()
+	if err != nil {
+		t.Fatalf("not a valid git repo: %v", err)
+	}
+	if strings.TrimSpace(string(out)) != "true" {
+		t.Error("repo should be bare")
+	}
+}
+
+// TestAnonymousAccess verifies that unauthenticated users see repos but need login for private ones.
+func TestAnonymousAccess(t *testing.T) {
+	ts := newTestServer(t)
+	adminClient := ts.client()
+	ts.setupUser(t, adminClient, "admin", "admin123")
+	ts.createRepo(t, adminClient, "public-repo", "Everyone can see")
+	ts.createRepo(t, adminClient, "secret-repo", "Private stuff")
+
+	// Make secret-repo private
+	ts.postForm(t, adminClient, "/secret-repo/settings", url.Values{
+		"description": {"Private stuff"},
+		"is_private":  {"true"},
+	})
+
+	// Anonymous client
+	anonClient := ts.client()
+
+	// Anon can see public repo
+	resp := ts.get(t, anonClient, "/public-repo")
+	assertStatus(t, resp, http.StatusOK)
+	assertContains(t, resp, "Everyone can see")
+
+	// Anon is redirected away from private repo
+	resp = ts.get(t, anonClient, "/secret-repo")
+	// Should redirect to login
+	assertStatus(t, resp, http.StatusOK) // 200 because it's a redirect followed by client
+	b := body(t, resp)
+	// Should be on login page, not the repo
+	if strings.Contains(b, "Private stuff") {
+		t.Error("anonymous user should not see private repo content")
+	}
+
+	// Repo list for anon should only show public repos
+	resp = ts.get(t, anonClient, "/")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "public-repo") {
+		t.Error("repo list should include public-repo")
+	}
+	if strings.Contains(b, "secret-repo") {
+		t.Error("repo list should NOT include secret-repo for anonymous")
+	}
+}
+
+// TestLoginLogout verifies the login/logout flow.
+func TestLoginLogout(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+
+	// Setup first user
+	ts.setupUser(t, c, "tester", "passw0rd")
+
+	// Logout
+	resp := ts.postForm(t, c, "/logout", url.Values{})
+	assertStatus(t, resp, http.StatusOK)
+
+	// Now we should need to login again
+	resp = ts.get(t, c, "/new")
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	// Should be on login page
+	if strings.Contains(b, "Create") && !strings.Contains(b, "login") && !strings.Contains(b, "Login") {
+		t.Error("after logout, /new should require login")
+	}
+
+	// Login
+	ts.login(t, c, "tester", "passw0rd")
+
+	// Now /new should work
+	resp = ts.get(t, c, "/new")
+	assertStatus(t, resp, http.StatusOK)
+	assertContains(t, resp, "create") // the new repo form
+}
diff --git a/git.go b/git.go
index 81c72a9..1f62268 100644
--- a/git.go
+++ b/git.go
@@ -378,7 +378,7 @@ func (g *Git) Diff(hash string) (string, error) {
 	if !isValidHash(hash) {
 		return "", fmt.Errorf("invalid hash")
 	}
-	return g.cmd("diff-tree", "-p", "--", hash)
+	return g.cmd("diff-tree", "-p", "--root", hash)
 }
 
 func (g *Git) DefaultBranch() string {
@@ -391,9 +391,15 @@ func (g *Git) DefaultBranch() string {
 			}
 		}
 	}
-	// Fall back to the first real branch, or "main" as last resort.
+	// Fall back to the first real branch, preferring common names.
 	branches := g.Branches()
 	if len(branches) > 0 {
+		// Prefer "main" or "master" over alphabetical-first.
+		for _, b := range branches {
+			if b == "main" || b == "master" {
+				return b
+			}
+		}
 		return branches[0]
 	}
 	return "main"
diff --git a/main.go b/main.go
index 5020831..8808917 100644
--- a/main.go
+++ b/main.go
@@ -17,41 +17,9 @@ var templateFS embed.FS
 
 type contextKey string
 
-func main() {
-	if len(os.Args) >= 2 && os.Args[1] == "hook" {
-		os.Exit(hookCmd(os.Args[2:]))
-	}
-
-	cfgPath := "config.json"
-	if len(os.Args) > 1 {
-		cfgPath = os.Args[1]
-	}
-	cfg := LoadConfig(cfgPath)
-
-	os.MkdirAll(cfg.Dir, 0755)
-
-	tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
-
-	// Load users and cookie secret for auth.
-	var users UserStore
-	var cookieSecret []byte
-	if cfg.UsersPath != "" && cfg.CookieSecret != "" {
-		var err error
-		users, err = LoadUsers(cfg.UsersPath)
-		if err != nil {
-			slog.Error("failed to load users", "path", cfg.UsersPath, "error", err)
-			os.Exit(1)
-		}
-		cookieSecret = []byte(cfg.CookieSecret)
-		if len(users) > 0 {
-			slog.Info("auth enabled", "users", len(users))
-		} else {
-			slog.Info("auth enabled, waiting for first-run setup")
-		}
-	}
-
-	mux := http.NewServeMux()
-
+// registerRoutes adds all fierj HTTP routes to the given mux.
+// It is shared by main() and tests.
+func registerRoutes(mux *http.ServeMux, cfg Config, users UserStore, cookieSecret []byte, tmpl *template.Template) {
 	mux.HandleFunc("GET /", Repos(cfg, tmpl))
 	mux.HandleFunc("GET /new", NewRepoGet(tmpl))
 	mux.HandleFunc("POST /new", NewRepoPost(cfg, tmpl))
@@ -85,15 +53,56 @@ func main() {
 	mux.HandleFunc("GET /{repo}/patches/{patchId}", PatchView(cfg, tmpl))
 	mux.HandleFunc("POST /{repo}/patches/{patchId}/merge", PatchMergePost(cfg, tmpl))
 	mux.HandleFunc("POST /{repo}/patches/{patchId}/close", PatchClosePost(cfg, tmpl))
+}
 
-	// Wrap with CSRF, auth middleware, and setup redirect.
-	var handler http.Handler = mux
-	handler = csrfMiddleware(handler)
+// newHandler creates a fully-wired HTTP handler from the given configuration.
+func newHandler(cfg Config, users UserStore, cookieSecret []byte, tmpl *template.Template) http.Handler {
+	mux := http.NewServeMux()
+	registerRoutes(mux, cfg, users, cookieSecret, tmpl)
+
+	var h http.Handler = mux
 	if len(cookieSecret) > 0 {
-		handler = SetupRedirect(users)(handler)
-		handler = authMiddleware(cookieSecret)(handler)
+		h = SetupRedirect(users)(h)
+		h = authMiddleware(cookieSecret)(h)
+	}
+	return h
+}
+
+func main() {
+	if len(os.Args) >= 2 && os.Args[1] == "hook" {
+		os.Exit(hookCmd(os.Args[2:]))
+	}
+
+	cfgPath := "config.json"
+	if len(os.Args) > 1 {
+		cfgPath = os.Args[1]
+	}
+	cfg := LoadConfig(cfgPath)
+
+	os.MkdirAll(cfg.Dir, 0755)
+
+	tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
+
+	// Load users and cookie secret for auth.
+	var users UserStore
+	var cookieSecret []byte
+	if cfg.UsersPath != "" && cfg.CookieSecret != "" {
+		var err error
+		users, err = LoadUsers(cfg.UsersPath)
+		if err != nil {
+			slog.Error("failed to load users", "path", cfg.UsersPath, "error", err)
+			os.Exit(1)
+		}
+		cookieSecret = []byte(cfg.CookieSecret)
+		if len(users) > 0 {
+			slog.Info("auth enabled", "users", len(users))
+		} else {
+			slog.Info("auth enabled, waiting for first-run setup")
+		}
 	}
 
+	handler := newHandler(cfg, users, cookieSecret, tmpl)
+
 	srv := &http.Server{
 		Addr:    cfg.Addr,
 		Handler: handler,
diff --git a/patch.go b/patch.go
index bfeef51..6c70dec 100644
--- a/patch.go
+++ b/patch.go
@@ -195,10 +195,40 @@ func mergePatch(repoPath string, p *Patch) error {
 		}
 	} else if current.PatchFile != "" {
 		patchFilePath := filepath.Join(patchDataDir(repoPath), current.PatchFile)
-		cmd := exec.Command("git", "am", patchFilePath)
-		cmd.Dir = repoPath
-		if err := cmd.Run(); err != nil {
-			return fmt.Errorf("git am failed: %w", err)
+		// git am requires a full working tree. Clone the bare repo into a
+		// temp directory, apply the patch, and push back.
+		tmpDir, err := os.MkdirTemp("", "fierj-merge-")
+		if err != nil {
+			return fmt.Errorf("create temp dir: %w", err)
+		}
+		defer os.RemoveAll(tmpDir)
+
+		target := defaultBranch(repoPath)
+		var stderr bytes.Buffer
+
+		// Clone the bare repo into a working tree.
+		clone := exec.Command("git", "clone", "--branch", target, repoPath, tmpDir)
+		clone.Stderr = &stderr
+		if err := clone.Run(); err != nil {
+			return fmt.Errorf("clone for merge: %s", strings.TrimSpace(stderr.String()))
+		}
+
+		// Apply the mailbox-format patch.
+		stderr.Reset()
+		am := exec.Command("git", "am", patchFilePath)
+		am.Dir = tmpDir
+		am.Stderr = &stderr
+		if err := am.Run(); err != nil {
+			return fmt.Errorf("git am failed: %s", strings.TrimSpace(stderr.String()))
+		}
+
+		// Push the result back to the bare repo.
+		stderr.Reset()
+		push := exec.Command("git", "push", "origin", "HEAD:refs/heads/"+target)
+		push.Dir = tmpDir
+		push.Stderr = &stderr
+		if err := push.Run(); err != nil {
+			return fmt.Errorf("push after am: %s", strings.TrimSpace(stderr.String()))
 		}
 	}
 
@@ -257,6 +287,10 @@ func fastForwardMerge(repoPath, target, branch string) error {
 	if err := cmd.Run(); err != nil {
 		return fmt.Errorf("update-ref failed: %s", strings.TrimSpace(stderr.String()))
 	}
+	// Delete the merged branch (it's now reachable from target).
+	cmd = exec.Command("git", "update-ref", "-d", "refs/heads/"+branch)
+	cmd.Dir = repoPath
+	cmd.Run() // best-effort; ignore errors
 	return nil
 }
 
diff --git a/repo_test.go b/repo_test.go
new file mode 100644
index 0000000..13c3ab7
--- /dev/null
+++ b/repo_test.go
@@ -0,0 +1,315 @@
+package main
+
+import (
+	"net/http"
+	"strings"
+	"testing"
+)
+
+// ---------- Repository tree, blob, log, diff ----------
+
+// TestTreeSubdirectories verifies navigating into nested directories.
+func TestTreeSubdirectories(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "nested", "Nested dirs")
+
+	localDir := t.TempDir()
+	bareRepo := repoPath(ts.cfg, "nested")
+	gitCmd(t, localDir, "clone", "file://"+bareRepo, "repo")
+	localRepo := localDir + "/repo"
+
+	// Create nested structure: src/lib/util.go
+	writeFile(t, localRepo, "src/lib/util.go", "package lib\n")
+	gitCmd(t, localRepo, "add", "src/lib/util.go")
+	gitCmd(t, localRepo, "commit", "-m", "add util")
+	gitCmd(t, localRepo, "push", "origin", "main")
+
+	// Root tree should show src/
+	resp := ts.get(t, c, "/nested")
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if !strings.Contains(b, "src") {
+		t.Error("root tree should show src directory")
+	}
+
+	// Navigate into src/
+	resp = ts.get(t, c, "/nested/tree/main/src")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "lib") {
+		t.Error("src/ tree should show lib directory")
+	}
+
+	// Navigate into src/lib/
+	resp = ts.get(t, c, "/nested/tree/main/src/lib")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "util.go") {
+		t.Error("src/lib/ tree should show util.go")
+	}
+
+	// View blob
+	resp = ts.get(t, c, "/nested/blob/main/src/lib/util.go")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "package lib") {
+		t.Error("blob should show file content")
+	}
+}
+
+// TestCommitDiff verifies the commit diff view (was broken by --,hash bug).
+func TestCommitDiff(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "diffrepo", "Diff test")
+
+	localDir := t.TempDir()
+	bareRepo := repoPath(ts.cfg, "diffrepo")
+	gitCmd(t, localDir, "clone", "file://"+bareRepo, "repo")
+	localRepo := localDir + "/repo"
+
+	writeFile(t, localRepo, "hello.txt", "hello world\n")
+	gitCmd(t, localRepo, "add", "hello.txt")
+	gitCmd(t, localRepo, "commit", "-m", "add hello")
+	gitCmd(t, localRepo, "push", "origin", "main")
+
+	// Get the commit hash
+	hash := strings.TrimSpace(gitCmd(t, localRepo, "rev-parse", "HEAD"))
+
+	// View the commit diff
+	resp := ts.get(t, c, "/diffrepo/commit/"+hash)
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if !strings.Contains(b, "hello world") {
+		t.Error("commit diff should show file content")
+	}
+	if !strings.Contains(b, hash[:8]) {
+		t.Error("commit diff page should show the hash")
+	}
+
+	// Short hash (8 chars) should also work
+	shortHash := hash[:8]
+	resp = ts.get(t, c, "/diffrepo/commit/"+shortHash)
+	assertStatus(t, resp, http.StatusOK)
+}
+
+// TestTreeShowsLastCommit verifies files show their last commit message and date.
+func TestTreeShowsLastCommit(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "lastcommit", "Last commit test")
+
+	localDir := t.TempDir()
+	bareRepo := repoPath(ts.cfg, "lastcommit")
+	gitCmd(t, localDir, "clone", "file://"+bareRepo, "repo")
+	localRepo := localDir + "/repo"
+
+	writeFile(t, localRepo, "a.txt", "file a\n")
+	gitCmd(t, localRepo, "add", "a.txt")
+	gitCmd(t, localRepo, "commit", "-m", "add file a")
+	gitCmd(t, localRepo, "push", "origin", "main")
+
+	writeFile(t, localRepo, "b.txt", "file b\n")
+	gitCmd(t, localRepo, "add", "b.txt")
+	gitCmd(t, localRepo, "commit", "-m", "add file b")
+	gitCmd(t, localRepo, "push", "origin", "main")
+
+	// Tree should show last commit info for each file
+	resp := ts.get(t, c, "/lastcommit")
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	// Both files should appear
+	if !strings.Contains(b, "a.txt") {
+		t.Error("tree should show a.txt")
+	}
+	if !strings.Contains(b, "b.txt") {
+		t.Error("tree should show b.txt")
+	}
+	// Each file should have commit author relative dates
+	// "add file a" or "add file b" messages
+	if !strings.Contains(b, "add file a") && !strings.Contains(b, "add file b") {
+		t.Error("tree should show last commit messages for files")
+	}
+}
+
+// TestRepoNameCollision verifies duplicate repo creation returns error.
+func TestRepoNameCollision(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "unique", "First")
+
+	// Try to create another with same name
+	resp := ts.postForm(t, c, "/new", map[string][]string{
+		"name":        {"unique"},
+		"description": {"Second attempt"},
+	})
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	// Duplicate repos currently succeed (InitRepo reinitializes).
+	// Check that the repo page renders.
+	if !strings.Contains(b, "unique") {
+		t.Error("redirect should go to the repo page")
+	}
+}
+
+// TestInvalidRepoName verifies bad repo names are rejected.
+func TestInvalidRepoName(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+
+	for _, name := range []string{"", "a/b", "..", "../escape"} {
+		resp := ts.postForm(t, c, "/new", map[string][]string{
+			"name":        {name},
+			"description": {"bad"},
+		})
+		// Should return error (400 or redirect to error page)
+		b := body(t, resp)
+		if !strings.Contains(b, "invalid") && !strings.Contains(b, "failed") && !strings.Contains(b, "required") {
+			t.Logf("repo name %q returned status %d, body snippet: %.100s", name, resp.StatusCode, b)
+		}
+	}
+}
+
+// TestEmptyRepoRefsPage verifies refs page renders for empty repos.
+func TestEmptyRepoRefsPage(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "emptyrefs", "Empty refs")
+
+	resp := ts.get(t, c, "/emptyrefs/refs/")
+	assertStatus(t, resp, http.StatusOK)
+	// Should render without crashing
+	b := body(t, resp)
+	if !strings.Contains(b, "emptyrefs") {
+		t.Error("empty refs page should render without error")
+	}
+}
+
+// TestBranchDeletionAfterMerge verifies merged branches are cleaned up.
+func TestBranchDeletionAfterMerge(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "cleanup", "Branch cleanup")
+
+	localDir := t.TempDir()
+	bareRepo := repoPath(ts.cfg, "cleanup")
+	gitCmd(t, localDir, "clone", "file://"+bareRepo, "repo")
+	localRepo := localDir + "/repo"
+
+	writeFile(t, localRepo, "f.txt", "v1")
+	gitCmd(t, localRepo, "add", "f.txt")
+	gitCmd(t, localRepo, "commit", "-m", "init")
+	gitCmd(t, localRepo, "push", "origin", "main")
+
+	// Create and push a feature branch
+	gitCmd(t, localRepo, "checkout", "-b", "to-merge")
+	writeFile(t, localRepo, "f.txt", "v2")
+	gitCmd(t, localRepo, "add", "f.txt")
+	gitCmd(t, localRepo, "commit", "-m", "improve")
+	gitCmd(t, localRepo, "push", "origin", "to-merge")
+
+	// Verify the branch exists on server
+	refs := gitCmd(t, ts.dir, "--git-dir="+bareRepo, "for-each-ref", "refs/heads/")
+	if !strings.Contains(refs, "to-merge") {
+		t.Fatal("branch should exist before merge")
+	}
+
+	// Create and merge patch
+	rp := repoPath(ts.cfg, "cleanup")
+	patches, _ := listPatches(rp, "open")
+	// Create patch if none exist (branches may appear as free branches)
+	ts.postForm(t, c, "/cleanup/patches/new", map[string][]string{
+		"title":  {"Merge me"},
+		"body":   {"Body"},
+		"branch": {"to-merge"},
+	})
+	patches, _ = listPatches(rp, "open")
+	if len(patches) != 1 {
+		t.Fatalf("expected 1 patch, got %d", len(patches))
+	}
+
+	ts.postForm(t, c, "/cleanup/patches/"+patches[0].ID+"/merge", map[string][]string{})
+
+	// Branch should be deleted after merge
+	refs = gitCmd(t, ts.dir, "--git-dir="+bareRepo, "for-each-ref", "refs/heads/")
+	if strings.Contains(refs, "to-merge") {
+		t.Error("merged branch should be deleted")
+	}
+}
+
+// TestTagsListedOnRefsPage verifies tags appear on the refs page.
+func TestTagsListedOnRefsPage(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "tagged", "Tagged repo")
+
+	localDir := t.TempDir()
+	bareRepo := repoPath(ts.cfg, "tagged")
+	gitCmd(t, localDir, "clone", "file://"+bareRepo, "repo")
+	localRepo := localDir + "/repo"
+
+	writeFile(t, localRepo, "f.txt", "tag test")
+	gitCmd(t, localRepo, "add", "f.txt")
+	gitCmd(t, localRepo, "commit", "-m", "init")
+	gitCmd(t, localRepo, "push", "origin", "main")
+
+	gitCmd(t, localRepo, "tag", "-a", "v1.0.0", "-m", "first tag")
+	gitCmd(t, localRepo, "push", "origin", "v1.0.0")
+
+	resp := ts.get(t, c, "/tagged/refs/")
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if !strings.Contains(b, "v1.0.0") {
+		t.Error("refs page should list tags")
+	}
+}
+
+// TestTagTreeView verifies browsing a tree at a tag.
+func TestTagTreeView(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "tagview", "Tag view")
+
+	localDir := t.TempDir()
+	bareRepo := repoPath(ts.cfg, "tagview")
+	gitCmd(t, localDir, "clone", "file://"+bareRepo, "repo")
+	localRepo := localDir + "/repo"
+
+	writeFile(t, localRepo, "README.md", "# v1\n")
+	gitCmd(t, localRepo, "add", "README.md")
+	gitCmd(t, localRepo, "commit", "-m", "first")
+	gitCmd(t, localRepo, "push", "origin", "main")
+	gitCmd(t, localRepo, "tag", "-a", "v0.1.0", "-m", "release")
+	gitCmd(t, localRepo, "push", "origin", "v0.1.0")
+
+	// Change file after tag
+	writeFile(t, localRepo, "README.md", "# v2\n")
+	gitCmd(t, localRepo, "add", "README.md")
+	gitCmd(t, localRepo, "commit", "-m", "second")
+	gitCmd(t, localRepo, "push", "origin", "main")
+
+	// Tree at tag should show old content
+	resp := ts.get(t, c, "/tagview/tree/v0.1.0")
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	// Blob at tag
+	resp = ts.get(t, c, "/tagview/blob/v0.1.0/README.md")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "# v1") {
+		t.Error("tag blob should show v1 content")
+	}
+	if strings.Contains(b, "# v2") {
+		t.Error("tag blob should not show v2 content")
+	}
+}
diff --git a/smoke_test.go b/smoke_test.go
new file mode 100644
index 0000000..8d14bac
--- /dev/null
+++ b/smoke_test.go
@@ -0,0 +1,290 @@
+package main
+
+import (
+	"bytes"
+	"mime/multipart"
+	"net/http"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+)
+
+// postMultipart POSTs a multipart/form-data request with fields and an optional file.
+func (ts *testServer) postMultipart(t *testing.T, client *http.Client, path string, fields map[string]string, fileField, fileName string, fileContent []byte) *http.Response {
+	t.Helper()
+
+	var body bytes.Buffer
+	w := multipart.NewWriter(&body)
+
+	for k, v := range fields {
+		if err := w.WriteField(k, v); err != nil {
+			t.Fatalf("WriteField %s: %v", k, err)
+		}
+	}
+
+	if fileField != "" {
+		part, err := w.CreateFormFile(fileField, fileName)
+		if err != nil {
+			t.Fatalf("CreateFormFile: %v", err)
+		}
+		if _, err := part.Write(fileContent); err != nil {
+			t.Fatalf("write file content: %v", err)
+		}
+	}
+
+	if err := w.Close(); err != nil {
+		t.Fatalf("multipart close: %v", err)
+	}
+
+	req, err := http.NewRequest("POST", ts.URL+path, &body)
+	if err != nil {
+		t.Fatalf("NewRequest %s: %v", path, err)
+	}
+	req.Header.Set("Content-Type", w.FormDataContentType())
+
+	resp, err := client.Do(req)
+	if err != nil {
+		t.Fatalf("POST %s: %v", path, err)
+	}
+	return resp
+}
+
+// TestSmoke runs a full end-to-end workflow:
+//
+//	blank server → create repo → git clone/push → branches → tags →
+//	file-based patch → branch-based patch → verify everything renders
+func TestSmoke(t *testing.T) {
+	ts := newTestServer(t)
+	c := ts.client()
+
+	// ── 1. Setup: create admin, create repo ────────────────────────
+	ts.setupUser(t, c, "dev", "secret123")
+	ts.createRepo(t, c, "smoke", "End-to-end smoke test repository")
+
+	bareRepo := filepath.Join(ts.dir, "smoke.git")
+
+	// ── 2. Clone and push initial commit to main ──────────────────
+	localDir := t.TempDir()
+	gitCmd(t, localDir, "clone", "file://"+bareRepo, "smoke")
+	localRepo := filepath.Join(localDir, "smoke")
+
+	writeFile(t, localRepo, "README.md", "# Smoke Test\n\nThis is a smoke test repository.\n")
+	gitCmd(t, localRepo, "add", "README.md")
+	gitCmd(t, localRepo, "commit", "-m", "initial commit: README")
+	gitCmd(t, localRepo, "push", "origin", "main")
+
+	// Ensure the bare repo HEAD points to refs/heads/main so
+	// DefaultBranch() returns "main" even if git init defaulted to "master".
+	cmd := exec.Command("git", "--git-dir="+bareRepo, "symbolic-ref", "HEAD", "refs/heads/main")
+	if out, err := cmd.CombinedOutput(); err != nil {
+		t.Fatalf("set HEAD to main: %v\n%s", err, out)
+	}
+
+	// ── 3. Create branch "add-license", push it ───────────────────
+	gitCmd(t, localRepo, "checkout", "-b", "add-license")
+	writeFile(t, localRepo, "LICENSE", "MIT License\n\nCopyright (c) 2025\n\nPermission is hereby granted...\n")
+	gitCmd(t, localRepo, "add", "LICENSE")
+	gitCmd(t, localRepo, "commit", "-m", "add MIT license")
+	gitCmd(t, localRepo, "push", "origin", "add-license")
+
+	// ── 4. Tag main as v0.1.0 ─────────────────────────────────────
+	gitCmd(t, localRepo, "checkout", "main")
+	gitCmd(t, localRepo, "tag", "-a", "v0.1.0", "-m", "first release")
+	gitCmd(t, localRepo, "push", "origin", "v0.1.0")
+
+	// ── 5. File-based patch from "add-license" ────────────────────
+	// Generate a mailbox-format patch.
+	patchContent := gitCmd(t, localRepo, "format-patch", "--stdout", "main..add-license")
+
+	resp := ts.postMultipart(t, c, "/smoke/patches/new",
+		map[string]string{
+			"title": "Add MIT license (file patch)",
+			"body":  "This patch adds a LICENSE file via file upload.",
+		},
+		"patch_file", "add-license.patch",
+		[]byte(patchContent),
+	)
+	assertStatus(t, resp, http.StatusOK)
+	b := body(t, resp)
+	if !strings.Contains(b, "Add MIT license") {
+		t.Error("patch view should show patch title after file upload")
+	}
+	if !strings.Contains(b, "MIT License") {
+		t.Error("patch diff should contain the license text")
+	}
+
+	// Find the file-based patch ID.
+	rp := repoPath(ts.cfg, "smoke")
+	patches, _ := listPatches(rp, "open")
+	if len(patches) != 1 {
+		t.Fatalf("expected 1 open file-based patch, got %d", len(patches))
+	}
+	filePatchID := patches[0].ID
+
+	// Merge the file-based patch via clone + git am + push.
+	resp = ts.postForm(t, c, "/smoke/patches/"+filePatchID+"/merge", url.Values{})
+	assertStatus(t, resp, http.StatusOK)
+
+	// ── 6. Create branch "update-readme" (from updated main) ──────
+	// Pull first so the local main has the merged license.
+	gitCmd(t, localRepo, "pull", "origin", "main")
+	gitCmd(t, localRepo, "checkout", "-b", "update-readme")
+	writeFile(t, localRepo, "README.md", "# Smoke Test\n\nThis is a smoke test repository.\n\n## License\n\nMIT — see [LICENSE](./LICENSE).\n")
+	gitCmd(t, localRepo, "add", "README.md")
+	gitCmd(t, localRepo, "commit", "-m", "update README with license info")
+	gitCmd(t, localRepo, "push", "origin", "update-readme")
+
+	// ── 7. Branch-based patch from "update-readme" ────────────────
+	resp = ts.postForm(t, c, "/smoke/patches/new", url.Values{
+		"title":  {"Update README (branch patch)"},
+		"body":   {"Add a License section to the README."},
+		"branch": {"update-readme"},
+	})
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "Update README") {
+		t.Error("patch view should show branch-based patch title")
+	}
+	if !strings.Contains(b, "LICENSE") {
+		t.Error("patch diff should show README changes referencing LICENSE")
+	}
+
+	// Find the branch-based patch ID.
+	patches, _ = listPatches(rp, "open")
+	if len(patches) != 1 {
+		t.Fatalf("expected 1 open branch-based patch, got %d", len(patches))
+	}
+	branchPatchID := patches[0].ID
+
+	// Merge the branch-based patch (fast-forward).
+	resp = ts.postForm(t, c, "/smoke/patches/"+branchPatchID+"/merge", url.Values{})
+	assertStatus(t, resp, http.StatusOK)
+
+	// ── 8. Tag main as v0.2.0 ─────────────────────────────────────
+	gitCmd(t, localRepo, "checkout", "main")
+	gitCmd(t, localRepo, "pull", "origin", "main")
+	gitCmd(t, localRepo, "tag", "-a", "v0.2.0", "-m", "second release")
+	gitCmd(t, localRepo, "push", "origin", "v0.2.0")
+
+	// ── 9. Verify everything in the web UI ────────────────────────
+
+	// --- Repo tree ---
+	resp = ts.get(t, c, "/smoke")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "README.md") {
+		t.Error("tree: should show README.md")
+	}
+	if !strings.Contains(b, "LICENSE") {
+		t.Error("tree: should show LICENSE file (merged from file patch)")
+	}
+
+	// --- Blob: README ---
+	resp = ts.get(t, c, "/smoke/blob/main/README.md")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "LICENSE") {
+		t.Error("blob: README should contain merged changes referencing LICENSE")
+	}
+
+	// --- Blob: LICENSE ---
+	resp = ts.get(t, c, "/smoke/blob/main/LICENSE")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "MIT License") {
+		t.Error("blob: LICENSE should contain license text from file patch")
+	}
+
+	// --- Commit log ---
+	resp = ts.get(t, c, "/smoke/log/main")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	for _, want := range []string{
+		"initial commit",
+		"add MIT license",
+		"update README",
+	} {
+		if !strings.Contains(b, want) {
+			t.Errorf("log: should contain commit %q", want)
+		}
+	}
+
+	// --- Refs page: branches and tags ---
+	resp = ts.get(t, c, "/smoke/refs/")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	for _, want := range []string{"main", "add-license"} {
+		if !strings.Contains(b, want) {
+			t.Errorf("refs: should list branch %q", want)
+		}
+	}
+	for _, want := range []string{"v0.1.0", "v0.2.0"} {
+		if !strings.Contains(b, want) {
+			t.Errorf("refs: should list tag %q", want)
+		}
+	}
+
+	// --- Patches list: both should be merged ---
+	resp = ts.get(t, c, "/smoke/patches?state=merged")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "Add MIT license") {
+		t.Error("patches: should list 'Add MIT license' as merged")
+	}
+	if !strings.Contains(b, "Update README") {
+		t.Error("patches: should list 'Update README' as merged")
+	}
+
+	// --- No open patches remain ---
+	resp = ts.get(t, c, "/smoke/patches?state=open")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	// The page should not list any open patches.
+	if strings.Contains(b, "Add MIT license") || strings.Contains(b, "Update README") {
+		// They could appear as "No open patches" is the expected but the names
+		// might still be in the branch selector. That's fine.
+	}
+
+	// --- Commit page for v0.2.0 tag ---
+	// Just check the tag tree works.
+	resp = ts.get(t, c, "/smoke/tree/v0.2.0")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "README.md") || !strings.Contains(b, "LICENSE") {
+		t.Error("tree at tag v0.2.0 should show both files")
+	}
+
+	// --- README rendering on tree page ---
+	resp = ts.get(t, c, "/smoke")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "smoke test repository") {
+		t.Error("tree: README content should be rendered on the repo page")
+	}
+
+	// --- Repo appears in home page list ---
+	resp = ts.get(t, c, "/")
+	assertStatus(t, resp, http.StatusOK)
+	b = body(t, resp)
+	if !strings.Contains(b, "smoke") {
+		t.Error("home page: should list the smoke repo")
+	}
+	if !strings.Contains(b, "End-to-end smoke test repository") {
+		t.Error("home page: should show repo description")
+	}
+}
+
+// writeFile creates a file with content, creating parent directories as needed.
+func writeFile(t *testing.T, dir, name, content string) {
+	t.Helper()
+	path := filepath.Join(dir, name)
+	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+		t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
+	}
+	if err := os.WriteFile(path, []byte(content), 0644); err != nil {
+		t.Fatalf("write %s: %v", path, err)
+	}
+}