Make the superproject selectable via the header

The header stands for the root repository: it is selected on startup,
shows the root's own state word, and the detail pane shows its state
like a submodule's (HEAD vs. upstream, changes, unpushed, leftover
submodule clones as untracked repositories). Submodules are ignored in
the root's changes; their rows show them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 11:54:40 +02:00
co-authored by Claude Sonnet 5.5
parent f9838c0c99
commit 72c060882b
18 changed files with 688 additions and 92 deletions
+90 -15
View File
@@ -85,6 +85,13 @@ func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
}
}
repo.Root = &submodule.Submodule{Name: filepath.Base(p.root), Path: ""}
if remote, err := remoteName(ctx, p.root); err != nil {
return nil, err
} else if remote != "" {
repo.Root.URL, _, _ = try(ctx, p.root, "config", "--get", "remote."+remote+".url")
}
mods := map[string]info{}
subs, err := p.build(ctx, p.root, nil, mods)
if err != nil {
@@ -141,6 +148,9 @@ func (p *Provider) Status(ctx context.Context, path string) (*submodule.Status,
case <-ctx.Done():
return nil, ctx.Err()
}
if path == "" {
return p.rootStatus(ctx)
}
p.mu.RLock()
in, ok := p.mods[path]
p.mu.RUnlock()
@@ -208,17 +218,79 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
return nil, err
}
if err := pinState(ctx, in, st, pin, head, branch, nil); err != nil {
return nil, err
}
return st, nil
}
// rootStatus describes the superproject. Its "pin" is HEAD, compared with the
// upstream of the current branch; submodules are ignored, their rows show
// them.
func (p *Provider) rootStatus(ctx context.Context) (*submodule.Status, error) {
in := info{superDir: p.root, dir: p.root}
st := &submodule.Status{Root: true}
if gitDir, _, err := gitDirs(in.dir); err == nil {
if fi, err := os.Stat(filepath.Join(gitDir, "FETCH_HEAD")); err == nil {
st.FetchedAt = fi.ModTime()
}
}
head, err := run(ctx, in.dir, "rev-parse", "HEAD")
if err != nil {
return nil, err
}
branch, onBranch, err := try(ctx, in.dir, "symbolic-ref", "--short", "-q", "HEAD")
if err != nil {
return nil, err
}
if !onBranch {
st.Checkout = submodule.CheckoutDetached
}
out, err := run(ctx, in.dir, "status", "--porcelain=v2", "-z", "--untracked-files=normal", "--ignore-submodules=all")
if err != nil {
return nil, err
}
entries := parseStatus(out)
// a leftover of --ignore-submodules=all in older git versions would
// report the submodules the rows already show
entries = slices.DeleteFunc(entries, func(e entry) bool { return e.submodule })
st.Worktree = worktreeOf(entries)
if st.Changes, err = changes(ctx, in.dir, entries); err != nil {
return nil, err
}
// the root is compared with its upstream, so that the header and the
// detail pane agree; only without one the closest branch is looked for
var chosen *candidate
if branch != "" {
if up, ok, err := try(ctx, in.dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", branch+"@{upstream}"); err != nil {
return nil, err
} else if ok {
chosen = &candidate{up, submodule.RefFromCurrentBranch}
}
}
if err := pinState(ctx, in, st, head, head, branch, chosen); err != nil {
return nil, err
}
return st, nil
}
// pinState fills the fields of st that describe pin: whether it is pushed, the
// ref it resolves to (chosen, if not nil), the commits and the distance to the
// ref. head is the checked-out commit.
func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, branch string, chosen *candidate) error {
// the pin must exist locally to say anything about it
if _, ok, err := try(ctx, in.dir, "cat-file", "-e", pin+"^{commit}"); err != nil {
return nil, err
return err
} else if !ok {
return nil, fmt.Errorf("pinned commit %.7s not fetched", pin)
return fmt.Errorf("pinned commit %.7s not fetched", pin)
}
// remote branches containing the pin, oldest tip first
out, err = run(ctx, in.dir, "for-each-ref", "--contains", pin, "--sort=committerdate", "--format=%(refname)", "refs/remotes")
out, err := run(ctx, in.dir, "for-each-ref", "--contains", pin, "--sort=committerdate", "--format=%(refname)", "refs/remotes")
if err != nil {
return nil, err
return err
}
var containing []string
for _, r := range strings.Fields(out) {
@@ -228,44 +300,47 @@ func status(ctx context.Context, in info) (*submodule.Status, error) {
}
st.Pin.Pushed = len(containing) > 0
ref, source, err := resolve(ctx, in, pin, branch, containing)
if err != nil {
return nil, err
var ref string
var source submodule.RefSource
if chosen != nil {
ref, source = chosen.ref, chosen.source
} else if ref, source, err = resolve(ctx, in, pin, branch, containing); err != nil {
return err
}
st.Pin.Ref, st.Pin.Source = ref, source
// commits
if st.Pinned, err = commit(ctx, in.dir, pin); err != nil {
return nil, err
return err
}
st.CheckedOut = st.Pinned
if head != pin {
if st.CheckedOut, err = commit(ctx, in.dir, head); err != nil {
return nil, err
return err
}
}
if source == submodule.RefFromSHA {
st.Pin.Ref = st.Pinned.SHA
return st, nil
return nil
}
if st.Tip, err = commit(ctx, in.dir, ref); err != nil {
return nil, err
return err
}
out, err = run(ctx, in.dir, "rev-list", "--left-right", "--count", pin+"..."+ref)
if err != nil {
return nil, err
return err
}
if st.Pin.Ahead, st.Pin.Behind, err = parseLeftRight(out); err != nil {
return nil, err
return err
}
if st.Pin.Ahead+st.Pin.Behind > 0 {
out, err = run(ctx, in.dir, "log", commitFormat, fmt.Sprintf("--max-count=%d", maxBetween), pin+"..."+ref)
if err != nil {
return nil, err
return err
}
st.Between = parseCommits(out)
}
return st, nil
return nil
}
// resolve finds the ref that describes the pin, following the order in
+172
View File
@@ -0,0 +1,172 @@
package git
import (
"context"
"os"
"path/filepath"
"slices"
"testing"
"submarine/internal/submodule"
)
// rootFixture is ownFixture with a bare origin for the superproject, main
// pushed and tracking it. It returns the provider, the fixture root and the
// path of the bare repository.
func rootFixture(t *testing.T) (*Provider, string, string) {
t.Helper()
p, root := ownFixture(t)
bare := filepath.Join(root, "remotes", "super.git")
git(t, root, "init", "-q", "--bare", bare)
git(t, p.root, "remote", "add", "origin", bare)
git(t, p.root, "push", "-q", "-u", "origin", "main")
return p, root, bare
}
func rootStatus(t *testing.T, p *Provider) *submodule.Status {
t.Helper()
st, err := p.Status(context.Background(), "")
if err != nil {
t.Fatal(err)
}
if !st.Root {
t.Error("Status.Root = false for the superproject")
}
return st
}
func issueKinds(st *submodule.Status) []submodule.IssueKind {
var kinds []submodule.IssueKind
for _, is := range st.Issues() {
kinds = append(kinds, is.Kind)
}
return kinds
}
func TestTreeRoot(t *testing.T) {
p, root, bare := rootFixture(t)
repo, err := p.Tree(context.Background())
if err != nil {
t.Fatal(err)
}
if r := repo.Root; r == nil || !r.IsRoot() || r.Name != "super" || r.URL != bare || len(r.Children) != 0 {
t.Errorf("Root = %+v (fixture %s)", r, root)
}
}
// The fixture has dirty, conflicting and staged submodules; none of them
// makes the superproject itself changed, their rows show them.
func TestRootStatusIgnoresSubmodules(t *testing.T) {
p, _, _ := rootFixture(t)
st := rootStatus(t, p)
if kinds := issueKinds(st); len(kinds) != 0 {
t.Errorf("issues = %v, want none", kinds)
}
if st.Changes != nil || st.PinStaged || st.Presence != submodule.PresenceOK {
t.Errorf("Changes = %+v, PinStaged = %v", st.Changes, st.PinStaged)
}
if st.Pin.Ref != "origin/main" || st.Pin.Source != submodule.RefFromCurrentBranch || !st.Pin.Pushed {
t.Errorf("Pin = %+v", st.Pin)
}
if st.Pinned == nil || st.Pinned != st.CheckedOut && *st.Pinned != *st.CheckedOut {
t.Errorf("Pinned = %+v, CheckedOut = %+v, want HEAD for both", st.Pinned, st.CheckedOut)
}
}
func TestRootStatusUnpushed(t *testing.T) {
p, _, _ := rootFixture(t)
git(t, p.root, "commit", "--allow-empty", "-m", "Local work")
st := rootStatus(t, p)
if kinds := issueKinds(st); !slices.Equal(kinds, []submodule.IssueKind{submodule.IssueUnpushed}) {
t.Errorf("issues = %v, want unpushed", kinds)
}
if st.Pin.Ahead != 1 || st.Pin.Behind != 0 {
t.Errorf("ahead/behind = %d/%d, want 1/0", st.Pin.Ahead, st.Pin.Behind)
}
if d := st.Issues()[0].Detail; d != "HEAD not pushed to any remote" {
t.Errorf("detail = %q", d)
}
}
// A submodule the current branch no longer has stays on disk after a branch
// switch (its .git is a file into .git/modules). It is an untracked
// repository of the superproject.
func TestRootStatusLeftoverSubmodule(t *testing.T) {
p, _, _ := rootFixture(t)
git(t, p.root, "switch", "-q", "-c", "nosub")
git(t, p.root, "rm", "-q", "--cached", "src/ui")
git(t, p.root, "commit", "-q", "-m", "Drop src/ui")
git(t, p.root, "switch", "-q", "main")
git(t, p.root, "switch", "-q", "nosub")
if _, err := os.Stat(filepath.Join(p.root, "src", "ui", ".git")); err != nil {
t.Fatalf("src/ui should be left behind: %v", err)
}
st := rootStatus(t, p)
if kinds := issueKinds(st); !slices.Equal(kinds, []submodule.IssueKind{submodule.IssueUnpushed, submodule.IssueChanged}) {
t.Fatalf("issues = %v, want unpushed (new branch) and changed", kinds)
}
if d := st.Issues()[1].Detail; d != "untracked repository" {
t.Errorf("detail = %q, want untracked repository", d)
}
if st.Changes == nil || len(st.Changes.Files) != 1 {
t.Fatalf("Changes = %+v", st.Changes)
}
if f := st.Changes.Files[0]; f.Path != "src/ui/" || f.Kind != submodule.ChangeUntracked || !f.Repo {
t.Errorf("file = %+v", f)
}
}
func TestRootStatusModified(t *testing.T) {
p, _, _ := rootFixture(t)
if err := os.WriteFile(filepath.Join(p.root, "README"), []byte("edited\n"), 0o644); err != nil {
t.Fatal(err)
}
st := rootStatus(t, p)
if kinds := issueKinds(st); !slices.Equal(kinds, []submodule.IssueKind{submodule.IssueChanged}) {
t.Errorf("issues = %v, want changed", kinds)
}
if st.Worktree != submodule.WorktreeModified {
t.Errorf("Worktree = %v", st.Worktree)
}
}
// The ref of the superproject is the upstream of its branch, even if another
// remote branch is closer, so that it agrees with the header's ahead/behind.
func TestRootStatusRefIsUpstream(t *testing.T) {
p, root, bare := rootFixture(t)
other := filepath.Join(root, "other")
git(t, root, "clone", "-q", bare, other)
git(t, other, "switch", "-q", "-c", "feat")
git(t, other, "commit", "--allow-empty", "-m", "Feature")
git(t, other, "push", "-q", "origin", "feat")
git(t, other, "switch", "-q", "main")
for range 3 {
git(t, other, "commit", "--allow-empty", "-m", "Upstream")
}
git(t, other, "push", "-q", "origin", "main")
git(t, p.root, "fetch", "-q")
st := rootStatus(t, p)
if st.Pin.Ref != "origin/main" || st.Pin.Source != submodule.RefFromCurrentBranch {
t.Errorf("ref = %q (%v), want origin/main via the current branch", st.Pin.Ref, st.Pin.Source)
}
if st.Pin.Ahead != 0 || st.Pin.Behind != 3 {
t.Errorf("ahead/behind = %d/%d, want 0/3", st.Pin.Ahead, st.Pin.Behind)
}
if len(st.Between) != 3 {
t.Errorf("Between = %d commits, want 3", len(st.Between))
}
}
func TestRootStatusDetached(t *testing.T) {
p, _, _ := rootFixture(t)
git(t, p.root, "switch", "-q", "--detach")
st := rootStatus(t, p)
if st.Checkout != submodule.CheckoutDetached {
t.Errorf("Checkout = %v, want detached", st.Checkout)
}
if !st.Pin.Pushed {
t.Error("detached HEAD on a pushed commit should be pushed")
}
}
+28
View File
@@ -42,8 +42,11 @@ func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
repo := &submodule.Repo{
Path: "~/coding/my_project",
Branch: "main",
Behind: 2,
FetchedAt: time.Now().Add(-2 * time.Minute),
}
repo.Root = &submodule.Submodule{Name: "my_project", URL: "git@github.com:acme/my_project.git"}
p.statuses[""] = rootStatus()
repo.Submodules = p.build(fixture(), nil)
return repo, nil
}
@@ -154,6 +157,31 @@ func changes(age time.Duration, files []submodule.FileChange) *submodule.Changes
return c
}
// rootStatus is a superproject that is behind its upstream and still has a
// clone of a submodule the current branch no longer has.
func rootStatus() *submodule.Status {
tip := commit("e4f5a6b", "Move docs into a submodule", "anna", 3*time.Hour)
head := commit("0a1b2c3", "Update README", "ben", 2*24*time.Hour)
st := &submodule.Status{
Root: true,
Worktree: submodule.WorktreeModified,
Pin: submodule.PinInfo{Ref: "origin/main", Behind: 2, Pushed: true, Source: submodule.RefFromCurrentBranch},
Pinned: head,
CheckedOut: head,
Tip: tip,
Between: []submodule.Commit{
*tip,
*commit("9d8c7b6", "Drop the legacy module", "chris", 20*time.Hour),
},
FetchedAt: now.Add(-2 * time.Minute),
}
st.Changes = changes(15*time.Minute, []submodule.FileChange{
{Path: "README.md", Kind: submodule.ChangeModified, Added: 4, Removed: 1},
{Path: "old_module/", Kind: submodule.ChangeUntracked, Repo: true},
})
return st
}
func fixture() []node {
c1 := commit("3f9a1c2", "Bump golangci-lint to v2", "anna", 26*time.Hour)
c2 := commit("a1b2c3d", "Initial import", "ben", 40*24*time.Hour)
+5
View File
@@ -16,6 +16,7 @@ type Repo struct {
Ahead int
Behind int
FetchedAt time.Time
Root *Submodule // the superproject as a selectable node; Path is "", no Children
Submodules []*Submodule
}
@@ -32,6 +33,9 @@ type Submodule struct {
FetchErr error // set if the last fetch failed; not an issue
}
// IsRoot reports whether s is the superproject itself.
func (s *Submodule) IsRoot() bool { return s != nil && s.Path == "" }
// Presence describes whether the submodule exists on disk.
type Presence int
@@ -171,6 +175,7 @@ func (c *Changes) Sort() {
// Status is the full state of one submodule.
type Status struct {
Root bool // the superproject: the "pin" is its HEAD
Presence Presence
Checkout Checkout
Worktree Worktree
+11 -3
View File
@@ -43,7 +43,11 @@ func (s *Status) Issues() []Issue {
var out []Issue
if !s.Pin.Pushed {
out = append(out, Issue{IssueUnpushed, LevelDanger, "pin not pushed to any remote"})
detail := "pin not pushed to any remote"
if s.Root {
detail = "HEAD not pushed to any remote"
}
out = append(out, Issue{IssueUnpushed, LevelDanger, detail})
}
if s.Worktree == WorktreeConflict {
out = append(out, Issue{IssueConflict, LevelDanger, "merge conflict"})
@@ -70,9 +74,13 @@ func (h Hidden) Total() int { return h.Warn + h.Danger }
// HiddenIssues counts the issues and staged pins of all loaded descendants. A
// descendant whose load failed counts as one danger, since its state is
// unknown.
func (m *Submodule) HiddenIssues() Hidden {
func (m *Submodule) HiddenIssues() Hidden { return HiddenIn(m.Children) }
// HiddenIn counts the issues and staged pins of mods and all their loaded
// descendants, like HiddenIssues.
func HiddenIn(mods []*Submodule) Hidden {
var h Hidden
Walk(m.Children, func(c *Submodule) {
Walk(mods, func(c *Submodule) {
if c.Status == nil && c.Err != nil {
h.Danger++
}
+11
View File
@@ -78,3 +78,14 @@ func TestHiddenIssuesCountLoadErrors(t *testing.T) {
t.Errorf("got %+v; want 1 danger", got)
}
}
func TestRootIssueWording(t *testing.T) {
st := &Status{Root: true}
got := st.Issues()
if len(got) != 1 || got[0].Kind != IssueUnpushed || got[0].Level != LevelDanger || got[0].Detail != "HEAD not pushed to any remote" {
t.Errorf("Issues() = %+v", got)
}
if !(&Submodule{}).IsRoot() || (&Submodule{Path: "a"}).IsRoot() {
t.Error("IsRoot should be true exactly for the empty path")
}
}