@ 261101.7

261024.13 · 7e0d1de832efc82df65d784e5a1f0113eaf03bfd

Browse a git tree as it was in any commit

Below a tree's address, @ and a commit's hash serve the whole tree as of that commit: docs, code, listings and search, read from git instead of the disk. The version is a path segment, not a query, so relative links in old docs and listings stay in the same version.

Each history entry links its version as browse. A version page names its commit, its age and subject, and links to the page as it is now. Versions never change: no live reload, stop button or copy path, and their listings and files are cached.

reading declarations

README.md

@@ -68,7 +68,7 @@ The server on your default address also serves 127.31.N.2, where a URL names a r  ## History -Add `?log` to a path for its git history, newest first: one line per commit, which opens to the full message and the diff. A tracked directory's listing opens it from its first entry, `<(git log)`, and a tracked doc or code page from a line at its top that names its last commit and its age, eg 3d, with the full date on hover. A commit's hash opens its diff for that path, with a link to all its files. Shares show it without trailers, and only when other users could read the repo. See [doc/history.md](doc/history.md).+Add `?log` to a path for its git history, newest first: one line per commit, which opens to the full message and the diff. A tracked directory's listing opens it from its first entry, `<(git log)`, and a tracked doc or code page from a line at its top that names its last commit and its age, eg 3d, with the full date on hover. A commit's hash opens its diff for that path, with a link to all its files. browse beside a commit opens the page as it was then: below the tree's address, `@` and a commit's hash, eg `@5430ac3/README.md`, serve the whole tree as of that commit, docs, code and listings alike, and links within it stay in that version. Shares show it without trailers, and only when other users could read the repo. See [doc/history.md](doc/history.md).  ## Log 

backlinks.go

