Files
stack-sync/main_test.go
T
LyAhn f4286edff2 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>
2026-09-28 19:57:42 +01:00

271 lines
8.6 KiB
Go

package main
import (
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func git(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
func makeRepo(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
git(t, path, "init", "-q")
git(t, path, "config", "user.name", "Test")
git(t, path, "config", "user.email", "test@example.com")
if err := os.WriteFile(filepath.Join(path, "tracked.txt"), []byte("clean\n"), 0o644); err != nil {
t.Fatal(err)
}
git(t, path, "add", "tracked.txt")
git(t, path, "commit", "-qm", "initial")
git(t, path, "remote", "add", "origin", "https://example.invalid/repo.git")
}
func TestDiscoverNestedRepositories(t *testing.T) {
root := t.TempDir()
parent := filepath.Join(root, "parent")
child := filepath.Join(parent, "apps", "child")
makeRepo(t, parent)
makeRepo(t, child)
paths, err := discover(root, nil)
if err != nil {
t.Fatal(err)
}
if len(paths) != 2 || paths[0] != parent || paths[1] != child {
t.Fatalf("discover() = %#v", paths)
}
}
func TestNestedRepoDoesNotDirtyParent(t *testing.T) {
root := t.TempDir()
parent := filepath.Join(root, "parent")
child := filepath.Join(parent, "child")
makeRepo(t, parent)
makeRepo(t, child)
paths, _ := discover(root, nil)
got := inspect(root, parent, paths)
if !got.Eligible {
t.Fatalf("parent blocked by nested repo: %+v", got)
}
if len(got.NestedRepoEntries) == 0 {
t.Fatalf("expected nested repository entry to be recorded: %+v", got)
}
}
func TestDirtyFileIsReportedButRepositoryRemainsEligible(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "repo")
makeRepo(t, path)
if err := os.WriteFile(filepath.Join(path, "tracked.txt"), []byte("changed\n"), 0o644); err != nil {
t.Fatal(err)
}
got := inspect(root, path, []string{path})
if !got.Eligible || len(got.Dirty) != 1 {
t.Fatalf("dirty repository was not eligible with its changes reported: %+v", got)
}
}
func TestUntrackedFileAlongsideNestedRepoIsStillReported(t *testing.T) {
root := t.TempDir()
parent := filepath.Join(root, "parent")
child := filepath.Join(parent, "child")
makeRepo(t, parent)
makeRepo(t, child)
if err := os.WriteFile(filepath.Join(parent, "notes.txt"), []byte("work\n"), 0o644); err != nil {
t.Fatal(err)
}
paths, _ := discover(root, nil)
got := inspect(root, parent, paths)
if !got.Eligible || len(got.Dirty) != 1 {
t.Fatalf("untracked file should be reported without blocking parent: %+v", got)
}
}
func TestSkippedDirectoriesAreNotSearched(t *testing.T) {
root := t.TempDir()
makeRepo(t, filepath.Join(root, "node_modules", "dependency"))
makeRepo(t, filepath.Join(root, "real"))
paths, err := discover(root, nil)
if err != nil {
t.Fatal(err)
}
if len(paths) != 1 || paths[0] != filepath.Join(root, "real") {
t.Fatalf("discover() = %#v", paths)
}
}
func TestStrictSyncRechecksAndSkipsNewlyDirtyRepository(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "repo")
makeRepo(t, path)
planned := inspect(root, path, []string{path})
if !planned.Eligible {
t.Fatalf("fixture should initially be eligible: %+v", planned)
}
if err := os.WriteFile(filepath.Join(path, "late-change.txt"), []byte("do not touch\n"), 0o644); err != nil {
t.Fatal(err)
}
var progress []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 {
t.Fatalf("syncAll() = %+v", results)
}
if len(progress) != 1 || progress[0].Started || !progress[0].Result.Skipped {
t.Fatalf("progress events = %+v, want one finished skip", progress)
}
}
func TestScanReportsDirtyEligibleRepository(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "repo")
makeRepo(t, path)
if err := os.WriteFile(filepath.Join(path, "untracked.txt"), []byte("work\n"), 0o644); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
code := run([]string{"scan", "--root", root}, bytes.NewReader(nil), &stdout, &stderr)
if code != 0 {
t.Fatalf("run() code = %d, stderr = %q", code, stderr.String())
}
if !bytes.Contains(stdout.Bytes(), []byte("dirty worktree (1 changes), checked-out branch protected")) {
t.Fatalf("scan did not report dirty eligible repository:\n%s", stdout.String())
}
}
func TestIndentNormalizesGitProgressOutput(t *testing.T) {
got := indent("first\rsecond\r\nthird", " ")
want := " first\n second\n third"
if got != want {
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())
}
}