From 295d694d78664316b214e3aee7cffd2c15ff8848 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sun, 6 Sep 2026 18:29:50 +0100 Subject: [PATCH] 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. --- .gitignore | 1 + README.md | 67 ++++++ go.mod | 3 + main.go | 572 +++++++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 202 ++++++++++++++++++ 5 files changed, 845 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 main.go create mode 100644 main_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f16dfb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/stack-sync diff --git a/README.md b/README.md new file mode 100644 index 0000000..22e6162 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Stack Sync + +`stack-sync` safely runs [`hub sync`](https://hub.github.com/hub-sync.1.html) across every Git repository in a workspace. It is designed for directory trees such as JezzWTF where the root may not be a repository and a parent repository may contain deliberately untracked, nested repositories. + +The interface is deliberately a CLI rather than a full-screen TUI: every run produces a reviewable plan, it works over SSH and in automation, and its JSON output can drive a future TUI or GUI without putting safety logic in the presentation layer. + +## Safety model + +Before a repository is eligible, Stack Sync verifies that it: + +- is on a branch (not a detached `HEAD`); +- has at least one remote; +- has no merge, rebase, cherry-pick, revert, or bisect in progress; +- has no modified, staged, deleted, conflicted, or untracked files. + +Untracked paths that are themselves discovered nested Git repositories are excluded from the parent repository's dirty check. All other untracked files still block it. + +Stack Sync never runs `git stash`, `git reset`, `git clean`, `git commit`, or any other command that saves or discards work. A repository is checked again immediately before `hub sync` to reduce the chance of a change slipping in between the plan and apply phases. Blocked repositories are skipped; use `--strict` to abort the whole run if even one is blocked. + +Stack Sync intentionally preserves `hub sync` semantics. That means `hub` may delete a local branch when its upstream branch has been deleted and it considers the local branch merged. It warns instead when it finds unpushed or apparently unmerged commits. The interactive confirmation calls this out; review `hub help sync` before using `--yes` in automation. + +## Build and install + +Requires Go 1.24 or newer, Git, and [hub](https://hub.github.com/). + +```bash +cd stack-sync +go test ./... +go build -o stack-sync . +install -Dm755 stack-sync ~/.local/bin/stack-sync +``` + +## Usage + +Scan the current workspace. This is read-only and is the default command: + +```bash +stack-sync scan --root ~/Coding/jwtf +# equivalent: +stack-sync --root ~/Coding/jwtf +``` + +Review the same plan, confirm it, and sync every eligible repository: + +```bash +stack-sync sync --root ~/Coding/jwtf +``` + +For automation, suppress the prompt and optionally require the entire workspace to be clean: + +```bash +stack-sync sync --root ~/Coding/jwtf --yes --strict +``` + +Useful options: + +```text +--jobs 4 maximum concurrent inspections or syncs +--timeout 5m per-repository hub sync timeout +--exclude temp skip a directory name anywhere in the tree +--exclude Org/old skip a root-relative path +--json emit structured output +``` + +Dependency caches, build outputs, and tool-managed directories such as `node_modules`, `target`, `.claude`, and `.codex` are skipped during discovery by default. These exclusions only affect repository discovery; they never make real changes inside a discovered repository disappear from its dirty-worktree check. + +Exit codes are `0` for a successful scan or sync (planned dirty repositories may be safely skipped), `1` for an operational/sync failure, `2` for invalid or unconfirmed non-interactive use, and `3` when strict mode refuses the plan or a previously eligible repository fails its immediate pre-sync safety check. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0a3cfbf --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module jezz.wtf/stack-sync + +go 1.24 diff --git a/main.go b/main.go new file mode 100644 index 0000000..d8f548c --- /dev/null +++ b/main.go @@ -0,0 +1,572 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "time" +) + +const version = "0.1.1" + +var defaultSkippedDirs = map[string]bool{ + ".cache": true, ".claude": true, ".codex": true, ".git": true, ".next": true, ".pnpm-store": true, + ".terraform": true, ".venv": true, "build": true, "coverage": true, + "dist": true, "node_modules": true, "target": true, "vendor": true, +} + +type options struct { + root string + json bool + yes bool + strict bool + jobs int + timeout time.Duration + exclusions stringList +} + +type stringList []string + +func (s *stringList) String() string { return strings.Join(*s, ",") } +func (s *stringList) Set(v string) error { + *s = append(*s, v) + return nil +} + +type repo struct { + Path string `json:"path"` + RelativePath string `json:"relativePath"` + Branch string `json:"branch,omitempty"` + RemoteCount int `json:"remoteCount"` + Dirty []string `json:"dirty,omitempty"` + NestedRepoEntries []string `json:"nestedRepoEntries,omitempty"` + BlockReason string `json:"blockReason,omitempty"` + Eligible bool `json:"eligible"` +} + +type syncResult struct { + Path string `json:"path"` + Success bool `json:"success"` + Skipped bool `json:"skipped"` + Message string `json:"message,omitempty"` + Duration string `json:"duration,omitempty"` +} + +func main() { + code := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr) + os.Exit(code) +} + +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + command := "scan" + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { + command, args = args[0], args[1:] + } + if command == "help" { + usage(stdout) + return 0 + } + if command == "version" { + fmt.Fprintf(stdout, "stack-sync %s\n", version) + return 0 + } + if command != "scan" && command != "sync" { + fmt.Fprintf(stderr, "unknown command %q\n\n", command) + usage(stderr) + return 2 + } + + opts, err := parseFlags(command, args, stderr) + if err != nil { + return 2 + } + root, err := filepath.Abs(opts.root) + if err != nil { + fmt.Fprintf(stderr, "resolve root: %v\n", err) + return 1 + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + fmt.Fprintf(stderr, "resolve root: %v\n", err) + return 1 + } + + paths, err := discover(root, opts.exclusions) + if err != nil { + fmt.Fprintf(stderr, "discover repositories: %v\n", err) + return 1 + } + if len(paths) == 0 { + fmt.Fprintf(stderr, "no Git repositories found under %s\n", root) + return 1 + } + repos := inspectAll(root, paths, opts.jobs) + + if command == "scan" { + if opts.json { + writeJSON(stdout, repos) + } else { + printScan(stdout, root, repos) + } + return 0 + } + + if _, err := exec.LookPath("hub"); err != nil { + fmt.Fprintln(stderr, "hub is required for sync but was not found on PATH") + return 1 + } + if !opts.json { + printScan(stdout, root, repos) + } + eligible := eligibleCount(repos) + blocked := blockedCount(repos) + if eligible == 0 { + fmt.Fprintln(stderr, "nothing to sync: every repository is blocked") + return 3 + } + if opts.strict && blocked > 0 { + fmt.Fprintf(stderr, "strict mode: refusing to sync because %d repositories are blocked\n", blocked) + return 3 + } + if !opts.yes { + if opts.json || !isTerminal(os.Stdin) { + fmt.Fprintln(stderr, "refusing to sync without confirmation; pass --yes in non-interactive use") + return 2 + } + fmt.Fprintf(stdout, "\nRun hub sync in %d clean repositories? hub may delete local branches it considers merged. [y/N] ", eligible) + answer, _ := bufio.NewReader(stdin).ReadString('\n') + answer = strings.ToLower(strings.TrimSpace(answer)) + if answer != "y" && answer != "yes" { + fmt.Fprintln(stdout, "Cancelled; no repositories were changed.") + return 0 + } + } + + results := syncAll(root, repos, opts.jobs, opts.timeout, stdout, opts.json) + if opts.json { + writeJSON(stdout, results) + } else { + printSummary(stdout, results) + } + for i, result := range results { + if !result.Success && !result.Skipped { + return 1 + } + // A repository which passed the plan but failed its immediate safety + // recheck is materially different from an already-planned safe skip. + if result.Skipped && repos[i].Eligible { + return 3 + } + } + return 0 +} + +func parseFlags(command string, args []string, stderr io.Writer) (options, error) { + opts := options{root: ".", jobs: min(4, runtime.NumCPU()), timeout: 5 * time.Minute} + fs := flag.NewFlagSet(command, flag.ContinueOnError) + fs.SetOutput(stderr) + fs.StringVar(&opts.root, "root", opts.root, "workspace root to search") + fs.BoolVar(&opts.json, "json", false, "emit machine-readable JSON") + fs.IntVar(&opts.jobs, "jobs", opts.jobs, "maximum parallel inspections/syncs") + fs.Var(&opts.exclusions, "exclude", "directory name or root-relative path to skip (repeatable)") + if command == "sync" { + fs.BoolVar(&opts.yes, "yes", false, "apply without an interactive confirmation") + fs.BoolVar(&opts.strict, "strict", false, "abort the entire run if any repository is blocked") + fs.DurationVar(&opts.timeout, "timeout", opts.timeout, "timeout for each hub sync") + } + fs.Usage = func() { usage(stderr) } + if err := fs.Parse(args); err != nil { + return opts, err + } + if fs.NArg() != 0 { + err := fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " ")) + fmt.Fprintln(stderr, err) + return opts, err + } + if opts.jobs < 1 { + err := errors.New("--jobs must be at least 1") + fmt.Fprintln(stderr, err) + return opts, err + } + if opts.timeout <= 0 { + err := errors.New("--timeout must be positive") + fmt.Fprintln(stderr, err) + return opts, err + } + return opts, nil +} + +func usage(w io.Writer) { + fmt.Fprintln(w, `stack-sync safely runs hub sync across a directory tree. + +Usage: + stack-sync [scan] [options] + stack-sync sync [options] + stack-sync version + +Commands: + scan Discover repositories and report whether each is safe to sync (default) + sync Scan, confirm, re-check safety, then run hub sync in eligible repositories + +Safety: + Repositories with edits, untracked files, detached HEADs, no remotes, or an + in-progress Git operation are never synced. Untracked nested repositories do + not falsely dirty their parent. stack-sync never stashes, resets, or commits. + +Use "stack-sync -h" for command options.`) +} + +func discover(root string, exclusions []string) ([]string, error) { + excluded := make(map[string]bool, len(exclusions)) + for _, item := range exclusions { + item = filepath.Clean(item) + excluded[item] = true + } + var repos []string + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() { + return nil + } + if path != root { + rel, _ := filepath.Rel(root, path) + if defaultSkippedDirs[entry.Name()] || excluded[entry.Name()] || excluded[rel] { + return filepath.SkipDir + } + } + marker := filepath.Join(path, ".git") + if info, err := os.Stat(marker); err == nil && (info.IsDir() || info.Mode().IsRegular()) { + repos = append(repos, path) + // Continue: unrelated repositories may intentionally be nested here. + } + return nil + }) + if err != nil { + return nil, err + } + sort.Strings(repos) + return repos, nil +} + +func inspectAll(root string, paths []string, jobs int) []repo { + type item struct { + index int + path string + } + work := make(chan item) + results := make([]repo, len(paths)) + var wg sync.WaitGroup + for range min(jobs, len(paths)) { + wg.Add(1) + go func() { + defer wg.Done() + for next := range work { + results[next.index] = inspect(root, next.path, paths) + } + }() + } + for i, path := range paths { + work <- item{i, path} + } + close(work) + wg.Wait() + return results +} + +func inspect(root, path string, allRepos []string) repo { + rel, _ := filepath.Rel(root, path) + if rel == "." { + rel = filepath.Base(root) + } + r := repo{Path: path, RelativePath: rel} + + branch, err := gitOutput(path, "symbolic-ref", "--quiet", "--short", "HEAD") + if err != nil { + r.BlockReason = "detached HEAD or unborn branch" + } else { + r.Branch = strings.TrimSpace(branch) + } + remotes, remoteErr := gitOutput(path, "remote") + if remoteErr != nil { + setBlock(&r, "cannot list remotes") + } else if strings.TrimSpace(remotes) == "" { + setBlock(&r, "no remotes") + } else { + r.RemoteCount = len(strings.Fields(remotes)) + } + if operation := gitOperation(path); operation != "" { + setBlock(&r, operation+" in progress") + } + + status, err := gitBytes(path, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none") + if err != nil { + setBlock(&r, "cannot read worktree status") + return r + } + nested := nestedPaths(path, allRepos) + for _, change := range parsePorcelain(status) { + if change.code == "??" && belongsToNestedRepo(change.path, nested) { + r.NestedRepoEntries = append(r.NestedRepoEntries, change.path) + continue + } + r.Dirty = append(r.Dirty, change.code+" "+change.path) + } + if len(r.Dirty) > 0 { + setBlock(&r, fmt.Sprintf("dirty worktree (%d changes)", len(r.Dirty))) + } + r.Eligible = r.BlockReason == "" + return r +} + +func setBlock(r *repo, reason string) { + if r.BlockReason == "" { + r.BlockReason = reason + } +} + +type change struct{ code, path string } + +func parsePorcelain(data []byte) []change { + parts := bytes.Split(data, []byte{0}) + changes := make([]change, 0, len(parts)) + for i := 0; i < len(parts); i++ { + part := parts[i] + if len(part) < 4 { + continue + } + code, path := string(part[:2]), string(part[3:]) + changes = append(changes, change{code, filepath.Clean(path)}) + if (strings.Contains(code, "R") || strings.Contains(code, "C")) && i+1 < len(parts) { + i++ // porcelain v1 -z includes the original path as the next field + } + } + return changes +} + +func nestedPaths(parent string, allRepos []string) []string { + var nested []string + prefix := parent + string(filepath.Separator) + for _, candidate := range allRepos { + if strings.HasPrefix(candidate, prefix) { + rel, _ := filepath.Rel(parent, candidate) + nested = append(nested, filepath.Clean(rel)) + } + } + sort.Slice(nested, func(i, j int) bool { return len(nested[i]) < len(nested[j]) }) + return nested +} + +func belongsToNestedRepo(path string, nested []string) bool { + path = strings.TrimSuffix(filepath.Clean(path), string(filepath.Separator)) + for _, child := range nested { + if path == child || strings.HasPrefix(path, child+string(filepath.Separator)) { + return true + } + } + return false +} + +func gitOperation(path string) string { + checks := []struct{ marker, label string }{ + {"MERGE_HEAD", "merge"}, {"CHERRY_PICK_HEAD", "cherry-pick"}, {"REVERT_HEAD", "revert"}, + {"BISECT_LOG", "bisect"}, {"rebase-merge", "rebase"}, {"rebase-apply", "rebase"}, + } + for _, check := range checks { + marker, err := gitOutput(path, "rev-parse", "--git-path", check.marker) + if err != nil { + continue + } + marker = strings.TrimSpace(marker) + if !filepath.IsAbs(marker) { + marker = filepath.Join(path, marker) + } + if _, err := os.Stat(marker); err == nil { + return check.label + } + } + return "" +} + +func gitOutput(path string, args ...string) (string, error) { + data, err := gitBytes(path, args...) + return string(data), err +} + +func gitBytes(path string, args ...string) ([]byte, error) { + cmd := exec.Command("git", append([]string{"-C", path}, args...)...) + return cmd.Output() +} + +func syncAll(root string, repos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet bool) []syncResult { + type item struct { + index int + repo repo + } + work := make(chan item) + results := make([]syncResult, len(repos)) + var wg sync.WaitGroup + var outputMu sync.Mutex + allPaths := make([]string, 0, len(repos)) + for _, r := range repos { + allPaths = append(allPaths, r.Path) + } + + for range min(jobs, len(repos)) { + wg.Add(1) + go func() { + defer wg.Done() + for next := range work { + r := next.repo + if !r.Eligible { + results[next.index] = syncResult{Path: r.RelativePath, Skipped: true, Message: r.BlockReason} + continue + } + // Close the scan/apply race: inspect again immediately before mutation. + fresh := inspect(root, r.Path, allPaths) + if !fresh.Eligible { + results[next.index] = syncResult{Path: r.RelativePath, Skipped: true, Message: "pre-sync recheck: " + fresh.BlockReason} + continue + } + if !quiet { + outputMu.Lock() + fmt.Fprintf(stdout, "\nSTART %s (%s)\n", r.RelativePath, r.Branch) + outputMu.Unlock() + } + started := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + cmd := exec.CommandContext(ctx, "hub", "sync", "--color=never") + cmd.Dir = r.Path + combined, err := cmd.CombinedOutput() + cancel() + message := strings.TrimSpace(string(combined)) + if ctx.Err() == context.DeadlineExceeded { + err = ctx.Err() + message = "timed out after " + timeout.String() + } + duration := time.Since(started).Round(time.Millisecond).String() + if !quiet { + outputMu.Lock() + state := "DONE" + if err != nil { + state = "FAIL" + } + fmt.Fprintf(stdout, "%s %s (%s)\n", state, r.RelativePath, duration) + if message != "" { + fmt.Fprintln(stdout, indent(message, " ")) + } + outputMu.Unlock() + } + if err != nil && message == "" { + message = err.Error() + } + results[next.index] = syncResult{Path: r.RelativePath, Success: err == nil, Message: message, Duration: duration} + } + }() + } + for i, r := range repos { + work <- item{i, r} + } + close(work) + wg.Wait() + return results +} + +func printScan(w io.Writer, root string, repos []repo) { + fmt.Fprintf(w, "Workspace: %s\nRepositories: %d (%d ready, %d blocked)\n\n", root, len(repos), eligibleCount(repos), blockedCount(repos)) + width := len("REPOSITORY") + for _, r := range repos { + if len(r.RelativePath) > width { + width = len(r.RelativePath) + } + } + fmt.Fprintf(w, "%-*s %-10s %s\n", width, "REPOSITORY", "BRANCH", "STATUS") + for _, r := range repos { + status := "ready" + if !r.Eligible { + status = "BLOCKED: " + r.BlockReason + } + if len(r.NestedRepoEntries) > 0 { + status += fmt.Sprintf("; %d nested repo path(s) ignored", len(r.NestedRepoEntries)) + } + branch := r.Branch + if branch == "" { + branch = "-" + } + fmt.Fprintf(w, "%-*s %-10s %s\n", width, r.RelativePath, truncate(branch, 10), status) + } +} + +func printSummary(w io.Writer, results []syncResult) { + var ok, failed, skipped int + for _, r := range results { + switch { + case r.Success: + ok++ + case r.Skipped: + skipped++ + default: + failed++ + } + } + 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)) + } + } +} + +func eligibleCount(repos []repo) int { + n := 0 + for _, r := range repos { + if r.Eligible { + n++ + } + } + return n +} +func blockedCount(repos []repo) int { return len(repos) - eligibleCount(repos) } +func writeJSON(w io.Writer, value any) { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(value) +} +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n-1] + "…" +} +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} +func indent(s, prefix string) string { + // Git's progress output uses carriage returns when it believes it has a + // terminal. Normalize them so captured concurrent output cannot overwrite a + // repository label or escape its indentation. + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + return prefix + strings.ReplaceAll(s, "\n", "\n"+prefix) +} + +func isTerminal(file *os.File) bool { + info, err := file.Stat() + return err == nil && info.Mode()&os.ModeCharDevice != 0 +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..2214d04 --- /dev/null +++ b/main_test.go @@ -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) + } +}