feat(cli): safely sync repositories across a workspace

Walk a workspace for Git repositories, including intentionally nested repos,
while pruning dependency caches and build output directories.

Inspect each branch, remote, operation state, and porcelain status before
running hub sync. Recheck safety immediately before mutation, keep nested
repositories from falsely dirtying their parent, and support concurrency,
exclusions, strict mode, timeouts, JSON output, and explicit confirmation.
This commit is contained in:
2026-09-06 18:29:50 +01:00
commit 295d694d78
5 changed files with 845 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
package main
import (
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"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 TestDirtyFileBlocksRepository(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 blocked: %+v", got)
}
}
func TestUntrackedFileAlongsideNestedRepoStillBlocksParent(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 block 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 TestSyncRechecksAndSkipsNewlyDirtyRepository(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)
}
bin := filepath.Join(root, "bin")
if err := os.Mkdir(bin, 0o755); err != nil {
t.Fatal(err)
}
marker := filepath.Join(root, "hub-was-called")
script := "#!/bin/sh\ntouch \"$STACK_SYNC_TEST_MARKER\"\n"
if err := os.WriteFile(filepath.Join(bin, "hub"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("STACK_SYNC_TEST_MARKER", marker)
results := syncAll(root, []repo{planned}, 1, time.Minute, io.Discard, true)
if len(results) != 1 || !results[0].Skipped || results[0].Success {
t.Fatalf("syncAll() = %+v", results)
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("hub ran despite the newly dirty worktree")
}
}
func TestSyncInvokesHubForCleanRepository(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "repo")
makeRepo(t, path)
planned := inspect(root, path, []string{path})
bin := filepath.Join(root, "bin")
if err := os.Mkdir(bin, 0o755); err != nil {
t.Fatal(err)
}
marker := filepath.Join(root, "hub-was-called")
script := "#!/bin/sh\nprintf '%s' \"$*\" > \"$STACK_SYNC_TEST_MARKER\"\n"
if err := os.WriteFile(filepath.Join(bin, "hub"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("STACK_SYNC_TEST_MARKER", marker)
results := syncAll(root, []repo{planned}, 1, time.Minute, io.Discard, true)
if len(results) != 1 || !results[0].Success || results[0].Skipped {
t.Fatalf("syncAll() = %+v", results)
}
args, err := os.ReadFile(marker)
if err != nil {
t.Fatalf("hub was not called: %v", err)
}
if string(args) != "sync --color=never" {
t.Fatalf("hub arguments = %q", args)
}
}
func TestScanWithBlockedRepositoryIsAValidReport(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("BLOCKED: dirty worktree")) {
t.Fatalf("scan did not report blocked 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)
}
}