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
+25
View File
@@ -241,9 +241,24 @@ func committedState(ctx context.Context, in info, st *submodule.Status, committe
return err
}
st.Committed = c
if st.CommittedDrift.Ahead > 0 && st.CommittedDrift.Behind > 0 {
if st.Bases.Committed, err = mergeBase(ctx, in.dir, committed, pin); err != nil {
return err
}
}
return nil
}
// mergeBase returns the commit where the histories of a and b split; nil if
// they have none in common.
func mergeBase(ctx context.Context, dir, a, b string) (*submodule.Commit, error) {
base, ok, err := try(ctx, dir, "merge-base", a, b)
if err != nil || !ok || base == "" {
return nil, err
}
return commit(ctx, dir, base)
}
// rootStatus describes the superproject. Its "pin" is HEAD, compared with the
// upstream of the current branch; submodules are ignored, their rows show
// them.
@@ -346,6 +361,11 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
if st.Drift.Ahead, st.Drift.Behind, err = parseLeftRight(out); err != nil {
return err
}
if st.Drift.Ahead > 0 && st.Drift.Behind > 0 {
if st.Bases.Checkout, err = mergeBase(ctx, in.dir, pin, head); err != nil {
return err
}
}
}
if source == submodule.RefFromSHA {
st.Pin.Ref = st.Pinned.SHA
@@ -361,6 +381,11 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
if st.Pin.Ahead, st.Pin.Behind, err = parseLeftRight(out); err != nil {
return err
}
if st.Pin.Ahead > 0 && st.Pin.Behind > 0 {
if st.Bases.Tip, err = mergeBase(ctx, in.dir, pin, ref); err != nil {
return err
}
}
return nil
}
+47
View File
@@ -294,3 +294,50 @@ func TestCommittedPinAheadOfStaged(t *testing.T) {
t.Errorf("spine = %+v, want the committed pin directly above the staged one", spine)
}
}
// A pin and its ref that each have a commit the other lacks, and a checkout
// that did the same to the pin: both get the merge base.
func TestBasesOfDivergedCommits(t *testing.T) {
p, root := ownFixture(t)
ctx := context.Background()
super := filepath.Join(root, "super")
// src/net: the pin is unpushed; upstream moved on
pushUpstream(t, root, "net")
if err := p.Fetch(ctx, "src/net"); err != nil {
t.Fatal(err)
}
st, err := p.Status(ctx, "src/net")
if err != nil {
t.Fatal(err)
}
if st.Pin.Ahead != 1 || st.Pin.Behind != 1 {
t.Fatalf("src/net: ↑%d↓%d, want ↑1↓1", st.Pin.Ahead, st.Pin.Behind)
}
if b := st.Bases.Tip; b == nil || b.SHA == st.Pinned.SHA || b.SHA == st.Tip.SHA {
t.Errorf("src/net: Bases.Tip = %+v, want a commit before the pin and the tip", b)
}
if sp := st.Spine(); len(sp) != 3 || !sp[0].Fork || sp[0].Commit.SHA != st.Tip.SHA {
t.Errorf("src/net: spine = %+v, want a fork headed by the tip", sp)
}
// src/core: a commit on top of a checkout that is behind the pin
git(t, filepath.Join(super, "src", "core"), "commit", "--allow-empty", "-m", "Local")
st, err = p.Status(ctx, "src/core")
if err != nil {
t.Fatal(err)
}
if st.Drift != (submodule.Distance{Ahead: 1, Behind: 1}) {
t.Fatalf("src/core: drift %+v, want 1/1", st.Drift)
}
if b := st.Bases.Checkout; b == nil || b.SHA == st.Pinned.SHA || b.SHA == st.CheckedOut.SHA {
t.Errorf("src/core: Bases.Checkout = %+v", b)
}
// in sync and linear: no bases
if st, err = p.Status(ctx, "src/ui"); err != nil {
t.Fatal(err)
} else if st.Bases != (submodule.Bases{}) {
t.Errorf("src/ui: Bases = %+v, want none", st.Bases)
}
}
+1
View File
@@ -278,6 +278,7 @@ func fixture() []node {
}},
{relPath: "libs/http", status: &submodule.Status{
Pin: submodule.PinInfo{Ref: "origin/develop", Ahead: 1, Behind: 2, Pushed: true, Source: submodule.RefFromContains},
Bases: submodule.Bases{Tip: commit("a9b8c7d", "Release 1.9.0", "frank", 20*24*time.Hour)},
Pinned: httpPin,
CheckedOut: httpPin,
Tip: httpTip,
+6
View File
@@ -176,6 +176,11 @@ func (c *Changes) Sort() {
// `git rev-list --left-right --count a...b`.
type Distance struct{ Ahead, Behind int }
// Bases are the merge bases of the pin with the commits that have diverged
// from it, where the histories split. A field is nil if that commit has not
// diverged or the base is unknown.
type Bases struct{ Tip, Checkout, Committed *Commit }
// Status is the full state of one submodule.
type Status struct {
Root bool // the superproject: the "pin" is its HEAD
@@ -193,6 +198,7 @@ type Status struct {
// only added. CommittedDrift compares it with Pinned like Drift.
Committed *Commit
CommittedDrift Distance
Bases Bases
Changes *Changes // nil if the worktree is clean or the details are unknown
FetchedAt time.Time // last fetch of the submodule; zero if never
}
+49 -23
View File
@@ -12,12 +12,17 @@ const (
RoleStaged // the pin in the index, if it differs from RolePin
RoleTip // the tip of the ref the pin was resolved to
RoleYou // the commit checked out in the submodule
RoleBase // where a diverged commit's history splits from the pin's
)
// SpineNode is one commit on the spine and the roles that sit on it.
type SpineNode struct {
Roles []Role
Commit *Commit
// Fork marks the head of a diverged branch. It is the first node; its
// Below is the number of commits down to the RoleBase node, on its own
// lane, and the other nodes follow on the main lane.
Fork bool
// Below describes the history between this node and the next one down;
// it is the zero Link for the last node.
Below Link
@@ -42,19 +47,26 @@ func (l Link) Diverged() bool { return l.Above > 0 && l.Below > 0 }
// spineItem is a commit with its roles on its way to becoming a node.
type spineItem struct {
roles []Role
commit *Commit
height int // commits above (+) or below (−) the pin; unused if div
div bool // diverged from the pin
own int // commits only this item has
pin int // commits only the pin has
isPin bool
roles []Role
commit *Commit
height int // commits above (+) or below (−) the pin; unused if div
div bool // diverged from the pin
divAbove bool // where a diverged item goes if it is not drawn as a fork
base *Commit // merge base with the pin, if div and known
own int // commits only this item has
pin int // commits only the pin has
isPin bool
}
// Spine arranges the pin (and the staged pin, if any), the tip and the checkout as one line of history,
// newest on top. Roles on the same commit share a node. A tip or checkout
// that has diverged from the pin is placed outermost, above or below
// respectively, with a diverged Link. Missing commits are left out.
// Spine arranges the pin (and the staged pin, if any), the tip and the
// checkout as one line of history, newest on top. Roles on the same commit
// share a node.
//
// A commit that has diverged from the pin is drawn as a fork if it is the
// only one and its merge base is known: it is the first node, and the
// RoleBase node where the histories split follows on the main lane. Otherwise
// a diverged tip is placed above the pin and a diverged checkout below it,
// with a diverged Link. Missing commits are left out.
func (s *Status) Spine() []SpineNode {
if s.Pinned == nil {
return nil
@@ -68,13 +80,12 @@ func (s *Status) Spine() []SpineNode {
}
pin := spineItem{roles: []Role{pinRole}, commit: s.Pinned, isPin: true}
var above, below []spineItem
place := func(role Role, c *Commit, own, pinOnly int, divAbove bool) {
var above, below, divs []spineItem
place := func(role Role, c, base *Commit, own, pinOnly int, divAbove bool) {
it := spineItem{roles: []Role{role}, commit: c, own: own, pin: pinOnly}
switch {
case own == 0 && pinOnly == 0:
pin.roles = append(pin.roles, role)
return
case own > 0 && pinOnly == 0:
it.height = own
above = append(above, it)
@@ -82,26 +93,37 @@ func (s *Status) Spine() []SpineNode {
it.height = -pinOnly
below = append(below, it)
default:
it.div = true
if divAbove {
above = append(above, it)
} else {
below = append(below, it)
}
it.div, it.divAbove, it.base = true, divAbove, base
divs = append(divs, it)
}
}
if s.Tip != nil {
place(RoleTip, s.Tip, s.Pin.Behind, s.Pin.Ahead, true)
place(RoleTip, s.Tip, s.Bases.Tip, s.Pin.Behind, s.Pin.Ahead, true)
}
if s.PinStaged && s.Committed != nil {
place(RolePin, s.Committed, s.CommittedDrift.Ahead, s.CommittedDrift.Behind, false)
place(RolePin, s.Committed, s.Bases.Committed, s.CommittedDrift.Ahead, s.CommittedDrift.Behind, false)
}
switch {
case s.CheckedOut == nil, s.Root: // HEAD is the superproject's pin and checkout
case s.CheckedOut.SHA == s.Pinned.SHA:
pin.roles = append(pin.roles, RoleYou)
default:
place(RoleYou, s.CheckedOut, s.Drift.Ahead, s.Drift.Behind, false)
place(RoleYou, s.CheckedOut, s.Bases.Checkout, s.Drift.Ahead, s.Drift.Behind, false)
}
var fork *spineItem
if len(divs) == 1 && divs[0].base != nil {
fork = &divs[0]
// the main lane continues to the base, where the fork joins it
below = append(below, spineItem{roles: []Role{RoleBase}, commit: fork.base, height: -fork.pin})
} else {
for _, it := range divs {
if it.divAbove {
above = append(above, it)
} else {
below = append(below, it)
}
}
}
// diverged items are outermost, the rest is ordered by height
@@ -143,6 +165,10 @@ func (s *Status) Spine() []SpineNode {
nodes = append(nodes, SpineNode{Roles: it.roles, Commit: it.commit})
last = it
}
if fork != nil {
head := SpineNode{Roles: fork.roles, Commit: fork.commit, Fork: true, Below: Link{Above: fork.own}}
nodes = append([]SpineNode{head}, nodes...)
}
return nodes
}
+43 -1
View File
@@ -80,6 +80,48 @@ func TestSpine(t *testing.T) {
nodes: []string{"t:tip", "s:staged", "p:pin,you"},
links: []Link{{Above: 1}, {Above: 2}},
},
{
name: "tip diverged from the pin: a fork",
st: Status{Pinned: c("p"), CheckedOut: c("p"), Tip: c("t"), Pin: PinInfo{Ahead: 1, Behind: 2},
Bases: Bases{Tip: c("b")}},
nodes: []string{"t:tip", "p:pin,you", "b:base"},
links: []Link{{Above: 2}, {Above: 1}},
},
{
name: "checkout diverged from the pin: a fork",
st: Status{Pinned: c("p"), CheckedOut: c("y"), Tip: c("p"), Drift: Distance{Ahead: 3, Behind: 2},
Bases: Bases{Checkout: c("b")}},
nodes: []string{"y:you", "p:pin,tip", "b:base"},
links: []Link{{Above: 3}, {Above: 2}},
},
{
name: "fork with the tip above the pin on the main lane",
st: Status{Pinned: c("p"), CheckedOut: c("y"), Tip: c("t"), Pin: PinInfo{Behind: 2},
Drift: Distance{Ahead: 1, Behind: 1}, Bases: Bases{Checkout: c("b")}},
nodes: []string{"y:you", "t:tip", "p:pin", "b:base"},
links: []Link{{Above: 1}, {Above: 2}, {Above: 1}},
},
{
name: "something older than the base continues below it",
st: Status{Pinned: c("p"), CheckedOut: c("y"), Tip: c("t"), Pin: PinInfo{Ahead: 1, Behind: 2},
Bases: Bases{Tip: c("b")}, Drift: Distance{Behind: 3}},
nodes: []string{"t:tip", "p:pin", "b:base", "y:you"},
links: []Link{{Above: 2}, {Above: 1}, {Above: 2}},
},
{
name: "the base is also the checkout",
st: Status{Pinned: c("p"), CheckedOut: c("b"), Tip: c("t"), Pin: PinInfo{Ahead: 1, Behind: 2},
Bases: Bases{Tip: c("b")}, Drift: Distance{Behind: 1}},
nodes: []string{"t:tip", "p:pin", "b:you,base"},
links: []Link{{Above: 2}, {Above: 1}},
},
{
name: "two diverged commits are not drawn as a fork",
st: Status{Pinned: c("p"), CheckedOut: c("y"), Tip: c("t"), Pin: PinInfo{Ahead: 1, Behind: 2},
Drift: Distance{Ahead: 1, Behind: 1}, Bases: Bases{Tip: c("b"), Checkout: c("b")}},
nodes: []string{"t:tip", "p:pin", "y:you"},
links: []Link{{Above: 2, Below: 1}, {Above: 1, Below: 1}},
},
{
name: "no tip, checkout not loaded",
st: Status{Pinned: c("p")},
@@ -92,7 +134,7 @@ func TestSpine(t *testing.T) {
links: []Link{{Above: 2}},
},
}
words := map[Role]string{RolePin: "pin", RoleStaged: "staged", RoleHead: "head", RoleTip: "tip", RoleYou: "you"}
words := map[Role]string{RolePin: "pin", RoleStaged: "staged", RoleHead: "head", RoleTip: "tip", RoleYou: "you", RoleBase: "base"}
for _, tt := range tests {
var nodes []string
var links []Link