Show the pin, tip and checkout as a spine in the detail pane

Replace the Pinned/Checkout/Tip rows and the list of commits between pin
and tip with one line of history, newest on top: a node per commit with
its roles (pin, tip, you, staged, HEAD) and the number of commits between
two nodes. A staged pin shows the committed pin as its own node and notes
when it moves back. All three on one commit read "in sync".

Add Status.Drift, Committed and CommittedDrift and Status.Spine(); drop
Status.Between and Repo.FetchedAt. The fetch age moves from the header to
the detail pane (right-aligned in the name's line). The header summary is
right-aligned, ordered by importance and cut with an ellipsis if too narrow.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 16:34:14 +02:00
co-authored by Claude Sonnet 5.5
parent aeedfe96d4
commit 4c1be3f9ab
19 changed files with 714 additions and 193 deletions
+2 -1
View File
@@ -114,7 +114,8 @@ The mock provider (`internal/submodule/mock`) covers every state and sleeps
`Status` calls run at once; the others wait on a semaphore and still
respect `ctx` (D16).
- Superproject: branch from `symbolic-ref`, ahead/behind against
`@{upstream}`, "fetched" from the mtime of `FETCH_HEAD`.
`@{upstream}`. Its fetch age (mtime of `FETCH_HEAD`) is `Status.FetchedAt`
of the root node, like a submodule's.
- `Fetch`: `git fetch --all --prune --no-recurse-submodules --quiet` (we
fetch every submodule ourselves), at most 8 at once, 2 min timeout each.
To never prompt, `GIT_SSH_COMMAND="ssh -o BatchMode=yes"` is set unless
+31 -3
View File
@@ -18,7 +18,7 @@ transparent.
pin, so a mismatch was invisible.
**Decision.** Model all three commits. The tree's ref column always describes
the **pin**; checkout drift is an issue (`off pin`).
**Consequences.** The detail pane shows Pinned / Checkout / Tip rows.
**Consequences.** The detail pane shows all three (see D25).
## D3 — Raw dimensions in the domain, summarized issues in the UI
@@ -303,8 +303,8 @@ not issues.
submodules that is hundreds of network round trips (and credential or
host-key failures) every time the tool opens, whether or not the user wants
fresh remote data.
**Decision.** Supersedes D18. Nothing is fetched on startup. The header and
the detail pane show the last fetch (`FETCH_HEAD`, also from fetches made in
**Decision.** Supersedes D18. Nothing is fetched on startup. The detail pane
shows the last fetch of the selected repository (`FETCH_HEAD`, also from fetches made in
other shells), so the user can judge how stale the remote refs are and press
`f` to fetch. The `--no-fetch` flag is removed with it (it still had the
side effect of switching off the update check; that has
@@ -386,3 +386,31 @@ restores below, without staging.
**Consequences.** `Switcher` is a new optional provider interface; the mock
does not implement it. The switch runs as the restore does (one write
operation at a time, same lines above the footer).
## D25 — Detail pane: a spine instead of three commit rows and a list
**Context.** `Pinned` / `Checkout` / `Tip` were three rows of SHAs to
compare, the distance sat on the `Ref` line, and a list of up to 50 commits
(two lines each) pushed the rest out of view. Where the pin, the checkout and
the tip are had to be worked out by reading.
**Decision.** Draw them as one line of history, newest on top: a node per
commit, the roles in the label column, the number of commits between two
nodes in a gap row. Roles on one commit share a node. The commit list is
dropped (no cap, no popup): the gap count says how far, not what changed.
The `Ref` line loses the distance suffix (the gap says it) and keeps
`via <rule>`.
**Consequences.** `Status.Between` is gone; `Status.Drift` (checkout ↔ pin,
one `rev-list --left-right --count`, only when off pin) and `Status.Spine()`
are new. Author and age of commits are no longer shown anywhere in the pane.
Diverged tip or checkout show `diverged ↑n ↓m` in the gap; drawing a real
fork needs the merge base and is left for later.
**Amendment (with D25).** The header no longer shows `fetched <age>`. It was
the superproject's own fetch, which the tree's `↓n` do not depend on, and read
as the age of everything. The age lives only in the detail pane, right-aligned
in the name's line (the superproject's when the header is selected); the
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.
+12 -9
View File
@@ -59,11 +59,14 @@ commit hashes. It doesn't answer the questions users actually have:
- **F20** Shows the selected submodule (or the superproject): full path, name, all issues in
words, staged-pin and detached-HEAD notes.
- **F21** Shows the resolved ref, how it was resolved, and three commits:
**Pinned**, **Checkout** and **Tip** (SHA + subject). A checkout that
differs from the pin is highlighted.
- **F22** Lists the commits between pin and tip (SHA, subject, author, age)
with a title stating ahead/behind/diverged.
- **F21** Shows the resolved ref, how it was resolved, and the **pin** (and
the **staged** pin, if there is one), the
**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
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).
- **F23** Shows child count, issues below, and the remote URL.
- **F24** For a submodule with uncommitted changes, previews the changed
files so the user can remember what they were doing: a flat list with a
@@ -78,8 +81,8 @@ commit hashes. It doesn't answer the questions users actually have:
or untracked, untracked repositories (e.g. a submodule clone the current
branch no longer has), conflicts, and HEAD not pushed. Its HEAD is compared
with the upstream of the branch. Submodules do not count for it, their rows
show them. The detail pane shows it like a submodule (HEAD and Tip instead
of Pinned and Checkout, a summary of all submodules instead of Children).
show them. The detail pane shows it like a submodule (HEAD and tip instead
of pin and you, a summary of all submodules instead of Children).
See [state-model.md](state-model.md#superproject).
### Header and footer
@@ -100,8 +103,8 @@ commit hashes. It doesn't answer the questions users actually have:
### Fetch and refresh
- **F50** Nothing is fetched on startup. The header and the detail pane show
when the repositories were last fetched (`FETCH_HEAD`), whoever did it.
- **F50** Nothing is fetched on startup. The detail pane shows
when the selected repository was last fetched (`FETCH_HEAD`), whoever did it.
`f` fetches the superproject and every initialized submodule in the
background (all remotes, with prune).
- **F51** While fetching, the header shows progress (`fetching 12/174`)
+27 -2
View File
@@ -31,7 +31,8 @@ in several at once.
| `PinStaged` | Pin changed in the superproject index but not committed |
| `Pin` | `Ref`, `Source`, `Ahead`, `Behind`, `Pushed` |
| `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) |
| `Between` | Commits that make up the pin ↔ tip difference |
| `Drift` | Checkout compared with the pin: `Ahead` = commits only the checkout has, `Behind` = commits only the pin has |
| `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 |
@@ -172,7 +173,8 @@ are local; `<pin>` is the index gitlink.
| `Pushed` | `for-each-ref --contains <pin> refs/remotes` is not empty |
| `Pin.Ref` | rules above; the remote is `origin`, else the first remote |
| `Ahead`, `Behind` | `rev-list --left-right --count <pin>...<ref>` |
| `Between` | `log <pin>...<ref>`, at most 50 commits |
| `Drift` | `rev-list --left-right --count <head>...<pin>`, only if the checkout differs from the pin |
| `Committed`, `CommittedDrift` | `ls-tree HEAD` gitlink, if it differs from the pin and exists locally; `rev-list --left-right --count <committed>...<pin>` |
Details:
@@ -196,6 +198,29 @@ Details:
fails with "pinned commit … not fetched" (shown as `error`) instead of
guessing `unpushed`.
## Spine
`Status.Spine()` arranges the pin, the tip and the checkout as one line of
history, newest on top, for the detail pane. Each node is a commit with its
roles (`pin`, `tip`, `you`; `HEAD` for the superproject). It uses `Pin.Ahead` /
`Pin.Behind` (pin ↔ tip) and `Drift` (checkout ↔ pin).
- Roles on the same commit share a node (`pin · tip · you`);
`SpineNode.InSync()` is true if all of them are on it (`HEAD` and `tip` for
the superproject).
- Tip or checkout *ahead* of the pin (it is their ancestor) sit above it,
*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`).
- 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`.
- No tip (the pin resolved to a SHA) or no checkout commit: no node.
## Distance notation
| Display | Meaning |
+55 -21
View File
@@ -15,17 +15,17 @@
## Layout
```
~/coding/my_project main 13 submodules · 1 unpushed · 1 missing · 2 off pin · … fetched 2m ago
~/coding/my_project main 1 unpushed · 1 missing · 2 off pin · 2 changed · 13 submodules
docs origin/main │ src/
misc/tools/ │ my_submodule_3
my_submodule_1 origin/main │
my_submodule_2 missing a1b2c3d │ unpushed pin not pushed to any remote
src/ │
my_submodule_1 off pin origin/main │ Ref origin/main ↑1
▸ my_submodule_2 [2] changed ·2 origin/main │ via checked out branch
my_submodule_3 unpushed origin/main ↑1 │ Pinned b7c8d9e Hotfix null pointer
libs/ │ Checkout b7c8d9e Hotfix null pointer
logging origin/main ↓3 │ Tip 6f5e4d3 Release 2.1.0
my_submodule_1 off pin origin/main │ Ref origin/main · via checked out branch
▸ my_submodule_2 [2] changed ·2 origin/main │ pin · you ● b7c8d9e Hotfix null pointer
my_submodule_3 unpushed origin/main ↑1 │ ┊ 1 commit
libs/ │ tip ● 6f5e4d3 Release 2.1.0
logging origin/main ↓3 │
↑↓/jk move • ←→/hl fold • ⇧←→/HL fold all • space toggle • ? help • q quit
```
@@ -75,23 +75,53 @@ indent ▸ name [n] status ref delta
### Detail pane
For the superproject (header selected), `Pinned` / `Checkout` become one
`HEAD` line, the commit list says "HEAD is n commits behind …", and
`Children` becomes `Modules` (count and issues of all submodules). See
For the superproject (header selected), the `pin` and `you` roles become
`HEAD`, and `Children` becomes `Modules` (count and issues of all submodules). See
[Superproject](state-model.md#superproject).
1. Directory (muted) and name (bold)
1. Directory (muted) and name (bold); `fetched <age>` of the submodule,
muted, right-aligned in the name's line (left out if the pane is too
narrow, or the submodule is missing)
2. One line per issue: word (colored, 10 columns) + explanation;
`in sync nothing to do` if there are none and no pin is staged;
`staged` and `detached HEAD on pin` as information
3. `Changes` (only with uncommitted changes), see
[Change preview](#change-preview)
4. `Ref` with distance and `via <rule>`; `Fetched <age>` of the submodule;
4. `Ref` with `· via <rule>`, a blank line, then the [spine](#spine);
`fetch failed: <git's message>` (yellow word) if the last fetch failed
5. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it
differs from the pin
6. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list
7. `Children` (count and issues below), `URL`
5. `Children` (count and issues below), `URL`
#### Spine
```
Ref origin/main · via .gitmodules branch
tip ● 3f1bf20 Fix: update package dependencies
┊ 4 commits
pin ● 9ac01de Add ethercat node
┊ 2 commits
you ● 48005ac Merge branch 'feat/ethercat…
```
- Newest on top, like `git log`: where each commit is, is read from its
position, not by comparing SHAs. The commits between two nodes are not
listed, only counted (`┊ 4 commits`; `1 commit` if adjacent).
- The label column holds the roles (`pin`, `tip`, `you`; `HEAD` for the
superproject), joined by ` · ` if they sit on one commit
(`pin · tip`, `pin · you`). If all of them sit on one commit it says
`in sync` instead, which also keeps the commit column in line with the
other values; the column only grows for a longer label.
- With a staged pin the spine shows both: `staged` (blue label) is the pin
in the index, `pin` the one in the superproject's HEAD, so the bump is
visible as a distance between two nodes. The committed pin is left out if
it is not on disk. If the staged pin is *older* than the committed one it
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 missing submodule has only the pin node (SHA, no subject).
#### Change preview
@@ -147,7 +177,11 @@ shows the superproject's own state word like a tree row (`in sync`,
`changed`, `unpushed +1`, spinner, `error`). The counts that follow are
about submodules only.
`path branch ↑↓ state · n submodules · n error · <count> <issue>… · n pin staged · n loading · <fetch>`
`path branch ↑↓ state n error · <count> <issue>… · n pin staged · n loading · n submodules · <fetch>`
The summary and `<fetch>` form one group at the right edge; the path,
branch and state stay at the left. If the line is too narrow it is cut with
`…` at the right end, so the most important counts stay.
Issue counts are ordered by severity (unpushed, conflict, missing, off pin,
changed), each in its level color. Zero counts are omitted. `n loading`
@@ -160,12 +194,12 @@ superproject shows `(detached)` as its branch.
| State | Shows |
|---|---|
| fetching | spinner + `fetching 12/174` (repositories done / total) |
| done | `fetched just now`, then the age (`fetched 5m ago`) |
| some failed | `fetched just now · 3 failed` (count in yellow) |
| never fetched | `fetched never` |
| done | nothing |
| some failed | `3 failed` (count in yellow) |
The age is the newer of the superproject's `FETCH_HEAD` and the end of the
application's own fetch.
The header has no fetch age: the age belongs to one repository, and a header
age would be read as the age of everything. It is in the detail pane, for
the selected repository (the superproject's own when the header is selected).
## Colors