From f287d6009f4bc66be671bb0f464956bfdcf3b488 Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Fri, 23 Oct 2026 16:40:25 +0200 Subject: [PATCH] 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 --- docs/decisions.md | 10 ++++ docs/requirements.md | 2 + docs/state-model.md | 20 +++++-- docs/ux-guide.md | 20 +++++-- internal/submodule/git/provider.go | 25 +++++++++ internal/submodule/git/provider_test.go | 47 ++++++++++++++++ internal/submodule/mock/mock.go | 1 + internal/submodule/model.go | 6 +++ internal/submodule/spine.go | 72 +++++++++++++++++-------- internal/submodule/spine_test.go | 44 ++++++++++++++- internal/tui/detail/detail.go | 40 ++++++++++++-- internal/tui/detail/spine_test.go | 25 +++++++++ internal/tui/theme/issues.go | 2 + internal/tui/theme/theme.go | 6 +-- 14 files changed, 283 insertions(+), 37 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index f5ca218..88a8099 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -414,3 +414,13 @@ header keeps the progress and `n failed`. `Repo.FetchedAt` is gone. **Amendment (with D25).** A staged pin shows both pins on the spine: `staged` (the index) and `pin` (the superproject's HEAD, `Status.Committed`), so a bump reads as a distance. Before, only the index pin was shown at all. + +**Amendment (with D25): forks.** A diverged tip, checkout or committed pin is +drawn as a second lane that joins the main one at a `base` node (SHA + +subject of `git merge-base`), instead of a straight line with +`diverged ↑n ↓m`. The base was chosen over a bare join mark: where the split +happened is what decides between a merge and a rebase. Cost: one +`merge-base` and one `log -1` per diverged commit, only when it is diverged. +Only one fork is drawn; with two diverged commits (rare) the old line with +the diverged gap remains, since nodes on three lanes would need the +merge bases between all of them. diff --git a/docs/requirements.md b/docs/requirements.md index 6613552..37a3cfc 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -64,6 +64,8 @@ commit hashes. It doesn't answer the questions users actually have: **tip** and the checkout (**you**) as one line of history, newest on top: a node per commit (SHA + subject) with its roles, and the number of commits between two nodes. Roles on the same commit share a node. A + commit that has diverged from the pin is drawn as a fork that joins at + the commit where the histories split (`base`). A checkout that differs from the pin is highlighted. - **F22** *(dropped)* The commits between pin and tip are not listed; only their number is shown (F21). diff --git a/docs/state-model.md b/docs/state-model.md index f5c9056..f6ee0c5 100644 --- a/docs/state-model.md +++ b/docs/state-model.md @@ -32,6 +32,7 @@ in several at once. | `Pin` | `Ref`, `Source`, `Ahead`, `Behind`, `Pushed` | | `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) | | `Drift` | Checkout compared with the pin: `Ahead` = commits only the checkout has, `Behind` = commits only the pin has | +| `Bases` | `Tip`, `Checkout`, `Committed`: `merge-base ` for each commit that is diverged from the pin (ahead and behind); nil otherwise or without a common ancestor | | `Committed`, `CommittedDrift` | With a staged pin: the pin in the superproject's HEAD (nil if it is not on disk or the submodule was only added) and how it compares with the staged pin, like `Drift` | | `Changes` | Changed files of the worktree (nil if clean), see below | | `FetchedAt` | Last fetch of the submodule (`FETCH_HEAD` mtime); zero if never | @@ -174,6 +175,7 @@ are local; `` is the index gitlink. | `Pin.Ref` | rules above; the remote is `origin`, else the first remote | | `Ahead`, `Behind` | `rev-list --left-right --count ...` | | `Drift` | `rev-list --left-right --count ...`, only if the checkout differs from the pin | +| `Bases` | `merge-base ` / `` / ``, only when diverged; then `log -1` of it | | `Committed`, `CommittedDrift` | `ls-tree HEAD` gitlink, if it differs from the pin and exists locally; `rev-list --left-right --count ...` | Details: @@ -212,10 +214,20 @@ roles (`pin`, `tip`, `you`; `HEAD` for the superproject). It uses `Pin.Ahead` / *behind* below it; the `Link` between two nodes counts the commits separating them. The count is the distance, so a tip 4 commits ahead of the pin shows `4 commits`, and an adjacent one `1 commit`. -- A diverged tip is placed above the pin, a diverged checkout below it, - with a diverged `Link` (`Above` and `Below` count the commits only the - upper or lower node has). The link between a diverged node and a node other - than the pin is unknown (zero `Link`). +- A commit that has **diverged** from the pin (tip, checkout or committed + pin) is drawn as a **fork** if it is the only one and its merge base is + known (`Status.Bases`): it is the first node (`SpineNode.Fork`), with the + number of commits down to the base as its `Below`; the main lane follows + (pin and the linear nodes), and a `RoleBase` node where the two histories + split. Linear nodes between the pin and the base stay on the main lane + above it, older ones continue below it, and one that is the base commit + shares its node (`you · base`). The pin-to-base distance is the pin's side + of the divergence. +- Otherwise (two diverged commits, or no merge base) there is no fork: a + diverged tip is placed above the pin, a diverged checkout below it, with a + diverged `Link` (`Above` and `Below` count the commits only the upper or + lower node has). The link between a diverged node and a node other than the + pin is unknown (zero `Link`). - With a staged pin the node of `Pinned` (the index) is labelled `staged`, and the committed pin gets its own `pin` node, placed by `CommittedDrift` like the checkout. Both can share nodes with `tip` and `you`. diff --git a/docs/ux-guide.md b/docs/ux-guide.md index e619ca0..6ed28f4 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -118,9 +118,23 @@ you ● 48005ac Merge branch 'feat/ethercat… sits below it, and the gap above it says `(staged pin moves back)` (blue). - The checkout (`you`) is yellow (label and SHA) when it is on its own node, i.e. off pin. Everything else is the default text color, the gap muted. -- A diverged pair shows `┊ diverged ↑n ↓m` (arrows from the lower node's - view, like the tree). A real fork is not drawn yet. -- The nodes are `●` (`o` with `SUBMARINE_ASCII`), the gap `┊` (`:`). +- A commit that has **diverged** from the pin gets its own lane to the right + of the main one. The two lanes join at a `base` node, the commit where the + histories split; the counts say how many commits each side has since: + + ``` + tip ● e1f2a3b Drop Go 1.21 support + ┊ 2 commits + pin · you ● ┊ c4d5e6f Experimental HTTP/3 + ┊ ┊ 1 commit + base ●─╯ a9b8c7d Release 1.9.0 + ``` + + Only one diverged commit is drawn as a fork, and only if the base is known. + Otherwise the nodes stay in a line and the gap says `┊ diverged ↑n ↓m` + (arrows from the lower node's view, like the tree). +- The nodes are `●` (`o` with `SUBMARINE_ASCII`), the gap `┊` (`:`), the + join `─╯` (`-/`). - A missing submodule has only the pin node (SHA, no subject). #### Change preview diff --git a/internal/submodule/git/provider.go b/internal/submodule/git/provider.go index 505b38a..68ea130 100644 --- a/internal/submodule/git/provider.go +++ b/internal/submodule/git/provider.go @@ -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 } diff --git a/internal/submodule/git/provider_test.go b/internal/submodule/git/provider_test.go index 9a8148b..4c9081c 100644 --- a/internal/submodule/git/provider_test.go +++ b/internal/submodule/git/provider_test.go @@ -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) + } +} diff --git a/internal/submodule/mock/mock.go b/internal/submodule/mock/mock.go index b37fc3e..4a87b8c 100644 --- a/internal/submodule/mock/mock.go +++ b/internal/submodule/mock/mock.go @@ -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, diff --git a/internal/submodule/model.go b/internal/submodule/model.go index cce1fca..088023d 100644 --- a/internal/submodule/model.go +++ b/internal/submodule/model.go @@ -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 } diff --git a/internal/submodule/spine.go b/internal/submodule/spine.go index 2ec9f2a..7f772bd 100644 --- a/internal/submodule/spine.go +++ b/internal/submodule/spine.go @@ -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 } diff --git a/internal/submodule/spine_test.go b/internal/submodule/spine_test.go index 33c39e4..d0ecbee 100644 --- a/internal/submodule/spine_test.go +++ b/internal/submodule/spine_test.go @@ -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 diff --git a/internal/tui/detail/detail.go b/internal/tui/detail/detail.go index c3efa1b..007d447 100644 --- a/internal/tui/detail/detail.go +++ b/internal/tui/detail/detail.go @@ -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) diff --git a/internal/tui/detail/spine_test.go b/internal/tui/detail/spine_test.go index c68eb6c..260667f 100644 --- a/internal/tui/detail/spine_test.go +++ b/internal/tui/detail/spine_test.go @@ -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) + } +} diff --git a/internal/tui/theme/issues.go b/internal/tui/theme/issues.go index 55ca2d2..36d58a4 100644 --- a/internal/tui/theme/issues.go +++ b/internal/tui/theme/issues.go @@ -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: diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go index a2e23bc..bbfed2f 100644 --- a/internal/tui/theme/theme.go +++ b/internal/tui/theme/theme.go @@ -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: "/", } }