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
+206 -24
View File
@@ -16,9 +16,10 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
)
const version = "0.3.1"
const version = "0.4.0"
var defaultSkippedDirs = map[string]bool{
".cache": true, ".claude": true, ".codex": true, ".git": true, ".next": true, ".pnpm-store": true,
@@ -27,13 +28,14 @@ var defaultSkippedDirs = map[string]bool{
}
type options struct {
root string
json bool
yes bool
strict bool
jobs int
timeout time.Duration
exclusions stringList
root string
json bool
yes bool
strict bool
jobs int
timeout time.Duration
exclusions stringList
createMissing bool
}
type stringList []string
@@ -163,7 +165,12 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
}
}
results := syncAll(root, repos, opts.jobs, opts.timeout, stdout, opts.json, opts.strict)
var liveMu sync.Mutex
live := newLiveProgress(stdout, &liveMu, len(repos))
results := syncAllWithLive(root, repos, opts.jobs, opts.timeout, stdout, opts.json, opts.strict, opts.createMissing, live)
liveMu.Lock()
live.clear()
liveMu.Unlock()
if opts.json {
writeJSON(stdout, results)
} else {
@@ -196,8 +203,16 @@ func parseFlags(command string, args []string, stderr io.Writer) (options, error
}
if command == "sync" || command == "tui" {
fs.DurationVar(&opts.timeout, "timeout", opts.timeout, "timeout for each repository sync")
fs.BoolVar(&opts.createMissing, "create-missing", false, "create local branches that exist only on the remote")
}
fs.Usage = func() {
usage(stderr)
// PrintDefaults was dropped along with the stock usage function, which
// left "use <command> -h for command options" promising flags that
// were never actually listed.
fmt.Fprintf(stderr, "\nOptions for %s:\n", command)
fs.PrintDefaults()
}
fs.Usage = func() { usage(stderr) }
if err := fs.Parse(args); err != nil {
return opts, err
}
@@ -239,6 +254,13 @@ Safety:
repositories without remotes, and in-progress Git operations remain blocked.
stack-sync never stashes, resets, cleans, or commits.
Branch handling:
Outdated local branches are fast-forwarded, and a local branch whose upstream
was deleted is removed once it is merged into the remote default branch.
A local branch with no upstream and no matching remote branch cannot be
fast-forwarded, so it is reported as UNTRACKED and left alone. Pass
--create-missing to also create branches that exist only on the remote.
Use "stack-sync <command> -h" for command options.`)
}
@@ -422,15 +444,20 @@ func gitBytes(path string, args ...string) ([]byte, error) {
return cmd.Output()
}
func syncAll(root string, repos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict bool) []syncResult {
return syncSelected(root, repos, repos, jobs, timeout, stdout, quiet, strict)
func syncAll(root string, repos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict, createMissing bool) []syncResult {
return syncSelected(root, repos, repos, jobs, timeout, stdout, quiet, strict, createMissing, nil)
}
func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict bool) []syncResult {
return syncSelectedWithProgress(root, repos, workspaceRepos, jobs, timeout, stdout, quiet, strict, nil)
// syncAllWithLive is syncAll with the in-place progress line attached.
func syncAllWithLive(root string, repos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict, createMissing bool, live *liveProgress) []syncResult {
return syncSelected(root, repos, repos, jobs, timeout, stdout, quiet, strict, createMissing, live)
}
func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict bool, progress func(syncProgressEvent)) []syncResult {
func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict, createMissing bool, live *liveProgress) []syncResult {
return syncSelectedWithProgress(root, repos, workspaceRepos, jobs, timeout, stdout, quiet, strict, createMissing, live, nil)
}
func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict, createMissing bool, live *liveProgress, progress func(syncProgressEvent)) []syncResult {
type item struct {
index int
repo repo
@@ -448,6 +475,7 @@ func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs in
if progress != nil {
progress(syncProgressEvent{Path: result.Path, Result: result})
}
live.update(func() { live.finished(result); live.draw() })
}
for range min(jobs, len(repos)) {
@@ -475,15 +503,18 @@ func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs in
}
if !quiet {
outputMu.Lock()
live.clear()
fmt.Fprintf(stdout, "\nSTART %s (%s)\n", r.RelativePath, r.Branch)
outputMu.Unlock()
}
live.update(func() { live.started(); live.draw() })
started := time.Now()
report, err := syncRepository(r.Path, timeout, len(fresh.Dirty) > 0, fresh.NestedRepoEntries)
report, err := syncRepositoryWith(r.Path, timeout, len(fresh.Dirty) > 0, fresh.NestedRepoEntries, syncOptions{CreateMissing: createMissing})
message := report.Message
duration := time.Since(started).Round(time.Millisecond).String()
if !quiet {
outputMu.Lock()
live.clear()
state := "DONE"
if err != nil {
state = "FAIL"
@@ -536,26 +567,170 @@ func printScan(w io.Writer, root string, repos []repo) {
}
}
// actionOrder fixes the order actions are summarised in, so the same workspace
// always reads the same way from one run to the next.
var actionOrder = []string{"updated", "deleted", "created", "protected", "warning", "untracked"}
// liveProgress redraws a single line in place while repositories sync, so
// workspace totals are visible without waiting for the final summary. It is only
// enabled when stdout is a terminal: writing cursor control into a pipe or a
// --json document would corrupt the captured output, so callers get a nil
// *liveProgress in those cases and every method here is a no-op.
type liveProgress struct {
w io.Writer
mu *sync.Mutex
total int
done int
active int
changed int
failed int
skipped int
counts map[string]int
lastWidth int
}
func newLiveProgress(w io.Writer, mu *sync.Mutex, total int) *liveProgress {
if !isTerminalWriter(w) {
return nil
}
return &liveProgress{w: w, mu: mu, total: total, counts: make(map[string]int, len(actionOrder))}
}
// started records that a repository has begun syncing.
func (p *liveProgress) started() {
if p == nil {
return
}
p.active++
}
// finished folds a completed repository into the running totals.
func (p *liveProgress) finished(result syncResult) {
if p == nil {
return
}
p.done++
p.active--
for _, branch := range result.Branches {
p.counts[branch.Action]++
}
switch {
case result.Skipped:
p.skipped++
case !result.Success:
p.failed++
case repositoryChanged(result):
p.changed++
}
}
func (p *liveProgress) text() string {
parts := []string{fmt.Sprintf("%d/%d", p.done, p.total)}
if p.active > 0 {
parts = append(parts, fmt.Sprintf("%d active", p.active))
}
parts = append(parts, fmt.Sprintf("%d changed", p.changed))
if line := formatActionCounts(p.counts); line != "" {
parts = append(parts, line)
}
if p.skipped > 0 {
parts = append(parts, fmt.Sprintf("%d skipped", p.skipped))
}
if p.failed > 0 {
parts = append(parts, fmt.Sprintf("%d FAILED", p.failed))
}
return " " + strings.Join(parts, " · ")
}
// draw repaints the line. Callers must already hold p.mu when drawing.
func (p *liveProgress) draw() {
if p == nil {
return
}
line := p.text()
fmt.Fprintf(p.w, "\r%s\r%s", strings.Repeat(" ", p.lastWidth), line)
p.lastWidth = utf8.RuneCountInString(line)
}
// clear erases the line so ordinary output can be written on top of it.
func (p *liveProgress) clear() {
if p == nil {
return
}
p.lastWidth = 0
}
func (p *liveProgress) update(fn func()) {
if p == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
fn()
}
func printSummary(w io.Writer, results []syncResult) {
var ok, failed, skipped int
counts := make(map[string]int, len(actionOrder))
var changed, unchanged, failed, skipped int
for _, r := range results {
for _, branch := range r.Branches {
counts[branch.Action]++
}
switch {
case r.Success:
ok++
case r.Skipped:
skipped++
default:
case !r.Success:
failed++
case repositoryChanged(r):
changed++
default:
unchanged++
}
}
fmt.Fprintf(w, "\nSummary: %d synced, %d failed, %d skipped\n", ok, failed, skipped)
for _, r := range results {
if !r.Success && !r.Skipped {
fmt.Fprintf(w, " FAILED %s: %s\n", r.Path, firstLine(r.Message))
// "synced" counted every repository that ran, including ones where nothing
// was actually touched, so a run that changed nothing still looked busy.
fmt.Fprintf(w, "\nSummary: %d changed, %d unchanged, %d failed, %d skipped\n", changed, unchanged, failed, skipped)
if line := formatActionCounts(counts); line != "" {
fmt.Fprintf(w, " %s\n", line)
}
// Deletions and failures are destructive or unexpected, so they are listed
// in full here rather than left to be found in the scrolling log above.
if failed > 0 || counts["deleted"] > 0 {
fmt.Fprintln(w, "\nNeeds review:")
for _, r := range results {
if !r.Success && !r.Skipped {
fmt.Fprintf(w, " FAILED %s: %s\n", r.Path, firstLine(r.Message))
}
for _, branch := range r.Branches {
if branch.Action == "deleted" {
fmt.Fprintf(w, " DELETED %s / %s: %s\n", r.Path, branch.Branch, branch.Message)
}
}
}
}
}
func repositoryChanged(r syncResult) bool {
for _, branch := range r.Branches {
switch branch.Action {
case "updated", "deleted", "created":
return true
}
}
return false
}
func formatActionCounts(counts map[string]int) string {
parts := make([]string, 0, len(actionOrder))
for _, action := range actionOrder {
if n := counts[action]; n > 0 {
parts = append(parts, fmt.Sprintf("%d %s", n, action))
}
}
return strings.Join(parts, " · ")
}
func eligibleCount(repos []repo) int {
n := 0
for _, r := range repos {
@@ -605,3 +780,10 @@ func isTerminal(file *os.File) bool {
info, err := file.Stat()
return err == nil && info.Mode()&os.ModeCharDevice != 0
}
// isTerminalWriter reports whether w is a real terminal, so callers can avoid
// emitting cursor control sequences into redirected or buffered output.
func isTerminalWriter(w io.Writer) bool {
file, ok := w.(*os.File)
return ok && isTerminal(file)
}