Archived
Fetch in the background and follow changes in the repositories
- On startup, after the local state is shown, fetch the superproject and every initialized submodule (all remotes, prune, at most 8 at once). Fetches never prompt; failures show git's message. --no-fetch skips it, f fetches again. - The header shows "fetching n/m", then the fetch age and failures; the detail pane shows each submodule's last fetch and fetch errors. - The git provider polls each repository's metadata (refs, HEAD, index, .gitmodules) every 2 s and reports changes. The app reloads only what changed, keeping old values, expansion and cursor; loads are single-flight with coalesced reruns. Refresh is driven by the repositories, so a fetch in another shell updates the view like ours. - r reloads everything (worktree edits are not watched). - Docs: F50-F53, N1/N8 rewritten, D18-D20 (D12 superseded). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
19c6f17eb2
commit
27f0c0b5d2
@@ -1,7 +1,7 @@
|
|||||||
# AGENTS.md
|
# AGENTS.md
|
||||||
|
|
||||||
submarine: a Go + Bubble Tea TUI for repositories with many git submodules.
|
submarine: a Go + Bubble Tea TUI for repositories with many git submodules.
|
||||||
Currently a prototype with a read-only git provider and mock data.
|
Currently a prototype with a git provider (with background fetch) and mock data.
|
||||||
|
|
||||||
## Read first
|
## Read first
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ and decisions. Read the relevant document before changing behavior:
|
|||||||
```sh
|
```sh
|
||||||
go run ./cmd/submarine [dir] # run on a repository (default: cwd)
|
go run ./cmd/submarine [dir] # run on a repository (default: cwd)
|
||||||
go run ./cmd/submarine --mock # run on mock data
|
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
|
scripts/make-fixture.sh DIR # build a test superproject in DIR
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go test ./...
|
go test ./...
|
||||||
@@ -34,8 +35,9 @@ gofmt -l . # must print nothing
|
|||||||
- **Layering.** `internal/submodule` has no UI imports and decides what a
|
- **Layering.** `internal/submodule` has no UI imports and decides what a
|
||||||
state means (`Status.Issues()`). `internal/tui/theme` decides how it looks.
|
state means (`Status.Issues()`). `internal/tui/theme` decides how it looks.
|
||||||
Components never classify states or define colors themselves.
|
Components never classify states or define colors themselves.
|
||||||
- **Data access** only through `submodule.Provider`. The git provider is
|
- **Data access** only through `submodule.Provider` (and its optional
|
||||||
read-only and must never use the network.
|
`Fetcher` / `Watcher`). The git provider only uses the network in
|
||||||
|
`Fetch`, and git must never prompt.
|
||||||
- **Colors** only from `theme.Theme`, ANSI 16 colors for meaning (see
|
- **Colors** only from `theme.Theme`, ANSI 16 colors for meaning (see
|
||||||
`docs/ux-guide.md#colors`). Render segments on a background with a `base`
|
`docs/ux-guide.md#colors`). Render segments on a background with a `base`
|
||||||
style (`LevelStyle(level, base)`, `Delta(pin, base)`).
|
style (`LevelStyle(level, base)`, `Delta(pin, base)`).
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
useMock := flag.Bool("mock", false, "use built-in mock data instead of a real repository")
|
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() {
|
flag.Usage = func() {
|
||||||
fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [dir]")
|
fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [--no-fetch] [dir]")
|
||||||
flag.PrintDefaults()
|
flag.PrintDefaults()
|
||||||
}
|
}
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -55,7 +56,7 @@ func main() {
|
|||||||
zone.NewGlobal()
|
zone.NewGlobal()
|
||||||
defer zone.Close()
|
defer zone.Close()
|
||||||
|
|
||||||
p := tea.NewProgram(tui.New(ctx, provider), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
p := tea.NewProgram(tui.New(ctx, provider, tui.Options{Fetch: !*noFetch}), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+6
-6
@@ -18,13 +18,13 @@ bare commit hash.
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Prototype.** The UI is complete for browsing. It reads real repositories
|
**Prototype.** The UI is complete for browsing. It reads real repositories
|
||||||
through a read-only git provider (`internal/submodule/git`):
|
through a git provider (`internal/submodule/git`): `submarine [dir]`. It
|
||||||
`submarine [dir]`. `submarine --mock` shows the built-in mock data
|
fetches in the background on startup (`--no-fetch` to skip; `f` to fetch
|
||||||
(`internal/submodule/mock`). Actions (`u`, `b`, `f`, `/`, `!`) only show a
|
again) and follows changes made in other shells. `submarine --mock` shows
|
||||||
"not implemented" message.
|
the built-in mock data (`internal/submodule/mock`). The actions `u`, `b`,
|
||||||
|
`/`, `!` only show a "not implemented" message.
|
||||||
|
|
||||||
Next steps, roughly in order:
|
Next steps, roughly in order:
|
||||||
|
|
||||||
1. Actions: fetch, checkout pin (`submodule update`), bump pin to tip, init.
|
1. Actions: checkout pin (`submodule update`), bump pin to tip, init.
|
||||||
2. Filter (`/`) and "problems only" (`!`) views.
|
2. Filter (`/`) and "problems only" (`!`) views.
|
||||||
3. Refresh after actions and on demand.
|
|
||||||
|
|||||||
+71
-5
@@ -13,15 +13,19 @@ internal/submodule/ domain model; NO UI imports
|
|||||||
model.go Repo, Submodule, Status, PinInfo, Commit, enums
|
model.go Repo, Submodule, Status, PinInfo, Commit, enums
|
||||||
status.go Issues(), HiddenIssues(), Walk()
|
status.go Issues(), HiddenIssues(), Walk()
|
||||||
provider.go Provider interface
|
provider.go Provider interface
|
||||||
internal/submodule/git/ read-only provider using the git CLI
|
internal/submodule/git/ provider using the git CLI (reads, fetch, watch)
|
||||||
exec.go git runner (environment, errors)
|
exec.go git runner (environment, errors)
|
||||||
parse.go pure parsers for git output (status, numstat, …)
|
parse.go pure parsers for git output (status, numstat, …)
|
||||||
provider.go Tree, Status, pin → ref resolution
|
provider.go Tree, Status, pin → ref resolution
|
||||||
|
fetch.go Fetch (network), Fetchable
|
||||||
|
watch.go Watch: polls git metadata for changes
|
||||||
|
gitdir.go resolves .git dirs and gitdir: files
|
||||||
internal/submodule/mock/ fixture provider with artificial latency
|
internal/submodule/mock/ fixture provider with artificial latency
|
||||||
internal/tui/ Bubble Tea application
|
internal/tui/ Bubble Tea application
|
||||||
app.go root model: layout, message routing, loading
|
app.go root model: layout, message routing, loading
|
||||||
keys.go key bindings (also feed the help view)
|
keys.go key bindings (also feed the help view)
|
||||||
msgs.go treeLoadedMsg, statusLoadedMsg
|
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
|
theme/ colors, glyphs, issue words, change letters, level styles
|
||||||
tree/ tree component: rows, navigation, mouse, view
|
tree/ tree component: rows, navigation, mouse, view
|
||||||
detail/ detail pane; changes.go: change preview
|
detail/ detail pane; changes.go: change preview
|
||||||
@@ -47,6 +51,18 @@ type Provider interface {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Optional capabilities; the app type-asserts them:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Fetcher interface {
|
||||||
|
Fetchable(path string) bool // initialized? ("" = superproject)
|
||||||
|
Fetch(ctx context.Context, path string) error
|
||||||
|
}
|
||||||
|
type Watcher interface {
|
||||||
|
Watch(ctx context.Context) <-chan Change // {Path, Local, Structure}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- `Tree` returns the superproject and all submodules with `Parent` and
|
- `Tree` returns the superproject and all submodules with `Parent` and
|
||||||
`Children` linked. `Status` fields are nil.
|
`Children` linked. `Status` fields are nil.
|
||||||
- `Status` is called once per submodule, concurrently. It must be safe to
|
- `Status` is called once per submodule, concurrently. It must be safe to
|
||||||
@@ -61,7 +77,7 @@ The mock provider (`internal/submodule/mock`) covers every state and sleeps
|
|||||||
`internal/submodule/git` shells out to `git -C <dir> …` (see D14).
|
`internal/submodule/git` shells out to `git -C <dir> …` (see D14).
|
||||||
|
|
||||||
- Environment: `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0` (reads never
|
- Environment: `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0` (reads never
|
||||||
take `index.lock`), `LC_ALL=C`. No command touches the network.
|
take `index.lock`), `LC_ALL=C`. Only `Fetch` uses the network.
|
||||||
- `Tree` reads each repository's `.gitmodules` (`git config -f
|
- `Tree` reads each repository's `.gitmodules` (`git config -f
|
||||||
.gitmodules`) and recurses into initialized submodules. It records, per
|
.gitmodules`) and recurses into initialized submodules. It records, per
|
||||||
path, the parent repository and the `.gitmodules` branch for `Status`.
|
path, the parent repository and the `.gitmodules` branch for `Status`.
|
||||||
@@ -72,6 +88,22 @@ The mock provider (`internal/submodule/mock`) covers every state and sleeps
|
|||||||
respect `ctx` (D16).
|
respect `ctx` (D16).
|
||||||
- Superproject: branch from `symbolic-ref`, ahead/behind against
|
- Superproject: branch from `symbolic-ref`, ahead/behind against
|
||||||
`@{upstream}`, "fetched" from the mtime of `FETCH_HEAD`.
|
`@{upstream}`, "fetched" from the mtime of `FETCH_HEAD`.
|
||||||
|
- `Fetch`: `git fetch --all --prune --no-recurse-submodules --quiet` (we
|
||||||
|
fetch every submodule ourselves), at most 8 at once, 2 min timeout each.
|
||||||
|
To never prompt, `GIT_SSH_COMMAND="ssh -o BatchMode=yes"` is set unless
|
||||||
|
the user configured `GIT_SSH_COMMAND` or `core.sshCommand`. The error is
|
||||||
|
git's first line of output.
|
||||||
|
- `Watch` polls every 2 s (`WatchInterval`) and compares a fingerprint per
|
||||||
|
repository: names, mtimes and sizes of the git metadata files (D19).
|
||||||
|
- remote part: `FETCH_HEAD`, `refs/remotes/**`, `refs/tags/**`
|
||||||
|
- local part: `HEAD`, `index`, `MERGE_HEAD`, `packed-refs`,
|
||||||
|
`refs/heads/**`, `reftable/**`
|
||||||
|
- structure: the worktree `.gitmodules`, and whether `.git` exists
|
||||||
|
|
||||||
|
Git dirs are resolved from `.git` (directory, or `gitdir:` file for
|
||||||
|
submodules, plus `commondir` for worktrees) without running git. Our own
|
||||||
|
reads write nothing (`GIT_OPTIONAL_LOCKS=0`), so they never trigger a
|
||||||
|
change. Worktree file edits are not watched (`r` reloads).
|
||||||
|
|
||||||
## Loading flow
|
## Loading flow
|
||||||
|
|
||||||
@@ -80,7 +112,38 @@ The mock provider (`internal/submodule/mock`) covers every state and sleeps
|
|||||||
calling `Status()` → `statusLoadedMsg`.
|
calling `Status()` → `statusLoadedMsg`.
|
||||||
3. Each result is stored on the submodule (`Status` or `Err`); the view
|
3. Each result is stored on the submodule (`Status` or `Err`); the view
|
||||||
updates incrementally, including parents' hidden-issue counts.
|
updates incrementally, including parents' hidden-issue counts.
|
||||||
4. The spinner stops ticking when nothing is loading.
|
4. After the first tree: the watcher starts (`changeMsg`), and the
|
||||||
|
background fetch starts one `tea.Cmd` per fetchable repository
|
||||||
|
(`fetchDoneMsg`).
|
||||||
|
5. The spinner ticks only while something loads or fetches.
|
||||||
|
|
||||||
|
### Fetch and refresh are decoupled (D20)
|
||||||
|
|
||||||
|
```
|
||||||
|
Fetch (network) ──writes refs──▶ .git/… ◀──writes refs── user's shell
|
||||||
|
│
|
||||||
|
Watch (polls every 2 s)
|
||||||
|
│ Change{Path, Local, Structure}
|
||||||
|
▼
|
||||||
|
app reloads Status / Tree
|
||||||
|
```
|
||||||
|
|
||||||
|
- `fetchDoneMsg` only updates the progress and `Submodule.FetchErr`; it
|
||||||
|
never reloads anything. The watcher sees the new refs and triggers the
|
||||||
|
reload, exactly as for a fetch made in another shell.
|
||||||
|
- `changeMsg`:
|
||||||
|
- submodule, remote only → reload its `Status`
|
||||||
|
- submodule, local → reload it and its direct children (their pins live
|
||||||
|
in its index)
|
||||||
|
- superproject, or `.gitmodules` anywhere → reload the tree; if local,
|
||||||
|
also every status
|
||||||
|
- A reloaded tree carries the old `Status`, `Err` and `FetchErr` over by
|
||||||
|
path, and `tree.ReplaceRoots` keeps expansion and the cursor, so a reload
|
||||||
|
doesn't flash spinners or move the selection.
|
||||||
|
- The `reloader` runs at most one load per key (a submodule path, or the
|
||||||
|
tree). A request during a load marks it dirty; it runs once more
|
||||||
|
afterwards. Results therefore never arrive out of order and bursts of
|
||||||
|
changes (a fetch writing many refs) coalesce.
|
||||||
|
|
||||||
## Tree component
|
## Tree component
|
||||||
|
|
||||||
@@ -114,7 +177,10 @@ go test ./...
|
|||||||
- `internal/submodule`: issue classification and hidden-issue aggregation.
|
- `internal/submodule`: issue classification and hidden-issue aggregation.
|
||||||
- `internal/submodule/git`: parser unit tests, and integration tests
|
- `internal/submodule/git`: parser unit tests, and integration tests
|
||||||
against a fixture repository built by `scripts/make-fixture.sh` (skipped
|
against a fixture repository built by `scripts/make-fixture.sh` (skipped
|
||||||
if `git` is not installed).
|
if `git` is not installed). Fetch and watch tests build their own
|
||||||
|
fixture, since they change it; the fixture's remotes are local bare
|
||||||
|
repositories, so fetching works offline.
|
||||||
|
- `internal/tui`: `reloader`.
|
||||||
- `internal/tui/tree`: navigation rules. Tests need `zone.NewGlobal()` (see
|
- `internal/tui/tree`: navigation rules. Tests need `zone.NewGlobal()` (see
|
||||||
`TestMain`).
|
`TestMain`).
|
||||||
|
|
||||||
|
|||||||
+38
-1
@@ -90,7 +90,7 @@ artificial latency.
|
|||||||
**Consequences.** A git backend can be added without UI changes; the UI
|
**Consequences.** A git backend can be added without UI changes; the UI
|
||||||
stays responsive with many submodules.
|
stays responsive with many submodules.
|
||||||
|
|
||||||
## D12 — No network on startup
|
## D12 — No network on startup (superseded by D18)
|
||||||
|
|
||||||
**Decision.** Work from local refs and show their age; fetching is an
|
**Decision.** Work from local refs and show their age; fetching is an
|
||||||
explicit action (`f`).
|
explicit action (`f`).
|
||||||
@@ -146,3 +146,40 @@ Dirt inside nested submodules is ignored (D7); unstaged moves show as
|
|||||||
removed + untracked.
|
removed + untracked.
|
||||||
**Consequences.** One extra `git diff` per dirty submodule, none for clean
|
**Consequences.** One extra `git diff` per dirty submodule, none for clean
|
||||||
ones. Not included: staged/unstaged markers, diff bodies, stash count.
|
ones. Not included: staged/unstaged markers, diff bodies, stash count.
|
||||||
|
|
||||||
|
## D18 — Background fetch on startup
|
||||||
|
|
||||||
|
**Context.** Without fetching, every answer is only as fresh as the user's
|
||||||
|
last `git fetch`, which they rarely run in every submodule.
|
||||||
|
**Decision.** Supersedes D12. Show the local state at once, then fetch the
|
||||||
|
superproject and every initialized submodule in the background (all
|
||||||
|
remotes, prune, no recursion: we fetch each repository ourselves, at most 8
|
||||||
|
at once). Fetches never prompt; they fail with git's message instead.
|
||||||
|
`--no-fetch` disables it, `f` repeats it.
|
||||||
|
**Consequences.** Startup still doesn't wait for the network (N1). A
|
||||||
|
failed fetch is shown per submodule and counted in the header; it is not an
|
||||||
|
issue (D3).
|
||||||
|
|
||||||
|
## D19 — Watch git metadata by polling
|
||||||
|
|
||||||
|
**Context.** The view should follow changes made outside the application.
|
||||||
|
File system events (fsnotify) need a dependency, one watch per directory
|
||||||
|
(hundreds of repositories × ref directories hit inotify limits) and don't
|
||||||
|
work on WSL's Windows drives or network file systems.
|
||||||
|
**Decision.** Poll every 2 s: compare names, mtimes and sizes of each
|
||||||
|
repository's git metadata (`FETCH_HEAD`, refs, `HEAD`, `index`, …),
|
||||||
|
split into a remote, a local and a structure part.
|
||||||
|
**Consequences.** Works everywhere, no dependency; a few `stat`s per
|
||||||
|
repository per poll; changes appear within about 2 s. Edits of worktree
|
||||||
|
files are not detected (that would mean running `git status` everywhere
|
||||||
|
every poll); `r` reloads on demand.
|
||||||
|
|
||||||
|
## D20 — Refresh is driven by the repositories, not by our fetch
|
||||||
|
|
||||||
|
**Decision.** A finished fetch only updates its progress. Reloading is
|
||||||
|
triggered solely by the watcher, so our own fetch and a fetch in another
|
||||||
|
shell take the same path. Reloads keep the old state visible, run at most
|
||||||
|
once per submodule at a time and coalesce bursts.
|
||||||
|
**Consequences.** One mechanism to test and trust. After our fetch the
|
||||||
|
data updates up to one poll interval later; the header shows "fetched just
|
||||||
|
now" right away.
|
||||||
|
|||||||
+23
-5
@@ -85,13 +85,28 @@ commit hashes. It doesn't answer the questions users actually have:
|
|||||||
- **F41** Mouse: click a row to select it, click `▸`/`▾` to toggle, scroll
|
- **F41** Mouse: click a row to select it, click `▸`/`▾` to toggle, scroll
|
||||||
wheel moves the selection.
|
wheel moves the selection.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- **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
|
||||||
|
failed, git's message.
|
||||||
|
- **F52** The view follows changes in the repositories, no matter who made
|
||||||
|
them: a fetch, commit, checkout or `git add` in another shell shows up
|
||||||
|
within about two seconds. Refreshing is driven by the repositories, not by
|
||||||
|
the application's own fetch.
|
||||||
|
- **F53** `f` fetches again; `r` reloads everything (e.g. after editing
|
||||||
|
files, which is not watched).
|
||||||
|
|
||||||
### Actions (planned, not implemented)
|
### Actions (planned, not implemented)
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `u` | Check out the pinned commit (`git submodule update`) |
|
| `u` | Check out the pinned commit (`git submodule update`) |
|
||||||
| `b` | Bump the pin to the tip of its ref |
|
| `b` | Bump the pin to the tip of its ref |
|
||||||
| `f` | Fetch remotes |
|
|
||||||
| `/` | Filter by name/path |
|
| `/` | Filter by name/path |
|
||||||
| `!` | Show only submodules with issues |
|
| `!` | Show only submodules with issues |
|
||||||
|
|
||||||
@@ -100,12 +115,13 @@ commit hashes. It doesn't answer the questions users actually have:
|
|||||||
```sh
|
```sh
|
||||||
submarine [dir] # the repository containing dir (default: current directory)
|
submarine [dir] # the repository containing dir (default: current directory)
|
||||||
submarine --mock # built-in mock data
|
submarine --mock # built-in mock data
|
||||||
|
submarine --no-fetch [dir] # no background fetch on startup
|
||||||
```
|
```
|
||||||
|
|
||||||
## Non-functional requirements
|
## Non-functional requirements
|
||||||
|
|
||||||
- **N1** Startup must not block on the network. The tool works from local
|
- **N1** Startup must not block on the network. The tool shows the local
|
||||||
refs and shows how old they are ("fetched 2m ago"); fetching is explicit.
|
state at once and fetches in the background (F50).
|
||||||
- **N2** Loading many submodules must not freeze the UI (async, per
|
- **N2** Loading many submodules must not freeze the UI (async, per
|
||||||
submodule).
|
submodule).
|
||||||
- **N3** Colors follow the user's terminal theme (ANSI 16 colors); the UI
|
- **N3** Colors follow the user's terminal theme (ANSI 16 colors); the UI
|
||||||
@@ -116,5 +132,7 @@ submarine --mock # built-in mock data
|
|||||||
- **N6** Works in narrow terminals (detail pane hides below 90 columns).
|
- **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
|
- **N7** The UI is independent of the data source: the mock and the git
|
||||||
provider are interchangeable without UI changes.
|
provider are interchangeable without UI changes.
|
||||||
- **N8** The git provider is read-only and never uses the network; it uses
|
- **N8** The git provider only uses the network in `Fetch`, and never
|
||||||
the `git` command line, so the user's git configuration applies.
|
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.
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ in several at once.
|
|||||||
| `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) |
|
| `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) |
|
||||||
| `Between` | Commits that make up the pin ↔ tip difference |
|
| `Between` | Commits that make up the pin ↔ tip difference |
|
||||||
| `Changes` | Changed files of the worktree (nil if clean), see below |
|
| `Changes` | Changed files of the worktree (nil if clean), see below |
|
||||||
|
| `FetchedAt` | Last fetch of the submodule (`FETCH_HEAD` mtime); zero if never |
|
||||||
|
|
||||||
|
`Submodule.FetchErr` is set if the application's last fetch of the
|
||||||
|
submodule failed. Like loading errors, it is not an issue kind: a failed
|
||||||
|
fetch says nothing about the submodule's state.
|
||||||
|
|
||||||
`Status == nil` means "still loading". `Submodule.Err` is set if loading
|
`Status == nil` means "still loading". `Submodule.Err` is set if loading
|
||||||
failed.
|
failed.
|
||||||
|
|||||||
+20
-6
@@ -63,7 +63,8 @@ indent ▸ name [n] status ref delta
|
|||||||
`detached HEAD on pin` as information
|
`detached HEAD on pin` as information
|
||||||
3. `Changes` (only with uncommitted changes), see
|
3. `Changes` (only with uncommitted changes), see
|
||||||
[Change preview](#change-preview)
|
[Change preview](#change-preview)
|
||||||
4. `Ref` with distance and `via <rule>`
|
4. `Ref` with distance and `via <rule>`; `Fetched <age>` of the submodule;
|
||||||
|
`fetch failed: <git's message>` (yellow word) if the last fetch failed
|
||||||
5. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it
|
5. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it
|
||||||
differs from the pin
|
differs from the pin
|
||||||
6. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list
|
6. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list
|
||||||
@@ -115,12 +116,23 @@ Changes 5 files · 2M 1A 1D 1R · +37 −12
|
|||||||
- Untracked directories are shown as one entry (`tmp/`).
|
- Untracked directories are shown as one entry (`tmp/`).
|
||||||
### Header
|
### Header
|
||||||
|
|
||||||
`path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · fetched <age>`
|
`path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · <fetch>`
|
||||||
|
|
||||||
Issue counts are ordered by severity (unpushed, conflict, missing, off pin,
|
Issue counts are ordered by severity (unpushed, conflict, missing, off pin,
|
||||||
changed), each in its level color. Zero counts are omitted. `<age>` is
|
changed), each in its level color. Zero counts are omitted. A detached
|
||||||
`never` if the superproject has never been fetched; a detached superproject
|
superproject shows `(detached)` as its branch.
|
||||||
shows `(detached)` as its branch.
|
|
||||||
|
`<fetch>` on the right:
|
||||||
|
|
||||||
|
| State | Shows |
|
||||||
|
|---|---|
|
||||||
|
| fetching | spinner + `fetching 12/174` (repositories done / total) |
|
||||||
|
| done | `fetched just now`, then the age (`fetched 5m ago`) |
|
||||||
|
| some failed | `fetched just now · 3 failed` (count in yellow) |
|
||||||
|
| never fetched | `fetched never` |
|
||||||
|
|
||||||
|
The age is the newer of the superproject's `FETCH_HEAD` and the end of the
|
||||||
|
application's own fetch.
|
||||||
|
|
||||||
## Colors
|
## Colors
|
||||||
|
|
||||||
@@ -171,7 +183,9 @@ colors in components.
|
|||||||
| `pgup`, `pgdn` | Move by one screen |
|
| `pgup`, `pgdn` | Move by one screen |
|
||||||
| `?` | Toggle full help |
|
| `?` | Toggle full help |
|
||||||
| `q`, `ctrl+c` | Quit |
|
| `q`, `ctrl+c` | Quit |
|
||||||
| `u`, `b`, `f`, `/`, `!` | Planned actions (see requirements) |
|
| `f` | Fetch all repositories (does nothing while a fetch runs) |
|
||||||
|
| `r` | Reload everything (e.g. after editing files) |
|
||||||
|
| `u`, `b`, `/`, `!` | Planned actions (see requirements) |
|
||||||
|
|
||||||
"First child" always means the first child **as displayed** (children are
|
"First child" always means the first child **as displayed** (children are
|
||||||
grouped by directory).
|
grouped by directory).
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,22 +18,40 @@ var gitEnv = append(os.Environ(),
|
|||||||
"LC_ALL=C",
|
"LC_ALL=C",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// gitError is a failed git command; Stderr is git's message.
|
||||||
|
type gitError struct {
|
||||||
|
Args []string
|
||||||
|
Stderr string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *gitError) Error() string {
|
||||||
|
msg := e.Stderr
|
||||||
|
if msg == "" {
|
||||||
|
msg = e.Err.Error()
|
||||||
|
}
|
||||||
|
return "git " + strings.Join(e.Args, " ") + ": " + msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *gitError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
// run executes git in dir and returns stdout with the trailing newline
|
// run executes git in dir and returns stdout with the trailing newline
|
||||||
// removed. The error includes git's stderr.
|
// removed. The error includes git's stderr.
|
||||||
func run(ctx context.Context, dir string, args ...string) (string, error) {
|
func run(ctx context.Context, dir string, args ...string) (string, error) {
|
||||||
|
return runEnv(ctx, dir, nil, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runEnv is run with additional environment variables.
|
||||||
|
func runEnv(ctx context.Context, dir string, env []string, args ...string) (string, error) {
|
||||||
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...)
|
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...)
|
||||||
cmd.Env = gitEnv
|
cmd.Env = slices.Concat(gitEnv, env) // a fresh slice: run is called concurrently
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return "", ctx.Err()
|
return "", ctx.Err()
|
||||||
}
|
}
|
||||||
msg := strings.TrimSpace(stderr.String())
|
return "", &gitError{Args: args, Stderr: strings.TrimSpace(stderr.String()), Err: err}
|
||||||
if msg == "" {
|
|
||||||
msg = err.Error()
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
|
|
||||||
}
|
}
|
||||||
return strings.TrimRight(stdout.String(), "\n"), nil
|
return strings.TrimRight(stdout.String(), "\n"), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fetchTimeout bounds a single fetch, e.g. against an unreachable host.
|
||||||
|
const fetchTimeout = 2 * time.Minute
|
||||||
|
|
||||||
|
// maxFetches bounds concurrent fetches; they wait on the network, not the
|
||||||
|
// CPU.
|
||||||
|
const maxFetches = 8
|
||||||
|
|
||||||
|
// sshBatchEnv makes ssh fail instead of asking for a password or host key,
|
||||||
|
// which would corrupt the terminal UI. It is nil if the user configured
|
||||||
|
// their own ssh command.
|
||||||
|
func sshBatchEnv(root string) []string {
|
||||||
|
if os.Getenv("GIT_SSH_COMMAND") != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if out, ok, _ := try(context.Background(), root, "config", "core.sshCommand"); ok && out != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{"GIT_SSH_COMMAND=ssh -o BatchMode=yes"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetchable reports whether path ("" is the superproject) is a checked-out
|
||||||
|
// repository.
|
||||||
|
func (p *Provider) Fetchable(path string) bool {
|
||||||
|
_, ok := p.repoDir(path)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoDir returns the worktree of path if it is initialized.
|
||||||
|
func (p *Provider) repoDir(path string) (string, bool) {
|
||||||
|
if path == "" {
|
||||||
|
return p.root, true
|
||||||
|
}
|
||||||
|
p.mu.RLock()
|
||||||
|
in, ok := p.mods[path]
|
||||||
|
p.mu.RUnlock()
|
||||||
|
if !ok || !initialized(in.dir) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return in.dir, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fetches all remotes of one repository. Submodules are fetched
|
||||||
|
// separately, not recursively. The error is git's first line of output.
|
||||||
|
func (p *Provider) Fetch(ctx context.Context, path string) error {
|
||||||
|
dir, ok := p.repoDir(path)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%s is not initialized", path)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case p.fetchSem <- struct{}{}:
|
||||||
|
defer func() { <-p.fetchSem }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err := runEnv(ctx, dir, p.sshEnv, "fetch", "--all", "--prune", "--no-recurse-submodules", "--quiet")
|
||||||
|
var ge *gitError
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
return fmt.Errorf("timed out after %s", fetchTimeout)
|
||||||
|
case errors.As(err, &ge) && ge.Stderr != "":
|
||||||
|
line, _, _ := strings.Cut(ge.Stderr, "\n")
|
||||||
|
return errors.New(strings.TrimPrefix(line, "fatal: "))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ownFixture builds a fixture the test may modify. It returns the provider
|
||||||
|
// (after Tree) and the fixture root (superproject in root/super, work
|
||||||
|
// clones of the remotes in root/work).
|
||||||
|
func ownFixture(t *testing.T) (*Provider, string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := exec.LookPath("git"); err != nil {
|
||||||
|
t.Skip("git not available")
|
||||||
|
}
|
||||||
|
root := filepath.Join(t.TempDir(), "fx")
|
||||||
|
if err := makeFixture(root); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p, err := New(filepath.Join(root, "super"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := p.Tree(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p, root
|
||||||
|
}
|
||||||
|
|
||||||
|
func git(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
args = append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@t"}, args...)
|
||||||
|
if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushUpstream adds a commit to a fixture remote, as if someone else pushed.
|
||||||
|
func pushUpstream(t *testing.T, root, remote string) {
|
||||||
|
t.Helper()
|
||||||
|
work := filepath.Join(root, "work", remote)
|
||||||
|
git(t, work, "fetch", "-q")
|
||||||
|
git(t, work, "reset", "-q", "--hard", "origin/main")
|
||||||
|
git(t, work, "commit", "--allow-empty", "-m", "Upstream change")
|
||||||
|
git(t, work, "push", "-q", "origin", "main")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetch(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
before, err := p.Status(ctx, "src/core")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pushUpstream(t, root, "core")
|
||||||
|
if err := p.Fetch(ctx, "src/core"); err != nil {
|
||||||
|
t.Fatalf("Fetch: %v", err)
|
||||||
|
}
|
||||||
|
after, err := p.Status(ctx, "src/core")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if after.Pin.Behind <= before.Pin.Behind {
|
||||||
|
t.Errorf("after fetch: ↓%d, want more than ↓%d", after.Pin.Behind, before.Pin.Behind)
|
||||||
|
}
|
||||||
|
if after.FetchedAt.IsZero() {
|
||||||
|
t.Error("after fetch: FetchedAt is zero")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.Fetch(ctx, ""); err != nil {
|
||||||
|
t.Errorf("Fetch(superproject without remotes): %v", err)
|
||||||
|
}
|
||||||
|
if p.Fetchable("libs/missing") {
|
||||||
|
t.Error("Fetchable(libs/missing) = true for an uninitialized submodule")
|
||||||
|
}
|
||||||
|
if err := p.Fetch(ctx, "libs/missing"); err == nil {
|
||||||
|
t.Error("Fetch(libs/missing): want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchError(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
git(t, filepath.Join(root, "super", "src", "ui"), "remote", "set-url", "origin", filepath.Join(root, "nowhere.git"))
|
||||||
|
|
||||||
|
err := p.Fetch(context.Background(), "src/ui")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Fetch with a bad remote: want error")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "\n") || strings.HasPrefix(err.Error(), "git ") {
|
||||||
|
t.Errorf("error should be git's first line only, got %q", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// next waits for a change or fails after timeout.
|
||||||
|
func next(t *testing.T, ch <-chan submodule.Change) submodule.Change {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case c := <-ch:
|
||||||
|
return c
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("no change reported")
|
||||||
|
return submodule.Change{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatch(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
p.WatchInterval = 20 * time.Millisecond
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
ch := p.Watch(ctx)
|
||||||
|
|
||||||
|
// our own reads must not look like changes
|
||||||
|
for _, path := range []string{"src/core", "libs/work", "libs/conflict"} {
|
||||||
|
if _, err := p.Status(ctx, path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case c := <-ch:
|
||||||
|
t.Fatalf("change while idle: %+v", c)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
// a fetch in another shell
|
||||||
|
pushUpstream(t, root, "net")
|
||||||
|
git(t, filepath.Join(super, "src", "net"), "fetch", "-q")
|
||||||
|
if c := next(t, ch); c != (submodule.Change{Path: "src/net"}) {
|
||||||
|
t.Errorf("after fetch: %+v, want remote change of src/net", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a commit in another shell
|
||||||
|
git(t, filepath.Join(super, "libs", "store"), "commit", "-q", "--allow-empty", "-m", "Local")
|
||||||
|
if c := next(t, ch); c != (submodule.Change{Path: "libs/store", Local: true}) {
|
||||||
|
t.Errorf("after commit: %+v, want local change of libs/store", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
for range ch { // closed after cancel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitDirs(t *testing.T) {
|
||||||
|
p, _ := loadFixture(t)
|
||||||
|
gd, cd, err := gitDirs(p.root)
|
||||||
|
if err != nil || gd != filepath.Join(p.root, ".git") || cd != gd {
|
||||||
|
t.Errorf("superproject: %q, %q, %v", gd, cd, err)
|
||||||
|
}
|
||||||
|
gd, cd, err = gitDirs(filepath.Join(p.root, "src", "core"))
|
||||||
|
if err != nil || gd != filepath.Join(p.root, ".git", "modules", "src", "core") || cd != gd {
|
||||||
|
t.Errorf("submodule: %q, %q, %v", gd, cd, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gitDirs returns the git directory of the worktree at dir and its common
|
||||||
|
// directory (they differ only for linked worktrees). A submodule's .git is
|
||||||
|
// a file "gitdir: <path>".
|
||||||
|
func gitDirs(dir string) (gitDir, commonDir string, err error) {
|
||||||
|
dotGit := filepath.Join(dir, ".git")
|
||||||
|
fi, err := os.Stat(dotGit)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
gitDir = dotGit
|
||||||
|
if !fi.IsDir() {
|
||||||
|
b, err := os.ReadFile(dotGit)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
p, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "gitdir:")
|
||||||
|
if !ok {
|
||||||
|
return "", "", errors.New(dotGit + ": not a gitdir file")
|
||||||
|
}
|
||||||
|
gitDir = joinPath(dir, strings.TrimSpace(p))
|
||||||
|
}
|
||||||
|
commonDir = gitDir
|
||||||
|
if b, err := os.ReadFile(filepath.Join(gitDir, "commondir")); err == nil {
|
||||||
|
commonDir = joinPath(gitDir, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
return gitDir, commonDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinPath(base, p string) string {
|
||||||
|
if !filepath.IsAbs(p) {
|
||||||
|
p = filepath.Join(base, p)
|
||||||
|
}
|
||||||
|
return filepath.Clean(p)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"submarine/internal/submodule"
|
"submarine/internal/submodule"
|
||||||
)
|
)
|
||||||
@@ -27,8 +28,13 @@ const maxBetween = 50
|
|||||||
|
|
||||||
// Provider reads a superproject and its submodules with git.
|
// Provider reads a superproject and its submodules with git.
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
root string // absolute superproject root
|
root string // absolute superproject root
|
||||||
sem chan struct{} // bounds concurrent Status calls
|
sem chan struct{} // bounds concurrent Status calls
|
||||||
|
fetchSem chan struct{} // bounds concurrent fetches
|
||||||
|
sshEnv []string // extra environment for fetches
|
||||||
|
|
||||||
|
// WatchInterval is how often Watch checks for changes.
|
||||||
|
WatchInterval time.Duration
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
mods map[string]info // by Submodule.Path; filled by Tree
|
mods map[string]info // by Submodule.Path; filled by Tree
|
||||||
@@ -49,8 +55,11 @@ func New(dir string) (*Provider, error) {
|
|||||||
return nil, fmt.Errorf("%s is not a git repository", dir)
|
return nil, fmt.Errorf("%s is not a git repository", dir)
|
||||||
}
|
}
|
||||||
return &Provider{
|
return &Provider{
|
||||||
root: root,
|
root: root,
|
||||||
sem: make(chan struct{}, runtime.NumCPU()),
|
sem: make(chan struct{}, runtime.NumCPU()),
|
||||||
|
fetchSem: make(chan struct{}, maxFetches),
|
||||||
|
sshEnv: sshBatchEnv(root),
|
||||||
|
WatchInterval: 2 * time.Second,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +177,12 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
|
|||||||
return st, nil
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if gitDir, _, err := gitDirs(in.dir); err == nil {
|
||||||
|
if fi, err := os.Stat(filepath.Join(gitDir, "FETCH_HEAD")); err == nil {
|
||||||
|
st.FetchedAt = fi.ModTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// checkout and worktree
|
// checkout and worktree
|
||||||
head, err := run(ctx, in.dir, "rev-parse", "HEAD")
|
head, err := run(ctx, in.dir, "rev-parse", "HEAD")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -27,11 +27,8 @@ func TestMain(m *testing.M) {
|
|||||||
}
|
}
|
||||||
defer os.RemoveAll(tmp)
|
defer os.RemoveAll(tmp)
|
||||||
if _, err := exec.LookPath("git"); err == nil {
|
if _, err := exec.LookPath("git"); err == nil {
|
||||||
_, file, _, _ := runtime.Caller(0)
|
if err := makeFixture(filepath.Join(tmp, "fx")); err != nil {
|
||||||
script := filepath.Join(filepath.Dir(file), "..", "..", "..", "scripts", "make-fixture.sh")
|
panic(err)
|
||||||
out, err := exec.Command("bash", script, filepath.Join(tmp, "fx")).CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
panic("make-fixture.sh: " + err.Error() + "\n" + string(out))
|
|
||||||
}
|
}
|
||||||
fixtureDir = filepath.Join(tmp, "fx", "super")
|
fixtureDir = filepath.Join(tmp, "fx", "super")
|
||||||
}
|
}
|
||||||
@@ -40,6 +37,16 @@ func TestMain(m *testing.M) {
|
|||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// makeFixture runs scripts/make-fixture.sh; the superproject is dir/super.
|
||||||
|
func makeFixture(dir string) error {
|
||||||
|
_, file, _, _ := runtime.Caller(0)
|
||||||
|
script := filepath.Join(filepath.Dir(file), "..", "..", "..", "scripts", "make-fixture.sh")
|
||||||
|
if out, err := exec.Command("bash", script, dir).CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("make-fixture.sh: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func loadFixture(t *testing.T) (*Provider, *submodule.Repo) {
|
func loadFixture(t *testing.T) (*Provider, *submodule.Repo) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if fixtureDir == "" {
|
if fixtureDir == "" {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fingerprint summarizes the git metadata of one repository. Each part is
|
||||||
|
// a list of file names with modification time and size; a change in a part
|
||||||
|
// means something in that area changed.
|
||||||
|
type fingerprint struct {
|
||||||
|
remote string // FETCH_HEAD, remote-tracking branches, tags
|
||||||
|
local string // HEAD, index, local branches, packed refs, merge state
|
||||||
|
structure string // .gitmodules and whether the repository is checked out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch polls the git metadata of the superproject and all submodules every
|
||||||
|
// WatchInterval and reports the repositories that changed. Polling (instead
|
||||||
|
// of file system events) works on every file system, including WSL's
|
||||||
|
// Windows drives, and needs no watch handles.
|
||||||
|
func (p *Provider) Watch(ctx context.Context) <-chan submodule.Change {
|
||||||
|
ch := make(chan submodule.Change, 16)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
prev := p.fingerprints()
|
||||||
|
t := time.NewTicker(p.WatchInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
cur := p.fingerprints()
|
||||||
|
for path, f := range cur {
|
||||||
|
old, ok := prev[path]
|
||||||
|
if !ok || old == f {
|
||||||
|
continue // new repositories only get a baseline
|
||||||
|
}
|
||||||
|
c := submodule.Change{
|
||||||
|
Path: path,
|
||||||
|
Local: old.local != f.local || old.structure != f.structure,
|
||||||
|
Structure: old.structure != f.structure,
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- c:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev = cur
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// fingerprints returns the fingerprint of every known repository, keyed by
|
||||||
|
// submodule path ("" is the superproject).
|
||||||
|
func (p *Provider) fingerprints() map[string]fingerprint {
|
||||||
|
dirs := map[string]string{"": p.root}
|
||||||
|
p.mu.RLock()
|
||||||
|
for path, in := range p.mods {
|
||||||
|
dirs[path] = in.dir
|
||||||
|
}
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
fps := make(map[string]fingerprint, len(dirs))
|
||||||
|
for path, dir := range dirs {
|
||||||
|
fps[path] = fingerprintOf(dir)
|
||||||
|
}
|
||||||
|
return fps
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintOf(dir string) fingerprint {
|
||||||
|
var f fingerprint
|
||||||
|
var structure strings.Builder
|
||||||
|
stamp(&structure, filepath.Join(dir, ".gitmodules"))
|
||||||
|
gitDir, common, err := gitDirs(dir)
|
||||||
|
if err != nil {
|
||||||
|
f.structure = structure.String() + "not checked out"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
f.structure = structure.String()
|
||||||
|
|
||||||
|
var remote, local strings.Builder
|
||||||
|
stamp(&remote, filepath.Join(gitDir, "FETCH_HEAD"))
|
||||||
|
stampTree(&remote, filepath.Join(common, "refs", "remotes"))
|
||||||
|
stampTree(&remote, filepath.Join(common, "refs", "tags"))
|
||||||
|
|
||||||
|
for _, name := range []string{"HEAD", "index", "MERGE_HEAD"} {
|
||||||
|
stamp(&local, filepath.Join(gitDir, name))
|
||||||
|
}
|
||||||
|
stamp(&local, filepath.Join(common, "packed-refs"))
|
||||||
|
stampTree(&local, filepath.Join(common, "refs", "heads"))
|
||||||
|
stampTree(&local, filepath.Join(common, "reftable")) // reftable ref storage
|
||||||
|
f.remote, f.local = remote.String(), local.String()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// stamp appends the name, modification time and size of a file; nothing if
|
||||||
|
// it doesn't exist.
|
||||||
|
func stamp(b *strings.Builder, name string) {
|
||||||
|
if fi, err := os.Stat(name); err == nil {
|
||||||
|
fmt.Fprintf(b, "%s %d %d\n", name, fi.ModTime().UnixNano(), fi.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stampTree stamps every file below dir.
|
||||||
|
func stampTree(b *strings.Builder, dir string) {
|
||||||
|
filepath.WalkDir(dir, func(name string, d fs.DirEntry, err error) error {
|
||||||
|
if err == nil && !d.IsDir() {
|
||||||
|
stamp(b, name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ package mock
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"path"
|
"path"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"submarine/internal/submodule"
|
"submarine/internal/submodule"
|
||||||
@@ -15,7 +17,9 @@ import (
|
|||||||
// Provider serves a fixed fixture tree.
|
// Provider serves a fixed fixture tree.
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
MinDelay, MaxDelay time.Duration
|
MinDelay, MaxDelay time.Duration
|
||||||
statuses map[string]*submodule.Status
|
|
||||||
|
mu sync.RWMutex // Tree can run again (reload) while Status runs
|
||||||
|
statuses map[string]*submodule.Status
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Provider {
|
func New() *Provider {
|
||||||
@@ -32,6 +36,8 @@ type node struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
|
func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
p.statuses = map[string]*submodule.Status{}
|
p.statuses = map[string]*submodule.Status{}
|
||||||
repo := &submodule.Repo{
|
repo := &submodule.Repo{
|
||||||
Path: "~/coding/my_project",
|
Path: "~/coding/my_project",
|
||||||
@@ -57,6 +63,9 @@ func (p *Provider) build(nodes []node, parent *submodule.Submodule) []*submodule
|
|||||||
Parent: parent,
|
Parent: parent,
|
||||||
}
|
}
|
||||||
m.Children = p.build(n.children, m)
|
m.Children = p.build(n.children, m)
|
||||||
|
if n.status.Presence == submodule.PresenceOK && n.status.FetchedAt.IsZero() {
|
||||||
|
n.status.FetchedAt = now.Add(-2 * time.Minute)
|
||||||
|
}
|
||||||
p.statuses[full] = n.status
|
p.statuses[full] = n.status
|
||||||
out = append(out, m)
|
out = append(out, m)
|
||||||
}
|
}
|
||||||
@@ -64,22 +73,54 @@ func (p *Provider) build(nodes []node, parent *submodule.Submodule) []*submodule
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Provider) Status(ctx context.Context, path string) (*submodule.Status, error) {
|
func (p *Provider) Status(ctx context.Context, path string) (*submodule.Status, error) {
|
||||||
delay := p.MinDelay
|
if err := p.sleep(ctx, p.MinDelay, p.MaxDelay); err != nil {
|
||||||
if span := p.MaxDelay - p.MinDelay; span > 0 {
|
return nil, err
|
||||||
delay += rand.N(span)
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-time.After(delay):
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil, ctx.Err()
|
|
||||||
}
|
}
|
||||||
|
p.mu.RLock()
|
||||||
s, ok := p.statuses[path]
|
s, ok := p.statuses[path]
|
||||||
|
p.mu.RUnlock()
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("unknown submodule %q", path)
|
return nil, fmt.Errorf("unknown submodule %q", path)
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetchable is true for the superproject and initialized submodules.
|
||||||
|
func (p *Provider) Fetchable(path string) bool {
|
||||||
|
if path == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
s, ok := p.statuses[path]
|
||||||
|
return ok && s.Presence == submodule.PresenceOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch pretends to fetch; one submodule always fails. It changes no data.
|
||||||
|
func (p *Provider) Fetch(ctx context.Context, path string) error {
|
||||||
|
if err := p.sleep(ctx, 300*time.Millisecond, 2*time.Second); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if path == "src/my_submodule_3" {
|
||||||
|
return errors.New("git@github.com: Permission denied (publickey).")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sleep waits a random duration in [min, max) or until ctx is done.
|
||||||
|
func (p *Provider) sleep(ctx context.Context, min, max time.Duration) error {
|
||||||
|
d := min
|
||||||
|
if span := max - min; span > 0 {
|
||||||
|
d += rand.N(span)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(d):
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- fixture -----------------------------------------------------------------
|
// --- fixture -----------------------------------------------------------------
|
||||||
|
|
||||||
var now = time.Now()
|
var now = time.Now()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type Submodule struct {
|
|||||||
Children []*Submodule
|
Children []*Submodule
|
||||||
Status *Status // nil while loading
|
Status *Status // nil while loading
|
||||||
Err error // set if loading the status failed
|
Err error // set if loading the status failed
|
||||||
|
FetchErr error // set if the last fetch failed; not an issue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Presence describes whether the submodule exists on disk.
|
// Presence describes whether the submodule exists on disk.
|
||||||
@@ -177,6 +178,7 @@ type Status struct {
|
|||||||
Pinned *Commit
|
Pinned *Commit
|
||||||
CheckedOut *Commit
|
CheckedOut *Commit
|
||||||
Tip *Commit
|
Tip *Commit
|
||||||
Between []Commit // commits that make up the pin <-> tip difference
|
Between []Commit // commits that make up the pin <-> tip difference
|
||||||
Changes *Changes // nil if the worktree is clean or the details are unknown
|
Changes *Changes // nil if the worktree is clean or the details are unknown
|
||||||
|
FetchedAt time.Time // last fetch of the submodule; zero if never
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,3 +8,27 @@ type Provider interface {
|
|||||||
Tree(ctx context.Context) (*Repo, error)
|
Tree(ctx context.Context) (*Repo, error)
|
||||||
Status(ctx context.Context, path string) (*Status, error)
|
Status(ctx context.Context, path string) (*Status, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetcher is implemented by providers that can download remote refs. Path
|
||||||
|
// "" is the superproject. Fetch must be safe to call concurrently and must
|
||||||
|
// never prompt the user.
|
||||||
|
type Fetcher interface {
|
||||||
|
// Fetchable reports whether path is a repository that can be fetched
|
||||||
|
// (e.g. not an uninitialized submodule).
|
||||||
|
Fetchable(path string) bool
|
||||||
|
Fetch(ctx context.Context, path string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watcher is implemented by providers that notice changes made outside the
|
||||||
|
// application, e.g. a fetch or commit in another shell. The channel is
|
||||||
|
// closed when ctx is done.
|
||||||
|
type Watcher interface {
|
||||||
|
Watch(ctx context.Context) <-chan Change
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change reports that the git metadata of a repository changed.
|
||||||
|
type Change struct {
|
||||||
|
Path string // "" is the superproject
|
||||||
|
Local bool // HEAD, index or local branches changed, not only remote refs
|
||||||
|
Structure bool // .gitmodules changed: submodules may have been added or removed
|
||||||
|
}
|
||||||
|
|||||||
+217
-26
@@ -4,6 +4,7 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
@@ -26,16 +27,29 @@ const (
|
|||||||
detailWidthRatio = 0.4
|
detailWidthRatio = 0.4
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Options configure the application.
|
||||||
|
type Options struct {
|
||||||
|
Fetch bool // fetch all repositories in the background on startup
|
||||||
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
provider submodule.Provider
|
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
|
theme theme.Theme
|
||||||
keys keyMap
|
keys keyMap
|
||||||
|
|
||||||
repo *submodule.Repo
|
repo *submodule.Repo
|
||||||
byPath map[string]*submodule.Submodule
|
byPath map[string]*submodule.Submodule
|
||||||
loading int
|
err error
|
||||||
err error
|
|
||||||
|
loads reloader
|
||||||
|
reloadStatuses bool // the running or pending tree load also reloads every status
|
||||||
|
changes <-chan submodule.Change
|
||||||
|
fetch header.Fetch
|
||||||
|
ticking bool // a spinner tick is scheduled
|
||||||
|
|
||||||
tree tree.Model
|
tree tree.Model
|
||||||
detail detail.Model
|
detail detail.Model
|
||||||
@@ -46,23 +60,30 @@ type Model struct {
|
|||||||
showDetail bool
|
showDetail bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ctx context.Context, p submodule.Provider) Model {
|
func New(ctx context.Context, p submodule.Provider, opts Options) Model {
|
||||||
t := theme.Default()
|
t := theme.Default()
|
||||||
k := defaultKeys()
|
k := defaultKeys()
|
||||||
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
|
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
|
||||||
return Model{
|
m := Model{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
provider: p,
|
provider: p,
|
||||||
|
opts: opts,
|
||||||
theme: t,
|
theme: t,
|
||||||
keys: k,
|
keys: k,
|
||||||
|
loads: newReloader(),
|
||||||
|
ticking: true, // Init schedules the first tick
|
||||||
tree: tree.New(t),
|
tree: tree.New(t),
|
||||||
detail: detail.New(t),
|
detail: detail.New(t),
|
||||||
footer: footer.New(t, k),
|
footer: footer.New(t, k),
|
||||||
spinner: sp,
|
spinner: sp,
|
||||||
}
|
}
|
||||||
|
m.fetcher, _ = p.(submodule.Fetcher)
|
||||||
|
m.watcher, _ = p.(submodule.Watcher)
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
|
m.loads.start(treeKey)
|
||||||
return tea.Batch(m.loadTree(), m.spinner.Tick)
|
return tea.Batch(m.loadTree(), m.spinner.Tick)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +101,74 @@ func (m Model) loadStatus(path string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reloadTree loads the tree again, unless a load is running (then it runs
|
||||||
|
// once more afterwards). With statuses, every status is reloaded too.
|
||||||
|
func (m *Model) reloadTree(statuses bool) tea.Cmd {
|
||||||
|
m.reloadStatuses = m.reloadStatuses || statuses
|
||||||
|
if !m.loads.start(treeKey) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.loadTree()
|
||||||
|
}
|
||||||
|
|
||||||
|
// reloadStatus loads one status again; the old one stays visible meanwhile.
|
||||||
|
func (m *Model) reloadStatus(path string) tea.Cmd {
|
||||||
|
if !m.loads.start(path) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.loadStatus(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) waitForChange() tea.Cmd {
|
||||||
|
ch := m.changes
|
||||||
|
return func() tea.Msg {
|
||||||
|
c, ok := <-ch
|
||||||
|
return changeMsg{change: c, closed: !ok}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startFetch fetches the superproject and every initialized submodule in
|
||||||
|
// the background. It does nothing while a fetch is running.
|
||||||
|
func (m *Model) startFetch() tea.Cmd {
|
||||||
|
if m.fetcher == nil || m.fetch.Running || m.repo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
paths := []string{""}
|
||||||
|
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
|
||||||
|
if m.fetcher.Fetchable(s.Path) {
|
||||||
|
paths = append(paths, s.Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
m.fetch = header.Fetch{Running: true, Total: len(paths), At: m.fetch.At}
|
||||||
|
cmds := []tea.Cmd{m.tick()}
|
||||||
|
for _, p := range paths {
|
||||||
|
f, ctx := m.fetcher, m.ctx
|
||||||
|
cmds = append(cmds, func() tea.Msg { return fetchDoneMsg{p, f.Fetch(ctx, p)} })
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// busy reports whether something shows a spinner.
|
||||||
|
func (m Model) busy() bool {
|
||||||
|
if m.repo == nil || m.fetch.Running {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
loading := false
|
||||||
|
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
|
||||||
|
loading = loading || (s.Status == nil && s.Err == nil)
|
||||||
|
})
|
||||||
|
return loading
|
||||||
|
}
|
||||||
|
|
||||||
|
// tick schedules a spinner tick if none is scheduled.
|
||||||
|
func (m *Model) tick() tea.Cmd {
|
||||||
|
if m.ticking {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.ticking = true
|
||||||
|
return m.spinner.Tick
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
@@ -88,25 +177,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case treeLoadedMsg:
|
case treeLoadedMsg:
|
||||||
if msg.err != nil {
|
return m.treeLoaded(msg)
|
||||||
m.err = msg.err
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.repo = msg.repo
|
|
||||||
m.byPath = map[string]*submodule.Submodule{}
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) {
|
|
||||||
m.byPath[s.Path] = s
|
|
||||||
cmds = append(cmds, m.loadStatus(s.Path))
|
|
||||||
})
|
|
||||||
m.loading = len(cmds)
|
|
||||||
m.tree.SetRoots(m.repo.Submodules)
|
|
||||||
return m, tea.Batch(cmds...)
|
|
||||||
|
|
||||||
case statusLoadedMsg:
|
case statusLoadedMsg:
|
||||||
if s, ok := m.byPath[msg.path]; ok {
|
if s, ok := m.byPath[msg.path]; ok {
|
||||||
s.Status, s.Err = msg.status, msg.err
|
if msg.err != nil {
|
||||||
m.loading--
|
s.Err = msg.err
|
||||||
|
} else {
|
||||||
|
s.Status, s.Err = msg.status, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.loads.done(msg.path) {
|
||||||
|
return m, m.loadStatus(msg.path)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case changeMsg:
|
||||||
|
return m.changed(msg)
|
||||||
|
|
||||||
|
case fetchDoneMsg:
|
||||||
|
m.fetch.Done++
|
||||||
|
if msg.err != nil {
|
||||||
|
m.fetch.Failed++
|
||||||
|
}
|
||||||
|
if s, ok := m.byPath[msg.path]; ok {
|
||||||
|
s.FetchErr = msg.err
|
||||||
|
}
|
||||||
|
if m.fetch.Done == m.fetch.Total {
|
||||||
|
m.fetch.Running = false
|
||||||
|
m.fetch.At = time.Now()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -115,8 +214,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.spinner, cmd = m.spinner.Update(msg)
|
m.spinner, cmd = m.spinner.Update(msg)
|
||||||
m.tree.SetSpinner(m.spinner.View())
|
m.tree.SetSpinner(m.spinner.View())
|
||||||
m.detail.SetSpinner(m.spinner.View())
|
m.detail.SetSpinner(m.spinner.View())
|
||||||
if m.repo != nil && m.loading == 0 {
|
if !m.busy() {
|
||||||
return m, nil // stop ticking once everything is loaded
|
m.ticking = false // stop ticking until there is work again
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
|
|
||||||
@@ -132,6 +232,81 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// treeLoaded installs a (re)loaded tree. On a reload the known statuses are
|
||||||
|
// carried over by path, so rows don't fall back to a spinner.
|
||||||
|
func (m Model) treeLoaded(msg treeLoadedMsg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
if m.loads.done(treeKey) {
|
||||||
|
cmds = append(cmds, m.loadTree())
|
||||||
|
}
|
||||||
|
initial := m.repo == nil
|
||||||
|
if msg.err != nil {
|
||||||
|
if initial {
|
||||||
|
m.err = msg.err
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
cmds = append(cmds, m.footer.Flash("reload failed: "+msg.err.Error()))
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
old := m.byPath
|
||||||
|
m.repo = msg.repo
|
||||||
|
m.byPath = map[string]*submodule.Submodule{}
|
||||||
|
reload := initial || m.reloadStatuses
|
||||||
|
m.reloadStatuses = false
|
||||||
|
submodule.Walk(m.repo.Submodules, 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
|
||||||
|
}
|
||||||
|
if reload || s.Status == nil {
|
||||||
|
cmds = append(cmds, m.reloadStatus(s.Path))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if initial {
|
||||||
|
m.tree.SetRoots(m.repo.Submodules)
|
||||||
|
if m.watcher != nil {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
cmds = append(cmds, m.tick())
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// changed reacts to a change the watcher saw, whoever made it.
|
||||||
|
func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if msg.closed {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
cmds := []tea.Cmd{m.waitForChange()}
|
||||||
|
c := msg.change
|
||||||
|
switch {
|
||||||
|
case c.Path == "" || c.Structure:
|
||||||
|
// 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))
|
||||||
|
default:
|
||||||
|
s, ok := m.byPath[c.Path]
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cmds = append(cmds, m.reloadStatus(s.Path))
|
||||||
|
if c.Local { // the children's pins live in this repository
|
||||||
|
for _, child := range s.Children {
|
||||||
|
cmds = append(cmds, m.reloadStatus(child.Path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
k := m.keys
|
k := m.keys
|
||||||
switch {
|
switch {
|
||||||
@@ -162,7 +337,23 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.tree.PageUp()
|
m.tree.PageUp()
|
||||||
case key.Matches(msg, k.PageDown):
|
case key.Matches(msg, k.PageDown):
|
||||||
m.tree.PageDown()
|
m.tree.PageDown()
|
||||||
case key.Matches(msg, k.Update, k.Bump, k.Fetch, k.Filter, k.Issues):
|
case key.Matches(msg, k.Fetch):
|
||||||
|
var flash string
|
||||||
|
switch {
|
||||||
|
case m.fetcher == nil:
|
||||||
|
flash = "this data source can't fetch"
|
||||||
|
case m.fetch.Running:
|
||||||
|
flash = "already fetching"
|
||||||
|
}
|
||||||
|
if flash != "" {
|
||||||
|
cmd := m.footer.Flash(flash)
|
||||||
|
m.layout()
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
return m, m.startFetch()
|
||||||
|
case key.Matches(msg, k.Reload):
|
||||||
|
return m, m.reloadTree(true)
|
||||||
|
case key.Matches(msg, k.Update, k.Bump, k.Filter, k.Issues):
|
||||||
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
|
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
|
||||||
m.layout()
|
m.layout()
|
||||||
return m, cmd
|
return m, cmd
|
||||||
@@ -204,7 +395,7 @@ func (m Model) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left,
|
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left,
|
||||||
header.View(m.theme, m.repo, m.width),
|
header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width),
|
||||||
body,
|
body,
|
||||||
m.footer.View(),
|
m.footer.View(),
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -99,6 +99,12 @@ func (m Model) lines(s *submodule.Submodule) []string {
|
|||||||
label("")+muted.Render("via "+st.Pin.Source.String()),
|
label("")+muted.Render("via "+st.Pin.Source.String()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if st.Presence == submodule.PresenceOK {
|
||||||
|
out = append(out, label("Fetched")+muted.Render(theme.Ago(st.FetchedAt)))
|
||||||
|
}
|
||||||
|
if s.FetchErr != nil {
|
||||||
|
out = append(out, theme.Fg(t.Warn).Width(labelWidth).Render("fetch")+text.Render("failed: "+s.FetchErr.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
// commits
|
// commits
|
||||||
commitLine := func(name string, c *submodule.Commit, highlight bool) string {
|
commitLine := func(name string, c *submodule.Commit, highlight bool) string {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package header
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
@@ -10,8 +11,18 @@ import (
|
|||||||
"submarine/internal/tui/theme"
|
"submarine/internal/tui/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
// View renders repo info, state counts and fetch age on one line.
|
// Fetch is the progress of the application's own background fetch.
|
||||||
func View(t theme.Theme, repo *submodule.Repo, width int) string {
|
type Fetch struct {
|
||||||
|
Running bool
|
||||||
|
Done int // repositories fetched (or failed)
|
||||||
|
Total int // repositories to fetch
|
||||||
|
Failed int // failed fetches of the last run
|
||||||
|
At time.Time // when the last run finished
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
bar := lipgloss.NewStyle().Background(t.Bar)
|
bar := lipgloss.NewStyle().Background(t.Bar)
|
||||||
seg := func(c lipgloss.TerminalColor, s string) string { return bar.Foreground(c).Render(s) }
|
seg := func(c lipgloss.TerminalColor, s string) string { return bar.Foreground(c).Render(s) }
|
||||||
|
|
||||||
@@ -60,7 +71,22 @@ func View(t theme.Theme, repo *submodule.Repo, width int) string {
|
|||||||
mid += sep + seg(t.Muted, fmt.Sprintf("%d loading", loading))
|
mid += sep + seg(t.Muted, fmt.Sprintf("%d loading", loading))
|
||||||
}
|
}
|
||||||
|
|
||||||
right := seg(t.Muted, "fetched "+theme.Ago(repo.FetchedAt)+" ")
|
var right string
|
||||||
|
switch {
|
||||||
|
case fetch.Running:
|
||||||
|
right = seg(t.Primary, spinner) + seg(t.Muted, fmt.Sprintf(" fetching %d/%d ", fetch.Done, fetch.Total))
|
||||||
|
default:
|
||||||
|
// our own fetch may have finished before the watcher saw FETCH_HEAD
|
||||||
|
at := repo.FetchedAt
|
||||||
|
if fetch.At.After(at) {
|
||||||
|
at = fetch.At
|
||||||
|
}
|
||||||
|
right = seg(t.Muted, "fetched "+theme.Ago(at))
|
||||||
|
if fetch.Failed > 0 {
|
||||||
|
right += seg(t.Muted, " · ") + seg(t.Warn, fmt.Sprintf("%d failed", fetch.Failed))
|
||||||
|
}
|
||||||
|
right += seg(t.Muted, " ")
|
||||||
|
}
|
||||||
|
|
||||||
gap := width - lipgloss.Width(left) - lipgloss.Width(mid) - lipgloss.Width(right)
|
gap := width - lipgloss.Width(left) - lipgloss.Width(mid) - lipgloss.Width(right)
|
||||||
if gap < 4 {
|
if gap < 4 {
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package tui
|
|||||||
import "github.com/charmbracelet/bubbles/key"
|
import "github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
type keyMap struct {
|
type keyMap struct {
|
||||||
Up, Down, Left, Right, Toggle key.Binding
|
Up, Down, Left, Right, Toggle key.Binding
|
||||||
CollapseAll, ExpandAll key.Binding
|
CollapseAll, ExpandAll key.Binding
|
||||||
Home, End, PageUp, PageDown key.Binding
|
Home, End, PageUp, PageDown key.Binding
|
||||||
Update, Bump, Fetch, Filter, Issues key.Binding
|
Update, Bump, Fetch, Reload, Filter, Issues key.Binding
|
||||||
Help, Quit key.Binding
|
Help, Quit key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultKeys() keyMap {
|
func defaultKeys() keyMap {
|
||||||
@@ -26,6 +26,7 @@ func defaultKeys() keyMap {
|
|||||||
Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "checkout pin")),
|
Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "checkout pin")),
|
||||||
Bump: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "bump pin to tip")),
|
Bump: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "bump pin to tip")),
|
||||||
Fetch: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fetch")),
|
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")),
|
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||||
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
||||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||||
@@ -42,7 +43,7 @@ func (k keyMap) FullHelp() [][]key.Binding {
|
|||||||
{k.Up, k.Down, k.Left, k.Right, k.Toggle},
|
{k.Up, k.Down, k.Left, k.Right, k.Toggle},
|
||||||
{k.CollapseAll, k.ExpandAll},
|
{k.CollapseAll, k.ExpandAll},
|
||||||
{k.Home, k.End, k.PageUp, k.PageDown},
|
{k.Home, k.End, k.PageUp, k.PageDown},
|
||||||
{k.Update, k.Bump, k.Fetch, k.Filter, k.Issues},
|
{k.Update, k.Bump, k.Fetch, k.Reload, k.Filter, k.Issues},
|
||||||
{k.Help, k.Quit},
|
{k.Help, k.Quit},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,3 +12,17 @@ type statusLoadedMsg struct {
|
|||||||
status *submodule.Status
|
status *submodule.Status
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchDoneMsg reports one finished fetch. It only updates the fetch
|
||||||
|
// progress; refreshing is left to the watcher, which also sees fetches made
|
||||||
|
// outside the application.
|
||||||
|
type fetchDoneMsg struct {
|
||||||
|
path string // "" is the superproject
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// changeMsg is a change reported by the provider's watcher.
|
||||||
|
type changeMsg struct {
|
||||||
|
change submodule.Change
|
||||||
|
closed bool // the watcher stopped
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
// treeKey is the reloader key for loading the tree; submodules use their
|
||||||
|
// path.
|
||||||
|
const treeKey = "\x00tree"
|
||||||
|
|
||||||
|
// reloader allows at most one load per key at a time. A request while a
|
||||||
|
// load is running marks the key dirty, and the load runs once more after
|
||||||
|
// the current one finishes, so the last result always reflects the latest
|
||||||
|
// change and results never arrive out of order.
|
||||||
|
type reloader struct {
|
||||||
|
running map[string]bool
|
||||||
|
dirty map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newReloader() reloader {
|
||||||
|
return reloader{running: map[string]bool{}, dirty: map[string]bool{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start reports whether a load for key should start now. If one is already
|
||||||
|
// running, the key is marked dirty instead.
|
||||||
|
func (r reloader) start(key string) bool {
|
||||||
|
if r.running[key] {
|
||||||
|
r.dirty[key] = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.running[key] = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// done marks the load for key as finished. It reports whether the load must
|
||||||
|
// run again because it was requested meanwhile; the key then stays running.
|
||||||
|
func (r reloader) done(key string) (again bool) {
|
||||||
|
if r.dirty[key] {
|
||||||
|
delete(r.dirty, key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
delete(r.running, key)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// busy reports whether any load is running.
|
||||||
|
func (r reloader) busy() bool { return len(r.running) > 0 }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestReloader(t *testing.T) {
|
||||||
|
r := newReloader()
|
||||||
|
if !r.start("a") {
|
||||||
|
t.Fatal("first start: want true")
|
||||||
|
}
|
||||||
|
if r.start("a") || r.start("a") {
|
||||||
|
t.Fatal("start while running: want false")
|
||||||
|
}
|
||||||
|
if !r.start("b") {
|
||||||
|
t.Fatal("other key: want true")
|
||||||
|
}
|
||||||
|
if !r.done("a") {
|
||||||
|
t.Fatal("done after requests while running: want again")
|
||||||
|
}
|
||||||
|
if r.done("a") {
|
||||||
|
t.Fatal("second done: requests were coalesced into one rerun")
|
||||||
|
}
|
||||||
|
if r.done("b") {
|
||||||
|
t.Fatal("done without requests: want no rerun")
|
||||||
|
}
|
||||||
|
if r.busy() {
|
||||||
|
t.Fatal("busy after all done")
|
||||||
|
}
|
||||||
|
if !r.start("a") {
|
||||||
|
t.Fatal("start after done: want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,28 @@ func (m *Model) SetRoots(roots []*submodule.Submodule) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReplaceRoots swaps in a reloaded tree. Unlike SetRoots it keeps the
|
||||||
|
// expansion state and the cursor (by path); if the selected submodule is
|
||||||
|
// gone, the cursor moves to the first one.
|
||||||
|
func (m *Model) ReplaceRoots(roots []*submodule.Submodule) {
|
||||||
|
var path string
|
||||||
|
if m.cursor != nil {
|
||||||
|
path = m.cursor.Path
|
||||||
|
}
|
||||||
|
m.roots = roots
|
||||||
|
m.cursor = nil
|
||||||
|
for _, r := range m.rows() {
|
||||||
|
if r.kind == rowNode && r.node.Path == path {
|
||||||
|
m.cursor = r.node
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.cursor == nil && len(roots) > 0 {
|
||||||
|
m.cursor = m.rows()[m.firstNodeRow()].node
|
||||||
|
}
|
||||||
|
m.clampOffset()
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) SetSize(w, h int) {
|
func (m *Model) SetSize(w, h int) {
|
||||||
m.width, m.height = w, h
|
m.width, m.height = w, h
|
||||||
m.clampOffset()
|
m.clampOffset()
|
||||||
|
|||||||
@@ -69,6 +69,23 @@ func visible(m *Model) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReplaceRootsKeepsState(t *testing.T) {
|
||||||
|
m := newTree(t)
|
||||||
|
m.Down() // b
|
||||||
|
m.Right() // expand b, cursor on c
|
||||||
|
m.Right() // expand c, cursor on d
|
||||||
|
assertCursor(t, m, "d")
|
||||||
|
|
||||||
|
m.ReplaceRoots(fixture()) // same paths, new nodes
|
||||||
|
assertCursor(t, m, "d")
|
||||||
|
if got := len(visible(m)); got != 8 {
|
||||||
|
t.Errorf("visible rows = %v, want b and c still expanded", visible(m))
|
||||||
|
}
|
||||||
|
|
||||||
|
m.ReplaceRoots(fixture()[:1]) // only "a" left
|
||||||
|
assertCursor(t, m, "a")
|
||||||
|
}
|
||||||
|
|
||||||
func TestInitiallyCollapsedWithHeaders(t *testing.T) {
|
func TestInitiallyCollapsedWithHeaders(t *testing.T) {
|
||||||
m := newTree(t)
|
m := newTree(t)
|
||||||
got := visible(m)
|
got := visible(m)
|
||||||
|
|||||||
Reference in New Issue
Block a user