@@ -44,7 +44,12 @@ type backlinks struct { func (t *tree) serveBacklinks(w http.ResponseWriter, r *http.Request, name string) { 	ctx, cancel := context.WithTimeout(r.Context(), searchBudget) 	defer cancel()-	res := t.backlinks(ctx, name)+	// A version has no link index, and walking a commit to build one runs+	// git for every directory, so its docs list no backlinks.+	res := backlinks{Links: []hit{}}+	if t.at == nil {+		res = t.backlinks(ctx, name)+	} 	w.Header().Set("Content-Type", "application/json") 	w.Header().Set("Cache-Control", "no-store") 	json.NewEncoder(w).Encode(res)

doc/history.md

@@ -9,3 +9,5 @@ www runs `git log`, `git show` and `git rev-parse` in the directory of what the  A shared server shows history and diffs as loopback does, with four differences. History appears only when other users could read the repo's git directory, its `HEAD`, `objects` and `refs`; nobody reads through www what git run as another user would not show them. Each message loses its trailer block, which carries emails and session links in lines such as `Signed-off-by:` and `Claude-Session:`. As in git, the last paragraph is a trailer block when a quarter of its lines are `Key: value` lines, so a cherry-pick note or a wrapped trailer does not keep it. A page in a repo the share keeps private never reloads on a commit. Diffs leave out files whose names begin with a dot, as listings do. A share never shows git's errors, which name paths on the machine, and says only `No git history here.` No history page shows authors, on a share or on loopback.++Each commit in a history has a browse link to the page as it was in that commit. Below a tree's address, `@` and a commit's hash serve the tree as of that commit, eg `@5430ac3/` for its root or `@5430ac3/doc/history.md` for one doc. Docs, code pages, listings and search work there as on the tree itself, read from git rather than the disk, and relative links stay in the same version. Any length of hash from four characters up works and redirects to git's short form. A line at the top names the commit, its age and subject, and links to the page as it is now; the breadcrumbs and the tab title carry the hash. A version never changes, so it has no live reload, no Delete lock button and no path to copy, and its docs list no backlinks. A tree that holds an entry named like a version, eg a directory `@cafe`, serves that entry instead. Shares and the web address have no versions.

git.go

@@ -332,6 +332,9 @@ var trailer = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]*[ \t]*:[ \t]*\S`) // lastCommit is the newest commit that touched name, or nil when none // did or name lies outside a repo. func (t *tree) lastCommit(ctx context.Context, name string) *commit {+	if t.at != nil {+		return nil+	} 	log, err := t.gitLog(ctx, name, 1) 	if err != nil || len(log) == 0 { 		return nil@@ -373,6 +376,9 @@ func commitLine(c *commit) string { // history and names its last commit, or "" when it has none. The commit's // time shows as its age, eg 3d; its full date is on hover. func (t *tree) logLine(ctx context.Context, name string) string {+	if t.at != nil {+		return t.versionLine(name, false)+	} 	c := t.lastCommit(ctx, name) 	if c == nil { 		return ""@@ -442,8 +448,21 @@ func (t *tree) serveLog(w http.ResponseWriter, r *http.Request, name string, dir 	for i := range log { 		c := &log[i] 		show := esc("?commit=" + c.hash)-		fmt.Fprintf(&body, "<li><details data-diff=\"%s\">\n<summary><span class=\"own\">%s</span> <span class=\"subject\">%s</span></summary>\n",-			show, commitLine(c), esc(c.subject))+		// A local tree links each commit's version of the page: the+		// file or directory as it was then.+		then := ""+		if t.at == nil && !t.shared && t.web == nil {+			p := t.prefix + "@" + c.short + "/"+			if name != "." {+				p += name+				if dir {+					p += "/"+				}+			}+			then = fmt.Sprintf(" <a class=\"then\" href=\"%s\">browse</a>", esc((&url.URL{Path: p}).String()))+		}+		fmt.Fprintf(&body, "<li><details data-diff=\"%s\">\n<summary><span class=\"own\">%s%s</span> <span class=\"subject\">%s</span></summary>\n",+			show, commitLine(c), then, esc(c.subject)) 		for _, para := range paragraphs(c.body) { 			body.WriteString("<p>" + para + "</p>\n") 		}

server.go

@@ -206,6 +206,9 @@ type tree struct { 	secret   bool        // mounted under a token, and left off the index 	web      *webRepo    // the cached repo this tree is a worktree of, on the web address 	linked   linkIndex   // the relative links of each doc, for ?backlinks+	at       *commit     // for a version, the commit it shows+	base     *tree       // for a version, the tree it is a version of+	versions sync.Map    // the versions served so far, by full hash }  func newTree(root *os.Root, dir, prefix, label string) *tree {@@ -679,6 +682,14 @@ func (t *tree) name(urlPath string) string {  func (t *tree) serve(w http.ResponseWriter, r *http.Request) { 	name := t.name(r.URL.Path)+	// A first segment such as @5430ac3 names a version, unless the tree+	// holds an entry by that name.+	if first, _, _ := strings.Cut(name, "/"); t.at == nil && !t.shared && t.web == nil && atSegment.MatchString(first) {+		if _, err := fs.Stat(t.fsys, first); err != nil {+			t.serveVersion(w, r, first)+			return+		}+	} 	info, err := fs.Stat(t.fsys, name) 	if ref := (*refusal)(nil); errors.As(err, &ref) { 		t.fail(w, r, name, http.StatusForbidden, t.why(ref))@@ -840,8 +851,12 @@ func (t *tree) serveDir(w http.ResponseWriter, r *http.Request, name string) { 	} 	// Entries reached through a link out of the tree are checked one by 	// one, since each may be refused. So is every entry of a shared tree.-	_, err = t.root.Stat(name)-	outside := err != nil || t.shared+	// A version holds only what git stored, so nothing leads out of it.+	outside := false+	if t.at == nil {+		_, err = t.root.Stat(name)+		outside = err != nil || t.shared+	} 	var items []item 	for _, e := range entries { 		n := e.Name()@@ -873,6 +888,9 @@ func (t *tree) serveDir(w http.ResponseWriter, r *http.Request, name string) { 	})  	var body strings.Builder+	if t.at != nil {+		body.WriteString(t.versionLine(name, true))+	} 	body.WriteString("<ul class=\"listing\">\n") 	if t.lastCommit(r.Context(), name) != nil { 		body.WriteString("<li class=\"log\"><a href=\"?log\">" + template.HTMLEscapeString(logLabel) + "</a></li>\n")@@ -936,8 +954,15 @@ type page struct { }  func (t *tree) title(name string) string {+	at := ""+	if t.at != nil {+		at = "@" + t.at.short+	} 	if name == "." {-		return path.Base(t.label)+		return path.Base(t.label) + at+	}+	if at != "" {+		return name + " " + at 	} 	return name }@@ -945,6 +970,9 @@ func (t *tree) title(name string) string { // rootCrumb is the tree's first breadcrumb: its base name, with the full // path on hover. The address bar already shows the path. func (t *tree) rootCrumb() crumb {+	if t.at != nil {+		return crumb{Label: path.Base(t.label) + "@" + t.at.short, Title: t.label + " as of " + t.at.hash}+	} 	return crumb{Label: path.Base(t.label), Title: t.label} } @@ -1005,9 +1033,15 @@ func (t *tree) render(w http.ResponseWriter, r *http.Request, status int, p page 	if t.web != nil { 		p.NoStop, p.Status = true, t.web.status() 	}+	// A version never changes and has no lock or path on disk: no live+	// reload, no stop button, nothing to copy.+	if t.at != nil {+		p.NoStop = true+		r = r.WithContext(context.WithValue(r.Context(), liveKey{}, ""))+	} 	if status == http.StatusOK { 		p.Search = searchURL(r.URL.Path)-		if !t.shared {+		if !t.shared && t.at == nil { 			p.Path = filepath.Join(t.dir, filepath.FromSlash(strings.TrimPrefix(r.URL.Path, t.prefix))) 		} 	}

version.go

new file mode 100644@@ -0,0 +1,261 @@+package main++import (+	"bytes"+	"context"+	"errors"+	"fmt"+	"html/template"+	"io"+	"io/fs"+	"net/http"+	"net/url"+	"path"+	"regexp"+	"strconv"+	"strings"+	"time"+)++// A version is a tree as it was in one commit, served below the tree's+// URL at @ and the commit's short hash, eg /scratch/www/@5430ac3/doc/.+// The version is a path segment rather than a query, so relative links in+// its docs, listings and breadcrumbs stay in that version.+var atSegment = regexp.MustCompile(`^@([0-9a-f]{4,40})$`)++// atVersion is the tree t as it was in the commit seg names, eg @5430ac3,+// and the segment that names it best. A commit never changes, so each is+// built once per tree.+func (t *tree) atVersion(ctx context.Context, seg string) (*tree, string, error) {+	m := atSegment.FindStringSubmatch(seg)+	if m == nil {+		return nil, "", fs.ErrNotExist+	}+	out, err := git(ctx, t.dir, "log", "-1", "--no-show-signature", "-z", "--format="+commitFormat, m[1]+"^{commit}", "--")+	if err != nil {+		return nil, "", fmt.Errorf("no commit %s in this repo", m[1])+	}+	cs := t.commits(out)+	if len(cs) == 0 {+		return nil, "", fmt.Errorf("no commit %s in this repo", m[1])+	}+	c := cs[0]+	if v, ok := t.versions.Load(c.hash); ok {+		return v.(*tree), "@" + c.short, nil+	}+	v := &tree{dir: t.dir, realDir: t.realDir, prefix: t.prefix + "@" + c.short + "/", label: t.label, wd: -1, at: &c, base: t}+	v.fsys = commitFS{dir: t.dir, rev: c.hash, when: c.date}+	got, _ := t.versions.LoadOrStore(c.hash, v)+	return got.(*tree), "@" + c.short, nil+}++// serveVersion serves a path below a version of t; first is the path's+// first segment, eg @5430ac3. A longer or shorter hash than git's own+// redirects to it. A share has no versions, as it shows no history.+func (t *tree) serveVersion(w http.ResponseWriter, r *http.Request, first string) {+	name := t.name(r.URL.Path)+	v, seg, err := t.atVersion(r.Context(), first)+	if err != nil {+		t.fail(w, r, name, http.StatusNotFound, err.Error())+		return+	}+	if seg != first {+		u := t.prefix + seg + strings.TrimPrefix(r.URL.EscapedPath(), t.prefix+first)+		if r.URL.RawQuery != "" {+			u += "?" + r.URL.RawQuery+		}+		http.Redirect(w, r, u, http.StatusMovedPermanently)+		return+	}+	v.serve(w, r)+}++// versionLine is the line at the top of a page in a version: the commit,+// its age and subject, and a link to the page as it is now.+func (t *tree) versionLine(name string, dir bool) string {+	c, esc := t.at, template.HTMLEscapeString+	now := t.base.prefix+	if name != "." {+		now += name+		if dir {+			now += "/"+		}+	}+	return fmt.Sprintf("<p class=\"own latest\">as of <a class=\"hash\" href=\"%s?commit=%s\" title=\"%s\">%s</a> "++		"<span class=\"date\" title=\"%s\">%s</span> %s · <a href=\"%s\">now</a></p>\n",+		esc(t.base.prefixURL()), esc(c.hash), esc(c.hash), esc(c.short), c.date.Local().Format("2006-01-02 15:04"),+		age(time.Since(c.date)), esc(c.subject), esc((&url.URL{Path: now}).String()))+}++// commitFS reads a directory as it was in one commit: listings from git+// ls-tree, files from git cat-file. dir is where git runs, so paths are+// relative to it, as the tree's are.+type commitFS struct {+	dir, rev string+	when     time.Time+}++// gitEntry is one name in a listing from git ls-tree.+type gitEntry struct {+	name, typ, obj string+	mode           uint32+	size           int64+}++// list is the directory name as it was, by git ls-tree, kept in the git+// cache under the commit, which never changes.+func (c commitFS) list(name string) ([]gitEntry, error) {+	key := "ls\x00" + c.dir + "\x00" + c.rev + "\x00" + name+	if v, ok := gitCache.get(key); ok {+		return v.([]gitEntry), nil+	}+	spec := "./"+	if name != "." {+		spec = "./" + name + "/"+	}+	out, err := git(context.Background(), c.dir, "ls-tree", "-l", "-z", c.rev, "--", spec)+	if err != nil {+		return nil, err+	}+	var entries []gitEntry+	size := int64(0)+	for _, rec := range strings.Split(string(out), "\x00") {+		meta, p, ok := strings.Cut(rec, "\t")+		if !ok {+			continue+		}+		f := strings.Fields(meta)+		if len(f) != 4 {+			continue+		}+		mode, _ := strconv.ParseUint(f[0], 8, 32)+		n, _ := strconv.ParseInt(f[3], 10, 64)+		e := gitEntry{name: path.Base(p), typ: f[1], obj: f[2], mode: uint32(mode), size: n}+		entries = append(entries, e)+		size += int64(len(e.name)+len(e.obj)) + 48+	}+	gitCache.put(key, entries, size)+	return entries, nil+}++// entry finds name in its directory's listing.+func (c commitFS) entry(name string) (gitEntry, error) {+	entries, err := c.list(path.Dir(name))+	if err != nil {+		return gitEntry{}, err+	}+	base := path.Base(name)+	for _, e := range entries {+		if e.name == base {+			return e, nil+		}+	}+	return gitEntry{}, fs.ErrNotExist+}++// blob reads a file's content by its object, kept in the git cache.+func (c commitFS) blob(obj string) ([]byte, error) {+	key := "blob\x00" + c.dir + "\x00" + obj+	if v, ok := gitCache.get(key); ok {+		return v.([]byte), nil+	}+	b, err := git(context.Background(), c.dir, "cat-file", "blob", obj)+	if err != nil {+		return nil, err+	}+	gitCache.put(key, b, int64(len(b)))+	return b, nil+}++func (c commitFS) Open(name string) (fs.File, error) {+	if !fs.ValidPath(name) {+		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}+	}+	if name == "." {+		return &commitDir{fs: c, info: commitInfo{name: ".", dir: true, when: c.when}}, nil+	}+	e, err := c.entry(name)+	if err != nil {+		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}+	}+	info := c.info(e)+	if info.dir {+		return &commitDir{fs: c, path: name, info: info}, nil+	}+	b, err := c.blob(e.obj)+	if err != nil {+		return nil, &fs.PathError{Op: "open", Path: name, Err: err}+	}+	return &commitFile{Reader: bytes.NewReader(b), info: info}, nil+}++// info describes an entry; a submodule shows as an empty directory.+func (c commitFS) info(e gitEntry) commitInfo {+	return commitInfo{name: e.name, size: e.size, dir: e.typ == "tree" || e.typ == "commit", when: c.when}+}++type commitInfo struct {+	name string+	size int64+	dir  bool+	when time.Time+}++func (i commitInfo) Name() string       { return i.name }+func (i commitInfo) Size() int64        { return i.size }+func (i commitInfo) ModTime() time.Time { return i.when }+func (i commitInfo) IsDir() bool        { return i.dir }+func (i commitInfo) Sys() any           { return nil }+func (i commitInfo) Mode() fs.FileMode {+	if i.dir {+		return fs.ModeDir | 0o555+	}+	return 0o444+}+func (i commitInfo) Type() fs.FileMode          { return i.Mode().Type() }+func (i commitInfo) Info() (fs.FileInfo, error) { return i, nil }++// commitFile is a file's content, seekable, as http.ServeContent needs.+type commitFile struct {+	*bytes.Reader+	info commitInfo+}++func (f *commitFile) Stat() (fs.FileInfo, error) { return f.info, nil }+func (f *commitFile) Close() error               { return nil }++type commitDir struct {+	fs   commitFS+	path string+	info commitInfo+	read bool+}++func (d *commitDir) Stat() (fs.FileInfo, error) { return d.info, nil }+func (d *commitDir) Close() error               { return nil }+func (d *commitDir) Read([]byte) (int, error) {+	return 0, &fs.PathError{Op: "read", Path: d.path, Err: errors.New("is a directory")}+}++func (d *commitDir) ReadDir(n int) ([]fs.DirEntry, error) {+	if d.read {+		if n > 0 {+			return nil, io.EOF+		}+		return nil, nil+	}+	d.read = true+	name := d.path+	if name == "" {+		name = "."+	}+	entries, err := d.fs.list(name)+	if err != nil {+		return nil, err+	}+	out := make([]fs.DirEntry, len(entries))+	for i, e := range entries {+		out[i] = d.fs.info(e)+	}+	return out, nil+}

version_test.go

new file mode 100644@@ -0,0 +1,39 @@+package main++import (+	"strings"+	"testing"+)++// A version serves the tree as it was in one commit: files, listings and+// relative links stay in it, and its page says which commit it is.+func TestVersion(t *testing.T) {+	s, dir := repoTree(t)+	h := s.handler()+	first := head(t, dir, "HEAD~1")+	short := first[:7]+	at := "/@" + short + "/"++	if body := get(h, at+"a/b.go").Body.String(); strings.Contains(body, "package b") || !strings.Contains(body, "as of <a class=\"hash\"") || !strings.Contains(body, "now</a>") {+		t.Errorf("a/b.go as first committed:\n%s", body)+	}+	if body := get(h, "/a/b.go").Body.String(); !strings.Contains(body, `package</span> b`) {+		t.Errorf("a/b.go now:\n%s", body)+	}+	listing := get(h, at+"a/").Body.String()+	if !strings.Contains(listing, "@"+short+"</a>") || !strings.Contains(listing, `href="b.go"`) || strings.Contains(listing, "Delete lock") || strings.Contains(listing, "?log") {+		t.Errorf("listing of a/ as of %s:\n%s", short, listing)+	}+	if body := get(h, at+"untracked/x.txt"); body.Code != 404 {+		t.Errorf("untracked file found in a version: %d", body.Code)+	}+	if rec := get(h, "/@"+first+"/a/b.go"); rec.Header().Get("Location") != at+"a/b.go" {+		t.Errorf("full hash redirects to %q", rec.Header().Get("Location"))+	}+	if rec := get(h, "/@0000000/a/b.go"); rec.Code != 404 {+		t.Errorf("unknown commit: %d", rec.Code)+	}+	if body := get(h, "/a/b.go?log").Body.String(); !strings.Contains(body, `<a class="then" href="`+at+`a/b.go">browse</a>`) {+		t.Errorf("history links no version:\n%s", body)+	}+}