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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user