Archived
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:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
+16
-4
@@ -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 <pin> <that commit>` 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; `<pin>` is the index gitlink.
|
||||
| `Pin.Ref` | rules above; the remote is `origin`, else the first remote |
|
||||
| `Ahead`, `Behind` | `rev-list --left-right --count <pin>...<ref>` |
|
||||
| `Drift` | `rev-list --left-right --count <head>...<pin>`, only if the checkout differs from the pin |
|
||||
| `Bases` | `merge-base <pin> <ref>` / `<head>` / `<committed>`, 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 <committed>...<pin>` |
|
||||
|
||||
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`.
|
||||
|
||||
+17
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: "/",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user