Archived
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01a9babe64 | ||
|
|
648e1fd46c | ||
|
|
2b6b781826 | ||
|
|
b9f6055759 | ||
|
|
7980d3356a | ||
|
|
a328d3cc3a | ||
|
|
a285a35c95 | ||
|
|
928e76154f | ||
|
|
72c060882b |
@@ -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.
|
||||
@@ -20,15 +20,21 @@ and decisions. Read the relevant document before changing behavior:
|
||||
```sh
|
||||
go run ./cmd/submarine [dir] # run on a repository (default: cwd)
|
||||
go run ./cmd/submarine --mock # run on mock data
|
||||
go run ./cmd/submarine --no-fetch [dir] # without background fetch
|
||||
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
|
||||
@@ -38,8 +44,9 @@ gofmt -l . # must print nothing
|
||||
state means (`Status.Issues()`). `internal/tui/theme` decides how it looks.
|
||||
Components never classify states or define colors themselves.
|
||||
- **Data access** only through `submodule.Provider` (and its optional
|
||||
`Fetcher` / `Watcher`). The git provider only uses the network in
|
||||
`Fetch`, and git must never prompt.
|
||||
`Fetcher` / `Watcher` / `Restorer`). The git provider only uses the
|
||||
network in `Fetch` and, for missing objects, in `Restore`; git must never
|
||||
prompt, and the provider writes only on a user's action.
|
||||
- **Colors** only from `theme.Theme`, ANSI 16 colors for meaning (see
|
||||
`docs/ux-guide.md#colors`). Render segments on a background with a `base`
|
||||
style (`LevelStyle(level, base)`, `Delta(pin, base)`).
|
||||
|
||||
@@ -24,8 +24,9 @@ the questions you actually have:
|
||||
(`·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.
|
||||
- **Live.** Fetches in the background on startup and follows changes you make
|
||||
in other shells within about two seconds.
|
||||
- **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.
|
||||
- **Built for dozens to hundreds of submodules.** Each status loads
|
||||
asynchronously, so the UI never freezes.
|
||||
|
||||
@@ -44,7 +45,6 @@ Update later with `sub update`.
|
||||
```sh
|
||||
sub # the repository in the current directory
|
||||
sub ~/code/project # any directory inside a repository
|
||||
sub --no-fetch # skip the background fetch
|
||||
sub --mock # built-in sample data, no repository needed
|
||||
```
|
||||
|
||||
@@ -109,7 +109,10 @@ files.
|
||||
| `space` | Toggle the selected submodule |
|
||||
| `home` `end` / `g` `G`, `pgup` `pgdn` | Jump, page |
|
||||
| `/` | 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 |
|
||||
@@ -118,8 +121,9 @@ The mouse works too: click to select, click `▸` to toggle, wheel to move.
|
||||
|
||||
## Status
|
||||
|
||||
A prototype. Browsing is complete; the actions `u` (check out the pin),
|
||||
`b` (bump the pin to the tip) and `!` (problems only) are planned.
|
||||
A prototype. Browsing is complete, and `x` restores a submodule or the whole
|
||||
superproject, and `b` switches its branch. More write actions (stage pin) are being specified in
|
||||
[docs/actions.md](docs/actions.md).
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
@@ -35,9 +35,8 @@ func main() {
|
||||
}
|
||||
|
||||
useMock := flag.Bool("mock", false, "use built-in mock data instead of a real repository")
|
||||
noFetch := flag.Bool("no-fetch", false, "don't fetch remotes in the background on startup")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [--no-fetch] [dir]\n submarine update [--check] [--force]\n submarine version")
|
||||
fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [dir]\n submarine update [--check] [--force]\n submarine version")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
flag.Parse()
|
||||
@@ -72,9 +71,9 @@ func main() {
|
||||
zone.NewGlobal()
|
||||
defer zone.Close()
|
||||
|
||||
notice := checkForUpdate(ctx, *noFetch || *useMock)
|
||||
notice := checkForUpdate(ctx, *useMock)
|
||||
|
||||
p := tea.NewProgram(tui.New(ctx, provider, tui.Options{Fetch: !*noFetch}), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
p := tea.NewProgram(tui.New(ctx, provider), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
@@ -90,7 +89,7 @@ func main() {
|
||||
|
||||
// checkForUpdate looks for a newer release in the background (at most once a
|
||||
// day, cached) and delivers its version, or "" when there is none. Disabled
|
||||
// for dev builds, --no-fetch, --mock and SUBMARINE_NO_UPDATE_CHECK.
|
||||
// for dev builds, --mock and SUBMARINE_NO_UPDATE_CHECK.
|
||||
func checkForUpdate(ctx context.Context, off bool) <-chan string {
|
||||
out := make(chan string, 1)
|
||||
cache, err := os.UserCacheDir()
|
||||
|
||||
+9
-7
@@ -1,7 +1,8 @@
|
||||
# submarine — documentation
|
||||
|
||||
submarine is a terminal UI for git repositories with many (recursively nested)
|
||||
submodules. It shows **only the submodules**, as a collapsible tree, and
|
||||
submodules. It shows **the submodules**, as a collapsible tree (plus the state
|
||||
of the superproject itself in the header), and
|
||||
describes each pin as a **branch plus distance** (`origin/main ↓1`) instead of a
|
||||
bare commit hash.
|
||||
|
||||
@@ -13,6 +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) |
|
||||
| [decisions.md](decisions.md) | Decision log with rationale (ADR style) |
|
||||
|
||||
## Install
|
||||
@@ -29,12 +31,12 @@ Installs `submarine` and the alias `sub` (linux/macOS, amd64/arm64) to
|
||||
|
||||
**Prototype.** The UI is complete for browsing. It reads real repositories
|
||||
through a git provider (`internal/submodule/git`): `submarine [dir]`. It
|
||||
fetches in the background on startup (`--no-fetch` to skip; `f` to fetch
|
||||
again) and follows changes made in other shells. `submarine --mock` shows
|
||||
the built-in mock data (`internal/submodule/mock`). The actions `u`, `b`,
|
||||
`!` only show a "not implemented" message.
|
||||
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.
|
||||
|
||||
Next steps, roughly in order:
|
||||
|
||||
1. Actions: checkout pin (`submodule update`), bump pin to tip, init.
|
||||
2. "Problems only" (`!`) view; it can reuse the tree's filter.
|
||||
1. Stage pin, see [actions.md](actions.md). (Restore and switch branch are done.)
|
||||
2. (done) "Problems only" (`!`) view, reusing the tree's filter.
|
||||
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
# 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).
|
||||
|
||||
## Principle
|
||||
|
||||
Offer as few actions as possible, but enough to work with submodules.
|
||||
submarine shows what differs from what git records. The actions resolve such
|
||||
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` |
|
||||
|
||||
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
|
||||
|
||||
Makes the selected repository, and everything below it, exactly what git
|
||||
records. It works on any submodule and on the superproject (header
|
||||
selected). Restoring the superproject gives the exact project that the
|
||||
current root commit specifies, with the pins you have staged.
|
||||
|
||||
### Target
|
||||
|
||||
| Selected | Target commit | Branch |
|
||||
|---|---|---|
|
||||
| Superproject | `HEAD` | stays on the current branch |
|
||||
| Submodule | The pin in the parent's **index** (what the UI shows as *Pinned*); with a staged pin you choose, see below | see [Branches](#branches) |
|
||||
| Nested submodule, during the recursion | The gitlink in the restored parent's index | as for a submodule |
|
||||
|
||||
**Staged pins are kept, at every depth.** A staged pin is a decision, not
|
||||
drift. Each repository's files are reset to its `HEAD`, but its staged pins
|
||||
stay in its index, and the submodules below are restored to them. So a
|
||||
nested submodule with a staged pin and uncommitted changes keeps the pin
|
||||
and is reset to it; its changes are discarded (and listed in the question).
|
||||
|
||||
**Choosing the pin.** If the selected submodule has a staged pin itself, `x`
|
||||
first asks which pin to restore to:
|
||||
|
||||
```
|
||||
Restore libs/staged to s staged pin · c committed pin (unstages it) any other key cancels
|
||||
```
|
||||
|
||||
- `s`: the staged pin, which stays staged.
|
||||
- `c`: the committed pin (the gitlink in the parent's `HEAD`). The parent's
|
||||
index is set back to it, which is how a pin is unstaged in submarine.
|
||||
|
||||
The loss check then runs for the chosen pin (the two can differ: a staged
|
||||
commit that exists only locally is lost by `c`, not by `s`).
|
||||
|
||||
Two exceptions, where a staged pin is not kept:
|
||||
|
||||
- **The repository moves.** If a repository is off its pin, restore checks
|
||||
out another commit; staged pins in its own index belonged to the old one.
|
||||
They are counted as a loss (`2 staged pins`), so the question appears. Its
|
||||
submodules follow the target commit's tree.
|
||||
- **An added, not yet committed submodule** is no staged pin: its
|
||||
`.gitmodules` entry is reset with the other files. Its directory then
|
||||
counts as an untracked file.
|
||||
|
||||
### Postconditions
|
||||
|
||||
After a successful restore of repository R with target T:
|
||||
|
||||
1. **R's HEAD is T.** The index and the worktree equal T's tree, except for
|
||||
the staged pins that were kept. No modified, added, removed or conflicted
|
||||
files remain.
|
||||
2. **No in-progress operation** remains in R: merge, rebase, cherry-pick,
|
||||
revert.
|
||||
3. **No untracked files or directories** remain in R. This includes leftover
|
||||
directories of submodules that T no longer has (*untracked repository*).
|
||||
Ignored files are kept.
|
||||
4. **Every submodule that R's index records is present and initialized**, even if
|
||||
it was never initialized, is inactive (`submodule.<name>.active`) or has
|
||||
`update = none / merge / rebase` configured. Its URL comes from T's
|
||||
`.gitmodules` (synced into the config).
|
||||
5. **Each of those submodules is restored the same way**, with its gitlink
|
||||
in R's index (T's, or a kept staged pin) as its target, recursively at
|
||||
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`.
|
||||
|
||||
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
|
||||
submodule that is only on disk is deleted as an untracked directory.
|
||||
|
||||
### Branches
|
||||
|
||||
- **Superproject:** stays on its branch. The branch is not moved, since the
|
||||
target is its own `HEAD`.
|
||||
- **Submodule on the pin:** if HEAD is a branch that points at the target,
|
||||
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.
|
||||
|
||||
Restore never moves, creates or deletes a branch.
|
||||
|
||||
### What can be lost
|
||||
|
||||
Restore discards work. The things it can lose:
|
||||
|
||||
| Lost | Where it comes from |
|
||||
|---|---|
|
||||
| Changed files | modified, added, removed, renamed files (`changed`) |
|
||||
| Untracked files | `changed` (untracked) |
|
||||
| Conflicts and in-progress operations | `conflict`; merge, rebase, cherry-pick, revert state |
|
||||
| Commits | A detached HEAD off the pin whose commits are on no branch, tag or remote ref (only the reflog still has them) |
|
||||
| Staged pins | Staged pins in a repository that restore moves to another commit (see [Target](#target)) |
|
||||
| Repositories | An untracked directory that is a repository with its **own** `.git` directory (a clone that was never added as a submodule). Its commits may exist nowhere else. A removed submodule whose git directory is absorbed into `.git/modules` loses only its worktree. Its objects stay |
|
||||
|
||||
Nothing else is lost. Branches, stashes, remote refs, ignored files and
|
||||
absorbed submodule git directories stay.
|
||||
|
||||
### Confirmation
|
||||
|
||||
Restore asks **only if something would be lost**. Never asking is unsafe
|
||||
(`git clean -ff` deletes repositories that exist nowhere else, and nothing
|
||||
undoes a restore); always asking puts a question on the harmless, common
|
||||
cases (`off pin`, `missing`, a staged pin) and teaches a reflexive `y`.
|
||||
|
||||
- **Nothing would be lost:** restore runs at once.
|
||||
- **Something would be lost:** a line above the footer says what is
|
||||
discarded, summed over the whole subtree on disk:
|
||||
|
||||
```
|
||||
Restore src/app? Discards 7 changed files in 3 repositories, 2 commits on no branch, 1 untracked repository. y restore · n cancel
|
||||
```
|
||||
|
||||
`y` runs it, any other key cancels.
|
||||
- **The state can't be read** for a repository: it is counted as `n
|
||||
repositories of unknown state` and needs the same `y`.
|
||||
|
||||
The check reads the disk at the moment of the keypress (`RestoreLoss`), not
|
||||
the loaded statuses, so loading or stale statuses don't matter. Nothing is
|
||||
lost that is not in the table above; ignored files are never touched.
|
||||
|
||||
### Network
|
||||
|
||||
A restore may need objects that are not on disk: a submodule that was never
|
||||
cloned (no git directory in `.git/modules`), or a target commit that was
|
||||
never fetched. **Restore may clone and fetch, but only what is missing**,
|
||||
non-interactively (`GIT_TERMINAL_PROMPT=0`, ssh `BatchMode`) and with a
|
||||
timeout. If the commit is on no remote, restore fails with `commit … is not
|
||||
available on any remote`. This is the one place besides `f` that uses the
|
||||
network (decision D23).
|
||||
|
||||
### Running, progress and errors
|
||||
|
||||
- A line above the footer shows `checking …` and then `restoring …` with a
|
||||
spinner. Only one restore runs at a time; pressing `x` meanwhile flashes
|
||||
`wait for the restore of src/app to finish`. Everything else keeps working.
|
||||
- A running fetch does not block a restore.
|
||||
- Done: flash `restored src/app`; the app reloads the tree and all statuses,
|
||||
because the structure can have changed.
|
||||
- Failure: the restore stops with git's message, shown in red above the
|
||||
footer (`restore of src/app failed: …`) until the next key. Restore is
|
||||
idempotent, so it can be run again after fixing the cause. Repositories
|
||||
that were restored before the failure stay restored.
|
||||
|
||||
### Implementation note
|
||||
|
||||
`git.Provider.Restore` (`internal/submodule/git/restore.go`) walks the
|
||||
repositories itself, top-down; the children of a repository run
|
||||
concurrently, bounded by the provider's semaphore. For each repository
|
||||
(`T` = target) it runs, with `-c submodule.recurse=false`:
|
||||
|
||||
```sh
|
||||
cat-file / fetch --all # only if T is missing locally
|
||||
rebase|cherry-pick|revert --quit # whichever is in progress; --quit, not --abort: no ref moves
|
||||
checkout --force --detach T # only if HEAD is not T, so a branch is never moved
|
||||
reset --hard # also ends a merge
|
||||
update-index --cacheinfo … # put the staged pins back (only if HEAD did not move)
|
||||
clean -ffd # -ff: untracked repositories too; ignored files stay
|
||||
submodule sync
|
||||
submodule update --init --checkout --force -- <not yet initialized children>
|
||||
# then every gitlink of the index (ls-files -s) is restored the same way
|
||||
```
|
||||
|
||||
`--checkout` overrides `submodule.<name>.update`, and the explicit paths
|
||||
override `submodule.<name>.active`, so no child module is skipped.
|
||||
|
||||
With `c` the parent's index gets the committed pin first (`update-index
|
||||
--cacheinfo` in the parent).
|
||||
|
||||
`RestoreLoss` (`lossOf`) follows the repositories on disk: per repository
|
||||
`status` (without submodules), the in-progress state files, and
|
||||
`rev-list --count HEAD --not --branches --tags --remotes` if HEAD is not the
|
||||
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
|
||||
parent's index. It is the same as `git add <path>` in the parent, but only
|
||||
when it is safe.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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).
|
||||
|
||||
### 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.
|
||||
|
||||
## 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.
|
||||
|
||||
| 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
|
||||
action, hence `x`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Provider (`restore_test.go`), on fixtures from `scripts/make-fixture.sh`:
|
||||
a restored superproject is exact (every repository clean, `submodule status
|
||||
--recursive` without prefixes, `libs/missing` initialized), branches are
|
||||
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,
|
||||
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
|
||||
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).
|
||||
|
||||
## Open questions
|
||||
|
||||
Decided for restore: ignored files are kept (`clean` without `-x`); a
|
||||
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?
|
||||
+30
-5
@@ -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
|
||||
```
|
||||
|
||||
@@ -64,6 +69,21 @@ type Watcher interface {
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
type Restorer interface {
|
||||
RestoreLoss(ctx context.Context, path string) (Loss, error) // what a restore would discard
|
||||
Restore(ctx context.Context, path string) error // may clone/fetch what is missing
|
||||
}
|
||||
```
|
||||
|
||||
```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
|
||||
@@ -119,9 +139,9 @@ The mock provider (`internal/submodule/mock`) covers every state and sleeps
|
||||
calling `Status()` → `statusLoadedMsg`.
|
||||
3. Each result is stored on the submodule (`Status` or `Err`); the view
|
||||
updates incrementally, including parents' hidden-issue counts.
|
||||
4. After the first tree: the watcher starts (`changeMsg`), and the
|
||||
background fetch starts one `tea.Cmd` per fetchable repository
|
||||
(`fetchDoneMsg`).
|
||||
4. After the first tree: the watcher starts (`changeMsg`). Nothing is
|
||||
fetched until the user presses `f`, which starts one `tea.Cmd` per
|
||||
fetchable repository (`fetchDoneMsg`).
|
||||
5. The spinner ticks only while something loads or fetches.
|
||||
|
||||
### Fetch and refresh are decoupled (D20)
|
||||
@@ -187,6 +207,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`,
|
||||
@@ -197,7 +222,7 @@ No CI runner: releases are built and published from a developer machine.
|
||||
so `sub` works too). `SUBMARINE_UPDATE_URL` overrides the registry.
|
||||
- After the TUI exits, a one-line notice is printed if a newer release
|
||||
exists. The check runs in the background at startup, at most once a day
|
||||
(cached in the user cache dir) and is off for dev builds, `--no-fetch`,
|
||||
(cached in the user cache dir) and is off for dev builds,
|
||||
`--mock` and `SUBMARINE_NO_UPDATE_CHECK=1`.
|
||||
|
||||
## Testing
|
||||
|
||||
+129
-1
@@ -150,7 +150,7 @@ removed + untracked.
|
||||
**Consequences.** One extra `git diff` per dirty submodule, none for clean
|
||||
ones. Not included: staged/unstaged markers, diff bodies, stash count.
|
||||
|
||||
## D18 — Background fetch on startup
|
||||
## D18 — Background fetch on startup (superseded by D22)
|
||||
|
||||
**Context.** Without fetching, every answer is only as fresh as the user's
|
||||
last `git fetch`, which they rarely run in every submodule.
|
||||
@@ -258,3 +258,131 @@ one word, D3). It is not counted in `+n`, because `+n` and the header count
|
||||
issues and a staged pin stays quiet (D4). A collapsed row shows `●` if a
|
||||
staged pin is hidden below it (`Hidden.Staged`), placed before the `·k`
|
||||
counts so truncation of the 16-column cell cuts the counts first.
|
||||
|
||||
## Superproject is selectable in the header
|
||||
|
||||
The state of the root repository was invisible: a submodule the current
|
||||
branch no longer has stays on disk as an untracked repository, root files
|
||||
can be modified, HEAD can be unpushed. The root becomes a node with an
|
||||
empty path (the convention `Fetcher`/`Change` already use), selected on
|
||||
startup, and the **header** is its row: it already holds the root's path,
|
||||
branch and ahead/behind, and a tree row would cost a line and an indent
|
||||
level for an item that is not part of the tree. Selecting the header shows
|
||||
the usual detail pane; the header also shows the root's state word, so a
|
||||
problem is visible while a submodule is selected.
|
||||
|
||||
- *Changes ignore submodules* (`--ignore-submodules=all`): off-pin and staged
|
||||
pins already have their rows; counting them again would make the root
|
||||
`changed` in most real repositories.
|
||||
- *Unpushed stays Danger*: same issue kind and level as a submodule, one
|
||||
rule for "work that exists only here" (D4).
|
||||
- *The ref is the upstream*, not the closest branch: the header shows
|
||||
ahead/behind of the upstream, and "up to date" means relative to the
|
||||
tracked branch. A nearer feature branch would make the two disagree.
|
||||
Without an upstream the usual resolution applies.
|
||||
- The root is not in `Walk`, so counts, fetch and loading logic stay
|
||||
submodule-only; the app tracks it by the path `""`.
|
||||
|
||||
## Problems only reuses the filter's tree reduction
|
||||
|
||||
`!` shows only submodules with issues. Instead of a second view, the tree's
|
||||
filter got a second criterion: a submodule matches if its path contains the
|
||||
term *and*, with problems only, it has an issue. Ancestors of matches stay as
|
||||
context, expanded, so a danger deep in the tree is reachable without
|
||||
unfolding anything. Unlike the filter text it is a mode that outlives the
|
||||
filter line, so it has its own notice line above the footer: a short list
|
||||
must not look like a healthy repository (D4). Loading and failed submodules
|
||||
count as problems because their state is unknown (D7 counts `error` as danger
|
||||
too); the cost is that rows vanish as statuses arrive, so the selection is
|
||||
refitted after each status. Staged pins are not shown by it, since they are
|
||||
not issues.
|
||||
|
||||
## D22 — Fetch only on request
|
||||
|
||||
**Context.** D18 fetched every repository on startup. With hundreds of
|
||||
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
|
||||
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
|
||||
`SUBMARINE_NO_UPDATE_CHECK`).
|
||||
**Consequences.** Right after startup, `unpushed`, `↓n` and the like are as
|
||||
fresh as the last fetch. `fetched never` appears for a repository that was
|
||||
never fetched. The progress display and per-submodule fetch errors stay as
|
||||
they are, they now belong to `f`.
|
||||
|
||||
## D23 — Restore: exact worktree, asks only for losses, may fetch what is missing
|
||||
|
||||
**Context.** The first write action had to give "the exact version of the
|
||||
project my root commit specifies" in one key, without destroying work by
|
||||
accident.
|
||||
**Decision.** `x` restores the selection: reset to the target (HEAD for the
|
||||
superproject, the index gitlink for a submodule, so a staged pin is kept and
|
||||
used), remove untracked files and repositories, end merges/rebases, and do
|
||||
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.
|
||||
- *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
|
||||
asking would train a reflexive `y` on the harmless cases. The loss is read
|
||||
from the disk when the key is pressed, not from the loaded statuses.
|
||||
- *Network.* Supersedes the "network only in `Fetch`" rule (N8): restore may
|
||||
clone a missing submodule and fetch a missing commit, non-interactively and
|
||||
with a timeout, because "no child module missing" cannot be met offline.
|
||||
- Key `x`, not `R`, which sits next to `r` (reload).
|
||||
- *Staged pins are kept, at every depth.* The first version reset the
|
||||
superproject's index and so dropped staged pins silently (they were not
|
||||
counted as a loss). A staged pin is a decision, not drift: each
|
||||
repository's staged pins are put back after its reset and are the targets
|
||||
of its submodules. Unstaging gets a place instead: for a submodule with a
|
||||
staged pin, `x` asks *staged* (`s`) or *committed* (`c`, unstages it). That
|
||||
asks only where the intent is ambiguous and needs no extra key. Staged pins
|
||||
of a repository that moves to another commit can't be carried over and
|
||||
count as a loss.
|
||||
**Consequences.** `Restorer` is a new optional provider interface; the mock
|
||||
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).
|
||||
|
||||
+49
-20
@@ -11,7 +11,8 @@ commit hashes. It doesn't answer the questions users actually have:
|
||||
|
||||
## Goals
|
||||
|
||||
- Show **only submodules** (not files), recursively nested, as a tree.
|
||||
- Show **only submodules** (not files), recursively nested, as a tree; the
|
||||
superproject itself is shown by the header, which is selectable.
|
||||
- Describe pins as **ref + distance** instead of a commit hash.
|
||||
- Let **problems stand out** and keep healthy submodules quiet.
|
||||
- Work comfortably with **dozens to hundreds** of submodules.
|
||||
@@ -56,7 +57,7 @@ commit hashes. It doesn't answer the questions users actually have:
|
||||
|
||||
### Detail pane
|
||||
|
||||
- **F20** Shows the selected submodule: full path, name, all issues in
|
||||
- **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
|
||||
@@ -72,14 +73,23 @@ commit hashes. It doesn't answer the questions users actually have:
|
||||
shown; the rest is summarized as "n more" with their kinds. Staged and
|
||||
unstaged changes are combined.
|
||||
|
||||
- **F25** The superproject is selectable (the header; selected on startup)
|
||||
and has a state of its own, so a stale checkout shows up: files changed
|
||||
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).
|
||||
See [state-model.md](state-model.md#superproject).
|
||||
|
||||
### Header and footer
|
||||
|
||||
- **F30** The header shows the superproject path, its branch and
|
||||
ahead/behind, the number of submodules, a count per issue kind (most
|
||||
ahead/behind, its own state word (**F25**), the number of submodules, a count per issue kind (most
|
||||
severe first), staged pins, how many are still loading, and the time since
|
||||
the last fetch.
|
||||
- **F31** The footer shows key hints; `?` toggles the full help. It
|
||||
temporarily shows short messages (e.g. "not implemented").
|
||||
temporarily shows short messages (e.g. "copied <path>").
|
||||
|
||||
### Navigation
|
||||
|
||||
@@ -90,9 +100,10 @@ commit hashes. It doesn't answer the questions users actually have:
|
||||
|
||||
### Fetch and refresh
|
||||
|
||||
- **F50** On startup, after the local state is shown, the superproject and
|
||||
every initialized submodule are fetched in the background (all remotes,
|
||||
with prune). `--no-fetch` turns this off.
|
||||
- **F50** Nothing is fetched on startup. The header and the detail pane show
|
||||
when the repositories were 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`)
|
||||
instead of the fetch age; afterwards the age and the number of failed
|
||||
fetches. The detail pane shows each submodule's last fetch and, if it
|
||||
@@ -108,28 +119,44 @@ commit hashes. It doesn't answer the questions users actually have:
|
||||
A text input above the footer takes the term; `esc` closes it and removes
|
||||
the filter. A selection that is filtered out moves to the next visible
|
||||
submodule (none if nothing matches); closing the filter keeps the
|
||||
selection. See [Filter](ux-guide.md#filter).
|
||||
selection (the superproject if none is left). See [Filter](ux-guide.md#filter).
|
||||
|
||||
### Actions (planned, not implemented)
|
||||
- **F55** `!` toggles "problems only": the tree shows only submodules with
|
||||
an issue (**F10**) or an unknown state (loading, `error`), plus their
|
||||
ancestors, expanded. It combines with the filter (**F54**) and stays when
|
||||
the filter closes. A line above the footer says `problems only`, so a short
|
||||
list is not mistaken for a healthy repository. See
|
||||
[Problems only](ux-guide.md#problems-only).
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `u` | Check out the pinned commit (`git submodule update`) |
|
||||
| `b` | Bump the pin to the tip of its ref |
|
||||
| `!` | Show only submodules with issues |
|
||||
### Actions
|
||||
|
||||
- **F56** `x` restores the selection (a submodule, or the superproject when
|
||||
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
|
||||
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
|
||||
|
||||
```sh
|
||||
submarine [dir] # the repository containing dir (default: current directory)
|
||||
submarine --mock # built-in mock data
|
||||
submarine --no-fetch [dir] # no background fetch on startup
|
||||
```
|
||||
|
||||
## Non-functional requirements
|
||||
|
||||
- **N1** Startup must not block on the network. The tool shows the local
|
||||
state at once and fetches in the background (F50).
|
||||
state at once and uses the network only when the user asks (F50).
|
||||
- **N2** Loading many submodules must not freeze the UI (async, per
|
||||
submodule).
|
||||
- **N3** Colors follow the user's terminal theme (ANSI 16 colors); the UI
|
||||
@@ -140,7 +167,9 @@ submarine --no-fetch [dir] # no background fetch on startup
|
||||
- **N6** Works in narrow terminals (detail pane hides below 90 columns).
|
||||
- **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 never
|
||||
prompts (no password, passphrase or host-key questions; those fetches
|
||||
fail with git's message). It uses the `git` command line, so the user's
|
||||
git configuration applies. Apart from fetching, it is read-only.
|
||||
- **N8** The git provider only uses the network in `Fetch` and, for objects
|
||||
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
|
||||
only when the user asks.
|
||||
|
||||
@@ -103,6 +103,24 @@ per level, red first, then yellow, so a danger can never hide inside a
|
||||
collapsed subtree and a warning is never mistaken for one.
|
||||
A descendant whose load failed (`error`) counts as one danger.
|
||||
|
||||
## Superproject
|
||||
|
||||
The superproject is a node with an empty `Path` (`Repo.Root`, no children;
|
||||
`Submodule.IsRoot()`) and a `Status` with `Root` set. It reuses the pin
|
||||
fields with **pin = HEAD**: `Pinned` and `CheckedOut` are HEAD, `Checkout`
|
||||
is never `CheckoutDiffers` (`CheckoutDetached` for a detached HEAD),
|
||||
`PinStaged` is false. `Issues()` is the same code; only `unpushed` says
|
||||
"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).
|
||||
- **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
|
||||
repository.
|
||||
- `Walk` and the header counts do not include the root.
|
||||
|
||||
## Resolving a pin to a ref
|
||||
|
||||
The pin is displayed as a ref, not a hash. A commit can be on several
|
||||
|
||||
+113
-26
@@ -75,6 +75,11 @@ 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
|
||||
[Superproject](state-model.md#superproject).
|
||||
|
||||
1. Directory (muted) and name (bold)
|
||||
2. One line per issue: word (colored, 10 columns) + explanation;
|
||||
`in sync nothing to do` if there are none and no pin is staged;
|
||||
@@ -136,7 +141,13 @@ Changes 5 files · 2M 1A 1D 1R · +37 −12
|
||||
- Untracked directories are shown as one entry (`tmp/`).
|
||||
### Header
|
||||
|
||||
`path branch ↑↓ · n submodules · n error · <count> <issue>… · n pin staged · n loading · <fetch>`
|
||||
The header is the superproject's row: it is selected on startup and drawn
|
||||
with the selected background (bold) while selected. After the branch it
|
||||
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>`
|
||||
|
||||
Issue counts are ordered by severity (unpushed, conflict, missing, off pin,
|
||||
changed), each in its level color. Zero counts are omitted. `n loading`
|
||||
@@ -172,6 +183,7 @@ colors in components.
|
||||
| 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.
|
||||
@@ -195,13 +207,13 @@ colors in components.
|
||||
|
||||
| Key | Behavior |
|
||||
|---|---|
|
||||
| `↑` / `k`, `↓` / `j` | Previous / next submodule; directory headers are skipped |
|
||||
| `↑` / `k`, `↓` / `j` | Previous / next submodule; directory headers are skipped. `↑` from the first submodule selects the header (superproject) |
|
||||
| `space` | Toggle expand/collapse of the selected submodule |
|
||||
| `→` / `l` | Collapsed with children: expand and select the first child. Expanded: select the first child. Leaf: nothing |
|
||||
| `←` / `h` | Expanded: collapse it (stays selected). Otherwise: collapse the parent and select it. Top level: nothing |
|
||||
| `→` / `l` | Header selected: first submodule. Collapsed with children: expand and select the first child. Expanded: select the first child. Leaf: nothing |
|
||||
| `←` / `h` | Expanded: collapse it (stays selected). Otherwise: collapse the parent and select it. Top level: select the header (superproject) |
|
||||
| `L` / `shift+→` / `ctrl+→` | Expand all; then follow the first child from the selection down to a submodule without children |
|
||||
| `H` / `shift+←` / `ctrl+←` | Collapse all (resets remembered expansion); select the top-level ancestor |
|
||||
| `home` / `g`, `end` / `G` | First / last submodule |
|
||||
| `home` / `g`, `end` / `G` | Header (superproject) / last submodule |
|
||||
| `pgup`, `pgdn` | Move by one screen |
|
||||
| `?` | Toggle full help |
|
||||
| `q`, `ctrl+c` | Quit |
|
||||
@@ -209,7 +221,8 @@ colors in components.
|
||||
| `r` | Reload everything (e.g. after editing files) |
|
||||
| `c` | Copy the absolute directory of the selected submodule to the clipboard (OSC 52, so it also works over SSH; needs terminal support, in tmux `set -g set-clipboard on`). The footer confirms with `copied <path>`. Without a local provider (mock) the path relative to the superproject is copied |
|
||||
| `/` | Open the filter, see [Filter](#filter) |
|
||||
| `u`, `b`, `!` | Planned actions (see requirements) |
|
||||
| `!` | Toggle problems only, see [Problems only](#problems-only) |
|
||||
| `x` | Restore the selection, see [Restore](#restore) |
|
||||
|
||||
"First child" always means the first child **as displayed** (children are
|
||||
grouped by directory).
|
||||
@@ -235,40 +248,114 @@ match vanish. If nothing matches the tree says `no submodule matches`.
|
||||
`/` edits the term again.
|
||||
- **`esc`** closes the input and removes the filter, in both modes. The
|
||||
selection stays; its ancestors are expanded so it is still visible.
|
||||
- The superproject is never filtered out; if nothing matches it is selected.
|
||||
- The filter line shows the keys that apply on its right.
|
||||
- The header counts are not filtered.
|
||||
|
||||
### Problems only
|
||||
|
||||
`!` reduces the tree to what needs attention. It uses the filter's rules
|
||||
(matches with their ancestors, shown expanded, see [Filter](#filter)), with
|
||||
"match" meaning *has an issue* (`Status.Issues()`) instead of *path contains
|
||||
the term*.
|
||||
|
||||
- A submodule that is still loading or failed to load (`error`) counts as a
|
||||
problem, since its state is unknown (as in the hidden counts, D7). Healthy
|
||||
submodules vanish when their status arrives.
|
||||
- A staged pin is not an issue and does not show the row.
|
||||
- It combines with the filter (both must match). Unlike the filter it is a
|
||||
mode, not an input: it stays until `!` is pressed again, also after `esc`
|
||||
closed the filter.
|
||||
- A line above the footer shows `problems only` (yellow) and `! show all`.
|
||||
While the filter line is open, its hint starts with `problems only (!)`
|
||||
instead.
|
||||
- With no problem the tree says `no problems`.
|
||||
- The selection follows the filter's rule: if it stops being visible (also
|
||||
when a status changes), the next visible submodule is selected, else the
|
||||
previous one. The superproject stays selectable.
|
||||
- The header counts are not filtered.
|
||||
|
||||
### Restore
|
||||
|
||||
`x` makes the selection, and everything below it, exactly what git records
|
||||
(see [actions.md](actions.md#restore)). It uses the line above the footer:
|
||||
|
||||
| Phase | Line |
|
||||
|---|---|
|
||||
| the submodule has a staged pin | yellow `Restore src/app to`, `s staged pin · c committed pin (unstages it)`, right-aligned `any other key cancels` |
|
||||
| reading what is lost | spinner, muted `checking src/app…` |
|
||||
| something would be lost | yellow `Restore src/app?`, red `Discards 7 changed files in 3 repositories, 2 commits on no branch, 1 untracked repository.`, right-aligned `y restore · n cancel` |
|
||||
| running | spinner, yellow `restoring src/app…` |
|
||||
| failed | red `restore of src/app failed: <git's message>`, until the next key |
|
||||
|
||||
`y` restores, any other key cancels; the keys are not passed on while a
|
||||
question is open. After `c` the name reads `src/app to the committed pin`. Without a loss there is no question. Success flashes
|
||||
`restored src/app` in the footer. For the header the name is `the
|
||||
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, and whether it works. Update this
|
||||
table when a key is added, moved or implemented. "Bar" is the short footer
|
||||
Every key in the UI, where it is shown. Update this
|
||||
table when a key is added or moved. "Bar" is the short footer
|
||||
bar, "Palette" the detail pane's palette (the footer takes its keys over
|
||||
where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette)),
|
||||
"Help" the full help (`?`).
|
||||
|
||||
| Key | Action | Group | Shown in | Implemented |
|
||||
|---|---|---|---|---|
|
||||
| `↑` / `k`, `↓` / `j` | Move selection | Navigation | Bar, Help | yes |
|
||||
| `←` / `h`, `→` / `l` | Fold / unfold, parent / first child | Navigation | Bar, Help | yes |
|
||||
| `H` / `⇧←`, `L` / `⇧→` | Collapse / expand all | Navigation | Bar, Help | yes |
|
||||
| `space` | Toggle expand/collapse | Navigation | Bar, Help | yes |
|
||||
| `home` / `g`, `end` / `G` | First / last submodule | Navigation | Help | yes |
|
||||
| `pgup`, `pgdn` | Move by one screen | Navigation | Help | yes |
|
||||
| `c` | Copy path | Submodule | Palette, Help | yes |
|
||||
| `u` | Checkout pin | Submodule | Help | no (flashes "not implemented") |
|
||||
| `b` | Bump pin to tip | Submodule | Help | no (flashes "not implemented") |
|
||||
| `f` | Fetch all repositories | Global | Help | yes |
|
||||
| `r` | Reload everything | Global | Help | yes |
|
||||
| `/` | Filter | Global | Help | yes |
|
||||
| `!` | Problems only | Global | Help | no (flashes "not implemented") |
|
||||
| `?` | Toggle full help | Global | Bar, Help | yes |
|
||||
| `q`, `ctrl+c` | Quit | Global | Bar, Help | yes |
|
||||
| Key | Action | Group | Shown in |
|
||||
|---|---|---|---|
|
||||
| `↑` / `k`, `↓` / `j` | Move selection | Navigation | Bar, Help |
|
||||
| `←` / `h`, `→` / `l` | Fold / unfold, parent / first child | Navigation | Bar, Help |
|
||||
| `H` / `⇧←`, `L` / `⇧→` | Collapse / expand all | Navigation | Bar, Help |
|
||||
| `space` | Toggle expand/collapse | Navigation | Bar, Help |
|
||||
| `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 |
|
||||
| `/` | Filter | Global | Help |
|
||||
| `!` | Problems only (toggle) | Global | Help |
|
||||
| `?` | Toggle full help | Global | Bar, Help |
|
||||
| `q`, `ctrl+c` | Quit | Global | Bar, Help |
|
||||
|
||||
### Mouse
|
||||
|
||||
- Click a row: select it.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Package git provides a read-only submodule.Provider backed by the git
|
||||
// command line. It never touches the network.
|
||||
// Package git provides a submodule.Provider backed by the git command line.
|
||||
// It reads; it writes only in Restore, and uses the network only in Fetch and
|
||||
// in Restore (for what is missing).
|
||||
package git
|
||||
|
||||
import (
|
||||
@@ -32,6 +33,7 @@ type Provider struct {
|
||||
sem chan struct{} // bounds concurrent Status calls
|
||||
fetchSem chan struct{} // bounds concurrent fetches
|
||||
sshEnv []string // extra environment for fetches
|
||||
extraEnv []string // more environment for fetches and clones; tests
|
||||
|
||||
// WatchInterval is how often Watch checks for changes.
|
||||
WatchInterval time.Duration
|
||||
@@ -85,6 +87,13 @@ func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
|
||||
}
|
||||
}
|
||||
|
||||
repo.Root = &submodule.Submodule{Name: filepath.Base(p.root), Path: ""}
|
||||
if remote, err := remoteName(ctx, p.root); err != nil {
|
||||
return nil, err
|
||||
} else if remote != "" {
|
||||
repo.Root.URL, _, _ = try(ctx, p.root, "config", "--get", "remote."+remote+".url")
|
||||
}
|
||||
|
||||
mods := map[string]info{}
|
||||
subs, err := p.build(ctx, p.root, nil, mods)
|
||||
if err != nil {
|
||||
@@ -141,6 +150,9 @@ func (p *Provider) Status(ctx context.Context, path string) (*submodule.Status,
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if path == "" {
|
||||
return p.rootStatus(ctx)
|
||||
}
|
||||
p.mu.RLock()
|
||||
in, ok := p.mods[path]
|
||||
p.mu.RUnlock()
|
||||
@@ -208,17 +220,79 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := pinState(ctx, in, st, pin, head, branch, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// rootStatus describes the superproject. Its "pin" is HEAD, compared with the
|
||||
// upstream of the current branch; submodules are ignored, their rows show
|
||||
// them.
|
||||
func (p *Provider) rootStatus(ctx context.Context) (*submodule.Status, error) {
|
||||
in := info{superDir: p.root, dir: p.root}
|
||||
st := &submodule.Status{Root: true}
|
||||
if gitDir, _, err := gitDirs(in.dir); err == nil {
|
||||
if fi, err := os.Stat(filepath.Join(gitDir, "FETCH_HEAD")); err == nil {
|
||||
st.FetchedAt = fi.ModTime()
|
||||
}
|
||||
}
|
||||
|
||||
head, err := run(ctx, in.dir, "rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
branch, onBranch, err := try(ctx, in.dir, "symbolic-ref", "--short", "-q", "HEAD")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !onBranch {
|
||||
st.Checkout = submodule.CheckoutDetached
|
||||
}
|
||||
out, err := run(ctx, in.dir, "status", "--porcelain=v2", "-z", "--untracked-files=normal", "--ignore-submodules=all")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := parseStatus(out)
|
||||
// a leftover of --ignore-submodules=all in older git versions would
|
||||
// report the submodules the rows already show
|
||||
entries = slices.DeleteFunc(entries, func(e entry) bool { return e.submodule })
|
||||
st.Worktree = worktreeOf(entries)
|
||||
if st.Changes, err = changes(ctx, in.dir, entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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
|
||||
var chosen *candidate
|
||||
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 {
|
||||
chosen = &candidate{up, submodule.RefFromCurrentBranch}
|
||||
}
|
||||
}
|
||||
if err := pinState(ctx, in, st, head, head, branch, chosen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 {
|
||||
return nil, err
|
||||
return err
|
||||
} else if !ok {
|
||||
return nil, fmt.Errorf("pinned commit %.7s not fetched", pin)
|
||||
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")
|
||||
out, err := run(ctx, in.dir, "for-each-ref", "--contains", pin, "--sort=committerdate", "--format=%(refname)", "refs/remotes")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
var containing []string
|
||||
for _, r := range strings.Fields(out) {
|
||||
@@ -228,44 +302,47 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
|
||||
}
|
||||
st.Pin.Pushed = len(containing) > 0
|
||||
|
||||
ref, source, err := resolve(ctx, in, pin, branch, containing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var ref string
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
st.Pin.Ref, st.Pin.Source = ref, source
|
||||
|
||||
// commits
|
||||
if st.Pinned, err = commit(ctx, in.dir, pin); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
st.CheckedOut = st.Pinned
|
||||
if head != pin {
|
||||
if st.CheckedOut, err = commit(ctx, in.dir, head); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
}
|
||||
if source == submodule.RefFromSHA {
|
||||
st.Pin.Ref = st.Pinned.SHA
|
||||
return st, nil
|
||||
return nil
|
||||
}
|
||||
if st.Tip, err = commit(ctx, in.dir, ref); err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
out, err = run(ctx, in.dir, "rev-list", "--left-right", "--count", pin+"..."+ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
if st.Pin.Ahead, st.Pin.Behind, err = parseLeftRight(out); err != nil {
|
||||
return nil, err
|
||||
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 err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
st.Between = parseCommits(out)
|
||||
}
|
||||
return st, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolve finds the ref that describes the pin, following the order in
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
)
|
||||
|
||||
// restoreArgs are prepended to every git command of a restore: the user's
|
||||
// submodule.recurse must not make checkout and reset recurse behind our back,
|
||||
// the restore walks the submodules itself.
|
||||
var restoreArgs = []string{"-c", "submodule.recurse=false"}
|
||||
|
||||
// maxWalk bounds the entries visited when looking for repositories inside an
|
||||
// untracked directory.
|
||||
const maxWalk = 20000
|
||||
|
||||
// gitlink is a submodule entry of a tree or the index.
|
||||
type gitlink struct{ path, sha string }
|
||||
|
||||
// RestoreLoss reports what Restore(path) would discard. It walks the
|
||||
// repositories on disk, not the loaded tree.
|
||||
func (p *Provider) RestoreLoss(ctx context.Context, path string, to submodule.RestoreTarget) (submodule.Loss, error) {
|
||||
dir, target, err := p.restoreTarget(ctx, path, to)
|
||||
if err != nil {
|
||||
return submodule.Loss{}, err
|
||||
}
|
||||
if dir == "" { // not initialized: nothing on disk to lose
|
||||
return submodule.Loss{}, nil
|
||||
}
|
||||
return p.lossOf(ctx, dir, target), nil
|
||||
}
|
||||
|
||||
// Restore makes path ("" is the superproject) and all submodules below it
|
||||
// exactly what git records, see docs/actions.md. Staged pins are kept. With
|
||||
// RestoreCommitted, a submodule's own staged pin is unstaged first. A missing
|
||||
// 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")
|
||||
}
|
||||
p.mu.RLock()
|
||||
in, ok := p.mods[path]
|
||||
p.mu.RUnlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown submodule %q", path)
|
||||
}
|
||||
pin, err := pinOf(ctx, in, to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if to == submodule.RestoreCommitted {
|
||||
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+pin+","+in.relPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !initialized(in.dir) {
|
||||
if err := p.initSubmodules(ctx, in.superDir, []string{in.relPath}); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return p.restoreRepo(ctx, in.dir, pin)
|
||||
}
|
||||
|
||||
// restoreTarget returns the directory and the commit path is restored to:
|
||||
// HEAD for the superproject, else the pin to. The directory is "" if the
|
||||
// submodule is not initialized.
|
||||
func (p *Provider) restoreTarget(ctx context.Context, path string, to submodule.RestoreTarget) (dir, target string, err error) {
|
||||
if path == "" {
|
||||
return p.root, "HEAD", nil
|
||||
}
|
||||
p.mu.RLock()
|
||||
in, ok := p.mods[path]
|
||||
p.mu.RUnlock()
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unknown submodule %q", path)
|
||||
}
|
||||
pin, err := pinOf(ctx, in, to)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !initialized(in.dir) {
|
||||
return "", pin, nil
|
||||
}
|
||||
return in.dir, pin, nil
|
||||
}
|
||||
|
||||
// pinOf returns the gitlink of a submodule in its parent's index (staged)
|
||||
// or HEAD (committed).
|
||||
func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, error) {
|
||||
if to == submodule.RestoreCommitted {
|
||||
out, _, err := try(ctx, in.superDir, "ls-tree", "HEAD", "--", in.relPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if pin := parseGitlink(out); pin != "" {
|
||||
return pin, nil
|
||||
}
|
||||
return "", errors.New("has no committed pin (added, but not committed yet)")
|
||||
}
|
||||
out, err := run(ctx, in.superDir, "ls-files", "-s", "--", in.relPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pin := parseGitlink(out)
|
||||
if pin == "" {
|
||||
return "", errors.New("has no gitlink in the superproject index (stale .gitmodules entry?)")
|
||||
}
|
||||
return pin, nil
|
||||
}
|
||||
|
||||
// --- restore -----------------------------------------------------------------
|
||||
|
||||
// restoreRepo restores the initialized repository at dir to target (a commit
|
||||
// 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 {
|
||||
env := p.networkEnv()
|
||||
g := func(args ...string) (string, error) {
|
||||
return p.exec(ctx, dir, env, append(append([]string{}, restoreArgs...), args...)...)
|
||||
}
|
||||
|
||||
sha, err := g("rev-parse", "--verify", "-q", target+"^{commit}")
|
||||
if err != nil {
|
||||
if sha, err = p.fetchMissing(ctx, dir, target); err != nil {
|
||||
return fmt.Errorf("%s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
if gitDir, _, err := gitDirs(dir); err == nil {
|
||||
p.clearOperations(ctx, dir, gitDir)
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
} else if keep, err = stagedPins(ctx, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := g("reset", "--hard", "-q"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range keep {
|
||||
if _, err := g("update-index", "--cacheinfo", "160000,"+l.sha+","+l.path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// -ff also removes untracked repositories; ignored files stay
|
||||
if _, err := g("clean", "-ffdq"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := g("submodule", "sync", "-q"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
links, err := indexGitlinks(ctx, dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var missing []string
|
||||
for _, l := range links {
|
||||
if !initialized(filepath.Join(dir, filepath.FromSlash(l.path))) {
|
||||
missing = append(missing, l.path)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
if err := p.initSubmodules(ctx, dir, missing); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// the children are independent repositories
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(links))
|
||||
for i, l := range links {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := p.restoreRepo(ctx, filepath.Join(dir, filepath.FromSlash(l.path)), l.sha); err != nil {
|
||||
errs[i] = err
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// networkEnv is the environment of commands that may clone or fetch.
|
||||
func (p *Provider) networkEnv() []string {
|
||||
return append(append([]string{}, p.sshEnv...), p.extraEnv...)
|
||||
}
|
||||
|
||||
// initSubmodules clones or checks out the given submodules of the repository
|
||||
// at dir, whatever their active and update settings say (--checkout overrides
|
||||
// update = none/rebase/merge, the explicit paths override inactive).
|
||||
func (p *Provider) initSubmodules(ctx context.Context, dir string, rels []string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, fetchTimeout*time.Duration(len(rels)))
|
||||
defer cancel()
|
||||
args := append(append([]string{}, restoreArgs...), "submodule", "update", "--init", "--checkout", "--force", "-q", "--")
|
||||
_, err := p.exec(ctx, dir, p.networkEnv(), append(args, rels...)...)
|
||||
return err
|
||||
}
|
||||
|
||||
// exec runs git like runEnv, but counts against the concurrency limit. Hold
|
||||
// it only for the command, never while recursing.
|
||||
func (p *Provider) exec(ctx context.Context, dir string, env []string, args ...string) (string, error) {
|
||||
select {
|
||||
case p.sem <- struct{}{}:
|
||||
defer func() { <-p.sem }()
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
return runEnv(ctx, dir, env, args...)
|
||||
}
|
||||
|
||||
// fetchMissing fetches all remotes of dir to get target, and returns its SHA.
|
||||
func (p *Provider) fetchMissing(ctx context.Context, dir, target string) (string, error) {
|
||||
fctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
||||
defer cancel()
|
||||
// a failing fetch is reported with the missing commit below
|
||||
_, ferr := runEnv(fctx, dir, p.networkEnv(), "fetch", "--all", "--no-recurse-submodules", "--quiet")
|
||||
sha, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", target+"^{commit}")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
msg := fmt.Sprintf("commit %.7s is not available", target)
|
||||
if ferr != nil {
|
||||
msg += " (fetch failed: " + firstLine(ferr) + ")"
|
||||
} else {
|
||||
msg += " on any remote"
|
||||
}
|
||||
return "", errors.New(msg)
|
||||
}
|
||||
return sha, nil
|
||||
}
|
||||
|
||||
func firstLine(err error) string {
|
||||
var ge *gitError
|
||||
msg := err.Error()
|
||||
if errors.As(err, &ge) && ge.Stderr != "" {
|
||||
msg = ge.Stderr
|
||||
}
|
||||
line, _, _ := strings.Cut(msg, "\n")
|
||||
return strings.TrimPrefix(line, "fatal: ")
|
||||
}
|
||||
|
||||
// clearOperations ends a merge, rebase, cherry-pick or revert in progress
|
||||
// without moving any ref (--quit, not --abort).
|
||||
func (p *Provider) clearOperations(ctx context.Context, dir, gitDir string) {
|
||||
exists := func(name string) bool { _, err := os.Stat(filepath.Join(gitDir, name)); return err == nil }
|
||||
if exists("rebase-merge") || exists("rebase-apply") {
|
||||
_, _, _ = try(ctx, dir, "rebase", "--quit")
|
||||
}
|
||||
if exists("CHERRY_PICK_HEAD") || exists("sequencer") {
|
||||
_, _, _ = try(ctx, dir, "cherry-pick", "--quit")
|
||||
}
|
||||
if exists("REVERT_HEAD") {
|
||||
_, _, _ = try(ctx, dir, "revert", "--quit")
|
||||
}
|
||||
// a merge is ended by the reset --hard that follows
|
||||
}
|
||||
|
||||
// treeGitlinks lists the submodules recorded in commit's tree, at any depth
|
||||
// of directories.
|
||||
func treeGitlinks(ctx context.Context, dir, commit string) ([]gitlink, error) {
|
||||
out, err := run(ctx, dir, "ls-tree", "-r", "-z", commit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var links []gitlink
|
||||
for _, rec := range strings.Split(out, "\x00") {
|
||||
meta, path, ok := strings.Cut(rec, "\t")
|
||||
if f := strings.Fields(meta); ok && len(f) == 3 && f[0] == "160000" {
|
||||
links = append(links, gitlink{path: path, sha: f[2]})
|
||||
}
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
// indexGitlinks lists the submodules in the index of the repository at dir.
|
||||
func indexGitlinks(ctx context.Context, dir string) ([]gitlink, error) {
|
||||
out, err := run(ctx, dir, "ls-files", "-s", "-z")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var links []gitlink
|
||||
for _, rec := range strings.Split(out, "\x00") {
|
||||
meta, path, ok := strings.Cut(rec, "\t")
|
||||
if f := strings.Fields(meta); ok && len(f) == 3 && f[0] == "160000" && f[2] == "0" {
|
||||
links = append(links, gitlink{path: path, sha: f[1]})
|
||||
}
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
// stagedPins lists the gitlinks of the index of dir that differ from HEAD's,
|
||||
// i.e. staged pins of submodules that HEAD has. A submodule that was added
|
||||
// but not committed is not one: its .gitmodules entry is not kept either.
|
||||
func stagedPins(ctx context.Context, dir string) ([]gitlink, error) {
|
||||
index, err := indexGitlinks(ctx, dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tree, err := treeGitlinks(ctx, dir, "HEAD")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
committed := map[string]string{}
|
||||
for _, l := range tree {
|
||||
committed[l.path] = l.sha
|
||||
}
|
||||
var staged []gitlink
|
||||
for _, l := range index {
|
||||
if c, ok := committed[l.path]; ok && c != l.sha {
|
||||
staged = append(staged, l)
|
||||
}
|
||||
}
|
||||
return staged, nil
|
||||
}
|
||||
|
||||
// --- loss --------------------------------------------------------------------
|
||||
|
||||
// lossOf adds up what a restore of the repository at dir to target
|
||||
// discards, including the submodules on disk below it. target is "" if it is
|
||||
// not known; then commits are only judged by the refs that reach them.
|
||||
func (p *Provider) lossOf(ctx context.Context, dir, target string) submodule.Loss {
|
||||
var own submodule.Loss
|
||||
var children []gitlink
|
||||
var targets map[string]string
|
||||
unknown := func() { own.Unknown++ }
|
||||
|
||||
func() {
|
||||
select {
|
||||
case p.sem <- struct{}{}:
|
||||
defer func() { <-p.sem }()
|
||||
case <-ctx.Done():
|
||||
unknown()
|
||||
return
|
||||
}
|
||||
|
||||
out, err := run(ctx, dir, "status", "--porcelain=v2", "-z", "--untracked-files=normal", "--ignore-submodules=all")
|
||||
if err != nil {
|
||||
unknown()
|
||||
return
|
||||
}
|
||||
for _, e := range parseStatus(out) {
|
||||
if e.submodule {
|
||||
continue // restored without loss
|
||||
}
|
||||
own.Files++
|
||||
if e.kind == submodule.ChangeUntracked && strings.HasSuffix(e.path, "/") {
|
||||
own.Repositories += standaloneRepos(ctx, filepath.Join(dir, filepath.FromSlash(e.path)))
|
||||
}
|
||||
}
|
||||
|
||||
if gitDir, _, err := gitDirs(dir); err == nil {
|
||||
for _, name := range []string{"rebase-merge", "rebase-apply", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "sequencer"} {
|
||||
if _, err := os.Stat(filepath.Join(gitDir, name)); err == nil {
|
||||
own.Operations++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// commits only this checkout reaches are lost if the checkout moves
|
||||
head, err := run(ctx, dir, "rev-parse", "--verify", "-q", "HEAD")
|
||||
if err != nil {
|
||||
unknown()
|
||||
} else {
|
||||
moves := target == ""
|
||||
if !moves {
|
||||
if sha, ok, _ := try(ctx, dir, "rev-parse", "--verify", "-q", target+"^{commit}"); ok {
|
||||
moves = sha != head
|
||||
} else {
|
||||
moves = true
|
||||
}
|
||||
}
|
||||
if moves {
|
||||
n, ok, _ := try(ctx, dir, "rev-list", "--count", "HEAD", "--not", "--branches", "--tags", "--remotes")
|
||||
if ok {
|
||||
fmt.Sscan(n, &own.Commits)
|
||||
} else {
|
||||
unknown()
|
||||
}
|
||||
}
|
||||
|
||||
// staged pins are kept, unless the checkout moves away from
|
||||
// the commit they were staged on; then the submodules follow
|
||||
// the target's tree
|
||||
staged, err := stagedPins(ctx, dir)
|
||||
if err != nil {
|
||||
unknown()
|
||||
}
|
||||
var links []gitlink
|
||||
switch {
|
||||
case !moves:
|
||||
links, err = indexGitlinks(ctx, dir)
|
||||
case target != "":
|
||||
own.StagedPins = len(staged)
|
||||
links, err = treeGitlinks(ctx, dir, target)
|
||||
default:
|
||||
own.StagedPins = len(staged)
|
||||
}
|
||||
if err == nil && links != nil {
|
||||
targets = map[string]string{}
|
||||
for _, l := range links {
|
||||
targets[l.path] = l.sha
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if children, err = indexGitlinks(ctx, dir); err != nil {
|
||||
unknown()
|
||||
}
|
||||
}()
|
||||
|
||||
if own.Files+own.Operations+own.Commits+own.Repositories+own.StagedPins+own.Unknown > 0 {
|
||||
own.Modules = 1
|
||||
}
|
||||
|
||||
// the children on disk, concurrently
|
||||
results := make([]submodule.Loss, len(children))
|
||||
var wg sync.WaitGroup
|
||||
for i, c := range children {
|
||||
sub := filepath.Join(dir, filepath.FromSlash(c.path))
|
||||
if !initialized(sub) {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results[i] = p.lossOf(ctx, sub, targets[c.path])
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
for _, r := range results {
|
||||
own = own.Add(r)
|
||||
}
|
||||
return own
|
||||
}
|
||||
|
||||
// standaloneRepos counts the repositories with their own .git directory in
|
||||
// the untracked directory dir (or dir itself). An absorbed submodule leaves
|
||||
// only a .git file; its commits live on in .git/modules.
|
||||
func standaloneRepos(ctx context.Context, dir string) int {
|
||||
n, seen := 0, 0
|
||||
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if seen++; err != nil || seen > maxWalk || ctx.Err() != nil {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
if d.IsDir() && d.Name() == ".git" {
|
||||
n++
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
)
|
||||
|
||||
// out runs git and returns its trimmed stdout.
|
||||
func out(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
b, err := exec.Command("git", append([]string{"-C", dir}, args...)...).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v", args, err)
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool { _, err := os.Stat(path); return err == nil }
|
||||
|
||||
// assertExact checks that the superproject at dir and every submodule below
|
||||
// it is initialized, on its pin (staged ones included) and has no change
|
||||
// besides staged pins.
|
||||
func assertExact(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if s := out(t, dir, "diff", "--name-only", "--ignore-submodules=dirty"); s != "" { // worktree vs index
|
||||
t.Errorf("%s not clean:\n%s", dir, s)
|
||||
}
|
||||
if s := out(t, dir, "ls-files", "--others", "--exclude-standard"); s != "" {
|
||||
t.Errorf("%s has untracked files:\n%s", dir, s)
|
||||
}
|
||||
raw, err := exec.Command("git", "-C", dir, "submodule", "status", "--recursive").Output() // the prefix is a leading space
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if line[0] != ' ' {
|
||||
t.Errorf("submodule not exact: %s", line)
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(line)
|
||||
sub := filepath.Join(dir, f[1])
|
||||
if s := out(t, sub, "diff", "--name-only", "--ignore-submodules=dirty"); s != "" {
|
||||
t.Errorf("%s not clean:\n%s", f[1], s)
|
||||
}
|
||||
if s := out(t, sub, "ls-files", "--others", "--exclude-standard"); s != "" {
|
||||
t.Errorf("%s has untracked files:\n%s", f[1], s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreSuperprojectMakesEverythingExact(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
ctx := context.Background()
|
||||
|
||||
// ignored files stay
|
||||
if err := os.WriteFile(filepath.Join(super, ".git", "info", "exclude"), []byte("keep.log\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keep := filepath.Join(super, "libs", "work", "keep.log")
|
||||
// libs/work is a submodule: its own exclude file lives in its git dir
|
||||
wkGit := out(t, filepath.Join(super, "libs", "work"), "rev-parse", "--absolute-git-dir")
|
||||
if err := os.WriteFile(filepath.Join(wkGit, "info", "exclude"), []byte("keep.log\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(keep, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// a branch must not move
|
||||
conflict := filepath.Join(super, "libs", "conflict")
|
||||
mainTip := out(t, conflict, "rev-parse", "main")
|
||||
|
||||
if err := p.Restore(ctx, "", submodule.RestoreStaged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertExact(t, super)
|
||||
if got := out(t, conflict, "rev-parse", "main"); got != mainTip {
|
||||
t.Errorf("branch main moved: %s -> %s", mainTip, got)
|
||||
}
|
||||
if !exists(keep) {
|
||||
t.Error("ignored file was deleted")
|
||||
}
|
||||
if exists(filepath.Join(conflict, ".git", "MERGE_HEAD")) || exists(filepath.Join(out(t, conflict, "rev-parse", "--absolute-git-dir"), "MERGE_HEAD")) {
|
||||
t.Error("merge still in progress")
|
||||
}
|
||||
if !exists(filepath.Join(super, "libs", "missing", ".git")) {
|
||||
t.Error("libs/missing was not initialized")
|
||||
}
|
||||
// the fixture stages libs/staged and libs/conflict
|
||||
if got := out(t, super, "diff", "--cached", "--name-only"); got != "libs/conflict\nlibs/staged" {
|
||||
t.Errorf("staged = %q, want the staged pins kept", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreSubmoduleKeepsStagedPinAsTarget(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
staged := out(t, filepath.Join(super, "libs", "staged"), "rev-parse", "HEAD")
|
||||
// the fixture staged the checked-out commit; move the checkout away
|
||||
git(t, filepath.Join(super, "libs", "staged"), "checkout", "-q", "--detach", "HEAD~1")
|
||||
|
||||
if err := p.Restore(context.Background(), "libs/staged", submodule.RestoreStaged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := out(t, filepath.Join(super, "libs", "staged"), "rev-parse", "HEAD"); got != staged {
|
||||
t.Errorf("HEAD = %s, want the staged pin %s", got, staged)
|
||||
}
|
||||
if !strings.Contains(out(t, super, "diff", "--cached", "--name-only"), "libs/staged") {
|
||||
t.Error("restoring a submodule must keep its staged pin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreLoss(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
ctx := context.Background()
|
||||
|
||||
loss := func(path string) submodule.Loss {
|
||||
t.Helper()
|
||||
l, err := p.RestoreLoss(ctx, path, submodule.RestoreStaged)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// off pin and missing lose nothing; neither does an unpushed pin
|
||||
for _, path := range []string{"src/core", "libs/missing", "src/net", "libs/staged"} {
|
||||
if l := loss(path); !l.Empty() {
|
||||
t.Errorf("%s: loss %+v, want none", path, l)
|
||||
}
|
||||
}
|
||||
|
||||
// libs/work: 6 files and a directory, untracked and changed
|
||||
if l := loss("libs/work"); l.Files < 6 || l.Modules != 1 {
|
||||
t.Errorf("libs/work: %+v", l)
|
||||
}
|
||||
// a nested change counts for its ancestors
|
||||
if l := loss("libs/log"); l.Files != 1 || l.Modules != 1 {
|
||||
t.Errorf("libs/log: %+v, want the nested change", l)
|
||||
}
|
||||
// a merge in progress
|
||||
if l := loss("libs/conflict"); l.Operations != 1 || l.Files == 0 {
|
||||
t.Errorf("libs/conflict: %+v", l)
|
||||
}
|
||||
|
||||
// commits: detached and unreachable are lost, on a branch they are not
|
||||
ui := filepath.Join(super, "src", "ui")
|
||||
git(t, ui, "checkout", "-q", "--detach")
|
||||
git(t, ui, "commit", "-q", "--allow-empty", "-m", "detached work")
|
||||
if l := loss("src/ui"); l.Commits != 1 {
|
||||
t.Errorf("detached commit: %+v", l)
|
||||
}
|
||||
git(t, ui, "switch", "-q", "-c", "wip")
|
||||
if l := loss("src/ui"); l.Commits != 0 {
|
||||
t.Errorf("commit on a branch: %+v", l)
|
||||
}
|
||||
|
||||
// a clone nobody added as a submodule
|
||||
git(t, super, "clone", "-q", filepath.Join(root, "remotes", "core.git"), "stray")
|
||||
if l := loss(""); l.Repositories != 1 {
|
||||
t.Errorf("root: %+v, want one standalone repository", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreRemovesLeftoversAndEndsRebase(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
ctx := context.Background()
|
||||
|
||||
// a standalone clone is deleted
|
||||
git(t, super, "clone", "-q", filepath.Join(root, "remotes", "core.git"), "stray")
|
||||
|
||||
// a submodule the branch no longer has leaves its directory behind
|
||||
git(t, super, "rm", "-q", "--cached", "libs/topic")
|
||||
git(t, super, "commit", "-q", "-m", "Drop topic")
|
||||
|
||||
// a rebase in progress in vendor; the branches must survive
|
||||
v := filepath.Join(super, "vendor")
|
||||
base := out(t, v, "rev-parse", "HEAD")
|
||||
git(t, v, "switch", "-q", "-c", "x")
|
||||
os.WriteFile(filepath.Join(v, "README"), []byte("x\n"), 0o644)
|
||||
git(t, v, "commit", "-q", "-am", "x")
|
||||
git(t, v, "switch", "-q", "-c", "y", base)
|
||||
os.WriteFile(filepath.Join(v, "README"), []byte("y\n"), 0o644)
|
||||
git(t, v, "commit", "-q", "-am", "y")
|
||||
yTip := out(t, v, "rev-parse", "y")
|
||||
_ = exec.Command("git", "-C", v, "rebase", "x").Run() // conflicts
|
||||
if !exists(filepath.Join(out(t, v, "rev-parse", "--absolute-git-dir"), "rebase-merge")) {
|
||||
t.Fatal("setup: no rebase in progress")
|
||||
}
|
||||
|
||||
if err := p.Restore(ctx, "", submodule.RestoreStaged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, gone := range []string{"stray", "libs/topic"} {
|
||||
if exists(filepath.Join(super, gone)) {
|
||||
t.Errorf("%s still exists", gone)
|
||||
}
|
||||
}
|
||||
if exists(filepath.Join(out(t, v, "rev-parse", "--absolute-git-dir"), "rebase-merge")) {
|
||||
t.Error("rebase still in progress")
|
||||
}
|
||||
if got := out(t, v, "rev-parse", "y"); got != yTip {
|
||||
t.Errorf("branch y moved: %s -> %s", yTip, got)
|
||||
}
|
||||
assertExact(t, super)
|
||||
}
|
||||
|
||||
func TestRestoreClonesAMissingSubmodule(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
p.extraEnv = []string{"GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=protocol.file.allow", "GIT_CONFIG_VALUE_0=always"}
|
||||
super := filepath.Join(root, "super")
|
||||
|
||||
// no object database left: it must be cloned again
|
||||
if err := os.RemoveAll(filepath.Join(super, ".git", "modules", "libs", "missing")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.Restore(context.Background(), "libs/missing", submodule.RestoreStaged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(filepath.Join(super, "libs", "missing", ".git")) {
|
||||
t.Error("not cloned")
|
||||
}
|
||||
if got, want := out(t, filepath.Join(super, "libs", "missing"), "rev-parse", "HEAD"), strings.Fields(out(t, super, "ls-files", "-s", "libs/missing"))[1]; got != want {
|
||||
t.Errorf("HEAD = %s, want pin %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreReportsAnUnavailablePin(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
// pin a commit that exists on no remote and not in the submodule
|
||||
core := filepath.Join(super, "src", "core")
|
||||
bogus := "0123456789abcdef0123456789abcdef01234567"
|
||||
git(t, super, "update-index", "--cacheinfo", "160000,"+bogus+",src/core")
|
||||
_ = core
|
||||
err := p.Restore(context.Background(), "src/core", submodule.RestoreStaged)
|
||||
if err == nil || !strings.Contains(err.Error(), "not available") {
|
||||
t.Fatalf("err = %v, want a message about the unavailable commit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreIgnoresUpdateNoneAndInactive(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
git(t, super, "config", "submodule.libs/missing.update", "none")
|
||||
git(t, super, "config", "submodule.libs/missing.active", "false")
|
||||
git(t, super, "config", "submodule.active", "src")
|
||||
|
||||
if err := p.Restore(context.Background(), "", submodule.RestoreStaged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(filepath.Join(super, "libs", "missing", ".git")) {
|
||||
t.Error("libs/missing not initialized")
|
||||
}
|
||||
assertExact(t, super)
|
||||
}
|
||||
|
||||
func TestRestoreKeepsNestedStagedPinAndResetsToIt(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
log := filepath.Join(super, "libs", "log")
|
||||
nested := filepath.Join(log, "modules", "nested")
|
||||
|
||||
// stage a new pin of nested in libs/log, then change nested again
|
||||
git(t, nested, "commit", "-q", "-am", "Nested work")
|
||||
staged := out(t, nested, "rev-parse", "HEAD")
|
||||
git(t, log, "add", "modules/nested")
|
||||
os.WriteFile(filepath.Join(nested, "README"), []byte("more\n"), 0o644)
|
||||
|
||||
if l, err := p.RestoreLoss(context.Background(), "", submodule.RestoreStaged); err != nil || l.StagedPins != 0 || l.Files == 0 {
|
||||
t.Fatalf("loss %+v, %v: want the change, not the staged pin", l, err)
|
||||
}
|
||||
if err := p.Restore(context.Background(), "", submodule.RestoreStaged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := out(t, log, "diff", "--cached", "--name-only"); got != "modules/nested" {
|
||||
t.Errorf("libs/log staged = %q, want the nested pin", got)
|
||||
}
|
||||
if got := out(t, nested, "rev-parse", "HEAD"); got != staged {
|
||||
t.Errorf("nested HEAD = %s, want the staged pin %s", got, staged)
|
||||
}
|
||||
assertExact(t, super)
|
||||
}
|
||||
|
||||
func TestRestoreCommittedUnstagesThePin(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
committed := strings.Fields(out(t, super, "ls-tree", "HEAD", "libs/staged"))[2]
|
||||
|
||||
if err := p.Restore(context.Background(), "libs/staged", submodule.RestoreCommitted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := out(t, super, "diff", "--cached", "--name-only"); strings.Contains(got, "libs/staged") {
|
||||
t.Errorf("still staged: %q", got)
|
||||
}
|
||||
if got := out(t, filepath.Join(super, "libs", "staged"), "rev-parse", "HEAD"); got != committed {
|
||||
t.Errorf("HEAD = %s, want the committed pin %s", got, committed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreLossCountsStagedPinsOfAMovingRepository(t *testing.T) {
|
||||
p, root := ownFixture(t)
|
||||
super := filepath.Join(root, "super")
|
||||
log := filepath.Join(super, "libs", "log")
|
||||
nested := filepath.Join(log, "modules", "nested")
|
||||
|
||||
// libs/log moves on (off pin, on its branch) and stages a nested pin
|
||||
git(t, nested, "commit", "-q", "-am", "Nested work")
|
||||
git(t, log, "add", "modules/nested")
|
||||
git(t, log, "commit", "-q", "--allow-empty", "-m", "Log work")
|
||||
git(t, nested, "commit", "-q", "--allow-empty", "-m", "More nested work")
|
||||
git(t, log, "add", "modules/nested")
|
||||
|
||||
l, err := p.RestoreLoss(context.Background(), "libs/log", submodule.RestoreStaged)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// the nested commits are on a detached HEAD that moves back: lost too
|
||||
if l.StagedPins != 1 || l.Commits != 2 {
|
||||
t.Errorf("loss %+v, want 1 staged pin and the 2 detached nested commits", l)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
)
|
||||
|
||||
// rootFixture is ownFixture with a bare origin for the superproject, main
|
||||
// pushed and tracking it. It returns the provider, the fixture root and the
|
||||
// path of the bare repository.
|
||||
func rootFixture(t *testing.T) (*Provider, string, string) {
|
||||
t.Helper()
|
||||
p, root := ownFixture(t)
|
||||
bare := filepath.Join(root, "remotes", "super.git")
|
||||
git(t, root, "init", "-q", "--bare", bare)
|
||||
git(t, p.root, "remote", "add", "origin", bare)
|
||||
git(t, p.root, "push", "-q", "-u", "origin", "main")
|
||||
return p, root, bare
|
||||
}
|
||||
|
||||
func rootStatus(t *testing.T, p *Provider) *submodule.Status {
|
||||
t.Helper()
|
||||
st, err := p.Status(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !st.Root {
|
||||
t.Error("Status.Root = false for the superproject")
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func issueKinds(st *submodule.Status) []submodule.IssueKind {
|
||||
var kinds []submodule.IssueKind
|
||||
for _, is := range st.Issues() {
|
||||
kinds = append(kinds, is.Kind)
|
||||
}
|
||||
return kinds
|
||||
}
|
||||
|
||||
func TestTreeRoot(t *testing.T) {
|
||||
p, root, bare := rootFixture(t)
|
||||
repo, err := p.Tree(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := repo.Root; r == nil || !r.IsRoot() || r.Name != "super" || r.URL != bare || len(r.Children) != 0 {
|
||||
t.Errorf("Root = %+v (fixture %s)", r, root)
|
||||
}
|
||||
}
|
||||
|
||||
// The fixture has dirty, conflicting and staged submodules; none of them
|
||||
// makes the superproject itself changed, their rows show them.
|
||||
func TestRootStatusIgnoresSubmodules(t *testing.T) {
|
||||
p, _, _ := rootFixture(t)
|
||||
st := rootStatus(t, p)
|
||||
if kinds := issueKinds(st); len(kinds) != 0 {
|
||||
t.Errorf("issues = %v, want none", kinds)
|
||||
}
|
||||
if st.Changes != nil || st.PinStaged || st.Presence != submodule.PresenceOK {
|
||||
t.Errorf("Changes = %+v, PinStaged = %v", st.Changes, st.PinStaged)
|
||||
}
|
||||
if st.Pin.Ref != "origin/main" || st.Pin.Source != submodule.RefFromCurrentBranch || !st.Pin.Pushed {
|
||||
t.Errorf("Pin = %+v", st.Pin)
|
||||
}
|
||||
if st.Pinned == nil || st.Pinned != st.CheckedOut && *st.Pinned != *st.CheckedOut {
|
||||
t.Errorf("Pinned = %+v, CheckedOut = %+v, want HEAD for both", st.Pinned, st.CheckedOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootStatusUnpushed(t *testing.T) {
|
||||
p, _, _ := rootFixture(t)
|
||||
git(t, p.root, "commit", "--allow-empty", "-m", "Local work")
|
||||
st := rootStatus(t, p)
|
||||
if kinds := issueKinds(st); !slices.Equal(kinds, []submodule.IssueKind{submodule.IssueUnpushed}) {
|
||||
t.Errorf("issues = %v, want unpushed", kinds)
|
||||
}
|
||||
if st.Pin.Ahead != 1 || st.Pin.Behind != 0 {
|
||||
t.Errorf("ahead/behind = %d/%d, want 1/0", st.Pin.Ahead, st.Pin.Behind)
|
||||
}
|
||||
if d := st.Issues()[0].Detail; d != "HEAD not pushed to any remote" {
|
||||
t.Errorf("detail = %q", d)
|
||||
}
|
||||
}
|
||||
|
||||
// A submodule the current branch no longer has stays on disk after a branch
|
||||
// switch (its .git is a file into .git/modules). It is an untracked
|
||||
// repository of the superproject.
|
||||
func TestRootStatusLeftoverSubmodule(t *testing.T) {
|
||||
p, _, _ := rootFixture(t)
|
||||
git(t, p.root, "switch", "-q", "-c", "nosub")
|
||||
git(t, p.root, "rm", "-q", "--cached", "src/ui")
|
||||
git(t, p.root, "commit", "-q", "-m", "Drop src/ui")
|
||||
git(t, p.root, "switch", "-q", "main")
|
||||
git(t, p.root, "switch", "-q", "nosub")
|
||||
if _, err := os.Stat(filepath.Join(p.root, "src", "ui", ".git")); err != nil {
|
||||
t.Fatalf("src/ui should be left behind: %v", err)
|
||||
}
|
||||
|
||||
st := rootStatus(t, p)
|
||||
if kinds := issueKinds(st); !slices.Equal(kinds, []submodule.IssueKind{submodule.IssueUnpushed, submodule.IssueChanged}) {
|
||||
t.Fatalf("issues = %v, want unpushed (new branch) and changed", kinds)
|
||||
}
|
||||
if d := st.Issues()[1].Detail; d != "untracked repository" {
|
||||
t.Errorf("detail = %q, want untracked repository", d)
|
||||
}
|
||||
if st.Changes == nil || len(st.Changes.Files) != 1 {
|
||||
t.Fatalf("Changes = %+v", st.Changes)
|
||||
}
|
||||
if f := st.Changes.Files[0]; f.Path != "src/ui/" || f.Kind != submodule.ChangeUntracked || !f.Repo {
|
||||
t.Errorf("file = %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootStatusModified(t *testing.T) {
|
||||
p, _, _ := rootFixture(t)
|
||||
if err := os.WriteFile(filepath.Join(p.root, "README"), []byte("edited\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := rootStatus(t, p)
|
||||
if kinds := issueKinds(st); !slices.Equal(kinds, []submodule.IssueKind{submodule.IssueChanged}) {
|
||||
t.Errorf("issues = %v, want changed", kinds)
|
||||
}
|
||||
if st.Worktree != submodule.WorktreeModified {
|
||||
t.Errorf("Worktree = %v", st.Worktree)
|
||||
}
|
||||
}
|
||||
|
||||
// The ref of the superproject is the upstream of its branch, even if another
|
||||
// remote branch is closer, so that it agrees with the header's ahead/behind.
|
||||
func TestRootStatusRefIsUpstream(t *testing.T) {
|
||||
p, root, bare := rootFixture(t)
|
||||
other := filepath.Join(root, "other")
|
||||
git(t, root, "clone", "-q", bare, other)
|
||||
git(t, other, "switch", "-q", "-c", "feat")
|
||||
git(t, other, "commit", "--allow-empty", "-m", "Feature")
|
||||
git(t, other, "push", "-q", "origin", "feat")
|
||||
git(t, other, "switch", "-q", "main")
|
||||
for range 3 {
|
||||
git(t, other, "commit", "--allow-empty", "-m", "Upstream")
|
||||
}
|
||||
git(t, other, "push", "-q", "origin", "main")
|
||||
git(t, p.root, "fetch", "-q")
|
||||
|
||||
st := rootStatus(t, p)
|
||||
if st.Pin.Ref != "origin/main" || st.Pin.Source != submodule.RefFromCurrentBranch {
|
||||
t.Errorf("ref = %q (%v), want origin/main via the current branch", st.Pin.Ref, st.Pin.Source)
|
||||
}
|
||||
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) {
|
||||
p, _, _ := rootFixture(t)
|
||||
git(t, p.root, "switch", "-q", "--detach")
|
||||
st := rootStatus(t, p)
|
||||
if st.Checkout != submodule.CheckoutDetached {
|
||||
t.Errorf("Checkout = %v, want detached", st.Checkout)
|
||||
}
|
||||
if !st.Pin.Pushed {
|
||||
t.Error("detached HEAD on a pushed commit should be pushed")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,11 @@ func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
|
||||
repo := &submodule.Repo{
|
||||
Path: "~/coding/my_project",
|
||||
Branch: "main",
|
||||
Behind: 2,
|
||||
FetchedAt: time.Now().Add(-2 * time.Minute),
|
||||
}
|
||||
repo.Root = &submodule.Submodule{Name: "my_project", URL: "git@github.com:acme/my_project.git"}
|
||||
p.statuses[""] = rootStatus()
|
||||
repo.Submodules = p.build(fixture(), nil)
|
||||
return repo, nil
|
||||
}
|
||||
@@ -154,6 +157,31 @@ func changes(age time.Duration, files []submodule.FileChange) *submodule.Changes
|
||||
return c
|
||||
}
|
||||
|
||||
// rootStatus is a superproject that is behind its upstream and still has a
|
||||
// clone of a submodule the current branch no longer has.
|
||||
func rootStatus() *submodule.Status {
|
||||
tip := commit("e4f5a6b", "Move docs into a submodule", "anna", 3*time.Hour)
|
||||
head := commit("0a1b2c3", "Update README", "ben", 2*24*time.Hour)
|
||||
st := &submodule.Status{
|
||||
Root: true,
|
||||
Worktree: submodule.WorktreeModified,
|
||||
Pin: submodule.PinInfo{Ref: "origin/main", Behind: 2, Pushed: true, Source: submodule.RefFromCurrentBranch},
|
||||
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),
|
||||
}
|
||||
st.Changes = changes(15*time.Minute, []submodule.FileChange{
|
||||
{Path: "README.md", Kind: submodule.ChangeModified, Added: 4, Removed: 1},
|
||||
{Path: "old_module/", Kind: submodule.ChangeUntracked, Repo: true},
|
||||
})
|
||||
return st
|
||||
}
|
||||
|
||||
func fixture() []node {
|
||||
c1 := commit("3f9a1c2", "Bump golangci-lint to v2", "anna", 26*time.Hour)
|
||||
c2 := commit("a1b2c3d", "Initial import", "ben", 40*24*time.Hour)
|
||||
|
||||
@@ -16,6 +16,7 @@ type Repo struct {
|
||||
Ahead int
|
||||
Behind int
|
||||
FetchedAt time.Time
|
||||
Root *Submodule // the superproject as a selectable node; Path is "", no Children
|
||||
Submodules []*Submodule
|
||||
}
|
||||
|
||||
@@ -32,6 +33,9 @@ type Submodule struct {
|
||||
FetchErr error // set if the last fetch failed; not an issue
|
||||
}
|
||||
|
||||
// IsRoot reports whether s is the superproject itself.
|
||||
func (s *Submodule) IsRoot() bool { return s != nil && s.Path == "" }
|
||||
|
||||
// Presence describes whether the submodule exists on disk.
|
||||
type Presence int
|
||||
|
||||
@@ -171,6 +175,7 @@ func (c *Changes) Sort() {
|
||||
|
||||
// Status is the full state of one submodule.
|
||||
type Status struct {
|
||||
Root bool // the superproject: the "pin" is its HEAD
|
||||
Presence Presence
|
||||
Checkout Checkout
|
||||
Worktree Worktree
|
||||
|
||||
@@ -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.
|
||||
@@ -33,6 +36,78 @@ type Locator interface {
|
||||
Dir(path string) string
|
||||
}
|
||||
|
||||
// Restorer is implemented by providers that can make a repository (and all
|
||||
// submodules below it) exactly what git records. Path "" is the
|
||||
// superproject. Restore may clone or fetch objects that are missing, but must
|
||||
// never prompt the user.
|
||||
type Restorer interface {
|
||||
// RestoreLoss reports what Restore would discard, read from the disk at
|
||||
// this moment.
|
||||
RestoreLoss(ctx context.Context, path string, to RestoreTarget) (Loss, error)
|
||||
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
|
||||
|
||||
const (
|
||||
RestoreStaged RestoreTarget = iota // the pin in the parent's index: a staged pin is kept and used
|
||||
RestoreCommitted // the pin in the parent's HEAD: a staged pin is unstaged
|
||||
)
|
||||
|
||||
// Loss is what a restore discards. The zero Loss means nothing is lost.
|
||||
type Loss struct {
|
||||
Files int // changed, conflicted and untracked files and directories
|
||||
Modules int // repositories with a loss, the repository itself included
|
||||
Operations int // merges, rebases, cherry-picks, reverts in progress
|
||||
Commits int // commits that no branch, tag or remote ref reaches
|
||||
Repositories int // untracked repositories with their own .git directory
|
||||
StagedPins int // staged pins of repositories that move to another commit
|
||||
Unknown int // repositories whose state could not be read
|
||||
}
|
||||
|
||||
// Empty reports whether nothing would be lost.
|
||||
func (l Loss) Empty() bool { return l == Loss{} }
|
||||
|
||||
// Add sums two losses.
|
||||
func (l Loss) Add(o Loss) Loss {
|
||||
return Loss{
|
||||
Files: l.Files + o.Files,
|
||||
Modules: l.Modules + o.Modules,
|
||||
Operations: l.Operations + o.Operations,
|
||||
Commits: l.Commits + o.Commits,
|
||||
Repositories: l.Repositories + o.Repositories,
|
||||
StagedPins: l.StagedPins + o.StagedPins,
|
||||
Unknown: l.Unknown + o.Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
// Change reports that the git metadata of a repository changed.
|
||||
type Change struct {
|
||||
Path string // "" is the superproject
|
||||
|
||||
@@ -43,7 +43,11 @@ func (s *Status) Issues() []Issue {
|
||||
|
||||
var out []Issue
|
||||
if !s.Pin.Pushed {
|
||||
out = append(out, Issue{IssueUnpushed, LevelDanger, "pin not pushed to any remote"})
|
||||
detail := "pin not pushed to any remote"
|
||||
if s.Root {
|
||||
detail = "HEAD not pushed to any remote"
|
||||
}
|
||||
out = append(out, Issue{IssueUnpushed, LevelDanger, detail})
|
||||
}
|
||||
if s.Worktree == WorktreeConflict {
|
||||
out = append(out, Issue{IssueConflict, LevelDanger, "merge conflict"})
|
||||
@@ -70,9 +74,13 @@ func (h Hidden) Total() int { return 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
|
||||
// unknown.
|
||||
func (m *Submodule) HiddenIssues() Hidden {
|
||||
func (m *Submodule) HiddenIssues() Hidden { return HiddenIn(m.Children) }
|
||||
|
||||
// HiddenIn counts the issues and staged pins of mods and all their loaded
|
||||
// descendants, like HiddenIssues.
|
||||
func HiddenIn(mods []*Submodule) Hidden {
|
||||
var h Hidden
|
||||
Walk(m.Children, func(c *Submodule) {
|
||||
Walk(mods, func(c *Submodule) {
|
||||
if c.Status == nil && c.Err != nil {
|
||||
h.Danger++
|
||||
}
|
||||
|
||||
@@ -78,3 +78,14 @@ func TestHiddenIssuesCountLoadErrors(t *testing.T) {
|
||||
t.Errorf("got %+v; want 1 danger", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootIssueWording(t *testing.T) {
|
||||
st := &Status{Root: true}
|
||||
got := st.Issues()
|
||||
if len(got) != 1 || got[0].Kind != IssueUnpushed || got[0].Level != LevelDanger || got[0].Detail != "HEAD not pushed to any remote" {
|
||||
t.Errorf("Issues() = %+v", got)
|
||||
}
|
||||
if !(&Submodule{}).IsRoot() || (&Submodule{Path: "a"}).IsRoot() {
|
||||
t.Error("IsRoot should be true exactly for the empty path")
|
||||
}
|
||||
}
|
||||
|
||||
+94
-23
@@ -15,6 +15,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"
|
||||
@@ -29,17 +30,11 @@ const (
|
||||
detailWidthRatio = 0.4
|
||||
)
|
||||
|
||||
// Options configure the application.
|
||||
type Options struct {
|
||||
Fetch bool // fetch all repositories in the background on startup
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
ctx context.Context
|
||||
provider submodule.Provider
|
||||
fetcher submodule.Fetcher // nil if the provider can't fetch
|
||||
watcher submodule.Watcher // nil if the provider can't watch
|
||||
opts Options
|
||||
theme theme.Theme
|
||||
keys keyMap
|
||||
|
||||
@@ -62,11 +57,16 @@ type Model struct {
|
||||
// exactly then. The input is focused while typing and blurred after enter.
|
||||
filtering bool
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func New(ctx context.Context, p submodule.Provider, opts Options) Model {
|
||||
func New(ctx context.Context, p submodule.Provider) Model {
|
||||
t := theme.Default()
|
||||
k := defaultKeys()
|
||||
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
|
||||
@@ -79,7 +79,6 @@ func New(ctx context.Context, p submodule.Provider, opts Options) Model {
|
||||
m := Model{
|
||||
ctx: ctx,
|
||||
provider: p,
|
||||
opts: opts,
|
||||
theme: t,
|
||||
keys: k,
|
||||
loads: newReloader(),
|
||||
@@ -163,10 +162,10 @@ 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 {
|
||||
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 := false
|
||||
loading := m.repo.Root != nil && m.repo.Root.Status == nil && m.repo.Root.Err == nil
|
||||
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
|
||||
loading = loading || (s.Status == nil && s.Err == nil)
|
||||
})
|
||||
@@ -199,6 +198,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
s.Status, s.Err = msg.status, nil
|
||||
}
|
||||
m.tree.Refit()
|
||||
}
|
||||
if m.loads.done(msg.path) {
|
||||
return m, m.loadStatus(msg.path)
|
||||
@@ -208,6 +208,15 @@ 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)
|
||||
|
||||
case restoreDoneMsg:
|
||||
return m.restoreDone(msg)
|
||||
|
||||
case fetchDoneMsg:
|
||||
m.fetch.Done++
|
||||
if msg.err != nil {
|
||||
@@ -234,6 +243,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
|
||||
|
||||
@@ -267,7 +279,7 @@ func (m Model) treeLoaded(msg treeLoadedMsg) (tea.Model, tea.Cmd) {
|
||||
m.byPath = map[string]*submodule.Submodule{}
|
||||
reload := initial || m.reloadStatuses
|
||||
m.reloadStatuses = false
|
||||
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
|
||||
track := func(s *submodule.Submodule) {
|
||||
m.byPath[s.Path] = s
|
||||
if o, ok := old[s.Path]; ok {
|
||||
s.Status, s.Err, s.FetchErr = o.Status, o.Err, o.FetchErr
|
||||
@@ -275,7 +287,12 @@ func (m Model) treeLoaded(msg treeLoadedMsg) (tea.Model, tea.Cmd) {
|
||||
if reload || s.Status == nil {
|
||||
cmds = append(cmds, m.reloadStatus(s.Path))
|
||||
}
|
||||
})
|
||||
}
|
||||
if m.repo.Root != nil {
|
||||
track(m.repo.Root)
|
||||
}
|
||||
submodule.Walk(m.repo.Submodules, track)
|
||||
m.tree.SetRoot(m.repo.Root)
|
||||
|
||||
if initial {
|
||||
m.tree.SetRoots(m.repo.Submodules)
|
||||
@@ -283,9 +300,6 @@ func (m Model) treeLoaded(msg treeLoadedMsg) (tea.Model, tea.Cmd) {
|
||||
m.changes = m.watcher.Watch(m.ctx)
|
||||
cmds = append(cmds, m.waitForChange())
|
||||
}
|
||||
if m.opts.Fetch {
|
||||
cmds = append(cmds, m.startFetch())
|
||||
}
|
||||
} else {
|
||||
m.tree.ReplaceRoots(m.repo.Submodules)
|
||||
}
|
||||
@@ -305,6 +319,10 @@ func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) {
|
||||
// superproject: pins of all submodules may have moved (local), or
|
||||
// only its header (remote); .gitmodules: the structure changed
|
||||
cmds = append(cmds, m.reloadTree(c.Local))
|
||||
if c.Path == "" {
|
||||
// the root's own state: files, HEAD, and (for a fetch) its upstream
|
||||
cmds = append(cmds, m.reloadStatus(""))
|
||||
}
|
||||
default:
|
||||
s, ok := m.byPath[c.Path]
|
||||
if !ok {
|
||||
@@ -322,6 +340,21 @@ 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:
|
||||
return m.choosePin(msg)
|
||||
case restoreAsking:
|
||||
return m.answerRestore(msg)
|
||||
}
|
||||
}
|
||||
if m.notice != "" {
|
||||
m.notice = ""
|
||||
m.layout()
|
||||
}
|
||||
if m.filter.Focused() {
|
||||
return m.typeFilter(msg)
|
||||
}
|
||||
@@ -377,10 +410,13 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.filtering = true
|
||||
m.layout()
|
||||
return m, m.filter.Focus()
|
||||
case key.Matches(msg, k.Update, k.Bump, k.Issues):
|
||||
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
|
||||
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()
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -429,6 +465,9 @@ func (m Model) filterView() string {
|
||||
if !m.filter.Focused() {
|
||||
hint = "/ edit • esc close"
|
||||
}
|
||||
if m.tree.ProblemsOnly() {
|
||||
hint = "problems only (!) • " + hint
|
||||
}
|
||||
hint = theme.Fg(m.theme.Muted).Render(hint)
|
||||
m.filter.Width = max(m.width-lipgloss.Width(hint)-6, 1)
|
||||
in := m.filter.View()
|
||||
@@ -436,6 +475,22 @@ func (m Model) filterView() string {
|
||||
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(in + strings.Repeat(" ", gap) + hint)
|
||||
}
|
||||
|
||||
// extraLine reports whether a line above the footer is shown: a restore in
|
||||
// progress, a failure, the filter, or the notice that only problems are
|
||||
// shown.
|
||||
func (m Model) extraLine() bool {
|
||||
return m.filtering || m.tree.ProblemsOnly() || m.restore != nil || m.notice != ""
|
||||
}
|
||||
|
||||
// problemsView is the line above the footer while only problems are shown, so
|
||||
// a short list is not mistaken for a healthy repository.
|
||||
func (m Model) problemsView() string {
|
||||
left := theme.Fg(m.theme.Warn).Render("problems only")
|
||||
right := theme.Fg(m.theme.Muted).Render("! show all")
|
||||
gap := max(m.width-2-lipgloss.Width(left)-lipgloss.Width(right), 1)
|
||||
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(left + strings.Repeat(" ", gap) + right)
|
||||
}
|
||||
|
||||
// copyPath copies the absolute directory of the selected submodule to the
|
||||
// clipboard, or its path in the superproject if the provider can't tell.
|
||||
func (m Model) copyPath() (tea.Model, tea.Cmd) {
|
||||
@@ -444,6 +499,9 @@ func (m Model) copyPath() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
path := s.Path
|
||||
if s.IsRoot() && m.repo != nil {
|
||||
path = m.repo.Path
|
||||
}
|
||||
if l, ok := m.provider.(submodule.Locator); ok {
|
||||
if dir := l.Dir(s.Path); dir != "" {
|
||||
path = dir
|
||||
@@ -458,7 +516,7 @@ func (m Model) copyPath() (tea.Model, tea.Cmd) {
|
||||
func (m *Model) layout() {
|
||||
m.footer.SetWidth(m.width)
|
||||
bodyHeight := m.height - headerHeight - lipgloss.Height(m.footer.View())
|
||||
if m.filtering {
|
||||
if m.extraLine() {
|
||||
bodyHeight--
|
||||
}
|
||||
bodyHeight = max(bodyHeight, 0)
|
||||
@@ -488,13 +546,26 @@ func (m Model) View() string {
|
||||
Border(lipgloss.NormalBorder(), false, false, false, true).
|
||||
BorderForeground(m.theme.Border).
|
||||
PaddingLeft(1).
|
||||
Render(m.detail.View(m.tree.Selected()))
|
||||
Render(m.detail.View(m.tree.Selected(), m.repo))
|
||||
body = lipgloss.JoinHorizontal(lipgloss.Top, body, pane)
|
||||
}
|
||||
|
||||
parts := []string{header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width), body}
|
||||
if m.filtering {
|
||||
head := header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width, m.tree.RootSelected())
|
||||
parts := []string{zone.Mark(m.tree.RootZone(), head), body}
|
||||
if m.restore != nil {
|
||||
parts = append(parts, m.restoreView())
|
||||
} else if m.notice != "" {
|
||||
parts = append(parts, m.noticeView())
|
||||
} else if m.filtering {
|
||||
parts = append(parts, m.filterView())
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
@@ -40,13 +40,15 @@ func (m Model) PaletteVisible() bool { return m.height >= paletteMinHeight }
|
||||
func (m *Model) SetSize(w, h int) { m.width, m.height = w, h }
|
||||
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
|
||||
|
||||
func (m Model) View(s *submodule.Submodule) string {
|
||||
// View renders s; repo is the loaded superproject (nil while loading), which
|
||||
// the superproject's own view needs for its path and submodule summary.
|
||||
func (m Model) View(s *submodule.Submodule, repo *submodule.Repo) string {
|
||||
if m.width <= 0 || m.height <= 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
if s != nil {
|
||||
lines = m.lines(s)
|
||||
lines = m.lines(s, repo)
|
||||
}
|
||||
for i, l := range lines {
|
||||
lines[i] = ansi.Truncate(l, m.width, "…")
|
||||
@@ -68,12 +70,17 @@ func (m Model) View(s *submodule.Submodule) string {
|
||||
return body + "\n" + palette
|
||||
}
|
||||
|
||||
func (m Model) lines(s *submodule.Submodule) []string {
|
||||
func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
|
||||
t := m.theme
|
||||
muted, text := theme.Fg(t.Muted), theme.Fg(t.Text)
|
||||
label := func(l string) string { return muted.Width(labelWidth).Render(l) }
|
||||
|
||||
dir := path.Dir(s.Path)
|
||||
root := s.IsRoot() && repo != nil
|
||||
full := s.Path
|
||||
if root {
|
||||
full = repo.Path
|
||||
}
|
||||
dir := path.Dir(full)
|
||||
if dir == "." {
|
||||
dir = ""
|
||||
} else {
|
||||
@@ -104,7 +111,11 @@ func (m Model) lines(s *submodule.Submodule) []string {
|
||||
out = append(out, theme.Fg(t.Info).Width(labelWidth).Render(theme.Staged)+text.Render("pin change staged, not committed"))
|
||||
}
|
||||
if st.Checkout == submodule.CheckoutDetached {
|
||||
out = append(out, label("")+muted.Render("detached HEAD on pin"))
|
||||
text := "detached HEAD on pin"
|
||||
if st.Root {
|
||||
text = "detached HEAD"
|
||||
}
|
||||
out = append(out, label("")+muted.Render(text))
|
||||
}
|
||||
if st.Changes != nil {
|
||||
out = append(out, "")
|
||||
@@ -141,15 +152,19 @@ func (m Model) lines(s *submodule.Submodule) []string {
|
||||
}
|
||||
return label(name) + shaStyle.Render(c.SHA) + " " + text.Render(c.Subject)
|
||||
}
|
||||
out = append(out,
|
||||
commitLine("Pinned", st.Pinned, false),
|
||||
commitLine("Checkout", st.CheckedOut, st.Checkout == submodule.CheckoutDiffers),
|
||||
commitLine("Tip", st.Tip, false),
|
||||
)
|
||||
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)))
|
||||
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),
|
||||
@@ -160,29 +175,50 @@ func (m Model) lines(s *submodule.Submodule) []string {
|
||||
|
||||
// children & meta
|
||||
out = append(out, "")
|
||||
if n := len(s.Children); n > 0 {
|
||||
if root {
|
||||
n := 0
|
||||
submodule.Walk(repo.Submodules, func(*submodule.Submodule) { n++ })
|
||||
summary := fmt.Sprintf("%d", n)
|
||||
if parts := m.hiddenParts(submodule.HiddenIn(repo.Submodules)); parts != "" {
|
||||
summary += muted.Render(" · ") + parts
|
||||
}
|
||||
out = append(out, label("Modules")+summary)
|
||||
} else if n := len(s.Children); n > 0 {
|
||||
children := fmt.Sprintf("%d direct", n)
|
||||
h := s.HiddenIssues()
|
||||
var parts []string
|
||||
if h.Danger > 0 {
|
||||
parts = append(parts, t.LevelStyle(submodule.LevelDanger, lipgloss.NewStyle()).Render(fmt.Sprintf("%d error(s)", h.Danger)))
|
||||
}
|
||||
if h.Warn > 0 {
|
||||
parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn)))
|
||||
}
|
||||
if h.Staged > 0 {
|
||||
parts = append(parts, theme.Fg(t.Info).Render(fmt.Sprintf("%d %s", h.Staged, theme.Staged)))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
children += muted.Render(" · ") + strings.Join(parts, muted.Render(", ")) + muted.Render(" below")
|
||||
if parts := m.hiddenParts(s.HiddenIssues()); parts != "" {
|
||||
children += muted.Render(" · ") + parts + muted.Render(" below")
|
||||
}
|
||||
out = append(out, label("Children")+children)
|
||||
}
|
||||
out = append(out, label("URL")+muted.Render(s.URL))
|
||||
if s.URL != "" {
|
||||
out = append(out, label("URL")+muted.Render(s.URL))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) betweenTitle(p submodule.PinInfo) string {
|
||||
// hiddenParts words issue counts like "1 error(s), 2 warning(s)"; empty if
|
||||
// there are none.
|
||||
func (m Model) hiddenParts(h submodule.Hidden) string {
|
||||
t := m.theme
|
||||
var parts []string
|
||||
if h.Danger > 0 {
|
||||
parts = append(parts, t.LevelStyle(submodule.LevelDanger, lipgloss.NewStyle()).Render(fmt.Sprintf("%d error(s)", h.Danger)))
|
||||
}
|
||||
if h.Warn > 0 {
|
||||
parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn)))
|
||||
}
|
||||
if 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"
|
||||
@@ -191,11 +227,11 @@ func (m Model) betweenTitle(p submodule.PinInfo) string {
|
||||
}
|
||||
switch {
|
||||
case p.Ahead > 0 && p.Behind > 0:
|
||||
return fmt.Sprintf("Pin and %s have diverged (%s%d %s%d):",
|
||||
p.Ref, m.theme.Glyphs.Ahead, p.Ahead, m.theme.Glyphs.Behind, p.Behind)
|
||||
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("Pin is %d %s ahead of %s:", p.Ahead, plural(p.Ahead), p.Ref)
|
||||
return fmt.Sprintf("%s is %d %s ahead of %s:", subject, p.Ahead, plural(p.Ahead), p.Ref)
|
||||
default:
|
||||
return fmt.Sprintf("Pin is %d %s behind %s:", p.Behind, plural(p.Behind), p.Ref)
|
||||
return fmt.Sprintf("%s is %d %s behind %s:", subject, p.Behind, plural(p.Behind), p.Ref)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,13 @@ type Fetch struct {
|
||||
}
|
||||
|
||||
// View renders repo info, state counts and fetch state on one line. spinner
|
||||
// is the current spinner frame.
|
||||
func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, width int) string {
|
||||
// is the current spinner frame. The header stands for the superproject: with
|
||||
// selected it is drawn like a selected tree row.
|
||||
func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, width int, selected bool) string {
|
||||
bar := lipgloss.NewStyle().Background(t.Bar)
|
||||
if selected {
|
||||
bar = lipgloss.NewStyle().Background(t.Selected).Bold(true)
|
||||
}
|
||||
seg := func(c lipgloss.TerminalColor, s string) string { return bar.Foreground(c).Render(s) }
|
||||
|
||||
if repo == nil {
|
||||
@@ -34,6 +38,9 @@ func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, widt
|
||||
if d := t.Delta(submodule.PinInfo{Ahead: repo.Ahead, Behind: repo.Behind, Pushed: true}, bar); d != "" {
|
||||
left += seg(t.Text, " ") + d
|
||||
}
|
||||
if repo.Root != nil {
|
||||
left += seg(t.Text, " ") + t.StatusWord(repo.Root, bar, spinner)
|
||||
}
|
||||
|
||||
// count submodules per issue kind (most severe first), plus staged pins
|
||||
var total, loading, errored, staged int
|
||||
|
||||
+10
-9
@@ -3,11 +3,12 @@ package tui
|
||||
import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
type keyMap struct {
|
||||
Up, Down, Left, Right, Toggle key.Binding
|
||||
CollapseAll, ExpandAll key.Binding
|
||||
Home, End, PageUp, PageDown key.Binding
|
||||
Update, Bump, Fetch, Reload, Filter, Issues key.Binding
|
||||
Copy, Help, Quit key.Binding
|
||||
Up, Down, Left, Right, Toggle key.Binding
|
||||
CollapseAll, ExpandAll key.Binding
|
||||
Home, End, PageUp, PageDown key.Binding
|
||||
Fetch, Reload, Filter, Issues key.Binding
|
||||
Copy, Restore, Switch key.Binding
|
||||
Help, Quit key.Binding
|
||||
}
|
||||
|
||||
func defaultKeys() keyMap {
|
||||
@@ -23,12 +24,12 @@ func defaultKeys() keyMap {
|
||||
End: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end/G", "last")),
|
||||
PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")),
|
||||
PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "page down")),
|
||||
Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "checkout pin")),
|
||||
Bump: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "bump pin to tip")),
|
||||
Fetch: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fetch")),
|
||||
Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")),
|
||||
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
||||
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")),
|
||||
@@ -45,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} }
|
||||
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} }
|
||||
@@ -59,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.Update, k.Bump},
|
||||
{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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
"submarine/internal/tui/theme"
|
||||
)
|
||||
|
||||
type restorePhase int
|
||||
|
||||
const (
|
||||
restoreChoosing restorePhase = iota // staged or committed pin?
|
||||
restoreChecking // reading what would be lost
|
||||
restoreAsking // waiting for y / n
|
||||
restoreRunning
|
||||
)
|
||||
|
||||
// restoreState is the restore in progress; only one runs at a time.
|
||||
type restoreState struct {
|
||||
path, name string // name is how the footer calls it
|
||||
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.
|
||||
type lossMsg struct {
|
||||
path string
|
||||
loss submodule.Loss
|
||||
err error
|
||||
}
|
||||
|
||||
type restoreDoneMsg struct {
|
||||
path string
|
||||
branch string // the branch a switch ended on, "" if detached
|
||||
err error
|
||||
}
|
||||
|
||||
// startRestore checks what restoring the selection would discard. If nothing
|
||||
// would be lost it restores at once, else it asks first. A submodule with a
|
||||
// staged pin first asks which pin to restore to.
|
||||
func (m Model) startRestore() (tea.Model, tea.Cmd) {
|
||||
s := m.tree.Selected()
|
||||
_, ok := m.provider.(submodule.Restorer)
|
||||
var flash string
|
||||
switch {
|
||||
case s == nil:
|
||||
return m, nil
|
||||
case !ok:
|
||||
flash = "this data source can't restore"
|
||||
case m.restore != nil:
|
||||
flash = "wait for the restore of " + m.restore.name + " to finish"
|
||||
}
|
||||
if flash != "" {
|
||||
cmd := m.footer.Flash(flash)
|
||||
m.layout()
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
m.restore = &restoreState{path: s.Path, name: restoreName(s)}
|
||||
if !s.IsRoot() && s.Status != nil && s.Status.PinStaged {
|
||||
m.restore.phase = restoreChoosing
|
||||
m.layout()
|
||||
return m, nil
|
||||
}
|
||||
return m.checkRestore()
|
||||
}
|
||||
|
||||
// checkRestore reads what the restore would discard.
|
||||
func (m Model) checkRestore() (tea.Model, tea.Cmd) {
|
||||
m.restore.phase = restoreChecking
|
||||
m.layout()
|
||||
ctx, path, to, b := m.ctx, m.restore.path, m.restore.to, m.restore.branch
|
||||
check := func() tea.Msg {
|
||||
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())
|
||||
}
|
||||
|
||||
// choosePin handles a key while the restore asks for the pin: s the staged
|
||||
// one, c the committed one (which unstages it), anything else cancels.
|
||||
func (m Model) choosePin(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case msg.Type == tea.KeyCtrlC:
|
||||
return m, tea.Quit
|
||||
case msg.String() == "s":
|
||||
m.restore.to = submodule.RestoreStaged
|
||||
return m.checkRestore()
|
||||
case msg.String() == "c":
|
||||
m.restore.to = submodule.RestoreCommitted
|
||||
return m.checkRestore()
|
||||
}
|
||||
m.restore = nil
|
||||
m.layout()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// lossChecked reacts to the check: restore, or ask.
|
||||
func (m Model) lossChecked(msg lossMsg) (tea.Model, tea.Cmd) {
|
||||
if m.restore == nil || m.restore.path != msg.path || m.restore.phase != restoreChecking {
|
||||
return m, nil
|
||||
}
|
||||
if msg.err != nil {
|
||||
m.restore = nil
|
||||
m.notice = "can't check " + restoreName(m.byPath[msg.path]) + ": " + msg.err.Error()
|
||||
m.layout()
|
||||
return m, nil
|
||||
}
|
||||
m.restore.loss = msg.loss
|
||||
if msg.loss.Empty() {
|
||||
return m.runRestore()
|
||||
}
|
||||
m.restore.phase = restoreAsking
|
||||
m.layout()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// answerRestore handles a key while the restore waits for its answer: y
|
||||
// restores, anything else cancels.
|
||||
func (m Model) answerRestore(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case msg.Type == tea.KeyCtrlC:
|
||||
return m, tea.Quit
|
||||
case msg.String() == "y":
|
||||
return m.runRestore()
|
||||
}
|
||||
m.restore = nil
|
||||
m.layout()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) runRestore() (tea.Model, tea.Cmd) {
|
||||
m.restore.phase = restoreRunning
|
||||
m.layout()
|
||||
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) {
|
||||
r := m.restore
|
||||
m.restore = nil
|
||||
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()
|
||||
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 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"
|
||||
}
|
||||
return s.Path
|
||||
}
|
||||
|
||||
// restoreView is the line above the footer while a restore is in progress.
|
||||
func (m Model) restoreView() string {
|
||||
r := m.restore
|
||||
left, right := "", ""
|
||||
key := func(k string) string { return theme.Fg(m.theme.Text).Bold(true).Render(k) }
|
||||
muted := func(s string) string { return theme.Fg(m.theme.Muted).Render(s) }
|
||||
switch r.phase {
|
||||
case restoreChoosing:
|
||||
left = theme.Fg(m.theme.Warn).Render("Restore "+r.name+" to") + " " +
|
||||
key("s") + muted(" staged pin · ") + key("c") + muted(" committed pin (unstages it)")
|
||||
right = muted("any other key cancels")
|
||||
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(r.verbIng()+" "+r.name+r.toText()+"…")
|
||||
case restoreAsking:
|
||||
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(" "+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)
|
||||
}
|
||||
|
||||
// line lays out one line above the footer: left, right-aligned right. The
|
||||
// left part is cut if both don't fit.
|
||||
func (m Model) line(left, right string) string {
|
||||
w := m.width - 2
|
||||
if right != "" {
|
||||
left = ansi.Truncate(left, max(w-lipgloss.Width(right)-2, 1), "…")
|
||||
}
|
||||
gap := max(w-lipgloss.Width(left)-lipgloss.Width(right), 1)
|
||||
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(left + strings.Repeat(" ", gap) + right)
|
||||
}
|
||||
|
||||
// noticeView is the line above the footer after a restore failed.
|
||||
func (m Model) noticeView() string {
|
||||
return m.line(theme.Fg(m.theme.Danger).Render(m.notice), "")
|
||||
}
|
||||
|
||||
// lossText words what a restore discards, e.g. "7 changed files in 3
|
||||
// repositories, 2 commits on no branch, 1 untracked repository".
|
||||
func lossText(l submodule.Loss) string {
|
||||
var parts []string
|
||||
if l.Files > 0 {
|
||||
s := plural(l.Files, "changed file", "changed files")
|
||||
if l.Modules > 1 {
|
||||
s += fmt.Sprintf(" in %d repositories", l.Modules)
|
||||
}
|
||||
parts = append(parts, s)
|
||||
}
|
||||
if l.Operations > 0 {
|
||||
parts = append(parts, plural(l.Operations, "operation in progress", "operations in progress"))
|
||||
}
|
||||
if l.Commits > 0 {
|
||||
parts = append(parts, plural(l.Commits, "commit on no branch", "commits on no branch"))
|
||||
}
|
||||
if l.Repositories > 0 {
|
||||
parts = append(parts, plural(l.Repositories, "untracked repository", "untracked repositories"))
|
||||
}
|
||||
if l.StagedPins > 0 {
|
||||
parts = append(parts, plural(l.StagedPins, "staged pin", "staged pins"))
|
||||
}
|
||||
if l.Unknown > 0 {
|
||||
parts = append(parts, plural(l.Unknown, "repository of unknown state", "repositories of unknown state"))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func plural(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return "1 " + one
|
||||
}
|
||||
return fmt.Sprintf("%d %s", n, many)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
zone "github.com/lrstanley/bubblezone"
|
||||
|
||||
"submarine/internal/submodule"
|
||||
"submarine/internal/submodule/mock"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
zone.NewGlobal()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestLossText(t *testing.T) {
|
||||
cases := []struct {
|
||||
in submodule.Loss
|
||||
want string
|
||||
}{
|
||||
{submodule.Loss{Files: 1, Modules: 1}, "1 changed file"},
|
||||
{submodule.Loss{Files: 7, Modules: 3, Commits: 2, Repositories: 1}, "7 changed files in 3 repositories, 2 commits on no branch, 1 untracked repository"},
|
||||
{submodule.Loss{Operations: 1, Modules: 1, Unknown: 2}, "1 operation in progress, 2 repositories of unknown state"},
|
||||
{submodule.Loss{StagedPins: 2, Modules: 1}, "2 staged pins"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := lossText(c.in); got != c.want {
|
||||
t.Errorf("lossText(%+v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRestorer is the mock data with a restore that records its calls.
|
||||
type fakeRestorer struct {
|
||||
submodule.Provider
|
||||
loss submodule.Loss
|
||||
restored []string
|
||||
to []submodule.RestoreTarget
|
||||
}
|
||||
|
||||
func (f *fakeRestorer) RestoreLoss(context.Context, string, submodule.RestoreTarget) (submodule.Loss, error) {
|
||||
return f.loss, nil
|
||||
}
|
||||
|
||||
func (f *fakeRestorer) Restore(_ context.Context, path string, to submodule.RestoreTarget) error {
|
||||
f.restored = append(f.restored, path)
|
||||
f.to = append(f.to, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
// run executes a command, flattening batches, and returns the messages.
|
||||
func run(cmd tea.Cmd) []tea.Msg {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
msg := cmd()
|
||||
if b, ok := msg.(tea.BatchMsg); ok {
|
||||
var out []tea.Msg
|
||||
for _, c := range b {
|
||||
if c != nil {
|
||||
out = append(out, run(c)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return []tea.Msg{msg}
|
||||
}
|
||||
|
||||
func press(m tea.Model, k string) (tea.Model, tea.Cmd) {
|
||||
return m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)})
|
||||
}
|
||||
|
||||
// newRestoreModel returns a model with the mock tree loaded.
|
||||
func newRestoreModel(t *testing.T, loss submodule.Loss) (tea.Model, *fakeRestorer) {
|
||||
t.Helper()
|
||||
f := &fakeRestorer{Provider: mock.New(), loss: loss}
|
||||
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
|
||||
}
|
||||
|
||||
// feed delivers the messages of cmd to m, once.
|
||||
func feed(m tea.Model, cmd tea.Cmd) tea.Model {
|
||||
for _, msg := range run(cmd) {
|
||||
switch msg.(type) {
|
||||
case lossMsg, restoreDoneMsg, branchesMsg:
|
||||
m, _ = m.Update(msg)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestRestoreWithoutLossRunsAtOnce(t *testing.T) {
|
||||
m, f := newRestoreModel(t, submodule.Loss{})
|
||||
m, cmd := press(m, "x")
|
||||
var next tea.Cmd
|
||||
for _, msg := range run(cmd) {
|
||||
if l, ok := msg.(lossMsg); ok {
|
||||
m, next = m.Update(l)
|
||||
}
|
||||
}
|
||||
m = feed(m, next)
|
||||
if len(f.restored) != 1 || f.restored[0] != "" {
|
||||
t.Fatalf("restored %q, want the superproject once", f.restored)
|
||||
}
|
||||
if m.(Model).restore != nil {
|
||||
t.Error("restore still in progress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreAsksWhenSomethingIsLost(t *testing.T) {
|
||||
m, f := newRestoreModel(t, submodule.Loss{Files: 3, Modules: 1})
|
||||
m, cmd := press(m, "x")
|
||||
var next tea.Cmd
|
||||
for _, msg := range run(cmd) {
|
||||
if l, ok := msg.(lossMsg); ok {
|
||||
m, next = m.Update(l)
|
||||
}
|
||||
}
|
||||
if next != nil || m.(Model).restore == nil || m.(Model).restore.phase != restoreAsking {
|
||||
t.Fatalf("want a question, got %+v", m.(Model).restore)
|
||||
}
|
||||
|
||||
// any other key cancels
|
||||
c, _ := press(m, "n")
|
||||
if c.(Model).restore != nil || len(f.restored) != 0 {
|
||||
t.Fatal("n must cancel without restoring")
|
||||
}
|
||||
|
||||
// y restores
|
||||
m, cmd = press(m, "y")
|
||||
m = feed(m, cmd)
|
||||
if len(f.restored) != 1 {
|
||||
t.Fatalf("restored %q", f.restored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreWithoutRestorerSaysSo(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, "x")
|
||||
if m.(Model).restore != nil {
|
||||
t.Fatal("the mock can't restore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreOfAStagedPinAsksWhichPin(t *testing.T) {
|
||||
m, f := newRestoreModel(t, submodule.Loss{})
|
||||
mm := m.(Model)
|
||||
var staged *submodule.Submodule
|
||||
submodule.Walk(mm.repo.Submodules, func(s *submodule.Submodule) {
|
||||
if staged == nil && s.Children == nil {
|
||||
staged = s
|
||||
}
|
||||
})
|
||||
staged.Status = &submodule.Status{PinStaged: true, Pin: submodule.PinInfo{Pushed: true}}
|
||||
mm.tree.Select(staged.Path)
|
||||
if mm.tree.Selected() != staged {
|
||||
t.Fatalf("setup: selected %v", mm.tree.Selected())
|
||||
}
|
||||
m = mm
|
||||
|
||||
m, _ = press(m, "x")
|
||||
if m.(Model).restore == nil || m.(Model).restore.phase != restoreChoosing {
|
||||
t.Fatalf("want the pin question, got %+v", m.(Model).restore)
|
||||
}
|
||||
m, cmd := press(m, "c")
|
||||
var next tea.Cmd
|
||||
for _, msg := range run(cmd) {
|
||||
if l, ok := msg.(lossMsg); ok {
|
||||
m, next = m.Update(l)
|
||||
}
|
||||
}
|
||||
feed(m, next)
|
||||
if len(f.to) != 1 || f.to[0] != submodule.RestoreCommitted || f.restored[0] != staged.Path {
|
||||
t.Fatalf("restored %q to %v, want %s to the committed pin", f.restored, f.to, staged.Path)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,31 @@ func IssueWord(k submodule.IssueKind) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// background.
|
||||
func (t Theme) StatusWord(s *submodule.Submodule, base lipgloss.Style, spinner string) string {
|
||||
seg := func(c lipgloss.TerminalColor, text string) string { return base.Foreground(c).Render(text) }
|
||||
switch {
|
||||
case s.Err != nil:
|
||||
return t.LevelStyle(submodule.LevelDanger, base).Render("error")
|
||||
case s.Status == nil:
|
||||
return seg(t.Primary, spinner)
|
||||
}
|
||||
if issues := s.Status.Issues(); len(issues) > 0 {
|
||||
out := t.LevelStyle(issues[0].Level, base).Render(IssueWord(issues[0].Kind))
|
||||
if len(issues) > 1 {
|
||||
out += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1))
|
||||
}
|
||||
return out
|
||||
}
|
||||
if s.Status.PinStaged {
|
||||
return seg(t.Info, Staged)
|
||||
}
|
||||
return seg(t.Muted, InSync)
|
||||
}
|
||||
|
||||
// ChangeLetter is the one-letter label of a changed file.
|
||||
func ChangeLetter(k submodule.ChangeKind) string {
|
||||
switch k {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
// SetFilter shows only the submodules whose path contains term (ignoring
|
||||
// case), plus their ancestors, which are shown expanded. An empty term
|
||||
// removes the filter.
|
||||
// removes the filter. It combines with SetProblemsOnly.
|
||||
//
|
||||
// If the selected submodule is filtered out, the next visible one is
|
||||
// selected (the previous one if there is none after it, none if nothing
|
||||
@@ -21,15 +21,46 @@ func (m *Model) SetFilter(term string) {
|
||||
}
|
||||
old := m.cursor
|
||||
m.filter = term
|
||||
if term == "" {
|
||||
m.narrow(old)
|
||||
}
|
||||
|
||||
// SetProblemsOnly shows only the submodules with issues, plus their
|
||||
// ancestors, which are shown expanded. A submodule whose status is loading or
|
||||
// failed to load counts as a problem, since its state is unknown. The
|
||||
// selection behaves as with SetFilter.
|
||||
func (m *Model) SetProblemsOnly(on bool) {
|
||||
if on == m.problemsOnly {
|
||||
return
|
||||
}
|
||||
old := m.cursor
|
||||
m.problemsOnly = on
|
||||
m.narrow(old)
|
||||
}
|
||||
|
||||
// ProblemsOnly reports whether only submodules with issues are shown.
|
||||
func (m Model) ProblemsOnly() bool { return m.problemsOnly }
|
||||
|
||||
// Refit keeps the selection visible after statuses changed, which can make a
|
||||
// submodule enter or leave the problems-only view.
|
||||
func (m *Model) Refit() {
|
||||
if m.problemsOnly {
|
||||
m.reselect(m.cursor)
|
||||
m.clampOffset()
|
||||
}
|
||||
}
|
||||
|
||||
// narrow fixes the selection after the filter or the problems-only view
|
||||
// changed.
|
||||
func (m *Model) narrow(old *submodule.Submodule) {
|
||||
if m.narrowed() {
|
||||
m.reselect(old)
|
||||
} else {
|
||||
for p := parentOf(old); p != nil; p = p.Parent {
|
||||
m.expanded[p.Path] = true
|
||||
}
|
||||
if m.cursor == nil {
|
||||
m.cursor = m.firstNode()
|
||||
}
|
||||
} else {
|
||||
m.reselect(old)
|
||||
}
|
||||
m.clampOffset()
|
||||
}
|
||||
@@ -37,6 +68,9 @@ func (m *Model) SetFilter(term string) {
|
||||
// Filter returns the active filter term in lower case.
|
||||
func (m Model) Filter() string { return m.filter }
|
||||
|
||||
// narrowed reports whether the tree is reduced to matches.
|
||||
func (m Model) narrowed() bool { return m.filter != "" || m.problemsOnly }
|
||||
|
||||
func parentOf(s *submodule.Submodule) *submodule.Submodule {
|
||||
if s == nil {
|
||||
return nil
|
||||
@@ -48,6 +82,10 @@ func parentOf(s *submodule.Submodule) *submodule.Submodule {
|
||||
// visible any more, the closest visible one after it in display order
|
||||
// becomes the selection, else the closest one before it.
|
||||
func (m *Model) reselect(old *submodule.Submodule) {
|
||||
if old != nil && old == m.root { // the superproject is never filtered out
|
||||
m.cursor = old
|
||||
return
|
||||
}
|
||||
visible := map[*submodule.Submodule]bool{}
|
||||
var first *submodule.Submodule
|
||||
for _, r := range m.rows() {
|
||||
@@ -63,6 +101,9 @@ func (m *Model) reselect(old *submodule.Submodule) {
|
||||
return
|
||||
}
|
||||
m.cursor = first
|
||||
if first == nil {
|
||||
m.cursor = m.root // nothing matches: the superproject remains
|
||||
}
|
||||
if old == nil || first == nil {
|
||||
return
|
||||
}
|
||||
@@ -99,10 +140,10 @@ func (m *Model) appendAll(out *[]row, mods []*submodule.Submodule, depth int) {
|
||||
}
|
||||
}
|
||||
|
||||
// isExpanded reports whether the children of s are shown. While filtering,
|
||||
// isExpanded reports whether the children of s are shown. While narrowed,
|
||||
// that is the case if a match is below s, whatever the user expanded.
|
||||
func (m *Model) isExpanded(s *submodule.Submodule) bool {
|
||||
if m.filter == "" {
|
||||
if !m.narrowed() {
|
||||
return m.expanded[s.Path]
|
||||
}
|
||||
for _, c := range s.Children {
|
||||
@@ -115,7 +156,7 @@ func (m *Model) isExpanded(s *submodule.Submodule) bool {
|
||||
|
||||
// filtered returns the submodules of mods that pass the filter.
|
||||
func (m *Model) filtered(mods []*submodule.Submodule) []*submodule.Submodule {
|
||||
if m.filter == "" {
|
||||
if !m.narrowed() {
|
||||
return mods
|
||||
}
|
||||
var out []*submodule.Submodule
|
||||
@@ -129,7 +170,7 @@ func (m *Model) filtered(mods []*submodule.Submodule) []*submodule.Submodule {
|
||||
|
||||
// shown reports whether s matches the filter or has a descendant that does.
|
||||
func (m *Model) shown(s *submodule.Submodule) bool {
|
||||
if strings.Contains(strings.ToLower(s.Path), m.filter) {
|
||||
if strings.Contains(strings.ToLower(s.Path), m.filter) && (!m.problemsOnly || hasProblem(s)) {
|
||||
return true
|
||||
}
|
||||
for _, c := range s.Children {
|
||||
@@ -139,3 +180,9 @@ func (m *Model) shown(s *submodule.Submodule) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasProblem reports whether s has an issue or an unknown state (loading, or
|
||||
// failed to load, like HiddenIn counts it).
|
||||
func hasProblem(s *submodule.Submodule) bool {
|
||||
return s.Status == nil || len(s.Status.Issues()) > 0
|
||||
}
|
||||
|
||||
+75
-31
@@ -23,26 +23,38 @@ const (
|
||||
type Model struct {
|
||||
id string // zone prefix, unique to this instance
|
||||
theme theme.Theme
|
||||
root *submodule.Submodule // the superproject: selectable, above the first row; may be nil
|
||||
roots []*submodule.Submodule
|
||||
expanded map[string]bool // keyed by Submodule.Path; kept when ancestors collapse
|
||||
filter string // lower-case; empty if not filtering
|
||||
cursor *submodule.Submodule
|
||||
offset int // index of the first visible row
|
||||
width int
|
||||
height int
|
||||
spinner string
|
||||
// problemsOnly hides submodules without issues; it combines with filter.
|
||||
problemsOnly bool
|
||||
cursor *submodule.Submodule
|
||||
offset int // index of the first visible row
|
||||
width int
|
||||
height int
|
||||
spinner string
|
||||
}
|
||||
|
||||
func New(t theme.Theme) Model {
|
||||
return Model{id: zone.NewPrefix(), theme: t, expanded: map[string]bool{}}
|
||||
}
|
||||
|
||||
// SetRoots replaces the tree. All nodes start collapsed.
|
||||
// SetRoot sets the superproject, which can be selected like a row above the
|
||||
// first one. Call it before SetRoots / ReplaceRoots.
|
||||
func (m *Model) SetRoot(root *submodule.Submodule) { m.root = root }
|
||||
|
||||
// SetRoots replaces the tree. All nodes start collapsed. The superproject is
|
||||
// selected if there is one, else the first submodule.
|
||||
func (m *Model) SetRoots(roots []*submodule.Submodule) {
|
||||
m.roots = roots
|
||||
m.expanded = map[string]bool{}
|
||||
m.cursor = nil
|
||||
m.offset = 0
|
||||
if m.root != nil {
|
||||
m.cursor = m.root
|
||||
return
|
||||
}
|
||||
m.cursor = m.firstNode()
|
||||
}
|
||||
|
||||
@@ -54,10 +66,14 @@ func (m *Model) ReplaceRoots(roots []*submodule.Submodule) {
|
||||
if m.cursor != nil {
|
||||
path = m.cursor.Path
|
||||
}
|
||||
wasRoot := m.cursor.IsRoot() // by path: SetRoot may have installed a new node
|
||||
m.roots = roots
|
||||
m.cursor = nil
|
||||
if wasRoot && m.root != nil {
|
||||
m.cursor = m.root
|
||||
}
|
||||
for _, r := range m.rows() {
|
||||
if r.kind == rowNode && r.node.Path == path {
|
||||
if m.cursor == nil && r.kind == rowNode && r.node.Path == path {
|
||||
m.cursor = r.node
|
||||
break
|
||||
}
|
||||
@@ -76,9 +92,19 @@ func (m *Model) SetSize(w, h int) {
|
||||
// SetSpinner sets the frame drawn for submodules that are still loading.
|
||||
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
|
||||
|
||||
// Selected returns the submodule under the cursor, or nil.
|
||||
// Selected returns the submodule under the cursor, or nil. It is the
|
||||
// superproject (empty Path) while that is selected.
|
||||
func (m Model) Selected() *submodule.Submodule { return m.cursor }
|
||||
|
||||
// RootSelected reports whether the superproject is selected.
|
||||
func (m Model) RootSelected() bool { return m.onRoot() }
|
||||
|
||||
func (m Model) onRoot() bool { return m.root != nil && m.cursor == m.root }
|
||||
|
||||
// RootZone is the zone mark of the superproject's display; a click on it
|
||||
// selects the superproject. The caller wraps the header in it.
|
||||
func (m Model) RootZone() string { return m.id + "root" }
|
||||
|
||||
// --- navigation --------------------------------------------------------------
|
||||
|
||||
// Up selects the previous submodule row.
|
||||
@@ -104,6 +130,10 @@ func (m *Model) Toggle() {
|
||||
|
||||
// Right expands the selected submodule and selects its first child.
|
||||
func (m *Model) Right() {
|
||||
if m.onRoot() {
|
||||
m.Down()
|
||||
return
|
||||
}
|
||||
if m.cursor == nil || len(m.cursor.Children) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -120,6 +150,9 @@ func (m *Model) Left() {
|
||||
m.setExpanded(m.cursor, false)
|
||||
case m.cursor.Parent != nil:
|
||||
m.setExpanded(m.cursor.Parent, false)
|
||||
case m.root != nil && !m.onRoot():
|
||||
m.cursor = m.root // the superproject is the parent of the top level
|
||||
m.offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,24 +212,37 @@ func isAncestor(a, s *submodule.Submodule) bool {
|
||||
}
|
||||
|
||||
// moveBy moves the cursor by n submodule rows, skipping directory headers
|
||||
// and stopping at the ends.
|
||||
// and stopping at the ends. The superproject counts as a row before the
|
||||
// first one.
|
||||
func (m *Model) moveBy(n int) {
|
||||
rows := m.rows()
|
||||
i := m.cursorRow(rows)
|
||||
if i < 0 {
|
||||
if m.onRoot() {
|
||||
i = -1
|
||||
} else if i < 0 {
|
||||
return
|
||||
}
|
||||
step := 1
|
||||
if n < 0 {
|
||||
step, n = -1, -n
|
||||
}
|
||||
for j := i + step; n > 0 && j >= 0 && j < len(rows); j += step {
|
||||
if rows[j].kind == rowNode {
|
||||
for j := i + step; n > 0 && j >= -1 && j < len(rows); j += step {
|
||||
switch {
|
||||
case j == -1:
|
||||
if m.root != nil {
|
||||
i = -1
|
||||
n--
|
||||
}
|
||||
case rows[j].kind == rowNode:
|
||||
i = j
|
||||
n--
|
||||
}
|
||||
}
|
||||
m.cursor = rows[i].node
|
||||
if i < 0 {
|
||||
m.cursor = m.root
|
||||
} else {
|
||||
m.cursor = rows[i].node
|
||||
}
|
||||
m.clampOffset()
|
||||
}
|
||||
|
||||
@@ -233,6 +279,10 @@ func (m *Model) clampOffset() {
|
||||
if m.height <= 0 {
|
||||
return
|
||||
}
|
||||
if m.onRoot() {
|
||||
m.offset = 0
|
||||
return
|
||||
}
|
||||
rows := m.rows()
|
||||
i := m.cursorRow(rows)
|
||||
if i < 0 {
|
||||
@@ -269,6 +319,11 @@ func (m *Model) Update(msg tea.Msg) {
|
||||
if mm.Action != tea.MouseActionPress || mm.Button != tea.MouseButtonLeft {
|
||||
return
|
||||
}
|
||||
if m.root != nil && zone.Get(m.RootZone()).InBounds(mm) {
|
||||
m.cursor = m.root
|
||||
m.offset = 0
|
||||
return
|
||||
}
|
||||
for _, r := range m.visibleRows() {
|
||||
if r.kind != rowNode {
|
||||
continue
|
||||
@@ -312,8 +367,12 @@ func (m Model) View() string {
|
||||
lines = append(lines, m.nodeLine(r, nameColW, refW))
|
||||
}
|
||||
}
|
||||
if len(rows) == 0 && m.filter != "" {
|
||||
lines = append(lines, theme.Fg(m.theme.Muted).Width(m.width).Render(" no submodule matches"))
|
||||
if len(rows) == 0 && m.narrowed() {
|
||||
none := " no submodule matches"
|
||||
if m.filter == "" {
|
||||
none = " no problems"
|
||||
}
|
||||
lines = append(lines, theme.Fg(m.theme.Muted).Width(m.width).Render(none))
|
||||
}
|
||||
for len(lines) < m.height {
|
||||
lines = append(lines, strings.Repeat(" ", m.width))
|
||||
@@ -416,22 +475,7 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
|
||||
staged := s.Status != nil && s.Status.PinStaged
|
||||
h := s.HiddenIssues()
|
||||
stagedMark := staged && len(issues) > 0
|
||||
var status string
|
||||
switch {
|
||||
case s.Err != nil:
|
||||
status = t.LevelStyle(submodule.LevelDanger, base).Render("error")
|
||||
case s.Status == nil:
|
||||
status = seg(t.Primary, m.spinner)
|
||||
case len(issues) > 0:
|
||||
status = t.LevelStyle(issues[0].Level, base).Render(theme.IssueWord(issues[0].Kind))
|
||||
if len(issues) > 1 {
|
||||
status += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1))
|
||||
}
|
||||
case staged:
|
||||
status = seg(t.Info, theme.Staged)
|
||||
default:
|
||||
status = seg(t.Muted, theme.InSync)
|
||||
}
|
||||
status := t.StatusWord(s, base, m.spinner)
|
||||
if stagedMark || collapsed && h.Staged > 0 {
|
||||
status += pad(1) + seg(t.Info, t.Glyphs.Staged)
|
||||
}
|
||||
|
||||
@@ -305,3 +305,189 @@ func equal(a, b []string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// newTreeWithRoot is newTree with a selectable superproject.
|
||||
func newTreeWithRoot(t *testing.T) *Model {
|
||||
t.Helper()
|
||||
m := New(theme.Default())
|
||||
m.SetRoot(&submodule.Submodule{Name: "root"})
|
||||
m.SetRoots(fixture())
|
||||
m.SetSize(80, 20)
|
||||
return &m
|
||||
}
|
||||
|
||||
func TestRootIsSelectedInitially(t *testing.T) {
|
||||
m := newTreeWithRoot(t)
|
||||
if !m.RootSelected() {
|
||||
t.Fatal("root not selected initially")
|
||||
}
|
||||
assertCursor(t, m, "")
|
||||
}
|
||||
|
||||
func TestRootIsAboveFirstRow(t *testing.T) {
|
||||
m := newTreeWithRoot(t)
|
||||
m.Down()
|
||||
assertCursor(t, m, "a")
|
||||
if m.RootSelected() {
|
||||
t.Error("root still selected after Down")
|
||||
}
|
||||
m.Up()
|
||||
if !m.RootSelected() {
|
||||
t.Error("Up from the first submodule should select the root")
|
||||
}
|
||||
m.Up() // stays
|
||||
if !m.RootSelected() {
|
||||
t.Error("Up on the root should stay on the root")
|
||||
}
|
||||
|
||||
m.End()
|
||||
assertCursor(t, m, "f")
|
||||
m.Home()
|
||||
if !m.RootSelected() {
|
||||
t.Error("Home should select the root")
|
||||
}
|
||||
m.PageDown()
|
||||
assertCursor(t, m, "f")
|
||||
m.PageUp()
|
||||
if !m.RootSelected() {
|
||||
t.Error("PageUp should reach the root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootNavigationKeys(t *testing.T) {
|
||||
m := newTreeWithRoot(t)
|
||||
m.Right() // like Down
|
||||
assertCursor(t, m, "a")
|
||||
m.Left() // top level, nothing to collapse: the parent is the root
|
||||
if !m.RootSelected() {
|
||||
t.Error("Left on a top-level submodule should select the root")
|
||||
}
|
||||
m.Left()
|
||||
m.Toggle()
|
||||
m.CollapseAll()
|
||||
m.ExpandAll()
|
||||
if !m.RootSelected() {
|
||||
t.Error("fold keys must not move the root selection")
|
||||
}
|
||||
|
||||
m.Down()
|
||||
m.Down() // b
|
||||
m.Right()
|
||||
m.Right() // d
|
||||
m.CollapseAll()
|
||||
assertCursor(t, m, "b")
|
||||
}
|
||||
|
||||
func TestReplaceRootsKeepsRootSelected(t *testing.T) {
|
||||
m := newTreeWithRoot(t)
|
||||
newRoot := &submodule.Submodule{Name: "root"}
|
||||
m.SetRoot(newRoot)
|
||||
m.ReplaceRoots(fixture())
|
||||
if !m.RootSelected() || m.Selected() != newRoot {
|
||||
t.Error("root selection should move to the reloaded root")
|
||||
}
|
||||
|
||||
m.Down()
|
||||
m.SetRoot(&submodule.Submodule{Name: "root"})
|
||||
m.ReplaceRoots(fixture())
|
||||
assertCursor(t, m, "a")
|
||||
}
|
||||
|
||||
func TestFilterKeepsRoot(t *testing.T) {
|
||||
m := newTreeWithRoot(t)
|
||||
m.SetFilter("f")
|
||||
if !m.RootSelected() {
|
||||
t.Error("the root stays selected while filtering")
|
||||
}
|
||||
m.Down()
|
||||
assertCursor(t, m, "f")
|
||||
|
||||
m.SetFilter("zzz") // nothing matches: the root remains, not nil
|
||||
if !m.RootSelected() {
|
||||
t.Error("no match should fall back to the root")
|
||||
}
|
||||
m.SetFilter("")
|
||||
if !m.RootSelected() {
|
||||
t.Error("closing the filter should keep the root")
|
||||
}
|
||||
}
|
||||
|
||||
// healthy gives every submodule of the fixture an issue-free status, then
|
||||
// marks the given paths as changed.
|
||||
func healthy(t *testing.T, m *Model, changed ...string) {
|
||||
t.Helper()
|
||||
submodule.Walk(m.roots, func(s *submodule.Submodule) {
|
||||
s.Status = &submodule.Status{Pin: submodule.PinInfo{Pushed: true}}
|
||||
for _, p := range changed {
|
||||
if s.Path == p {
|
||||
s.Status.Worktree = submodule.WorktreeModified
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProblemsOnlyShowsIssuesWithAncestors(t *testing.T) {
|
||||
m := newTree(t)
|
||||
healthy(t, m, "d")
|
||||
m.SetProblemsOnly(true)
|
||||
want := []string{"src/", "b", "modules/", "c", "d"}
|
||||
if got := visible(m); !equal(got, want) {
|
||||
t.Fatalf("rows = %v, want %v", got, want)
|
||||
}
|
||||
assertCursor(t, m, "b") // a was selected and is healthy: next available
|
||||
m.Select("d")
|
||||
m.SetProblemsOnly(false)
|
||||
assertCursor(t, m, "d")
|
||||
if got := visible(m); len(got) != 8 {
|
||||
t.Fatalf("rows = %v, want all expanded to d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemsOnlyKeepsUnknownStates(t *testing.T) {
|
||||
m := newTree(t)
|
||||
healthy(t, m)
|
||||
f := m.roots[2]
|
||||
f.Status, f.Err = nil, os.ErrNotExist // failed to load
|
||||
m.roots[0].Status = nil // still loading
|
||||
m.SetProblemsOnly(true)
|
||||
if got, want := visible(m), []string{"a", "src/", "f"}; !equal(got, want) {
|
||||
t.Fatalf("rows = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemsOnlyWithoutProblemsKeepsRoot(t *testing.T) {
|
||||
m := New(theme.Default())
|
||||
m.SetRoot(&submodule.Submodule{Status: &submodule.Status{Root: true}})
|
||||
m.SetRoots(fixture())
|
||||
m.SetSize(80, 20)
|
||||
healthy(t, &m)
|
||||
m.SetProblemsOnly(true)
|
||||
if len(m.rows()) != 0 || !m.RootSelected() {
|
||||
t.Fatalf("rows %v, root selected %v", visible(&m), m.RootSelected())
|
||||
}
|
||||
m.Down() // must not panic
|
||||
}
|
||||
|
||||
func TestProblemsOnlyCombinesWithFilter(t *testing.T) {
|
||||
m := newTree(t)
|
||||
healthy(t, m, "d", "f")
|
||||
m.SetProblemsOnly(true)
|
||||
m.SetFilter("f")
|
||||
if got, want := visible(m), []string{"src/", "f"}; !equal(got, want) {
|
||||
t.Fatalf("rows = %v, want %v", got, want)
|
||||
}
|
||||
m.SetFilter("")
|
||||
if !m.ProblemsOnly() || len(visible(m)) != 6 {
|
||||
t.Fatalf("closing the filter keeps problems only: %v", visible(m))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefitMovesSelectionWhenProblemDisappears(t *testing.T) {
|
||||
m := newTree(t)
|
||||
healthy(t, m, "a", "f")
|
||||
m.SetProblemsOnly(true)
|
||||
assertCursor(t, m, "a")
|
||||
m.roots[0].Status.Worktree = submodule.WorktreeClean
|
||||
m.Refit()
|
||||
assertCursor(t, m, "f")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user