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
+10
View File
@@ -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.
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+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
+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: "/",
}
}