10 Commits
Author SHA1 Message Date
simonfrankenandClaude Sonnet 5.5 8aa12774eb Resolve pins to the closest branch
A pin on a pushed feature branch showed as diverged from origin/main
because the .gitmodules branch won before any distance was compared.

Now the branch containing the pin with the fewest commits after it wins;
the .gitmodules branch, the upstream and the remote default branch only
break ties and are always compared. An unpushed pin compares just those
preferred refs by commits on either side.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:57:55 +02:00
simonfrankenandClaude Sonnet 5.5 5cb809428b Add c to copy the selected path, split key bar into groups
c copies the absolute directory of the selected submodule via OSC 52.
Keys are grouped into navigation (footer left), global (footer right)
and submodule (detail pane palette, footer if the pane can't show it).
Adds a key reference table to the UX guide.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:48:16 +02:00
simonfrankenandClaude Sonnet 5.5 90ea9cc241 Add shift+arrows for collapse/expand all
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:30:14 +02:00
simonfrankenandClaude Sonnet 5.5 e4340fb35e Show h/l, j/k and H/L in the key legend
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:29:47 +02:00
simonfrankenandClaude Sonnet 5.5 6cb7bd6967 Add H/L as aliases for collapse/expand all
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:27:27 +02:00
simonfrankenandClaude Sonnet 5.5 17151a578d Split hidden issue counts by level
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:21:00 +02:00
simonfrankenandClaude Sonnet 5.5 aec4ec6e3b Show failed loads as errors in the header
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:17:27 +02:00
simonfrankenandClaude Sonnet 5.5 e5fdc875af Count failed loads as danger in hidden issues
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:14:47 +02:00
simonfrankenandClaude Sonnet 5.5 af6d03a5b1 Mark untracked directories that are git repositories
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:12:13 +02:00
simonfrankenandClaude Sonnet 5.5 69d0f9b704 Don't count failed loads as loading; explain missing gitlink
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:09:59 +02:00
23 changed files with 529 additions and 127 deletions
+7 -1
View File
@@ -31,7 +31,7 @@ internal/tui/ Bubble Tea application
tree/ tree component: rows, navigation, mouse, view
detail/ detail pane; changes.go: change preview
header/ header line
footer/ help and flash messages
footer/ key bar (navigation left, global right) and flash messages
```
### Layering rules
@@ -64,6 +64,12 @@ type Watcher interface {
}
```
```go
type Locator interface {
Dir(path string) string // absolute directory, "" if unknown
}
```
- `Tree` returns the superproject and all submodules with `Parent` and
`Children` linked. `Status` fields are nil.
- `Status` is called once per submodule, concurrently. It must be safe to
+38 -5
View File
@@ -7,7 +7,8 @@ Each entry: context, decision, consequences. Newest last.
**Context.** Hashes don't tell users whether a pin is current.
**Decision.** Resolve each pin to a ref and show the distance (`origin/main ↓1`).
The resolution order is fixed and the applied rule is shown in the detail pane
(see [state-model.md](state-model.md#resolving-a-pin-to-a-ref)).
(see [state-model.md](state-model.md#resolving-a-pin-to-a-ref); amended:
the closest branch wins, see "Pin resolves to the closest branch").
**Consequences.** Resolution is a heuristic; showing the rule keeps it
transparent.
@@ -53,15 +54,17 @@ everyone else's checkout.
**Context.** Only the first level is shown by default, so nested problems
would be invisible.
**Decision.** Collapsed parents show `·k` (issues in all descendants), colored
by the worst one. Problems in a child do **not** mark the parent itself as
`changed`.
**Decision.** Collapsed parents show `·k` (issues in all descendants), one
count per level: red for danger, then yellow for warn (amended: a single
count in the worst color made 1 error + 1 warning look like 2 errors; the
status column grew from 14 to 16 for the second count). Problems in a child
do **not** mark the parent itself as `changed`.
## D8 — Collapsed by default, branch-first navigation
**Decision.** Only the first level is visible. `→` expands and enters the
first child, `←` collapses and goes to the parent, `space` toggles,
`ctrl+→/←` expand/collapse all (see [ux-guide.md](ux-guide.md#navigation)).
`shift+→/←`, `L`/`H` (or `ctrl+→/←`) expand/collapse all (see [ux-guide.md](ux-guide.md#navigation)).
`←` on an expanded node collapses it in place, so a node can be closed
without leaving it. Remembered expansion is kept on collapse, but reset by
"collapse all".
@@ -198,3 +201,33 @@ package so versions stay clean.
corrupt or partial downloads, not a compromised registry (no signatures).
The update check is a deliberate, outside-the-provider network use: only
on `update` and a once-a-day background check that can be disabled.
## Copy path with `c` via OSC 52
`c` copies the selected submodule's absolute directory (optional
`submodule.Locator`; the relative path as fallback). The absolute path is
what a shell needs (`cd <paste>`). OSC 52 instead of a clipboard tool
(`xclip`, `pbcopy`): no external dependency and it works over SSH. The cost:
terminals that block OSC 52 copy nothing silently; the footer still says
`copied`, since the application can't know.
## Key groups: global, navigation, submodule
Keys that act on the selected submodule (copy path, later checkout, bump) are
shown in a palette in the detail pane, next to what they act on; global keys
(`?`, `q`) sit right in the footer. Where the pane can't show the palette
(hidden below 90 columns, or under 12 lines) the footer shows these keys
instead, so small terminals need no extra UI (no overlay or action menu).
Keys never depend on what is visible.
## Pin resolves to the closest branch
A pin on a pushed feature branch showed as diverged from `origin/main`,
because `branch = main` in `.gitmodules` won before any distance was looked
at. Now the closest branch containing the pin wins; the `.gitmodules`
branch, the upstream and the remote default branch only break ties (and are
always compared, even past the 16-branch cap). An unpushed pin compares only
those preferred refs, by commits on either side: scanning every branch's
merge base would be too slow. Cost: a pin on main far behind its tip shows a
younger branch that contains it if that branch's tip is closer, so "n behind
main" is no longer visible there; `via <rule>` still tells which ref it is.
+2 -1
View File
@@ -34,7 +34,8 @@ commit hashes. It doesn't answer the questions users actually have:
- **F5** A collapsed submodule shows the number of its **direct** children,
e.g. `[2]`.
- **F6** A collapsed submodule shows the number of issues **hidden** in all
its descendants (`·2`), colored by the most severe hidden issue.
its descendants (`·1 ·2`), one count per level: errors in red, then
warnings in yellow.
- **F7** Expansion state of descendants is remembered when a parent is
collapsed and restored when it is expanded again (except after
"collapse all", which resets it).
+33 -17
View File
@@ -72,7 +72,7 @@ truth for what counts as a problem.
| `conflict` | Merge conflict in the worktree | Danger |
| `missing` | Not initialized; directory missing | Warn |
| `off pin` | Checkout differs from pin | Warn |
| `changed` | Uncommitted changes; untracked files | Warn |
| `changed` | Uncommitted changes; untracked files (worded "untracked repository" if all of them are git repositories) | Warn |
Rules:
@@ -96,23 +96,38 @@ Rules:
### Hidden issues
`Submodule.HiddenIssues()` counts the issues of **all** loaded descendants
and returns the most severe level. A collapsed submodule shows this as `·k`
in that level's color, so a danger can never hide inside a collapsed subtree.
per level (`Hidden{Warn, Danger}`). A collapsed submodule shows them as `·k`
per level, red first, then yellow, so a danger can never hide inside a
collapsed subtree and a warning is never mistaken for one.
A descendant whose load failed (`error`) counts as one danger.
## Resolving a pin to a ref
The pin is displayed as a ref, not a hash. A commit can be on several
branches, on none, or deep in the history, so the git provider resolves it by
this order and records which rule applied in `PinInfo.Source`:
branches, on none, or deep in the history. The git provider shows the
**closest** branch and records which rule proposed it in `PinInfo.Source`.
Preferred refs, in this order (each only if it exists):
| Preference | Ref | `RefSource` |
|---|---|---|
| a | `branch = …` in `.gitmodules` | `RefFromGitmodules` |
| b | Upstream of the branch checked out in the submodule | `RefFromCurrentBranch` |
| c | Remote default branch (`<remote>/HEAD`) | `RefFromDefaultBranch` |
Resolution:
| # | Rule | `RefSource` |
|---|---|---|
| 1 | `branch = …` in `.gitmodules` | `RefFromGitmodules` |
| 2 | Branch currently checked out in the submodule | `RefFromCurrentBranch` |
| 3 | Remote default branch, if it contains the pin | `RefFromDefaultBranch` |
| 4 | Any remote branch containing the pin, preferring the closest tip (`git branch -r --contains`) | `RefFromContains` |
| 5 | A tag (`git describe`) | `RefFromTag` |
| 6 | Short SHA | `RefFromSHA` |
| 1 | Pin pushed: the branch containing the pin with the fewest commits after it. Candidates are the preferred refs that contain the pin plus any remote branch containing it (`for-each-ref --contains`); a tie goes to the preferred ref | preferred ref's, else `RefFromContains` |
| 2 | Pin unpushed: the preferred ref with the fewest commits on either side (`↑n↓m`), ties by preference | preferred ref's |
| 3 | A tag (`git describe`) | `RefFromTag` |
| 4 | Short SHA | `RefFromSHA` |
So a `.gitmodules` branch is shown when it is as close as any other branch,
but a pin that sits on a feature branch shows that branch rather than
"diverged from main". The trade-off: a pin on main that a younger branch
also contains shows the younger branch if its tip is fewer commits away.
Distances: `git rev-list --left-right --count <pin>...<ref>` gives
`Ahead` (left) and `Behind` (right).
@@ -141,12 +156,13 @@ are local; `<pin>` is the index gitlink.
Details:
- Rule 1: `branch = .` means the superproject's current branch. The rule
applies only if `<remote>/<branch>` exists locally.
- Rule 2 uses the checked-out branch's upstream (`<branch>@{upstream}`).
- Rule 4 compares at most 16 containing branches (oldest tip first) and
picks the one with the fewest commits after the pin.
- Rule 6 shows the short SHA; there is no tip and no distance.
- Preference a: `branch = .` means the superproject's current branch. The
ref counts only if `<remote>/<branch>` exists locally.
- Preference b uses the checked-out branch's upstream (`<branch>@{upstream}`).
- Rule 1 counts `rev-list --count <pin>..<ref>`. Besides the preferred refs
it compares at most 16 containing branches (oldest tip first).
- Rule 2 counts `rev-list --count <pin>...<ref>`.
- Rule 4 shows the short SHA; there is no tip and no distance.
- `--ignore-submodules=dirty`: changes inside a nested submodule do not make
its parent `changed` (D7), but a moved nested pin does.
- Renames are detected only for staged moves (`git mv`); an unstaged move
+56 -9
View File
@@ -26,7 +26,7 @@
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
↑/↓ move • ←/→ collapse/expand • space toggle • u checkout pin • … • ? help • q quit
↑↓/jk move • ←→/hl fold • ⇧←→/HL fold all • space toggle • ? help • q quit
```
| Area | Height / width |
@@ -36,6 +36,21 @@
| Detail pane | 40% of the width, at least 36 columns; hidden below 90 columns |
| Footer | 1 line (more with full help) |
### Key bar and palette
Keys come in three groups (`keyMap.Navigation`, `.Submodule`, `.Global`):
- **Navigation** (move, fold, toggle): left in the footer.
- **Global** (`?`, `q`): right-aligned in the footer, always kept.
- **Submodule** (act on the selected one, e.g. `c`): a palette on the last
line of the detail pane. It is left out if the pane is shorter than 12
lines. Whenever the palette isn't shown (pane hidden or too short), these
keys move into the footer after the navigation keys.
If the footer is too narrow, keys are dropped from the left group's end
(submodule keys first); the global keys stay. Full help (`?`) lists all keys
in columns, whatever the size. Keys work whether or not they are shown.
### Tree row
```
@@ -47,7 +62,7 @@ indent ▸ name [n] status ref delta
| indent | 2 per depth | |
| toggle | 3 | `▸` collapsed, `▾` expanded, blank for leaves |
| name | fits the widest visible name | name, `[n]` = direct children (collapsed only) |
| status | 14 | issue word, `+n`, `·k` hidden issues; muted `in sync` without issues; spinner while loading |
| status | 16 | issue word, `+n`, `·k` hidden issues per level (red, then yellow); muted `in sync` without issues; spinner while loading |
| ref | widest ref, max 22 | resolved ref; muted short SHA if unresolved |
| delta | 6 | `↑n`, `↓n`, `↑n↓m` |
@@ -91,7 +106,9 @@ Changes 5 files · 2M 1A 1D 1R · +37 −12
meaning, the color only supports it (N4).
- Letters and colors: `!` red bold, `M` yellow, `A` green, `D` red, `R`
cyan, `?` muted. Line counts: green `+n`, red `−n`; `bin` for binary
files, `submodule` for a moved nested pin, nothing for untracked files.
files, `submodule` for a moved nested pin, `repository` for an untracked
directory that is a git repository itself (cloned, never added as a
submodule), nothing for other untracked files.
- Order: kind (`!`, `M`, `A`, `D`, `R`, `?`), then changed lines, largest
first, because the biggest edits are the ones people remember.
- Budget: 10 files. Every kind that occurs gets at least one line, the
@@ -118,10 +135,12 @@ Changes 5 files · 2M 1A 1D 1R · +37 −12
- Untracked directories are shown as one entry (`tmp/`).
### Header
`path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · <fetch>`
`path branch ↑↓ · n submodules · n error · <count> <issue>… · n pin staged · n loading · <fetch>`
Issue counts are ordered by severity (unpushed, conflict, missing, off pin,
changed), each in its level color. Zero counts are omitted. A detached
changed), each in its level color. Zero counts are omitted. `n loading`
counts only submodules whose status is still being loaded; ones whose load
failed are counted separately as `n error` (red, before the issue counts). A detached
superproject shows `(detached)` as its branch.
`<fetch>` on the right:
@@ -179,14 +198,15 @@ colors in components.
| `space` | Toggle expand/collapse of the selected submodule |
| `→` / `l` | Collapsed with children: expand and select the first child. Expanded: select the first child. Leaf: nothing |
| `←` / `h` | Expanded: collapse it (stays selected). Otherwise: collapse the parent and select it. Top level: nothing |
| `ctrl+→` | Expand all; then follow the first child from the selection down to a submodule without children |
| `ctrl+←` | Collapse all (resets remembered expansion); select the top-level ancestor |
| `L` / `shift+→` / `ctrl+→` | Expand all; then follow the first child from the selection down to a submodule without children |
| `H` / `shift+←` / `ctrl+←` | Collapse all (resets remembered expansion); select the top-level ancestor |
| `home` / `g`, `end` / `G` | First / last submodule |
| `pgup`, `pgdn` | Move by one screen |
| `?` | Toggle full help |
| `q`, `ctrl+c` | Quit |
| `f` | Fetch all repositories (does nothing while a fetch runs) |
| `r` | Reload everything (e.g. after editing files) |
| `c` | Copy the absolute directory of the selected submodule to the clipboard (OSC 52, so it also works over SSH; needs terminal support, in tmux `set -g set-clipboard on`). The footer confirms with `copied <path>`. Without a local provider (mock) the path relative to the superproject is copied |
| `u`, `b`, `/`, `!` | Planned actions (see requirements) |
"First child" always means the first child **as displayed** (children are
@@ -196,6 +216,32 @@ Collapsing an ancestor of the selection moves the selection to that ancestor.
The view scrolls so the selection is always visible; when the first submodule
is selected, headers above it are shown too.
### Key reference
Every key in the UI, where it is shown, and whether it works. Update this
table when a key is added, moved or implemented. "Bar" is the short footer
bar, "Palette" the detail pane's palette (the footer takes its keys over
where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette)),
"Help" the full help (`?`).
| Key | Action | Group | Shown in | Implemented |
|---|---|---|---|---|
| `↑` / `k`, `↓` / `j` | Move selection | Navigation | Bar, Help | yes |
| `←` / `h`, `→` / `l` | Fold / unfold, parent / first child | Navigation | Bar, Help | yes |
| `H` / `⇧←`, `L` / `⇧→` | Collapse / expand all | Navigation | Bar, Help | yes |
| `space` | Toggle expand/collapse | Navigation | Bar, Help | yes |
| `home` / `g`, `end` / `G` | First / last submodule | Navigation | Help | yes |
| `pgup`, `pgdn` | Move by one screen | Navigation | Help | yes |
| `c` | Copy path | Submodule | Palette, Help | yes |
| `u` | Checkout pin | Submodule | Help | no (flashes "not implemented") |
| `b` | Bump pin to tip | Submodule | Help | no (flashes "not implemented") |
| `f` | Fetch all repositories | Global | Help | yes |
| `r` | Reload everything | Global | Help | yes |
| `/` | Filter | Global | Help | no (flashes "not implemented") |
| `!` | Problems only | Global | Help | no (flashes "not implemented") |
| `?` | Toggle full help | Global | Bar, Help | yes |
| `q`, `ctrl+c` | Quit | Global | Bar, Help | yes |
### Mouse
- Click a row: select it.
@@ -205,5 +251,6 @@ is selected, headers above it are shown too.
## Accessibility and terminals
- `SUBMARINE_ASCII=1`: ASCII symbols (`^ v > v . *`) instead of Unicode.
- Some terminals or multiplexers capture `ctrl+←/→`; letter aliases can be
added if needed.
- Many setups capture `ctrl+←/→` (macOS Mission Control, some terminals and
multiplexers), so expand/collapse all also work as `shift+→/←` and as `L` / `H`, the
shifted vim keys for `→` / `←` (for terminals that don't report shift+arrows).
+1 -1
View File
@@ -3,6 +3,7 @@ module submarine
go 1.27.3
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
@@ -11,7 +12,6 @@ require (
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
+11
View File
@@ -36,6 +36,17 @@ func (p *Provider) Fetchable(path string) bool {
return ok
}
// Dir returns the absolute directory of path ("" is the superproject),
// whether or not it is initialized.
func (p *Provider) Dir(path string) string {
if path == "" {
return p.root
}
p.mu.RLock()
defer p.mu.RUnlock()
return p.mods[path].dir
}
// repoDir returns the worktree of path if it is initialized.
func (p *Provider) repoDir(path string) (string, bool) {
if path == "" {
+103 -50
View File
@@ -20,7 +20,7 @@ import (
)
// maxContainsCandidates limits how many remote branches containing the pin
// are compared when resolving the pin by RefFromContains.
// are compared besides the preferred refs when resolving a pushed pin.
const maxContainsCandidates = 16
// maxBetween limits the commits listed between pin and tip.
@@ -158,7 +158,7 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
}
pin := parseGitlink(out)
if pin == "" {
return nil, errors.New("no gitlink in the superproject index")
return nil, errors.New("listed in .gitmodules, but has no gitlink in the superproject index (stale entry?)")
}
out, _, err = try(ctx, in.superDir, "ls-tree", "HEAD", "--", in.relPath)
if err != nil {
@@ -269,80 +269,130 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
}
// resolve finds the ref that describes the pin, following the order in
// docs/state-model.md. containing lists the remote branches containing the
// pin, oldest tip first.
// docs/state-model.md: the closest branch wins, the preferred refs (see
// preferredRefs) break ties. containing lists the remote branches containing
// the pin, oldest tip first.
func resolve(ctx context.Context, in info, pin, branch string, containing []string) (string, submodule.RefSource, error) {
remote, err := remoteName(ctx, in.dir)
preferred, err := preferredRefs(ctx, in, branch)
if err != nil {
return "", 0, err
}
// 1. branch from .gitmodules
if b := in.branch; b != "" && remote != "" {
if b == "." {
b, _, err = try(ctx, in.superDir, "symbolic-ref", "--short", "-q", "HEAD")
if err != nil {
return "", 0, err
}
}
if b != "" {
if _, ok, err := try(ctx, in.dir, "rev-parse", "--verify", "-q", "refs/remotes/"+remote+"/"+b); err != nil {
// 1. pushed: the branch containing the pin with the fewest commits after it
if len(containing) > 0 {
var cands []candidate
for _, c := range preferred {
if _, ok, err := try(ctx, in.dir, "merge-base", "--is-ancestor", pin, c.ref); err != nil {
return "", 0, err
} else if ok {
return remote + "/" + b, submodule.RefFromGitmodules, nil
cands = append(cands, c)
}
}
}
// 2. upstream of the checked-out branch
if branch != "" {
if up, ok, err := try(ctx, in.dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", branch+"@{upstream}"); err != nil {
return "", 0, err
} else if ok && up != "" {
return up, submodule.RefFromCurrentBranch, nil
for _, r := range containing[:min(len(containing), maxContainsCandidates)] {
if !slices.ContainsFunc(cands, func(c candidate) bool { return c.ref == r }) {
cands = append(cands, candidate{r, submodule.RefFromContains})
}
}
}
// 3. remote default branch, if it contains the pin
if remote != "" {
if def, ok, err := try(ctx, in.dir, "symbolic-ref", "--short", "-q", "refs/remotes/"+remote+"/HEAD"); err != nil {
if c, ok, err := closest(ctx, in.dir, pin+"..", cands); err != nil {
return "", 0, err
} else if ok {
if _, ok, err := try(ctx, in.dir, "merge-base", "--is-ancestor", pin, def); err != nil {
return "", 0, err
} else if ok {
return def, submodule.RefFromDefaultBranch, nil
}
return c.ref, c.source, nil
}
}
// 4. remote branch containing the pin with the closest tip
best, bestN := "", -1
for _, r := range containing[:min(len(containing), maxContainsCandidates)] {
out, err := run(ctx, in.dir, "rev-list", "--count", pin+".."+r)
if err != nil {
return "", 0, err
}
var n int
if _, err := fmt.Sscan(out, &n); err == nil && (bestN < 0 || n < bestN) {
best, bestN = r, n
}
}
if best != "" {
return best, submodule.RefFromContains, nil
// 2. unpushed: the preferred ref with the fewest commits on either side
if c, ok, err := closest(ctx, in.dir, pin+"...", preferred); err != nil {
return "", 0, err
} else if ok {
return c.ref, c.source, nil
}
// 5. nearest tag
// 3. nearest tag
if tag, ok, err := try(ctx, in.dir, "describe", "--tags", "--abbrev=0", pin); err != nil {
return "", 0, err
} else if ok && tag != "" {
return tag, submodule.RefFromTag, nil
}
// 6. the commit itself
// 4. the commit itself
return "", submodule.RefFromSHA, nil
}
// candidate is a ref the pin may resolve to and the rule that proposed it.
type candidate struct {
ref string
source submodule.RefSource
}
// preferredRefs returns the refs the user most likely means, in order of
// preference and without duplicates: the .gitmodules branch, the upstream of
// the checked-out branch, the remote default branch. Each must exist.
func preferredRefs(ctx context.Context, in info, branch string) ([]candidate, error) {
remote, err := remoteName(ctx, in.dir)
if err != nil {
return nil, err
}
var refs []candidate
add := func(ref string, source submodule.RefSource) {
if ref != "" && !slices.ContainsFunc(refs, func(c candidate) bool { return c.ref == ref }) {
refs = append(refs, candidate{ref, source})
}
}
// branch from .gitmodules
if b := in.branch; b != "" && remote != "" {
if b == "." {
b, _, err = try(ctx, in.superDir, "symbolic-ref", "--short", "-q", "HEAD")
if err != nil {
return nil, err
}
}
if b != "" {
if _, ok, err := try(ctx, in.dir, "rev-parse", "--verify", "-q", "refs/remotes/"+remote+"/"+b); err != nil {
return nil, err
} else if ok {
add(remote+"/"+b, submodule.RefFromGitmodules)
}
}
}
// upstream of the checked-out branch
if branch != "" {
if up, ok, err := try(ctx, in.dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", branch+"@{upstream}"); err != nil {
return nil, err
} else if ok {
add(up, submodule.RefFromCurrentBranch)
}
}
// remote default branch
if remote != "" {
if def, ok, err := try(ctx, in.dir, "symbolic-ref", "--short", "-q", "refs/remotes/"+remote+"/HEAD"); err != nil {
return nil, err
} else if ok {
add(def, submodule.RefFromDefaultBranch)
}
}
return refs, nil
}
// closest returns the candidate with the fewest commits in the range
// `<from><ref>` (from is e.g. "<pin>.."); the first one wins a tie.
func closest(ctx context.Context, dir, from string, cands []candidate) (candidate, bool, error) {
best, bestN := candidate{}, -1
for _, c := range cands {
out, err := run(ctx, dir, "rev-list", "--count", from+c.ref)
if err != nil {
return candidate{}, false, err
}
var n int
if _, err := fmt.Sscan(out, &n); err == nil && (bestN < 0 || n < bestN) {
best, bestN = c, n
}
}
return best, bestN >= 0, nil
}
// remoteName returns "origin" if it exists, else the first remote, else "".
func remoteName(ctx context.Context, dir string) (string, error) {
out, err := run(ctx, dir, "remote")
@@ -387,6 +437,9 @@ func changes(ctx context.Context, dir string, entries []entry) (*submodule.Chang
c.Added += n.added
c.Removed += n.removed
}
if e.kind == submodule.ChangeUntracked && strings.HasSuffix(e.path, "/") {
f.Repo = initialized(filepath.Join(dir, filepath.FromSlash(e.path)))
}
if e.kind != submodule.ChangeRemoved {
if fi, err := os.Stat(filepath.Join(dir, filepath.FromSlash(e.path))); err == nil {
f.Modified = fi.ModTime()
+2 -1
View File
@@ -75,7 +75,7 @@ func TestTree(t *testing.T) {
submodule.Walk(repo.Submodules, func(s *submodule.Submodule) { paths = append(paths, s.Path) })
want := []string{
"src/core", "src/ui", "src/net", "libs/store", "libs/log", "libs/log/modules/nested",
"libs/feature", "libs/conflict", "libs/staged", "libs/missing", "vendor", "libs/work",
"libs/feature", "libs/conflict", "libs/staged", "libs/missing", "vendor", "libs/work", "libs/topic",
}
if !slices.Equal(paths, want) {
t.Errorf("paths = %v\nwant %v", paths, want)
@@ -106,6 +106,7 @@ func TestStatus(t *testing.T) {
{path: "libs/log", ref: "origin/main", source: submodule.RefFromCurrentBranch},
{path: "libs/log/modules/nested", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromDefaultBranch, checkout: submodule.CheckoutDetached},
{path: "libs/feature", ref: "origin/feat/search", source: submodule.RefFromContains, behind: 1, checkout: submodule.CheckoutDetached},
{path: "libs/topic", ref: "origin/feat/search", source: submodule.RefFromContains, checkout: submodule.CheckoutDetached},
{path: "libs/conflict", issues: []submodule.IssueKind{submodule.IssueConflict}, ref: "origin/main", source: submodule.RefFromCurrentBranch, staged: true},
{path: "libs/staged", ref: "origin/main", source: submodule.RefFromCurrentBranch, staged: true},
{path: "libs/missing", issues: []submodule.IssueKind{submodule.IssueMissing}},
+1
View File
@@ -132,6 +132,7 @@ type FileChange struct {
Removed int // removed lines
Binary bool // line counts are unknown
Submodule bool // a nested submodule pointer changed
Repo bool // an untracked directory that is a git repository itself
Modified time.Time // modification time of the file; zero if it doesn't exist
}
+7
View File
@@ -26,6 +26,13 @@ type Watcher interface {
Watch(ctx context.Context) <-chan Change
}
// Locator is implemented by providers whose submodules live on the local
// disk. Dir returns the absolute directory of path ("" is the superproject),
// or "" if it is unknown.
type Locator interface {
Dir(path string) string
}
// Change reports that the git metadata of a repository changed.
type Change struct {
Path string // "" is the superproject
+37 -7
View File
@@ -55,21 +55,36 @@ func (s *Status) Issues() []Issue {
case WorktreeModified:
out = append(out, Issue{IssueChanged, LevelWarn, "uncommitted changes"})
case WorktreeUntracked:
out = append(out, Issue{IssueChanged, LevelWarn, "untracked files"})
out = append(out, Issue{IssueChanged, LevelWarn, s.untrackedText()})
}
return out
}
// HiddenIssues counts the issues of all loaded descendants and returns the
// most severe level among them.
func (m *Submodule) HiddenIssues() (count int, worst Level) {
// Hidden counts issues below a submodule per level.
type Hidden struct{ Warn, Danger int }
// Total is the number of issues of all levels.
func (h Hidden) Total() int { return h.Warn + h.Danger }
// HiddenIssues counts the issues of all loaded descendants per level. A
// descendant whose load failed counts as one danger, since its state is
// unknown.
func (m *Submodule) HiddenIssues() Hidden {
var h Hidden
Walk(m.Children, func(c *Submodule) {
if c.Status == nil && c.Err != nil {
h.Danger++
}
for _, is := range c.Status.Issues() {
count++
worst = max(worst, is.Level)
switch is.Level {
case LevelDanger:
h.Danger++
case LevelWarn:
h.Warn++
}
}
})
return count, worst
return h
}
// Walk calls fn for every submodule in depth-first order.
@@ -79,3 +94,18 @@ func Walk(mods []*Submodule, fn func(*Submodule)) {
Walk(m.Children, fn)
}
}
// untrackedText words the untracked-files issue: "untracked repository" if
// everything untracked is a git repository of its own, e.g. a clone that was
// never added as a submodule.
func (s *Status) untrackedText() string {
if s.Changes == nil || s.Changes.Total != len(s.Changes.Files) {
return "untracked files"
}
for _, f := range s.Changes.Files {
if f.Kind != ChangeUntracked || !f.Repo {
return "untracked files"
}
}
return "untracked repository"
}
+31 -3
View File
@@ -1,6 +1,7 @@
package submodule
import (
"errors"
"slices"
"testing"
)
@@ -45,8 +46,35 @@ func TestHiddenIssues(t *testing.T) {
loading := &Submodule{}
root := &Submodule{Status: ok, Children: []*Submodule{child, {Status: ok}, loading}}
count, worst := root.HiddenIssues()
if count != 2 || worst != LevelDanger {
t.Errorf("HiddenIssues() = %d, %v; want 2, LevelDanger", count, worst)
if got := root.HiddenIssues(); got != (Hidden{Warn: 1, Danger: 1}) {
t.Errorf("HiddenIssues() = %+v; want 1 warn, 1 danger", got)
}
}
func TestUntrackedRepositoryWording(t *testing.T) {
repo := FileChange{Path: "lib/x/", Kind: ChangeUntracked, Repo: true}
file := FileChange{Path: "a.txt", Kind: ChangeUntracked}
for _, c := range []struct {
files []FileChange
want string
}{
{[]FileChange{repo}, "untracked repository"},
{[]FileChange{repo, file}, "untracked files"},
{[]FileChange{file}, "untracked files"},
} {
s := &Status{Worktree: WorktreeUntracked, Changes: &Changes{Total: len(c.files), Files: c.files}}
got := s.Issues()
if len(got) == 0 || got[len(got)-1].Detail != c.want {
t.Errorf("%v: got %v, want %q", c.files, got, c.want)
}
}
}
func TestHiddenIssuesCountLoadErrors(t *testing.T) {
bad := &Submodule{Err: errors.New("boom")}
loading := &Submodule{}
parent := &Submodule{Children: []*Submodule{bad, loading}}
if got := parent.HiddenIssues(); got != (Hidden{Danger: 1}) {
t.Errorf("got %+v; want 1 danger", got)
}
}
+22 -1
View File
@@ -73,7 +73,7 @@ func New(ctx context.Context, p submodule.Provider, opts Options) Model {
loads: newReloader(),
ticking: true, // Init schedules the first tick
tree: tree.New(t),
detail: detail.New(t),
detail: detail.New(t, k.Submodule()),
footer: footer.New(t, k),
spinner: sp,
}
@@ -353,6 +353,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, m.startFetch()
case key.Matches(msg, k.Reload):
return m, m.reloadTree(true)
case key.Matches(msg, k.Copy):
return m.copyPath()
case key.Matches(msg, k.Update, k.Bump, k.Filter, k.Issues):
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
m.layout()
@@ -361,6 +363,24 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
// copyPath copies the absolute directory of the selected submodule to the
// clipboard, or its path in the superproject if the provider can't tell.
func (m Model) copyPath() (tea.Model, tea.Cmd) {
s := m.tree.Selected()
if s == nil {
return m, nil
}
path := s.Path
if l, ok := m.provider.(submodule.Locator); ok {
if dir := l.Dir(s.Path); dir != "" {
path = dir
}
}
cmd := tea.Batch(copyToClipboard(path), m.footer.Flash("copied "+path))
m.layout()
return m, cmd
}
// layout distributes the terminal size among the components.
func (m *Model) layout() {
m.footer.SetWidth(m.width)
@@ -373,6 +393,7 @@ func (m *Model) layout() {
treeWidth = m.width - detailWidth
m.detail.SetSize(detailWidth-2, bodyHeight) // border + padding
}
m.footer.SetActions(!m.showDetail || !m.detail.PaletteVisible())
m.tree.SetSize(treeWidth, bodyHeight)
}
+21
View File
@@ -0,0 +1,21 @@
package tui
import (
"os"
"github.com/aymanbagabas/go-osc52/v2"
tea "github.com/charmbracelet/bubbletea"
)
// copyToClipboard sets the system clipboard with the OSC 52 escape sequence,
// which works over SSH and in terminals that allow it; others ignore it.
func copyToClipboard(text string) tea.Cmd {
return func() tea.Msg {
seq := osc52.New(text)
if os.Getenv("TMUX") != "" {
seq = seq.Tmux()
}
_, _ = seq.WriteTo(os.Stderr)
return nil
}
}
+2
View File
@@ -163,6 +163,8 @@ func (m Model) fileLines(files []submodule.FileChange) []string {
switch {
case f.Submodule:
r.counts = muted.Render("submodule")
case f.Repo:
r.counts = muted.Render("repository")
case f.Binary:
r.counts = muted.Render("bin")
default:
+38 -6
View File
@@ -6,6 +6,8 @@ import (
"path"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
@@ -20,9 +22,20 @@ type Model struct {
width int
height int
spinner string
help help.Model
actions []key.Binding
}
func New(t theme.Theme) Model { return Model{theme: t} }
// paletteMinHeight is the pane height below which the palette is left out:
// the information is worth more than the hints.
const paletteMinHeight = 12
func New(t theme.Theme, actions []key.Binding) Model {
return Model{theme: t, help: theme.NewHelp(t), actions: actions}
}
// PaletteVisible reports whether the palette of submodule actions fits.
func (m Model) PaletteVisible() bool { return m.height >= paletteMinHeight }
func (m *Model) SetSize(w, h int) { m.width, m.height = w, h }
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
@@ -38,10 +51,21 @@ func (m Model) View(s *submodule.Submodule) string {
for i, l := range lines {
lines[i] = ansi.Truncate(l, m.width, "…")
}
if len(lines) > m.height {
lines = lines[:m.height]
height := m.height
var palette string
if m.PaletteVisible() {
height-- // the palette is the last line
m.help.Width = 0
palette = ansi.Truncate(m.help.ShortHelpView(m.actions), m.width, "…")
}
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(strings.Join(lines, "\n"))
if len(lines) > height {
lines = lines[:height]
}
body := lipgloss.NewStyle().Width(m.width).Height(height).Render(strings.Join(lines, "\n"))
if palette == "" {
return body
}
return body + "\n" + palette
}
func (m Model) lines(s *submodule.Submodule) []string {
@@ -138,8 +162,16 @@ func (m Model) lines(s *submodule.Submodule) []string {
out = append(out, "")
if n := len(s.Children); n > 0 {
children := fmt.Sprintf("%d direct", n)
if k, worst := s.HiddenIssues(); k > 0 {
children += t.LevelStyle(worst, lipgloss.NewStyle()).Render(fmt.Sprintf(" · %d issue(s) below", k))
h := s.HiddenIssues()
var parts []string
if h.Danger > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelDanger, lipgloss.NewStyle()).Render(fmt.Sprintf("%d error(s)", h.Danger)))
}
if h.Warn > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn)))
}
if len(parts) > 0 {
children += muted.Render(" · ") + strings.Join(parts, muted.Render(", ")) + muted.Render(" below")
}
out = append(out, label("Children")+children)
}
+46 -11
View File
@@ -2,11 +2,14 @@
package footer
import (
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"submarine/internal/tui/theme"
)
@@ -15,26 +18,34 @@ const flashDuration = 2 * time.Second
type clearFlashMsg struct{ id int }
// Keys provides the key groups. The short bar shows Navigation (and
// Submodule, if requested) on the left and Global on the right; full help
// shows FullHelp.
type Keys interface {
help.KeyMap
Navigation() []key.Binding
Submodule() []key.Binding
Global() []key.Binding
}
type Model struct {
theme theme.Theme
help help.Model
keys help.KeyMap
keys Keys
width int
flash string
flashID int
actions bool // show the submodule bindings in the short bar
}
func New(t theme.Theme, keys help.KeyMap) Model {
h := help.New()
h.Styles.ShortKey = theme.Fg(t.Text).Bold(true)
h.Styles.ShortDesc = theme.Fg(t.Muted)
h.Styles.ShortSeparator = theme.Fg(t.Border)
h.Styles.FullKey = h.Styles.ShortKey
h.Styles.FullDesc = h.Styles.ShortDesc
h.Styles.FullSeparator = h.Styles.ShortSeparator
return Model{theme: t, help: h, keys: keys}
func New(t theme.Theme, keys Keys) Model {
return Model{theme: t, help: theme.NewHelp(t), keys: keys}
}
// SetActions shows the submodule bindings in the short bar. Set it if the
// detail pane doesn't show them.
func (m *Model) SetActions(on bool) { m.actions = on }
func (m *Model) SetWidth(w int) {
m.width = w
m.help.Width = w - 2
@@ -62,5 +73,29 @@ func (m Model) View() string {
if m.flash != "" {
return style.Foreground(m.theme.Warn).Render(m.flash)
}
return style.Render(m.help.View(m.keys))
if m.help.ShowAll {
return style.Render(m.help.View(m.keys))
}
return style.Render(m.shortBar(m.width - 2))
}
// shortBar puts the global keys on the right and as many of the others on the
// left as fit; the submodule keys are dropped first.
func (m Model) shortBar(w int) string {
h := m.help
h.Width = 0
right := h.ShortHelpView(m.keys.Global())
left := m.keys.Navigation()
if m.actions {
left = append(left[:len(left):len(left)], m.keys.Submodule()...)
}
for ; ; left = left[:len(left)-1] {
l := h.ShortHelpView(left)
if gap := w - lipgloss.Width(l) - lipgloss.Width(right); gap >= 2 || len(left) == 0 {
if gap < 0 { // not even the global keys fit
return ansi.Truncate(right, w, "…")
}
return l + strings.Repeat(" ", gap) + right
}
}
}
+9 -2
View File
@@ -36,13 +36,17 @@ func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, widt
}
// count submodules per issue kind (most severe first), plus staged pins
var total, loading, staged int
var total, loading, errored, staged int
perKind := map[submodule.IssueKind]int{}
level := map[submodule.IssueKind]submodule.Level{}
submodule.Walk(repo.Submodules, func(s *submodule.Submodule) {
total++
if s.Status == nil {
loading++
if s.Err == nil {
loading++
} else {
errored++
}
return
}
if s.Status.PinStaged {
@@ -56,6 +60,9 @@ func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, widt
sep := seg(t.Muted, " · ")
mid := seg(t.Muted, fmt.Sprintf("%d submodules", total))
if errored > 0 {
mid += sep + t.LevelStyle(submodule.LevelDanger, bar).Render(fmt.Sprintf("%d error", errored))
}
for _, k := range []submodule.IssueKind{
submodule.IssueUnpushed, submodule.IssueConflict, submodule.IssueMissing,
submodule.IssueOffPin, submodule.IssueChanged,
+24 -7
View File
@@ -7,18 +7,18 @@ type keyMap struct {
CollapseAll, ExpandAll key.Binding
Home, End, PageUp, PageDown key.Binding
Update, Bump, Fetch, Reload, Filter, Issues key.Binding
Help, Quit key.Binding
Copy, Help, Quit key.Binding
}
func defaultKeys() keyMap {
return keyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/↓", "move")),
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/→", "collapse/expand")),
Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "collapse, parent")),
Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "expand, first child")),
Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")),
CollapseAll: key.NewBinding(key.WithKeys("ctrl+left"), key.WithHelp("ctrl+←", "collapse all")),
ExpandAll: key.NewBinding(key.WithKeys("ctrl+right"), key.WithHelp("ctrl+→", "expand all")),
CollapseAll: key.NewBinding(key.WithKeys("shift+left", "ctrl+left", "H"), key.WithHelp("H/⇧←", "collapse all")),
ExpandAll: key.NewBinding(key.WithKeys("shift+right", "ctrl+right", "L"), key.WithHelp("L/⇧→", "expand all")),
Home: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home/g", "first")),
End: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end/G", "last")),
PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")),
@@ -29,13 +29,29 @@ func defaultKeys() keyMap {
Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")),
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
}
}
// Navigation returns the bindings that move the selection or fold the tree.
func (k keyMap) Navigation() []key.Binding {
move := key.NewBinding(key.WithKeys("up"), key.WithHelp("↑↓/jk", "move"))
fold := key.NewBinding(key.WithKeys("left"), key.WithHelp("←→/hl", "fold"))
all := key.NewBinding(key.WithKeys("H"), key.WithHelp("⇧←→/HL", "fold all"))
return []key.Binding{move, fold, all, k.Toggle}
}
// Submodule returns the bindings that act on the selected submodule. They
// are shown in the detail pane, or in the footer if the pane can't show them.
func (k keyMap) Submodule() []key.Binding { return []key.Binding{k.Copy} }
// Global returns the bindings that don't depend on the selection.
func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} }
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.Left, k.Toggle, k.Update, k.Bump, k.Fetch, k.Help, k.Quit}
return append(append(k.Navigation(), k.Submodule()...), k.Global()...)
}
func (k keyMap) FullHelp() [][]key.Binding {
@@ -43,7 +59,8 @@ func (k keyMap) FullHelp() [][]key.Binding {
{k.Up, k.Down, k.Left, k.Right, k.Toggle},
{k.CollapseAll, k.ExpandAll},
{k.Home, k.End, k.PageUp, k.PageDown},
{k.Update, k.Bump, k.Fetch, k.Reload, k.Filter, k.Issues},
{k.Copy, k.Update, k.Bump},
{k.Fetch, k.Reload, k.Filter, k.Issues},
{k.Help, k.Quit},
}
}
+15
View File
@@ -0,0 +1,15 @@
package theme
import "github.com/charmbracelet/bubbles/help"
// NewHelp returns a help renderer in the theme's colors.
func NewHelp(t Theme) help.Model {
h := help.New()
h.Styles.ShortKey = Fg(t.Text).Bold(true)
h.Styles.ShortDesc = Fg(t.Muted)
h.Styles.ShortSeparator = Fg(t.Border)
h.Styles.FullKey = h.Styles.ShortKey
h.Styles.FullDesc = h.Styles.ShortDesc
h.Styles.FullSeparator = h.Styles.ShortSeparator
return h
}
+13 -5
View File
@@ -15,7 +15,7 @@ import (
)
const (
statusWidth = 14 // "unpushed +1 ·2"
statusWidth = 16 // "unpushed +1 ·2 ·3"
deltaWidth = 6
maxRefWidth = 22
)
@@ -414,11 +414,19 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
default:
status = seg(t.Muted, theme.InSync)
}
if k, worst := s.HiddenIssues(); collapsed && k > 0 {
if status != "" {
status += pad(1)
if h := s.HiddenIssues(); collapsed && h.Total() > 0 {
for _, c := range []struct {
level submodule.Level
n int
}{{submodule.LevelDanger, h.Danger}, {submodule.LevelWarn, h.Warn}} {
if c.n == 0 {
continue
}
if status != "" {
status += pad(1)
}
status += t.LevelStyle(c.level, base).Render(fmt.Sprintf("%s%d", t.Glyphs.Problems, c.n))
}
status += t.LevelStyle(worst, base).Render(fmt.Sprintf("%s%d", t.Glyphs.Problems, k))
}
status = ansi.Truncate(status, statusWidth, "")
status += pad(statusWidth - lipgloss.Width(status))
+10
View File
@@ -72,6 +72,8 @@ g "$w" push origin main
g "$root/work/log" submodule add "$root/remotes/nested.git" modules/nested
g "$root/work/log" commit -m "Add nested"
g "$root/work/log" push origin main
# "archive" ties with main (same tip, sorts first) -> the preferred ref wins
g "$root/work/log" push origin main:archive
super="$root/super"
git init -q "$super"
@@ -88,6 +90,7 @@ add ui libs/staged
add net libs/missing
add ui vendor
add work libs/work
add feature libs/topic -b main
g "$super" submodule update --init --recursive
g "$super" commit -m "Add submodules"
@@ -109,6 +112,13 @@ g "$super/libs/feature" checkout origin/feat/search~1
g "$super" add libs/feature
g "$super" commit -m "Pin feature search"
# libs/topic: .gitmodules says main, but the pin is the tip of feat/search
# -> the closest branch wins over the configured one
g "$super/libs/topic" fetch
g "$super/libs/topic" checkout origin/feat/search
g "$super" add libs/topic
g "$super" commit -m "Pin topic search"
# src/core: checkout moved away from the pin -> off pin
g "$super/src/core" checkout HEAD~1