github.com/troglobit / digit

Show tag and branch decorations next to commits

The log, overview, and commit views carried no ref context, so a
release commit looked like any other.

Decorate commits with clickable tag and branch-head pills (git's %D),
each linking to the log from that ref.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Joachim Wiberg · 59 days ago commit 5b981d2 · parent 04e6992 · child 71cf00d · patch
diff --git a/internal/git/git.go b/internal/git/git.go
index b8982bb..bbbc521 100644
--- a/internal/git/git.go
+++ b/internal/git/git.go
@@ -49,6 +49,13 @@ type Commit struct {
 	Author  string
 	Date    time.Time
 	Subject string
+	Refs    []CommitRef // branches/tags pointing here, for decoration
+}
+
+// CommitRef is a branch or tag pointing at a commit (git's %D).
+type CommitRef struct {
+	Name string
+	Tag  bool
 }
 
 // CommitDetail is a full commit as shown on the commit page.
@@ -369,11 +376,11 @@ func (r *Repo) DefaultBranch(candidates []string) (string, Hash, error) {
 	return "", "", ErrNotFound
 }
 
-var logFormat = strings.Join([]string{"%H", "%an", "%at", "%s"}, fieldSep) + recordSep
+var logFormat = strings.Join([]string{"%H", "%an", "%at", "%s", "%D"}, fieldSep) + recordSep
 
 // Log returns up to n commits starting from sha.
 func (r *Repo) Log(sha Hash, n int) ([]*Commit, error) {
-	out, err := r.git("log", "-n", strconv.Itoa(n), "--format="+logFormat, string(sha), "--")
+	out, err := r.git("log", "-n", strconv.Itoa(n), "--decorate", "--format="+logFormat, string(sha), "--")
 	if err != nil {
 		return nil, err
 	}
@@ -384,7 +391,7 @@ func (r *Repo) Log(sha Hash, n int) ([]*Commit, error) {
 // query, matched case-insensitively and verbatim (no regexp).
 func (r *Repo) Search(sha Hash, query string, n int) ([]*Commit, error) {
 	out, err := r.git("log", "-n", strconv.Itoa(n), "-i", "--fixed-strings",
-		"--grep="+query, "--format="+logFormat, string(sha), "--")
+		"--grep="+query, "--decorate", "--format="+logFormat, string(sha), "--")
 	if err != nil {
 		return nil, err
 	}
@@ -395,7 +402,7 @@ func parseLog(out []byte) []*Commit {
 	var commits []*Commit
 	for _, rec := range strings.Split(string(out), recordSep) {
 		f := strings.Split(strings.TrimPrefix(rec, "\n"), fieldSep)
-		if len(f) < 4 {
+		if len(f) < 5 {
 			continue
 		}
 		commits = append(commits, &Commit{
@@ -403,11 +410,35 @@ func parseLog(out []byte) []*Commit {
 			Author:  f[1],
 			Date:    unixTime(f[2]),
 			Subject: f[3],
+			Refs:    parseDecoration(f[4]),
 		})
 	}
 	return commits
 }
 
+// parseDecoration turns git's %D ("HEAD -> main, tag: v1.0, origin/main")
+// into typed refs, dropping the bare HEAD pointer.
+func parseDecoration(d string) []CommitRef {
+	d = strings.TrimSpace(d)
+	if d == "" {
+		return nil
+	}
+	var refs []CommitRef
+	for _, part := range strings.Split(d, ", ") {
+		switch {
+		case part == "HEAD":
+			continue
+		case strings.HasPrefix(part, "HEAD -> "):
+			refs = append(refs, CommitRef{Name: strings.TrimPrefix(part, "HEAD -> ")})
+		case strings.HasPrefix(part, "tag: "):
+			refs = append(refs, CommitRef{Name: strings.TrimPrefix(part, "tag: "), Tag: true})
+		default:
+			refs = append(refs, CommitRef{Name: part})
+		}
+	}
+	return refs
+}
+
 func (r *Repo) refs(patterns ...string) ([]*Ref, error) {
 	args := []string{"for-each-ref", "--sort=-creatordate",
 		"--format=%(refname)" + fieldSep + "%(refname:short)" + fieldSep +
@@ -517,24 +548,24 @@ func (r *Repo) Tree(sha Hash, path string) ([]TreeEntry, error) {
 	return ents, nil
 }
 
-var showFormat = strings.Join([]string{"%H", "%P", "%an", "%at", "%s", "%b"}, fieldSep) + recordSep
+var showFormat = strings.Join([]string{"%H", "%P", "%an", "%at", "%s", "%b", "%D"}, fieldSep) + recordSep
 
 // Show returns full commit details including the diff against its
 // first parent, reading at most max bytes of git output.  The second
 // return value reports whether the diff was truncated.
 func (r *Repo) Show(sha Hash, max int64) (*CommitDetail, bool, error) {
 	out, truncated, err := r.gitLimited(max,
-		"show", "--no-color", "--format="+showFormat, string(sha), "--")
+		"show", "--no-color", "--decorate", "--format="+showFormat, string(sha), "--")
 	if err != nil {
 		return nil, false, ErrNotFound
 	}
 	head, diff, _ := strings.Cut(string(out), recordSep)
 	f := strings.Split(head, fieldSep)
-	if len(f) < 6 {
+	if len(f) < 7 {
 		return nil, false, fmt.Errorf("unexpected git show output for %s", sha)
 	}
 	return &CommitDetail{
-		Commit:  Commit{Hash: f[0], Author: f[2], Date: unixTime(f[3]), Subject: f[4]},
+		Commit:  Commit{Hash: f[0], Author: f[2], Date: unixTime(f[3]), Subject: f[4], Refs: parseDecoration(f[6])},
 		Parents: strings.Fields(f[1]),
 		Body:    strings.TrimSpace(f[5]),
 		Diff:    strings.TrimLeft(diff, "\n"),
diff --git a/static/style.css b/static/style.css
index 3bf240a..da0a908 100644
--- a/static/style.css
+++ b/static/style.css
@@ -285,6 +285,26 @@ td.idle, td.author {
   white-space: nowrap;
 }
 
+/* Ref decorations (tags/branch heads) shown next to commits. */
+.ref {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.2rem;
+  margin-right: 0.4rem;
+  padding: 0 0.45rem;
+  border: 1px solid;
+  border-color: color-mix(in srgb, var(--c) 40%, var(--border));
+  border-radius: 999px;
+  font-size: 0.72rem;
+  line-height: 1.55;
+  white-space: nowrap;
+  vertical-align: middle;
+  color: var(--c);
+}
+.ref.tag { --c: var(--warn); }
+.ref.branch { --c: var(--accent); }
+.ref:hover { text-decoration: none; border-color: currentColor; }
+
 code, pre, .sha, td.mode, kbd {
   font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
   font-size: 0.875rem;
diff --git a/templates/commit.html b/templates/commit.html
index ee897af..d7be9df 100644
--- a/templates/commit.html
+++ b/templates/commit.html
@@ -7,7 +7,7 @@
   {{with .Body}}<pre class="body">{{.}}</pre>{{end}}
   <div class="commitmeta">
     <span class="who">{{.Author}} · {{ago .Date}}</span>
-    <span class="refs"><span class="sha" title="{{.Hash}}">commit {{shortsha .Hash}}</span>{{range $i, $p := .Parents}} · {{if eq $i 0}}parent{{else}}other parent{{end}} <a class="sha"{{if eq $i 0}} data-nav="parent" title="parent (p)"{{else if eq $i 1}} data-nav="parent2" title="other parent (o)"{{end}} href="{{url $.Data.Name "commit" $p}}">{{shortsha $p}}</a>{{end}}{{with $.Data.Child}} · child <a class="sha" data-nav="child" title="child (c)" href="{{url $.Data.Name "commit" .}}">{{shortsha .}}</a>{{end}} · <a href="{{url $.Data.Name "commit" (printf "%s.patch" .Hash)}}">patch</a></span>
+    <span class="refs">{{range .Refs}}<a class="ref {{if .Tag}}tag{{else}}branch{{end}}" href="{{url $.Data.Name "log" .Name}}" title="log from {{.Name}}">{{if .Tag}}&#127991;{{else}}&#9095;{{end}} {{.Name}}</a>{{end}}<span class="sha" title="{{.Hash}}">commit {{shortsha .Hash}}</span>{{range $i, $p := .Parents}} · {{if eq $i 0}}parent{{else}}other parent{{end}} <a class="sha"{{if eq $i 0}} data-nav="parent" title="parent (p)"{{else if eq $i 1}} data-nav="parent2" title="other parent (o)"{{end}} href="{{url $.Data.Name "commit" $p}}">{{shortsha $p}}</a>{{end}}{{with $.Data.Child}} · child <a class="sha" data-nav="child" title="child (c)" href="{{url $.Data.Name "commit" .}}">{{shortsha .}}</a>{{end}} · <a href="{{url $.Data.Name "commit" (printf "%s.patch" .Hash)}}">patch</a></span>
   </div>
 </div>
 {{end}}
diff --git a/templates/layout.html b/templates/layout.html
index 94cde82..3e4e1c5 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -96,7 +96,7 @@
   {{range .Commits}}
   <tr>
     <td class="sha"><a href="{{url $.Name "commit" .Hash}}">{{shortsha .Hash}}</a></td>
-    <td class="subject">{{.Subject}}</td>
+    <td class="subject">{{range .Refs}}<a class="ref {{if .Tag}}tag{{else}}branch{{end}}" href="{{url $.Name "log" .Name}}" title="log from {{.Name}}">{{if .Tag}}&#127991;{{else}}&#9095;{{end}} {{.Name}}</a>{{end}}{{.Subject}}</td>
     <td class="author">{{.Author}}</td>
     <td class="idle">{{ago .Date}}</td>
   </tr>