Archived
Add read-only git provider
Implement submodule.Provider with the git CLI so submarine reads real repositories: `submarine [dir]`, with `--mock` for the built-in data. - Tree reads .gitmodules recursively; Status derives presence, checkout, worktree, staged pin, pushed state and pin -> ref resolution from local git commands only (no network). - Concurrent Status calls are bounded by runtime.NumCPU(). - scripts/make-fixture.sh builds a superproject covering every state; the integration tests run against it. - theme.Ago shows "never" for a zero time. - Docs updated; decisions D14-D16 added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c84f34ee2b
commit
64fe34b5b0
@@ -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 on mock data.
|
Currently a prototype with a read-only git provider and mock data.
|
||||||
|
|
||||||
## Read first
|
## Read first
|
||||||
|
|
||||||
@@ -18,7 +18,9 @@ and decisions. Read the relevant document before changing behavior:
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./cmd/submarine # run (mock data)
|
go run ./cmd/submarine [dir] # run on a repository (default: cwd)
|
||||||
|
go run ./cmd/submarine --mock # run on mock data
|
||||||
|
scripts/make-fixture.sh DIR # build a test superproject in DIR
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go test ./...
|
go test ./...
|
||||||
gofmt -l . # must print nothing
|
gofmt -l . # must print nothing
|
||||||
@@ -32,7 +34,8 @@ 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`.
|
- **Data access** only through `submodule.Provider`. The git provider is
|
||||||
|
read-only and must never use the network.
|
||||||
- **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)`).
|
||||||
|
|||||||
+20
-3
@@ -12,20 +12,37 @@ import (
|
|||||||
zone "github.com/lrstanley/bubblezone"
|
zone "github.com/lrstanley/bubblezone"
|
||||||
|
|
||||||
"submarine/internal/submodule"
|
"submarine/internal/submodule"
|
||||||
|
"submarine/internal/submodule/git"
|
||||||
"submarine/internal/submodule/mock"
|
"submarine/internal/submodule/mock"
|
||||||
"submarine/internal/tui"
|
"submarine/internal/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
useMock := flag.Bool("mock", true, "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")
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [dir]")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
}
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
if flag.NArg() > 1 {
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
var provider submodule.Provider
|
var provider submodule.Provider
|
||||||
if *useMock {
|
if *useMock {
|
||||||
provider = mock.New()
|
provider = mock.New()
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintln(os.Stderr, "only --mock is supported in the prototype")
|
dir := "."
|
||||||
os.Exit(2)
|
if flag.NArg() == 1 {
|
||||||
|
dir = flag.Arg(0)
|
||||||
|
}
|
||||||
|
p, err := git.New(dir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "submarine:", err)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
provider = p
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|||||||
+8
-7
@@ -17,13 +17,14 @@ bare commit hash.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
**Prototype.** The UI is complete for browsing and runs on mock data
|
**Prototype.** The UI is complete for browsing. It reads real repositories
|
||||||
(`internal/submodule/mock`). There is no git backend yet, and actions
|
through a read-only git provider (`internal/submodule/git`):
|
||||||
(`u`, `b`, `f`, `/`, `!`) only show a "not implemented" message.
|
`submarine [dir]`. `submarine --mock` shows the built-in mock data
|
||||||
|
(`internal/submodule/mock`). Actions (`u`, `b`, `f`, `/`, `!`) only show a
|
||||||
|
"not implemented" message.
|
||||||
|
|
||||||
Next steps, roughly in order:
|
Next steps, roughly in order:
|
||||||
|
|
||||||
1. Git provider implementing `submodule.Provider` (read-only).
|
1. Actions: fetch, checkout pin (`submodule update`), bump pin to tip, init.
|
||||||
2. Actions: fetch, checkout pin (`submodule update`), bump pin to tip, init.
|
2. Filter (`/`) and "problems only" (`!`) views.
|
||||||
3. Filter (`/`) and "problems only" (`!`) views.
|
3. Refresh after actions and on demand.
|
||||||
4. Refresh after actions and on demand.
|
|
||||||
|
|||||||
+27
-2
@@ -8,11 +8,15 @@ Go, [Bubble Tea](https://github.com/charmbracelet/bubbletea) (v1),
|
|||||||
## Package layout
|
## Package layout
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd/submarine/main.go wiring: choose provider (--mock), start the program
|
cmd/submarine/main.go wiring: choose provider (git or --mock), start the program
|
||||||
internal/submodule/ domain model; NO UI imports
|
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
|
||||||
|
exec.go git runner (environment, errors)
|
||||||
|
parse.go pure parsers for git output
|
||||||
|
provider.go Tree, Status, pin → ref resolution
|
||||||
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
|
||||||
@@ -52,6 +56,23 @@ type Provider interface {
|
|||||||
The mock provider (`internal/submodule/mock`) covers every state and sleeps
|
The mock provider (`internal/submodule/mock`) covers every state and sleeps
|
||||||
100–800 ms per `Status` call to exercise the loading path.
|
100–800 ms per `Status` call to exercise the loading path.
|
||||||
|
|
||||||
|
### Git provider
|
||||||
|
|
||||||
|
`internal/submodule/git` shells out to `git -C <dir> …` (see D14).
|
||||||
|
|
||||||
|
- Environment: `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0` (reads never
|
||||||
|
take `index.lock`), `LC_ALL=C`. No command touches the network.
|
||||||
|
- `Tree` reads each repository's `.gitmodules` (`git config -f
|
||||||
|
.gitmodules`) and recurses into initialized submodules. It records, per
|
||||||
|
path, the parent repository and the `.gitmodules` branch for `Status`.
|
||||||
|
`Name` is the last element of `RelPath`.
|
||||||
|
- `Status` runs about ten small git commands per submodule (see
|
||||||
|
[state-model.md](state-model.md#git-commands)). At most `runtime.NumCPU()`
|
||||||
|
`Status` calls run at once; the others wait on a semaphore and still
|
||||||
|
respect `ctx` (D16).
|
||||||
|
- Superproject: branch from `symbolic-ref`, ahead/behind against
|
||||||
|
`@{upstream}`, "fetched" from the mtime of `FETCH_HEAD`.
|
||||||
|
|
||||||
## Loading flow
|
## Loading flow
|
||||||
|
|
||||||
1. `Init` runs `Tree()` → `treeLoadedMsg`.
|
1. `Init` runs `Tree()` → `treeLoadedMsg`.
|
||||||
@@ -91,14 +112,18 @@ 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
|
||||||
|
against a fixture repository built by `scripts/make-fixture.sh` (skipped
|
||||||
|
if `git` is not installed).
|
||||||
- `internal/tui/tree`: navigation rules. Tests need `zone.NewGlobal()` (see
|
- `internal/tui/tree`: navigation rules. Tests need `zone.NewGlobal()` (see
|
||||||
`TestMain`).
|
`TestMain`).
|
||||||
|
|
||||||
Manual check in a headless terminal:
|
Manual check in a headless terminal:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
scripts/make-fixture.sh /tmp/subfix # superproject with every state
|
||||||
go build -o /tmp/submarine ./cmd/submarine
|
go build -o /tmp/submarine ./cmd/submarine
|
||||||
tmux new-session -d -s sub -x 130 -y 24 /tmp/submarine
|
tmux new-session -d -s sub -x 130 -y 24 "/tmp/submarine /tmp/subfix/super"
|
||||||
tmux send-keys -t sub Down Right
|
tmux send-keys -t sub Down Right
|
||||||
tmux capture-pane -pt sub # add -e to see colors
|
tmux capture-pane -pt sub # add -e to see colors
|
||||||
tmux kill-session -t sub
|
tmux kill-session -t sub
|
||||||
|
|||||||
@@ -99,3 +99,32 @@ explicit action (`f`).
|
|||||||
|
|
||||||
**Decision.** Keep mouse support (click to select/toggle, wheel to move)
|
**Decision.** Keep mouse support (click to select/toggle, wheel to move)
|
||||||
using bubblezone markers per row.
|
using bubblezone markers per row.
|
||||||
|
|
||||||
|
## D14 — Git provider shells out to the git CLI
|
||||||
|
|
||||||
|
**Context.** The provider can use a Go library (go-git) or the `git` binary.
|
||||||
|
**Decision.** Run `git -C <dir> …` with a fixed environment
|
||||||
|
(`GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, `LC_ALL=C`) and parse
|
||||||
|
stable output formats (`--porcelain=v2`, custom `--format`).
|
||||||
|
**Consequences.** Behaves exactly like the user's git (config, `gitdir:`
|
||||||
|
files, worktrees, partial clones), no new dependency, fast on large repos.
|
||||||
|
Requires `git` on `PATH`; one process per query.
|
||||||
|
|
||||||
|
## D15 — Git provider semantics
|
||||||
|
|
||||||
|
**Decision.** The pin is the **index** gitlink, because that is what the
|
||||||
|
next commit records and what `submodule update` checks out; a difference to
|
||||||
|
HEAD is `PinStaged`. Worktree state uses `--ignore-submodules=dirty`, so dirt
|
||||||
|
in a nested submodule doesn't mark its parent (consistent with D7). A pin
|
||||||
|
whose commit isn't present locally is a loading `error`, not `unpushed`,
|
||||||
|
because without the commit nothing can be said about it.
|
||||||
|
`--mock` is no longer the default; `submarine [dir]` reads a real repository.
|
||||||
|
|
||||||
|
## D16 — Bounded concurrency inside the provider
|
||||||
|
|
||||||
|
**Context.** The app starts one `Status` command per submodule at once
|
||||||
|
(D11); with hundreds of submodules that would spawn thousands of git
|
||||||
|
processes.
|
||||||
|
**Decision.** The git provider limits concurrent `Status` calls to
|
||||||
|
`runtime.NumCPU()` with a semaphore that respects `ctx`.
|
||||||
|
**Consequences.** The UI stays unchanged; rows fill in steadily.
|
||||||
|
|||||||
+11
-2
@@ -88,6 +88,13 @@ commit hashes. It doesn't answer the questions users actually have:
|
|||||||
| `/` | Filter by name/path |
|
| `/` | Filter by name/path |
|
||||||
| `!` | Show only submodules with issues |
|
| `!` | Show only submodules with issues |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
submarine [dir] # the repository containing dir (default: current directory)
|
||||||
|
submarine --mock # built-in mock data
|
||||||
|
```
|
||||||
|
|
||||||
## 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 works from local
|
||||||
@@ -100,5 +107,7 @@ commit hashes. It doesn't answer the questions users actually have:
|
|||||||
- **N5** Works without a Nerd Font. `SUBMARINE_ASCII=1` replaces the few
|
- **N5** Works without a Nerd Font. `SUBMARINE_ASCII=1` replaces the few
|
||||||
remaining Unicode symbols with ASCII.
|
remaining Unicode symbols with ASCII.
|
||||||
- **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: replacing the mock
|
- **N7** The UI is independent of the data source: the mock and the git
|
||||||
provider with a git provider requires no UI changes.
|
provider are interchangeable without UI changes.
|
||||||
|
- **N8** The git provider is read-only and never uses the network; it uses
|
||||||
|
the `git` command line, so the user's git configuration applies.
|
||||||
|
|||||||
+33
-1
@@ -9,7 +9,7 @@ them.
|
|||||||
|
|
||||||
| Commit | Source |
|
| Commit | Source |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Pinned** | The gitlink recorded in the superproject (HEAD or index) |
|
| **Pinned** | The gitlink recorded in the superproject index (differs from HEAD → `PinStaged`) |
|
||||||
| **Checked out** | The submodule's `HEAD` |
|
| **Checked out** | The submodule's `HEAD` |
|
||||||
| **Tip** | The tip of the ref the pin was resolved to, e.g. `origin/main` |
|
| **Tip** | The tip of the ref the pin was resolved to, e.g. `origin/main` |
|
||||||
|
|
||||||
@@ -97,6 +97,38 @@ Distances: `git rev-list --left-right --count <pin>...<ref>` gives
|
|||||||
ahead of its ref can still be pushed (e.g. on another branch); only
|
ahead of its ref can still be pushed (e.g. on another branch); only
|
||||||
`Pushed == false` is dangerous.
|
`Pushed == false` is dangerous.
|
||||||
|
|
||||||
|
## Git commands
|
||||||
|
|
||||||
|
How the git provider (`internal/submodule/git`) fills `Status`. All commands
|
||||||
|
are local; `<pin>` is the index gitlink.
|
||||||
|
|
||||||
|
| Field | Command (in the parent repo / in the submodule) |
|
||||||
|
|---|---|
|
||||||
|
| `Pinned`, `PinStaged` | parent: `ls-files -s -- <path>` vs `ls-tree HEAD -- <path>` |
|
||||||
|
| `Presence` | directory missing → `DirMissing`; no `.git` in it → `NotInitialized` |
|
||||||
|
| `Checkout` | `rev-parse HEAD`, `symbolic-ref -q HEAD` (no branch → detached) |
|
||||||
|
| `Worktree` | `status --porcelain=v2 --ignore-submodules=dirty` |
|
||||||
|
| `Pushed` | `for-each-ref --contains <pin> refs/remotes` is not empty |
|
||||||
|
| `Pin.Ref` | rules above; the remote is `origin`, else the first remote |
|
||||||
|
| `Ahead`, `Behind` | `rev-list --left-right --count <pin>...<ref>` |
|
||||||
|
| `Between` | `log <pin>...<ref>`, at most 50 commits |
|
||||||
|
|
||||||
|
Details:
|
||||||
|
|
||||||
|
- Rule 1: `branch = .` means the superproject's current branch. The rule
|
||||||
|
applies only if `<remote>/<branch>` exists locally.
|
||||||
|
- Rule 2 uses the checked-out branch's upstream (`<branch>@{upstream}`).
|
||||||
|
- Rule 4 compares at most 16 containing branches (oldest tip first) and
|
||||||
|
picks the one with the fewest commits after the pin.
|
||||||
|
- Rule 6 shows the short SHA; there is no tip and no distance.
|
||||||
|
- `--ignore-submodules=dirty`: changes inside a nested submodule do not make
|
||||||
|
its parent `changed` (D7), but a moved nested pin does.
|
||||||
|
- A missing submodule only gets a `Pinned` SHA; nested submodules of an
|
||||||
|
uninitialized submodule are unknown and not shown.
|
||||||
|
- If the pinned commit doesn't exist in the submodule (not fetched), loading
|
||||||
|
fails with "pinned commit … not fetched" (shown as `error`) instead of
|
||||||
|
guessing `unpushed`.
|
||||||
|
|
||||||
## Distance notation
|
## Distance notation
|
||||||
|
|
||||||
| Display | Meaning |
|
| Display | Meaning |
|
||||||
|
|||||||
+3
-1
@@ -72,7 +72,9 @@ indent ▸ name [n] status ref delta
|
|||||||
`path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · fetched <age>`
|
`path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · fetched <age>`
|
||||||
|
|
||||||
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.
|
changed), each in its level color. Zero counts are omitted. `<age>` is
|
||||||
|
`never` if the superproject has never been fetched; a detached superproject
|
||||||
|
shows `(detached)` as its branch.
|
||||||
|
|
||||||
## Colors
|
## Colors
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gitEnv keeps git non-interactive, lock-free for reads and its output
|
||||||
|
// parseable.
|
||||||
|
var gitEnv = append(os.Environ(),
|
||||||
|
"GIT_TERMINAL_PROMPT=0",
|
||||||
|
"GIT_OPTIONAL_LOCKS=0",
|
||||||
|
"LC_ALL=C",
|
||||||
|
)
|
||||||
|
|
||||||
|
// run executes git in dir and returns stdout with the trailing newline
|
||||||
|
// removed. The error includes git's stderr.
|
||||||
|
func run(ctx context.Context, dir string, args ...string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...)
|
||||||
|
cmd.Env = gitEnv
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return "", ctx.Err()
|
||||||
|
}
|
||||||
|
msg := strings.TrimSpace(stderr.String())
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(stdout.String(), "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// try is run for commands whose failure only means "no answer", e.g.
|
||||||
|
// "no upstream" or "ref does not exist". Context errors still propagate.
|
||||||
|
func try(ctx context.Context, dir string, args ...string) (string, bool, error) {
|
||||||
|
out, err := run(ctx, dir, args...)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return out, true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gitmodule is one entry of a .gitmodules file.
|
||||||
|
type gitmodule struct {
|
||||||
|
name, path, url, branch string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGitmodules parses the output of
|
||||||
|
// `git config -f .gitmodules --get-regexp '^submodule\..*\.(path|url|branch)$'`.
|
||||||
|
// Entries keep the order of first appearance; entries without a path are
|
||||||
|
// dropped.
|
||||||
|
func parseGitmodules(out string) []gitmodule {
|
||||||
|
var order []string
|
||||||
|
byName := map[string]*gitmodule{}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
key, value, _ := strings.Cut(line, " ")
|
||||||
|
key, ok := strings.CutPrefix(key, "submodule.")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dot := strings.LastIndexByte(key, '.')
|
||||||
|
if dot < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, attr := key[:dot], key[dot+1:]
|
||||||
|
g := byName[name]
|
||||||
|
if g == nil {
|
||||||
|
g = &gitmodule{name: name}
|
||||||
|
byName[name] = g
|
||||||
|
order = append(order, name)
|
||||||
|
}
|
||||||
|
switch attr {
|
||||||
|
case "path":
|
||||||
|
g.path = value
|
||||||
|
case "url":
|
||||||
|
g.url = value
|
||||||
|
case "branch":
|
||||||
|
g.branch = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var mods []gitmodule
|
||||||
|
for _, name := range order {
|
||||||
|
if g := byName[name]; g.path != "" {
|
||||||
|
mods = append(mods, *g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mods
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWorktree summarizes `git status --porcelain=v2` output. A conflict
|
||||||
|
// outranks modifications, which outrank untracked files.
|
||||||
|
func parseWorktree(out string) submodule.Worktree {
|
||||||
|
w := submodule.WorktreeClean
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "u "):
|
||||||
|
return submodule.WorktreeConflict
|
||||||
|
case strings.HasPrefix(line, "1 "), strings.HasPrefix(line, "2 "):
|
||||||
|
w = submodule.WorktreeModified
|
||||||
|
case strings.HasPrefix(line, "? ") && w == submodule.WorktreeClean:
|
||||||
|
w = submodule.WorktreeUntracked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLeftRight parses `git rev-list --left-right --count a...b`.
|
||||||
|
func parseLeftRight(out string) (left, right int, err error) {
|
||||||
|
f := strings.Fields(out)
|
||||||
|
if len(f) != 2 {
|
||||||
|
return 0, 0, fmt.Errorf("unexpected rev-list output %q", out)
|
||||||
|
}
|
||||||
|
if left, err = strconv.Atoi(f[0]); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if right, err = strconv.Atoi(f[1]); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return left, right, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitFormat is the `git log --format` matching parseCommits: fields
|
||||||
|
// separated by US, records terminated by RS.
|
||||||
|
const commitFormat = "--format=%h%x1f%s%x1f%an%x1f%ct%x1e"
|
||||||
|
|
||||||
|
// parseCommits parses `git log` output produced with commitFormat.
|
||||||
|
func parseCommits(out string) []submodule.Commit {
|
||||||
|
var cs []submodule.Commit
|
||||||
|
for _, rec := range strings.Split(out, "\x1e") {
|
||||||
|
f := strings.Split(strings.TrimLeft(rec, "\n"), "\x1f")
|
||||||
|
if len(f) != 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := submodule.Commit{SHA: f[0], Subject: f[1], Author: f[2]}
|
||||||
|
if sec, err := strconv.ParseInt(f[3], 10, 64); err == nil {
|
||||||
|
c.When = time.Unix(sec, 0)
|
||||||
|
}
|
||||||
|
cs = append(cs, c)
|
||||||
|
}
|
||||||
|
return cs
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGitlink returns the SHA of a gitlink (mode 160000) entry in
|
||||||
|
// `git ls-files -s` or `git ls-tree` output, or "" if there is none.
|
||||||
|
func parseGitlink(out string) string {
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
f := strings.Fields(line)
|
||||||
|
switch {
|
||||||
|
case len(f) >= 3 && f[0] == "160000" && f[1] == "commit": // ls-tree
|
||||||
|
return f[2]
|
||||||
|
case len(f) >= 2 && f[0] == "160000": // ls-files -s
|
||||||
|
return f[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseGitmodules(t *testing.T) {
|
||||||
|
out := "submodule.src/core.path src/core\n" +
|
||||||
|
"submodule.src/core.url ../core.git\n" +
|
||||||
|
"submodule.lib.v2.path libs/v2\n" +
|
||||||
|
"submodule.lib.v2.branch develop\n" +
|
||||||
|
"submodule.lib.v2.url git@example.com:lib.git\n" +
|
||||||
|
"submodule.nopath.url x\n"
|
||||||
|
want := []gitmodule{
|
||||||
|
{name: "src/core", path: "src/core", url: "../core.git"},
|
||||||
|
{name: "lib.v2", path: "libs/v2", url: "git@example.com:lib.git", branch: "develop"},
|
||||||
|
}
|
||||||
|
if got := parseGitmodules(out); !slices.Equal(got, want) {
|
||||||
|
t.Errorf("parseGitmodules() = %+v, want %+v", got, want)
|
||||||
|
}
|
||||||
|
if got := parseGitmodules(""); got != nil {
|
||||||
|
t.Errorf("parseGitmodules(\"\") = %+v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseWorktree(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
out string
|
||||||
|
want submodule.Worktree
|
||||||
|
}{
|
||||||
|
{"", submodule.WorktreeClean},
|
||||||
|
{"? new.txt", submodule.WorktreeUntracked},
|
||||||
|
{"? new.txt\n1 .M N... 100644 100644 100644 a b README", submodule.WorktreeModified},
|
||||||
|
{"2 R. N... 100644 100644 100644 a b R100 new\told", submodule.WorktreeModified},
|
||||||
|
{"1 .M N... 100644 100644 100644 a b README\nu UU N... 1 2 3 4 a b c README", submodule.WorktreeConflict},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := parseWorktree(tt.out); got != tt.want {
|
||||||
|
t.Errorf("parseWorktree(%q) = %v, want %v", tt.out, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLeftRight(t *testing.T) {
|
||||||
|
if l, r, err := parseLeftRight("3\t1"); err != nil || l != 3 || r != 1 {
|
||||||
|
t.Errorf("parseLeftRight = %d, %d, %v; want 3, 1, nil", l, r, err)
|
||||||
|
}
|
||||||
|
if _, _, err := parseLeftRight("3"); err == nil {
|
||||||
|
t.Error("parseLeftRight(\"3\") should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCommits(t *testing.T) {
|
||||||
|
out := "abc1234\x1fFix it\x1fAda\x1f1700000000\x1e\n" +
|
||||||
|
"def5678\x1fAdd\x1fBob\x1f1700000100\x1e\n" +
|
||||||
|
"malformed\x1e\n"
|
||||||
|
got := parseCommits(out)
|
||||||
|
want := []string{"abc1234", "def5678"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("parseCommits() = %+v, want SHAs %v", got, want)
|
||||||
|
}
|
||||||
|
for i, c := range got {
|
||||||
|
if c.SHA != want[i] {
|
||||||
|
t.Errorf("commit %d: SHA %q, want %q", i, c.SHA, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got[0].Subject != "Fix it" || got[0].Author != "Ada" || got[0].When.Unix() != 1700000000 {
|
||||||
|
t.Errorf("parseCommits()[0] = %+v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGitlink(t *testing.T) {
|
||||||
|
if got := parseGitlink("160000 0123abc 0\tsrc/core"); got != "0123abc" {
|
||||||
|
t.Errorf("ls-files: got %q", got)
|
||||||
|
}
|
||||||
|
if got := parseGitlink("160000 commit 0123abc\tsrc/core"); got != "0123abc" {
|
||||||
|
t.Errorf("ls-tree: got %q", got)
|
||||||
|
}
|
||||||
|
if got := parseGitlink("100644 0123abc 0\tREADME"); got != "" {
|
||||||
|
t.Errorf("blob: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
// Package git provides a read-only submodule.Provider backed by the git
|
||||||
|
// command line. It never touches the network.
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxContainsCandidates limits how many remote branches containing the pin
|
||||||
|
// are compared when resolving the pin by RefFromContains.
|
||||||
|
const maxContainsCandidates = 16
|
||||||
|
|
||||||
|
// maxBetween limits the commits listed between pin and tip.
|
||||||
|
const maxBetween = 50
|
||||||
|
|
||||||
|
// Provider reads a superproject and its submodules with git.
|
||||||
|
type Provider struct {
|
||||||
|
root string // absolute superproject root
|
||||||
|
sem chan struct{} // bounds concurrent Status calls
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
mods map[string]info // by Submodule.Path; filled by Tree
|
||||||
|
}
|
||||||
|
|
||||||
|
// info is what Status needs about a submodule beyond its path.
|
||||||
|
type info struct {
|
||||||
|
superDir string // absolute path of the parent repository
|
||||||
|
dir string // absolute path of the submodule
|
||||||
|
relPath string // relative to superDir, slash-separated
|
||||||
|
branch string // branch from .gitmodules, may be "" or "."
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a provider for the repository containing dir.
|
||||||
|
func New(dir string) (*Provider, error) {
|
||||||
|
root, err := run(context.Background(), dir, "rev-parse", "--show-toplevel")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s is not a git repository", dir)
|
||||||
|
}
|
||||||
|
return &Provider{
|
||||||
|
root: root,
|
||||||
|
sem: make(chan struct{}, runtime.NumCPU()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
|
||||||
|
repo := &submodule.Repo{Path: abbrevHome(p.root)}
|
||||||
|
if b, ok, err := try(ctx, p.root, "symbolic-ref", "--short", "-q", "HEAD"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if ok {
|
||||||
|
repo.Branch = b
|
||||||
|
} else {
|
||||||
|
repo.Branch = "(detached)"
|
||||||
|
}
|
||||||
|
if out, ok, err := try(ctx, p.root, "rev-list", "--left-right", "--count", "HEAD...@{upstream}"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if ok {
|
||||||
|
repo.Ahead, repo.Behind, _ = parseLeftRight(out)
|
||||||
|
}
|
||||||
|
if dir, ok, err := try(ctx, p.root, "rev-parse", "--path-format=absolute", "--git-common-dir"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if ok {
|
||||||
|
if fi, err := os.Stat(filepath.Join(dir, "FETCH_HEAD")); err == nil {
|
||||||
|
repo.FetchedAt = fi.ModTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mods := map[string]info{}
|
||||||
|
subs, err := p.build(ctx, p.root, nil, mods)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
repo.Submodules = subs
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.mods = mods
|
||||||
|
p.mu.Unlock()
|
||||||
|
return repo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// build reads the .gitmodules of the repository at dir and recurses into
|
||||||
|
// initialized submodules.
|
||||||
|
func (p *Provider) build(ctx context.Context, dir string, parent *submodule.Submodule, mods map[string]info) ([]*submodule.Submodule, error) {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, ".gitmodules")); err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
out, _, err := try(ctx, dir, "config", "-f", ".gitmodules", "--get-regexp", `^submodule\..*\.(path|url|branch)$`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var subs []*submodule.Submodule
|
||||||
|
for _, g := range parseGitmodules(out) {
|
||||||
|
rel := path.Clean(g.path)
|
||||||
|
full := rel
|
||||||
|
if parent != nil {
|
||||||
|
full = path.Join(parent.Path, rel)
|
||||||
|
}
|
||||||
|
s := &submodule.Submodule{
|
||||||
|
Name: path.Base(rel),
|
||||||
|
Path: full,
|
||||||
|
RelPath: rel,
|
||||||
|
URL: g.url,
|
||||||
|
Parent: parent,
|
||||||
|
}
|
||||||
|
subDir := filepath.Join(dir, filepath.FromSlash(rel))
|
||||||
|
mods[full] = info{superDir: dir, dir: subDir, relPath: rel, branch: g.branch}
|
||||||
|
if initialized(subDir) {
|
||||||
|
if s.Children, err = p.build(ctx, subDir, s, mods); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subs = append(subs, s)
|
||||||
|
}
|
||||||
|
return subs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Provider) Status(ctx context.Context, path string) (*submodule.Status, error) {
|
||||||
|
select {
|
||||||
|
case p.sem <- struct{}{}:
|
||||||
|
defer func() { <-p.sem }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
p.mu.RLock()
|
||||||
|
in, ok := p.mods[path]
|
||||||
|
p.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown submodule %q", path)
|
||||||
|
}
|
||||||
|
return status(ctx, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func status(ctx context.Context, in info) (*submodule.Status, error) {
|
||||||
|
// pin: index gitlink, compared with HEAD
|
||||||
|
out, err := run(ctx, in.superDir, "ls-files", "-s", "--", in.relPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pin := parseGitlink(out)
|
||||||
|
if pin == "" {
|
||||||
|
return nil, errors.New("no gitlink in the superproject index")
|
||||||
|
}
|
||||||
|
out, _, err = try(ctx, in.superDir, "ls-tree", "HEAD", "--", in.relPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
st := &submodule.Status{PinStaged: parseGitlink(out) != pin}
|
||||||
|
|
||||||
|
// presence
|
||||||
|
if _, err := os.Stat(in.dir); errors.Is(err, fs.ErrNotExist) {
|
||||||
|
st.Presence = submodule.DirMissing
|
||||||
|
} else if !initialized(in.dir) {
|
||||||
|
st.Presence = submodule.NotInitialized
|
||||||
|
}
|
||||||
|
if st.Presence != submodule.PresenceOK {
|
||||||
|
st.Pinned = &submodule.Commit{SHA: pin[:min(7, len(pin))]}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkout and worktree
|
||||||
|
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
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case head != pin:
|
||||||
|
st.Checkout = submodule.CheckoutDiffers
|
||||||
|
case !onBranch:
|
||||||
|
st.Checkout = submodule.CheckoutDetached
|
||||||
|
}
|
||||||
|
out, err = run(ctx, in.dir, "status", "--porcelain=v2", "--ignore-submodules=dirty")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
st.Worktree = parseWorktree(out)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
} else if !ok {
|
||||||
|
return nil, 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")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var containing []string
|
||||||
|
for _, r := range strings.Fields(out) {
|
||||||
|
if !strings.HasSuffix(r, "/HEAD") {
|
||||||
|
containing = append(containing, strings.TrimPrefix(r, "refs/remotes/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.Pin.Pushed = len(containing) > 0
|
||||||
|
|
||||||
|
ref, source, err := resolve(ctx, in, pin, branch, containing)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
st.Pin.Ref, st.Pin.Source = ref, source
|
||||||
|
|
||||||
|
// commits
|
||||||
|
if st.Pinned, err = commit(ctx, in.dir, pin); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
st.CheckedOut = st.Pinned
|
||||||
|
if head != pin {
|
||||||
|
if st.CheckedOut, err = commit(ctx, in.dir, head); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if source == submodule.RefFromSHA {
|
||||||
|
st.Pin.Ref = st.Pinned.SHA
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
if st.Tip, err = commit(ctx, in.dir, ref); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, err = run(ctx, in.dir, "rev-list", "--left-right", "--count", pin+"..."+ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if st.Pin.Ahead, st.Pin.Behind, err = parseLeftRight(out); err != nil {
|
||||||
|
return nil, 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
|
||||||
|
}
|
||||||
|
st.Between = parseCommits(out)
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve finds the ref that describes the pin, following the order in
|
||||||
|
// docs/state-model.md. containing lists the remote branches containing the
|
||||||
|
// pin, oldest tip first.
|
||||||
|
func resolve(ctx context.Context, in info, pin, branch string, containing []string) (string, submodule.RefSource, error) {
|
||||||
|
remote, err := remoteName(ctx, in.dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. branch from .gitmodules
|
||||||
|
if b := in.branch; b != "" && remote != "" {
|
||||||
|
if b == "." {
|
||||||
|
b, _, err = try(ctx, in.superDir, "symbolic-ref", "--short", "-q", "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b != "" {
|
||||||
|
if _, ok, err := try(ctx, in.dir, "rev-parse", "--verify", "-q", "refs/remotes/"+remote+"/"+b); err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
} else if ok {
|
||||||
|
return remote + "/" + b, submodule.RefFromGitmodules, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. upstream of the checked-out branch
|
||||||
|
if branch != "" {
|
||||||
|
if up, ok, err := try(ctx, in.dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", branch+"@{upstream}"); err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
} else if ok && up != "" {
|
||||||
|
return up, submodule.RefFromCurrentBranch, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. remote default branch, if it contains the pin
|
||||||
|
if remote != "" {
|
||||||
|
if def, ok, err := try(ctx, in.dir, "symbolic-ref", "--short", "-q", "refs/remotes/"+remote+"/HEAD"); err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
} else if ok {
|
||||||
|
if _, ok, err := try(ctx, in.dir, "merge-base", "--is-ancestor", pin, def); err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
} else if ok {
|
||||||
|
return def, submodule.RefFromDefaultBranch, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. remote branch containing the pin with the closest tip
|
||||||
|
best, bestN := "", -1
|
||||||
|
for _, r := range containing[:min(len(containing), maxContainsCandidates)] {
|
||||||
|
out, err := run(ctx, in.dir, "rev-list", "--count", pin+".."+r)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if _, err := fmt.Sscan(out, &n); err == nil && (bestN < 0 || n < bestN) {
|
||||||
|
best, bestN = r, n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best != "" {
|
||||||
|
return best, submodule.RefFromContains, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. nearest tag
|
||||||
|
if tag, ok, err := try(ctx, in.dir, "describe", "--tags", "--abbrev=0", pin); err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
} else if ok && tag != "" {
|
||||||
|
return tag, submodule.RefFromTag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. the commit itself
|
||||||
|
return "", submodule.RefFromSHA, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// remoteName returns "origin" if it exists, else the first remote, else "".
|
||||||
|
func remoteName(ctx context.Context, dir string) (string, error) {
|
||||||
|
out, err := run(ctx, dir, "remote")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
remotes := strings.Fields(out)
|
||||||
|
for _, r := range remotes {
|
||||||
|
if r == "origin" {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(remotes) > 0 {
|
||||||
|
return remotes[0], nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func commit(ctx context.Context, dir, rev string) (*submodule.Commit, error) {
|
||||||
|
out, err := run(ctx, dir, "log", "-1", commitFormat, rev, "--")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cs := parseCommits(out)
|
||||||
|
if len(cs) == 0 {
|
||||||
|
return nil, fmt.Errorf("no commit %s", rev)
|
||||||
|
}
|
||||||
|
return &cs[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialized reports whether dir is a checked-out repository (.git file or
|
||||||
|
// directory present).
|
||||||
|
func initialized(dir string) bool {
|
||||||
|
_, err := os.Stat(filepath.Join(dir, ".git"))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func abbrevHome(p string) string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil || home == "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
if p == home {
|
||||||
|
return "~"
|
||||||
|
}
|
||||||
|
if rest, ok := strings.CutPrefix(p, home+string(filepath.Separator)); ok {
|
||||||
|
return "~/" + filepath.ToSlash(rest)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixtureDir is the superproject built by scripts/make-fixture.sh, or ""
|
||||||
|
// if git or bash is unavailable.
|
||||||
|
var fixtureDir string
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
code := func() int {
|
||||||
|
tmp, err := os.MkdirTemp("", "submarine-fixture-")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmp)
|
||||||
|
if _, err := exec.LookPath("git"); err == nil {
|
||||||
|
_, file, _, _ := runtime.Caller(0)
|
||||||
|
script := filepath.Join(filepath.Dir(file), "..", "..", "..", "scripts", "make-fixture.sh")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
return m.Run()
|
||||||
|
}()
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadFixture(t *testing.T) (*Provider, *submodule.Repo) {
|
||||||
|
t.Helper()
|
||||||
|
if fixtureDir == "" {
|
||||||
|
t.Skip("git not available")
|
||||||
|
}
|
||||||
|
p, err := New(fixtureDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repo, err := p.Tree(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p, repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTree(t *testing.T) {
|
||||||
|
_, repo := loadFixture(t)
|
||||||
|
if repo.Branch != "main" {
|
||||||
|
t.Errorf("Branch = %q, want main", repo.Branch)
|
||||||
|
}
|
||||||
|
if !repo.FetchedAt.IsZero() {
|
||||||
|
t.Errorf("FetchedAt = %v, want zero (superproject never fetched)", repo.FetchedAt)
|
||||||
|
}
|
||||||
|
var paths []string
|
||||||
|
submodule.Walk(repo.Submodules, func(s *submodule.Submodule) { paths = append(paths, s.Path) })
|
||||||
|
want := []string{
|
||||||
|
"src/core", "src/ui", "src/net", "libs/store", "libs/log", "libs/log/modules/nested",
|
||||||
|
"libs/feature", "libs/conflict", "libs/staged", "libs/missing", "vendor",
|
||||||
|
}
|
||||||
|
if !slices.Equal(paths, want) {
|
||||||
|
t.Errorf("paths = %v\nwant %v", paths, want)
|
||||||
|
}
|
||||||
|
log := repo.Submodules[4]
|
||||||
|
nested := log.Children[0]
|
||||||
|
if nested.Parent != log || nested.Name != "nested" || nested.RelPath != "modules/nested" {
|
||||||
|
t.Errorf("nested = %+v", nested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatus(t *testing.T) {
|
||||||
|
p, _ := loadFixture(t)
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
issues []submodule.IssueKind
|
||||||
|
ref string
|
||||||
|
source submodule.RefSource
|
||||||
|
ahead int
|
||||||
|
behind int
|
||||||
|
staged bool
|
||||||
|
checkout submodule.Checkout
|
||||||
|
}{
|
||||||
|
{path: "src/core", issues: []submodule.IssueKind{submodule.IssueOffPin}, ref: "origin/main", source: submodule.RefFromDefaultBranch, checkout: submodule.CheckoutDiffers},
|
||||||
|
{path: "src/ui", ref: "origin/main", source: submodule.RefFromCurrentBranch, behind: 2},
|
||||||
|
{path: "src/net", issues: []submodule.IssueKind{submodule.IssueUnpushed}, ref: "origin/main", source: submodule.RefFromCurrentBranch, ahead: 1},
|
||||||
|
{path: "libs/store", ref: "origin/develop", source: submodule.RefFromGitmodules},
|
||||||
|
{path: "libs/log", ref: "origin/main", source: submodule.RefFromCurrentBranch},
|
||||||
|
{path: "libs/log/modules/nested", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromDefaultBranch, checkout: submodule.CheckoutDetached},
|
||||||
|
{path: "libs/feature", ref: "origin/feat/search", source: submodule.RefFromContains, behind: 1, checkout: submodule.CheckoutDetached},
|
||||||
|
{path: "libs/conflict", issues: []submodule.IssueKind{submodule.IssueConflict}, ref: "origin/main", source: submodule.RefFromCurrentBranch, staged: true},
|
||||||
|
{path: "libs/staged", ref: "origin/main", source: submodule.RefFromCurrentBranch, staged: true},
|
||||||
|
{path: "libs/missing", issues: []submodule.IssueKind{submodule.IssueMissing}},
|
||||||
|
{path: "vendor", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromCurrentBranch, behind: 2},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
st, err := p.Status(context.Background(), tt.path)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: %v", tt.path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var kinds []submodule.IssueKind
|
||||||
|
for _, is := range st.Issues() {
|
||||||
|
kinds = append(kinds, is.Kind)
|
||||||
|
}
|
||||||
|
if !slices.Equal(kinds, tt.issues) {
|
||||||
|
t.Errorf("%s: issues %v, want %v", tt.path, kinds, tt.issues)
|
||||||
|
}
|
||||||
|
if st.Presence != submodule.PresenceOK {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if st.Pin.Ref != tt.ref || st.Pin.Source != tt.source {
|
||||||
|
t.Errorf("%s: ref %q (%v), want %q (%v)", tt.path, st.Pin.Ref, st.Pin.Source, tt.ref, tt.source)
|
||||||
|
}
|
||||||
|
if st.Pin.Ahead != tt.ahead || st.Pin.Behind != tt.behind {
|
||||||
|
t.Errorf("%s: ↑%d↓%d, want ↑%d↓%d", tt.path, st.Pin.Ahead, st.Pin.Behind, tt.ahead, tt.behind)
|
||||||
|
}
|
||||||
|
if len(st.Between) != tt.ahead+tt.behind {
|
||||||
|
t.Errorf("%s: %d commits between, want %d", tt.path, len(st.Between), tt.ahead+tt.behind)
|
||||||
|
}
|
||||||
|
if st.PinStaged != tt.staged {
|
||||||
|
t.Errorf("%s: PinStaged %v, want %v", tt.path, st.PinStaged, tt.staged)
|
||||||
|
}
|
||||||
|
if st.Checkout != tt.checkout {
|
||||||
|
t.Errorf("%s: Checkout %v, want %v", tt.path, st.Checkout, tt.checkout)
|
||||||
|
}
|
||||||
|
if st.Pinned == nil || st.CheckedOut == nil || st.Tip == nil || st.Pinned.Author != "Ada Lovelace" {
|
||||||
|
t.Errorf("%s: commits %+v %+v %+v", tt.path, st.Pinned, st.CheckedOut, st.Tip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusMissingPresence(t *testing.T) {
|
||||||
|
p, _ := loadFixture(t)
|
||||||
|
st, err := p.Status(context.Background(), "libs/missing")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Presence != submodule.NotInitialized || st.Pinned == nil || len(st.Pinned.SHA) != 7 {
|
||||||
|
t.Errorf("libs/missing: %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusErrors(t *testing.T) {
|
||||||
|
p, _ := loadFixture(t)
|
||||||
|
if _, err := p.Status(context.Background(), "nope"); err == nil {
|
||||||
|
t.Error("unknown path: want error")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := p.Status(ctx, "src/core"); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("canceled: err = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewNotARepo(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("git"); err != nil {
|
||||||
|
t.Skip("git not available")
|
||||||
|
}
|
||||||
|
if _, err := New(t.TempDir()); err == nil {
|
||||||
|
t.Error("New(non-repo): want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,8 +55,11 @@ func (t Theme) Delta(p submodule.PinInfo, base lipgloss.Style) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ago formats a time relative to now, e.g. "5m ago".
|
// Ago formats a time relative to now, e.g. "5m ago"; the zero time is "never".
|
||||||
func Ago(t time.Time) string {
|
func Ago(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return "never"
|
||||||
|
}
|
||||||
d := time.Since(t)
|
d := time.Since(t)
|
||||||
switch {
|
switch {
|
||||||
case d < time.Minute:
|
case d < time.Minute:
|
||||||
|
|||||||
Executable
+126
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds a superproject with submodules in every state submarine shows.
|
||||||
|
# Usage: scripts/make-fixture.sh <dir> (dir must not exist)
|
||||||
|
# The superproject is <dir>/super; bare "remotes" live in <dir>/remotes.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
[ $# -eq 1 ] || { echo "usage: $0 <dir>" >&2; exit 2; }
|
||||||
|
[ ! -e "$1" ] || { echo "$1 already exists" >&2; exit 2; }
|
||||||
|
mkdir -p "$1"
|
||||||
|
root=$(cd "$1" && pwd)
|
||||||
|
|
||||||
|
# Reproducible and isolated from the user's git config.
|
||||||
|
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
|
||||||
|
export GIT_CONFIG_COUNT=2
|
||||||
|
export GIT_CONFIG_KEY_0=protocol.file.allow GIT_CONFIG_VALUE_0=always
|
||||||
|
export GIT_CONFIG_KEY_1=init.defaultBranch GIT_CONFIG_VALUE_1=main
|
||||||
|
export GIT_AUTHOR_NAME="Ada Lovelace" GIT_AUTHOR_EMAIL=ada@example.com
|
||||||
|
export GIT_COMMITTER_NAME="Ada Lovelace" GIT_COMMITTER_EMAIL=ada@example.com
|
||||||
|
|
||||||
|
g() { git -C "$@" >/dev/null 2>&1; }
|
||||||
|
|
||||||
|
# commit <repo> <file> <message>
|
||||||
|
commit() {
|
||||||
|
echo "$3" >>"$1/$2"
|
||||||
|
g "$1" add "$2"
|
||||||
|
g "$1" commit -m "$3"
|
||||||
|
}
|
||||||
|
|
||||||
|
# remote <name>: bare repo with two commits on main, pushed from a work clone
|
||||||
|
remote() {
|
||||||
|
git init -q --bare "$root/remotes/$1.git"
|
||||||
|
git clone -q "$root/remotes/$1.git" "$root/work/$1" 2>/dev/null
|
||||||
|
commit "$root/work/$1" README "Initial $1"
|
||||||
|
commit "$root/work/$1" README "Add docs to $1"
|
||||||
|
g "$root/work/$1" push origin main
|
||||||
|
}
|
||||||
|
|
||||||
|
# upstream <name> <message>: a new commit on the remote's main
|
||||||
|
upstream() {
|
||||||
|
commit "$root/work/$1" README "$2"
|
||||||
|
g "$root/work/$1" push origin main
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$root/remotes" "$root/work"
|
||||||
|
for r in core ui net store log feature nested; do remote "$r"; done
|
||||||
|
|
||||||
|
# "feature" has a branch that main doesn't contain
|
||||||
|
g "$root/work/feature" switch -c feat/search
|
||||||
|
commit "$root/work/feature" search "Add search"
|
||||||
|
commit "$root/work/feature" search "Tune search"
|
||||||
|
g "$root/work/feature" push origin feat/search
|
||||||
|
g "$root/work/feature" switch main
|
||||||
|
|
||||||
|
# "store" has a develop branch for the .gitmodules branch rule
|
||||||
|
g "$root/work/store" switch -c develop
|
||||||
|
commit "$root/work/store" dev "Start develop"
|
||||||
|
g "$root/work/store" push origin develop
|
||||||
|
g "$root/work/store" switch main
|
||||||
|
|
||||||
|
# "nested" becomes a submodule of "log"
|
||||||
|
g "$root/work/log" submodule add "$root/remotes/nested.git" modules/nested
|
||||||
|
g "$root/work/log" commit -m "Add nested"
|
||||||
|
g "$root/work/log" push origin main
|
||||||
|
|
||||||
|
super="$root/super"
|
||||||
|
git init -q "$super"
|
||||||
|
commit "$super" README "Initial superproject"
|
||||||
|
add() { g "$super" submodule add "${@:3}" "$root/remotes/$1.git" "$2"; }
|
||||||
|
add core src/core
|
||||||
|
add ui src/ui
|
||||||
|
add net src/net
|
||||||
|
add store libs/store -b develop
|
||||||
|
add log libs/log
|
||||||
|
add feature libs/feature
|
||||||
|
add core libs/conflict
|
||||||
|
add ui libs/staged
|
||||||
|
add net libs/missing
|
||||||
|
add ui vendor
|
||||||
|
g "$super" submodule update --init --recursive
|
||||||
|
g "$super" commit -m "Add submodules"
|
||||||
|
|
||||||
|
# src/ui: remote moved on, fetched -> pin behind (↓2)
|
||||||
|
upstream ui "Fix layout"
|
||||||
|
upstream ui "Add theme"
|
||||||
|
g "$super/src/ui" fetch
|
||||||
|
g "$super/vendor" fetch
|
||||||
|
g "$super/libs/staged" fetch
|
||||||
|
|
||||||
|
# src/net: local commit pinned but never pushed -> unpushed
|
||||||
|
commit "$super/src/net" NOTES "Local experiment"
|
||||||
|
g "$super" add src/net
|
||||||
|
g "$super" commit -m "Pin net experiment"
|
||||||
|
|
||||||
|
# libs/feature: pinned inside feat/search only -> ref from contains (↓1)
|
||||||
|
g "$super/libs/feature" fetch
|
||||||
|
g "$super/libs/feature" checkout origin/feat/search~1
|
||||||
|
g "$super" add libs/feature
|
||||||
|
g "$super" commit -m "Pin feature search"
|
||||||
|
|
||||||
|
# src/core: checkout moved away from the pin -> off pin
|
||||||
|
g "$super/src/core" checkout HEAD~1
|
||||||
|
|
||||||
|
# libs/log/modules/nested: uncommitted change, hidden under a collapsed parent
|
||||||
|
echo dirty >>"$super/libs/log/modules/nested/README"
|
||||||
|
|
||||||
|
# vendor: untracked file
|
||||||
|
echo scratch >"$super/vendor/scratch.txt"
|
||||||
|
|
||||||
|
# libs/staged: new pin staged in the superproject, not committed
|
||||||
|
g "$super/libs/staged" merge --ff-only origin/main
|
||||||
|
g "$super" add libs/staged
|
||||||
|
|
||||||
|
# libs/conflict: merge conflict in the worktree
|
||||||
|
g "$super/libs/conflict" switch -c left
|
||||||
|
commit "$super/libs/conflict" README "Left change"
|
||||||
|
g "$super/libs/conflict" switch main
|
||||||
|
commit "$super/libs/conflict" README "Right change"
|
||||||
|
g "$super/libs/conflict" push origin main
|
||||||
|
git -C "$super/libs/conflict" merge left >/dev/null 2>&1 || true
|
||||||
|
# keep the pin on the checkout so only the conflict shows
|
||||||
|
g "$super" add libs/conflict
|
||||||
|
|
||||||
|
# libs/missing: deinitialized -> missing
|
||||||
|
g "$super" submodule deinit -f libs/missing
|
||||||
|
|
||||||
|
echo "$super"
|
||||||
Reference in New Issue
Block a user