This repository has been archived on 2026-10-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
submarine/internal/submodule/status.go
T
simonfrankenandClaude Opus 5.5 c9932932bb Make the screen explain itself: labels, tools, help pages, hints
For developers new to submodules (notes/usability-plan.md, D32):
- x and X read "restore to pin" and "restore committed pins"
- problems only, filter and fetch are shown in the footer
- ? shows every key in titled columns, then a legend of the status
  words, symbols and spine roles; esc closes it
- u reads "pull (fast-forward)" on the superproject
- the detail pane suggests f when the last fetch is old
- a hint above the footer until the first key
- the header says "staged", like the tree

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:26:32 +02:00

159 lines
4.7 KiB
Go

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
)
// IssueKind is the summarized category of a problem.
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
IssueOffTarget // pin pushed, but not on the target branch
)
// Kinds lists every issue kind, most severe first.
var Kinds = []IssueKind{IssueUnpushed, IssueConflict, IssueMissing, IssueOffPin, IssueChanged, IssueOffTarget}
// Level is how much attention an issue of kind k deserves.
func (k IssueKind) Level() Level {
switch k {
case IssueUnpushed, IssueConflict:
return LevelDanger
case IssueOffTarget:
return LevelInfo
default:
return LevelWarn
}
}
// Issue is one problem of a submodule. Detail distinguishes the states that
// are summarized into one kind.
type Issue struct {
Kind IssueKind
Level Level
Detail string
}
// Issues summarizes the status, most severe first. Being behind or ahead of
// the remote, a staged pin and a detached HEAD are not issues.
func (s *Status) Issues() []Issue {
if s == nil {
return nil
}
switch s.Presence {
case NotInitialized:
return []Issue{{IssueMissing, IssueMissing.Level(), "not initialized"}}
case DirMissing:
return []Issue{{IssueMissing, IssueMissing.Level(), "directory missing"}}
}
var out []Issue
if !s.Pin.Pushed {
detail := "pin not pushed to any remote"
if s.Root {
detail = "HEAD not pushed to any remote"
}
out = append(out, Issue{IssueUnpushed, IssueUnpushed.Level(), detail})
}
if s.Worktree == WorktreeConflict {
out = append(out, Issue{IssueConflict, IssueConflict.Level(), "merge conflict"})
}
if s.Checkout == CheckoutDiffers {
out = append(out, Issue{IssueOffPin, IssueOffPin.Level(), "checkout differs from pin"})
}
switch s.Worktree {
case WorktreeModified:
out = append(out, Issue{IssueChanged, IssueChanged.Level(), "uncommitted changes"})
case WorktreeUntracked:
out = append(out, Issue{IssueChanged, IssueChanged.Level(), 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, IssueOffTarget.Level(), 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{ 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.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
// unknown.
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(mods, func(c *Submodule) {
if c.Status == nil && c.Err != nil {
h.Danger++
}
if c.Status != nil && c.Status.PinStaged {
h.Staged++
}
for _, is := range c.Status.Issues() {
switch is.Level {
case LevelDanger:
h.Danger++
case LevelWarn:
h.Warn++
case LevelInfo:
h.Info++
}
}
})
return h
}
// Walk calls fn for every submodule in depth-first order.
func Walk(mods []*Submodule, fn func(*Submodule)) {
for _, m := range mods {
fn(m)
Walk(m.Children, fn)
}
}
// untrackedText words the untracked-files issue: "untracked repository" if
// everything untracked is a git repository of its own, e.g. a clone that was
// never added as a submodule.
func (s *Status) untrackedText() string {
if s.Changes == nil || s.Changes.Total != len(s.Changes.Files) {
return "untracked files"
}
for _, f := range s.Changes.Files {
if f.Kind != ChangeUntracked || !f.Repo {
return "untracked files"
}
}
return "untracked repository"
}