diff --git a/AGENTS.md b/AGENTS.md index 920a2d7..dfd457f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md 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 @@ -18,7 +18,9 @@ and decisions. Read the relevant document before changing behavior: ## Commands ```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 test ./... 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 state means (`Status.Issues()`). `internal/tui/theme` decides how it looks. Components never classify states or define colors themselves. -- **Data access** only through `submodule.Provider`. +- **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 `docs/ux-guide.md#colors`). Render segments on a background with a `base` style (`LevelStyle(level, base)`, `Delta(pin, base)`). diff --git a/cmd/submarine/main.go b/cmd/submarine/main.go index 402d6fa..4b4dfee 100644 --- a/cmd/submarine/main.go +++ b/cmd/submarine/main.go @@ -12,20 +12,37 @@ import ( zone "github.com/lrstanley/bubblezone" "submarine/internal/submodule" + "submarine/internal/submodule/git" "submarine/internal/submodule/mock" "submarine/internal/tui" ) 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() + if flag.NArg() > 1 { + flag.Usage() + os.Exit(2) + } var provider submodule.Provider if *useMock { provider = mock.New() } else { - fmt.Fprintln(os.Stderr, "only --mock is supported in the prototype") - os.Exit(2) + dir := "." + 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()) diff --git a/docs/README.md b/docs/README.md index 45cb6de..af1c213 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,13 +17,14 @@ bare commit hash. ## Status -**Prototype.** The UI is complete for browsing and runs on mock data -(`internal/submodule/mock`). There is no git backend yet, and actions -(`u`, `b`, `f`, `/`, `!`) only show a "not implemented" message. +**Prototype.** The UI is complete for browsing. It reads real repositories +through a read-only git provider (`internal/submodule/git`): +`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: -1. Git provider implementing `submodule.Provider` (read-only). -2. Actions: fetch, checkout pin (`submodule update`), bump pin to tip, init. -3. Filter (`/`) and "problems only" (`!`) views. -4. Refresh after actions and on demand. +1. Actions: fetch, checkout pin (`submodule update`), bump pin to tip, init. +2. Filter (`/`) and "problems only" (`!`) views. +3. Refresh after actions and on demand. diff --git a/docs/architecture.md b/docs/architecture.md index f3fea0f..850015b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,11 +8,15 @@ Go, [Bubble Tea](https://github.com/charmbracelet/bubbletea) (v1), ## 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 model.go Repo, Submodule, Status, PinInfo, Commit, enums status.go Issues(), HiddenIssues(), Walk() 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/tui/ Bubble Tea application 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 100–800 ms per `Status` call to exercise the loading path. +### Git provider + +`internal/submodule/git` shells out to `git -C …` (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 1. `Init` runs `Tree()` → `treeLoadedMsg`. @@ -91,14 +112,18 @@ go test ./... ``` - `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 `TestMain`). Manual check in a headless terminal: ```sh +scripts/make-fixture.sh /tmp/subfix # superproject with every state 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 capture-pane -pt sub # add -e to see colors tmux kill-session -t sub diff --git a/docs/decisions.md b/docs/decisions.md index a3c8d8c..d6f9b5e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -99,3 +99,32 @@ explicit action (`f`). **Decision.** Keep mouse support (click to select/toggle, wheel to move) 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 …` 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. diff --git a/docs/requirements.md b/docs/requirements.md index dac0e10..368ee6f 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -88,6 +88,13 @@ commit hashes. It doesn't answer the questions users actually have: | `/` | Filter by name/path | | `!` | 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 - **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 remaining Unicode symbols with ASCII. - **N6** Works in narrow terminals (detail pane hides below 90 columns). -- **N7** The UI is independent of the data source: replacing the mock - provider with a git provider requires no UI changes. +- **N7** The UI is independent of the data source: the mock and the git + 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. diff --git a/docs/state-model.md b/docs/state-model.md index 778f812..d260c07 100644 --- a/docs/state-model.md +++ b/docs/state-model.md @@ -9,7 +9,7 @@ them. | 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` | | **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 ...` gives ahead of its ref can still be pushed (e.g. on another branch); only `Pushed == false` is dangerous. +## Git commands + +How the git provider (`internal/submodule/git`) fills `Status`. All commands +are local; `` is the index gitlink. + +| Field | Command (in the parent repo / in the submodule) | +|---|---| +| `Pinned`, `PinStaged` | parent: `ls-files -s -- ` vs `ls-tree HEAD -- ` | +| `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 refs/remotes` is not empty | +| `Pin.Ref` | rules above; the remote is `origin`, else the first remote | +| `Ahead`, `Behind` | `rev-list --left-right --count ...` | +| `Between` | `log ...`, at most 50 commits | + +Details: + +- Rule 1: `branch = .` means the superproject's current branch. The rule + applies only if `/` exists locally. +- Rule 2 uses the checked-out branch's upstream (`@{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 | Display | Meaning | diff --git a/docs/ux-guide.md b/docs/ux-guide.md index 92de76a..fd9dab3 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -72,7 +72,9 @@ indent ▸ name [n] status ref delta `path branch ↑↓ · n submodules · … · n pin staged · n loading · fetched ` 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. `` is +`never` if the superproject has never been fetched; a detached superproject +shows `(detached)` as its branch. ## Colors diff --git a/internal/submodule/git/exec.go b/internal/submodule/git/exec.go new file mode 100644 index 0000000..ac33a27 --- /dev/null +++ b/internal/submodule/git/exec.go @@ -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 +} diff --git a/internal/submodule/git/parse.go b/internal/submodule/git/parse.go new file mode 100644 index 0000000..96b2418 --- /dev/null +++ b/internal/submodule/git/parse.go @@ -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 "" +} diff --git a/internal/submodule/git/parse_test.go b/internal/submodule/git/parse_test.go new file mode 100644 index 0000000..a3c5a45 --- /dev/null +++ b/internal/submodule/git/parse_test.go @@ -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) + } +} diff --git a/internal/submodule/git/provider.go b/internal/submodule/git/provider.go new file mode 100644 index 0000000..ce73455 --- /dev/null +++ b/internal/submodule/git/provider.go @@ -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 +} diff --git a/internal/submodule/git/provider_test.go b/internal/submodule/git/provider_test.go new file mode 100644 index 0000000..a37a67d --- /dev/null +++ b/internal/submodule/git/provider_test.go @@ -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") + } +} diff --git a/internal/tui/theme/issues.go b/internal/tui/theme/issues.go index a015c61..2773af2 100644 --- a/internal/tui/theme/issues.go +++ b/internal/tui/theme/issues.go @@ -55,8 +55,11 @@ func (t Theme) Delta(p submodule.PinInfo, base lipgloss.Style) string { 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 { + if t.IsZero() { + return "never" + } d := time.Since(t) switch { case d < time.Minute: diff --git a/scripts/make-fixture.sh b/scripts/make-fixture.sh new file mode 100755 index 0000000..b801c6a --- /dev/null +++ b/scripts/make-fixture.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Builds a superproject with submodules in every state submarine shows. +# Usage: scripts/make-fixture.sh (dir must not exist) +# The superproject is /super; bare "remotes" live in /remotes. +set -euo pipefail + +[ $# -eq 1 ] || { echo "usage: $0 " >&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 +commit() { + echo "$3" >>"$1/$2" + g "$1" add "$2" + g "$1" commit -m "$3" +} + +# remote : 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 : 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"