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 }