Draw diverged commits on the spine as a fork

A tip, checkout or committed pin that has diverged from the pin gets its
own lane, which joins the main lane at a "base" node: the merge base of
the two, with its SHA and subject. Only one diverged commit is drawn as a
fork; with two, or without a merge base, the nodes stay in a line with
the "diverged" gap.

Add Status.Bases, filled by the git provider with `merge-base` for each
diverged commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 16:40:25 +02:00
co-authored by Claude Sonnet 5.5
parent 4c1be3f9ab
commit f287d6009f
14 changed files with 283 additions and 37 deletions
+37 -3
View File
@@ -208,6 +208,20 @@ func (m Model) spineLines(nodes []submodule.SpineNode, offPin bool) []string {
pinAt := slices.IndexFunc(nodes, func(n submodule.SpineNode) bool { return hasRole(n, submodule.RolePin) })
stagedAt := slices.IndexFunc(nodes, func(n submodule.SpineNode) bool { return hasRole(n, submodule.RoleStaged) })
// a fork has its own lane to the right of the main one; it starts at the
// first node and joins the main lane at the base
fork, baseAt := len(nodes) > 0 && nodes[0].Fork, -1
if fork {
baseAt = slices.IndexFunc(nodes, func(n submodule.SpineNode) bool { return hasRole(n, submodule.RoleBase) })
}
line := muted.Render(t.Glyphs.Gap)
lanes := func(main, side string) string {
if !fork {
return main
}
return main + " " + side
}
var out []string
for i, n := range nodes {
label, sha := muted, theme.Fg(t.Primary)
@@ -217,10 +231,30 @@ func (m Model) spineLines(nodes []submodule.SpineNode, offPin bool) []string {
if offPin && len(n.Roles) == 1 && n.Roles[0] == submodule.RoleYou {
label, sha = theme.Fg(t.Warn), theme.Fg(t.Warn)
}
out = append(out, label.Width(width).Render(labels[i])+sha.Render(t.Glyphs.Node+" "+n.Commit.SHA)+" "+text.Render(n.Commit.Subject))
node := sha.Render(t.Glyphs.Node)
var cells string
switch {
case n.Fork:
cells = lanes(" ", node)
case i == baseAt:
cells = node + muted.Render(t.Glyphs.Rule+t.Glyphs.Join)
case fork && i < baseAt:
cells = lanes(node, line)
default:
cells = lanes(node, " ")
}
out = append(out, label.Width(width).Render(labels[i])+cells+" "+sha.Render(n.Commit.SHA)+" "+text.Render(n.Commit.Subject))
if i < len(nodes)-1 {
gap := muted.Render(t.Glyphs.Gap + m.linkText(n.Below))
if i+1 == stagedAt && pinAt >= 0 && pinAt < stagedAt && n.Below.Linear() {
// the main lane starts below the fork's head, the fork's lane ends at the base
main, side := " ", " "
if !fork || i > 0 {
main = line
}
if fork && i < baseAt {
side = line
}
gap := lanes(main, side) + muted.Render(m.linkText(n.Below))
if !fork && i+1 == stagedAt && pinAt >= 0 && pinAt < stagedAt && n.Below.Linear() {
gap += theme.Fg(t.Info).Render(" (staged pin moves back)")
}
out = append(out, strings.Repeat(" ", width)+gap)
+25
View File
@@ -73,3 +73,28 @@ func TestSpineLinesInSync(t *testing.T) {
t.Errorf("got %q, want %q", got, want)
}
}
func TestSpineLinesFork(t *testing.T) {
m := New(theme.Default(), nil)
c := func(sha, subject string) *submodule.Commit { return &submodule.Commit{SHA: sha, Subject: subject} }
st := submodule.Status{
Pinned: c("c4d5e6f", "Experimental"), CheckedOut: c("c4d5e6f", "Experimental"), Tip: c("e1f2a3b", "Drop Go 1.21"),
Pin: submodule.PinInfo{Ahead: 1, Behind: 2},
Bases: submodule.Bases{Tip: c("a1b2c3d", "Release 1.4")},
}
var got []string
for _, l := range m.spineLines(st.Spine(), false) {
got = append(got, ansi.Strip(l))
}
g := theme.Default().Glyphs
want := []string{
"tip " + " " + g.Node + " e1f2a3b Drop Go 1.21",
" " + " " + g.Gap + " 2 commits",
"pin · you " + g.Node + " " + g.Gap + " c4d5e6f Experimental",
" " + g.Gap + " " + g.Gap + " 1 commit",
"base " + g.Node + g.Rule + g.Join + " a1b2c3d Release 1.4",
}
if !slices.Equal(got, want) {
t.Errorf("got\n%q\nwant\n%q", got, want)
}
}
+2
View File
@@ -41,6 +41,8 @@ func RoleWord(r submodule.Role) string {
return "HEAD"
case submodule.RoleStaged:
return Staged
case submodule.RoleBase:
return "base"
case submodule.RoleTip:
return "tip"
default:
+3 -3
View File
@@ -54,17 +54,17 @@ func Fg(c lipgloss.TerminalColor) lipgloss.Style {
// Glyphs are the few symbols used next to the state words.
type Glyphs struct {
Staged, Ahead, Behind, Collapsed, Expanded, Problems, Minus, From, Rule, Node, Gap string
Staged, Ahead, Behind, Collapsed, Expanded, Problems, Minus, From, Rule, Node, Gap, Join string
}
func Unicode() Glyphs {
return Glyphs{
Staged: "●", Ahead: "↑", Behind: "↓", Collapsed: "▸", Expanded: "▾", Problems: "·", Minus: "−", From: "←", Rule: "─", Node: "●", Gap: "┊",
Staged: "●", Ahead: "↑", Behind: "↓", Collapsed: "▸", Expanded: "▾", Problems: "·", Minus: "−", From: "←", Rule: "─", Node: "●", Gap: "┊", Join: "╯",
}
}
func ASCII() Glyphs {
return Glyphs{
Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".", Minus: "-", From: "<-", Rule: "-", Node: "o", Gap: ":",
Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".", Minus: "-", From: "<-", Rule: "-", Node: "o", Gap: ":", Join: "/",
}
}