package main
import (
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// A tracked directory's listing opens its history from its first entry.
func TestListingLeadsToHistory(t *testing.T) {
s, _ := repoTree(t)
h := s.handler()
entry := `<ul class="listing">` + "\n" + `<li class="log"><a href="?log"><(git log)</a></li>`
for _, p := range []string{"/", "/a/"} {
if body := get(h, p).Body.String(); !strings.Contains(body, entry) {
t.Errorf("%s: no history entry first:\n%s", p, body)
}
}
if body := get(h, "/untracked/").Body.String(); strings.Contains(body, "?log") {
t.Errorf("untracked directory leads to history:\n%s", body)
}
}
// A directory's history covers its subtree; a file's covers the file.
func TestHistoryPage(t *testing.T) {
s, dir := repoTree(t)
h := s.handler()
rec := get(h, "/?log")
body := rec.Body.String()
if rec.Code != http.StatusOK {
t.Fatalf("status %d:\n%s", rec.Code, body)
}
add, ren := strings.Index(body, "Add the docs"), strings.Index(body, "Rename the package <b>")
if add < 0 || ren < 0 || ren > add {
t.Errorf("want both commits, newest first:\n%s", body)
}
if !strings.Contains(body, "<p>The first line wraps here.<br>- a list item<br>- another</p>\n<p>A second paragraph.</p>") {
t.Errorf("body not in paragraphs:\n%s", body)
}
if !strings.Contains(body, `<span><(git log)</span>`) {
t.Errorf("no history crumb:\n%s", body)
}
// Each commit folds; its diff loads from the commit page, which the
// fold also links in its place.
c := head(t, dir, "HEAD")
for _, want := range []string{
`<li><details data-diff="?commit=` + c + `">` + "\n<summary><span class=\"own lead\"><span class=\"date\" title=\"" + c[:7],
`</span></span> <span class="subject">Rename the package <b></span></summary>` + "\n" + `<p class="own links">commit <a href="?commit=` + c + `">` + c + `</a> · <a class="then"`,
`<div class="diffs"><p class="own"><a href="?commit=` + c + `">diff</a></p></div>` + "\n</details></li>",
} {
if !strings.Contains(body, want) {
t.Errorf("no %q:\n%s", want, body)
}
}
if body := get(h, "/index.md?log").Body.String(); strings.Contains(body, "Rename") || !strings.Contains(body, "Add the docs") {
t.Errorf("file history holds other files' commits:\n%s", body)
}
if body := get(h, "/.gitignore?log").Body.String(); !strings.Contains(body, `<a href="/.gitignore">.gitignore</a>`) {
t.Errorf("dot file's crumb lost its dot:\n%s", body)
}
if body := get(h, "/a/b.go?log").Body.String(); !strings.Contains(body, "Rename") || !strings.Contains(body, `<a href="/a/b.go">b.go</a>`) {
t.Errorf("file history:\n%s", body)
}
if body := get(h, "/untracked/?log").Body.String(); !strings.Contains(body, "No commit touches untracked/.") {
t.Errorf("untracked history:\n%s", body)
}
}
// A commit with no message still folds: its line holds the hash and date
// alone. A message without a blank line is all subject.
func TestHistoryOddMessages(t *testing.T) {
s, dir := repoTree(t)
os.WriteFile(filepath.Join(dir, "index.md"), []byte("# Changed\n"), 0o644)
if out, err := exec.Command("git", "-C", dir, "-c", "user.name=t", "-c", "user.email=t@x",
"commit", "-qa", "--allow-empty-message", "-m", "").CombinedOutput(); err != nil {
t.Fatalf("git commit: %v\n%s", err, out)
}
os.WriteFile(filepath.Join(dir, "index.md"), []byte("# Again\n"), 0o644)
commitAll(t, dir, "One paragraph\nthat runs on")
body := get(s.handler(), "/index.md?log").Body.String()
empty, long := head(t, dir, "HEAD~1"), head(t, dir, "HEAD")
for _, want := range []string{
`<details data-diff="?commit=` + empty + `">`,
`</span></span> <span class="subject"></span></summary>`,
`<details data-diff="?commit=` + long + `">`,
`</span></span> <span class="subject">One paragraph that runs on</span></summary>`,
} {
if !strings.Contains(body, want) {
t.Errorf("no %q:\n%s", want, body)
}
}
}
// A directory without its slash keeps ?log through the redirect.
func TestHistoryRedirectKeepsQuery(t *testing.T) {
s, _ := repoTree(t)
if rec := get(s.handler(), "/a?log"); rec.Header().Get("Location") != "/a/?log" {
t.Errorf("redirect to %q", rec.Header().Get("Location"))
}
}
// Doc and code pages say at the top whether they match their last
// commit, which they name on hover; an edit since makes them changed.
func TestPageTopNamesLastCommit(t *testing.T) {
s, dir := repoTree(t)
h := s.handler()
for p, subject := range map[string]string{"/index.md": "Add the docs", "/a/b.go": "Rename the package <b>"} {
body := get(h, p).Body.String()
if !strings.Contains(body, `<details class="own latest" data-timeline="?timeline">`) || !strings.Contains(body, ` · `+subject+` · `) || !strings.Contains(body, `">current</span></summary>`) ||
!strings.Contains(body, `<div class="versions"><a href="?log"><(git log)</a></div>`) || strings.Contains(body, `class="own now"`) {
t.Errorf("%s: no top line with %q:\n%s", p, subject, body)
}
}
if id := head(t, dir, "HEAD:a/b.go"); blobID([]byte("package b\n"), len(id)) != id {
t.Errorf("blob id %s, git says %s", blobID([]byte("package b\n"), len(id)), id)
}
os.WriteFile(filepath.Join(dir, "a", "b.go"), []byte("package c\n"), 0o644)
if body := get(h, "/a/b.go").Body.String(); !strings.Contains(body, `">changed</span></summary>`) {
t.Errorf("edited page is not changed:\n%s", body)
}
if body := get(h, "/untracked/x.txt").Body.String(); strings.Contains(body, "?log") {
t.Errorf("untracked file has a top line:\n%s", body)
}
}
// Outside a repo nothing leads to history, and the page says why.
func TestNoRepoNoHistory(t *testing.T) {
needGit(t)
s, _, _ := serveTree(t)
h := s.handler()
if body := get(h, "/").Body.String(); strings.Contains(body, "?log") {
t.Errorf("history entry outside a repo:\n%s", body)
}
if rec := get(h, "/?log"); rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "not a git repository") {
t.Errorf("status %d:\n%s", rec.Code, rec.Body.String())
}
}
// A share shows history without trailers, and only while other users
// could read the repo. Its errors name no path.
func TestShareShowsHistory(t *testing.T) {
needGit(t)
s := shareTree(t)
dir := s.list()[0].dir
if s.list()[0].secret {
dir = s.list()[1].dir
}
exec.Command("git", "init", "-q", dir).Run()
commitAll(t, dir, "Shared subject\n\nThe body.\n\nClaude-Session: https://claude.ai/code/session_x\nSigned-off-by: t <t@x>")
gitDir := filepath.Join(dir, ".git")
exec.Command("chmod", "-R", "go+rX", gitDir).Run()
h := s.handler()
if body := get(h, "/docs/").Body.String(); !strings.Contains(body, `href="?log"`) {
t.Errorf("share listing leads to no history:\n%s", body)
}
body := get(h, "/docs/?log").Body.String()
if !strings.Contains(body, "Shared subject") || !strings.Contains(body, "<p>The body.</p>") {
t.Errorf("share history:\n%s", body)
}
if strings.Contains(body, "claude.ai") || strings.Contains(body, "t@x") {
t.Errorf("share history shows trailers:\n%s", body)
}
os.Chmod(filepath.Join(gitDir, "objects"), 0o700)
if body := get(h, "/docs/").Body.String(); strings.Contains(body, "?log") {
t.Errorf("history of a closed repo:\n%s", body)
}
rec := get(h, "/docs/?log")
if rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "No git history here.") || strings.Contains(rec.Body.String(), dir) {
t.Errorf("closed repo: %d\n%s", rec.Code, rec.Body.String())
}
}
// A commit page shows the message and the diff of the path it came from,
// and links to the commit across the tree.
func TestCommitPage(t *testing.T) {
s, dir := repoTree(t)
h := s.handler()
c := head(t, dir, "HEAD")
body := get(h, "/a/b.go?commit="+c).Body.String()
for _, want := range []string{
"Rename the package <b>",
`</span> · <span class="hash">` + c + `</span></p>`,
`<span class="del">-func F() {}</span><span class="add">+package b</span>`,
`<a href="/?commit=` + c + `">All files</a>`,
`<p class="own file"><a href="/a/b.go">a/b.go</a></p>`,
`<a href="/a/b.go?log"><(git log)</a>`,
"</div>\n<div class=\"diffs\">\n<p class=\"own\">Changes to a/b.go.",
"</pre>\n</div>\n",
} {
if !strings.Contains(body, want) {
t.Errorf("no %q:\n%s", want, body)
}
}
if body := get(h, "/index.md?commit="+c).Body.String(); !strings.Contains(body, "It does not change index.md.") {
t.Errorf("commit that skips the file:\n%s", body)
}
first := get(h, "/?commit="+head(t, dir, "HEAD~1")).Body.String()
if !strings.Contains(first, `<a href="/index.md">index.md</a>`) || !strings.Contains(first, `<a href="/a/b.go">a/b.go</a>`) || strings.Contains(first, "All files") {
t.Errorf("whole commit:\n%s", first)
}
for _, bad := range []string{"zz", "-p", head(t, dir, "HEAD:a/b.go")} {
if rec := get(h, "/?commit="+bad); rec.Code != http.StatusNotFound {
t.Errorf("%s: status %d:\n%s", bad, rec.Code, rec.Body.String())
}
}
}
func TestDiffName(t *testing.T) {
for in, want := range map[string]string{
"a/x.md b/x.md": "x.md",
"a/two words b/two words": "two words",
`"a/q\"uote" "b/q\"uote"`: `q"uote`,
`"a/tab\there.md" "b/tab\there.md"`: "tab\there.md",
} {
if got, ok := diffName(in); !ok || got != want {
t.Errorf("%q: %q %v, want %q", in, got, ok, want)
}
}
}
// A share's diffs leave out dot files.
func TestShareDiffSkipsDots(t *testing.T) {
needGit(t)
s := shareTree(t)
dir := s.list()[0].dir
if s.list()[0].secret {
dir = s.list()[1].dir
}
exec.Command("git", "init", "-q", dir).Run()
os.WriteFile(filepath.Join(dir, ".env"), []byte("TOKEN=hidden\n"), 0o644)
commitAll(t, dir, "Shared")
exec.Command("chmod", "-R", "go+rX", filepath.Join(dir, ".git")).Run()
body := get(s.handler(), "/docs/?commit="+head(t, dir, "HEAD")).Body.String()
if !strings.Contains(body, "+docs/pub.txt") || strings.Contains(body, "hidden") {
t.Errorf("share diff:\n%s", body)
}
}
// A history that git was slow to give names the command that writes
// changed-path filters, until the repo has them. A share never names it.
func TestSlowHistoryNamesCommitGraph(t *testing.T) {
s, dir := repoTree(t)
h := s.handler()
defer func(d time.Duration) { slowGit = d }(slowGit)
slowGit = -1
hint := "git commit-graph write --reachable --changed-paths"
if body := get(h, "/?log").Body.String(); !strings.Contains(body, hint) {
t.Errorf("no hint without filters:\n%s", body)
}
if out, err := exec.Command("git", "-C", dir, "commit-graph", "write", "--reachable", "--changed-paths").CombinedOutput(); err != nil {
t.Fatalf("commit-graph: %v\n%s", err, out)
}
if body := get(h, "/index.md?log").Body.String(); strings.Contains(body, hint) {
t.Errorf("hint with filters:\n%s", body)
}
os.Remove(filepath.Join(dir, ".git", "objects", "info", "commit-graph"))
if out, err := exec.Command("git", "-C", dir, "commit-graph", "write", "--reachable", "--split").CombinedOutput(); err != nil {
t.Fatalf("commit-graph: %v\n%s", err, out)
}
if body := get(h, "/a/?log").Body.String(); !strings.Contains(body, hint) {
t.Errorf("no hint for a split graph without filters:\n%s", body)
}
if out, err := exec.Command("git", "-C", dir, "commit-graph", "write", "--reachable", "--split=replace", "--changed-paths").CombinedOutput(); err != nil {
t.Fatalf("commit-graph: %v\n%s", err, out)
}
if body := get(h, "/a/b.go?log").Body.String(); strings.Contains(body, hint) {
t.Errorf("hint for a split graph with filters:\n%s", body)
}
}