fix(sync): stop reporting success when no branch was actually synced

hub sync silently dropped any local branch whose upstream it could not
resolve: it cleared its internal remote branch while leaving the "gone"
flag false, so neither the update nor the delete path ran. A run could
then print "Already up to date" having done nothing, which is why a
workspace-wide sync looked like it worked when it did not.

- report unmatched branches as UNTRACKED with the reason, so
  "Already up to date" is only said when it is true
- resolve the remote default branch via `git ls-remote --symref` when
  refs/remotes/<remote>/HEAD is missing, so merged branches whose
  upstream was deleted are still cleaned up
- make --create-missing opt-in, keeping hub's default of never creating
  a local branch that does not already exist
- summarise runs as changed/unchanged with per-action counts, and list
  deletions and failures in full instead of leaving them in scrollback
- restore per-command flag help, which the custom Usage had dropped
- add an in-place CLI progress line, drawn only to a terminal so piped
  and --json output stay free of cursor control
- group the TUI selection screen by outcome so blocked and dirty
  repositories are visible before a run starts

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
2026-09-28 19:57:42 +01:00
parent 4f6e9c9991
commit f4286edff2
7 changed files with 885 additions and 72 deletions
+135 -32
View File
@@ -75,7 +75,52 @@ func runTUI(root string, repos []repo, opts options) error {
return err
}
// repoGroup is the outcome a repository will have if a run starts right now.
// The selection screen is grouped by it so problems are visible before the user
// commits to anything, rather than only after a run.
type repoGroup int
const (
groupBlocked repoGroup = iota
groupDirty
groupReady
)
func groupOf(r repo) repoGroup {
switch {
case !r.Eligible:
return groupBlocked
case len(r.Dirty) > 0:
return groupDirty
default:
return groupReady
}
}
func groupLabel(g repoGroup) string {
switch g {
case groupBlocked:
return "BLOCKED · not eligible, fix by hand"
case groupDirty:
return "DIRTY · checked-out branch protected"
default:
return "READY"
}
}
// sortReposByOutcome puts the repositories that need attention first, then
// groups equal outcomes together in a stable, name-ordered list.
func sortReposByOutcome(repos []repo) {
sort.SliceStable(repos, func(i, j int) bool {
if gi, gj := groupOf(repos[i]), groupOf(repos[j]); gi != gj {
return gi < gj
}
return repos[i].RelativePath < repos[j].RelativePath
})
}
func newTUIModel(root string, repos []repo, opts options) tuiModel {
sortReposByOutcome(repos)
selected := make(map[string]bool)
for _, r := range repos {
selected[r.Path] = r.Eligible
@@ -103,6 +148,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.repos = msg.repos
sortReposByOutcome(m.repos)
m.reconcileSelection()
m.status = "Workspace refreshed"
m.clampViewport()
@@ -136,18 +182,20 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.repos = msg.repos
m.reconcileSelection()
var ok, failed, skipped int
var ok, unchanged, failed, skipped int
for _, result := range msg.results {
switch {
case result.Success:
ok++
case result.Skipped:
skipped++
default:
case !result.Success:
failed++
case repositoryChanged(result):
ok++
default:
unchanged++
}
}
m.status = fmt.Sprintf("Finished: %d synced, %d failed, %d safety-skipped", ok, failed, skipped)
m.status = fmt.Sprintf("Finished: %d changed, %d unchanged, %d failed, %d safety-skipped", ok, unchanged, failed, skipped)
if path := firstAttentionPath(msg.results); path != "" {
m.focusPath(path)
m.status += "; focused first issue"
@@ -263,6 +311,10 @@ func (m tuiModel) View() string {
b.WriteString("\n")
for i := visible.start; i < visible.end; i++ {
r := m.repos[i]
if i == 0 || groupOf(r) != groupOf(m.repos[i-1]) {
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %s", groupLabel(groupOf(r)))))
b.WriteString("\n")
}
pointer := " "
if i == m.cursor {
pointer = "›"
@@ -318,19 +370,50 @@ func (m tuiModel) View() string {
type rowRange struct{ start, end int }
func (m tuiModel) visibleRows() rowRange {
// listHeight is how many screen rows the repository list may occupy, leaving
// room for the header, detail pane, and footer.
func (m tuiModel) listHeight() int {
reserved := 15
if m.mode == modeSyncing {
reserved = 20
} else if len(m.lastResult) > 0 {
if m.mode == modeSyncing || len(m.lastResult) > 0 {
reserved = 20
}
available := m.height - reserved
if available < 4 {
available = 4
return max(4, m.height-reserved)
}
// groupHeaderRows counts the group headings rendered inside [start,end). A
// heading appears wherever the group changes, so scrolling into a new group
// labels it.
func (m tuiModel) groupHeaderRows(start, end int) int {
rows := 0
for i := max(0, start); i < end; i++ {
if i == 0 || groupOf(m.repos[i]) != groupOf(m.repos[i-1]) {
rows++
}
}
end := min(len(m.repos), m.offset+available)
return rowRange{start: min(m.offset, end), end: end}
return rows
}
// windowRows is the number of screen rows the slice occupies, headings included.
func (m tuiModel) windowRows(start, end int) int {
if start >= end {
return 0
}
return end - start + m.groupHeaderRows(start, end)
}
func (m tuiModel) visibleRows() rowRange {
available := m.listHeight()
start := min(m.offset, len(m.repos))
end := start
for end < len(m.repos) && m.windowRows(start, end+1) <= available {
end++
}
// Reclaiming rows above the offset keeps a short list from scrolling the
// top out of view when the offset no longer reflects a full window.
for start > 0 && m.windowRows(start-1, end) <= available {
start--
}
return rowRange{start: start, end: end}
}
func (m *tuiModel) clampViewport() {
@@ -339,16 +422,17 @@ func (m *tuiModel) clampViewport() {
return
}
m.cursor = max(0, min(m.cursor, len(m.repos)-1))
reserved := 15
if m.mode == modeSyncing || len(m.lastResult) > 0 {
reserved = 20
}
available := max(4, m.height-reserved)
if m.cursor < m.offset {
if m.offset > m.cursor {
m.offset = m.cursor
}
if m.cursor >= m.offset+available {
m.offset = m.cursor - available + 1
// Headings share the budget, so the window can be shorter than the row
// count alone suggests. Step forward until the cursor is on screen; the
// loop always terminates because an offset equal to the cursor renders it.
for m.offset < m.cursor {
if m.cursor < m.visibleRows().end {
break
}
m.offset++
}
}
@@ -435,7 +519,7 @@ func (m tuiModel) scanCmd() tea.Cmd {
func (m tuiModel) startSyncCmd(selected []repo, events chan tea.Msg) tea.Cmd {
return func() tea.Msg {
go func() {
results := syncSelectedWithProgress(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true, false, func(event syncProgressEvent) {
results := syncSelectedWithProgress(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true, false, m.opts.createMissing, nil, func(event syncProgressEvent) {
if event.Started {
events <- syncStartedMsg{path: event.Path}
} else {
@@ -503,35 +587,54 @@ func (m tuiModel) syncProgressView(width int) string {
}
func (m tuiModel) lastRunView(width int) string {
var ok, failed, skipped, protected, warnings int
var changed, unchanged, failed, skipped, protected, warnings, untracked, deleted, updated, created int
var critical, caution []string
for _, result := range m.lastResult {
switch {
case result.Success:
ok++
case result.Skipped:
skipped++
critical = append(critical, "↷ "+result.Path+": "+firstLine(result.Message))
default:
case !result.Success:
failed++
critical = append(critical, "✗ "+result.Path+": "+firstLine(result.Message))
case repositoryChanged(result):
changed++
default:
unchanged++
}
for _, branch := range result.Branches {
switch branch.Action {
case "updated":
updated++
case "created":
created++
case "deleted":
deleted++
critical = append(critical, "✗ "+result.Path+" / "+branch.Branch+" was deleted: "+branch.Message)
case "protected":
protected++
caution = append(caution, "◆ "+result.Path+" / "+branch.Branch+": "+branch.Message)
case "warning":
warnings++
caution = append(caution, "! "+result.Path+" / "+branch.Branch+": "+branch.Message)
case "untracked":
untracked++
caution = append(caution, "◦ "+result.Path+" / "+branch.Branch+": "+branch.Message)
}
}
}
details := append(critical, caution...)
header := titleStyle.Render("LAST RUN") + fmt.Sprintf(" %s %s %s", readyStyle.Render(fmt.Sprintf("%d synced", ok)), blockStyle.Render(fmt.Sprintf("%d failed", failed)), mutedStyle.Render(fmt.Sprintf("%d skipped", skipped)))
if protected+warnings > 0 {
header += titleStyle.Render(fmt.Sprintf(" %d protected · %d warnings", protected, warnings))
header := titleStyle.Render("LAST RUN") + fmt.Sprintf(" %s %s %s %s",
readyStyle.Render(fmt.Sprintf("%d changed", changed)),
mutedStyle.Render(fmt.Sprintf("%d unchanged", unchanged)),
blockStyle.Render(fmt.Sprintf("%d failed", failed)),
mutedStyle.Render(fmt.Sprintf("%d skipped", skipped)))
if updated+deleted+created > 0 {
header += titleStyle.Render(fmt.Sprintf(" %d updated · %d deleted · %d created", updated, deleted, created))
}
if protected+warnings+untracked > 0 {
header += titleStyle.Render(fmt.Sprintf(" %d protected · %d warnings · %d untracked", protected, warnings, untracked))
}
lines := []string{header}
shown := min(3, len(details))
@@ -559,7 +662,7 @@ func resultNeedsAttention(result syncResult) bool {
return true
}
for _, branch := range result.Branches {
if branch.Action == "warning" || branch.Action == "protected" {
if branch.Action == "warning" || branch.Action == "protected" || branch.Action == "untracked" {
return true
}
}
@@ -579,7 +682,7 @@ func firstAttentionPath(results []syncResult) string {
}
for _, result := range results {
for _, branch := range result.Branches {
if branch.Action == "warning" || branch.Action == "protected" {
if branch.Action == "warning" || branch.Action == "protected" || branch.Action == "untracked" {
return result.Path
}
}