Compare pins with a target branch and flag pins off it

The ref of a pin was the closest branch containing it, so a pin on a
feature branch looked like a perfect one, and the ref changed with the
branch checked out in the submodule. Split it in two:

- Pin.Target is the branch the pin should be on: `branch =` of
  .gitmodules (none if it does not exist locally), else the remote's
  default branch.
- Pin.Ref is shown: the target if the pin is on it, else the branch
  containing the pin with the fewest commits after it. A tie goes to the
  upstream of the checked-out branch, then to the first name; no branch is
  a favorite.

A pushed pin that is not on its target is `off target`, a new issue at the
new level Info (blue), counted in the header and below collapsed rows. The
spine shows the target's tip, labelled with its branch name, so the
distance from it is visible; a diverged one is the fork. The role words in
a spine label are styled separately and `you` is bold.

Add Status.TargetTip, TargetDrift and Bases.Target, Pin.Target and
Pin.Branches; drop RefSource.IsTarget. The detail pane's label column is 11
wide for the word `off target`.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 17:35:16 +02:00
co-authored by Claude Sonnet 5.5
parent f287d6009f
commit e8d366d88f
19 changed files with 606 additions and 238 deletions
+149 -112
View File
@@ -20,9 +20,13 @@ import (
"submarine/internal/submodule"
)
// maxPinBranches limits Pin.Branches, the other remote branches containing the
// pin.
const maxPinBranches = 8
// maxContainsCandidates limits how many remote branches containing the pin
// are compared besides the preferred refs when resolving a pushed pin.
const maxContainsCandidates = 16
// are compared when the pin is not on its target.
const maxContainsCandidates = 32
// Provider reads a superproject and its submodules with git.
type Provider struct {
@@ -296,7 +300,7 @@ func (p *Provider) rootStatus(ctx context.Context) (*submodule.Status, error) {
}
// 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
// detail pane agree; only without one the target 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 {
@@ -340,10 +344,15 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
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 {
} else if ref, source, st.Pin.Target, err = resolve(ctx, in, pin, branch, containing); err != nil {
return err
}
st.Pin.Ref, st.Pin.Source = ref, source
for _, b := range containing {
if b != ref && len(st.Pin.Branches) < maxPinBranches {
st.Pin.Branches = append(st.Pin.Branches, b)
}
}
// commits
if st.Pinned, err = commit(ctx, in.dir, pin); err != nil {
@@ -386,134 +395,162 @@ func pinState(ctx context.Context, in info, st *submodule.Status, pin, head, bra
return err
}
}
// the pin is on another branch than its target: where the target is
if target := st.Pin.Target; target != "" && target != ref {
if st.TargetTip, err = commit(ctx, in.dir, target); err != nil {
return err
}
out, err = run(ctx, in.dir, "rev-list", "--left-right", "--count", target+"..."+pin)
if err != nil {
return err
}
if st.TargetDrift.Ahead, st.TargetDrift.Behind, err = parseLeftRight(out); err != nil {
return err
}
if st.TargetDrift.Ahead > 0 && st.TargetDrift.Behind > 0 {
if st.Bases.Target, err = mergeBase(ctx, in.dir, pin, target); err != nil {
return err
}
}
}
return nil
}
// resolve finds the ref that describes the pin, following the order in
// docs/state-model.md: the closest branch wins, the preferred refs (see
// preferredRefs) break ties. 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) {
preferred, err := preferredRefs(ctx, in, branch)
// resolve finds the ref the pin is compared with, following the order in
// docs/state-model.md: the target (see targetRef) if the pin is on it, else the
// branch containing the pin that is closest to it, else the target anyway,
// the upstream of the checked-out branch, a tag, the commit itself. It also
// returns the target ("" if there is none). 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, string, error) {
target, source, hasTarget, err := targetRef(ctx, in)
if err != nil {
return "", 0, err
return "", 0, "", err
}
if !hasTarget {
target = ""
}
if hasTarget && slices.Contains(containing, target) {
return target, source, target, nil
}
// 1. pushed: the branch containing the pin with the fewest commits after it
if len(containing) > 0 {
var cands []candidate
for _, c := range preferred {
if _, ok, err := try(ctx, in.dir, "merge-base", "--is-ancestor", pin, c.ref); err != nil {
return "", 0, err
} else if ok {
cands = append(cands, c)
}
}
for _, r := range containing[:min(len(containing), maxContainsCandidates)] {
if !slices.ContainsFunc(cands, func(c candidate) bool { return c.ref == r }) {
cands = append(cands, candidate{r, submodule.RefFromContains})
}
}
if c, ok, err := closest(ctx, in.dir, pin+"..", cands); err != nil {
return "", 0, err
} else if ok {
return c.ref, c.source, nil
}
ref, err := closestContaining(ctx, in, pin, branch, containing)
return ref, submodule.RefFromContains, target, err
}
if hasTarget {
return target, source, target, nil
}
// 2. unpushed: the preferred ref with the fewest commits on either side
if c, ok, err := closest(ctx, in.dir, pin+"...", preferred); err != nil {
return "", 0, err
// the checked-out branch depends on the machine, so it is no target
if up, ok, err := upstream(ctx, in.dir, branch); err != nil {
return "", 0, "", err
} else if ok {
return c.ref, c.source, nil
return up, submodule.RefFromCurrentBranch, "", nil
}
// 3. nearest tag
if tag, ok, err := try(ctx, in.dir, "describe", "--tags", "--abbrev=0", pin); err != nil {
return "", 0, err
return "", 0, "", err
} else if ok && tag != "" {
return tag, submodule.RefFromTag, nil
return tag, submodule.RefFromTag, "", nil
}
// 4. the commit itself
return "", submodule.RefFromSHA, nil
return "", submodule.RefFromSHA, "", nil
}
// candidate is a ref the pin may resolve to and the rule that proposed it.
// upstream returns the upstream of the local branch, if there is one.
func upstream(ctx context.Context, dir, branch string) (string, bool, error) {
if branch == "" {
return "", false, nil
}
return try(ctx, dir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", branch+"@{upstream}")
}
// closestContaining picks the branch the pin is closest to among the remote
// branches containing it: the fewest commits after the pin. A tie goes to the
// upstream of the checked-out branch, then to the first name in alphabetical
// order, so that the answer is the same on every machine. No branch,
// including the default branch, is preferred otherwise.
func closestContaining(ctx context.Context, in info, pin, branch string, containing []string) (string, error) {
var best []string
bestN := -1
for _, c := range containing[:min(len(containing), maxContainsCandidates)] {
out, err := run(ctx, in.dir, "rev-list", "--count", pin+".."+c)
if err != nil {
return "", err
}
var n int
if _, err := fmt.Sscan(out, &n); err != nil {
continue
}
switch {
case bestN < 0 || n < bestN:
best, bestN = []string{c}, n
case n == bestN:
best = append(best, c)
}
}
if len(best) == 0 {
return containing[0], nil
}
if up, ok, err := upstream(ctx, in.dir, branch); err != nil {
return "", err
} else if ok && slices.Contains(best, up) {
return up, nil
}
slices.Sort(best)
return best[0], nil
}
// targetRef returns the branch the pin should be on: the `branch =` of
// .gitmodules (`.` is the superproject's current branch), else the remote's
// default branch, else main or master. A declared branch that does not exist
// locally is not replaced by the default: the answer would be wrong.
func targetRef(ctx context.Context, in info) (string, submodule.RefSource, bool, error) {
remote, err := remoteName(ctx, in.dir)
if err != nil || remote == "" {
return "", 0, false, err
}
exists := func(ref string) (bool, error) {
_, ok, err := try(ctx, in.dir, "rev-parse", "--verify", "-q", "refs/remotes/"+ref)
return ok, err
}
if b := in.branch; b != "" {
if b == "." {
if b, _, err = try(ctx, in.superDir, "symbolic-ref", "--short", "-q", "HEAD"); err != nil {
return "", 0, false, err
}
}
if b == "" {
return "", 0, false, nil
}
ok, err := exists(remote + "/" + b)
if err != nil || !ok {
return "", 0, false, err
}
return remote + "/" + b, submodule.RefFromGitmodules, true, nil
}
if def, ok, err := try(ctx, in.dir, "symbolic-ref", "--short", "-q", "refs/remotes/"+remote+"/HEAD"); err != nil {
return "", 0, false, err
} else if ok {
return def, submodule.RefFromDefaultBranch, true, nil
}
for _, name := range []string{"main", "master"} {
if ok, err := exists(remote + "/" + name); err != nil {
return "", 0, false, err
} else if ok {
return remote + "/" + name, submodule.RefFromDefaultBranch, true, nil
}
}
return "", 0, false, nil
}
// candidate is a ref and the rule that proposed it.
type candidate struct {
ref string
source submodule.RefSource
}
// preferredRefs returns the refs the user most likely means, in order of
// preference and without duplicates: the .gitmodules branch, the upstream of
// the checked-out branch, the remote default branch. Each must exist.
func preferredRefs(ctx context.Context, in info, branch string) ([]candidate, error) {
remote, err := remoteName(ctx, in.dir)
if err != nil {
return nil, err
}
var refs []candidate
add := func(ref string, source submodule.RefSource) {
if ref != "" && !slices.ContainsFunc(refs, func(c candidate) bool { return c.ref == ref }) {
refs = append(refs, candidate{ref, source})
}
}
// 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 nil, err
}
}
if b != "" {
if _, ok, err := try(ctx, in.dir, "rev-parse", "--verify", "-q", "refs/remotes/"+remote+"/"+b); err != nil {
return nil, err
} else if ok {
add(remote+"/"+b, submodule.RefFromGitmodules)
}
}
}
// 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 nil, err
} else if ok {
add(up, submodule.RefFromCurrentBranch)
}
}
// remote default branch
if remote != "" {
if def, ok, err := try(ctx, in.dir, "symbolic-ref", "--short", "-q", "refs/remotes/"+remote+"/HEAD"); err != nil {
return nil, err
} else if ok {
add(def, submodule.RefFromDefaultBranch)
}
}
return refs, nil
}
// closest returns the candidate with the fewest commits in the range
// `<from><ref>` (from is e.g. "<pin>.."); the first one wins a tie.
func closest(ctx context.Context, dir, from string, cands []candidate) (candidate, bool, error) {
best, bestN := candidate{}, -1
for _, c := range cands {
out, err := run(ctx, dir, "rev-list", "--count", from+c.ref)
if err != nil {
return candidate{}, false, err
}
var n int
if _, err := fmt.Sscan(out, &n); err == nil && (bestN < 0 || n < bestN) {
best, bestN = c, n
}
}
return best, bestN >= 0, 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")
+97 -9
View File
@@ -90,26 +90,29 @@ func TestStatus(t *testing.T) {
path string
issues []submodule.IssueKind
ref string
target string // the target if the pin is not on it; else the ref is the target
source submodule.RefSource
ahead int
behind int
staged bool
checkout submodule.Checkout
drift submodule.Distance // checkout compared with the pin
branches []string // other remote branches containing the pin
toTarget submodule.Distance // target tip compared with the pin; zero if the pin is on it
}{
{path: "src/core", issues: []submodule.IssueKind{submodule.IssueOffPin}, ref: "origin/main", source: submodule.RefFromDefaultBranch, checkout: submodule.CheckoutDiffers, drift: submodule.Distance{Behind: 1}},
{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: "src/ui", ref: "origin/main", source: submodule.RefFromDefaultBranch, behind: 2},
{path: "src/net", issues: []submodule.IssueKind{submodule.IssueUnpushed}, ref: "origin/main", source: submodule.RefFromDefaultBranch, ahead: 1},
{path: "libs/store", ref: "origin/develop", source: submodule.RefFromGitmodules},
{path: "libs/log", ref: "origin/main", source: submodule.RefFromCurrentBranch},
{path: "libs/log", ref: "origin/main", source: submodule.RefFromDefaultBranch, branches: []string{"origin/archive"}},
{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/topic", ref: "origin/feat/search", source: submodule.RefFromContains, 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/feature", issues: []submodule.IssueKind{submodule.IssueOffTarget}, ref: "origin/feat/search", source: submodule.RefFromContains, target: "origin/main", behind: 1, checkout: submodule.CheckoutDetached, toTarget: submodule.Distance{Behind: 1}},
{path: "libs/topic", issues: []submodule.IssueKind{submodule.IssueOffTarget}, ref: "origin/feat/search", source: submodule.RefFromContains, target: "origin/main", checkout: submodule.CheckoutDetached, toTarget: submodule.Distance{Behind: 2}},
{path: "libs/conflict", issues: []submodule.IssueKind{submodule.IssueConflict}, ref: "origin/main", source: submodule.RefFromDefaultBranch, staged: true},
{path: "libs/staged", ref: "origin/main", source: submodule.RefFromDefaultBranch, 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},
{path: "libs/work", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromCurrentBranch},
{path: "vendor", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromDefaultBranch, behind: 2},
{path: "libs/work", issues: []submodule.IssueKind{submodule.IssueChanged}, ref: "origin/main", source: submodule.RefFromDefaultBranch},
}
for _, tt := range tests {
st, err := p.Status(context.Background(), tt.path)
@@ -130,6 +133,13 @@ func TestStatus(t *testing.T) {
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)
}
want := tt.target
if want == "" && (tt.source == submodule.RefFromGitmodules || tt.source == submodule.RefFromDefaultBranch) {
want = tt.ref
}
if st.Pin.Target != want {
t.Errorf("%s: target %q, want %q", tt.path, st.Pin.Target, want)
}
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)
}
@@ -142,6 +152,12 @@ func TestStatus(t *testing.T) {
if tt.path == "libs/staged" && (st.CommittedDrift.Ahead != 0 || st.CommittedDrift.Behind == 0) {
t.Errorf("%s: CommittedDrift %+v, want the committed pin behind the staged one", tt.path, st.CommittedDrift)
}
if !slices.Equal(st.Pin.Branches, tt.branches) {
t.Errorf("%s: other branches %v, want %v", tt.path, st.Pin.Branches, tt.branches)
}
if st.TargetDrift != tt.toTarget || (st.TargetTip != nil) != (tt.toTarget != submodule.Distance{}) {
t.Errorf("%s: TargetDrift %+v with tip %+v, want %+v", tt.path, st.TargetDrift, st.TargetTip, tt.toTarget)
}
if st.Drift != tt.drift {
t.Errorf("%s: drift %+v, want %+v", tt.path, st.Drift, tt.drift)
}
@@ -334,6 +350,22 @@ func TestBasesOfDivergedCommits(t *testing.T) {
t.Errorf("src/core: Bases.Checkout = %+v", b)
}
// libs/feature: its pin is on a feature branch, and main moved on
pushUpstream(t, root, "feature")
if err := p.Fetch(ctx, "libs/feature"); err != nil {
t.Fatal(err)
}
st, err = p.Status(ctx, "libs/feature")
if err != nil {
t.Fatal(err)
}
if st.TargetDrift != (submodule.Distance{Ahead: 1, Behind: 1}) || st.Bases.Target == nil {
t.Errorf("libs/feature: TargetDrift %+v, Bases.Target %+v, want a diverged target with a base", st.TargetDrift, st.Bases.Target)
}
if sp := st.Spine(); len(sp) == 0 || !sp[0].Fork || sp[0].Roles[0] != submodule.RoleTarget {
t.Errorf("libs/feature: spine = %+v, want a fork headed by the target", sp)
}
// in sync and linear: no bases
if st, err = p.Status(ctx, "src/ui"); err != nil {
t.Fatal(err)
@@ -341,3 +373,59 @@ func TestBasesOfDivergedCommits(t *testing.T) {
t.Errorf("src/ui: Bases = %+v, want none", st.Bases)
}
}
// A `branch =` that does not exist locally is not replaced by the default
// branch: judging the pin against the wrong branch would be worse than not
// judging it.
func TestDeclaredBranchMissing(t *testing.T) {
p, root := ownFixture(t)
git(t, filepath.Join(root, "super"), "config", "-f", ".gitmodules", "submodule.libs/store.branch", "gone")
if _, err := p.Tree(context.Background()); err != nil {
t.Fatal(err)
}
st, err := p.Status(context.Background(), "libs/store")
if err != nil {
t.Fatal(err)
}
if st.Pin.Target != "" {
t.Errorf("target %q, want none", st.Pin.Target)
}
for _, is := range st.Issues() {
if is.Kind == submodule.IssueOffTarget {
t.Errorf("issue %+v without a target", is)
}
}
}
// Two branches contain the pin equally close: the branch the submodule is on
// wins, else the first name; the default branch is no favorite.
func TestContainingBranchTie(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
// the target is a branch that does not exist, so none applies
git(t, super, "config", "-f", ".gitmodules", "submodule.libs/log.branch", "gone")
if _, err := p.Tree(context.Background()); err != nil {
t.Fatal(err)
}
ref := func() (string, submodule.RefSource, []string) {
st, err := p.Status(context.Background(), "libs/log")
if err != nil {
t.Fatal(err)
}
return st.Pin.Ref, st.Pin.Source, st.Pin.Branches
}
// on branch main (upstream origin/main) in the fixture
if r, src, _ := ref(); r != "origin/main" || src != submodule.RefFromContains {
t.Errorf("on a branch: ref %q (%v), want origin/main", r, src)
}
git(t, filepath.Join(super, "libs", "log"), "checkout", "-q", "--detach")
r, src, others := ref()
if r != "origin/archive" || src != submodule.RefFromContains {
t.Errorf("detached: ref %q (%v), want origin/archive", r, src)
}
if !slices.Equal(others, []string{"origin/main"}) {
t.Errorf("detached: other branches %v, want [origin/main]", others)
}
}
+14 -6
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"math/rand/v2"
"path"
"strings"
"sync"
"time"
@@ -132,8 +133,12 @@ func commit(sha, subject, author string, ago time.Duration) *submodule.Commit {
}
func inSync(ref string, c *submodule.Commit) *submodule.Status {
pin := submodule.PinInfo{Ref: ref, Pushed: true}
if strings.HasPrefix(ref, "origin/") {
pin.Target = ref // on its target branch
}
return &submodule.Status{
Pin: submodule.PinInfo{Ref: ref, Pushed: true},
Pin: pin,
Pinned: c, CheckedOut: c, Tip: c,
}
}
@@ -271,13 +276,16 @@ func fixture() []node {
Tip: sub3Tip,
}},
{relPath: "libs/logging", status: &submodule.Status{
Pin: submodule.PinInfo{Ref: "origin/main", Behind: 3, Pushed: true},
Pinned: logPin,
CheckedOut: logPin,
Tip: logTip,
Pin: submodule.PinInfo{Ref: "origin/feat/slog", Source: submodule.RefFromContains, Target: "origin/main", Behind: 3, Pushed: true},
Pinned: logPin,
CheckedOut: logPin,
Tip: logTip,
TargetTip: commit("0f1e2d3", "Bump dependencies", "renovate", 30*time.Hour),
TargetDrift: submodule.Distance{Ahead: 2, Behind: 4},
Bases: submodule.Bases{Target: commit("9a8b7c6", "Release 0.8.0", "dana", 14*24*time.Hour)},
}},
{relPath: "libs/http", status: &submodule.Status{
Pin: submodule.PinInfo{Ref: "origin/develop", Ahead: 1, Behind: 2, Pushed: true, Source: submodule.RefFromContains},
Pin: submodule.PinInfo{Ref: "origin/develop", Target: "origin/develop", Ahead: 1, Behind: 2, Source: submodule.RefFromGitmodules},
Bases: submodule.Bases{Tip: commit("a9b8c7d", "Release 1.9.0", "frank", 20*24*time.Hour)},
Pinned: httpPin,
CheckedOut: httpPin,
+23 -10
View File
@@ -67,10 +67,10 @@ const (
type RefSource int
const (
RefFromGitmodules RefSource = iota
RefFromCurrentBranch
RefFromDefaultBranch
RefFromContains
RefFromGitmodules RefSource = iota // the target, `branch =` in .gitmodules; the pin is on it
RefFromDefaultBranch // the target, the remote's default branch; the pin is on it
RefFromContains // a branch containing the pin, which is not on the target
RefFromCurrentBranch // upstream of the checked-out branch, there being no better
RefFromTag
RefFromSHA
)
@@ -79,12 +79,12 @@ func (r RefSource) String() string {
switch r {
case RefFromGitmodules:
return ".gitmodules branch"
case RefFromCurrentBranch:
return "checked out branch"
case RefFromDefaultBranch:
return "remote default branch"
case RefFromContains:
return "branch containing pin"
case RefFromCurrentBranch:
return "checked out branch"
case RefFromTag:
return "tag"
default:
@@ -96,9 +96,17 @@ func (r RefSource) String() string {
type PinInfo struct {
Ref string // e.g. "origin/main"; empty if unresolved
Source RefSource
// Target is the branch the pin is supposed to be on (`branch =` of
// .gitmodules, else the remote's default branch); empty if there is none or
// it does not exist locally. Ref is the target if the pin is on it, else
// the branch the pin is on instead.
Target string
Ahead int // commits in pin not in ref
Behind int // commits in ref not in pin
Pushed bool // pin is reachable from some remote ref
// Branches are remote branches other than Ref that contain the pin, oldest
// tip first and limited.
Branches []string
}
// Commit is a short commit description.
@@ -179,7 +187,7 @@ type Distance struct{ Ahead, Behind int }
// Bases are the merge bases of the pin with the commits that have diverged
// from it, where the histories split. A field is nil if that commit has not
// diverged or the base is unknown.
type Bases struct{ Tip, Checkout, Committed *Commit }
type Bases struct{ Tip, Checkout, Committed, Target *Commit }
// Status is the full state of one submodule.
type Status struct {
@@ -198,7 +206,12 @@ type Status struct {
// only added. CommittedDrift compares it with Pinned like Drift.
Committed *Commit
CommittedDrift Distance
Bases Bases
Changes *Changes // nil if the worktree is clean or the details are unknown
FetchedAt time.Time // last fetch of the submodule; zero if never
// TargetTip is the tip of Pin.Target if the pin is not on it (the ref is
// another branch), else nil. TargetDrift compares it with Pinned like
// Drift: Ahead = commits only the target has.
TargetTip *Commit
TargetDrift Distance
Bases Bases
Changes *Changes // nil if the worktree is clean or the details are unknown
FetchedAt time.Time // last fetch of the submodule; zero if never
}
+4
View File
@@ -11,6 +11,7 @@ const (
RoleHead // the superproject's HEAD, which takes the pin's place
RoleStaged // the pin in the index, if it differs from RolePin
RoleTip // the tip of the ref the pin was resolved to
RoleTarget // the tip of the target branch, if the pin is not on it
RoleYou // the commit checked out in the submodule
RoleBase // where a diverged commit's history splits from the pin's
)
@@ -100,6 +101,9 @@ func (s *Status) Spine() []SpineNode {
if s.Tip != nil {
place(RoleTip, s.Tip, s.Bases.Tip, s.Pin.Behind, s.Pin.Ahead, true)
}
if s.TargetTip != nil {
place(RoleTarget, s.TargetTip, s.Bases.Target, s.TargetDrift.Ahead, s.TargetDrift.Behind, true)
}
if s.PinStaged && s.Committed != nil {
place(RolePin, s.Committed, s.Bases.Committed, s.CommittedDrift.Ahead, s.CommittedDrift.Behind, false)
}
+15 -1
View File
@@ -122,6 +122,20 @@ func TestSpine(t *testing.T) {
nodes: []string{"t:tip", "p:pin", "y:you"},
links: []Link{{Above: 2, Below: 1}, {Above: 1, Below: 1}},
},
{
name: "pin on a branch ahead of its target: the target is below the pin",
st: Status{Pinned: c("p"), CheckedOut: c("p"), Tip: c("t"), Pin: PinInfo{Behind: 1},
TargetTip: c("m"), TargetDrift: Distance{Behind: 3}},
nodes: []string{"t:tip", "p:pin,you", "m:target"},
links: []Link{{Above: 1}, {Above: 3}},
},
{
name: "pin on a branch diverged from its target: a fork",
st: Status{Pinned: c("p"), CheckedOut: c("p"), Tip: c("t"), Pin: PinInfo{Behind: 1},
TargetTip: c("m"), TargetDrift: Distance{Ahead: 12, Behind: 52}, Bases: Bases{Target: c("b")}},
nodes: []string{"m:target", "t:tip", "p:pin,you", "b:base"},
links: []Link{{Above: 12}, {Above: 1}, {Above: 52}},
},
{
name: "no tip, checkout not loaded",
st: Status{Pinned: c("p")},
@@ -134,7 +148,7 @@ func TestSpine(t *testing.T) {
links: []Link{{Above: 2}},
},
}
words := map[Role]string{RolePin: "pin", RoleStaged: "staged", RoleHead: "head", RoleTip: "tip", RoleYou: "you", RoleBase: "base"}
words := map[Role]string{RolePin: "pin", RoleStaged: "staged", RoleHead: "head", RoleTip: "tip", RoleTarget: "target", RoleYou: "you", RoleBase: "base"}
for _, tt := range tests {
var nodes []string
var links []Link
+27 -7
View File
@@ -1,10 +1,13 @@
package submodule
import "fmt"
// Level is how much attention an issue deserves.
type Level int
const (
LevelQuiet Level = iota // nothing to do
LevelInfo // good to know
LevelWarn // look at it
LevelDanger // act now
)
@@ -13,11 +16,12 @@ const (
type IssueKind int
const (
IssueMissing IssueKind = iota // not initialized or directory missing
IssueUnpushed // pin not reachable from any remote ref
IssueConflict // merge conflict in the worktree
IssueOffPin // checkout differs from pin
IssueChanged // uncommitted changes or untracked files
IssueMissing IssueKind = iota // not initialized or directory missing
IssueUnpushed // pin not reachable from any remote ref
IssueConflict // merge conflict in the worktree
IssueOffPin // checkout differs from pin
IssueChanged // uncommitted changes or untracked files
IssueOffTarget // pin pushed, but not on the target branch
)
// Issue is one problem of a submodule. Detail distinguishes the states that
@@ -61,15 +65,29 @@ func (s *Status) Issues() []Issue {
case WorktreeUntracked:
out = append(out, Issue{IssueChanged, LevelWarn, s.untrackedText()})
}
// a pin that is pushed, but not on the target (usually main); an
// unpushed pin is not on it either, but has the worse issue
if !s.Root && s.Pin.Pushed && s.Pin.Target != "" && s.Pin.Ref != s.Pin.Target {
out = append(out, Issue{IssueOffTarget, LevelInfo, s.offTargetText()})
}
return out
}
// offTargetText says which branch the pin is not on and where it is instead.
func (s *Status) offTargetText() string {
text := fmt.Sprintf("pin not on %s, on %s", s.Pin.Target, s.Pin.Ref)
if n := len(s.Pin.Branches); n > 0 {
text += fmt.Sprintf(" +%d", n)
}
return text
}
// Hidden counts issues below a submodule per level, plus staged pins (which
// are not issues).
type Hidden struct{ Warn, Danger, Staged int }
type Hidden struct{ Info, Warn, Danger, Staged int }
// Total is the number of issues of all levels; staged pins are not counted.
func (h Hidden) Total() int { return h.Warn + h.Danger }
func (h Hidden) Total() int { return h.Info + 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
@@ -93,6 +111,8 @@ func HiddenIn(mods []*Submodule) Hidden {
h.Danger++
case LevelWarn:
h.Warn++
case LevelInfo:
h.Info++
}
}
})
+23
View File
@@ -29,6 +29,12 @@ func TestIssues(t *testing.T) {
{"untracked is changed", &Status{Worktree: WorktreeUntracked, Pin: pushed}, []IssueKind{IssueChanged}},
{"danger first", &Status{Checkout: CheckoutDiffers, Worktree: WorktreeModified}, []IssueKind{IssueUnpushed, IssueOffPin, IssueChanged}},
{"conflict", &Status{Worktree: WorktreeConflict, Pin: pushed}, []IssueKind{IssueConflict}},
{"pushed, but not on the target", &Status{Pin: PinInfo{Pushed: true, Ref: "origin/feat", Target: "origin/main", Source: RefFromContains}}, []IssueKind{IssueOffTarget}},
{"off target comes last", &Status{Checkout: CheckoutDiffers, Pin: PinInfo{Pushed: true, Ref: "origin/feat", Target: "origin/main"}}, []IssueKind{IssueOffPin, IssueOffTarget}},
{"unpushed is not also off target", &Status{Pin: PinInfo{Ref: "origin/main", Target: "origin/main", Ahead: 1}}, []IssueKind{IssueUnpushed}},
{"on the target, ahead of nothing else", &Status{Pin: PinInfo{Pushed: true, Ref: "origin/main", Target: "origin/main", Behind: 4}}, nil},
{"no target, no judgement", &Status{Pin: PinInfo{Pushed: true, Ref: "origin/feat", Source: RefFromContains}}, nil},
{"the superproject has no target", &Status{Root: true, Pin: PinInfo{Pushed: true, Ref: "origin/feat", Target: "origin/main"}}, nil},
}
for _, tt := range tests {
if got := kinds(tt.s.Issues()); !slices.Equal(got, tt.want) {
@@ -37,6 +43,23 @@ func TestIssues(t *testing.T) {
}
}
func TestOffTargetText(t *testing.T) {
s := &Status{Pin: PinInfo{Target: "origin/marco-dev", Ref: "origin/fix/x"}}
for _, c := range []struct {
branches []string
want string
}{
{nil, "pin not on origin/marco-dev, on origin/fix/x"},
{[]string{"origin/main"}, "pin not on origin/marco-dev, on origin/fix/x +1"},
{[]string{"origin/main", "origin/y"}, "pin not on origin/marco-dev, on origin/fix/x +2"},
} {
s.Pin.Branches = c.branches
if got := s.offTargetText(); got != c.want {
t.Errorf("%v: %q, want %q", c.branches, got, c.want)
}
}
}
func TestHiddenIssues(t *testing.T) {
ok := &Status{Pin: PinInfo{Pushed: true}}
dirty := &Status{Worktree: WorktreeModified, Pin: PinInfo{Pushed: true}, PinStaged: true}