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()) } }