Archived
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbd233f58d | ||
|
|
99f1ba2bec | ||
|
|
651b0c2e69 | ||
|
|
2aeebfe3b1 |
@@ -29,8 +29,9 @@ the questions you actually have:
|
||||
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.
|
||||
records (`x`), switch it to another branch (`b`), or move a pin that is
|
||||
behind to the tip of its branch (`u`). They ask first if work would be
|
||||
lost.
|
||||
- **Built for dozens to hundreds of submodules.** Each status loads
|
||||
asynchronously, so the UI never freezes.
|
||||
|
||||
@@ -103,8 +104,10 @@ clipboard, ready for `cd <paste>`. It works over SSH too (OSC 52; in tmux,
|
||||
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:
|
||||
ended, missing submodules cloned. Staged pins are kept. A submodule whose pin
|
||||
is a branch tip ends on that branch instead of a detached HEAD (a missing
|
||||
local branch is created from the remote one; no branch is ever moved). If
|
||||
something would be lost, you are asked first:
|
||||
|
||||
```
|
||||
Restore src/app? Discards 7 changed files in 3 repositories. y restore · n cancel
|
||||
@@ -131,11 +134,22 @@ the local and remote branches, newest first; type to filter.
|
||||
|
||||
`↑` `↓` 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
|
||||
branch. Picking `origin/feat` creates a local `feat` that tracks it. 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.
|
||||
|
||||
An existing local branch that is only behind the remote branch is
|
||||
fast-forwarded; one with commits of its own is never moved.
|
||||
|
||||
### Update a pin with `u`
|
||||
|
||||
On a submodule whose pin is behind its branch (`origin/main ↓2`), `u` moves
|
||||
the pin to that branch's tip: the same as picking `origin/main` with `b`,
|
||||
without the popup. On the header it fast-forwards the superproject's branch
|
||||
to its upstream (`main ↓1`), like `git pull --ff-only`, and restores the
|
||||
submodules. It takes exactly the commits shown and fetches nothing; press
|
||||
`f` first to see newer ones.
|
||||
|
||||
### Fetch and reload
|
||||
|
||||
`f` fetches all repositories again, `r` reloads everything after you edited
|
||||
@@ -153,6 +167,7 @@ files.
|
||||
| `/` | Filter |
|
||||
| `!` | Problems only |
|
||||
| `c` | Copy path |
|
||||
| `u` | Update: move a pin that is behind to the tip of its branch (staged, like `b`); on the header, fast-forward the superproject |
|
||||
| `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 |
|
||||
@@ -165,8 +180,8 @@ click outside to close it.
|
||||
|
||||
## Status
|
||||
|
||||
A prototype. Browsing is complete. Two write actions exist: restore (`x`)
|
||||
and switch branch (`b`). More (stage pin) are being specified in
|
||||
A prototype. Browsing is complete. Three write actions exist: restore (`x`),
|
||||
switch branch (`b`) and update pin (`u`), see
|
||||
[docs/actions.md](docs/actions.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
+5
-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) | the write actions restore and switch branch (done) and stage pin (draft) |
|
||||
| [actions.md](actions.md) | the write actions restore, switch branch and update pin |
|
||||
| [decisions.md](decisions.md) | Decision log with rationale (ADR style) |
|
||||
|
||||
## Install
|
||||
@@ -33,10 +33,11 @@ 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 and `b` switches its
|
||||
branch ([actions.md](actions.md)); stage pin is drafted there.
|
||||
submodules with problems. `x` restores the selection, `b` switches its
|
||||
branch and `u` updates a pin that is behind ([actions.md](actions.md)).
|
||||
|
||||
Next steps, roughly in order:
|
||||
|
||||
1. Stage pin, see [actions.md](actions.md). (Restore and switch branch are done.)
|
||||
1. (done) Write actions: restore, switch branch, update pin, see
|
||||
[actions.md](actions.md). Stage pin was dropped (D29).
|
||||
2. (done) "Problems only" (`!`) view, reusing the tree's filter.
|
||||
|
||||
+113
-61
@@ -1,8 +1,9 @@
|
||||
# Actions (draft)
|
||||
|
||||
**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).
|
||||
**Status:** [Restore](#restore) (key `x`, [D23](decisions.md)),
|
||||
[Switch branch](#switch-branch) (key `b`, [D24](decisions.md)) and
|
||||
[Update pin](#update-pin) (key `u`, [D29](decisions.md)) are implemented.
|
||||
Stage pin was dropped ([D29](decisions.md)).
|
||||
|
||||
## Principle
|
||||
|
||||
@@ -13,12 +14,12 @@ a difference in one of two directions:
|
||||
| Action | Direction | Key |
|
||||
|---|---|---|
|
||||
| [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` |
|
||||
| [Update pin](#update-pin) | Both: a switch to the tip of the pin's ref | `u` |
|
||||
|
||||
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`).
|
||||
There is nothing else for now. Committing and pushing are done outside
|
||||
submarine. Recording a commit made in a submodule is a switch to its branch
|
||||
(`b`); a commit on no branch must get one first.
|
||||
|
||||
## Restore
|
||||
|
||||
@@ -86,7 +87,8 @@ After a successful restore of repository R with target T:
|
||||
any depth.
|
||||
|
||||
So the subtree shows `in sync` everywhere, except for states restore does
|
||||
not change: `unpushed`, behind/ahead of the ref, and `detached HEAD on pin`.
|
||||
not change: `unpushed`, behind/ahead of the ref, and `detached HEAD on pin`
|
||||
where no branch is at the pin (see [Branches](#branches)).
|
||||
|
||||
The recursion follows **T**, not the tree loaded in the UI. A submodule that
|
||||
is only in T (for example, after switching branches) is initialized. A
|
||||
@@ -100,8 +102,25 @@ submodule that is only on disk is deleted as an untracked directory.
|
||||
the branch stays checked out.
|
||||
- **Submodule off the pin:** HEAD is detached at the target. The branch that
|
||||
was checked out is **not moved**, so its commits stay on it.
|
||||
- **Submodule detached at the target** (after the step above, or because it
|
||||
was already so, also when it was in sync): HEAD is put on a branch whose
|
||||
tip is the target, the first that fits of ([D28](decisions.md)):
|
||||
1. a local branch tracking the ref the UI shows (or named like it), e.g.
|
||||
local `main` tracking `origin/main`, also when the pin is behind or
|
||||
ahead of the ref;
|
||||
2. the ref itself, if its tip is the target and no local branch has its
|
||||
name: the local branch is created, tracking it;
|
||||
3. any other local branch, the first alphabetically;
|
||||
4. any other remote branch whose name no local branch has, created as a
|
||||
tracking branch; the ref's remote first, then alphabetically.
|
||||
|
||||
Restore never moves, creates or deletes a branch.
|
||||
If git refuses one (checked out in another worktree, a name conflict),
|
||||
the next is tried. If none fits, HEAD stays detached.
|
||||
|
||||
Restore never moves or deletes a branch. A local branch that is behind or
|
||||
ahead of its remote branch is no candidate and is not fast-forwarded. The
|
||||
only branches restore creates are tracking branches for remote branches at
|
||||
the target, in steps 2 and 4.
|
||||
|
||||
### What can be lost
|
||||
|
||||
@@ -187,6 +206,14 @@ submodule update --init --checkout --force -- <not yet initialized children>
|
||||
`--checkout` overrides `submodule.<name>.update`, and the explicit paths
|
||||
override `submodule.<name>.active`, so no child module is skipped.
|
||||
|
||||
For a submodule, after `submodule sync`, `attachBranch` puts a detached HEAD
|
||||
on a branch: `symbolic-ref -q HEAD` (on a branch: done), `for-each-ref
|
||||
--points-at=T refs/heads refs/remotes` (no branch at T: done), then the ref
|
||||
is resolved like `Status` does, and the first candidate (see
|
||||
[Branches](#branches)) that `switch` / `switch -c <local> --track <remote>`
|
||||
accepts wins. The branch checked out before the restore is passed to the
|
||||
ref resolution. A child's `.gitmodules` branch is read once per parent.
|
||||
|
||||
With `c` the parent's index gets the committed pin first (`update-index
|
||||
--cacheinfo` in the parent).
|
||||
|
||||
@@ -232,9 +259,14 @@ For a **submodule**, with the tip `T` the list showed:
|
||||
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`.
|
||||
- a remote branch `origin/feat`: the local branch is the one checked out
|
||||
before if it tracks `origin/feat` (e.g. `dev`), else `feat`. It is
|
||||
created, tracking it, if it does not exist; if it exists at `T` it is
|
||||
checked out; if it is only
|
||||
**behind** `T` (an ancestor of it) it is fast-forwarded to `T` and
|
||||
checked out ([D29](decisions.md)); if it has commits of its own, or is
|
||||
checked out in another worktree, 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
|
||||
@@ -253,7 +285,7 @@ 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
|
||||
`… to origin/feat (detached: local feat can't be fast-forwarded)`); failure
|
||||
`switch of src/app to origin/feat failed: …`, until the next key. One
|
||||
restore or switch runs at a time.
|
||||
|
||||
@@ -261,70 +293,80 @@ restore or switch runs at a time.
|
||||
|
||||
`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>`.
|
||||
restore); then `switch` / `switch -c <local> --track <remote-branch>`; a local branch
|
||||
behind `T` is first moved with `merge-base --is-ancestor <local> T`, a look
|
||||
at `worktree list --porcelain` and `update-ref refs/heads/<local> T <old>`
|
||||
(which fails if the branch moved meanwhile). The restore leaves HEAD of the
|
||||
switched repository alone (Switch puts it on the chosen branch); the
|
||||
submodules below it are attached as in a restore.
|
||||
`SwitchLoss` is `lossOf` with `T` as the target. `Branches` is one
|
||||
`for-each-ref`, without the `origin/HEAD` symref.
|
||||
|
||||
## Stage pin
|
||||
## Update pin
|
||||
|
||||
Records the commit checked out in a submodule as its new pin in the
|
||||
parent's index. It is the same as `git add <path>` in the parent, but only
|
||||
when it is safe.
|
||||
Moves the pin of a submodule to the tip of its ref, the branch the row shows
|
||||
(`origin/main ↓2`). It is a [switch](#switch-branch) to that branch, with the
|
||||
tip the row shows: nothing is fetched, so `u` gives exactly the commits the
|
||||
row counts. `f` is what brings newer ones.
|
||||
|
||||
On the **superproject** the pin is HEAD and the ref is the upstream of its
|
||||
branch (the header's `main ↓1`): `u` fast-forwards the branch to it, like
|
||||
`git pull --ff-only`, and restores the submodules to the pins of the new
|
||||
commit.
|
||||
|
||||
### Available when
|
||||
|
||||
All of:
|
||||
|
||||
- the selected node is a submodule, not the superproject;
|
||||
- it is `off pin` (`Checkout == CheckoutDiffers`): HEAD differs from the
|
||||
pin in the parent's index;
|
||||
- its worktree is **clean** (`Worktree == WorktreeClean`). Uncommitted
|
||||
changes, untracked files and conflicts count as dirty, the same rule as
|
||||
the `changed` issue. A moved nested pin counts as a change too, so nested
|
||||
submodules are staged bottom-up: first inside the submodule, then the
|
||||
submodule itself (after a commit in it, outside submarine).
|
||||
|
||||
Changes *inside* nested submodules don't count. Like the `changed` issue,
|
||||
staging only looks at the submodule's own worktree.
|
||||
- it is initialized and its state is loaded;
|
||||
- its ref is a remote branch (not a tag, a SHA or a local branch) and has a
|
||||
tip; for the superproject: its branch has an upstream;
|
||||
- the pin is **behind** the ref and not ahead (`Behind > 0`, `Ahead == 0`).
|
||||
A diverged pin (`↑n↓m`) is left to `b`: moving it would drop the pin's
|
||||
own commits from the record.
|
||||
|
||||
### Effect
|
||||
|
||||
- The parent's index records the submodule's HEAD as the gitlink of its
|
||||
path. Nothing else changes: no worktree, no other index entry, no commit.
|
||||
- The submodule then shows `staged` (or `●` next to an issue), and the
|
||||
`off pin` issue is gone.
|
||||
- If the checkout equals the pin in the parent's `HEAD`, staging removes
|
||||
the staged pin instead (the index equals `HEAD` again).
|
||||
- If the checked-out commit is not pushed, it is staged anyway. The row
|
||||
then shows `unpushed` (Danger), which is the warning.
|
||||
- There is no confirmation: nothing is lost.
|
||||
- Undo: restore the submodule and choose `c` (committed pin), see
|
||||
[Target](#target).
|
||||
The same as [switching](#switch-branch) to the ref: the tip is staged
|
||||
as the pin, the submodule and everything below it are restored to it, and
|
||||
HEAD is put on the local branch. A local branch that is only behind is
|
||||
fast-forwarded, so the common case (local `main` at the old pin) ends on
|
||||
`main`. A checked-out branch that tracks the ref under another name (`dev`
|
||||
tracking `origin/main`) is the one used, so the update stays on it. For the
|
||||
superproject staged pins are lost, as in a switch (and counted).
|
||||
|
||||
If the ref no longer points at the tip the row showed (a fetch meanwhile),
|
||||
nothing happens: `can't update src/app: origin/main moved; check the new
|
||||
state`. The watcher has updated the row by then; `u` again takes the new
|
||||
tip.
|
||||
|
||||
### Confirmation, lines, errors
|
||||
|
||||
As for switch: the question appears only if something would be lost
|
||||
(`Update src/app to origin/main? Discards … y update · n cancel`). Lines
|
||||
above the footer: `checking src/app…`, `updating src/app to origin/main…`;
|
||||
flash `updated src/app to main`; failure `update of src/app to origin/main
|
||||
failed: …`.
|
||||
|
||||
### Implementation note
|
||||
|
||||
```sh
|
||||
# in the submodule
|
||||
git rev-parse HEAD # must still be the SHA shown in the UI, else abort
|
||||
# in the parent
|
||||
git update-index --cacheinfo 160000,<sha>,<path>
|
||||
```
|
||||
|
||||
`update-index` stages exactly the SHA that was checked, independent of the
|
||||
worktree. Flash: `staged pin of src/app` on success; git's message on
|
||||
failure.
|
||||
The UI only: `startPinUpdate` (`internal/tui/pinupdate.go`) reads the
|
||||
branches (`Switcher.Branches`), takes the one named like `Pin.Ref`, checks
|
||||
that its tip still starts with the abbreviated `Status.Tip.SHA`, and then
|
||||
runs the switch flow with it (`SwitchLoss`, `Switch`).
|
||||
|
||||
## Keys and palette
|
||||
|
||||
Both are submodule keys (`keyMap.Submodule`), shown in the detail pane's
|
||||
palette, or in the footer when the pane can't show it. Restore is always
|
||||
offered: it is idempotent, and the palette is static.
|
||||
All three are submodule keys (`keyMap.Submodule`), shown in the detail
|
||||
pane's palette, or in the footer when the pane can't show it. They are
|
||||
always offered, the palette is static: restore is idempotent, and `u` says
|
||||
why it can't update.
|
||||
|
||||
| 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` |
|
||||
| `u` update pin | [Update pin](#update-pin) | `this data source can't update pins`; `wait for the restore of … to finish`; `the superproject has no upstream branch`; `src/app is still loading`; `src/app is not initialized; restore it first`; `the pin of src/app is not on a branch`; `the pin of src/app has diverged from origin/main; b switches the branch`; `the pin of src/app is ahead of origin/main`; `src/app is up to date with origin/main` (the superproject without `the pin of`); after the check `can't update src/app: main is a local branch; b switches to it` |
|
||||
|
||||
`R` would sit next to `r` (reload), a dangerous mix-up for a destructive
|
||||
action, hence `x`.
|
||||
@@ -337,18 +379,30 @@ action, hence `x`.
|
||||
not moved, ignored files survive, a merge and a rebase in progress end, a
|
||||
standalone clone and a removed submodule's directory are deleted, a missing
|
||||
submodule is cloned again, `update = none` and inactive submodules are
|
||||
restored, an unavailable commit is reported. The loss is empty for off pin,
|
||||
restored, an unavailable commit is reported. A detached submodule ends on
|
||||
the local branch tracking the ref, else on the ref created as a tracking
|
||||
branch (before other branches at the pin), else on the first local
|
||||
branch; a local branch elsewhere is not moved; a checked-out branch stays;
|
||||
a branch of another worktree is skipped; nested submodules are attached
|
||||
too. The loss is empty for off pin,
|
||||
missing and unpushed submodules, counts files, nested changes, merges,
|
||||
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
|
||||
first; a remote pick stages the tip and creates a tracking branch; a
|
||||
local branch that is only behind is fast-forwarded; one with commits of
|
||||
its own, or checked out in another worktree, 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.
|
||||
- Update pin: UI (`pinupdate_test.go`): switches to the tip of the ref, asks
|
||||
with a loss, aborts if the ref moved, refuses (with its flash) a pin that
|
||||
is up to date, ahead, diverged, on a tag, on a local branch or not
|
||||
initialized; updates the superproject, not one without an upstream.
|
||||
Provider: the superproject's branch is fast-forwarded; a checked-out
|
||||
branch that tracks the remote branch under another name is kept.
|
||||
- By hand in tmux on a fixture, see
|
||||
[architecture.md](architecture.md#testing).
|
||||
|
||||
@@ -359,7 +413,5 @@ submodule is restored to the index gitlink, so a staged pin is its target and
|
||||
survives; restore may clone and fetch what is missing; the key is `x`; it
|
||||
asks only if something would be lost.
|
||||
|
||||
Open (stage pin):
|
||||
|
||||
1. **Key.** `s`?
|
||||
2. **Palette.** Show it always (like restore), or only while it applies?
|
||||
Open (update pin): updating several submodules at once (on the superproject
|
||||
or a collapsed parent), deliberately left out for now.
|
||||
|
||||
@@ -20,7 +20,7 @@ internal/submodule/git/ provider using the git CLI (reads, fetch, watch)
|
||||
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)
|
||||
switch.go Branches, SwitchLoss, Switch (stage the tip, restore, attach or fast-forward 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
|
||||
@@ -28,6 +28,7 @@ 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
|
||||
pinupdate.go update pin (u): a switch to the pin's ref, at the tip the row shows
|
||||
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
|
||||
|
||||
+70
-2
@@ -327,7 +327,8 @@ the same for every submodule of the target tree, initializing missing ones.
|
||||
- *Ignored files stay* (`clean` without `-x`): `.env`, `node_modules` and
|
||||
IDE settings are not "additional files" worth deleting in every submodule.
|
||||
- *Branches never move.* A checkout that is not on the target is detached
|
||||
first, so commits stay on their branch.
|
||||
first, so commits stay on their branch. (A submodule is then put on a
|
||||
branch at the target if there is one, see D28.)
|
||||
- *It asks only if something is lost* (changed or untracked files,
|
||||
operations in progress, commits on no ref, standalone repositories).
|
||||
Never asking would delete repositories that exist nowhere else; always
|
||||
@@ -361,7 +362,7 @@ 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.
|
||||
- *Key `b`*, not `s`, which stays reserved for stage pin (dropped, D29).
|
||||
- *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).
|
||||
@@ -499,3 +500,70 @@ timeout, failures are silent), which supersedes the once-a-day rule of D21.
|
||||
N1 still holds: the TUI never waits for it. `q` changes meaning while an
|
||||
update is available, so muscle memory that exits with `q` now updates; the
|
||||
bar says so. Typing `q` in the filter or a popup is unchanged.
|
||||
|
||||
## D28 — Restore puts a submodule on a branch at its pin
|
||||
|
||||
**Context.** After a restore (for example after the superproject's branch
|
||||
changed) every submodule was a detached HEAD, so IDEs showed only hashes.
|
||||
**Decision.** Amends D23. After restoring a submodule, at any depth, a
|
||||
detached HEAD is put on a branch whose tip is the pin, in this order: (1) a
|
||||
local branch tracking the shown ref (or named like it), (2) the ref itself
|
||||
as a new local tracking branch, if no local branch has its name, (3) any
|
||||
other local branch, alphabetically, (4) any other remote branch whose name
|
||||
no local branch has, as a new tracking branch, the ref's remote first. A
|
||||
HEAD already on a branch stays there. If git refuses a branch (checked out
|
||||
in another worktree, a name conflict), the next one is tried; if none fits,
|
||||
HEAD stays detached and the restore still succeeds. Submodules that were in
|
||||
sync are included. The superproject is not affected.
|
||||
**Rationale.** Attaching a branch whose tip is the pin changes no commit and
|
||||
no file, so nothing can be lost and the loss question stays the same. The
|
||||
ref comes first because it is what the UI shows; a local branch beats a new
|
||||
one so restore adds as few branches as possible.
|
||||
**Alternatives.** Fast-forwarding a local branch that is behind its remote
|
||||
branch at the pin: rejected, restore still never moves a branch. Staying
|
||||
detached when several branches fit: rejected, the order above always gives
|
||||
one answer, the same on every run.
|
||||
**Consequences.** Restore now may create local branches (only for remote
|
||||
branches at the pin whose name has none). Per detached submodule it costs a
|
||||
`symbolic-ref` and one `for-each-ref --points-at`; only if a branch is at
|
||||
the pin, the ref is resolved (as for `Status`) and one `switch` runs. No
|
||||
network. `detached HEAD on pin` is now left only where no branch is at the
|
||||
pin. Switch keeps its own rule for the repository it switches; the
|
||||
submodules below it follow this one.
|
||||
|
||||
## D29 — Update pin instead of stage pin; switch fast-forwards a branch behind
|
||||
|
||||
**Context.** Stage pin (record the checkout as the pin) was drafted, but a
|
||||
commit made in a submodule is on a branch, and switching to that branch
|
||||
(`b`) already stages it. The common job is a different one: a pin that is
|
||||
behind its branch (`origin/main ↓2`) should move to the tip. With `b` that
|
||||
is a search in the popup, and it often ended detached, because the local
|
||||
`main` sat at the old pin and was never moved (D24).
|
||||
**Decision.** `u` updates the pin of a submodule to the tip of its ref, only
|
||||
when the pin is behind it and not ahead. It is a switch to that branch (D24)
|
||||
with the tip the row shows: nothing is fetched, and if the ref moved
|
||||
meanwhile nothing happens. Switch (and so `u`) now fast-forwards a local
|
||||
branch that is an ancestor of the tip, unless a worktree has it checked out;
|
||||
`update-ref` with the old tip makes the move fail if the branch moved. Stage
|
||||
pin is dropped.
|
||||
- *No fetch.* The user sees `↓2` and gets those two commits; a fetch first
|
||||
would update to something the screen never showed. `f` is separate.
|
||||
- *Only behind.* A diverged pin has commits the ref lacks; moving it would
|
||||
drop them from the record, so that is a deliberate `b`.
|
||||
- *The superproject too.* Its pin is HEAD and its ref the upstream, so `u`
|
||||
there is `git pull --ff-only` plus a restore of the submodules. Switch
|
||||
keeps a checked-out branch that tracks the chosen remote branch under
|
||||
another name (`dev` tracking `origin/main`), so an update never changes
|
||||
the branch.
|
||||
- *One at a time.* Updating several at once is left for later.
|
||||
- *Fast-forward only.* A local branch with commits of its own is never
|
||||
moved, as before. Restore (D23, D28) still never moves a branch: it goes
|
||||
back to the recorded pin, where moving branches has no place.
|
||||
**Alternatives.** Keeping stage pin: it covers only a commit on no branch,
|
||||
which then is `unpushed` anyway. `u` fetching first (a real pull): rejected,
|
||||
see above. Resetting a local branch with commits of its own: loses them from
|
||||
the branch.
|
||||
**Consequences.** Amends D24 ("not moved") and D28's note that switch keeps
|
||||
its own rule. No new provider interface: `u` is `Branches`, `SwitchLoss` and
|
||||
`Switch`. Since `Status.Tip.SHA` is abbreviated, the UI compares it as a
|
||||
prefix of the branch's full SHA.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 420 KiB After Width: | Height: | Size: 143 KiB |
@@ -145,7 +145,8 @@ commit hashes. It doesn't answer the questions users actually have:
|
||||
the header is selected): its worktree becomes exactly what git records, and
|
||||
so do all submodules below it, recursively. Staged pins are kept at every
|
||||
depth. For a submodule with a staged pin it asks whether to restore to the
|
||||
staged or the committed pin (the latter unstages it). It asks for
|
||||
staged or the committed pin (the latter unstages it). A restored submodule
|
||||
whose pin is a branch tip ends on that branch, not detached. 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
|
||||
@@ -155,7 +156,13 @@ commit hashes. It doesn't answer the questions users actually have:
|
||||
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).
|
||||
- **F59** `u` updates the pin of the selection to the tip of its ref, when
|
||||
the pin is only behind it (`origin/main ↓2`): a switch (**F57**) to that
|
||||
branch, with exactly the tip the row shows. For the superproject that is
|
||||
a fast-forward of its branch to its upstream (`git pull --ff-only`). Nothing is fetched; if
|
||||
the ref moved meanwhile, nothing happens. A local branch that is only
|
||||
behind is fast-forwarded, in a switch too. Otherwise the footer says why
|
||||
not. See [actions.md](actions.md#update-pin).
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
+15
-3
@@ -46,9 +46,10 @@ Keys come in three groups (`keyMap.Navigation`, `.Submodule`, `.Global`):
|
||||
release exists, `q quit and update to vX` follows them as the right-most
|
||||
item, on a highlighted background (`Theme.Highlight`: black on Primary), and
|
||||
plain quit is `Q` (see D27).
|
||||
- **Submodule** (act on the selected one, e.g. `c`): a palette on the last
|
||||
line of the detail pane. It is left out if the pane is shorter than 12
|
||||
lines. Whenever the palette isn't shown (pane hidden or too short), these
|
||||
- **Submodule** (act on the selected one, e.g. `c`): a palette at the
|
||||
bottom of the detail pane, wrapped onto a second line if the pane is too
|
||||
narrow for all keys (none is cut off). It is left out if the pane is
|
||||
shorter than 12 lines. Whenever the palette isn't shown (pane hidden or too short), these
|
||||
keys move into the footer after the navigation keys.
|
||||
|
||||
If the footer is too narrow, keys are dropped from the left group's end
|
||||
@@ -393,6 +394,16 @@ than `Muted`, so the popup has the focus.
|
||||
[restore](#restore): `checking`, the question `Switch src/app to
|
||||
origin/feat? Discards … y switch · n cancel`, `switching …`.
|
||||
|
||||
### Update pin
|
||||
|
||||
`u` runs without a popup, on the line above the footer like a
|
||||
[switch](#switch-branch): `checking src/app…`, the question `Update src/app
|
||||
to origin/main? Discards … y update · n cancel` only if something would be
|
||||
lost, `updating src/app to origin/main…`, then the flash `updated src/app to
|
||||
main`. On the header the name is `the superproject` (`updated the
|
||||
superproject to main`). Where it can't update, a flash says why (see
|
||||
[actions.md](actions.md#keys-and-palette)).
|
||||
|
||||
### Key reference
|
||||
|
||||
Every key in the UI, where it is shown. Update this
|
||||
@@ -410,6 +421,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 |
|
||||
| `u` | Update pin to the tip of its ref (superproject: fast-forward to upstream) | Submodule | Palette, Help |
|
||||
| `b` | Switch branch (popup) | Submodule | Palette, Help |
|
||||
| `x` | Restore | Submodule | Palette, Help |
|
||||
| `f` | Fetch all repositories | Global | Help |
|
||||
|
||||
@@ -327,17 +327,10 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
|
||||
return fmt.Errorf("pinned commit %.7s not fetched", pin)
|
||||
}
|
||||
|
||||
// remote branches containing the pin, oldest tip first
|
||||
out, err := run(ctx, in.dir, "for-each-ref", "--contains", pin, "--sort=committerdate", "--format=%(refname)", "refs/remotes")
|
||||
containing, err := remoteContaining(ctx, in.dir, pin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var containing []string
|
||||
for _, r := range strings.Fields(out) {
|
||||
if !strings.HasSuffix(r, "/HEAD") {
|
||||
containing = append(containing, strings.TrimPrefix(r, "refs/remotes/"))
|
||||
}
|
||||
}
|
||||
st.Pin.Pushed = len(containing) > 0
|
||||
|
||||
var ref string
|
||||
@@ -359,6 +352,7 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
|
||||
return err
|
||||
}
|
||||
st.CheckedOut = st.Pinned
|
||||
var out string
|
||||
if head != pin {
|
||||
if st.CheckedOut, err = commit(ctx, in.dir, head); err != nil {
|
||||
return err
|
||||
@@ -417,6 +411,22 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
|
||||
return nil
|
||||
}
|
||||
|
||||
// remoteContaining lists the remote branches containing pin, oldest tip
|
||||
// first, without the origin/HEAD symref.
|
||||
func remoteContaining(ctx context.Context, dir, pin string) ([]string, error) {
|
||||
out, err := run(ctx, dir, "for-each-ref", "--contains", pin, "--sort=committerdate", "--format=%(refname)", "refs/remotes")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var containing []string
|
||||
for _, r := range strings.Fields(out) {
|
||||
if !strings.HasSuffix(r, "/HEAD") {
|
||||
containing = append(containing, strings.TrimPrefix(r, "refs/remotes/"))
|
||||
}
|
||||
}
|
||||
return containing, nil
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -45,7 +47,7 @@ func (p *Provider) RestoreLoss(ctx context.Context, path string, to submodule.Re
|
||||
// submodule is cloned and missing commits are fetched; git never prompts.
|
||||
func (p *Provider) Restore(ctx context.Context, path string, to submodule.RestoreTarget) error {
|
||||
if path == "" {
|
||||
return p.restoreRepo(ctx, p.root, "HEAD")
|
||||
return p.restoreRepo(ctx, p.root, "HEAD", nil)
|
||||
}
|
||||
p.mu.RLock()
|
||||
in, ok := p.mods[path]
|
||||
@@ -67,7 +69,7 @@ func (p *Provider) Restore(ctx context.Context, path string, to submodule.Restor
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return p.restoreRepo(ctx, in.dir, pin)
|
||||
return p.restoreRepo(ctx, in.dir, pin, &in)
|
||||
}
|
||||
|
||||
// restoreTarget returns the directory and the commit path is restored to:
|
||||
@@ -123,7 +125,9 @@ func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, er
|
||||
// or "HEAD") and then every submodule its index records. Branches are never
|
||||
// moved: a checkout that is not on target is detached first. If the checkout
|
||||
// stays on target, its staged pins are kept and are the submodules' targets.
|
||||
func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
||||
// For a submodule (in is not nil) a detached HEAD is then put on a branch at
|
||||
// target if there is one, see attachBranch; the submodules below always are.
|
||||
func (p *Provider) restoreRepo(ctx context.Context, dir, target string, in *info) error {
|
||||
env := p.networkEnv()
|
||||
g := func(args ...string) (string, error) {
|
||||
return p.exec(ctx, dir, env, append(append([]string{}, restoreArgs...), args...)...)
|
||||
@@ -138,6 +142,7 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
||||
if gitDir, _, err := gitDirs(dir); err == nil {
|
||||
p.clearOperations(ctx, dir, gitDir)
|
||||
}
|
||||
prev, _, _ := try(ctx, dir, "symbolic-ref", "--short", "-q", "HEAD") // for the ref, see attachBranch
|
||||
var keep []gitlink
|
||||
if head, err := g("rev-parse", "--verify", "-q", "HEAD"); err != nil || head != sha {
|
||||
if _, err := g("checkout", "--force", "--detach", sha); err != nil {
|
||||
@@ -161,6 +166,11 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
||||
if _, err := g("submodule", "sync", "-q"); err != nil {
|
||||
return err
|
||||
}
|
||||
if in != nil {
|
||||
if err := p.attachBranch(ctx, *in, sha, prev); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
links, err := indexGitlinks(ctx, dir)
|
||||
if err != nil {
|
||||
@@ -178,6 +188,16 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// the .gitmodules branch of each child, for its ref
|
||||
branches := map[string]string{}
|
||||
if out, ok, err := try(ctx, dir, "config", "-f", ".gitmodules", "--get-regexp", `^submodule\..*\.(path|branch)$`); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
for _, m := range parseGitmodules(out) {
|
||||
branches[path.Clean(m.path)] = m.branch
|
||||
}
|
||||
}
|
||||
|
||||
// the children are independent repositories
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(links))
|
||||
@@ -185,7 +205,8 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := p.restoreRepo(ctx, filepath.Join(dir, filepath.FromSlash(l.path)), l.sha); err != nil {
|
||||
child := info{superDir: dir, dir: filepath.Join(dir, filepath.FromSlash(l.path)), relPath: l.path, branch: branches[l.path]}
|
||||
if err := p.restoreRepo(ctx, child.dir, l.sha, &child); err != nil {
|
||||
errs[i] = err
|
||||
}
|
||||
}()
|
||||
@@ -194,6 +215,135 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string) error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// attachBranch puts the detached HEAD of the restored submodule in on a
|
||||
// branch whose tip is sha, the first that git accepts of, in this order:
|
||||
//
|
||||
// 1. a local branch tracking the ref the UI shows (see resolve), or named
|
||||
// like it;
|
||||
// 2. the ref itself, as a new local branch tracking it, if no local branch
|
||||
// has its name;
|
||||
// 3. any other local branch, alphabetically;
|
||||
// 4. any other remote branch whose name no local branch has, as a new
|
||||
// tracking branch; the ref's remote first, then alphabetically.
|
||||
//
|
||||
// A HEAD that is on a branch stays there. No branch is moved: a local branch
|
||||
// elsewhere is no candidate, and its remote branch neither. If nothing fits,
|
||||
// HEAD stays detached; only a context error is returned. prev is the branch
|
||||
// checked out before the restore; the ref depends on it.
|
||||
func (p *Provider) attachBranch(ctx context.Context, in info, sha, prev string) error {
|
||||
if _, onBranch, err := try(ctx, in.dir, "symbolic-ref", "-q", "HEAD"); err != nil || onBranch {
|
||||
return err
|
||||
}
|
||||
out, ok, err := try(ctx, in.dir, "for-each-ref", "--points-at="+sha,
|
||||
"--format=%(refname)%00%(upstream)%00%(symref)", "refs/heads", "refs/remotes")
|
||||
if err != nil || !ok {
|
||||
return err
|
||||
}
|
||||
var locals, remotes []string // alphabetical, as for-each-ref sorts them
|
||||
upstreams := map[string]string{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
f := strings.Split(line, "\x00")
|
||||
if len(f) != 3 || f[2] != "" { // a symref is origin/HEAD
|
||||
continue
|
||||
}
|
||||
if name, ok := strings.CutPrefix(f[0], "refs/heads/"); ok {
|
||||
locals = append(locals, name)
|
||||
upstreams[name] = strings.TrimPrefix(f[1], "refs/remotes/")
|
||||
} else if name, ok := strings.CutPrefix(f[0], "refs/remotes/"); ok {
|
||||
remotes = append(remotes, name)
|
||||
}
|
||||
}
|
||||
if len(locals)+len(remotes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// the ref, if it is a remote branch
|
||||
var ref string
|
||||
containing, err := remoteContaining(ctx, in.dir, sha)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r, source, _, err := resolve(ctx, in, sha, prev, containing); err != nil {
|
||||
return err
|
||||
} else if source != submodule.RefFromTag && source != submodule.RefFromSHA {
|
||||
ref = r
|
||||
}
|
||||
|
||||
localOf := func(remote string) string { _, l, _ := strings.Cut(remote, "/"); return l }
|
||||
hasLocal := func(name string) (bool, error) {
|
||||
_, ok, err := try(ctx, in.dir, "rev-parse", "--verify", "-q", "refs/heads/"+name)
|
||||
return ok, err
|
||||
}
|
||||
type candidate struct{ local, remote string } // remote: create local, tracking it
|
||||
var cands []candidate
|
||||
addRemote := func(r string) error {
|
||||
l := localOf(r)
|
||||
if l == "" {
|
||||
return nil
|
||||
}
|
||||
exists, err := hasLocal(l)
|
||||
if err == nil && !exists {
|
||||
cands = append(cands, candidate{l, r})
|
||||
}
|
||||
return err
|
||||
}
|
||||
if ref != "" {
|
||||
for _, l := range locals {
|
||||
if upstreams[l] == ref {
|
||||
cands = append(cands, candidate{local: l})
|
||||
}
|
||||
}
|
||||
if l := localOf(ref); slices.Contains(locals, l) {
|
||||
cands = append(cands, candidate{local: l})
|
||||
}
|
||||
if slices.Contains(remotes, ref) {
|
||||
if err := addRemote(ref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, l := range locals {
|
||||
cands = append(cands, candidate{local: l})
|
||||
}
|
||||
preferred, _, _ := strings.Cut(ref, "/")
|
||||
if preferred == "" {
|
||||
if preferred, err = remoteName(ctx, in.dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(remotes, func(a, b string) int {
|
||||
ap, bp := strings.HasPrefix(a, preferred+"/"), strings.HasPrefix(b, preferred+"/")
|
||||
switch {
|
||||
case ap && !bp:
|
||||
return -1
|
||||
case bp && !ap:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
for _, r := range remotes {
|
||||
if r != ref {
|
||||
if err := addRemote(r); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range cands {
|
||||
args := append(append([]string{}, restoreArgs...), "switch", "-q", c.local)
|
||||
if c.remote != "" {
|
||||
args = append(append([]string{}, restoreArgs...), "switch", "-q", "-c", c.local, "--track", c.remote)
|
||||
}
|
||||
if _, err := p.exec(ctx, in.dir, nil, args...); err == nil {
|
||||
return nil
|
||||
} else if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
// e.g. checked out in another worktree: try the next one
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// networkEnv is the environment of commands that may clone or fetch.
|
||||
func (p *Provider) networkEnv() []string {
|
||||
return append(append([]string{}, p.sshEnv...), p.extraEnv...)
|
||||
|
||||
@@ -332,3 +332,130 @@ func TestRestoreLossCountsStagedPinsOfAMovingRepository(t *testing.T) {
|
||||
t.Errorf("loss %+v, want 1 staged pin and the 2 detached nested commits", l)
|
||||
}
|
||||
}
|
||||
|
||||
// restoreSub restores the submodule path and returns the branch its HEAD is
|
||||
// on ("" if detached).
|
||||
func restoreSub(t *testing.T, p *Provider, super, path string) string {
|
||||
t.Helper()
|
||||
if err := p.Restore(context.Background(), path, submodule.RestoreStaged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out(t, filepath.Join(super, filepath.FromSlash(path)), "branch", "--show-current")
|
||||
}
|
||||
|
||||
func TestRestoreAttachesTheLocalBranchTrackingTheRef(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
// src/ui: in sync, the pin is behind origin/main, local main is at the pin
|
||||
git(t, filepath.Join(super, "src", "ui"), "checkout", "-q", "--detach")
|
||||
git(t, filepath.Join(super, "src", "ui"), "branch", "aaa") // alphabetically first, but no ref
|
||||
|
||||
if got := restoreSub(t, p, super, "src/ui"); got != "main" {
|
||||
t.Errorf("branch = %q, want main", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreCreatesTheRefBranchBeforeOtherBranches(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
log := filepath.Join(super, "libs", "log")
|
||||
// libs/log: the pin is the tip of origin/main and origin/archive
|
||||
git(t, log, "checkout", "-q", "--detach")
|
||||
git(t, log, "branch", "-D", "main")
|
||||
|
||||
if got := restoreSub(t, p, super, "libs/log"); got != "main" {
|
||||
t.Errorf("branch = %q, want main", got)
|
||||
}
|
||||
if up := out(t, log, "rev-parse", "--abbrev-ref", "main@{upstream}"); up != "origin/main" {
|
||||
t.Errorf("upstream = %q, want origin/main", up)
|
||||
}
|
||||
if out(t, log, "branch", "--list", "archive") != "" {
|
||||
t.Error("archive must not be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreCreatesARemoteFeatureBranch(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
topic := filepath.Join(super, "libs", "topic")
|
||||
mainTip := out(t, topic, "rev-parse", "main")
|
||||
// libs/topic: detached at the tip of origin/feat/search, local main elsewhere
|
||||
|
||||
if got := restoreSub(t, p, super, "libs/topic"); got != "feat/search" {
|
||||
t.Errorf("branch = %q, want feat/search", got)
|
||||
}
|
||||
if got := out(t, topic, "rev-parse", "main"); got != mainTip {
|
||||
t.Errorf("main moved: %s -> %s", mainTip, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestorePrefersLocalBranchesAlphabetically(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
feature := filepath.Join(super, "libs", "feature")
|
||||
// libs/feature: the pin is on no branch tip
|
||||
git(t, feature, "branch", "zz")
|
||||
git(t, feature, "branch", "aa")
|
||||
|
||||
if got := restoreSub(t, p, super, "libs/feature"); got != "aa" {
|
||||
t.Errorf("branch = %q, want aa", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreNeverMovesALocalBranchToAttach(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
topic := filepath.Join(super, "libs", "topic")
|
||||
git(t, topic, "branch", "feat/search", "origin/main") // same name, elsewhere
|
||||
before := out(t, topic, "rev-parse", "feat/search")
|
||||
|
||||
if got := restoreSub(t, p, super, "libs/topic"); got != "" {
|
||||
t.Errorf("branch = %q, want detached", got)
|
||||
}
|
||||
if after := out(t, topic, "rev-parse", "feat/search"); after != before {
|
||||
t.Errorf("feat/search moved: %s -> %s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreKeepsTheCheckedOutBranch(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
log := filepath.Join(super, "libs", "log")
|
||||
git(t, log, "switch", "-q", "-c", "zz") // at the pin, like main
|
||||
|
||||
if got := restoreSub(t, p, super, "libs/log"); got != "zz" {
|
||||
t.Errorf("branch = %q, want zz kept", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreSkipsABranchOfAnotherWorktree(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
log := filepath.Join(super, "libs", "log")
|
||||
git(t, log, "checkout", "-q", "--detach")
|
||||
git(t, log, "worktree", "add", "-q", filepath.Join(root, "wt"), "main")
|
||||
|
||||
if got := restoreSub(t, p, super, "libs/log"); got == "main" {
|
||||
t.Error("main is checked out in another worktree")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreSuperprojectAttachesNestedSubmodules(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
nested := filepath.Join(super, "libs", "log", "modules", "nested")
|
||||
git(t, nested, "checkout", "-q", "--detach")
|
||||
|
||||
if err := p.Restore(context.Background(), "", submodule.RestoreStaged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := out(t, nested, "branch", "--show-current"); got != "main" {
|
||||
t.Errorf("nested branch = %q, want main", got)
|
||||
}
|
||||
if got := out(t, filepath.Join(super, "libs", "topic"), "branch", "--show-current"); got != "feat/search" {
|
||||
t.Errorf("libs/topic branch = %q, want feat/search", got)
|
||||
}
|
||||
if got := out(t, super, "branch", "--show-current"); got != "main" {
|
||||
t.Errorf("superproject branch = %q, want main", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,8 +90,11 @@ func (p *Provider) SwitchLoss(ctx context.Context, path string, b submodule.Bran
|
||||
|
||||
// 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.
|
||||
// local branch is the one checked out before if it tracks b, else the one
|
||||
// of that name, created tracking b if there is none. A local branch that is
|
||||
// only behind the tip is fast-forwarded; one with commits of its own, or
|
||||
// checked out in another worktree, 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 {
|
||||
@@ -102,12 +105,16 @@ func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch)
|
||||
} else if !ok || sha != b.SHA {
|
||||
return "", fmt.Errorf("%s moved; open the list again", b.Name)
|
||||
}
|
||||
prev, _, err := try(ctx, dir, "symbolic-ref", "--short", "-q", "HEAD")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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 {
|
||||
if err := p.restoreRepo(ctx, dir, b.SHA, nil); err != nil { // HEAD goes on b below
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -118,15 +125,51 @@ func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch)
|
||||
_, 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); {
|
||||
local := b.Local
|
||||
if prev != "" && prev != local {
|
||||
if up, ok, err := upstream(ctx, dir, prev); err != nil {
|
||||
return "", err
|
||||
} else if ok && up == b.Name {
|
||||
local = prev // e.g. dev tracking origin/main: an update stays on dev
|
||||
}
|
||||
}
|
||||
switch tip, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", "refs/heads/"+local); {
|
||||
case err != nil:
|
||||
return "", err
|
||||
case !ok:
|
||||
_, err := g("switch", "-q", "-c", b.Local, "--track", b.Name)
|
||||
return b.Local, err
|
||||
_, err := g("switch", "-q", "-c", local, "--track", b.Name)
|
||||
return local, err
|
||||
case tip == b.SHA:
|
||||
_, err := g("switch", "-q", b.Local)
|
||||
return b.Local, err
|
||||
_, err := g("switch", "-q", local)
|
||||
return local, err
|
||||
default:
|
||||
if ok, err := p.fastForward(ctx, dir, local, tip, b.SHA); err != nil || !ok {
|
||||
return "", err
|
||||
}
|
||||
_, err := g("switch", "-q", local)
|
||||
return local, err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// fastForward moves the local branch from tip to sha if that loses nothing:
|
||||
// tip is an ancestor of sha and no worktree has the branch checked out. It
|
||||
// reports whether the branch was moved; a branch that moved meanwhile is
|
||||
// left alone.
|
||||
func (p *Provider) fastForward(ctx context.Context, dir, local, tip, sha string) (bool, error) {
|
||||
if _, ok, err := try(ctx, dir, "merge-base", "--is-ancestor", tip, sha); err != nil || !ok {
|
||||
return false, err
|
||||
}
|
||||
wts, err := run(ctx, dir, "worktree", "list", "--porcelain")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, line := range strings.Split(wts, "\n") {
|
||||
if line == "branch refs/heads/"+local {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
if _, ok, err := try(ctx, dir, "update-ref", "-m", "submarine: fast-forward", "refs/heads/"+local, sha, tip); err != nil || !ok {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -81,12 +81,15 @@ func TestSwitchSubmoduleStagesTipAndTracksRemoteBranch(t *testing.T) {
|
||||
assertExact(t, sub)
|
||||
}
|
||||
|
||||
func TestSwitchNeverMovesAnExistingLocalBranch(t *testing.T) {
|
||||
func TestSwitchNeverMovesALocalBranchWithCommitsOfItsOwn(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
|
||||
// same name, diverged: a commit of its own on top of origin/main
|
||||
git(t, sub, "switch", "-q", "-c", "feat/search", "origin/main")
|
||||
git(t, sub, "commit", "-q", "--allow-empty", "-m", "own")
|
||||
git(t, sub, "switch", "-q", "--detach", "HEAD~1")
|
||||
before := out(t, sub, "rev-parse", "feat/search")
|
||||
bs, err := p.Branches(ctx, "libs/feature")
|
||||
if err != nil {
|
||||
@@ -111,6 +114,112 @@ func TestSwitchNeverMovesAnExistingLocalBranch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchFastForwardsALocalBranchThatIsBehind(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
sub := filepath.Join(root, "super", "libs", "feature")
|
||||
ctx := context.Background()
|
||||
|
||||
git(t, sub, "branch", "feat/search", "origin/feat/search~1") // behind only
|
||||
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" || out(t, sub, "branch", "--show-current") != "feat/search" {
|
||||
t.Errorf("Switch = %q, branch = %q; want feat/search", got, out(t, sub, "branch", "--show-current"))
|
||||
}
|
||||
if tip := out(t, sub, "rev-parse", "feat/search"); tip != b.SHA {
|
||||
t.Errorf("feat/search = %s, want fast-forwarded to %s", tip, b.SHA)
|
||||
}
|
||||
assertExact(t, sub)
|
||||
}
|
||||
|
||||
func TestSwitchSuperprojectFastForwardsItsBranch(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
ctx := context.Background()
|
||||
|
||||
// main, checked out, is one behind origin/main: a pull --ff-only
|
||||
tip := out(t, super, "rev-parse", "HEAD")
|
||||
git(t, super, "update-ref", "refs/remotes/origin/main", tip)
|
||||
git(t, super, "update-ref", "refs/heads/main", tip+"~1")
|
||||
bs, err := p.Branches(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := p.Switch(ctx, "", findBranch(t, bs, "origin/main"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "main" || out(t, super, "branch", "--show-current") != "main" {
|
||||
t.Errorf("Switch = %q, branch = %q; want main", got, out(t, super, "branch", "--show-current"))
|
||||
}
|
||||
if head := out(t, super, "rev-parse", "main"); head != tip {
|
||||
t.Errorf("main = %s, want fast-forwarded to %s", head, tip)
|
||||
}
|
||||
assertExact(t, super)
|
||||
}
|
||||
|
||||
func TestSwitchStaysOnTheCheckedOutBranchThatTracksIt(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
ctx := context.Background()
|
||||
|
||||
// dev, checked out, tracks origin/main and is one behind it
|
||||
tip := out(t, super, "rev-parse", "HEAD")
|
||||
git(t, super, "remote", "add", "origin", filepath.Join(root, "nowhere")) // never contacted
|
||||
git(t, super, "update-ref", "refs/remotes/origin/main", tip)
|
||||
git(t, super, "switch", "-q", "-c", "dev")
|
||||
git(t, super, "update-ref", "refs/heads/dev", tip+"~1")
|
||||
git(t, super, "branch", "-q", "--set-upstream-to=origin/main", "dev")
|
||||
bs, err := p.Branches(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := p.Switch(ctx, "", findBranch(t, bs, "origin/main"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "dev" || out(t, super, "branch", "--show-current") != "dev" {
|
||||
t.Errorf("Switch = %q, branch = %q; want dev", got, out(t, super, "branch", "--show-current"))
|
||||
}
|
||||
if head := out(t, super, "rev-parse", "dev"); head != tip {
|
||||
t.Errorf("dev = %s, want fast-forwarded to %s", head, tip)
|
||||
}
|
||||
if main := out(t, super, "rev-parse", "main"); main != tip {
|
||||
t.Errorf("main = %s, must not have moved from %s", main, tip)
|
||||
}
|
||||
assertExact(t, super)
|
||||
}
|
||||
|
||||
func TestSwitchNeverMovesABranchOfAnotherWorktree(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
sub := filepath.Join(root, "super", "libs", "feature")
|
||||
ctx := context.Background()
|
||||
|
||||
git(t, sub, "branch", "feat/search", "origin/feat/search~1")
|
||||
git(t, sub, "worktree", "add", "-q", filepath.Join(root, "wt"), "feat/search")
|
||||
before := out(t, sub, "rev-parse", "feat/search")
|
||||
bs, err := p.Branches(ctx, "libs/feature")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := p.Switch(ctx, "libs/feature", findBranch(t, bs, "origin/feat/search"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("Switch returned %q, want detached", got)
|
||||
}
|
||||
if after := out(t, sub, "rev-parse", "feat/search"); after != before {
|
||||
t.Errorf("branch of the other worktree moved: %s -> %s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchAbortsWhenTheBranchMoved(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
sub := filepath.Join(root, "super", "libs", "feature")
|
||||
|
||||
@@ -451,6 +451,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m.startRestore()
|
||||
case key.Matches(msg, k.Switch):
|
||||
return m.startSwitch()
|
||||
case key.Matches(msg, k.Update):
|
||||
return m.startPinUpdate()
|
||||
case key.Matches(msg, k.Issues):
|
||||
m.tree.SetProblemsOnly(!m.tree.ProblemsOnly())
|
||||
m.layout()
|
||||
|
||||
@@ -55,20 +55,41 @@ func (m Model) View(s *submodule.Submodule, repo *submodule.Repo) string {
|
||||
lines[i] = ansi.Truncate(l, m.width, "…")
|
||||
}
|
||||
height := m.height
|
||||
var palette string
|
||||
var palette []string
|
||||
if m.PaletteVisible() {
|
||||
height-- // the palette is the last line
|
||||
m.help.Width = 0
|
||||
palette = ansi.Truncate(m.help.ShortHelpView(m.actions), m.width, "…")
|
||||
palette = m.palette()
|
||||
height -= len(palette) // the palette is at the bottom
|
||||
}
|
||||
if len(lines) > height {
|
||||
lines = lines[:height]
|
||||
}
|
||||
body := lipgloss.NewStyle().Width(m.width).Height(height).Render(strings.Join(lines, "\n"))
|
||||
if palette == "" {
|
||||
if len(palette) == 0 {
|
||||
return body
|
||||
}
|
||||
return body + "\n" + palette
|
||||
return body + "\n" + strings.Join(palette, "\n")
|
||||
}
|
||||
|
||||
// palette lays the actions out on as many lines as the width needs, so no
|
||||
// key is cut off.
|
||||
func (m Model) palette() []string {
|
||||
m.help.Width = 0
|
||||
var out []string
|
||||
var cur []key.Binding
|
||||
for _, b := range m.actions {
|
||||
if len(cur) > 0 && lipgloss.Width(m.help.ShortHelpView(append(cur, b))) > m.width {
|
||||
out = append(out, m.help.ShortHelpView(cur))
|
||||
cur = nil
|
||||
}
|
||||
cur = append(cur, b)
|
||||
}
|
||||
if len(cur) > 0 {
|
||||
out = append(out, m.help.ShortHelpView(cur))
|
||||
}
|
||||
for i, l := range out {
|
||||
out[i] = ansi.Truncate(l, m.width, "…")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package detail
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"submarine/internal/tui/theme"
|
||||
)
|
||||
|
||||
func TestPaletteWrapsInsteadOfCuttingKeys(t *testing.T) {
|
||||
keys := []key.Binding{
|
||||
key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
|
||||
key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "update pin")),
|
||||
key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")),
|
||||
key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")),
|
||||
}
|
||||
m := New(theme.Default(), keys)
|
||||
m.SetSize(36, 20)
|
||||
lines := m.palette()
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("want 2 lines, got %q", lines)
|
||||
}
|
||||
all := strings.Join(lines, "\n")
|
||||
for _, want := range []string{"copy path", "update pin", "switch branch", "x restore"} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Errorf("palette lacks %q: %q", want, lines)
|
||||
}
|
||||
}
|
||||
for _, l := range lines {
|
||||
if w := lipgloss.Width(l); w > 36 {
|
||||
t.Errorf("line %q is %d wide", l, w)
|
||||
}
|
||||
}
|
||||
m.SetSize(120, 20)
|
||||
if lines := m.palette(); len(lines) != 1 {
|
||||
t.Errorf("wide pane: want 1 line, got %q", lines)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ type keyMap struct {
|
||||
CollapseAll, ExpandAll key.Binding
|
||||
Home, End, PageUp, PageDown key.Binding
|
||||
Fetch, Reload, Filter, Issues key.Binding
|
||||
Copy, Restore, Switch key.Binding
|
||||
Copy, Restore, Switch, Update key.Binding
|
||||
Help, Quit, QuitUpdate key.Binding
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ func defaultKeys() keyMap {
|
||||
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")),
|
||||
Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "update pin")),
|
||||
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")),
|
||||
@@ -56,7 +57,9 @@ 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.Switch, k.Restore} }
|
||||
func (k keyMap) Submodule() []key.Binding {
|
||||
return []key.Binding{k.Copy, k.Update, 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} }
|
||||
@@ -87,7 +90,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.Switch, k.Restore},
|
||||
{k.Copy, k.Update, k.Switch, k.Restore},
|
||||
{k.Fetch, k.Reload, k.Filter, k.Issues},
|
||||
append([]key.Binding{k.Help, k.Quit}, k.fullUpdate()...),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
)
|
||||
|
||||
// startPinUpdate moves the pin of the selected submodule to the tip of its
|
||||
// ref, if the pin is only behind it: a switch to that branch, with the tip
|
||||
// the row shows. Nothing is fetched. For the superproject the pin is HEAD
|
||||
// and the ref its upstream: a fast-forward of its branch.
|
||||
func (m Model) startPinUpdate() (tea.Model, tea.Cmd) {
|
||||
s := m.tree.Selected()
|
||||
if s == nil {
|
||||
return m, nil
|
||||
}
|
||||
_, ok := m.provider.(submodule.Switcher)
|
||||
var flash string
|
||||
switch {
|
||||
case !ok:
|
||||
flash = "this data source can't update pins"
|
||||
case m.restore != nil:
|
||||
flash = "wait for the restore of " + m.restore.name + " to finish"
|
||||
default:
|
||||
flash = pinUpdateBlocker(s)
|
||||
}
|
||||
if flash != "" {
|
||||
cmd := m.footer.Flash(flash)
|
||||
m.layout()
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
st := s.Status
|
||||
m.restore = &restoreState{path: s.Path, name: restoreName(s), pinUpdate: true, phase: restoreChecking}
|
||||
m.layout()
|
||||
ctx, path, ref, sha := m.ctx, s.Path, st.Pin.Ref, st.Tip.SHA
|
||||
sw := m.provider.(submodule.Switcher)
|
||||
check := func() tea.Msg {
|
||||
b, err := pinUpdateBranch(ctx, sw, path, ref, sha)
|
||||
if err != nil {
|
||||
return lossMsg{path: path, err: err}
|
||||
}
|
||||
loss, err := sw.SwitchLoss(ctx, path, *b)
|
||||
return lossMsg{path, loss, err, b}
|
||||
}
|
||||
return m, tea.Batch(check, m.tick())
|
||||
}
|
||||
|
||||
// pinUpdateBlocker says why the pin of s can't be updated, "" if it can.
|
||||
func pinUpdateBlocker(s *submodule.Submodule) string {
|
||||
st, name := s.Status, restoreName(s)
|
||||
pin := "the pin of " + name
|
||||
if s.IsRoot() {
|
||||
pin = name // its pin is HEAD
|
||||
}
|
||||
switch {
|
||||
case st == nil:
|
||||
return name + " is still loading"
|
||||
case st.Presence != submodule.PresenceOK:
|
||||
return name + " is not initialized; restore it first"
|
||||
case st.Pin.Ref == "" || st.Tip == nil || st.Pin.Source == submodule.RefFromTag || st.Pin.Source == submodule.RefFromSHA:
|
||||
if s.IsRoot() {
|
||||
return name + " has no upstream branch"
|
||||
}
|
||||
return pin + " is not on a branch"
|
||||
case st.Pin.Ahead > 0 && st.Pin.Behind > 0:
|
||||
return pin + " has diverged from " + st.Pin.Ref + "; b switches the branch"
|
||||
case st.Pin.Ahead > 0:
|
||||
return pin + " is ahead of " + st.Pin.Ref
|
||||
case st.Pin.Behind == 0:
|
||||
return name + " is up to date with " + st.Pin.Ref
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// pinUpdateBranch finds the branch ref among the branches of path. Its tip
|
||||
// must still be sha, the tip the row showed (abbreviated, see Commit).
|
||||
func pinUpdateBranch(ctx context.Context, sw submodule.Switcher, path, ref, sha string) (*submodule.Branch, error) {
|
||||
bs, err := sw.Branches(ctx, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, b := range bs {
|
||||
if b.Name != ref {
|
||||
continue
|
||||
}
|
||||
if !b.Remote {
|
||||
return nil, fmt.Errorf("%s is a local branch; b switches to it", ref)
|
||||
}
|
||||
if !strings.HasPrefix(b.SHA, sha) {
|
||||
return nil, fmt.Errorf("%s moved; check the new state", ref)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s not found", ref)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
)
|
||||
|
||||
// newPinUpdateModel is the switch model with the first submodule selected
|
||||
// and given st as its status.
|
||||
func newPinUpdateModel(t *testing.T, loss submodule.Loss, st *submodule.Status) (tea.Model, *fakeSwitcher, string) {
|
||||
t.Helper()
|
||||
m, f := newSwitchModel(t, loss)
|
||||
repo, err := f.Tree(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := repo.Submodules[0].Path
|
||||
m, _ = m.Update(statusLoadedMsg{path: path, status: st})
|
||||
mm := m.(Model)
|
||||
mm.tree.Select(path)
|
||||
return mm, f, path
|
||||
}
|
||||
|
||||
// behind is a pin two commits behind origin/feat, whose tip is bbbbbbb.
|
||||
func behind() *submodule.Status {
|
||||
return &submodule.Status{
|
||||
Pin: submodule.PinInfo{Ref: "origin/feat", Behind: 2, Pushed: true, Source: submodule.RefFromGitmodules},
|
||||
Tip: &submodule.Commit{SHA: "bbbb"}, // abbreviated, like the git provider's
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinUpdateSwitchesToTheTipOfTheRef(t *testing.T) {
|
||||
m, f, path := newPinUpdateModel(t, submodule.Loss{}, behind())
|
||||
m, cmd := press(m, "u")
|
||||
m = settle(m, cmd)
|
||||
if len(f.switched) != 1 || f.switched[0] != path+" refs/remotes/origin/feat" {
|
||||
t.Fatalf("switched %q", f.switched)
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "updated "+path+" to feat") {
|
||||
t.Errorf("no flash:\n%s", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinUpdateAsksWhenSomethingIsLost(t *testing.T) {
|
||||
m, f, path := newPinUpdateModel(t, submodule.Loss{Files: 1, Modules: 1}, behind())
|
||||
m, cmd := press(m, "u")
|
||||
for _, msg := range run(cmd) {
|
||||
if l, ok := msg.(lossMsg); ok {
|
||||
m, _ = m.Update(l)
|
||||
}
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "Update "+path+" to origin/feat?") || !strings.Contains(v, "y update") {
|
||||
t.Errorf("question not shown:\n%s", v)
|
||||
}
|
||||
m, cmd = press(m, "y")
|
||||
feed(m, cmd)
|
||||
if len(f.switched) != 1 {
|
||||
t.Fatalf("switched %q", f.switched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinUpdateAbortsWhenTheRefMoved(t *testing.T) {
|
||||
st := behind()
|
||||
st.Tip.SHA = "ccccccc" // the row showed another tip than the branch has now
|
||||
m, f, _ := newPinUpdateModel(t, submodule.Loss{}, st)
|
||||
m, cmd := press(m, "u")
|
||||
m = settle(m, cmd)
|
||||
if len(f.switched) != 0 {
|
||||
t.Fatalf("switched %q", f.switched)
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "origin/feat moved") {
|
||||
t.Errorf("no notice:\n%s", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinUpdateOnlyWhenThePinIsBehind(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
edit func(*submodule.Status)
|
||||
want string
|
||||
}{
|
||||
{"up to date", func(s *submodule.Status) { s.Pin.Behind = 0 }, "is up to date with origin/feat"},
|
||||
{"ahead", func(s *submodule.Status) { s.Pin.Behind, s.Pin.Ahead = 0, 1 }, "is ahead of origin/feat"},
|
||||
{"diverged", func(s *submodule.Status) { s.Pin.Ahead = 1 }, "has diverged from origin/feat"},
|
||||
{"tag", func(s *submodule.Status) { s.Pin.Source = submodule.RefFromTag }, "is not on a branch"},
|
||||
{"missing", func(s *submodule.Status) { s.Presence = submodule.NotInitialized }, "is not initialized"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st := behind()
|
||||
tc.edit(st)
|
||||
m, f, _ := newPinUpdateModel(t, submodule.Loss{}, st)
|
||||
m, _ = press(m, "u") // the command is only the flash's timer
|
||||
if len(f.switched) != 0 || m.(Model).restore != nil {
|
||||
t.Fatalf("updated anyway: %q", f.switched)
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, tc.want) {
|
||||
t.Errorf("flash lacks %q:\n%s", tc.want, v)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinUpdateOfTheSuperprojectFastForwardsItsBranch(t *testing.T) {
|
||||
m, f := newSwitchModel(t, submodule.Loss{}) // the superproject is selected
|
||||
st := behind()
|
||||
st.Root = true
|
||||
m, _ = m.Update(statusLoadedMsg{path: "", status: st})
|
||||
m, cmd := press(m, "u")
|
||||
m = settle(m, cmd)
|
||||
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
|
||||
t.Fatalf("switched %q", f.switched)
|
||||
}
|
||||
if v := m.View(); !strings.Contains(v, "updated the superproject to feat") {
|
||||
t.Errorf("no flash:\n%s", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinUpdateOfTheSuperprojectWithoutUpstreamSaysSo(t *testing.T) {
|
||||
m, f := newSwitchModel(t, submodule.Loss{})
|
||||
st := behind()
|
||||
st.Root, st.Pin.Source, st.Tip = true, submodule.RefFromSHA, nil
|
||||
m, _ = m.Update(statusLoadedMsg{path: "", status: st})
|
||||
m, _ = press(m, "u")
|
||||
if len(f.switched) != 0 || !strings.Contains(m.View(), "the superproject has no upstream branch") {
|
||||
t.Errorf("switched %q, view:\n%s", f.switched, m.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinUpdateRefusesALocalRef(t *testing.T) {
|
||||
st := behind()
|
||||
st.Pin.Ref, st.Tip.SHA = "main", "aaaa" // upstream is a local branch
|
||||
m, f, _ := newPinUpdateModel(t, submodule.Loss{}, st)
|
||||
m, cmd := press(m, "u")
|
||||
m = settle(m, cmd)
|
||||
if len(f.switched) != 0 || !strings.Contains(m.View(), "main is a local branch") {
|
||||
t.Errorf("switched %q, view:\n%s", f.switched, m.View())
|
||||
}
|
||||
}
|
||||
+41
-12
@@ -28,13 +28,15 @@ type restoreState struct {
|
||||
to submodule.RestoreTarget
|
||||
loss submodule.Loss
|
||||
branch *submodule.Branch // set for a branch switch, which reuses this flow
|
||||
pinUpdate bool // a switch to the pin's ref (u); branch is set by the check
|
||||
}
|
||||
|
||||
// lossMsg is the result of the check before a restore.
|
||||
type lossMsg struct {
|
||||
path string
|
||||
loss submodule.Loss
|
||||
err error
|
||||
path string
|
||||
loss submodule.Loss
|
||||
err error
|
||||
branch *submodule.Branch // the branch a pin update switches to
|
||||
}
|
||||
|
||||
type restoreDoneMsg struct {
|
||||
@@ -86,7 +88,7 @@ func (m Model) checkRestore() (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
loss, err = m.provider.(submodule.Restorer).RestoreLoss(ctx, path, to)
|
||||
}
|
||||
return lossMsg{path, loss, err}
|
||||
return lossMsg{path: path, loss: loss, err: err}
|
||||
}
|
||||
return m, tea.Batch(check, m.tick())
|
||||
}
|
||||
@@ -115,11 +117,18 @@ func (m Model) lossChecked(msg lossMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
if msg.err != nil {
|
||||
verb := "check"
|
||||
if m.restore.pinUpdate {
|
||||
verb = "update"
|
||||
}
|
||||
m.restore = nil
|
||||
m.notice = "can't check " + restoreName(m.byPath[msg.path]) + ": " + msg.err.Error()
|
||||
m.notice = "can't " + verb + " " + restoreName(m.byPath[msg.path]) + ": " + msg.err.Error()
|
||||
m.layout()
|
||||
return m, nil
|
||||
}
|
||||
if msg.branch != nil {
|
||||
m.restore.branch = msg.branch
|
||||
}
|
||||
m.restore.loss = msg.loss
|
||||
if msg.loss.Empty() {
|
||||
return m.runRestore()
|
||||
@@ -165,15 +174,15 @@ func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
switch {
|
||||
case r != nil && r.branch != nil && msg.err != nil:
|
||||
m.notice = "switch of " + r.name + " to " + r.branch.Name + " failed: " + msg.err.Error()
|
||||
m.notice = r.noun() + " 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)"
|
||||
to += " (detached: local " + r.branch.Local + " can't be fast-forwarded)"
|
||||
}
|
||||
cmds = append(cmds, m.footer.Flash("switched "+r.name+" to "+to))
|
||||
cmds = append(cmds, m.footer.Flash(r.verbEd()+" "+r.name+" to "+to))
|
||||
default:
|
||||
name := "the superproject"
|
||||
if r != nil {
|
||||
@@ -202,22 +211,42 @@ func (r *restoreState) toText() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// verb and verbIng word the operation: a switch is shown like a restore, but
|
||||
// names its branch.
|
||||
// verb, verbIng, verbEd and noun word the operation: a switch and a pin
|
||||
// update are shown like a restore, but name their branch.
|
||||
func (r *restoreState) verb() string {
|
||||
if r.branch != nil {
|
||||
switch {
|
||||
case r.pinUpdate:
|
||||
return "Update"
|
||||
case r.branch != nil:
|
||||
return "Switch"
|
||||
}
|
||||
return "Restore"
|
||||
}
|
||||
|
||||
func (r *restoreState) verbIng() string {
|
||||
if r.branch != nil {
|
||||
switch {
|
||||
case r.pinUpdate:
|
||||
return "updating"
|
||||
case r.branch != nil:
|
||||
return "switching"
|
||||
}
|
||||
return "restoring"
|
||||
}
|
||||
|
||||
func (r *restoreState) verbEd() string {
|
||||
if r.pinUpdate {
|
||||
return "updated"
|
||||
}
|
||||
return "switched"
|
||||
}
|
||||
|
||||
func (r *restoreState) noun() string {
|
||||
if r.pinUpdate {
|
||||
return "update"
|
||||
}
|
||||
return "switch"
|
||||
}
|
||||
|
||||
func restoreName(s *submodule.Submodule) string {
|
||||
if s == nil || s.IsRoot() {
|
||||
return "the superproject"
|
||||
|
||||
Reference in New Issue
Block a user