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:
+109
-1
@@ -6,6 +6,8 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -124,7 +126,7 @@ func TestStrictSyncRechecksAndSkipsNewlyDirtyRepository(t *testing.T) {
|
||||
}
|
||||
|
||||
var progress []syncProgressEvent
|
||||
results := syncSelectedWithProgress(root, []repo{planned}, []repo{planned}, 1, time.Minute, io.Discard, true, true, func(event syncProgressEvent) {
|
||||
results := syncSelectedWithProgress(root, []repo{planned}, []repo{planned}, 1, time.Minute, io.Discard, true, true, false, nil, func(event syncProgressEvent) {
|
||||
progress = append(progress, event)
|
||||
})
|
||||
if len(results) != 1 || !results[0].Skipped || results[0].Success {
|
||||
@@ -160,3 +162,109 @@ func TestIndentNormalizesGitProgressOutput(t *testing.T) {
|
||||
t.Fatalf("indent() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The summary must say what actually changed, not just how many repositories
|
||||
// ran. "synced" previously counted repositories where nothing was touched.
|
||||
func TestSummaryReportsPerActionCountsAndDeletions(t *testing.T) {
|
||||
results := []syncResult{
|
||||
{Path: "api", Success: true, Branches: []branchSyncResult{
|
||||
{Branch: "main", Action: "updated"},
|
||||
{Branch: "stale", Action: "deleted", Message: "upstream was deleted and the branch was merged into main"},
|
||||
}},
|
||||
{Path: "web", Success: true, Branches: []branchSyncResult{
|
||||
{Branch: "wip", Action: "protected"},
|
||||
}},
|
||||
{Path: "cli", Success: true},
|
||||
{Path: "docs", Message: "fetch origin: authentication failed"},
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
printSummary(&out, results)
|
||||
got := out.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"1 changed", "2 unchanged", "1 failed", "0 skipped",
|
||||
"1 updated · 1 deleted · 1 protected",
|
||||
"Needs review:",
|
||||
"DELETED api / stale: upstream was deleted and the branch was merged into main",
|
||||
"FAILED docs: fetch origin: authentication failed",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("summary missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
// A workspace where nothing changed must not claim to have synced anything,
|
||||
// and must not open a "Needs review" section with nothing to review.
|
||||
var quiet bytes.Buffer
|
||||
printSummary(&quiet, []syncResult{{Path: "web", Success: true, Branches: []branchSyncResult{{Branch: "wip", Action: "protected"}}}})
|
||||
if strings.Contains(quiet.String(), "1 changed") {
|
||||
t.Fatalf("a protected-only run was reported as changed:\n%s", quiet.String())
|
||||
}
|
||||
if strings.Contains(quiet.String(), "Needs review") {
|
||||
t.Fatalf("a run with no failures or deletions opened a review section:\n%s", quiet.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The in-place progress line must never be written to a pipe, a buffer, or a
|
||||
// --json document, where the cursor control would corrupt the captured output.
|
||||
func TestLiveProgressIsDisabledForNonTerminalOutput(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
if p := newLiveProgress(&bytes.Buffer{}, &mu, 3); p != nil {
|
||||
t.Fatal("newLiveProgress() enabled the live line for a buffered writer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveProgressRendersRunningTotals(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
buf := &bytes.Buffer{}
|
||||
p := &liveProgress{w: buf, mu: &mu, total: 3, counts: map[string]int{}}
|
||||
|
||||
p.update(func() { p.started(); p.draw() })
|
||||
if !strings.Contains(buf.String(), "0/3") || !strings.Contains(buf.String(), "1 active") {
|
||||
t.Fatalf("start did not render progress: %q", buf.String())
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
p.update(func() {
|
||||
p.finished(syncResult{Path: "api", Success: true, Branches: []branchSyncResult{{Branch: "main", Action: "updated"}}})
|
||||
p.draw()
|
||||
})
|
||||
for _, want := range []string{"1/3", "1 changed", "1 updated"} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Fatalf("totals missing %q: %q", want, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
p.update(func() {
|
||||
p.finished(syncResult{Path: "web", Message: "fetch origin: authentication failed"})
|
||||
p.draw()
|
||||
})
|
||||
for _, want := range []string{"2/3", "1 FAILED"} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Fatalf("failure not surfaced live: %q", buf.String())
|
||||
}
|
||||
}
|
||||
if strings.Contains(buf.String(), "2 changed") {
|
||||
t.Fatalf("a failed repository was counted as changed: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end guard: a non-interactive run must emit no carriage returns at all.
|
||||
func TestSyncOutputToBufferHasNoCarriageReturns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "repo")
|
||||
makeRepo(t, path)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run([]string{"sync", "--root", root, "--yes"}, bytes.NewReader(nil), &stdout, &stderr)
|
||||
if code > 1 {
|
||||
t.Fatalf("run() code = %d, stderr = %q", code, stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "\r") {
|
||||
t.Fatalf("buffered output contained cursor control:\n%q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Summary:") {
|
||||
t.Fatalf("run did not print a summary:\n%q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user