261101.5 · 4c2d0371693dc2f3c3f3293bd77a9e2267d527d3
git.go splits off the history pages
git.go held both running git and the pages built from it. The timeline, log and commit pages and their diffs move to historypage.go with their tests. git.go keeps running git, reading commits and the cache warm.
Declarations moved unchanged, as a sorted comparison of every line outside the import blocks confirms. git blame -C -C traces them.
reading declarations
@@ -8,17 +8,12 @@ import ( "encoding/hex" "errors" "fmt"- "html/template" "io" "io/fs"- "net/http"- "net/url" "os" "os/exec"- "path" "path/filepath" "regexp"- "strconv" "strings" "time" )@@ -404,84 +399,6 @@ func (t *tree) gitHead(name string) (gitDir, state string) { return r.gitDir, r.gitDir + "\n" + r.head(ctx) } -// commitLine is label, a commit's stardate as a history or a commit page-// shows it, with its hash and exact time on hover.-func commitLine(label string, c *commit) string {- return fmt.Sprintf("<span class=\"date\" title=\"%s %s\">%s</span>", template.HTMLEscapeString(c.short), exact(c.date), template.HTMLEscapeString(label))-}--// starsFor are the stardates of the repo name lies in. A version reads-// its tree's own repo.-func (t *tree) starsFor(ctx context.Context, name string) *stardates {- if t.at != nil {- return stardatesIn(ctx, t.dir)- }- dir, _, err := t.gitPlace(name)- if err != nil {- return nil- }- return stardatesIn(ctx, dir)-}--// logLine is the line at the top of a doc or code page in a repo, or ""-// when no commit touched the page. b is the page's file as read.-func (t *tree) logLine(ctx context.Context, name string, b []byte) string {- if t.at != nil {- return t.versionLine(ctx, name, false)- }- c := t.lastCommit(ctx, name)- if c == nil {- return ""- }- state := "current"- if t.pageChanged(ctx, name, b) {- state = "changed"- }- return topLine(c, t.starsFor(ctx, name).label(c), state, "", "", "", "")-}--// topLine is the line at the top of a page in a repo, and the whole line-// is one target: it opens a menu of the page's versions, which loads from-// base?timeline. On the page as it is, the line says only state, current-// or changed, since where the reader is matters more than which commit;-// the commit, by star, its stardate or hash, and subject, is on hover.-// On a version, the line names the commit by star and subject, and now-// and show, links outside the line, lead to the page as it is and to the-// commit's page, which names its full hash. here, when not "", is the URL-// of the version shown, which the menu marks. Without the script the-// menu holds a link to the history.-func topLine(c *commit, star, state, base, here, now, show string) string {- esc := template.HTMLEscapeString- what := star + " · " + subjectOf(c)- // The stardate leads, as it does in histories and the version menu.- line := fmt.Sprintf("<span class=\"date\" title=\"%s %s\">%s</span> · <span class=\"subject\" title=\"%s %s\">%s</span>",- esc(c.short), exact(c.date), esc(star), esc(c.short), exact(c.date), esc(subjectOf(c)))- if state != "" {- line = fmt.Sprintf("<span class=\"state\" title=\"%s · %s %s\">%s</span>", esc(what), esc(c.short), exact(c.date), state)- }- if here != "" {- here = " data-here=\"" + esc(here) + "\""- }- // The links to the page as it is and to the commit float right,- // before the line, so they stay out of the line's one target.- out := ""- if now != "" {- out = fmt.Sprintf("<a class=\"own now\" href=\"%s\">current</a>\n", esc(now))- }- if show != "" {- out += fmt.Sprintf("<a class=\"own now\" href=\"%s\">commit</a>\n", esc(show))- }- // The whole line opens the timeline, so no word labels it; the name- // shows on hover and is read out first.- label := "timeline, " + state + ", " + what- if state == "" {- label = "timeline, " + what- }- return out + fmt.Sprintf("<details class=\"own latest\" data-timeline=\"%s?timeline\"%s>\n<summary title=\"timeline\" aria-label=\"%s\">%s</summary>\n"+- "<div class=\"versions\"><a href=\"%s?log\">%s</a></div>\n</details>\n",- esc(base), here, esc(label), line, esc(base), esc(logLabel))-}- // pageChanged reports whether b, the file of the page at name, differs // from its last commit. Git's id for b, against the id HEAD keeps for // the path, answers in microseconds; git status, which took 16 ms on@@ -540,442 +457,3 @@ func (t *tree) changed(ctx context.Context, name string) bool { out, err := git(ctx, dir, "status", "--porcelain", "-z", "--", spec) return err == nil && len(out) > 0 }--// subjectOf is what a link to a commit says: its subject, or its hash-// when the message is empty, so the link stays visible.-func subjectOf(c *commit) string {- if c.subject == "" {- return c.short- }- return c.subject-}--// exact is a commit's time to the minute, as hover shows it.-func exact(t time.Time) string {- return t.Local().Format("2006-01-02 15:04")-}--// serveTimeline lists every version of name, newest first, for the menu-// on its top line: each commit that touched it, by subject and stardate, then-// its history. The newest commit's row leads to the current page and says-// so, unless the page has changes not yet committed; then a row of its-// own leads there and says that. A page whose history the tree's-// versions do not hold, eg in a share or a nested repo, links each-// commit's page instead.-func (t *tree) serveTimeline(w http.ResponseWriter, r *http.Request, name string, dir bool) {- crumbs := append(t.pageCrumbs(name, dir), crumb{Label: "timeline"})- title := "timeline " + t.title(name)- log, err := t.gitLog(r.Context(), name, maxLog+1)- if err != nil {- t.render(w, r, http.StatusNotFound, page{Title: title, Crumbs: crumbs, Kind: "error",- Body: template.HTML("<pre class=\"error\">" + template.HTMLEscapeString(err.Error()) + "</pre>")})- return- }- if len(log) > maxLog {- log = log[:maxLog]- }- rest := below(name, dir)- versions := t.historyInVersions(r.Context(), name)- stars := t.starsFor(r.Context(), name)- esc := template.HTMLEscapeString- var body strings.Builder- // Each row leads with its stardate, so they line up down the menu.- labels := make([]string, len(log))- for i := range log {- labels[i] = stars.rowLabel(&log[i])- }- body.WriteString("<ol class=\"versions\"" + leadStyle(labels) + ">\n")- current := (&url.URL{Path: t.prefix + rest}).String()- clean := versions && !t.changed(r.Context(), name)- if versions && !clean {- fmt.Fprintf(&body, "<li><span class=\"date lead\"></span> <a href=\"%s\">current</a> <span class=\"date\">uncommitted changes</span></li>\n", esc(current))- }- for i := range log {- c := &log[i]- href, also, note := "?commit="+c.hash, "", ""- if versions {- href = t.versionURL(stars, c, rest)- }- // The current page is the newest commit's version, so its row- // leads there and is marked on either page.- if clean && i == 0 {- href, also, note = current, fmt.Sprintf(" data-also=\"%s\"", esc(href)), " · current"- }- if note != "" {- note = " <span class=\"date\">current</span>"- }- fmt.Fprintf(&body, "<li%s><span class=\"date lead\">%s</span> <a href=\"%s\" title=\"%s %s\">%s</a>%s</li>\n",- also, esc(labels[i]), esc(href), esc(c.short), exact(c.date), esc(subjectOf(c)), note)- }- fmt.Fprintf(&body, "<li class=\"log\"><a href=\"?log\">%s</a></li>\n</ol>\n", esc(logLabel))- t.render(w, r, http.StatusOK, page{Title: title, Crumbs: crumbs, Kind: "timeline", Body: template.HTML(body.String())})-}--// serveLog lists the commits that touched name, newest first, each with-// its hash, date, subject and body.-func (t *tree) serveLog(w http.ResponseWriter, r *http.Request, name string, dir bool) {- crumbs := append(t.pageCrumbs(name, dir), crumb{Label: logLabel})- title := "git log " + t.title(name)- start := time.Now()- log, err := t.gitLog(r.Context(), name, maxLog+1)- took := time.Since(start)- if err != nil {- t.render(w, r, http.StatusNotFound, page{Title: title, Crumbs: crumbs, Kind: "error",- Body: template.HTML("<pre class=\"error\">" + template.HTMLEscapeString(err.Error()) + "</pre>")})- return- }- stars := t.starsFor(r.Context(), name)- esc := template.HTMLEscapeString- var body strings.Builder- if len(log) == 0 {- shown := t.title(name)- if dir && name != "." {- shown += "/"- }- body.WriteString("<p class=\"own\">No commit touches " + esc(shown) + ".</p>\n")- }- if len(log) > maxLog {- log = log[:maxLog]- fmt.Fprintf(&body, "<p class=\"own\">The %d newest commits. <code>git log</code> lists the rest.</p>\n", maxLog)- }- // git walks every commit back to the ones that touched name, unless- // the repo's commit-graph tells it which paths each commit changed.- // www leaves the repo alone and names the command instead. A share- // says nothing, since its viewer cannot run it.- if took > slowGit && !t.shared && t.lacksPathFilters(r.Context(), name) {- fmt.Fprintf(&body, "<p class=\"own\">git took %d ms for this history. "+- "<code>git commit-graph write --reachable --changed-paths</code> in the repo makes it several times faster.</p>\n",- took.Milliseconds())- }- // Each commit folds out to its message and its diff. The diff comes- // from the commit page when the fold opens, through the one script;- // without it, the link in its place opens that page. A row is one- // target, the fold, so the links to the commit and its version wait- // inside it. A local tree links each commit's version of the page:- // the file or directory as it was then.- browse := t.historyInVersions(r.Context(), name)- // Each row leads with its stardate, so they line up down the history.- labels := make([]string, len(log))- for i := range log {- labels[i] = stars.rowLabel(&log[i])- }- body.WriteString("<ol class=\"log\"" + leadStyle(labels) + ">\n")- for i := range log {- c := &log[i]- show := esc("?commit=" + c.hash)- then := ""- if browse {- then = fmt.Sprintf(" · <a class=\"then\" href=\"%s\">browse</a>", esc(t.versionURL(stars, c, below(name, dir))))- }- fmt.Fprintf(&body, "<li><details data-diff=\"%s\">\n<summary><span class=\"own lead\">%s</span> <span class=\"subject\">%s</span></summary>\n"+- "<p class=\"own links\">commit <a href=\"%s\">%s</a>%s</p>\n",- show, commitLine(labels[i], c), esc(c.subject), show, esc(c.hash), then)- for _, para := range paragraphs(c.body) {- body.WriteString("<p>" + para + "</p>\n")- }- fmt.Fprintf(&body, "<div class=\"diffs\"><p class=\"own\"><a href=\"%s\">diff</a></p></div>\n</details></li>\n", show)- }- body.WriteString("</ol>\n")- t.render(w, r, http.StatusOK, page{Title: title, Crumbs: crumbs, Kind: "log", Body: template.HTML(body.String())})-}--// pageCrumbs are the crumbs of the page at name, the last a link back to-// it, for a page that hangs below it.-func (t *tree) pageCrumbs(name string, dir bool) []crumb {- crumbs := t.crumbs(name)- href := name- switch {- case name == ".":- href = ""- case dir:- href += "/"- }- crumbs[len(crumbs)-1].Href = (&url.URL{Path: t.prefix + href}).String()- return crumbs-}--// maxDiff caps the bytes of diff a commit page shows.-const maxDiff = 1 << 20--var hexHash = regexp.MustCompile(`^[0-9a-f]{4,64}$`)--// fileDiff is one file's part of a commit's diff: its name in the tree-// and the diff's lines below git's header. A file whose type changed has-// two.-type fileDiff struct {- name string // relative to the tree- rel string // relative to where git runs- lines []string-}--// gitShow reads the commit that hash names and its diff, limited to name.-// A share leaves out files whose names begin with a dot. cut reports a-// diff cut at maxDiff.-func (t *tree) gitShow(ctx context.Context, name, hash string) (c *commit, diffs []fileDiff, cut bool, err error) {- dir, spec, err := t.gitPlace(name)- if err != nil {- return nil, nil, false, t.gitErr(err)- }- if t.shared && !openRepo(ctx, dir) {- return nil, nil, false, errNoHistory- }- // A commit never changes, so its diff is kept under its full hash.- var key string- if r := repoAt(ctx, dir); r.complaint == "" && fullHash(hash) {- key = fmt.Sprintf("show\x00%s\x00%s\x00%s\x00%s\x00%s\x00%t", r.gitDir, dir, spec, name, hash, t.shared)- if v, ok := gitCache.get(key); ok {- s := v.(shown)- return &s.c, s.diffs, s.cut, nil- }- }- // ^{commit} refuses a blob or a tree, which would show a file's text.- rev := hash + "^{commit}"- show := func(args ...string) ([]byte, error) {- args = append([]string{"show", "--no-ext-diff", "--no-textconv", "--no-show-signature", "--no-color",- "--no-renames", "--diff-merges=first-parent", "--relative", "--src-prefix=a/", "--dst-prefix=b/"}, args...)- return git(ctx, dir, args...)- }- out, err := show("-s", "-z", "--format="+commitFormat, rev, "--")- if err != nil {- return nil, nil, false, t.gitErr(err)- }- log := t.commits(out)- if len(log) == 0 {- return nil, nil, false, t.gitErr(errors.New("no commit " + hash))- }- out, err = show("--format=", "--patch", rev, "--", spec)- if err != nil {- return nil, nil, false, t.gitErr(err)- }- if len(out) > maxDiff {- out, cut = out[:bytes.LastIndexByte(out[:maxDiff], '\n')+1], true- }- for _, b := range strings.Split("\n"+string(out), "\ndiff --git ")[1:] {- head, _, _ := strings.Cut(b, "\n")- f, ok := diffName(head)- if !ok || t.shared && hasDotName(f) {- continue- }- rel := f- switch {- case spec != ".":- f = name- case name != ".":- f = path.Join(name, f)- }- diffs = append(diffs, fileDiff{name: f, rel: rel, lines: diffLines(b)})- }- if key != "" {- c := log[0]- size := int64(len(c.hash)+len(c.short)+len(c.subject)+len(c.body)) + 64- for _, d := range diffs {- size += int64(len(d.name)) + 16- for _, l := range d.lines {- size += int64(len(l)) + 16- }- }- gitCache.put(key, shown{c, diffs, cut}, size)- }- return &log[0], diffs, cut, nil-}--// shown is a commit and its diff as gitShow keeps them.-type shown struct {- c commit- diffs []fileDiff- cut bool-}--// fullHash reports whether hash is a whole SHA-1 or SHA-256 name in hex,-// which no later commit can make ambiguous.-func fullHash(hash string) bool {- if len(hash) != 40 && len(hash) != 64 {- return false- }- return !strings.ContainsFunc(hash, func(r rune) bool { return !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') })-}--// diffName reads the file's name from the header of its diff, a/name-// b/name, which git quotes as C does when the name holds a quote, a-// backslash or a control character. Without renames both names are one.-func diffName(head string) (string, bool) {- if !strings.HasPrefix(head, `"`) {- n := (len(head) - len("a/ b/")) / 2- if n < 1 || !strings.HasPrefix(head, "a/") {- return "", false- }- return head[2 : 2+n], true- }- for i := 1; i < len(head); i++ {- switch head[i] {- case '\\':- i++- case '"':- f, err := strconv.Unquote(head[:i+1])- return strings.TrimPrefix(f, "a/"), err == nil && strings.HasPrefix(f, "a/")- }- }- return "", false-}--// diffLines drops the header git puts above a file's diff, which names-// the file again, and keeps the lines that say what kind of change it is.-func diffLines(block string) []string {- var out []string- lines := strings.Split(strings.TrimSuffix(block, "\n"), "\n")[1:]- for i, line := range lines {- if strings.HasPrefix(line, "@@") {- return append(out, lines[i:]...)- }- switch {- case strings.HasPrefix(line, "index "), strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "+++ "):- case strings.HasPrefix(line, "Binary files "):- out = append(out, "Binary file changed")- default:- out = append(out, line)- }- }- return out-}--// serveCommit shows the commit that hash names: its message and its diff-// limited to name, with a link to the diff across the whole tree. A-// history's fold takes the diff from div.diffs.-func (t *tree) serveCommit(w http.ResponseWriter, r *http.Request, name string, dir bool, hash string) {- crumbs := t.pageCrumbs(name, dir)- crumbs = append(crumbs, crumb{Label: logLabel, Href: crumbs[len(crumbs)-1].Href + "?log"}, crumb{Label: hash})- title := "git show " + hash + " " + t.title(name)- fail := func(err error) {- t.render(w, r, http.StatusNotFound, page{Title: title, Crumbs: crumbs, Kind: "error",- Body: template.HTML("<pre class=\"error\">" + template.HTMLEscapeString(err.Error()) + "</pre>")})- }- if !hexHash.MatchString(hash) {- fail(errors.New("Not a commit hash: " + hash))- return- }- c, diffs, cut, err := t.gitShow(r.Context(), name, hash)- if err != nil {- fail(err)- return- }- crumbs[len(crumbs)-1].Label = c.short- esc := template.HTMLEscapeString- var body strings.Builder- // The commit's full hash follows its stardate as plain text, so it- // can be selected and given to git.- fmt.Fprintf(&body, "<div class=\"commit\">\n<p class=\"own\">%s · <span class=\"hash\">%s</span></p>\n<p class=\"subject\">%s</p>\n", commitLine(t.starsFor(r.Context(), name).label(c), c), esc(c.hash), esc(c.subject))- for _, para := range paragraphs(c.body) {- body.WriteString("<p>" + para + "</p>\n")- }- body.WriteString("</div>\n")- // The semantic diff comes inline when it is kept or asked for with- // &semantic; otherwise the page asks for it once it shows the plain- // diff.- var sem *semantic- _, asked := r.URL.Query()["semantic"]- if key := t.semanticKey(name, c.hash); key != "" {- if v, ok := semanticCache.get(key); ok {- sem = v.(*semantic)- }- }- if sem == nil && asked {- sem = t.semanticDiff(r.Context(), name, c.hash, diffs)- }- switch {- case sem != nil:- body.WriteString(t.semanticHTML(r.URL.Path, c, sem))- case len(diffs) > 0:- q := r.URL.Query()- q.Set("commit", c.hash)- q.Set("semantic", "")- fmt.Fprintf(&body, "<div class=\"semantic\" data-src=\"%s\"><p class=\"own\">reading declarations</p></div>\n", esc((&url.URL{Path: r.URL.Path, RawQuery: q.Encode()}).String()))- }- body.WriteString("<div class=\"diffs\">\n")- all := (&url.URL{Path: t.prefix, RawQuery: "commit=" + c.hash}).String()- shown := name- if dir {- shown += "/"- }- switch {- case name != "." && len(diffs) == 0:- fmt.Fprintf(&body, "<p class=\"own\">It does not change %s. <a href=\"%s\">All files</a></p>\n", esc(shown), esc(all))- case name != ".":- fmt.Fprintf(&body, "<p class=\"own\">Changes to %s. <a href=\"%s\">All files</a></p>\n", esc(shown), esc(all))- case len(diffs) == 0:- body.WriteString("<p class=\"own\">It changes no file here.</p>\n")- }- for _, d := range diffs {- label := esc(d.name)- if _, err := fs.Stat(t.fsys, d.name); err == nil {- label = fmt.Sprintf("<a href=\"%s\">%s</a>", esc((&url.URL{Path: t.prefix + d.name}).String()), label)- }- var marks map[int][][2]int- if sem != nil {- marks = sem.marks[d.name]- }- fmt.Fprintf(&body, "<p class=\"own file\">%s</p>\n%s", label, diffPre(d.lines, marks))- }- if cut {- fmt.Fprintf(&body, "<p class=\"own\">The diff stops at %d KiB. <code>git show %s</code> shows the rest.</p>\n", maxDiff>>10, esc(c.short))- }- body.WriteString("</div>\n")- t.render(w, r, http.StatusOK, page{Title: title, Crumbs: crumbs, Kind: "commit", Body: template.HTML(body.String())})-}--// diffPre renders a diff's lines, each a block tinted by its kind, with-// the changed byte ranges marks gives by line index.-func diffPre(lines []string, marks map[int][][2]int) string {- var b strings.Builder- b.WriteString("<pre class=\"diff\">")- for i, line := range lines {- class := "meta"- switch {- case strings.HasPrefix(line, "@@"):- class = "hunk"- case strings.HasPrefix(line, "+"):- class = "add"- case strings.HasPrefix(line, "-"):- class = "del"- case strings.HasPrefix(line, " "):- class = "same"- }- fmt.Fprintf(&b, "<span class=\"%s\">%s</span>", class, markLine(line, marks[i]))- }- b.WriteString("</pre>\n")- return b.String()-}--// paragraphs splits a commit body at blank lines and joins the lines of-// each, since most bodies are wrapped by hand to 72 columns. A line that-// starts a list item or is indented keeps its own line.-func paragraphs(body string) []string {- var out []string- for _, para := range strings.Split(body, "\n\n") {- var b strings.Builder- for i, line := range strings.Split(strings.Trim(para, "\n"), "\n") {- if i > 0 {- if listLine(line) {- b.WriteString("<br>")- } else {- b.WriteString(" ")- }- }- b.WriteString(template.HTMLEscapeString(strings.TrimRight(line, " \t")))- }- if b.Len() > 0 {- out = append(out, b.String())- }- }- return out-}--func listLine(line string) bool {- if line == "" || line[0] == ' ' || line[0] == '\t' {- return true- }- if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") || strings.HasPrefix(line, "+ ") {- return true- }- num := strings.TrimLeft(line, "0123456789")- return len(num) < len(line) && (strings.HasPrefix(num, ". ") || strings.HasPrefix(num, ") "))-}
@@ -2,13 +2,11 @@ package main import ( "fmt"- "net/http" "os" "os/exec" "path/filepath" "strings" "testing"- "time" ) func needGit(t *testing.T) {@@ -46,170 +44,6 @@ func repoTree(t *testing.T) (*server, string) { return s, dir } -// 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())- }-}- // Loopback keeps trailers. func TestLoopbackKeepsTrailers(t *testing.T) { s, dir := repoTree(t)@@ -236,20 +70,6 @@ func TestDropTrailers(t *testing.T) { } } -// A commit reloads an open history page.-func TestLiveReloadsOnCommit(t *testing.T) {- needGit(t)- s, _, dir, ts := liveTree(t)- exec.Command("git", "init", "-q", dir).Run()- commitAll(t, dir, "First")- lines := stream(t, s, ts, "/a/")- time.Sleep(50 * time.Millisecond)- exec.Command("git", "-C", dir, "-c", "user.name=t", "-c", "user.email=t@x", "commit", "-q", "--allow-empty", "-m", "Second").Run()- if !reloads(lines, 2*time.Second) {- t.Fatal("no reload after a commit")- }-}- // head is the hash of HEAD in dir. func head(t *testing.T, dir, rev string) string { t.Helper()@@ -260,104 +80,6 @@ func head(t *testing.T, dir, rev string) string { return strings.TrimSpace(string(out)) } -// 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)- }-}- // A new tree's listing and README have their top lines cached before the // first visit. func TestWarmCachesFirstPages(t *testing.T) {
@@ -0,0 +1,534 @@+package main++import (+ "bytes"+ "context"+ "errors"+ "fmt"+ "html/template"+ "io/fs"+ "net/http"+ "net/url"+ "path"+ "regexp"+ "strconv"+ "strings"+ "time"+)++// commitLine is label, a commit's stardate as a history or a commit page+// shows it, with its hash and exact time on hover.+func commitLine(label string, c *commit) string {+ return fmt.Sprintf("<span class=\"date\" title=\"%s %s\">%s</span>", template.HTMLEscapeString(c.short), exact(c.date), template.HTMLEscapeString(label))+}++// starsFor are the stardates of the repo name lies in. A version reads+// its tree's own repo.+func (t *tree) starsFor(ctx context.Context, name string) *stardates {+ if t.at != nil {+ return stardatesIn(ctx, t.dir)+ }+ dir, _, err := t.gitPlace(name)+ if err != nil {+ return nil+ }+ return stardatesIn(ctx, dir)+}++// logLine is the line at the top of a doc or code page in a repo, or ""+// when no commit touched the page. b is the page's file as read.+func (t *tree) logLine(ctx context.Context, name string, b []byte) string {+ if t.at != nil {+ return t.versionLine(ctx, name, false)+ }+ c := t.lastCommit(ctx, name)+ if c == nil {+ return ""+ }+ state := "current"+ if t.pageChanged(ctx, name, b) {+ state = "changed"+ }+ return topLine(c, t.starsFor(ctx, name).label(c), state, "", "", "", "")+}++// topLine is the line at the top of a page in a repo, and the whole line+// is one target: it opens a menu of the page's versions, which loads from+// base?timeline. On the page as it is, the line says only state, current+// or changed, since where the reader is matters more than which commit;+// the commit, by star, its stardate or hash, and subject, is on hover.+// On a version, the line names the commit by star and subject, and now+// and show, links outside the line, lead to the page as it is and to the+// commit's page, which names its full hash. here, when not "", is the URL+// of the version shown, which the menu marks. Without the script the+// menu holds a link to the history.+func topLine(c *commit, star, state, base, here, now, show string) string {+ esc := template.HTMLEscapeString+ what := star + " · " + subjectOf(c)+ // The stardate leads, as it does in histories and the version menu.+ line := fmt.Sprintf("<span class=\"date\" title=\"%s %s\">%s</span> · <span class=\"subject\" title=\"%s %s\">%s</span>",+ esc(c.short), exact(c.date), esc(star), esc(c.short), exact(c.date), esc(subjectOf(c)))+ if state != "" {+ line = fmt.Sprintf("<span class=\"state\" title=\"%s · %s %s\">%s</span>", esc(what), esc(c.short), exact(c.date), state)+ }+ if here != "" {+ here = " data-here=\"" + esc(here) + "\""+ }+ // The links to the page as it is and to the commit float right,+ // before the line, so they stay out of the line's one target.+ out := ""+ if now != "" {+ out = fmt.Sprintf("<a class=\"own now\" href=\"%s\">current</a>\n", esc(now))+ }+ if show != "" {+ out += fmt.Sprintf("<a class=\"own now\" href=\"%s\">commit</a>\n", esc(show))+ }+ // The whole line opens the timeline, so no word labels it; the name+ // shows on hover and is read out first.+ label := "timeline, " + state + ", " + what+ if state == "" {+ label = "timeline, " + what+ }+ return out + fmt.Sprintf("<details class=\"own latest\" data-timeline=\"%s?timeline\"%s>\n<summary title=\"timeline\" aria-label=\"%s\">%s</summary>\n"++ "<div class=\"versions\"><a href=\"%s?log\">%s</a></div>\n</details>\n",+ esc(base), here, esc(label), line, esc(base), esc(logLabel))+}++// subjectOf is what a link to a commit says: its subject, or its hash+// when the message is empty, so the link stays visible.+func subjectOf(c *commit) string {+ if c.subject == "" {+ return c.short+ }+ return c.subject+}++// exact is a commit's time to the minute, as hover shows it.+func exact(t time.Time) string {+ return t.Local().Format("2006-01-02 15:04")+}++// serveTimeline lists every version of name, newest first, for the menu+// on its top line: each commit that touched it, by subject and stardate, then+// its history. The newest commit's row leads to the current page and says+// so, unless the page has changes not yet committed; then a row of its+// own leads there and says that. A page whose history the tree's+// versions do not hold, eg in a share or a nested repo, links each+// commit's page instead.+func (t *tree) serveTimeline(w http.ResponseWriter, r *http.Request, name string, dir bool) {+ crumbs := append(t.pageCrumbs(name, dir), crumb{Label: "timeline"})+ title := "timeline " + t.title(name)+ log, err := t.gitLog(r.Context(), name, maxLog+1)+ if err != nil {+ t.render(w, r, http.StatusNotFound, page{Title: title, Crumbs: crumbs, Kind: "error",+ Body: template.HTML("<pre class=\"error\">" + template.HTMLEscapeString(err.Error()) + "</pre>")})+ return+ }+ if len(log) > maxLog {+ log = log[:maxLog]+ }+ rest := below(name, dir)+ versions := t.historyInVersions(r.Context(), name)+ stars := t.starsFor(r.Context(), name)+ esc := template.HTMLEscapeString+ var body strings.Builder+ // Each row leads with its stardate, so they line up down the menu.+ labels := make([]string, len(log))+ for i := range log {+ labels[i] = stars.rowLabel(&log[i])+ }+ body.WriteString("<ol class=\"versions\"" + leadStyle(labels) + ">\n")+ current := (&url.URL{Path: t.prefix + rest}).String()+ clean := versions && !t.changed(r.Context(), name)+ if versions && !clean {+ fmt.Fprintf(&body, "<li><span class=\"date lead\"></span> <a href=\"%s\">current</a> <span class=\"date\">uncommitted changes</span></li>\n", esc(current))+ }+ for i := range log {+ c := &log[i]+ href, also, note := "?commit="+c.hash, "", ""+ if versions {+ href = t.versionURL(stars, c, rest)+ }+ // The current page is the newest commit's version, so its row+ // leads there and is marked on either page.+ if clean && i == 0 {+ href, also, note = current, fmt.Sprintf(" data-also=\"%s\"", esc(href)), " · current"+ }+ if note != "" {+ note = " <span class=\"date\">current</span>"+ }+ fmt.Fprintf(&body, "<li%s><span class=\"date lead\">%s</span> <a href=\"%s\" title=\"%s %s\">%s</a>%s</li>\n",+ also, esc(labels[i]), esc(href), esc(c.short), exact(c.date), esc(subjectOf(c)), note)+ }+ fmt.Fprintf(&body, "<li class=\"log\"><a href=\"?log\">%s</a></li>\n</ol>\n", esc(logLabel))+ t.render(w, r, http.StatusOK, page{Title: title, Crumbs: crumbs, Kind: "timeline", Body: template.HTML(body.String())})+}++// serveLog lists the commits that touched name, newest first, each with+// its hash, date, subject and body.+func (t *tree) serveLog(w http.ResponseWriter, r *http.Request, name string, dir bool) {+ crumbs := append(t.pageCrumbs(name, dir), crumb{Label: logLabel})+ title := "git log " + t.title(name)+ start := time.Now()+ log, err := t.gitLog(r.Context(), name, maxLog+1)+ took := time.Since(start)+ if err != nil {+ t.render(w, r, http.StatusNotFound, page{Title: title, Crumbs: crumbs, Kind: "error",+ Body: template.HTML("<pre class=\"error\">" + template.HTMLEscapeString(err.Error()) + "</pre>")})+ return+ }+ stars := t.starsFor(r.Context(), name)+ esc := template.HTMLEscapeString+ var body strings.Builder+ if len(log) == 0 {+ shown := t.title(name)+ if dir && name != "." {+ shown += "/"+ }+ body.WriteString("<p class=\"own\">No commit touches " + esc(shown) + ".</p>\n")+ }+ if len(log) > maxLog {+ log = log[:maxLog]+ fmt.Fprintf(&body, "<p class=\"own\">The %d newest commits. <code>git log</code> lists the rest.</p>\n", maxLog)+ }+ // git walks every commit back to the ones that touched name, unless+ // the repo's commit-graph tells it which paths each commit changed.+ // www leaves the repo alone and names the command instead. A share+ // says nothing, since its viewer cannot run it.+ if took > slowGit && !t.shared && t.lacksPathFilters(r.Context(), name) {+ fmt.Fprintf(&body, "<p class=\"own\">git took %d ms for this history. "++ "<code>git commit-graph write --reachable --changed-paths</code> in the repo makes it several times faster.</p>\n",+ took.Milliseconds())+ }+ // Each commit folds out to its message and its diff. The diff comes+ // from the commit page when the fold opens, through the one script;+ // without it, the link in its place opens that page. A row is one+ // target, the fold, so the links to the commit and its version wait+ // inside it. A local tree links each commit's version of the page:+ // the file or directory as it was then.+ browse := t.historyInVersions(r.Context(), name)+ // Each row leads with its stardate, so they line up down the history.+ labels := make([]string, len(log))+ for i := range log {+ labels[i] = stars.rowLabel(&log[i])+ }+ body.WriteString("<ol class=\"log\"" + leadStyle(labels) + ">\n")+ for i := range log {+ c := &log[i]+ show := esc("?commit=" + c.hash)+ then := ""+ if browse {+ then = fmt.Sprintf(" · <a class=\"then\" href=\"%s\">browse</a>", esc(t.versionURL(stars, c, below(name, dir))))+ }+ fmt.Fprintf(&body, "<li><details data-diff=\"%s\">\n<summary><span class=\"own lead\">%s</span> <span class=\"subject\">%s</span></summary>\n"++ "<p class=\"own links\">commit <a href=\"%s\">%s</a>%s</p>\n",+ show, commitLine(labels[i], c), esc(c.subject), show, esc(c.hash), then)+ for _, para := range paragraphs(c.body) {+ body.WriteString("<p>" + para + "</p>\n")+ }+ fmt.Fprintf(&body, "<div class=\"diffs\"><p class=\"own\"><a href=\"%s\">diff</a></p></div>\n</details></li>\n", show)+ }+ body.WriteString("</ol>\n")+ t.render(w, r, http.StatusOK, page{Title: title, Crumbs: crumbs, Kind: "log", Body: template.HTML(body.String())})+}++// pageCrumbs are the crumbs of the page at name, the last a link back to+// it, for a page that hangs below it.+func (t *tree) pageCrumbs(name string, dir bool) []crumb {+ crumbs := t.crumbs(name)+ href := name+ switch {+ case name == ".":+ href = ""+ case dir:+ href += "/"+ }+ crumbs[len(crumbs)-1].Href = (&url.URL{Path: t.prefix + href}).String()+ return crumbs+}++// maxDiff caps the bytes of diff a commit page shows.+const maxDiff = 1 << 20++var hexHash = regexp.MustCompile(`^[0-9a-f]{4,64}$`)++// fileDiff is one file's part of a commit's diff: its name in the tree+// and the diff's lines below git's header. A file whose type changed has+// two.+type fileDiff struct {+ name string // relative to the tree+ rel string // relative to where git runs+ lines []string+}++// gitShow reads the commit that hash names and its diff, limited to name.+// A share leaves out files whose names begin with a dot. cut reports a+// diff cut at maxDiff.+func (t *tree) gitShow(ctx context.Context, name, hash string) (c *commit, diffs []fileDiff, cut bool, err error) {+ dir, spec, err := t.gitPlace(name)+ if err != nil {+ return nil, nil, false, t.gitErr(err)+ }+ if t.shared && !openRepo(ctx, dir) {+ return nil, nil, false, errNoHistory+ }+ // A commit never changes, so its diff is kept under its full hash.+ var key string+ if r := repoAt(ctx, dir); r.complaint == "" && fullHash(hash) {+ key = fmt.Sprintf("show\x00%s\x00%s\x00%s\x00%s\x00%s\x00%t", r.gitDir, dir, spec, name, hash, t.shared)+ if v, ok := gitCache.get(key); ok {+ s := v.(shown)+ return &s.c, s.diffs, s.cut, nil+ }+ }+ // ^{commit} refuses a blob or a tree, which would show a file's text.+ rev := hash + "^{commit}"+ show := func(args ...string) ([]byte, error) {+ args = append([]string{"show", "--no-ext-diff", "--no-textconv", "--no-show-signature", "--no-color",+ "--no-renames", "--diff-merges=first-parent", "--relative", "--src-prefix=a/", "--dst-prefix=b/"}, args...)+ return git(ctx, dir, args...)+ }+ out, err := show("-s", "-z", "--format="+commitFormat, rev, "--")+ if err != nil {+ return nil, nil, false, t.gitErr(err)+ }+ log := t.commits(out)+ if len(log) == 0 {+ return nil, nil, false, t.gitErr(errors.New("no commit " + hash))+ }+ out, err = show("--format=", "--patch", rev, "--", spec)+ if err != nil {+ return nil, nil, false, t.gitErr(err)+ }+ if len(out) > maxDiff {+ out, cut = out[:bytes.LastIndexByte(out[:maxDiff], '\n')+1], true+ }+ for _, b := range strings.Split("\n"+string(out), "\ndiff --git ")[1:] {+ head, _, _ := strings.Cut(b, "\n")+ f, ok := diffName(head)+ if !ok || t.shared && hasDotName(f) {+ continue+ }+ rel := f+ switch {+ case spec != ".":+ f = name+ case name != ".":+ f = path.Join(name, f)+ }+ diffs = append(diffs, fileDiff{name: f, rel: rel, lines: diffLines(b)})+ }+ if key != "" {+ c := log[0]+ size := int64(len(c.hash)+len(c.short)+len(c.subject)+len(c.body)) + 64+ for _, d := range diffs {+ size += int64(len(d.name)) + 16+ for _, l := range d.lines {+ size += int64(len(l)) + 16+ }+ }+ gitCache.put(key, shown{c, diffs, cut}, size)+ }+ return &log[0], diffs, cut, nil+}++// shown is a commit and its diff as gitShow keeps them.+type shown struct {+ c commit+ diffs []fileDiff+ cut bool+}++// fullHash reports whether hash is a whole SHA-1 or SHA-256 name in hex,+// which no later commit can make ambiguous.+func fullHash(hash string) bool {+ if len(hash) != 40 && len(hash) != 64 {+ return false+ }+ return !strings.ContainsFunc(hash, func(r rune) bool { return !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') })+}++// diffName reads the file's name from the header of its diff, a/name+// b/name, which git quotes as C does when the name holds a quote, a+// backslash or a control character. Without renames both names are one.+func diffName(head string) (string, bool) {+ if !strings.HasPrefix(head, `"`) {+ n := (len(head) - len("a/ b/")) / 2+ if n < 1 || !strings.HasPrefix(head, "a/") {+ return "", false+ }+ return head[2 : 2+n], true+ }+ for i := 1; i < len(head); i++ {+ switch head[i] {+ case '\\':+ i+++ case '"':+ f, err := strconv.Unquote(head[:i+1])+ return strings.TrimPrefix(f, "a/"), err == nil && strings.HasPrefix(f, "a/")+ }+ }+ return "", false+}++// diffLines drops the header git puts above a file's diff, which names+// the file again, and keeps the lines that say what kind of change it is.+func diffLines(block string) []string {+ var out []string+ lines := strings.Split(strings.TrimSuffix(block, "\n"), "\n")[1:]+ for i, line := range lines {+ if strings.HasPrefix(line, "@@") {+ return append(out, lines[i:]...)+ }+ switch {+ case strings.HasPrefix(line, "index "), strings.HasPrefix(line, "--- "), strings.HasPrefix(line, "+++ "):+ case strings.HasPrefix(line, "Binary files "):+ out = append(out, "Binary file changed")+ default:+ out = append(out, line)+ }+ }+ return out+}++// serveCommit shows the commit that hash names: its message and its diff+// limited to name, with a link to the diff across the whole tree. A+// history's fold takes the diff from div.diffs.+func (t *tree) serveCommit(w http.ResponseWriter, r *http.Request, name string, dir bool, hash string) {+ crumbs := t.pageCrumbs(name, dir)+ crumbs = append(crumbs, crumb{Label: logLabel, Href: crumbs[len(crumbs)-1].Href + "?log"}, crumb{Label: hash})+ title := "git show " + hash + " " + t.title(name)+ fail := func(err error) {+ t.render(w, r, http.StatusNotFound, page{Title: title, Crumbs: crumbs, Kind: "error",+ Body: template.HTML("<pre class=\"error\">" + template.HTMLEscapeString(err.Error()) + "</pre>")})+ }+ if !hexHash.MatchString(hash) {+ fail(errors.New("Not a commit hash: " + hash))+ return+ }+ c, diffs, cut, err := t.gitShow(r.Context(), name, hash)+ if err != nil {+ fail(err)+ return+ }+ crumbs[len(crumbs)-1].Label = c.short+ esc := template.HTMLEscapeString+ var body strings.Builder+ // The commit's full hash follows its stardate as plain text, so it+ // can be selected and given to git.+ fmt.Fprintf(&body, "<div class=\"commit\">\n<p class=\"own\">%s · <span class=\"hash\">%s</span></p>\n<p class=\"subject\">%s</p>\n", commitLine(t.starsFor(r.Context(), name).label(c), c), esc(c.hash), esc(c.subject))+ for _, para := range paragraphs(c.body) {+ body.WriteString("<p>" + para + "</p>\n")+ }+ body.WriteString("</div>\n")+ // The semantic diff comes inline when it is kept or asked for with+ // &semantic; otherwise the page asks for it once it shows the plain+ // diff.+ var sem *semantic+ _, asked := r.URL.Query()["semantic"]+ if key := t.semanticKey(name, c.hash); key != "" {+ if v, ok := semanticCache.get(key); ok {+ sem = v.(*semantic)+ }+ }+ if sem == nil && asked {+ sem = t.semanticDiff(r.Context(), name, c.hash, diffs)+ }+ switch {+ case sem != nil:+ body.WriteString(t.semanticHTML(r.URL.Path, c, sem))+ case len(diffs) > 0:+ q := r.URL.Query()+ q.Set("commit", c.hash)+ q.Set("semantic", "")+ fmt.Fprintf(&body, "<div class=\"semantic\" data-src=\"%s\"><p class=\"own\">reading declarations</p></div>\n", esc((&url.URL{Path: r.URL.Path, RawQuery: q.Encode()}).String()))+ }+ body.WriteString("<div class=\"diffs\">\n")+ all := (&url.URL{Path: t.prefix, RawQuery: "commit=" + c.hash}).String()+ shown := name+ if dir {+ shown += "/"+ }+ switch {+ case name != "." && len(diffs) == 0:+ fmt.Fprintf(&body, "<p class=\"own\">It does not change %s. <a href=\"%s\">All files</a></p>\n", esc(shown), esc(all))+ case name != ".":+ fmt.Fprintf(&body, "<p class=\"own\">Changes to %s. <a href=\"%s\">All files</a></p>\n", esc(shown), esc(all))+ case len(diffs) == 0:+ body.WriteString("<p class=\"own\">It changes no file here.</p>\n")+ }+ for _, d := range diffs {+ label := esc(d.name)+ if _, err := fs.Stat(t.fsys, d.name); err == nil {+ label = fmt.Sprintf("<a href=\"%s\">%s</a>", esc((&url.URL{Path: t.prefix + d.name}).String()), label)+ }+ var marks map[int][][2]int+ if sem != nil {+ marks = sem.marks[d.name]+ }+ fmt.Fprintf(&body, "<p class=\"own file\">%s</p>\n%s", label, diffPre(d.lines, marks))+ }+ if cut {+ fmt.Fprintf(&body, "<p class=\"own\">The diff stops at %d KiB. <code>git show %s</code> shows the rest.</p>\n", maxDiff>>10, esc(c.short))+ }+ body.WriteString("</div>\n")+ t.render(w, r, http.StatusOK, page{Title: title, Crumbs: crumbs, Kind: "commit", Body: template.HTML(body.String())})+}++// diffPre renders a diff's lines, each a block tinted by its kind, with+// the changed byte ranges marks gives by line index.+func diffPre(lines []string, marks map[int][][2]int) string {+ var b strings.Builder+ b.WriteString("<pre class=\"diff\">")+ for i, line := range lines {+ class := "meta"+ switch {+ case strings.HasPrefix(line, "@@"):+ class = "hunk"+ case strings.HasPrefix(line, "+"):+ class = "add"+ case strings.HasPrefix(line, "-"):+ class = "del"+ case strings.HasPrefix(line, " "):+ class = "same"+ }+ fmt.Fprintf(&b, "<span class=\"%s\">%s</span>", class, markLine(line, marks[i]))+ }+ b.WriteString("</pre>\n")+ return b.String()+}++// paragraphs splits a commit body at blank lines and joins the lines of+// each, since most bodies are wrapped by hand to 72 columns. A line that+// starts a list item or is indented keeps its own line.+func paragraphs(body string) []string {+ var out []string+ for _, para := range strings.Split(body, "\n\n") {+ var b strings.Builder+ for i, line := range strings.Split(strings.Trim(para, "\n"), "\n") {+ if i > 0 {+ if listLine(line) {+ b.WriteString("<br>")+ } else {+ b.WriteString(" ")+ }+ }+ b.WriteString(template.HTMLEscapeString(strings.TrimRight(line, " \t")))+ }+ if b.Len() > 0 {+ out = append(out, b.String())+ }+ }+ return out+}++func listLine(line string) bool {+ if line == "" || line[0] == ' ' || line[0] == '\t' {+ return true+ }+ if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") || strings.HasPrefix(line, "+ ") {+ return true+ }+ num := strings.TrimLeft(line, "0123456789")+ return len(num) < len(line) && (strings.HasPrefix(num, ". ") || strings.HasPrefix(num, ") "))+}
@@ -0,0 +1,273 @@+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)+ }+}
@@ -302,3 +302,17 @@ func TestPagesRecord(t *testing.T) { t.Errorf("record %q", b) } }++// A commit reloads an open history page.+func TestLiveReloadsOnCommit(t *testing.T) {+ needGit(t)+ s, _, dir, ts := liveTree(t)+ exec.Command("git", "init", "-q", dir).Run()+ commitAll(t, dir, "First")+ lines := stream(t, s, ts, "/a/")+ time.Sleep(50 * time.Millisecond)+ exec.Command("git", "-C", dir, "-c", "user.name=t", "-c", "user.email=t@x", "commit", "-q", "--allow-empty", "-m", "Second").Run()+ if !reloads(lines, 2*time.Second) {+ t.Fatal("no reload after a commit")+ }+}