7 Commits
Author SHA1 Message Date
simonfrankenandClaude Sonnet 5.5 e8d366d88f Compare pins with a target branch and flag pins off it
The ref of a pin was the closest branch containing it, so a pin on a
feature branch looked like a perfect one, and the ref changed with the
branch checked out in the submodule. Split it in two:

- Pin.Target is the branch the pin should be on: `branch =` of
  .gitmodules (none if it does not exist locally), else the remote's
  default branch.
- Pin.Ref is shown: the target if the pin is on it, else the branch
  containing the pin with the fewest commits after it. A tie goes to the
  upstream of the checked-out branch, then to the first name; no branch is
  a favorite.

A pushed pin that is not on its target is `off target`, a new issue at the
new level Info (blue), counted in the header and below collapsed rows. The
spine shows the target's tip, labelled with its branch name, so the
distance from it is visible; a diverged one is the fork. The role words in
a spine label are styled separately and `you` is bold.

Add Status.TargetTip, TargetDrift and Bases.Target, Pin.Target and
Pin.Branches; drop RefSource.IsTarget. The detail pane's label column is 11
wide for the word `off target`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 17:35:16 +02:00
simonfrankenandClaude Sonnet 5.5 f287d6009f 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>
2026-10-23 16:40:25 +02:00
simonfrankenandClaude Sonnet 5.5 4c1be3f9ab 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>
2026-10-23 16:34:14 +02:00
simonfrankenandClaude Opus 5.5 aeedfe96d4 Document restore and switch branch in the README, new screenshot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:55:43 +02:00
simonfrankenandClaude Sonnet 5.5 01a9babe64 Add a release skill
Picks the next semver from the commits since the last tag, runs a release
check, tags and pushes, hands over to the developer for make release, then
creates the Gitea release with MCP.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:38:14 +02:00
simonfrankenandClaude Sonnet 5.5 648e1fd46c Support the mouse in the branch popup
A click on a branch switches to it, the wheel moves the selection, a click
outside closes the popup. The popup is hit-tested by its own position, as it
is drawn after zone.Scan.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:38:14 +02:00
simonfrankenandClaude Opus 5.5 2b6b781826 Add switch branch (b): stage the tip and restore to it
b opens a centered popup with a search line and the local and remote
branches of the selection. Enter stages the branch tip as the pin of a
submodule, restores it (and everything below) to it, and puts HEAD on the
branch; a remote branch gets a local tracking branch unless one exists
elsewhere. Works on the superproject too. Losses are asked like restore.
The backdrop behind the popup is dimmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:18:49 +02:00
40 changed files with 3196 additions and 429 deletions
+158
View File
@@ -0,0 +1,158 @@
---
name: release
description: Prepare and publish a submarine release. Finds the latest release, picks the next semver from the changes since, runs a release check, creates and pushes the git tag, hands over to the developer for `make release`, then creates the Gitea release with MCP. Use only when the user asks to release.
disable-model-invocation: true
---
# Release submarine
A release is built on the developer's machine (`make release`, see
`docs/architecture.md#distribution`). `make release` publishes the binaries
of the **clean `vX.Y.Z` tag at HEAD** to the Gitea package registry. This
skill does everything around it. The developer runs `make release`
themselves, in the middle: you never run it and never read or print `.env`
or `GITEA_TOKEN`.
Pushing a tag and creating a release are outward-facing and hard to undo.
Do them only after the confirmation in step 5, and never force-push, move or
delete a tag without asking.
Gitea: owner `simonfranken`, repo `submarine`, `https://git.simon-franken.de`.
## 1. Preconditions
Stop and tell the user, if any of these fails:
```sh
git fetch origin --tags
git branch --show-current # main
git status --porcelain # empty: make release refuses a dirty tree
git rev-parse HEAD origin/main # equal: HEAD is pushed, nothing to release from a local-only commit
git tag --points-at HEAD # empty: HEAD is not released yet
grep -q '^GITEA_TOKEN=.\+' .env # exists: only test it, never print it
```
## 2. Latest release
```sh
git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -1
```
Cross-check with the `mcp__gitea__get_latest_release` MCP tool (load it with
ToolSearch first). If the tag and the Gitea release differ (a tag without a
release is normal for the one in progress, a release without a tag is not),
tell the user and ask before going on. With no tag at all, the first release
is `v0.1.0`.
## 3. Changes since
```sh
git log --format='%h %s%n%b---' <last>..HEAD
git diff --stat <last>..HEAD
```
No commits since the last tag: stop, there is nothing to release. Read the
commits, and where a subject is not clear, the diff. Sort each change as:
- **breaking**: removes or changes a key, a command or flag, the on-disk or
registry contract (`install.sh`, asset names, `submarine-latest/latest/VERSION`,
`submarine update`), or behavior users rely on;
- **feature**: a new key, action, view or option, or a visible behavior;
- **fix / other**: bug fixes, docs, refactoring, tests, performance.
## 4. Next version
| Changes include | Version `0.y.z` (current) | Version `≥ 1.0.0` |
|---|---|---|
| breaking | minor (`0.y+1.0`) | major |
| feature | minor (`0.y+1.0`) | minor |
| only fix / other | patch (`0.y.z+1`) | patch |
submarine is below 1.0: the first release at `1.0.0` is a decision for the
user, never made by this skill. Prepare a one-line reason per change and the
proposed version.
## 5. Release check (before anything is tagged)
All must pass; stop at the first failure and report its output. Nothing has
been pushed yet, so there is nothing to undo.
```sh
go vet ./...
go test ./...
test -z "$(gofmt -l .)"
make dist VERSION=vX.Y.Z # all four platforms cross-compile
./dist/submarine_linux_amd64 version # prints "submarine vX.Y.Z" (run the one for this machine)
make clean
```
Also check by reading: `README.md` and `docs/` mention the new behavior of
the features in the changelog (AGENTS.md: keep docs in sync). A missing doc
is a finding to report, not a reason to edit during a release; ask the user.
`make dist` and `make clean` only touch the git-ignored `dist/`.
## 6. Confirm, then tag and push
Ask the user (AskUserQuestion) to confirm the version, with the proposal
first and marked `(Recommended)`, the other bumps as alternatives. Show: the
last version, the changes with their category, the check result, and that
the next step creates and pushes the tag. Only after the answer:
```sh
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z
git ls-remote --tags origin vX.Y.Z # the tag is on the remote
```
If the push fails, report git's message. Do not retry with force. If the tag
is wrong after it was pushed, ask before deleting it.
## 7. Hand over: the developer runs `make release`
HEAD is now the clean tag, which is what `make release` needs. Tell the
developer to run, in the repository:
```sh
make release
```
(it reads `GITEA_TOKEN` from `.env`, tests, builds, uploads, and points
`submarine-latest` at the version last). **Stop here and wait** until they
say it finished. If it failed, help with the output; do not run it yourself.
## 8. Verify the upload, create the Gitea release
Once the developer reports success:
```sh
curl -fsS https://git.simon-franken.de/api/packages/simonfranken/generic/submarine-latest/latest/VERSION
```
must print `vX.Y.Z` (the registry is public; `install.sh` reads it the same
way). If it doesn't, the upload did not complete: do not create the release,
report it.
Then create the release with `mcp__gitea__create_release` (load it with
ToolSearch first):
- `owner`: `simonfranken`, `repo`: `submarine`
- `tag_name`: `vX.Y.Z`
- `target`: the tag's commit, `git rev-parse vX.Y.Z^{commit}`
- `title`: `vX.Y.Z`
- `is_draft`: false, `is_pre_release`: false
- `body`: release notes, see below
Release notes are for users of the tool, not a commit dump: short, grouped
under **Breaking changes** (only if any), **New**, **Fixes** (leave out empty
groups and pure refactoring/tests), each item one line naming the key or
behavior (`b` switches the branch of a repository...). End with:
```
Install or update: `curl -fsSL https://git.simon-franken.de/simonfranken/submarine/raw/branch/main/install.sh | sh`, or `submarine update`.
Full changelog: https://git.simon-franken.de/simonfranken/submarine/compare/<last>...vX.Y.Z
```
The binaries live in the package registry, not on the release: attach
nothing. Finish with the link to the release and the version.
+8 -1
View File
@@ -22,12 +22,19 @@ go run ./cmd/submarine [dir] # run on a repository (default: cwd)
go run ./cmd/submarine --mock # run on mock data
scripts/make-fixture.sh DIR # build a test superproject in DIR
make dist # cross-compile into dist/
GITEA_TOKEN=… make release # publish a tagged release (outward-facing)
GITEA_TOKEN=… make release # publish a tagged release (outward-facing; run by the developer)
go vet ./...
go test ./...
gofmt -l . # must print nothing
```
## Releasing
Use the `release` skill (`/release`, `.claude/skills/release/SKILL.md`): it
picks the next semver, runs a check, tags and pushes, asks the developer to
run `make release`, then creates the Gitea release. Never run `make release`
or push tags outside of it.
## Rules
- **Keep docs in sync.** A change to behavior, keys, colors, wording or
+59 -14
View File
@@ -23,10 +23,14 @@ the questions you actually have:
issue word). A collapsed parent counts the problems hidden below it
(`·1`) and shows `●` if a staged pin is hidden.
- **Words, not symbols.** Every state is a short word (`unpushed`, `conflict`,
`missing`, `off pin`, `changed`), so nothing depends on color alone.
`missing`, `off pin`, `changed`, `off target`), so nothing depends on color
alone.
- **Live.** Follows changes you make in other shells within about two
seconds. It never fetches on its own: the header shows when you last did,
`f` fetches everything.
seconds. It never fetches on its own: the detail pane shows when a
repository was last fetched, `f` fetches everything.
- **Fix it from the same screen.** Restore a repository to exactly what git
records (`x`), or switch it to another branch (`b`). Both ask first if work
would be lost.
- **Built for dozens to hundreds of submodules.** Each status loads
asynchronously, so the UI never freezes.
@@ -55,9 +59,10 @@ sub --mock # built-in sample data, no repository needed
### Find what needs attention
Problems stand out: `unpushed` in red, `off pin` and `changed` in yellow,
everything else muted. Select a submodule to see the details on the right:
every issue in words, the resolved ref and why, the pinned, checked-out and
tip commits, and a preview of uncommitted changes.
`off target` (the pin is not on the branch it should be, usually `main`) in
blue, everything else muted. Select a submodule to see the details on the right:
every issue in words, the resolved ref and why, where the pin, the tip and
your checkout are in the history, and a preview of uncommitted changes.
```
src/ │ src/
@@ -65,11 +70,10 @@ tip commits, and a preview of uncommitted changes.
▸ net [2] unpushed origin/main ↑1│
ui in sync origin/main │ unpushed pin not pushed to any remote
│
│ Ref origin/main ↑1
│ via checked out branch
│ Pinned b7c8d9e Hotfix null pointer
│ Checkout b7c8d9e Hotfix null pointer
│ Tip 6f5e4d3 Release 2.1.0
│ Ref origin/main · via checked out branch
│ pin · you ● b7c8d9e Hotfix null pointer
│ ┊ 1 commit
│ tip ● 6f5e4d3 Release 2.1.0
```
### Jump to a submodule with `/`
@@ -94,6 +98,44 @@ Select a submodule and press `c`. Its absolute directory lands in your
clipboard, ready for `cd <paste>`. It works over SSH too (OSC 52; in tmux,
`set -g set-clipboard on`).
### Restore with `x`
Select a submodule (or the header for the whole superproject) and press `x`.
Its worktree becomes exactly what git records, and so do all submodules
below it: changes are reset, untracked files removed, merges and rebases
ended, missing submodules cloned. Staged pins are kept. If something would be
lost, you are asked first:
```
Restore src/app? Discards 7 changed files in 3 repositories. y restore · n cancel
```
`y` restores, any other key cancels. Without a loss it runs at once. For a
submodule with a staged pin, `x` first asks whether to restore to the staged
pin (`s`) or the committed one (`c`, which unstages it).
### Switch a branch with `b`
Press `b` on a submodule (or the header for the superproject). A popup lists
the local and remote branches, newest first; type to filter.
```
╭──────────────────────────────────────────────────────────╮
│ Switch libs/feature │
│ / sea │
│ ──────────────────────────────────────────────────────── │
│ origin/feat/search 58a2bf1 2h ago │
│ ↑↓ select · enter switch · esc cancel │
╰──────────────────────────────────────────────────────────╯
```
`↑` `↓` select, `enter` switches, `esc` cancels. The tip of the branch
becomes the submodule's staged pin and it is restored to it, with HEAD on the
branch. Picking `origin/feat` creates a local `feat` that tracks it; an
existing local branch of that name is never moved. Like restore, it asks
first if work would be lost. The list is read from disk, so press `f` first
to see new remote branches.
### Fetch and reload
`f` fetches all repositories again, `r` reloads everything after you edited
@@ -111,17 +153,20 @@ files.
| `/` | Filter |
| `!` | Problems only |
| `c` | Copy path |
| `b` | Switch branch: pick a local or remote branch; its tip is staged and the repository restored to it |
| `x` | Restore: make the worktree exactly what git records (asks if work would be lost) |
| `f` / `r` | Fetch / reload |
| `?` | Full help |
| `q` | Quit |
The mouse works too: click to select, click `▸` to toggle, wheel to move.
The mouse works too: click to select, click `▸` to toggle, wheel to move. In
the branch popup, click a branch to switch to it, use the wheel to move, and
click outside to close it.
## Status
A prototype. Browsing is complete, and `x` restores a submodule or the whole
superproject. More write actions (stage pin) are being specified in
A prototype. Browsing is complete. Two write actions exist: restore (`x`)
and switch branch (`b`). More (stage pin) are being specified in
[docs/actions.md](docs/actions.md).
## Documentation
+4 -4
View File
@@ -14,7 +14,7 @@ bare commit hash.
| [state-model.md](state-model.md) | How a submodule's state is determined, summarized into issues, and how pins are resolved to refs |
| [ux-guide.md](ux-guide.md) | Layout, attention hierarchy, colors, wording, navigation and key bindings |
| [architecture.md](architecture.md) | Package layout, the provider boundary, async loading, testing |
| [actions.md](actions.md) | **Draft:** the write actions restore and stage pin |
| [actions.md](actions.md) | the write actions restore and switch branch (done) and stage pin (draft) |
| [decisions.md](decisions.md) | Decision log with rationale (ADR style) |
## Install
@@ -33,10 +33,10 @@ Installs `submarine` and the alias `sub` (linux/macOS, amd64/arm64) to
through a git provider (`internal/submodule/git`): `submarine [dir]`. It
fetches only when asked (`f`) and follows changes made in other shells. `submarine --mock` shows
the built-in mock data (`internal/submodule/mock`). `!` shows only
submodules with problems. `x` restores the selection
([actions.md](actions.md)); stage pin is drafted there.
submodules with problems. `x` restores the selection and `b` switches its
branch ([actions.md](actions.md)); stage pin is drafted there.
Next steps, roughly in order:
1. Stage pin, see [actions.md](actions.md). (Restore is done.)
1. Stage pin, see [actions.md](actions.md). (Restore and switch branch are done.)
2. (done) "Problems only" (`!`) view, reusing the tree's filter.
+82 -5
View File
@@ -1,7 +1,7 @@
# Actions (draft)
**Status:** [Restore](#restore) is implemented (key `x`, decisions in
[D23](decisions.md)). [Stage pin](#stage-pin) is still a draft; its open
**Status:** [Restore](#restore) (key `x`, [D23](decisions.md)) and
[Switch branch](#switch-branch) (key `b`, [D24](decisions.md)) are implemented. [Stage pin](#stage-pin) is still a draft; its open
questions are [at the end](#open-questions).
## Principle
@@ -14,10 +14,11 @@ a difference in one of two directions:
|---|---|---|
| [Restore](#restore) | The worktree follows the recorded state | `x` |
| [Stage pin](#stage-pin) | The recorded state follows the worktree | `s` |
| [Switch branch](#switch-branch) | Both: the branch tip is recorded, then restored to | `b` |
There is nothing else for now. Committing, pushing, switching branches and
bumping a pin to the tip are done outside submarine. A bump is "check out
the tip in the submodule, then `s`".
There is nothing else for now. Committing, pushing and bumping a pin to the
tip are done outside submarine. A bump is "check out the tip in the
submodule, then `s`" (or switch to the branch with `b`).
## Restore
@@ -196,6 +197,74 @@ target. A directory in the untracked list is searched for `.git`
*directories* (a standalone repository); an absorbed submodule leaves only a
`.git` file and loses just its worktree.
## Switch branch
Moves the selected repository to another branch, in one step: the tip of the
branch becomes the pin, and the repository is restored to it.
### The popup
`b` opens a popup in the middle of the screen: a search line on top, below it
the branches of the selection, local ones first, then remote ones
(`origin/…`), each group with the newest tip first. Each row shows the name
(`*` marks the checked-out branch), the short SHA and the age of the tip.
The list is read from the disk; fetch (`f`) is what brings new remote
branches.
| Key | Effect |
|---|---|
| text | Filters: every word must be in the name, case-insensitive. The selection stays on its branch if it still matches |
| `↑` `↓`, `pgup` `pgdn` | Select |
| `enter` | Switch to the selected branch |
| `esc` | Close |
Mouse: a click on a branch switches to it (like `enter`), the wheel moves the
selection, a click outside the popup closes it; a click on the title, the
search line or an empty row does nothing. Everything else is ignored while the
popup is open. A header selection switches the superproject.
### Effect
For a **submodule**, with the tip `T` the list showed:
1. The parent's index records `T` as the pin (a staged pin; if `T` is the
committed pin, the index equals `HEAD` again).
2. The submodule, and everything below it, is [restored](#restore) to `T`.
3. HEAD is put on the branch (the branch name is the local one):
- a local branch: checked out;
- a remote branch `origin/feat`: the local branch `feat` is created,
tracking it; if `feat` exists at `T` it is checked out; if it exists
**elsewhere** it is not moved and HEAD stays detached at `T`.
For the **superproject** step 1 is left out: the branch is checked out via the
restore, so its submodules follow the pins of the new commit (staged pins of
the old state are lost, and counted).
Staging comes first, so a failed restore can be repeated with `x`. If the
branch moved since the list was read (a fetch), the switch aborts with
`origin/feat moved; open the list again`; the staged SHA is always the one
shown.
### Confirmation, network, errors
As for restore: the loss is read from the disk and the question
(`Switch src/app to origin/feat? Discards … y switch · n cancel`) appears
only if something would be lost. A submodule that is not initialized can't be
switched (`src/app is not initialized; restore it first`). Network: only what
is missing below, like restore. Lines above the footer: `checking …`,
`switching src/app to origin/feat…`; flash `switched src/app to feat` (or
`… to origin/feat (detached: local feat is elsewhere)`); failure
`switch of src/app to origin/feat failed: …`, until the next key. One
restore or switch runs at a time.
### Implementation note
`git.Provider.Switch` (`internal/submodule/git/switch.go`): verify the ref is
still `T`; `update-index --cacheinfo` in the parent; `restoreRepo` (see
restore); then `switch` / `switch -c <local> --track <remote-branch>`.
`SwitchLoss` is `lossOf` with `T` as the target. `Branches` is one
`for-each-ref`, without the `origin/HEAD` symref.
## Stage pin
Records the commit checked out in a submodule as its new pin in the
@@ -254,6 +323,7 @@ offered: it is idempotent, and the palette is static.
| Key | Action | Pressed when not possible (flash) |
|---|---|---|
| `x` restore | Restore the selection | `this data source can't restore` (mock); `wait for the restore of … to finish` |
| `b` switch branch | [Switch branch](#switch-branch) | `this data source can't switch branches`; `wait for the restore of … to finish`; `src/app is not initialized; restore it first` |
| `s` stage pin | [Stage pin](#stage-pin), not implemented | `src/app is on its pin` / `src/app has uncommitted changes; commit or restore them first` / `the superproject has no pin` |
`R` would sit next to `r` (reload), a dangerous mix-up for a destructive
@@ -272,6 +342,13 @@ action, hence `x`.
detached commits (not those on a branch) and standalone repositories.
- UI (`internal/tui/restore_test.go`): restores at once without a loss, asks
with one (`y` restores, anything else cancels), the mock can't restore.
- Switch: provider (`switch_test.go`): list without `origin/HEAD`, local
first; a remote pick stages the tip and creates a tracking branch; an
existing local branch elsewhere is not moved; a moved ref aborts; the
superproject switch ends exact; the loss is the restore's. Picker
(`branches_test.go`): filter, selection, scrolling. UI (`switch_test.go`):
opens, filters, enter switches, asks with a loss, esc closes, the mock
can't.
- By hand in tmux on a fixture, see
[architecture.md](architecture.md#testing).
+21 -2
View File
@@ -12,25 +12,30 @@ cmd/submarine/main.go wiring: choose provider (git or --mock), start the
internal/submodule/ domain model; NO UI imports
model.go Repo, Submodule, Status, PinInfo, Commit, enums
status.go Issues(), HiddenIssues(), Walk()
provider.go Provider interface
provider.go Provider interface; optional Fetcher, Watcher, Locator, Restorer, Switcher
internal/submodule/git/ provider using the git CLI (reads, fetch, watch)
exec.go git runner (environment, errors)
parse.go pure parsers for git output (status, numstat, …)
provider.go Tree, Status, pin → ref resolution
fetch.go Fetch (network), Fetchable
watch.go Watch: polls git metadata for changes
restore.go Restore, RestoreLoss (network: only what is missing)
switch.go Branches, SwitchLoss, Switch (stage the tip, restore, attach the branch)
gitdir.go resolves .git dirs and gitdir: files
internal/submodule/mock/ fixture provider with artificial latency
internal/update/ self-update from the package registry; NO UI imports
internal/tui/ Bubble Tea application
app.go root model: layout, message routing, loading
keys.go key bindings (also feed the help view)
restore.go, switch.go restore / switch flows; a switch reuses restoreState
overlay.go draws the picker over the finished view at the position the picker reports
msgs.go tree/status/fetch/change messages
refresh.go reloader: one load per key, coalesced reruns
theme/ colors, glyphs, issue words, change letters, level styles
tree/ tree component: rows, navigation, mouse, view
detail/ detail pane; changes.go: change preview
header/ header line
branches/ branch picker popup (search line + list)
footer/ key bar (navigation left, global right) and flash messages
```
@@ -71,6 +76,14 @@ type Restorer interface {
}
```
```go
type Switcher interface {
Branches(ctx context.Context, path string) ([]Branch, error) // local + remote, no network
SwitchLoss(ctx context.Context, path string, b Branch) (Loss, error) // as RestoreLoss
Switch(ctx context.Context, path string, b Branch) (string, error) // branch HEAD ends on, "" if detached
}
```
```go
type Locator interface {
Dir(path string) string // absolute directory, "" if unknown
@@ -101,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
@@ -194,6 +208,11 @@ No CI runner: releases are built and published from a developer machine.
- `submarine/<version>/submarine_<os>_<arch>` and `checksums.txt`
- `submarine-latest/latest/VERSION`, replaced **last**, so a failed upload
never advertises a broken version.
- The `release` skill (`.claude/skills/release/SKILL.md`) drives a release:
latest tag → next semver from the commits since → check (`vet`, `test`,
`gofmt`, `make dist`) → tag and push → the developer runs `make release` →
the skill verifies the registry's `VERSION` and creates the Gitea release
with MCP (notes only; the binaries stay in the registry).
- `install.sh` (`curl -fsSL …/raw/branch/main/install.sh | sh`) detects
OS/arch, verifies the sha256, installs `submarine` and a `sub` symlink to
`~/.local/bin` (`SUBMARINE_INSTALL_DIR`, `SUBMARINE_VERSION`,
+136 -4
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
@@ -220,7 +220,7 @@ shown in a palette in the detail pane, next to what they act on; global 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
## Pin resolves to the closest branch (superseded by D26)
A pin on a pushed feature branch showed as diverged from `origin/main`,
because `branch = main` in `.gitmodules` won before any distance was looked
@@ -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
@@ -349,3 +349,135 @@ the same for every submodule of the target tree, initializing missing ones.
does not implement it. Restore is idempotent and can be repeated after a
failure. It takes the time of its slowest clone; there is no per-repository
progress yet.
## D24 — Switch branch: stage the tip, then restore
**Context.** Switching branches was left to the shell. Users of a repository
with many submodules want to move one to another branch and have the
superproject record it.
**Decision.** `b` opens a popup (search line, local then remote branches).
Enter on a branch of a submodule stages its tip as the pin in the parent's
index and restores the submodule to it (D23, recursion included); then HEAD
is put on the branch. On the superproject it checks the branch out and
restores below, without staging.
- *Key `b`*, not `s`, which stays reserved for stage pin.
- *Remote branch.* A local branch of that name is created, tracking it. If
one exists at another commit it is **not moved**: HEAD stays detached at
the tip and the flash says so (restore never moves branches, D23).
- *Same loss rules as restore.* The loss is read from the disk; the question
appears only if something is lost. Staged first, so a failed restore can be
repeated with `x`. The pin question of restore (`s` / `c`) is skipped: the
staged pin is being replaced.
- *What the picker showed is what is staged.* The branch carries its SHA; a
fetch may move the ref meanwhile, then the switch aborts
(`origin/feat moved; open the list again`).
- *Network.* Extends D23: a tip often pins nested commits that are not on
disk, so switch may clone and fetch what is missing, like restore. The
list itself uses none; `f` refreshes remote branches.
- *Popup.* The first overlay: drawn over the finished view (after
`zone.Scan`, so no zone marker is cut); the keys go to the picker while it
is open, and so does the mouse: the popup remembers where it was drawn and
tests clicks itself (bubblezone can't see it, it is drawn after the scan).
A click on a branch switches, like `enter`; a click outside closes. The backdrop is dimmed: stripped of its styles and drawn in
`Backdrop`, an adaptive 256-color grey (238 dark / 252 light). `Muted`
(ANSI 8) was too bright to set the popup apart, and ANSI faint (SGR 2) is
ignored by some terminals. Like `Bar` and `Selected` it carries no meaning,
so it may leave the 16 ANSI colors.
**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.
**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.
## D26 — The ref is the target branch; a pin off it is `off target`
**Context.** The main use is checking that a superproject points at the right
commits of its submodules, ideally the tips of `main`. D1's "closest branch"
(see "Pin resolves to the closest branch") answered *where the pin is*, not
*where it should be*: a pin on a feature branch showed that branch, `in sync`
and quiet, because `unpushed` (D6) is satisfied by any remote branch. The
closest branch also changed with the branch checked out in the submodule, so
two machines could show different refs for the same superproject commit.
**Decision.** Supersedes "Pin resolves to the closest branch". The ref is the
target: the `branch =` of `.gitmodules` (no fallback to the default branch if
it does not exist locally), else the remote's default branch (`HEAD`, `main`,
`master`). Without a target the checked-out branch's upstream, a tag or the
SHA are shown for orientation, but cannot make a pin off target. A pushed pin
with commits its target lacks (`Ahead > 0`) is `off target`: a new issue with
the new level `LevelInfo` (blue), listed last. The other branches that contain
the pin are named in its text.
**Alternatives.** Severity by the superproject's branch (strict on `main`,
quiet elsewhere): rejected, the severity should not depend on the branch.
Matching the superproject's branch name in submodules: rejected, feature
branches need not be named alike. Warn level: not now; behind the target is
quiet and `off target` is information, since a feature branch of the
superproject legitimately pins feature commits.
**Consequences.** `RefFromContains` and the 16-branch scan are gone;
`Pin.Branches` replaces them. `off target` is counted in the header and below
collapsed rows (blue `·k`) and is part of "problems only". The issue word
needs 10 columns, so the detail pane's label column is 11 wide.
**Amendment (with D26): target and ref are separate.** Checking a real
repository, a stale `branch = marco-dev` in `.gitmodules` made every pin
"↑52↓12 from marco-dev" although the pin was on `main`, one commit behind. The
target (`PinInfo.Target`) now only decides `off target`; the ref shown is the
target if the pin is on it, else the branch containing the pin that is closest
to it, with its real distance. The closest branch is the one with the fewest
commits after the pin; ties go to the upstream of the branch the submodule is
on, then alphabetically. The default branch is no favorite: if
`.gitmodules` targets another branch, `main` is just another branch. The
tie-break must be fixed, or the same superproject commit would show
different branches on different machines. The text of the issue names both
(`pin not on origin/marco-dev, on origin/fix/x +1`), so a stale declaration
is visible. `RefFromContains` is back; `RefSource.IsTarget` is gone.
**Amendment (with D26): the target on the spine.** An `off target` row only
said that the pin is elsewhere, not how far from the target. The target's tip
is now a node (`target`) next to the pin, the tip of the shown ref and the
checkout, so a feature pin reads as a distance from main. The shown ref
contains the pin, so only the target can fork from it, and the fork code
needs no new case. Cost: `log -1`, one `rev-list --left-right --count` and,
if diverged, one `merge-base` per off-target submodule.
**Amendment (with D26): labels.** The target's tip is labelled with its short
branch name (`marco-dev`) instead of the word `target`: it is usually short,
and the name is what people know. Long names are cut at 16 columns, so the
label column cannot grow without bound. In a label every role word has its
own style, and `you` is bold in the default color: it was lost between the
muted words, and it is the one the user looks for.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 420 KiB

+30 -12
View File
@@ -48,8 +48,14 @@ commit hashes. It doesn't answer the questions users actually have:
muted `in sync`, or a blue `staged` if their pin change is staged. With
issues, a staged pin adds a blue `●` after the word.
See [state-model.md](state-model.md) for the full rules.
- **F11** Each submodule shows its pin as a ref (`origin/main`, a tag, or a
short SHA as a fallback) and the distance to that ref (`↑n`, `↓n`, `↑n↓m`).
- **F11** Each submodule shows its pin as a ref and the distance to it
(`↑n`, `↓n`, `↑n↓m`). The ref is the **target** branch (`branch =` of
`.gitmodules`, else the remote's default branch) if the pin is on it, else
the branch the pin is on that it is closest to; only without any a tag or
a short SHA.
- **F11a** A pin that is pushed but not on its target branch (for example
only on a feature branch) is `off target`, an information (blue) naming
the branch it is on. It does not depend on the superproject's branch.
- **F12** A pin that is not reachable from any remote ref ("unpushed") is
the most prominent state.
- **F13** States load asynchronously per submodule; a submodule shows a
@@ -59,11 +65,16 @@ 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
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).
- **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 +89,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 +111,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`)
@@ -137,6 +148,13 @@ commit hashes. It doesn't answer the questions users actually have:
staged or the committed pin (the latter unstages it). It asks for
confirmation only if something would be lost. See [actions.md](actions.md#restore).
- **F57** `b` opens a popup in the middle of the screen with a search line
and the local and remote branches of the selection (a submodule, or the
superproject). `↑` / `↓` select, `enter` switches, `esc` cancels. For a
submodule the branch tip becomes its staged pin and it is restored to it,
with everything below; HEAD ends on the branch. It asks for confirmation
only if something would be lost. See [actions.md](actions.md#switch-branch).
Stage pin is drafted in [actions.md](actions.md#stage-pin).
## Usage
@@ -161,7 +179,7 @@ submarine --mock # built-in mock data
- **N7** The UI is independent of the data source: the mock and the git
provider are interchangeable without UI changes.
- **N8** The git provider only uses the network in `Fetch` and, for objects
that are missing, in `Restore`. It never prompts (no password, passphrase
that are missing, in `Restore` and `Switch`. It never prompts (no password, passphrase
or host-key questions; those fail with git's message). It uses the `git`
command line, so the user's git configuration applies. Apart from fetching
and the actions of [actions.md](actions.md), it is read-only: it writes
+88 -40
View File
@@ -11,12 +11,13 @@ them.
|---|---|
| **Pinned** | The gitlink recorded in the superproject index (differs from HEAD → `PinStaged`) |
| **Checked out** | The submodule's `HEAD` |
| **Tip** | The tip of the ref the pin was resolved to, e.g. `origin/main` |
| **Tip** | The tip of the ref the pin was resolved to, normally the target branch, e.g. `origin/main` |
This gives two independent questions:
- **Is the checkout on the pin?** → `off pin` if not.
- **Where is the pin relative to its ref?** → `↑n` / `↓n` / `↑n↓m`.
- **Where is the pin relative to its ref?** → `↑n` / `↓n` / `↑n↓m`. A pin
that is not on its target branch (but on another) is `off target`.
## Raw status dimensions
@@ -31,7 +32,10 @@ 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 |
| `TargetTip`, `TargetDrift` | If the pin is not on its target (`off target`): the target's tip and how it compares with the pin, like `Drift` (`Ahead` = commits only the target has); nil/zero otherwise |
| `Bases` | `Tip`, `Checkout`, `Committed`, `Target`: `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 |
@@ -73,14 +77,19 @@ truth for what counts as a problem.
| `missing` | Not initialized; directory missing | Warn |
| `off pin` | Checkout differs from pin | Warn |
| `changed` | Uncommitted changes; untracked files (worded "untracked repository" if all of them are git repositories) | Warn |
| `off target` | Pin is pushed, but not on the target branch (`Pin.Target` is set and differs from `Pin.Ref`, see [Resolving a pin to a ref](#resolving-a-pin-to-a-ref)); the text names the target and the branch the pin is on (`pin not on origin/marco-dev, on origin/main +1`, `+n` for further branches). Not for the superproject | Info |
Rules:
- If the submodule is **missing**, that is its only issue (nothing else can
be determined).
- Issues are returned **most severe first**: unpushed, conflict, off pin,
changed.
- **Not issues** (quiet): in sync, pin behind/ahead/diverged but pushed,
changed, off target.
- An unpushed pin is not also `off target`: it has the worse issue.
- `off target` does not depend on the superproject's branch: a feature
branch of the superproject that pins feature-branch commits shows it too.
It is information, not a warning (D26).
- **Not issues** (quiet): in sync, pin behind its target (or another ref),
staged pin, detached HEAD on the pin. They are shown as information only.
A staged pin is also visible in the tree (`staged` or a `●` marker), see
[ux-guide.md](ux-guide.md#tree-row), but is never counted as an issue.
@@ -92,14 +101,15 @@ Rules:
| Level | Meaning | Presentation |
|---|---|---|
| `LevelQuiet` | Nothing to do | Whole row muted, no word |
| `LevelInfo` | Good to know | Blue word |
| `LevelWarn` | Look at it | Yellow word |
| `LevelDanger` | Act now | Red bold word |
### Hidden issues
`Submodule.HiddenIssues()` counts the issues of **all** loaded descendants
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
per level (`Hidden{Danger, Warn, Info}`). A collapsed submodule shows them as `·k`
per level, red first, then yellow, then blue, 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.
@@ -113,8 +123,9 @@ is never `CheckoutDiffers` (`CheckoutDetached` for a detached HEAD),
"HEAD not pushed to any remote" (Danger, like a submodule's pin).
- **Ref** is the upstream of the current branch (`RefFromCurrentBranch`), so
ahead/behind agrees with the header. Closest-branch resolution is only the
fallback without an upstream (detached, or no upstream configured).
ahead/behind agrees with the header. The target (below) is only the
fallback without an upstream (detached, or no upstream configured). The
superproject has no `off target` issue.
- **Changes** ignore submodules (`status --ignore-submodules=all`): an off-pin
or staged submodule is shown by its own row, not twice. A leftover
directory of a submodule the branch no longer has is an untracked
@@ -123,38 +134,43 @@ is never `CheckoutDiffers` (`CheckoutDetached` for a detached HEAD),
## 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. The git provider shows the
**closest** branch and records which rule proposed it in `PinInfo.Source`.
Two refs are involved (D26):
Preferred refs, in this order (each only if it exists):
- The **target** is the branch the pin is supposed to be on, the same for
everyone: the `branch =` of `.gitmodules` (`.` is the superproject's
current branch), else the remote's default branch (`<remote>/HEAD`, else
`<remote>/main`, else `<remote>/master`). A declared branch that does not
exist locally gives **no** target (the default branch would be a wrong
answer). The remote is `origin`, else the first remote. `PinInfo.Target`.
- The **ref** is what is displayed, and what `Tip`, `Ahead` and `Behind`
refer to. `PinInfo.Ref`, with the rule that found it in `PinInfo.Source`.
| 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:
Rules for the ref, in order:
| # | Rule | `RefSource` |
|---|---|---|
| 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` |
| 1 | The pin is on the target (a remote branch containing it): the target | `RefFromGitmodules` / `RefFromDefaultBranch` |
| 2 | The pin is on other remote branches: the one closest to the pin, fewest commits after it (`rev-list --count <pin>..<branch>`). A tie goes to the upstream of the branch checked out in the submodule, then to the first name in alphabetical order. No branch is a favorite, the default branch included. At most 32 branches are compared (oldest tip first) | `RefFromContains` |
| 3 | The pin is on no remote branch (unpushed): the target, for the distance | the target's |
| 4 | There is no target: upstream of the branch checked out in the submodule | `RefFromCurrentBranch` |
| 5 | A tag (`git describe`) | `RefFromTag` |
| 6 | Short SHA; no tip, no distance | `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.
Rule 2 is why a pin on a feature branch shows that branch with its real
distance (`origin/feat/x ↓1`) instead of "diverged from main". `Ref` differs
from `Target` exactly then, and the issue `off target` says so. It does not
matter which branch the target is: if `.gitmodules` declares `marco-dev`,
`main` is just another branch.
`Pin.Branches` lists the other remote branches that contain the pin besides
`Ref` (oldest tip first, at most 8).
Distances: `git rev-list --left-right --count <pin>...<ref>` gives
`Ahead` (left) and `Behind` (right).
`Ahead` (left: commits the pin has that the ref lacks) and `Behind` (right).
`Pushed` is true if the pin is reachable from any remote ref. A pin that is
ahead of its ref can still be pushed (e.g. on another branch); only
`Pushed == false` is dangerous.
not on its target is `off target` if it is pushed (on another branch), and
`unpushed` if it is not.
## Git commands
@@ -170,19 +186,17 @@ are local; `<pin>` is the index gitlink.
| `Changes` line counts | `diff HEAD --numstat -z -M --ignore-submodules=dirty`, only if a tracked file changed; failure leaves the counts at 0 |
| `FileChange.Modified` | `stat` of each changed file that still exists |
| `Pushed` | `for-each-ref --contains <pin> refs/remotes` is not empty |
| `Pin.Ref` | rules above; the remote is `origin`, else the first remote |
| `Pin.Ref`, `Pin.Target`, `Pin.Branches` | rules above; `for-each-ref --contains <pin> refs/remotes` (sorted by commit date), `rev-list --count <pin>..<branch>` per candidate |
| `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 |
| `TargetTip`, `TargetDrift` | `log -1` of the target and `rev-list --left-right --count <target>...<pin>`, only if the ref is not the target |
| `Bases` | `merge-base <pin> <ref>` / `<head>` / `<committed>` / `<target>`, 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:
- 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.
- Rule 4 uses the checked-out branch's upstream (`<branch>@{upstream}`).
- Rule 6 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
@@ -196,6 +210,40 @@ 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; `target` for the
target branch's tip if the pin is not on it). 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 commit that has **diverged** from the pin (target, 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`.
- No tip (the pin resolved to a SHA) or no checkout commit: no node.
## Distance notation
| Display | Meaning |
+124 -24
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,85 @@ 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)
2. One line per issue: word (colored, 10 columns) + explanation;
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, 11 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; the target's tip is labelled with the name of its branch, e.g.
`marco-dev`, without the remote and cut at 16 columns; `target` if unknown), 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).
- If the pin is `off target`, the target branch's tip is on the spine,
labelled with the name of its branch (`marco-dev`, without the remote, cut
at 16 columns), so the distance from main is visible: below the pin if the pin is
on top of the target, a fork if both have commits of their own. `tip` is
then the tip of the branch the pin is on (the `Ref`):
```
marco-dev ● d0bbbd7 updated submodules
┊ 12 commits
tip ● ┊ a523224 Remove stale entry
┊ ┊ 1 commit
pin · you ● ┊ 5a7df75 Merge branch 'ethercat…
┊ ┊ 52 commits
base ●─╯ 6707725 updated submodule
```
- The role words are muted, each word on its own: `you` is bold in the
default text color, so the checkout stands out in `pin · you`. On its own
node, i.e. off pin, it is yellow (word and SHA); `staged` is blue. Commit
subjects use the default text color, the gap is muted.
- 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
@@ -147,10 +209,14 @@ 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`
changed, off target), 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.
@@ -160,12 +226,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
@@ -176,13 +242,14 @@ colors in components.
|---|---|---|
| Danger | ANSI `1` red, bold | `unpushed`, `conflict`, `error`, `↑n` of an unpushed pin |
| Warn | ANSI `3` yellow | `missing`, `off pin`, `changed`, differing checkout SHA |
| Info | ANSI `4` blue | `staged`, the `●` staged marker |
| Info | ANSI `4` blue | `staged`, the `●` staged marker, `off target` |
| Primary | ANSI `6` cyan | branch names, commit hashes, spinner |
| Muted | ANSI `8` grey | quiet rows, directory headers, arrows, labels |
| Text | terminal default | normal text |
| Border | ANSI `8` grey | pane and help separators |
| Bar | adaptive 254 (light) / 235 (dark) | header background |
| Selected | adaptive 253 (light) / 237 (dark) | selected row background |
| Backdrop | adaptive 252 (light) / 238 (dark) | the screen behind a popup |
- Background shades have no ANSI-16 equivalent, so they use `AdaptiveColor`.
The background is detected once in `main` before Bubble Tea starts.
@@ -199,6 +266,7 @@ colors in components.
| `missing` | not initialized / directory missing |
| `off pin` | checkout differs from pin |
| `changed` | uncommitted changes / untracked files |
| `off target` | pin not on `<target>`, on `<branch>` (+n more) |
| `staged` | pin change staged, not committed |
| `in sync` | nothing to do |
@@ -293,6 +361,34 @@ question is open. After `c` the name reads `src/app to the committed pin`. Witho
superproject`. This line takes precedence over the filter line and the
problems-only notice.
### Switch branch
`b` opens a popup in the middle of the screen (see
[actions.md](actions.md#switch-branch)). The screen around it stays visible
but dimmed: its colors are dropped and it is drawn in `Backdrop`, darker
than `Muted`, so the popup has the focus.
```
╭──────────────────────────────────────────────────────────────────────╮
│ Switch libs/feature │
│ / filter branches │
│ ──────────────────────────────────────────────────────────────────── │
│ * main 92d1583 3d ago │
│ origin/feat/search 58a2bf1 2h ago │
│ ↑↓ select · enter switch · esc cancel │
╰──────────────────────────────────────────────────────────────────────╯
```
- Border `Border`, title in `Primary` bold, search prompt `Primary`, the
selected row on the `Selected` background, SHA and age `Muted`, the remote
prefix `Muted`, the checked-out branch bold with `*`.
- Width at most 72 columns, height as the list (at most 14 rows), so
filtering does not resize it. While reading: spinner and `reading
branches…`; no match: `no branch matches`; error: red, inside the popup.
- Enter closes the popup and continues on the line above the footer like a
[restore](#restore): `checking`, the question `Switch src/app to
origin/feat? Discards … y switch · n cancel`, `switching …`.
### Key reference
Every key in the UI, where it is shown. Update this
@@ -310,6 +406,7 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette
| `home` / `g`, `end` / `G` | Header / last submodule | Navigation | Help |
| `pgup`, `pgdn` | Move by one screen | Navigation | Help |
| `c` | Copy path | Submodule | Palette, Help |
| `b` | Switch branch (popup) | Submodule | Palette, Help |
| `x` | Restore | Submodule | Palette, Help |
| `f` | Fetch all repositories | Global | Help |
| `r` | Reload everything | Global | Help |
@@ -323,6 +420,9 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette
- Click a row: select it. Click the header: select the superproject.
- Click `▸` / `▾`: select and toggle.
- Scroll wheel: move the selection.
- In the [branch popup](#switch-branch): click a branch to switch to it,
wheel to move, click outside to close. The popup is hit-tested by its own
position, not with bubblezone, since it is drawn after `zone.Scan`.
## Accessibility and terminals
+209 -128
View File
@@ -20,12 +20,13 @@ import (
"submarine/internal/submodule"
)
// maxContainsCandidates limits how many remote branches containing the pin
// are compared besides the preferred refs when resolving a pushed pin.
const maxContainsCandidates = 16
// maxPinBranches limits Pin.Branches, the other remote branches containing the
// pin.
const maxPinBranches = 8
// maxBetween limits the commits listed between pin and tip.
const maxBetween = 50
// maxContainsCandidates limits how many remote branches containing the pin
// are compared when the pin is not on its target.
const maxContainsCandidates = 32
// Provider reads a superproject and its submodules with git.
type Provider struct {
@@ -79,13 +80,6 @@ func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
} else if ok {
repo.Ahead, repo.Behind, _ = parseLeftRight(out)
}
if dir, ok, err := try(ctx, p.root, "rev-parse", "--path-format=absolute", "--git-common-dir"); err != nil {
return nil, err
} else if ok {
if fi, err := os.Stat(filepath.Join(dir, "FETCH_HEAD")); err == nil {
repo.FetchedAt = fi.ModTime()
}
}
repo.Root = &submodule.Submodule{Name: filepath.Base(p.root), Path: ""}
if remote, err := remoteName(ctx, p.root); err != nil {
@@ -176,7 +170,8 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
if err != nil {
return nil, err
}
st := &submodule.Status{PinStaged: parseGitlink(out) != pin}
committed := parseGitlink(out)
st := &submodule.Status{PinStaged: committed != pin}
// presence
if _, err := os.Stat(in.dir); errors.Is(err, fs.ErrNotExist) {
@@ -223,9 +218,51 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
if err := pinState(ctx, in, st, pin, head, branch, nil); err != nil {
return nil, err
}
if st.PinStaged && committed != "" {
if err := committedState(ctx, in, st, committed, pin); err != nil {
return nil, err
}
}
return st, nil
}
// committedState fills the pin of the superproject's HEAD and how it compares
// with the staged pin. It stays empty if the commit is not on disk: the staged
// pin is what matters, so this is no reason to fail.
func committedState(ctx context.Context, in info, st *submodule.Status, committed, pin string) error {
if _, ok, err := try(ctx, in.dir, "cat-file", "-e", committed+"^{commit}"); err != nil || !ok {
return err
}
c, err := commit(ctx, in.dir, committed)
if err != nil {
return err
}
out, err := run(ctx, in.dir, "rev-list", "--left-right", "--count", committed+"..."+pin)
if err != nil {
return err
}
if st.CommittedDrift.Ahead, st.CommittedDrift.Behind, err = parseLeftRight(out); err != nil {
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.
@@ -263,7 +300,7 @@ func (p *Provider) rootStatus(ctx context.Context) (*submodule.Status, error) {
}
// the root is compared with its upstream, so that the header and the
// detail pane agree; only without one the closest branch is looked for
// detail pane agree; only without one the target is looked for
var chosen *candidate
if branch != "" {
if up, ok, err := try(ctx, in.dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", branch+"@{upstream}"); err != nil {
@@ -279,8 +316,9 @@ func (p *Provider) rootStatus(ctx context.Context) (*submodule.Status, error) {
}
// pinState fills the fields of st that describe pin: whether it is pushed, the
// ref it resolves to (chosen, if not nil), the commits and the distance to the
// ref. head is the checked-out commit.
// ref it resolves to (chosen, if not nil), the commits and the distances: of
// the checkout to the pin and of the pin to the ref. head is the checked-out
// commit.
func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, branch string, chosen *candidate) error {
// the pin must exist locally to say anything about it
if _, ok, err := try(ctx, in.dir, "cat-file", "-e", pin+"^{commit}"); err != nil {
@@ -306,10 +344,15 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
var source submodule.RefSource
if chosen != nil {
ref, source = chosen.ref, chosen.source
} else if ref, source, err = resolve(ctx, in, pin, branch, containing); err != nil {
} else if ref, source, st.Pin.Target, err = resolve(ctx, in, pin, branch, containing); err != nil {
return err
}
st.Pin.Ref, st.Pin.Source = ref, source
for _, b := range containing {
if b != ref && len(st.Pin.Branches) < maxPinBranches {
st.Pin.Branches = append(st.Pin.Branches, b)
}
}
// commits
if st.Pinned, err = commit(ctx, in.dir, pin); err != nil {
@@ -320,6 +363,18 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
if st.CheckedOut, err = commit(ctx, in.dir, head); err != nil {
return err
}
out, err = run(ctx, in.dir, "rev-list", "--left-right", "--count", head+"..."+pin)
if err != nil {
return err
}
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
@@ -335,141 +390,167 @@ 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+st.Pin.Behind > 0 {
out, err = run(ctx, in.dir, "log", commitFormat, fmt.Sprintf("--max-count=%d", maxBetween), pin+"..."+ref)
if st.Pin.Ahead > 0 && st.Pin.Behind > 0 {
if st.Bases.Tip, err = mergeBase(ctx, in.dir, pin, ref); err != nil {
return err
}
}
// the pin is on another branch than its target: where the target is
if target := st.Pin.Target; target != "" && target != ref {
if st.TargetTip, err = commit(ctx, in.dir, target); err != nil {
return err
}
out, err = run(ctx, in.dir, "rev-list", "--left-right", "--count", target+"..."+pin)
if err != nil {
return err
}
st.Between = parseCommits(out)
if st.TargetDrift.Ahead, st.TargetDrift.Behind, err = parseLeftRight(out); err != nil {
return err
}
if st.TargetDrift.Ahead > 0 && st.TargetDrift.Behind > 0 {
if st.Bases.Target, err = mergeBase(ctx, in.dir, pin, target); err != nil {
return err
}
}
}
return nil
}
// resolve finds the ref that describes the pin, following the order in
// 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) {
preferred, err := preferredRefs(ctx, in, branch)
// resolve finds the ref the pin is compared with, following the order in
// docs/state-model.md: the target (see targetRef) if the pin is on it, else the
// branch containing the pin that is closest to it, else the target anyway,
// the upstream of the checked-out branch, a tag, the commit itself. It also
// returns the target ("" if there is none). 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, string, error) {
target, source, hasTarget, err := targetRef(ctx, in)
if err != nil {
return "", 0, err
return "", 0, "", err
}
if !hasTarget {
target = ""
}
if hasTarget && slices.Contains(containing, target) {
return target, source, target, 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 {
cands = append(cands, c)
}
}
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})
}
}
if c, ok, err := closest(ctx, in.dir, pin+"..", cands); err != nil {
return "", 0, err
} else if ok {
return c.ref, c.source, nil
}
ref, err := closestContaining(ctx, in, pin, branch, containing)
return ref, submodule.RefFromContains, target, err
}
if hasTarget {
return target, source, target, 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
// the checked-out branch depends on the machine, so it is no target
if up, ok, err := upstream(ctx, in.dir, branch); err != nil {
return "", 0, "", err
} else if ok {
return c.ref, c.source, nil
return up, submodule.RefFromCurrentBranch, "", nil
}
// 3. nearest tag
if tag, ok, err := try(ctx, in.dir, "describe", "--tags", "--abbrev=0", pin); err != nil {
return "", 0, err
return "", 0, "", err
} else if ok && tag != "" {
return tag, submodule.RefFromTag, nil
return tag, submodule.RefFromTag, "", nil
}
// 4. the commit itself
return "", submodule.RefFromSHA, nil
return "", submodule.RefFromSHA, "", nil
}
// candidate is a ref the pin may resolve to and the rule that proposed it.
// upstream returns the upstream of the local branch, if there is one.
func upstream(ctx context.Context, dir, branch string) (string, bool, error) {
if branch == "" {
return "", false, nil
}
return try(ctx, dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", branch+"@{upstream}")
}
// closestContaining picks the branch the pin is closest to among the remote
// branches containing it: the fewest commits after the pin. A tie goes to the
// upstream of the checked-out branch, then to the first name in alphabetical
// order, so that the answer is the same on every machine. No branch,
// including the default branch, is preferred otherwise.
func closestContaining(ctx context.Context, in info, pin, branch string, containing []string) (string, error) {
var best []string
bestN := -1
for _, c := range containing[:min(len(containing), maxContainsCandidates)] {
out, err := run(ctx, in.dir, "rev-list", "--count", pin+".."+c)
if err != nil {
return "", err
}
var n int
if _, err := fmt.Sscan(out, &n); err != nil {
continue
}
switch {
case bestN < 0 || n < bestN:
best, bestN = []string{c}, n
case n == bestN:
best = append(best, c)
}
}
if len(best) == 0 {
return containing[0], nil
}
if up, ok, err := upstream(ctx, in.dir, branch); err != nil {
return "", err
} else if ok && slices.Contains(best, up) {
return up, nil
}
slices.Sort(best)
return best[0], nil
}
// targetRef returns the branch the pin should be on: the `branch =` of
// .gitmodules (`.` is the superproject's current branch), else the remote's
// default branch, else main or master. A declared branch that does not exist
// locally is not replaced by the default: the answer would be wrong.
func targetRef(ctx context.Context, in info) (string, submodule.RefSource, bool, error) {
remote, err := remoteName(ctx, in.dir)
if err != nil || remote == "" {
return "", 0, false, err
}
exists := func(ref string) (bool, error) {
_, ok, err := try(ctx, in.dir, "rev-parse", "--verify", "-q", "refs/remotes/"+ref)
return ok, err
}
if b := in.branch; b != "" {
if b == "." {
if b, _, err = try(ctx, in.superDir, "symbolic-ref", "--short", "-q", "HEAD"); err != nil {
return "", 0, false, err
}
}
if b == "" {
return "", 0, false, nil
}
ok, err := exists(remote + "/" + b)
if err != nil || !ok {
return "", 0, false, err
}
return remote + "/" + b, submodule.RefFromGitmodules, true, nil
}
if def, ok, err := try(ctx, in.dir, "symbolic-ref", "--short", "-q", "refs/remotes/"+remote+"/HEAD"); err != nil {
return "", 0, false, err
} else if ok {
return def, submodule.RefFromDefaultBranch, true, nil
}
for _, name := range []string{"main", "master"} {
if ok, err := exists(remote + "/" + name); err != nil {
return "", 0, false, err
} else if ok {
return remote + "/" + name, submodule.RefFromDefaultBranch, true, nil
}
}
return "", 0, false, nil
}
// candidate is a ref 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")
+178 -16
View File
@@ -68,9 +68,6 @@ func TestTree(t *testing.T) {
if repo.Branch != "main" {
t.Errorf("Branch = %q, want main", repo.Branch)
}
if !repo.FetchedAt.IsZero() {
t.Errorf("FetchedAt = %v, want zero (superproject never fetched)", repo.FetchedAt)
}
var paths []string
submodule.Walk(repo.Submodules, func(s *submodule.Submodule) { paths = append(paths, s.Path) })
want := []string{
@@ -93,25 +90,29 @@ func TestStatus(t *testing.T) {
path string
issues []submodule.IssueKind
ref string
target string // the target if the pin is not on it; else the ref is the target
source submodule.RefSource
ahead int
behind int
staged bool
checkout submodule.Checkout
drift submodule.Distance // checkout compared with the pin
branches []string // other remote branches containing the pin
toTarget submodule.Distance // target tip compared with the pin; zero if the pin is on it
}{
{path: "src/core", issues: []submodule.IssueKind{submodule.IssueOffPin}, ref: "origin/main", source: submodule.RefFromDefaultBranch, checkout: submodule.CheckoutDiffers},
{path: "src/ui", ref: "origin/main", source: submodule.RefFromCurrentBranch, behind: 2},
{path: "src/net", issues: []submodule.IssueKind{submodule.IssueUnpushed}, ref: "origin/main", source: submodule.RefFromCurrentBranch, ahead: 1},
{path: "src/core", issues: []submodule.IssueKind{submodule.IssueOffPin}, ref: "origin/main", source: submodule.RefFromDefaultBranch, checkout: submodule.CheckoutDiffers, drift: submodule.Distance{Behind: 1}},
{path: "src/ui", ref: "origin/main", source: submodule.RefFromDefaultBranch, behind: 2},
{path: "src/net", issues: []submodule.IssueKind{submodule.IssueUnpushed}, ref: "origin/main", source: submodule.RefFromDefaultBranch, ahead: 1},
{path: "libs/store", ref: "origin/develop", source: submodule.RefFromGitmodules},
{path: "libs/log", ref: "origin/main", source: submodule.RefFromCurrentBranch},
{path: "libs/log", ref: "origin/main", source: submodule.RefFromDefaultBranch, branches: []string{"origin/archive"}},
{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/feature", issues: []submodule.IssueKind{submodule.IssueOffTarget}, ref: "origin/feat/search", source: submodule.RefFromContains, target: "origin/main", behind: 1, checkout: submodule.CheckoutDetached, toTarget: submodule.Distance{Behind: 1}},
{path: "libs/topic", issues: []submodule.IssueKind{submodule.IssueOffTarget}, ref: "origin/feat/search", source: submodule.RefFromContains, target: "origin/main", checkout: submodule.CheckoutDetached, toTarget: submodule.Distance{Behind: 2}},
{path: "libs/conflict", issues: []submodule.IssueKind{submodule.IssueConflict}, ref: "origin/main", source: submodule.RefFromDefaultBranch, staged: true},
{path: "libs/staged", ref: "origin/main", source: submodule.RefFromDefaultBranch, staged: true},
{path: "libs/missing", issues: []submodule.IssueKind{submodule.IssueMissing}},
{path: "vendor", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromCurrentBranch, behind: 2},
{path: "libs/work", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromCurrentBranch},
{path: "vendor", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromDefaultBranch, behind: 2},
{path: "libs/work", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromDefaultBranch},
}
for _, tt := range tests {
st, err := p.Status(context.Background(), tt.path)
@@ -132,15 +133,34 @@ func TestStatus(t *testing.T) {
if st.Pin.Ref != tt.ref || st.Pin.Source != tt.source {
t.Errorf("%s: ref %q (%v), want %q (%v)", tt.path, st.Pin.Ref, st.Pin.Source, tt.ref, tt.source)
}
want := tt.target
if want == "" && (tt.source == submodule.RefFromGitmodules || tt.source == submodule.RefFromDefaultBranch) {
want = tt.ref
}
if st.Pin.Target != want {
t.Errorf("%s: target %q, want %q", tt.path, st.Pin.Target, want)
}
if st.Pin.Ahead != tt.ahead || st.Pin.Behind != tt.behind {
t.Errorf("%s: ↑%d↓%d, want ↑%d↓%d", tt.path, st.Pin.Ahead, st.Pin.Behind, tt.ahead, tt.behind)
}
if len(st.Between) != tt.ahead+tt.behind {
t.Errorf("%s: %d commits between, want %d", tt.path, len(st.Between), tt.ahead+tt.behind)
}
if st.PinStaged != tt.staged {
t.Errorf("%s: PinStaged %v, want %v", tt.path, st.PinStaged, tt.staged)
}
if tt.staged != (st.Committed != nil) {
t.Errorf("%s: Committed = %+v with staged = %v", tt.path, st.Committed, tt.staged)
}
if tt.path == "libs/staged" && (st.CommittedDrift.Ahead != 0 || st.CommittedDrift.Behind == 0) {
t.Errorf("%s: CommittedDrift %+v, want the committed pin behind the staged one", tt.path, st.CommittedDrift)
}
if !slices.Equal(st.Pin.Branches, tt.branches) {
t.Errorf("%s: other branches %v, want %v", tt.path, st.Pin.Branches, tt.branches)
}
if st.TargetDrift != tt.toTarget || (st.TargetTip != nil) != (tt.toTarget != submodule.Distance{}) {
t.Errorf("%s: TargetDrift %+v with tip %+v, want %+v", tt.path, st.TargetDrift, st.TargetTip, tt.toTarget)
}
if st.Drift != tt.drift {
t.Errorf("%s: drift %+v, want %+v", tt.path, st.Drift, tt.drift)
}
if st.Checkout != tt.checkout {
t.Errorf("%s: Checkout %v, want %v", tt.path, st.Checkout, tt.checkout)
}
@@ -267,3 +287,145 @@ func TestNewNotARepo(t *testing.T) {
t.Error("New(non-repo): want error")
}
}
// A staged pin older than the committed one: the committed pin is ahead.
func TestCommittedPinAheadOfStaged(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
git(t, filepath.Join(super, "src", "ui"), "checkout", "-q", "--detach", "HEAD~1")
git(t, super, "add", "src/ui")
st, err := p.Status(context.Background(), "src/ui")
if err != nil {
t.Fatal(err)
}
if !st.PinStaged || st.Committed == nil {
t.Fatalf("PinStaged = %v, Committed = %v", st.PinStaged, st.Committed)
}
if st.CommittedDrift != (submodule.Distance{Ahead: 1}) {
t.Errorf("CommittedDrift = %+v, want the committed pin 1 ahead", st.CommittedDrift)
}
spine := st.Spine()
if n := len(spine); n < 2 || spine[n-1].Commit.SHA != st.Pinned.SHA || spine[n-2].Commit.SHA != st.Committed.SHA {
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)
}
// libs/feature: its pin is on a feature branch, and main moved on
pushUpstream(t, root, "feature")
if err := p.Fetch(ctx, "libs/feature"); err != nil {
t.Fatal(err)
}
st, err = p.Status(ctx, "libs/feature")
if err != nil {
t.Fatal(err)
}
if st.TargetDrift != (submodule.Distance{Ahead: 1, Behind: 1}) || st.Bases.Target == nil {
t.Errorf("libs/feature: TargetDrift %+v, Bases.Target %+v, want a diverged target with a base", st.TargetDrift, st.Bases.Target)
}
if sp := st.Spine(); len(sp) == 0 || !sp[0].Fork || sp[0].Roles[0] != submodule.RoleTarget {
t.Errorf("libs/feature: spine = %+v, want a fork headed by the target", sp)
}
// 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)
}
}
// A `branch =` that does not exist locally is not replaced by the default
// branch: judging the pin against the wrong branch would be worse than not
// judging it.
func TestDeclaredBranchMissing(t *testing.T) {
p, root := ownFixture(t)
git(t, filepath.Join(root, "super"), "config", "-f", ".gitmodules", "submodule.libs/store.branch", "gone")
if _, err := p.Tree(context.Background()); err != nil {
t.Fatal(err)
}
st, err := p.Status(context.Background(), "libs/store")
if err != nil {
t.Fatal(err)
}
if st.Pin.Target != "" {
t.Errorf("target %q, want none", st.Pin.Target)
}
for _, is := range st.Issues() {
if is.Kind == submodule.IssueOffTarget {
t.Errorf("issue %+v without a target", is)
}
}
}
// Two branches contain the pin equally close: the branch the submodule is on
// wins, else the first name; the default branch is no favorite.
func TestContainingBranchTie(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
// the target is a branch that does not exist, so none applies
git(t, super, "config", "-f", ".gitmodules", "submodule.libs/log.branch", "gone")
if _, err := p.Tree(context.Background()); err != nil {
t.Fatal(err)
}
ref := func() (string, submodule.RefSource, []string) {
st, err := p.Status(context.Background(), "libs/log")
if err != nil {
t.Fatal(err)
}
return st.Pin.Ref, st.Pin.Source, st.Pin.Branches
}
// on branch main (upstream origin/main) in the fixture
if r, src, _ := ref(); r != "origin/main" || src != submodule.RefFromContains {
t.Errorf("on a branch: ref %q (%v), want origin/main", r, src)
}
git(t, filepath.Join(super, "libs", "log"), "checkout", "-q", "--detach")
r, src, others := ref()
if r != "origin/archive" || src != submodule.RefFromContains {
t.Errorf("detached: ref %q (%v), want origin/archive", r, src)
}
if !slices.Equal(others, []string{"origin/main"}) {
t.Errorf("detached: other branches %v, want [origin/main]", others)
}
}
-3
View File
@@ -154,9 +154,6 @@ func TestRootStatusRefIsUpstream(t *testing.T) {
if st.Pin.Ahead != 0 || st.Pin.Behind != 3 {
t.Errorf("ahead/behind = %d/%d, want 0/3", st.Pin.Ahead, st.Pin.Behind)
}
if len(st.Between) != 3 {
t.Errorf("Between = %d commits, want 3", len(st.Between))
}
}
func TestRootStatusDetached(t *testing.T) {
+132
View File
@@ -0,0 +1,132 @@
package git
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
"submarine/internal/submodule"
)
// switchDir returns the directory of path ("" is the superproject), and the
// submodule's info if it is one.
func (p *Provider) switchDir(path string) (dir string, in info, sub bool, err error) {
if path == "" {
return p.root, info{}, false, nil
}
p.mu.RLock()
in, ok := p.mods[path]
p.mu.RUnlock()
if !ok {
return "", info{}, false, fmt.Errorf("unknown submodule %q", path)
}
if !initialized(in.dir) {
return "", info{}, false, errors.New("not initialized; restore it first")
}
return in.dir, in, true, nil
}
// Branches lists the local and remote-tracking branches of path, local ones
// first, each group by the date of its tip, newest first. It reads only what
// is on disk.
func (p *Provider) Branches(ctx context.Context, path string) ([]submodule.Branch, error) {
dir, _, _, err := p.switchDir(path)
if err != nil {
return nil, err
}
out, err := run(ctx, dir, "for-each-ref",
"--format=%(refname)%00%(objectname)%00%(committerdate:unix)%00%(HEAD)%00%(symref)",
"refs/heads", "refs/remotes")
if err != nil {
return nil, err
}
var branches []submodule.Branch
for _, line := range strings.Split(out, "\n") {
f := strings.Split(line, "\x00")
if len(f) != 5 || f[4] != "" { // a symref is origin/HEAD
continue
}
b := submodule.Branch{Ref: f[0], SHA: f[1], Current: f[3] == "*"}
if secs, err := strconv.ParseInt(f[2], 10, 64); err == nil {
b.When = time.Unix(secs, 0)
}
if name, ok := strings.CutPrefix(f[0], "refs/heads/"); ok {
b.Name, b.Local = name, name
} else if name, ok := strings.CutPrefix(f[0], "refs/remotes/"); ok {
_, local, _ := strings.Cut(name, "/")
b.Name, b.Local, b.Remote = name, local, true
} else {
continue
}
branches = append(branches, b)
}
slices.SortStableFunc(branches, func(a, b submodule.Branch) int {
switch {
case a.Remote != b.Remote:
if a.Remote {
return 1
}
return -1
default:
return b.When.Compare(a.When)
}
})
return branches, nil
}
// SwitchLoss reports what switching path to b would discard: the same as a
// restore to b's tip.
func (p *Provider) SwitchLoss(ctx context.Context, path string, b submodule.Branch) (submodule.Loss, error) {
dir, _, _, err := p.switchDir(path)
if err != nil {
return submodule.Loss{}, err
}
return p.lossOf(ctx, dir, b.SHA), nil
}
// Switch makes b's tip the staged pin of a submodule, restores the repository
// to it (see Restore) and puts HEAD on the branch. For a remote branch the
// local branch of that name is created, tracking it. A local branch that
// exists elsewhere is never moved: HEAD stays detached and "" is returned.
func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch) (string, error) {
dir, in, sub, err := p.switchDir(path)
if err != nil {
return "", err
}
if sha, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", b.Ref+"^{commit}"); err != nil {
return "", err
} else if !ok || sha != b.SHA {
return "", fmt.Errorf("%s moved; open the list again", b.Name)
}
if sub { // staged first, so a failed restore can be repeated with x
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+b.SHA+","+in.relPath); err != nil {
return "", err
}
}
if err := p.restoreRepo(ctx, dir, b.SHA); err != nil {
return "", err
}
g := func(args ...string) (string, error) {
return p.exec(ctx, dir, nil, append(append([]string{}, restoreArgs...), args...)...)
}
if !b.Remote {
_, err := g("switch", "-q", b.Local)
return b.Local, err
}
switch tip, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", "refs/heads/"+b.Local); {
case err != nil:
return "", err
case !ok:
_, err := g("switch", "-q", "-c", b.Local, "--track", b.Name)
return b.Local, err
case tip == b.SHA:
_, err := g("switch", "-q", b.Local)
return b.Local, err
}
return "", nil
}
+181
View File
@@ -0,0 +1,181 @@
package git
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"submarine/internal/submodule"
)
func findBranch(t *testing.T, bs []submodule.Branch, name string) submodule.Branch {
t.Helper()
for _, b := range bs {
if b.Name == name {
return b
}
}
t.Fatalf("no branch %q in %v", name, bs)
return submodule.Branch{}
}
func TestBranchesListsLocalAndRemote(t *testing.T) {
p, root := ownFixture(t)
_ = root
bs, err := p.Branches(context.Background(), "libs/feature")
if err != nil {
t.Fatal(err)
}
var names []string
seenRemote := false
for _, b := range bs {
names = append(names, b.Name)
if b.Remote {
seenRemote = true
} else if seenRemote {
t.Errorf("local branch %s after a remote one: %v", b.Name, names)
}
if b.Name == "origin/HEAD" {
t.Error("origin/HEAD must not be listed")
}
}
rb := findBranch(t, bs, "origin/feat/search")
if !rb.Remote || rb.Local != "feat/search" {
t.Errorf("origin/feat/search = %+v", rb)
}
findBranch(t, bs, "origin/main")
}
func TestSwitchSubmoduleStagesTipAndTracksRemoteBranch(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
sub := filepath.Join(super, "libs", "feature")
ctx := context.Background()
bs, err := p.Branches(ctx, "libs/feature")
if err != nil {
t.Fatal(err)
}
b := findBranch(t, bs, "origin/feat/search")
got, err := p.Switch(ctx, "libs/feature", b)
if err != nil {
t.Fatal(err)
}
if got != "feat/search" {
t.Errorf("Switch returned %q, want feat/search", got)
}
if head := out(t, sub, "branch", "--show-current"); head != "feat/search" {
t.Errorf("branch = %q", head)
}
if up := out(t, sub, "rev-parse", "--abbrev-ref", "feat/search@{upstream}"); up != "origin/feat/search" {
t.Errorf("upstream = %q", up)
}
if head := out(t, sub, "rev-parse", "HEAD"); head != b.SHA {
t.Errorf("HEAD = %s, want %s", head, b.SHA)
}
if staged := out(t, super, "ls-files", "-s", "--", "libs/feature"); !strings.Contains(staged, b.SHA) {
t.Errorf("index = %q, want the tip %s staged", staged, b.SHA)
}
assertExact(t, sub)
}
func TestSwitchNeverMovesAnExistingLocalBranch(t *testing.T) {
p, root := ownFixture(t)
sub := filepath.Join(root, "super", "libs", "feature")
ctx := context.Background()
git(t, sub, "branch", "feat/search", "origin/main") // same name, elsewhere
before := out(t, sub, "rev-parse", "feat/search")
bs, err := p.Branches(ctx, "libs/feature")
if err != nil {
t.Fatal(err)
}
b := findBranch(t, bs, "origin/feat/search")
got, err := p.Switch(ctx, "libs/feature", b)
if err != nil {
t.Fatal(err)
}
if got != "" {
t.Errorf("Switch returned %q, want detached", got)
}
if head := out(t, sub, "branch", "--show-current"); head != "" {
t.Errorf("HEAD on %q, want detached", head)
}
if after := out(t, sub, "rev-parse", "feat/search"); after != before {
t.Errorf("branch moved: %s -> %s", before, after)
}
if head := out(t, sub, "rev-parse", "HEAD"); head != b.SHA {
t.Errorf("HEAD = %s, want %s", head, b.SHA)
}
}
func TestSwitchAbortsWhenTheBranchMoved(t *testing.T) {
p, root := ownFixture(t)
sub := filepath.Join(root, "super", "libs", "feature")
ctx := context.Background()
bs, err := p.Branches(ctx, "libs/feature")
if err != nil {
t.Fatal(err)
}
b := findBranch(t, bs, "origin/main")
b.SHA = out(t, sub, "rev-parse", "origin/feat/search") // what the list showed is stale
if _, err := p.Switch(ctx, "libs/feature", b); err == nil || !strings.Contains(err.Error(), "moved") {
t.Errorf("err = %v, want a moved error", err)
}
}
func TestSwitchSuperprojectRestoresSubmodulesToItsPins(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
ctx := context.Background()
git(t, super, "branch", "older", "HEAD~1")
bs, err := p.Branches(ctx, "")
if err != nil {
t.Fatal(err)
}
b := findBranch(t, bs, "older")
got, err := p.Switch(ctx, "", b)
if err != nil {
t.Fatal(err)
}
if got != "older" || out(t, super, "branch", "--show-current") != "older" {
t.Errorf("Switch = %q, branch = %q", got, out(t, super, "branch", "--show-current"))
}
assertExact(t, super)
}
func TestSwitchLossCountsChangedFiles(t *testing.T) {
p, root := ownFixture(t)
sub := filepath.Join(root, "super", "libs", "feature")
ctx := context.Background()
bs, err := p.Branches(ctx, "libs/feature")
if err != nil {
t.Fatal(err)
}
b := findBranch(t, bs, "origin/feat/search")
loss, err := p.SwitchLoss(ctx, "libs/feature", b)
if err != nil {
t.Fatal(err)
}
if !loss.Empty() {
t.Errorf("clean checkout: loss = %+v", loss)
}
if err := os.WriteFile(filepath.Join(sub, "new.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if loss, err = p.SwitchLoss(ctx, "libs/feature", b); err != nil || loss.Files != 1 {
t.Errorf("loss = %+v, %v; want 1 file", loss, err)
}
}
func TestBranchesOfAMissingSubmoduleSaysSo(t *testing.T) {
p, _ := ownFixture(t)
if _, err := p.Branches(context.Background(), "libs/missing"); err == nil || !strings.Contains(err.Error(), "not initialized") {
t.Errorf("err = %v", err)
}
}
+23 -27
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"math/rand/v2"
"path"
"strings"
"sync"
"time"
@@ -40,10 +41,9 @@ func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
defer p.mu.Unlock()
p.statuses = map[string]*submodule.Status{}
repo := &submodule.Repo{
Path: "~/coding/my_project",
Branch: "main",
Behind: 2,
FetchedAt: time.Now().Add(-2 * time.Minute),
Path: "~/coding/my_project",
Branch: "main",
Behind: 2,
}
repo.Root = &submodule.Submodule{Name: "my_project", URL: "git@github.com:acme/my_project.git"}
p.statuses[""] = rootStatus()
@@ -133,8 +133,12 @@ func commit(sha, subject, author string, ago time.Duration) *submodule.Commit {
}
func inSync(ref string, c *submodule.Commit) *submodule.Status {
pin := submodule.PinInfo{Ref: ref, Pushed: true}
if strings.HasPrefix(ref, "origin/") {
pin.Target = ref // on its target branch
}
return &submodule.Status{
Pin: submodule.PinInfo{Ref: ref, Pushed: true},
Pin: pin,
Pinned: c, CheckedOut: c, Tip: c,
}
}
@@ -169,11 +173,7 @@ func rootStatus() *submodule.Status {
Pinned: head,
CheckedOut: head,
Tip: tip,
Between: []submodule.Commit{
*tip,
*commit("9d8c7b6", "Drop the legacy module", "chris", 20*time.Hour),
},
FetchedAt: now.Add(-2 * time.Minute),
FetchedAt: now.Add(-2 * time.Minute),
}
st.Changes = changes(15*time.Minute, []submodule.FileChange{
{Path: "README.md", Kind: submodule.ChangeModified, Added: 4, Removed: 1},
@@ -217,7 +217,7 @@ func fixture() []node {
Pinned: subSubPin,
CheckedOut: subSubTip,
Tip: subSubTip,
Between: []submodule.Commit{*subSubTip},
Drift: submodule.Distance{Ahead: 1},
}
schema := inSync("origin/main", commit("1a2b3c4", "Add v3 schema", "ben", 6*24*time.Hour))
@@ -240,6 +240,8 @@ func fixture() []node {
crypto := inSync("v1.4.2", cryptoNew)
crypto.Pin.Source = submodule.RefFromTag
crypto.PinStaged = true
crypto.Committed = commit("e0f1a2b", "Release v1.4.1", "ben", 40*24*time.Hour)
crypto.CommittedDrift = submodule.Distance{Behind: 3}
zlib := inSync("v1.3.1", commit("51f2c3e", "zlib 1.3.1", "madler", 300*24*time.Hour))
zlib.Pin.Source = submodule.RefFromTag
@@ -259,6 +261,7 @@ func fixture() []node {
Pinned: srcSub1Pin,
CheckedOut: srcSub1Head,
Tip: srcSub1Pin,
Drift: submodule.Distance{Ahead: 2},
}},
{relPath: "src/my_submodule_2", status: srcSub2, children: []node{
{relPath: "modules/my_sub_submodule", status: subSub},
@@ -271,29 +274,22 @@ func fixture() []node {
Pinned: unpushed,
CheckedOut: unpushed,
Tip: sub3Tip,
Between: []submodule.Commit{*unpushed},
}},
{relPath: "libs/logging", status: &submodule.Status{
Pin: submodule.PinInfo{Ref: "origin/main", Behind: 3, Pushed: true},
Pinned: logPin,
CheckedOut: logPin,
Tip: logTip,
Between: []submodule.Commit{
*logTip,
*commit("7d8e9f0", "Fix race in buffered writer", "dana", 3*24*time.Hour),
*commit("6c7d8e9", "Update dependencies", "renovate", 5*24*time.Hour),
},
Pin: submodule.PinInfo{Ref: "origin/feat/slog", Source: submodule.RefFromContains, Target: "origin/main", Behind: 3, Pushed: true},
Pinned: logPin,
CheckedOut: logPin,
Tip: logTip,
TargetTip: commit("0f1e2d3", "Bump dependencies", "renovate", 30*time.Hour),
TargetDrift: submodule.Distance{Ahead: 2, Behind: 4},
Bases: submodule.Bases{Target: commit("9a8b7c6", "Release 0.8.0", "dana", 14*24*time.Hour)},
}},
{relPath: "libs/http", status: &submodule.Status{
Pin: submodule.PinInfo{Ref: "origin/develop", Ahead: 1, Behind: 2, Pushed: true, Source: submodule.RefFromContains},
Pin: submodule.PinInfo{Ref: "origin/develop", Target: "origin/develop", Ahead: 1, Behind: 2, Source: submodule.RefFromGitmodules},
Bases: submodule.Bases{Tip: commit("a9b8c7d", "Release 1.9.0", "frank", 20*24*time.Hour)},
Pinned: httpPin,
CheckedOut: httpPin,
Tip: httpTip,
Between: []submodule.Commit{
*httpTip,
*commit("d0e1f2a", "Refactor transport", "frank", 2*24*time.Hour),
*httpPin,
},
}},
{relPath: "libs/crypto", status: crypto},
{relPath: "third_party/zlib", status: zlib},
+37 -10
View File
@@ -15,7 +15,6 @@ type Repo struct {
Branch string
Ahead int
Behind int
FetchedAt time.Time
Root *Submodule // the superproject as a selectable node; Path is "", no Children
Submodules []*Submodule
}
@@ -68,10 +67,10 @@ const (
type RefSource int
const (
RefFromGitmodules RefSource = iota
RefFromCurrentBranch
RefFromDefaultBranch
RefFromContains
RefFromGitmodules RefSource = iota // the target, `branch =` in .gitmodules; the pin is on it
RefFromDefaultBranch // the target, the remote's default branch; the pin is on it
RefFromContains // a branch containing the pin, which is not on the target
RefFromCurrentBranch // upstream of the checked-out branch, there being no better
RefFromTag
RefFromSHA
)
@@ -80,12 +79,12 @@ func (r RefSource) String() string {
switch r {
case RefFromGitmodules:
return ".gitmodules branch"
case RefFromCurrentBranch:
return "checked out branch"
case RefFromDefaultBranch:
return "remote default branch"
case RefFromContains:
return "branch containing pin"
case RefFromCurrentBranch:
return "checked out branch"
case RefFromTag:
return "tag"
default:
@@ -97,9 +96,17 @@ func (r RefSource) String() string {
type PinInfo struct {
Ref string // e.g. "origin/main"; empty if unresolved
Source RefSource
// Target is the branch the pin is supposed to be on (`branch =` of
// .gitmodules, else the remote's default branch); empty if there is none or
// it does not exist locally. Ref is the target if the pin is on it, else
// the branch the pin is on instead.
Target string
Ahead int // commits in pin not in ref
Behind int // commits in ref not in pin
Pushed bool // pin is reachable from some remote ref
// Branches are remote branches other than Ref that contain the pin, oldest
// tip first and limited.
Branches []string
}
// Commit is a short commit description.
@@ -173,6 +180,15 @@ func (c *Changes) Sort() {
})
}
// Distance counts the commits only one side of a comparison has, like
// `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, Target *Commit }
// Status is the full state of one submodule.
type Status struct {
Root bool // the superproject: the "pin" is its HEAD
@@ -184,7 +200,18 @@ type Status struct {
Pinned *Commit
CheckedOut *Commit
Tip *Commit
Between []Commit // commits that make up the pin <-> tip difference
Changes *Changes // nil if the worktree is clean or the details are unknown
FetchedAt time.Time // last fetch of the submodule; zero if never
Drift Distance // checkout compared with the pin: Ahead = commits only the checkout has
// Committed is the pin in the superproject's HEAD while Pinned (the index) is
// staged; nil if nothing is staged, the commit is not on disk, or the pin was
// only added. CommittedDrift compares it with Pinned like Drift.
Committed *Commit
CommittedDrift Distance
// TargetTip is the tip of Pin.Target if the pin is not on it (the ref is
// another branch), else nil. TargetDrift compares it with Pinned like
// Drift: Ahead = commits only the target has.
TargetTip *Commit
TargetDrift 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
}
+29 -1
View File
@@ -1,6 +1,9 @@
package submodule
import "context"
import (
"context"
"time"
)
// Provider supplies repository data. Tree is expected to be fast (structure
// only); Status may be slow and is called once per submodule, concurrently.
@@ -44,6 +47,31 @@ type Restorer interface {
Restore(ctx context.Context, path string, to RestoreTarget) error
}
// Branch is a local or remote-tracking branch of a repository.
type Branch struct {
Ref string // full ref, e.g. "refs/remotes/origin/feat"
Name string // short name, e.g. "origin/feat"
Local string // name of the local branch it stands for, e.g. "feat"
Remote bool // a remote-tracking branch
Current bool // HEAD is on this branch
SHA string // the tip when the list was read
When time.Time // committer date of the tip
}
// Switcher is implemented by providers that can switch a repository to a
// branch: the tip becomes the staged pin of a submodule, and the repository
// (with everything below it) is restored to it. Path "" is the superproject.
// Like Restore, Switch may clone or fetch what is missing below, but must
// never prompt the user. Branches uses no network.
type Switcher interface {
Branches(ctx context.Context, path string) ([]Branch, error)
// SwitchLoss reports what Switch would discard, read from the disk now.
SwitchLoss(ctx context.Context, path string, b Branch) (Loss, error)
// Switch returns the branch HEAD ends up on, "" if it is detached. It
// fails if b.Ref no longer points at b.SHA.
Switch(ctx context.Context, path string, b Branch) (string, error)
}
// RestoreTarget is the pin a submodule is restored to. It doesn't matter for
// the superproject, whose target is always HEAD.
type RestoreTarget int
+191
View File
@@ -0,0 +1,191 @@
package submodule
import "slices"
// Role is what a commit means for a submodule.
type Role int
// The order is the order in which roles are listed for a commit.
const (
RolePin Role = iota // the commit recorded in the superproject's HEAD
RoleHead // the superproject's HEAD, which takes the pin's place
RoleStaged // the pin in the index, if it differs from RolePin
RoleTip // the tip of the ref the pin was resolved to
RoleTarget // the tip of the target branch, if the pin is not on it
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
}
// InSync reports whether the pin, the tip and the checkout are one commit.
func (n SpineNode) InSync() bool {
return slices.Contains(n.Roles, RoleTip) &&
(slices.Contains(n.Roles, RoleHead) || slices.Contains(n.Roles, RolePin) && slices.Contains(n.Roles, RoleYou))
}
// Link is the history between two spine nodes. Above counts the commits only
// the upper node has, Below those only the lower node has.
type Link struct{ Above, Below int }
// Linear reports whether the lower node is an ancestor of the upper one, so
// that Above is the number of commits between them.
func (l Link) Linear() bool { return l.Above > 0 && l.Below == 0 }
// Diverged reports whether the nodes are on different branches.
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
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 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
}
pinRole := RolePin
switch {
case s.Root:
pinRole = RoleHead
case s.PinStaged:
pinRole = RoleStaged // the committed pin gets its own node below
}
pin := spineItem{roles: []Role{pinRole}, commit: s.Pinned, isPin: true}
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)
case own > 0 && pinOnly == 0:
it.height = own
above = append(above, it)
case own == 0:
it.height = -pinOnly
below = append(below, it)
default:
it.div, it.divAbove, it.base = true, divAbove, base
divs = append(divs, it)
}
}
if s.Tip != nil {
place(RoleTip, s.Tip, s.Bases.Tip, s.Pin.Behind, s.Pin.Ahead, true)
}
if s.TargetTip != nil {
place(RoleTarget, s.TargetTip, s.Bases.Target, s.TargetDrift.Ahead, s.TargetDrift.Behind, true)
}
if s.PinStaged && s.Committed != nil {
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.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
byHeight := func(a, b spineItem) int { return b.height - a.height }
slices.SortStableFunc(above, func(a, b spineItem) int {
if a.div != b.div {
if a.div {
return -1
}
return 1
}
return byHeight(a, b)
})
slices.SortStableFunc(below, func(a, b spineItem) int {
if a.div != b.div {
if a.div {
return 1
}
return -1
}
return byHeight(a, b)
})
slices.Sort(pin.roles)
items := append(append(above, pin), below...)
var nodes []SpineNode
var last spineItem
for i, it := range items {
// the same commit twice, e.g. a tip and a checkout side by side
if i > 0 && !it.div && !last.div && it.height == last.height && it.commit.SHA == last.commit.SHA {
n := &nodes[len(nodes)-1]
n.Roles = append(n.Roles, it.roles...)
slices.Sort(n.Roles)
continue
}
if i > 0 {
nodes[len(nodes)-1].Below = link(last, it)
}
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
}
// link describes the history between an upper and a lower item.
func link(upper, lower spineItem) Link {
switch {
case upper.div && lower.isPin:
return Link{Above: upper.own, Below: upper.pin}
case lower.div && upper.isPin:
return Link{Above: lower.pin, Below: lower.own}
case upper.div || lower.div:
return Link{} // unknown
default:
return Link{Above: upper.height - lower.height}
}
}
+194
View File
@@ -0,0 +1,194 @@
package submodule
import (
"slices"
"testing"
)
func TestSpine(t *testing.T) {
c := func(sha string) *Commit { return &Commit{SHA: sha} }
tests := []struct {
name string
st Status
nodes []string // "sha:roles"
links []Link // Below of every node but the last
}{
{
name: "in sync",
st: Status{Pinned: c("a"), CheckedOut: c("a"), Tip: c("a")},
nodes: []string{"a:pin,tip,you"},
},
{
name: "pin behind tip, checkout on pin",
st: Status{Pinned: c("p"), CheckedOut: c("p"), Tip: c("t"), Pin: PinInfo{Behind: 4}},
nodes: []string{"t:tip", "p:pin,you"},
links: []Link{{Above: 4}},
},
{
name: "pin on tip, checkout behind",
st: Status{Pinned: c("p"), CheckedOut: c("y"), Tip: c("p"), Drift: Distance{Behind: 2}},
nodes: []string{"p:pin,tip", "y:you"},
links: []Link{{Above: 2}},
},
{
name: "all apart",
st: Status{Pinned: c("p"), CheckedOut: c("y"), Tip: c("t"), Pin: PinInfo{Behind: 4}, Drift: Distance{Behind: 2}},
nodes: []string{"t:tip", "p:pin", "y:you"},
links: []Link{{Above: 4}, {Above: 2}},
},
{
name: "checkout ahead of the pin",
st: Status{Pinned: c("p"), CheckedOut: c("y"), Tip: c("t"), Pin: PinInfo{Behind: 5}, Drift: Distance{Ahead: 2}},
nodes: []string{"t:tip", "y:you", "p:pin"},
links: []Link{{Above: 3}, {Above: 2}},
},
{
name: "checkout on the tip",
st: Status{Pinned: c("p"), CheckedOut: c("t"), Tip: c("t"), Pin: PinInfo{Behind: 3}, Drift: Distance{Ahead: 3}},
nodes: []string{"t:tip,you", "p:pin"},
links: []Link{{Above: 3}},
},
{
name: "pin ahead of its ref",
st: Status{Pinned: c("p"), CheckedOut: c("p"), Tip: c("t"), Pin: PinInfo{Ahead: 1}},
nodes: []string{"p:pin,you", "t:tip"},
links: []Link{{Above: 1}},
},
{
name: "pin diverged from tip",
st: Status{Pinned: c("p"), CheckedOut: c("p"), Tip: c("t"), Pin: PinInfo{Ahead: 2, Behind: 3}},
nodes: []string{"t:tip", "p:pin,you"},
links: []Link{{Above: 3, Below: 2}},
},
{
name: "checkout diverged from pin",
st: Status{Pinned: c("p"), CheckedOut: c("y"), Tip: c("p"), Drift: Distance{Ahead: 1, Behind: 2}},
nodes: []string{"p:pin,tip", "y:you"},
links: []Link{{Above: 2, Below: 1}},
},
{
name: "staged pin above the committed one, checkout on the staged pin",
st: Status{Pinned: c("s"), CheckedOut: c("s"), Tip: c("s"), PinStaged: true,
Committed: c("p"), CommittedDrift: Distance{Behind: 3}},
nodes: []string{"s:staged,tip,you", "p:pin"},
links: []Link{{Above: 3}},
},
{
name: "checkout stayed on the committed pin",
st: Status{Pinned: c("s"), CheckedOut: c("p"), Tip: c("t"), PinStaged: true, Pin: PinInfo{Behind: 1},
Committed: c("p"), CommittedDrift: Distance{Behind: 2}, Drift: Distance{Behind: 2}},
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: "pin on a branch ahead of its target: the target is below the pin",
st: Status{Pinned: c("p"), CheckedOut: c("p"), Tip: c("t"), Pin: PinInfo{Behind: 1},
TargetTip: c("m"), TargetDrift: Distance{Behind: 3}},
nodes: []string{"t:tip", "p:pin,you", "m:target"},
links: []Link{{Above: 1}, {Above: 3}},
},
{
name: "pin on a branch diverged from its target: a fork",
st: Status{Pinned: c("p"), CheckedOut: c("p"), Tip: c("t"), Pin: PinInfo{Behind: 1},
TargetTip: c("m"), TargetDrift: Distance{Ahead: 12, Behind: 52}, Bases: Bases{Target: c("b")}},
nodes: []string{"m:target", "t:tip", "p:pin,you", "b:base"},
links: []Link{{Above: 12}, {Above: 1}, {Above: 52}},
},
{
name: "no tip, checkout not loaded",
st: Status{Pinned: c("p")},
nodes: []string{"p:pin"},
},
{
name: "superproject",
st: Status{Root: true, Pinned: c("h"), CheckedOut: c("h"), Tip: c("t"), Pin: PinInfo{Behind: 2}},
nodes: []string{"t:tip", "h:head"},
links: []Link{{Above: 2}},
},
}
words := map[Role]string{RolePin: "pin", RoleStaged: "staged", RoleHead: "head", RoleTip: "tip", RoleTarget: "target", RoleYou: "you", RoleBase: "base"}
for _, tt := range tests {
var nodes []string
var links []Link
spine := tt.st.Spine()
for i, n := range spine {
s := n.Commit.SHA + ":"
for j, r := range n.Roles {
if j > 0 {
s += ","
}
s += words[r]
}
nodes = append(nodes, s)
if i < len(spine)-1 {
links = append(links, n.Below)
}
}
if !slices.Equal(nodes, tt.nodes) {
t.Errorf("%s: nodes %v, want %v", tt.name, nodes, tt.nodes)
}
if !slices.Equal(links, tt.links) {
t.Errorf("%s: links %v, want %v", tt.name, links, tt.links)
}
}
}
func TestSpineNodeInSync(t *testing.T) {
tests := []struct {
roles []Role
want bool
}{
{[]Role{RolePin, RoleTip, RoleYou}, true},
{[]Role{RoleHead, RoleTip}, true},
{[]Role{RolePin, RoleTip}, false},
{[]Role{RolePin, RoleYou}, false},
{[]Role{RoleTip}, false},
}
for _, tt := range tests {
if got := (SpineNode{Roles: tt.roles}).InSync(); got != tt.want {
t.Errorf("%v: InSync = %v, want %v", tt.roles, got, tt.want)
}
}
}
+27 -7
View File
@@ -1,10 +1,13 @@
package submodule
import "fmt"
// Level is how much attention an issue deserves.
type Level int
const (
LevelQuiet Level = iota // nothing to do
LevelInfo // good to know
LevelWarn // look at it
LevelDanger // act now
)
@@ -13,11 +16,12 @@ const (
type IssueKind int
const (
IssueMissing IssueKind = iota // not initialized or directory missing
IssueUnpushed // pin not reachable from any remote ref
IssueConflict // merge conflict in the worktree
IssueOffPin // checkout differs from pin
IssueChanged // uncommitted changes or untracked files
IssueMissing IssueKind = iota // not initialized or directory missing
IssueUnpushed // pin not reachable from any remote ref
IssueConflict // merge conflict in the worktree
IssueOffPin // checkout differs from pin
IssueChanged // uncommitted changes or untracked files
IssueOffTarget // pin pushed, but not on the target branch
)
// Issue is one problem of a submodule. Detail distinguishes the states that
@@ -61,15 +65,29 @@ func (s *Status) Issues() []Issue {
case WorktreeUntracked:
out = append(out, Issue{IssueChanged, LevelWarn, s.untrackedText()})
}
// a pin that is pushed, but not on the target (usually main); an
// unpushed pin is not on it either, but has the worse issue
if !s.Root && s.Pin.Pushed && s.Pin.Target != "" && s.Pin.Ref != s.Pin.Target {
out = append(out, Issue{IssueOffTarget, LevelInfo, s.offTargetText()})
}
return out
}
// offTargetText says which branch the pin is not on and where it is instead.
func (s *Status) offTargetText() string {
text := fmt.Sprintf("pin not on %s, on %s", s.Pin.Target, s.Pin.Ref)
if n := len(s.Pin.Branches); n > 0 {
text += fmt.Sprintf(" +%d", n)
}
return text
}
// Hidden counts issues below a submodule per level, plus staged pins (which
// are not issues).
type Hidden struct{ Warn, Danger, Staged int }
type Hidden struct{ Info, Warn, Danger, Staged int }
// Total is the number of issues of all levels; staged pins are not counted.
func (h Hidden) Total() int { return h.Warn + h.Danger }
func (h Hidden) Total() int { return h.Info + h.Warn + h.Danger }
// HiddenIssues counts the issues and staged pins of all loaded descendants. A
// descendant whose load failed counts as one danger, since its state is
@@ -93,6 +111,8 @@ func HiddenIn(mods []*Submodule) Hidden {
h.Danger++
case LevelWarn:
h.Warn++
case LevelInfo:
h.Info++
}
}
})
+23
View File
@@ -29,6 +29,12 @@ func TestIssues(t *testing.T) {
{"untracked is changed", &Status{Worktree: WorktreeUntracked, Pin: pushed}, []IssueKind{IssueChanged}},
{"danger first", &Status{Checkout: CheckoutDiffers, Worktree: WorktreeModified}, []IssueKind{IssueUnpushed, IssueOffPin, IssueChanged}},
{"conflict", &Status{Worktree: WorktreeConflict, Pin: pushed}, []IssueKind{IssueConflict}},
{"pushed, but not on the target", &Status{Pin: PinInfo{Pushed: true, Ref: "origin/feat", Target: "origin/main", Source: RefFromContains}}, []IssueKind{IssueOffTarget}},
{"off target comes last", &Status{Checkout: CheckoutDiffers, Pin: PinInfo{Pushed: true, Ref: "origin/feat", Target: "origin/main"}}, []IssueKind{IssueOffPin, IssueOffTarget}},
{"unpushed is not also off target", &Status{Pin: PinInfo{Ref: "origin/main", Target: "origin/main", Ahead: 1}}, []IssueKind{IssueUnpushed}},
{"on the target, ahead of nothing else", &Status{Pin: PinInfo{Pushed: true, Ref: "origin/main", Target: "origin/main", Behind: 4}}, nil},
{"no target, no judgement", &Status{Pin: PinInfo{Pushed: true, Ref: "origin/feat", Source: RefFromContains}}, nil},
{"the superproject has no target", &Status{Root: true, Pin: PinInfo{Pushed: true, Ref: "origin/feat", Target: "origin/main"}}, nil},
}
for _, tt := range tests {
if got := kinds(tt.s.Issues()); !slices.Equal(got, tt.want) {
@@ -37,6 +43,23 @@ func TestIssues(t *testing.T) {
}
}
func TestOffTargetText(t *testing.T) {
s := &Status{Pin: PinInfo{Target: "origin/marco-dev", Ref: "origin/fix/x"}}
for _, c := range []struct {
branches []string
want string
}{
{nil, "pin not on origin/marco-dev, on origin/fix/x"},
{[]string{"origin/main"}, "pin not on origin/marco-dev, on origin/fix/x +1"},
{[]string{"origin/main", "origin/y"}, "pin not on origin/marco-dev, on origin/fix/x +2"},
} {
s.Pin.Branches = c.branches
if got := s.offTargetText(); got != c.want {
t.Errorf("%v: %q, want %q", c.branches, got, c.want)
}
}
}
func TestHiddenIssues(t *testing.T) {
ok := &Status{Pin: PinInfo{Pushed: true}}
dirty := &Status{Worktree: WorktreeModified, Pin: PinInfo{Pushed: true}, PinStaged: true}
+25 -7
View File
@@ -5,7 +5,6 @@ package tui
import (
"context"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
@@ -15,6 +14,7 @@ import (
zone "github.com/lrstanley/bubblezone"
"submarine/internal/submodule"
"submarine/internal/tui/branches"
"submarine/internal/tui/detail"
"submarine/internal/tui/footer"
"submarine/internal/tui/header"
@@ -56,8 +56,10 @@ type Model struct {
// exactly then. The input is focused while typing and blurred after enter.
filtering bool
restore *restoreState // the restore in progress, nil if none
notice string // an error to show above the footer until the next key
restore *restoreState // the restore or switch in progress, nil if none
picker *branches.Model // the branch picker, nil if closed
pickerPath string // the repository the picker is for
notice string // an error to show above the footer until the next key
width, height int
showDetail bool
@@ -148,7 +150,7 @@ func (m *Model) startFetch() tea.Cmd {
paths = append(paths, s.Path)
}
})
m.fetch = header.Fetch{Running: true, Total: len(paths), At: m.fetch.At}
m.fetch = header.Fetch{Running: true, Total: len(paths)}
cmds := []tea.Cmd{m.tick()}
for _, p := range paths {
f, ctx := m.fetcher, m.ctx
@@ -159,7 +161,7 @@ func (m *Model) startFetch() tea.Cmd {
// busy reports whether something shows a spinner.
func (m Model) busy() bool {
if m.repo == nil || m.fetch.Running || (m.restore != nil && (m.restore.phase == restoreChecking || m.restore.phase == restoreRunning)) {
if m.repo == nil || m.fetch.Running || (m.picker != nil && m.picker.Loading()) || (m.restore != nil && (m.restore.phase == restoreChecking || m.restore.phase == restoreRunning)) {
return true
}
loading := m.repo.Root != nil && m.repo.Root.Status == nil && m.repo.Root.Err == nil
@@ -205,6 +207,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case changeMsg:
return m.changed(msg)
case branchesMsg:
return m.branchesLoaded(msg)
case lossMsg:
return m.lossChecked(msg)
@@ -221,7 +226,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if m.fetch.Done == m.fetch.Total {
m.fetch.Running = false
m.fetch.At = time.Now()
}
return m, nil
@@ -237,6 +241,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, cmd
case tea.MouseMsg:
if m.picker != nil {
return m.mousePicker(msg)
}
m.tree.Update(msg)
return m, nil
@@ -331,6 +338,9 @@ func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) {
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
k := m.keys
if m.picker != nil {
return m.pickBranch(msg)
}
if m.restore != nil {
switch m.restore.phase {
case restoreChoosing:
@@ -400,6 +410,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, m.filter.Focus()
case key.Matches(msg, k.Restore):
return m.startRestore()
case key.Matches(msg, k.Switch):
return m.startSwitch()
case key.Matches(msg, k.Issues):
m.tree.SetProblemsOnly(!m.tree.ProblemsOnly())
m.layout()
@@ -547,5 +559,11 @@ func (m Model) View() string {
} else if m.tree.ProblemsOnly() {
parts = append(parts, m.problemsView())
}
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
view := zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
if m.picker != nil { // after zone.Scan, so no zone marker is cut
pop := m.picker.View(m.width, m.height)
x, y := m.picker.Origin(m.width, m.height)
view = overlay(view, pop, x, y, theme.Fg(m.theme.Backdrop))
}
return view
}
+252
View File
@@ -0,0 +1,252 @@
// Package branches is the branch picker: a search line above a list of
// branches. It holds the state and renders itself; the app decides what the
// keys do.
package branches
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
)
const (
maxWidth = 72
minWidth = 36
maxRows = 14
frameRows = 6 // border, title, search, rule, hint, border
)
// Model is the picker. The zero value is not usable, use New.
type Model struct {
theme theme.Theme
title string
input textinput.Model
all []submodule.Branch
shown []int // indexes into all that match the search
cursor int // index into shown
offset int // first visible index into shown
rows int // visible list rows
loading bool
err error
spinner string
w, h int // size of the popup as last rendered, for hit tests
}
// New returns a picker in the loading state.
func New(t theme.Theme, title string) *Model {
in := textinput.New()
in.Prompt = "/ "
in.PromptStyle = theme.Fg(t.Primary)
in.Cursor.Style = theme.Fg(t.Primary)
in.Placeholder = "filter branches"
in.PlaceholderStyle = theme.Fg(t.Muted)
in.Focus()
return &Model{theme: t, title: title, input: in, loading: true, rows: maxRows}
}
// SetBranches fills the list and ends the loading state.
func (m *Model) SetBranches(bs []submodule.Branch) {
m.all, m.loading, m.err = bs, false, nil
m.refilter()
}
// SetErr shows err instead of the list.
func (m *Model) SetErr(err error) { m.loading, m.err = false, err }
// SetSpinner sets the spinner frame shown while loading.
func (m *Model) SetSpinner(s string) { m.spinner = s }
// Loading reports whether the list is still being read.
func (m *Model) Loading() bool { return m.loading }
// Selected returns the branch under the cursor, nil if none matches.
func (m *Model) Selected() *submodule.Branch {
if m.cursor < 0 || m.cursor >= len(m.shown) {
return nil
}
b := m.all[m.shown[m.cursor]]
return &b
}
// Title is what the picker switches, e.g. "src/app".
func (m *Model) Title() string { return m.title }
// Search is the text in the search line.
func (m *Model) Search() string { return m.input.Value() }
func (m *Model) Up() { m.move(-1) }
func (m *Model) Down() { m.move(1) }
func (m *Model) PageUp() { m.move(-max(m.rows-1, 1)) }
func (m *Model) PageDown() { m.move(max(m.rows-1, 1)) }
func (m *Model) move(d int) {
m.cursor = max(min(m.cursor+d, len(m.shown)-1), 0)
m.scroll()
}
func (m *Model) scroll() {
if m.cursor < m.offset {
m.offset = m.cursor
}
if m.cursor >= m.offset+m.rows {
m.offset = m.cursor - m.rows + 1
}
m.offset = max(min(m.offset, len(m.shown)-m.rows), 0)
}
// Update edits the search line. The cursor stays on the same branch if it
// still matches.
func (m *Model) Update(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
before := m.input.Value()
m.input, cmd = m.input.Update(msg)
if m.input.Value() != before {
m.refilter()
}
return cmd
}
// refilter recomputes shown: every word of the search must be in the name.
func (m *Model) refilter() {
var keep string
if b := m.Selected(); b != nil {
keep = b.Ref
}
words := strings.Fields(strings.ToLower(m.input.Value()))
m.shown = m.shown[:0]
m.cursor = 0
for i, b := range m.all {
name := strings.ToLower(b.Name)
ok := true
for _, w := range words {
ok = ok && strings.Contains(name, w)
}
if !ok {
continue
}
if b.Ref == keep {
m.cursor = len(m.shown)
}
m.shown = append(m.shown, i)
}
m.offset = 0
m.scroll()
}
// View renders the popup for a terminal of the given size.
func (m *Model) View(termWidth, termHeight int) string {
width := max(min(maxWidth, termWidth-4), minWidth)
width = min(width, termWidth)
inner := max(width-4, 1) // border and padding
// the list is as tall as the branches, so filtering doesn't resize it
m.rows = max(min(max(len(m.all), 3), maxRows, termHeight-frameRows-2), 1)
m.scroll()
m.input.Width = max(inner-3, 1)
muted := theme.Fg(m.theme.Muted)
lines := []string{
theme.Fg(m.theme.Primary).Bold(true).Render(ansi.Truncate("Switch "+m.title, inner, "…")),
m.input.View(),
muted.Render(strings.Repeat(m.theme.Glyphs.Rule, inner)),
}
var body []string
switch {
case m.loading:
body = []string{theme.Fg(m.theme.Primary).Render(m.spinner) + " " + muted.Render("reading branches…")}
case m.err != nil:
body = []string{theme.Fg(m.theme.Danger).Render(ansi.Truncate(m.err.Error(), inner, "…"))}
case len(m.shown) == 0 && len(m.all) == 0:
body = []string{muted.Render("no branches")}
case len(m.shown) == 0:
body = []string{muted.Render("no branch matches")}
default:
end := min(m.offset+m.rows, len(m.shown))
for i := m.offset; i < end; i++ {
body = append(body, m.row(m.all[m.shown[i]], i == m.cursor, inner))
}
}
for len(body) < m.rows {
body = append(body, "")
}
lines = append(lines, body[:m.rows]...)
lines = append(lines, muted.Render(ansi.Truncate("↑↓ select · enter switch · esc cancel", inner, "…")))
out := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(m.theme.Border).
Padding(0, 1).
Render(strings.Join(lines, "\n"))
m.w, m.h = lipgloss.Width(out), lipgloss.Height(out)
return out
}
// Origin is the top-left cell of the popup, centered in a terminal of the
// given size. Valid after View.
func (m *Model) Origin(termWidth, termHeight int) (x, y int) {
return max((termWidth-m.w)/2, 0), max((termHeight-m.h)/2, 0)
}
// listTop is the line of the popup (border, title, search, rule) where the
// list starts.
const listTop = 4
// Click handles a left click at cell (x, y) of a terminal of the given size.
// inside reports whether the click was within the popup. If it hit a branch,
// that branch is selected and hit is true.
func (m *Model) Click(x, y, termWidth, termHeight int) (inside, hit bool) {
ox, oy := m.Origin(termWidth, termHeight)
x, y = x-ox, y-oy
if x < 0 || y < 0 || x >= m.w || y >= m.h {
return false, false
}
row := y - listTop
if m.loading || m.err != nil || x == 0 || x == m.w-1 || row < 0 || row >= m.rows || m.offset+row >= len(m.shown) {
return true, false
}
m.cursor = m.offset + row
return true, true
}
// row renders one branch: the name, right-aligned the short SHA and age.
func (m *Model) row(b submodule.Branch, selected bool, width int) string {
base := lipgloss.NewStyle()
if selected {
base = base.Background(m.theme.Selected)
}
marker := " "
if b.Current {
marker = "*"
}
name, prefix := b.Name, ""
if b.Remote {
if remote, rest, ok := strings.Cut(b.Name, "/"); ok {
prefix, name = remote+"/", rest
}
}
right := base.Foreground(m.theme.Muted).Render(short(b.SHA) + " " + theme.Ago(b.When))
room := max(width-lipgloss.Width(right)-3, 1)
nameStyle := base.Foreground(m.theme.Text)
if b.Current {
nameStyle = nameStyle.Bold(true)
}
left := base.Foreground(m.theme.Primary).Render(marker) + base.Render(" ") +
base.Foreground(m.theme.Muted).Render(prefix) + nameStyle.Render(name)
left = ansi.Truncate(left, room, "…")
gap := max(width-lipgloss.Width(left)-lipgloss.Width(right), 1)
return left + base.Render(strings.Repeat(" ", gap)) + right
}
func short(sha string) string {
if len(sha) > 7 {
return sha[:7]
}
return sha
}
+170
View File
@@ -0,0 +1,170 @@
package branches
import (
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
)
func list() []submodule.Branch {
return []submodule.Branch{
{Ref: "refs/heads/main", Name: "main", Local: "main", Current: true, SHA: "aaaaaaaaaa"},
{Ref: "refs/heads/dev", Name: "dev", Local: "dev", SHA: "bbbbbbbbbb"},
{Ref: "refs/remotes/origin/main", Name: "origin/main", Local: "main", Remote: true, SHA: "aaaaaaaaaa"},
{Ref: "refs/remotes/origin/feat/search", Name: "origin/feat/search", Local: "feat/search", Remote: true, SHA: "cccccccccc"},
}
}
func typed(m *Model, s string) {
for _, r := range s {
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
}
}
func name(m *Model) string {
if b := m.Selected(); b != nil {
return b.Name
}
return ""
}
func TestSelectionMovesAndClamps(t *testing.T) {
m := New(theme.Default(), "src/app")
m.SetBranches(list())
if name(m) != "main" {
t.Fatalf("first = %q", name(m))
}
m.Up()
if name(m) != "main" {
t.Errorf("up at the top = %q", name(m))
}
m.Down()
m.Down()
if name(m) != "origin/main" {
t.Errorf("down twice = %q", name(m))
}
m.PageDown()
if name(m) != "origin/feat/search" {
t.Errorf("page down = %q, want the last", name(m))
}
m.Down()
if name(m) != "origin/feat/search" {
t.Errorf("down at the end = %q", name(m))
}
}
func TestSearchFiltersAndAllWordsMustMatch(t *testing.T) {
m := New(theme.Default(), "src/app")
m.SetBranches(list())
typed(m, "ORIGIN")
if len(m.shown) != 2 {
t.Errorf("origin matches %d, want 2", len(m.shown))
}
typed(m, " sea")
if len(m.shown) != 1 || name(m) != "origin/feat/search" {
t.Errorf("shown = %d, selected %q", len(m.shown), name(m))
}
typed(m, "zzz")
if m.Selected() != nil {
t.Errorf("no match must select nothing, got %q", name(m))
}
if !strings.Contains(m.View(100, 30), "no branch matches") {
t.Error("view does not say that nothing matches")
}
}
func TestSelectionStaysOnTheSameBranchWhenItStillMatches(t *testing.T) {
m := New(theme.Default(), "src/app")
m.SetBranches(list())
m.Down()
m.Down() // origin/main
typed(m, "main")
if name(m) != "origin/main" {
t.Errorf("selected %q, want origin/main kept", name(m))
}
}
func TestViewStates(t *testing.T) {
m := New(theme.Default(), "src/app")
if v := m.View(100, 30); !strings.Contains(v, "reading branches") || !strings.Contains(v, "Switch src/app") {
t.Errorf("loading view:\n%s", v)
}
m.SetErr(errors.New("not initialized; restore it first"))
if v := m.View(100, 30); !strings.Contains(v, "not initialized") {
t.Errorf("error view:\n%s", v)
}
m.SetBranches(nil)
if v := m.View(100, 30); !strings.Contains(v, "no branches") {
t.Errorf("empty view:\n%s", v)
}
m.SetBranches(list())
v := m.View(100, 30)
for _, want := range []string{"main", "origin/", "feat/search", "aaaaaaa", "enter switch"} {
if !strings.Contains(v, want) {
t.Errorf("view lacks %q:\n%s", want, v)
}
}
}
func TestListScrollsInASmallTerminal(t *testing.T) {
var many []submodule.Branch
for i := 0; i < 30; i++ {
n := string(rune('a'+i%26)) + string(rune('a'+i/26))
many = append(many, submodule.Branch{Ref: "refs/heads/" + n, Name: n, Local: n, SHA: "abcdef0123"})
}
m := New(theme.Default(), "x")
m.SetBranches(many)
m.View(80, 14) // fewer rows than branches
for i := 0; i < 20; i++ {
m.Down()
}
if m.cursor < m.offset || m.cursor >= m.offset+m.rows {
t.Errorf("cursor %d outside the window [%d,%d)", m.cursor, m.offset, m.offset+m.rows)
}
}
func TestClickSelectsTheBranchUnderTheMouse(t *testing.T) {
m := New(theme.Default(), "src/app")
m.SetBranches(list())
m.View(100, 30)
x, y := m.Origin(100, 30)
if inside, hit := m.Click(x+3, y+listTop+1, 100, 30); !inside || !hit || name(m) != "dev" {
t.Errorf("click on the 2nd row: inside=%v hit=%v selected %q", inside, hit, name(m))
}
if inside, hit := m.Click(x+3, y+listTop+3, 100, 30); !inside || !hit || name(m) != "origin/feat/search" {
t.Errorf("click on the 4th row: inside=%v hit=%v selected %q", inside, hit, name(m))
}
for _, c := range []struct {
name string
x, y int
}{
{"title", x + 3, y + 1},
{"search line", x + 3, y + 2},
{"hint line", x + 3, y + m.h - 2},
{"left border", x, y + listTop},
} {
before := name(m)
if inside, hit := m.Click(c.x, c.y, 100, 30); !inside || hit || name(m) != before {
t.Errorf("click on the %s: inside=%v hit=%v selected %q", c.name, inside, hit, name(m))
}
}
if inside, hit := m.Click(0, 0, 100, 30); inside || hit {
t.Errorf("click outside: inside=%v hit=%v", inside, hit)
}
}
func TestClickBelowTheLastBranchHitsNothing(t *testing.T) {
m := New(theme.Default(), "x")
m.SetBranches(list()[:1]) // 1 branch, the list is padded to 3 rows
m.View(100, 30)
x, y := m.Origin(100, 30)
if inside, hit := m.Click(x+3, y+listTop+2, 100, 30); !inside || hit {
t.Errorf("inside=%v hit=%v on an empty row", inside, hit)
}
}
+120 -63
View File
@@ -4,6 +4,7 @@ package detail
import (
"fmt"
"path"
"slices"
"strings"
"github.com/charmbracelet/bubbles/help"
@@ -15,7 +16,7 @@ import (
"submarine/internal/tui/theme"
)
const labelWidth = 10
const labelWidth = 11
type Model struct {
theme theme.Theme
@@ -100,6 +101,14 @@ func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
}
st := s.Status
// the age of the last fetch is right-aligned in the name's line
if st.Presence == submodule.PresenceOK {
fetched := "fetched " + theme.Ago(st.FetchedAt)
if gap := m.width - ansi.StringWidth(s.Name) - len(fetched); gap >= 2 {
out[1] += strings.Repeat(" ", gap) + muted.Render(fetched)
}
}
issues := st.Issues()
for _, is := range issues {
out = append(out, t.LevelStyle(is.Level, lipgloss.NewStyle()).Width(labelWidth).Render(theme.IssueWord(is.Kind))+text.Render(is.Detail))
@@ -123,56 +132,15 @@ func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
}
out = append(out, "")
// ref
// ref and spine
if st.Pin.Ref != "" {
delta := t.Delta(st.Pin, lipgloss.NewStyle())
if delta != "" {
delta = " " + delta
}
out = append(out,
label("Ref")+text.Render(st.Pin.Ref)+delta,
label("")+muted.Render("via "+st.Pin.Source.String()),
)
}
if st.Presence == submodule.PresenceOK {
out = append(out, label("Fetched")+muted.Render(theme.Ago(st.FetchedAt)))
out = append(out, label("Ref")+text.Render(st.Pin.Ref)+muted.Render(" · via "+st.Pin.Source.String()), "")
}
out = append(out, m.spineLines(st.Spine(), st.Checkout == submodule.CheckoutDiffers, st.Pin.Target)...)
if s.FetchErr != nil {
out = append(out, theme.Fg(t.Warn).Width(labelWidth).Render("fetch")+text.Render("failed: "+s.FetchErr.Error()))
}
// commits
commitLine := func(name string, c *submodule.Commit, highlight bool) string {
if c == nil {
return label(name) + muted.Render("—")
}
shaStyle := theme.Fg(t.Primary)
if highlight {
shaStyle = theme.Fg(t.Warn)
}
return label(name) + shaStyle.Render(c.SHA) + " " + text.Render(c.Subject)
}
if st.Root {
out = append(out, commitLine("HEAD", st.Pinned, false), commitLine("Tip", st.Tip, false))
} else {
out = append(out,
commitLine("Pinned", st.Pinned, false),
commitLine("Checkout", st.CheckedOut, st.Checkout == submodule.CheckoutDiffers),
commitLine("Tip", st.Tip, false),
)
}
// difference between pin and tip
if len(st.Between) > 0 {
out = append(out, "", text.Render(m.betweenTitle(st.Pin, st.Root)))
for _, c := range st.Between {
out = append(out,
" "+theme.Fg(t.Primary).Render(c.SHA)+" "+text.Render(c.Subject),
" "+muted.Render(fmt.Sprintf("%7s %s · %s", "", c.Author, theme.Ago(c.When))),
)
}
}
// children & meta
out = append(out, "")
if root {
@@ -207,31 +175,120 @@ func (m Model) hiddenParts(h submodule.Hidden) string {
if h.Warn > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn)))
}
if h.Info > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelInfo, lipgloss.NewStyle()).Render(fmt.Sprintf("%d note(s)", h.Info)))
}
if h.Staged > 0 {
parts = append(parts, theme.Fg(t.Info).Render(fmt.Sprintf("%d %s", h.Staged, theme.Staged)))
}
return strings.Join(parts, theme.Fg(t.Muted).Render(", "))
}
// betweenTitle words the commit list; root says HEAD instead of Pin.
func (m Model) betweenTitle(p submodule.PinInfo, root bool) string {
subject := "Pin"
if root {
subject = "HEAD"
}
plural := func(n int) string {
if n == 1 {
return "commit"
// spineLines draws the pin, the tip and the checkout as one line of history,
// newest on top: a node per commit with its roles in the label column, and
// between two nodes how many commits separate them. offPin colors the
// checkout's own node.
func (m Model) spineLines(nodes []submodule.SpineNode, offPin bool, target string) []string {
t := m.theme
muted, text := theme.Fg(t.Muted), theme.Fg(t.Text)
// the roles of a node, each word in its own style: the checkout stands
// out from the rest, and a staged pin is blue
labels := make([]string, len(nodes))
labelWidths := make([]int, len(nodes))
width := labelWidth
for i, n := range nodes {
var plain, styled []string
for _, r := range n.Roles {
word := theme.RoleWord(r, target)
style := muted
switch {
case r == submodule.RoleStaged:
style = theme.Fg(t.Info)
case r == submodule.RoleYou && offPin && len(n.Roles) == 1:
style = theme.Fg(t.Warn)
case r == submodule.RoleYou:
style = text.Bold(true)
}
plain = append(plain, word)
styled = append(styled, style.Render(word))
}
return "commits"
labels[i] = strings.Join(styled, muted.Render(" · "))
labelWidths[i] = ansi.StringWidth(strings.Join(plain, " · "))
if n.InSync() {
labels[i], labelWidths[i] = muted.Render(theme.InSync), len(theme.InSync)
}
width = max(width, labelWidths[i]+1)
}
switch {
case p.Ahead > 0 && p.Behind > 0:
return fmt.Sprintf("%s and %s have diverged (%s%d %s%d):",
subject, p.Ref, m.theme.Glyphs.Ahead, p.Ahead, m.theme.Glyphs.Behind, p.Behind)
case p.Ahead > 0:
return fmt.Sprintf("%s is %d %s ahead of %s:", subject, p.Ahead, plural(p.Ahead), p.Ref)
default:
return fmt.Sprintf("%s is %d %s behind %s:", subject, p.Behind, plural(p.Behind), p.Ref)
// a staged pin below the committed one moves the pin back
hasRole := func(n submodule.SpineNode, r submodule.Role) bool { return slices.Contains(n.Roles, r) }
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 {
sha := theme.Fg(t.Primary)
if offPin && len(n.Roles) == 1 && n.Roles[0] == submodule.RoleYou {
sha = theme.Fg(t.Warn)
}
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, labels[i]+strings.Repeat(" ", width-labelWidths[i])+cells+" "+sha.Render(n.Commit.SHA)+" "+text.Render(n.Commit.Subject))
if i < len(nodes)-1 {
// 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)
}
}
return out
}
// linkText words the history between two nodes, with a leading space; empty
// if it is unknown.
func (m Model) linkText(l submodule.Link) string {
g := m.theme.Glyphs
switch {
case l.Diverged():
return fmt.Sprintf(" diverged %s%d %s%d", g.Ahead, l.Below, g.Behind, l.Above)
case l.Above == 1:
return " 1 commit"
case l.Above > 1:
return fmt.Sprintf(" %d commits", l.Above)
}
return ""
}
+127
View File
@@ -0,0 +1,127 @@
package detail
import (
"fmt"
"slices"
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
)
func TestSpineLines(t *testing.T) {
m := New(theme.Default(), nil)
st := submodule.Status{
Pinned: &submodule.Commit{SHA: "9ac01de", Subject: "Add node"},
CheckedOut: &submodule.Commit{SHA: "48005ac", Subject: "Merge branch"},
Tip: &submodule.Commit{SHA: "3f1bf20", Subject: "Fix deps"},
Pin: submodule.PinInfo{Behind: 4},
Drift: submodule.Distance{Behind: 1},
}
var got []string
for _, l := range m.spineLines(st.Spine(), true, "") {
got = append(got, ansi.Strip(l))
}
g := theme.Default().Glyphs
want := []string{
lab("tip") + g.Node + " 3f1bf20 Fix deps",
gap + g.Gap + " 4 commits",
lab("pin") + g.Node + " 9ac01de Add node",
gap + g.Gap + " 1 commit",
lab("you") + g.Node + " 48005ac Merge branch",
}
if !slices.Equal(got, want) {
t.Errorf("got\n%q\nwant\n%q", got, want)
}
}
func TestSpineLinesStagedMovesBack(t *testing.T) {
m := New(theme.Default(), nil)
st := submodule.Status{
Pinned: &submodule.Commit{SHA: "87a1059", Subject: "refactor"},
CheckedOut: &submodule.Commit{SHA: "87a1059", Subject: "refactor"},
PinStaged: true,
Committed: &submodule.Commit{SHA: "8f23a6e", Subject: "Merge"},
CommittedDrift: submodule.Distance{Ahead: 15},
}
var got []string
for _, l := range m.spineLines(st.Spine(), false, "") {
got = append(got, ansi.Strip(l))
}
g := theme.Default().Glyphs
want := []string{
"pin " + g.Node + " 8f23a6e Merge",
" " + g.Gap + " 15 commits (staged pin moves back)",
"staged · you " + g.Node + " 87a1059 refactor",
}
if !slices.Equal(got, want) {
t.Errorf("got\n%q\nwant\n%q", got, want)
}
}
func TestSpineLinesInSync(t *testing.T) {
m := New(theme.Default(), nil)
c := &submodule.Commit{SHA: "3f1bf20", Subject: "Fix deps"}
st := submodule.Status{Pinned: c, CheckedOut: c, Tip: c}
lines := m.spineLines(st.Spine(), false, "")
if len(lines) != 1 {
t.Fatalf("got %d lines, want 1", len(lines))
}
want := lab("in sync") + theme.Default().Glyphs.Node + " 3f1bf20 Fix deps"
if got := ansi.Strip(lines[0]); got != want {
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{
lab("tip") + " " + g.Node + " e1f2a3b Drop Go 1.21",
gap + " " + g.Gap + " 2 commits",
lab("pin · you") + g.Node + " " + g.Gap + " c4d5e6f Experimental",
gap + g.Gap + " " + g.Gap + " 1 commit",
lab("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)
}
}
// lab pads a label like the label column; gap is the empty label column.
func lab(s string) string { return fmt.Sprintf("%-*s", labelWidth, s) }
var gap = lab("")
func TestSpineLinesTargetName(t *testing.T) {
m := New(theme.Default(), nil)
c := func(sha string) *submodule.Commit { return &submodule.Commit{SHA: sha, Subject: "s"} }
st := submodule.Status{
Pinned: c("p"), CheckedOut: c("p"), Tip: c("t"), Pin: submodule.PinInfo{Behind: 1},
TargetTip: c("m"), TargetDrift: submodule.Distance{Behind: 3},
}
for target, want := range map[string]string{
"origin/marco-dev": "marco-dev",
"origin/feature/a-very-long-branch-name": "feature/a-very-…",
"": "target",
} {
lines := m.spineLines(st.Spine(), false, target)
last := ansi.Strip(lines[len(lines)-1])
if !strings.HasPrefix(last, want) {
t.Errorf("target %q: last line %q, want label %q", target, last, want)
}
}
}
+25 -28
View File
@@ -3,9 +3,10 @@ package header
import (
"fmt"
"time"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
@@ -14,10 +15,9 @@ import (
// Fetch is the progress of the application's own background fetch.
type Fetch struct {
Running bool
Done int // repositories fetched (or failed)
Total int // repositories to fetch
Failed int // failed fetches of the last run
At time.Time // when the last run finished
Done int // repositories fetched (or failed)
Total int // repositories to fetch
Failed int // failed fetches of the last run
}
// View renders repo info, state counts and fetch state on one line. spinner
@@ -65,47 +65,44 @@ func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, widt
}
})
// most important first: errors, issues by severity, staged pins, then the
// total
sep := seg(t.Muted, " · ")
mid := seg(t.Muted, fmt.Sprintf("%d submodules", total))
var parts []string
if errored > 0 {
mid += sep + t.LevelStyle(submodule.LevelDanger, bar).Render(fmt.Sprintf("%d error", errored))
parts = append(parts, 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,
submodule.IssueOffPin, submodule.IssueChanged, submodule.IssueOffTarget,
} {
if n := perKind[k]; n > 0 {
mid += sep + t.LevelStyle(level[k], bar).Render(fmt.Sprintf("%d %s", n, theme.IssueWord(k)))
parts = append(parts, t.LevelStyle(level[k], bar).Render(fmt.Sprintf("%d %s", n, theme.IssueWord(k))))
}
}
if staged > 0 {
mid += sep + seg(t.Info, fmt.Sprintf("%d pin staged", staged))
parts = append(parts, seg(t.Info, fmt.Sprintf("%d pin staged", staged)))
}
if loading > 0 {
mid += sep + seg(t.Muted, fmt.Sprintf("%d loading", loading))
parts = append(parts, seg(t.Muted, fmt.Sprintf("%d loading", loading)))
}
parts = append(parts, seg(t.Muted, fmt.Sprintf("%d submodules", total)))
mid := strings.Join(parts, sep)
var right string
// the summary and the fetch state form one group at the right edge
right := mid
switch {
case fetch.Running:
right = seg(t.Primary, spinner) + seg(t.Muted, fmt.Sprintf(" fetching %d/%d ", fetch.Done, fetch.Total))
default:
// our own fetch may have finished before the watcher saw FETCH_HEAD
at := repo.FetchedAt
if fetch.At.After(at) {
at = fetch.At
}
right = seg(t.Muted, "fetched "+theme.Ago(at))
if fetch.Failed > 0 {
right += seg(t.Muted, " · ") + seg(t.Warn, fmt.Sprintf("%d failed", fetch.Failed))
}
right += seg(t.Muted, " ")
right += sep + seg(t.Primary, spinner) + seg(t.Muted, fmt.Sprintf(" fetching %d/%d", fetch.Done, fetch.Total))
case fetch.Failed > 0:
right += sep + seg(t.Warn, fmt.Sprintf("%d failed", fetch.Failed))
}
right += seg(t.Muted, " ")
gap := width - lipgloss.Width(left) - lipgloss.Width(mid) - lipgloss.Width(right)
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
if gap < 4 {
return bar.Width(width).MaxWidth(width).Render(left + seg(t.Text, " ") + mid)
// too narrow: the least important part, on the right, is cut
return bar.Width(width).Render(ansi.Truncate(left+seg(t.Text, " ")+mid, width, "…"))
}
sp := func(n int) string { return bar.Render(fmt.Sprintf("%*s", n, "")) }
return left + sp(gap/2) + mid + sp(gap-gap/2) + right
return left + bar.Render(fmt.Sprintf("%*s", gap, "")) + right
}
+5 -3
View File
@@ -7,7 +7,8 @@ type keyMap struct {
CollapseAll, ExpandAll key.Binding
Home, End, PageUp, PageDown key.Binding
Fetch, Reload, Filter, Issues key.Binding
Copy, Restore, Help, Quit key.Binding
Copy, Restore, Switch key.Binding
Help, Quit key.Binding
}
func defaultKeys() keyMap {
@@ -28,6 +29,7 @@ func defaultKeys() keyMap {
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")),
Switch: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")),
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")),
@@ -44,7 +46,7 @@ func (k keyMap) Navigation() []key.Binding {
// 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, k.Restore} }
func (k keyMap) Submodule() []key.Binding { return []key.Binding{k.Copy, k.Switch, k.Restore} }
// Global returns the bindings that don't depend on the selection.
func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} }
@@ -58,7 +60,7 @@ 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.Copy, k.Restore},
{k.Copy, k.Switch, k.Restore},
{k.Fetch, k.Reload, k.Filter, k.Issues},
{k.Help, k.Quit},
}
+33
View File
@@ -0,0 +1,33 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
// overlay draws fg over bg with its top-left cell at (x, y). bg must be the
// final string (after zone.Scan): its lines are cut at the popup's edges.
// The backdrop loses its colors and is drawn in dim, so the popup stands out.
func overlay(bg, fg string, x, y int, dim lipgloss.Style) string {
lines := strings.Split(bg, "\n")
for i, l := range lines {
lines[i] = dim.Render(ansi.Strip(l))
}
pop := strings.Split(fg, "\n")
pw := lipgloss.Width(fg)
left := x
for i, p := range pop {
row := y + i
if row >= len(lines) {
break
}
line := lines[row]
if lw := ansi.StringWidth(line); lw < left {
line += strings.Repeat(" ", left-lw)
}
lines[row] = ansi.Truncate(line, left, "") + "\x1b[0m" + p + "\x1b[0m" + ansi.TruncateLeft(line, left+pw, "")
}
return strings.Join(lines, "\n")
}
+36
View File
@@ -0,0 +1,36 @@
package tui
import (
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
func TestOverlayCentersThePopup(t *testing.T) {
bg := strings.Repeat(strings.Repeat(".", 10)+"\n", 4) + strings.Repeat(".", 10)
got := strings.Split(ansi.Strip(overlay(bg, "ab\ncd", 4, 1, lipgloss.NewStyle())), "\n")
want := []string{"..........", "....ab....", "....cd....", "..........", ".........."}
if len(got) != len(want) {
t.Fatalf("got %d lines", len(got))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("line %d = %q, want %q", i, got[i], want[i])
}
}
}
func TestOverlayDimsTheBackdrop(t *testing.T) {
bg := "\x1b[31mredredred\x1b[0m"
dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
got := overlay(bg, "X", 4, 0, dim)
if ansi.StringWidth(got) != 9 || ansi.Strip(got) != "redrXdred" {
t.Errorf("got %q", ansi.Strip(got))
}
if strings.Contains(got, "\x1b[31m") {
t.Errorf("the backdrop keeps its colors: %q", got)
}
}
+65 -20
View File
@@ -27,6 +27,7 @@ type restoreState struct {
phase restorePhase
to submodule.RestoreTarget
loss submodule.Loss
branch *submodule.Branch // set for a branch switch, which reuses this flow
}
// lossMsg is the result of the check before a restore.
@@ -37,8 +38,9 @@ type lossMsg struct {
}
type restoreDoneMsg struct {
path string
err error
path string
branch string // the branch a switch ended on, "" if detached
err error
}
// startRestore checks what restoring the selection would discard. If nothing
@@ -75,10 +77,15 @@ func (m Model) startRestore() (tea.Model, tea.Cmd) {
func (m Model) checkRestore() (tea.Model, tea.Cmd) {
m.restore.phase = restoreChecking
m.layout()
r := m.provider.(submodule.Restorer)
ctx, path, to := m.ctx, m.restore.path, m.restore.to
ctx, path, to, b := m.ctx, m.restore.path, m.restore.to, m.restore.branch
check := func() tea.Msg {
loss, err := r.RestoreLoss(ctx, path, to)
var loss submodule.Loss
var err error
if b != nil {
loss, err = m.provider.(submodule.Switcher).SwitchLoss(ctx, path, *b)
} else {
loss, err = m.provider.(submodule.Restorer).RestoreLoss(ctx, path, to)
}
return lossMsg{path, loss, err}
}
return m, tea.Batch(check, m.tick())
@@ -139,40 +146,78 @@ func (m Model) answerRestore(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m Model) runRestore() (tea.Model, tea.Cmd) {
m.restore.phase = restoreRunning
m.layout()
r := m.provider.(submodule.Restorer)
ctx, path, to := m.ctx, m.restore.path, m.restore.to
run := func() tea.Msg { return restoreDoneMsg{path, r.Restore(ctx, path, to)} }
ctx, path, to, b := m.ctx, m.restore.path, m.restore.to, m.restore.branch
run := func() tea.Msg {
if b != nil {
branch, err := m.provider.(submodule.Switcher).Switch(ctx, path, *b)
return restoreDoneMsg{path, branch, err}
}
return restoreDoneMsg{path, "", m.provider.(submodule.Restorer).Restore(ctx, path, to)}
}
return m, tea.Batch(run, m.tick())
}
// restoreDone shows the result and reloads everything: the structure may have
// changed.
func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
name := "the superproject"
if m.restore != nil {
name = m.restore.name + m.restore.toText()
}
r := m.restore
m.restore = nil
var cmds []tea.Cmd
if msg.err != nil {
m.notice = "restore of " + name + " failed: " + msg.err.Error()
} else {
cmds = append(cmds, m.footer.Flash("restored "+name))
switch {
case r != nil && r.branch != nil && msg.err != nil:
m.notice = "switch of " + r.name + " to " + r.branch.Name + " failed: " + msg.err.Error()
case r != nil && r.branch != nil:
to := r.branch.Name
if msg.branch != "" {
to = msg.branch
} else if r.branch.Remote {
to += " (detached: local " + r.branch.Local + " is elsewhere)"
}
cmds = append(cmds, m.footer.Flash("switched "+r.name+" to "+to))
default:
name := "the superproject"
if r != nil {
name = r.name + r.toText()
}
if msg.err != nil {
m.notice = "restore of " + name + " failed: " + msg.err.Error()
} else {
cmds = append(cmds, m.footer.Flash("restored "+name))
}
}
cmds = append(cmds, m.reloadTree(true))
m.layout()
return m, tea.Batch(cmds...)
}
// toText is " to the committed pin" if that was chosen; the staged pin is the
// toText is " to origin/feat" for a switch, " to the committed pin" if that was chosen; the staged pin is the
// usual target and needs no words.
func (r *restoreState) toText() string {
if r.branch != nil {
return " to " + r.branch.Name
}
if r.to == submodule.RestoreCommitted {
return " to the committed pin"
}
return ""
}
// verb and verbIng word the operation: a switch is shown like a restore, but
// names its branch.
func (r *restoreState) verb() string {
if r.branch != nil {
return "Switch"
}
return "Restore"
}
func (r *restoreState) verbIng() string {
if r.branch != nil {
return "switching"
}
return "restoring"
}
func restoreName(s *submodule.Submodule) string {
if s == nil || s.IsRoot() {
return "the superproject"
@@ -194,11 +239,11 @@ func (m Model) restoreView() string {
case restoreChecking:
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Muted).Render("checking "+r.name+"…")
case restoreRunning:
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Warn).Render("restoring "+r.name+r.toText()+"…")
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Warn).Render(r.verbIng()+" "+r.name+r.toText()+"…")
case restoreAsking:
left = theme.Fg(m.theme.Warn).Render("Restore "+r.name+r.toText()+"?") + " " +
left = theme.Fg(m.theme.Warn).Render(r.verb()+" "+r.name+r.toText()+"?") + " " +
theme.Fg(m.theme.Danger).Render("Discards "+lossText(r.loss)+".")
right = theme.Fg(m.theme.Text).Bold(true).Render("y") + theme.Fg(m.theme.Muted).Render(" restore · ") +
right = theme.Fg(m.theme.Text).Bold(true).Render("y") + theme.Fg(m.theme.Muted).Render(" "+strings.ToLower(r.verb())+" · ") +
theme.Fg(m.theme.Text).Bold(true).Render("n") + theme.Fg(m.theme.Muted).Render(" cancel")
}
return m.line(left, right)
+1 -1
View File
@@ -92,7 +92,7 @@ func newRestoreModel(t *testing.T, loss submodule.Loss) (tea.Model, *fakeRestore
func feed(m tea.Model, cmd tea.Cmd) tea.Model {
for _, msg := range run(cmd) {
switch msg.(type) {
case lossMsg, restoreDoneMsg:
case lossMsg, restoreDoneMsg, branchesMsg:
m, _ = m.Update(msg)
}
}
+125
View File
@@ -0,0 +1,125 @@
package tui
import (
tea "github.com/charmbracelet/bubbletea"
"submarine/internal/submodule"
"submarine/internal/tui/branches"
)
// branchesMsg is the branch list for the picker of path.
type branchesMsg struct {
path string
branches []submodule.Branch
err error
}
// startSwitch opens the branch picker for the selection and reads its
// branches.
func (m Model) startSwitch() (tea.Model, tea.Cmd) {
s := m.tree.Selected()
sw, ok := m.provider.(submodule.Switcher)
var flash string
switch {
case s == nil:
return m, nil
case !ok:
flash = "this data source can't switch branches"
case m.restore != nil:
flash = "wait for the restore of " + m.restore.name + " to finish"
case !s.IsRoot() && s.Status != nil && s.Status.Presence != submodule.PresenceOK:
flash = s.Path + " is not initialized; restore it first"
}
if flash != "" {
cmd := m.footer.Flash(flash)
m.layout()
return m, cmd
}
m.picker, m.pickerPath = branches.New(m.theme, restoreName(s)), s.Path
m.picker.SetSpinner(m.spinner.View())
ctx, path := m.ctx, s.Path
load := func() tea.Msg {
bs, err := sw.Branches(ctx, path)
return branchesMsg{path, bs, err}
}
return m, tea.Batch(load, m.tick())
}
// branchesLoaded fills the picker, unless it was closed or is for another
// repository meanwhile.
func (m Model) branchesLoaded(msg branchesMsg) (tea.Model, tea.Cmd) {
if m.picker == nil || m.pickerPath != msg.path {
return m, nil
}
if msg.err != nil {
m.picker.SetErr(msg.err)
} else {
m.picker.SetBranches(msg.branches)
}
return m, nil
}
// pickBranch handles a key while the picker is open: arrows move, enter
// switches, esc closes, everything else edits the search.
func (m Model) pickBranch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyEsc:
m.picker = nil
case tea.KeyUp:
m.picker.Up()
case tea.KeyDown:
m.picker.Down()
case tea.KeyPgUp:
m.picker.PageUp()
case tea.KeyPgDown:
m.picker.PageDown()
case tea.KeyEnter:
return m.confirmPick()
default:
return m, m.picker.Update(msg)
}
return m, nil
}
// confirmPick closes the picker and switches to the selected branch, with
// the same check and question as a restore.
func (m Model) confirmPick() (tea.Model, tea.Cmd) {
b := m.picker.Selected()
if b == nil {
return m, nil
}
s := m.byPath[m.pickerPath]
m.picker = nil
if s == nil || m.restore != nil {
return m, nil
}
m.restore = &restoreState{path: s.Path, name: restoreName(s), branch: b}
return m.checkRestore()
}
// mousePicker handles the mouse while the picker is open: the wheel moves
// the selection, a click on a branch switches to it, a click outside the
// popup closes it.
func (m Model) mousePicker(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
switch msg.Button {
case tea.MouseButtonWheelUp:
m.picker.Up()
case tea.MouseButtonWheelDown:
m.picker.Down()
case tea.MouseButtonLeft:
if msg.Action != tea.MouseActionPress {
return m, nil
}
inside, hit := m.picker.Click(msg.X, msg.Y, m.width, m.height)
switch {
case hit:
return m.confirmPick()
case !inside:
m.picker = nil
}
}
return m, nil
}
+224
View File
@@ -0,0 +1,224 @@
package tui
import (
"context"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"submarine/internal/submodule"
"submarine/internal/submodule/mock"
)
// fakeSwitcher is the mock data with a branch switch that records its calls.
type fakeSwitcher struct {
submodule.Provider
loss submodule.Loss
branches []submodule.Branch
switched []string // path + " " + ref
ends string // the branch Switch reports
}
func (f *fakeSwitcher) Branches(context.Context, string) ([]submodule.Branch, error) {
return f.branches, nil
}
func (f *fakeSwitcher) SwitchLoss(context.Context, string, submodule.Branch) (submodule.Loss, error) {
return f.loss, nil
}
func (f *fakeSwitcher) Switch(_ context.Context, path string, b submodule.Branch) (string, error) {
f.switched = append(f.switched, path+" "+b.Ref)
return f.ends, nil
}
func newSwitchModel(t *testing.T, loss submodule.Loss) (tea.Model, *fakeSwitcher) {
t.Helper()
f := &fakeSwitcher{
Provider: mock.New(),
loss: loss,
ends: "feat",
branches: []submodule.Branch{
{Ref: "refs/heads/main", Name: "main", Local: "main", Current: true, SHA: "aaaaaaa"},
{Ref: "refs/remotes/origin/feat", Name: "origin/feat", Local: "feat", Remote: true, SHA: "bbbbbbb"},
},
}
var m tea.Model = New(context.Background(), f)
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
repo, err := f.Tree(context.Background())
if err != nil {
t.Fatal(err)
}
m, _ = m.Update(treeLoadedMsg{repo: repo})
return m, f
}
// openPicker presses b and delivers the branch list.
func openPicker(t *testing.T, m tea.Model) tea.Model {
t.Helper()
m, cmd := press(m, "b")
m = feed(m, cmd)
if m.(Model).picker == nil {
t.Fatal("the picker did not open")
}
return m
}
func pressKey(m tea.Model, t tea.KeyType) (tea.Model, tea.Cmd) {
return m.Update(tea.KeyMsg{Type: t})
}
// settle delivers the loss check and, if it ran at once, the switch.
func settle(m tea.Model, cmd tea.Cmd) tea.Model {
var next tea.Cmd
for _, msg := range run(cmd) {
if l, ok := msg.(lossMsg); ok {
m, next = m.Update(l)
}
}
return feed(m, next)
}
func TestSwitchOpensThePickerWithTheBranches(t *testing.T) {
m, _ := newSwitchModel(t, submodule.Loss{})
m = openPicker(t, m)
v := m.View()
for _, want := range []string{"Switch the superproject", "main", "origin/", "feat"} {
if !strings.Contains(v, want) {
t.Errorf("view lacks %q", want)
}
}
}
func TestSwitchTypingFiltersAndEnterSwitchesWithoutLoss(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
m = openPicker(t, m)
m, _ = press(m, "fe") // j and k would be text, not navigation
m, cmd := pressKey(m, tea.KeyEnter)
if m.(Model).picker != nil {
t.Error("enter must close the picker")
}
m = settle(m, cmd)
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
t.Fatalf("switched %q", f.switched)
}
if m.(Model).restore != nil {
t.Error("switch still in progress")
}
}
func TestSwitchArrowsSelect(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
m = openPicker(t, m)
m, _ = pressKey(m, tea.KeyDown)
m, cmd := pressKey(m, tea.KeyEnter)
settle(m, cmd)
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
t.Fatalf("switched %q, want the second branch", f.switched)
}
}
func TestSwitchAsksWhenSomethingIsLost(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{Files: 2, Modules: 1})
m = openPicker(t, m)
m, cmd := pressKey(m, tea.KeyEnter)
var next tea.Cmd
for _, msg := range run(cmd) {
if l, ok := msg.(lossMsg); ok {
m, next = m.Update(l)
}
}
r := m.(Model).restore
if next != nil || r == nil || r.phase != restoreAsking {
t.Fatalf("want a question, got %+v", r)
}
if v := m.View(); !strings.Contains(v, "Switch the superproject to main?") || !strings.Contains(v, "y switch") {
t.Errorf("question not shown:\n%s", v)
}
if c, _ := press(m, "n"); c.(Model).restore != nil || len(f.switched) != 0 {
t.Fatal("n must cancel")
}
m, cmd = press(m, "y")
feed(m, cmd)
if len(f.switched) != 1 {
t.Fatalf("switched %q", f.switched)
}
}
func TestSwitchEscClosesThePicker(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
m = openPicker(t, m)
m, _ = pressKey(m, tea.KeyEsc)
if m.(Model).picker != nil || len(f.switched) != 0 {
t.Fatal("esc must close without switching")
}
}
func TestSwitchWithoutSwitcherSaysSo(t *testing.T) {
var m tea.Model = New(context.Background(), mock.New())
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
repo, _ := mock.New().Tree(context.Background())
m, _ = m.Update(treeLoadedMsg{repo: repo})
m, _ = press(m, "b")
if m.(Model).picker != nil {
t.Fatal("the mock can't switch")
}
if !strings.Contains(m.View(), "can't switch branches") {
t.Error("no flash")
}
}
func TestSwitchReportsADetachedHead(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
f.ends = ""
m = openPicker(t, m)
m, _ = pressKey(m, tea.KeyDown)
m, cmd := pressKey(m, tea.KeyEnter)
m = settle(m, cmd)
if v := m.View(); !strings.Contains(v, "detached") {
t.Errorf("flash lacks the detached note:\n%s", v)
}
}
// clickRow clicks the i-th visible branch of the open picker.
func clickRow(m tea.Model, i int) (tea.Model, tea.Cmd) {
mm := m.(Model)
x, y := mm.picker.Origin(mm.width, mm.height)
return m.Update(tea.MouseMsg{X: x + 3, Y: y + 4 + i, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
}
func TestSwitchClickOnABranchSwitches(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
m = openPicker(t, m)
m.View() // the popup learns where it is drawn
m, cmd := clickRow(m, 1)
if m.(Model).picker != nil {
t.Error("the click must close the picker")
}
settle(m, cmd)
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
t.Fatalf("switched %q, want the clicked branch", f.switched)
}
}
func TestSwitchClickOutsideClosesAndWheelMoves(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
m = openPicker(t, m)
m.View()
m, _ = m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress})
if b := m.(Model).picker.Selected(); b == nil || b.Name != "origin/feat" {
t.Errorf("wheel down selected %v", b)
}
// a click that releases or sits inside the popup's frame does nothing
mm := m.(Model)
x, y := mm.picker.Origin(mm.width, mm.height)
m, _ = m.Update(tea.MouseMsg{X: x + 3, Y: y + 1, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
if m.(Model).picker == nil {
t.Fatal("a click on the title must keep the popup open")
}
m, _ = m.Update(tea.MouseMsg{X: 0, Y: 0, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
if m.(Model).picker != nil || len(f.switched) != 0 {
t.Fatal("a click outside must close without switching")
}
}
+42
View File
@@ -2,9 +2,11 @@ package theme
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"submarine/internal/submodule"
)
@@ -27,11 +29,49 @@ func IssueWord(k submodule.IssueKind) string {
return "conflict"
case submodule.IssueOffPin:
return "off pin"
case submodule.IssueOffTarget:
return "off target"
default:
return "changed"
}
}
// maxTargetWidth limits the branch name that labels the target on the spine.
const maxTargetWidth = 16
// ShortRef is a remote ref without its remote, e.g. "marco-dev" for
// "origin/marco-dev", cut to maxTargetWidth with an ellipsis.
func ShortRef(ref string) string {
if _, name, ok := strings.Cut(ref, "/"); ok {
ref = name
}
return ansi.Truncate(ref, maxTargetWidth, "…")
}
// RoleWord is the label of a commit's role on the detail pane's spine; the
// target is labelled with the name of its branch.
func RoleWord(r submodule.Role, target string) string {
switch r {
case submodule.RolePin:
return "pin"
case submodule.RoleHead:
return "HEAD"
case submodule.RoleStaged:
return Staged
case submodule.RoleBase:
return "base"
case submodule.RoleTarget:
if target == "" {
return "target"
}
return ShortRef(target)
case submodule.RoleTip:
return "tip"
default:
return "you"
}
}
// StatusWord renders the state of s as one word: a spinner while loading,
// `error`, the most severe issue with "+n" for further ones, `staged`, or a
// muted "in sync". base is applied to every segment, e.g. to keep a row
@@ -100,6 +140,8 @@ func (t Theme) LevelStyle(l submodule.Level, base lipgloss.Style) lipgloss.Style
return base.Foreground(t.Danger).Bold(true)
case submodule.LevelWarn:
return base.Foreground(t.Warn)
case submodule.LevelInfo:
return base.Foreground(t.Info)
default:
return base.Foreground(t.Muted)
}
+8 -5
View File
@@ -8,8 +8,9 @@ import (
)
// Theme uses the 16 ANSI colors for everything that carries meaning, so
// the TUI follows the user's terminal color scheme. Only background shades,
// which have no ANSI equivalent, use adaptive 256-color values.
// the TUI follows the user's terminal color scheme. Only background shades
// and the dimmed backdrop, which have no ANSI equivalent, use adaptive
// 256-color values.
type Theme struct {
Primary lipgloss.TerminalColor // accent: branch names, commit hashes
Info lipgloss.TerminalColor
@@ -21,6 +22,7 @@ type Theme struct {
Border lipgloss.TerminalColor // pane separators, help separators
Bar lipgloss.TerminalColor // background of the header bar
Selected lipgloss.TerminalColor // background of the selected row
Backdrop lipgloss.TerminalColor // the screen behind a popup, dimmed
Glyphs Glyphs
}
@@ -40,6 +42,7 @@ func Default() Theme {
Border: lipgloss.Color("8"),
Bar: lipgloss.AdaptiveColor{Light: "254", Dark: "235"},
Selected: lipgloss.AdaptiveColor{Light: "253", Dark: "237"},
Backdrop: lipgloss.AdaptiveColor{Light: "252", Dark: "238"},
Glyphs: g,
}
}
@@ -51,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 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: "─",
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: "-",
Staged: "*", Ahead: "^", Behind: "v", Collapsed: ">", Expanded: "v", Problems: ".", Minus: "-", From: "<-", Rule: "-", Node: "o", Gap: ":", Join: "/",
}
}
+1 -1
View File
@@ -483,7 +483,7 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
for _, c := range []struct {
level submodule.Level
n int
}{{submodule.LevelDanger, h.Danger}, {submodule.LevelWarn, h.Warn}} {
}{{submodule.LevelDanger, h.Danger}, {submodule.LevelWarn, h.Warn}, {submodule.LevelInfo, h.Info}} {
if c.n == 0 {
continue
}
+3 -3
View File
@@ -44,7 +44,7 @@ upstream() {
mkdir -p "$root/remotes" "$root/work"
for r in core ui net store log feature nested work; do remote "$r"; done
# "feature" has a branch that main doesn't contain
# "feature" has a branch that main doesn't contain (a pin there is off target)
g "$root/work/feature" switch -c feat/search
commit "$root/work/feature" search "Add search"
commit "$root/work/feature" search "Tune search"
@@ -72,7 +72,7 @@ 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
# "archive" contains the pin of log like main (same tip): another branch with it
g "$root/work/log" push origin main:archive
super="$root/super"
@@ -113,7 +113,7 @@ 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
# -> off target: the pin is on a feature branch, not on the configured one
g "$super/libs/topic" fetch
g "$super/libs/topic" checkout origin/feat/search
g "$super" add libs/topic