Archived
Compare commits
7
Commits
v0.6.0
..
e8d366d88f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8d366d88f | ||
|
|
f287d6009f | ||
|
|
4c1be3f9ab | ||
|
|
aeedfe96d4 | ||
|
|
01a9babe64 | ||
|
|
648e1fd46c | ||
|
|
2b6b781826 |
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,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: "/",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user