Archived
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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,11 @@ func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) {
|
||||
repo := &submodule.Repo{
|
||||
Path: "~/coding/my_project",
|
||||
Branch: "main",
|
||||
Behind: 2,
|
||||
FetchedAt: time.Now().Add(-2 * time.Minute),
|
||||
}
|
||||
repo.Root = &submodule.Submodule{Name: "my_project", URL: "git@github.com:acme/my_project.git"}
|
||||
p.statuses[""] = rootStatus()
|
||||
repo.Submodules = p.build(fixture(), nil)
|
||||
return repo, nil
|
||||
}
|
||||
@@ -154,6 +157,31 @@ func changes(age time.Duration, files []submodule.FileChange) *submodule.Changes
|
||||
return c
|
||||
}
|
||||
|
||||
// rootStatus is a superproject that is behind its upstream and still has a
|
||||
// clone of a submodule the current branch no longer has.
|
||||
func rootStatus() *submodule.Status {
|
||||
tip := commit("e4f5a6b", "Move docs into a submodule", "anna", 3*time.Hour)
|
||||
head := commit("0a1b2c3", "Update README", "ben", 2*24*time.Hour)
|
||||
st := &submodule.Status{
|
||||
Root: true,
|
||||
Worktree: submodule.WorktreeModified,
|
||||
Pin: submodule.PinInfo{Ref: "origin/main", Behind: 2, Pushed: true, Source: submodule.RefFromCurrentBranch},
|
||||
Pinned: head,
|
||||
CheckedOut: head,
|
||||
Tip: tip,
|
||||
Between: []submodule.Commit{
|
||||
*tip,
|
||||
*commit("9d8c7b6", "Drop the legacy module", "chris", 20*time.Hour),
|
||||
},
|
||||
FetchedAt: now.Add(-2 * time.Minute),
|
||||
}
|
||||
st.Changes = changes(15*time.Minute, []submodule.FileChange{
|
||||
{Path: "README.md", Kind: submodule.ChangeModified, Added: 4, Removed: 1},
|
||||
{Path: "old_module/", Kind: submodule.ChangeUntracked, Repo: true},
|
||||
})
|
||||
return st
|
||||
}
|
||||
|
||||
func fixture() []node {
|
||||
c1 := commit("3f9a1c2", "Bump golangci-lint to v2", "anna", 26*time.Hour)
|
||||
c2 := commit("a1b2c3d", "Initial import", "ben", 40*24*time.Hour)
|
||||
|
||||
@@ -16,6 +16,7 @@ type Repo struct {
|
||||
Ahead int
|
||||
Behind int
|
||||
FetchedAt time.Time
|
||||
Root *Submodule // the superproject as a selectable node; Path is "", no Children
|
||||
Submodules []*Submodule
|
||||
}
|
||||
|
||||
@@ -32,6 +33,9 @@ type Submodule struct {
|
||||
FetchErr error // set if the last fetch failed; not an issue
|
||||
}
|
||||
|
||||
// IsRoot reports whether s is the superproject itself.
|
||||
func (s *Submodule) IsRoot() bool { return s != nil && s.Path == "" }
|
||||
|
||||
// Presence describes whether the submodule exists on disk.
|
||||
type Presence int
|
||||
|
||||
@@ -171,6 +175,7 @@ func (c *Changes) Sort() {
|
||||
|
||||
// Status is the full state of one submodule.
|
||||
type Status struct {
|
||||
Root bool // the superproject: the "pin" is its HEAD
|
||||
Presence Presence
|
||||
Checkout Checkout
|
||||
Worktree Worktree
|
||||
|
||||
@@ -43,7 +43,11 @@ func (s *Status) Issues() []Issue {
|
||||
|
||||
var out []Issue
|
||||
if !s.Pin.Pushed {
|
||||
out = append(out, Issue{IssueUnpushed, LevelDanger, "pin not pushed to any remote"})
|
||||
detail := "pin not pushed to any remote"
|
||||
if s.Root {
|
||||
detail = "HEAD not pushed to any remote"
|
||||
}
|
||||
out = append(out, Issue{IssueUnpushed, LevelDanger, detail})
|
||||
}
|
||||
if s.Worktree == WorktreeConflict {
|
||||
out = append(out, Issue{IssueConflict, LevelDanger, "merge conflict"})
|
||||
@@ -70,9 +74,13 @@ func (h Hidden) Total() int { return h.Warn + h.Danger }
|
||||
// HiddenIssues counts the issues and staged pins of all loaded descendants. A
|
||||
// descendant whose load failed counts as one danger, since its state is
|
||||
// unknown.
|
||||
func (m *Submodule) HiddenIssues() Hidden {
|
||||
func (m *Submodule) HiddenIssues() Hidden { return HiddenIn(m.Children) }
|
||||
|
||||
// HiddenIn counts the issues and staged pins of mods and all their loaded
|
||||
// descendants, like HiddenIssues.
|
||||
func HiddenIn(mods []*Submodule) Hidden {
|
||||
var h Hidden
|
||||
Walk(m.Children, func(c *Submodule) {
|
||||
Walk(mods, func(c *Submodule) {
|
||||
if c.Status == nil && c.Err != nil {
|
||||
h.Danger++
|
||||
}
|
||||
|
||||
@@ -78,3 +78,14 @@ func TestHiddenIssuesCountLoadErrors(t *testing.T) {
|
||||
t.Errorf("got %+v; want 1 danger", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootIssueWording(t *testing.T) {
|
||||
st := &Status{Root: true}
|
||||
got := st.Issues()
|
||||
if len(got) != 1 || got[0].Kind != IssueUnpushed || got[0].Level != LevelDanger || got[0].Detail != "HEAD not pushed to any remote" {
|
||||
t.Errorf("Issues() = %+v", got)
|
||||
}
|
||||
if !(&Submodule{}).IsRoot() || (&Submodule{Path: "a"}).IsRoot() {
|
||||
t.Error("IsRoot should be true exactly for the empty path")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user