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
+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++
}
}
})