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