package main import ( "bufio" "bytes" "encoding/json" "errors" "flag" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "sort" "strings" "sync" "time" "unicode/utf8" ) const version = "0.4.0" 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 createMissing bool } 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"` Branches []branchSyncResult `json:"branches,omitempty"` } type syncProgressEvent struct { Path string Started bool Result syncResult } 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" && command != "tui" { 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 == "tui" { if err := runTUI(root, repos, opts); err != nil { fmt.Fprintf(stderr, "run TUI: %v\n", err) return 1 } return 0 } if command == "scan" { if opts.json { writeJSON(stdout, repos) } else { printScan(stdout, root, repos) } return 0 } if !opts.json { printScan(stdout, root, repos) } eligible := eligibleCount(repos) if eligible == 0 { fmt.Fprintln(stderr, "nothing to sync: every repository is blocked") return 3 } strictUnsafe := strictUnsafeCount(repos) if opts.strict && strictUnsafe > 0 { fmt.Fprintf(stderr, "strict mode: refusing to sync because %d repositories are blocked or dirty\n", strictUnsafe) 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, "\nSynchronize branches in %d repositories? Merged branches whose upstream was deleted may be removed. [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 } } var liveMu sync.Mutex live := newLiveProgress(stdout, &liveMu, len(repos)) results := syncAllWithLive(root, repos, opts.jobs, opts.timeout, stdout, opts.json, opts.strict, opts.createMissing, live) liveMu.Lock() live.clear() liveMu.Unlock() 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 or dirty") } if command == "sync" || command == "tui" { fs.DurationVar(&opts.timeout, "timeout", opts.timeout, "timeout for each repository sync") fs.BoolVar(&opts.createMissing, "create-missing", false, "create local branches that exist only on the remote") } fs.Usage = func() { usage(stderr) // PrintDefaults was dropped along with the stock usage function, which // left "use -h for command options" promising flags that // were never actually listed. fmt.Fprintf(stderr, "\nOptions for %s:\n", command) fs.PrintDefaults() } 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 synchronizes Git branches across a directory tree. Usage: stack-sync [scan] [options] stack-sync sync [options] stack-sync tui [options] stack-sync version Commands: scan Discover repositories and report whether each is safe to sync (default) sync Scan, confirm, re-check safety, then sync branches in eligible repositories tui Interactively review, select, and sync repositories Safety: Dirty worktrees are reported but remain eligible: their checked-out branch is protected while safe inactive branches are synchronized. Detached HEADs, repositories without remotes, and in-progress Git operations remain blocked. stack-sync never stashes, resets, cleans, or commits. Branch handling: Outdated local branches are fast-forwarded, and a local branch whose upstream was deleted is removed once it is merged into the remote default branch. A local branch with no upstream and no matching remote branch cannot be fast-forwarded, so it is reported as UNTRACKED and left alone. Pass --create-missing to also create branches that exist only on the remote. 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) } 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, strict, createMissing bool) []syncResult { return syncSelected(root, repos, repos, jobs, timeout, stdout, quiet, strict, createMissing, nil) } // syncAllWithLive is syncAll with the in-place progress line attached. func syncAllWithLive(root string, repos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict, createMissing bool, live *liveProgress) []syncResult { return syncSelected(root, repos, repos, jobs, timeout, stdout, quiet, strict, createMissing, live) } func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict, createMissing bool, live *liveProgress) []syncResult { return syncSelectedWithProgress(root, repos, workspaceRepos, jobs, timeout, stdout, quiet, strict, createMissing, live, nil) } func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict, createMissing bool, live *liveProgress, progress func(syncProgressEvent)) []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(workspaceRepos)) for _, r := range workspaceRepos { allPaths = append(allPaths, r.Path) } record := func(index int, result syncResult) { results[index] = result if progress != nil { progress(syncProgressEvent{Path: result.Path, Result: result}) } live.update(func() { live.finished(result); live.draw() }) } for range min(jobs, len(repos)) { wg.Add(1) go func() { defer wg.Done() for next := range work { r := next.repo if !r.Eligible { record(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 { record(next.index, syncResult{Path: r.RelativePath, Skipped: true, Message: "pre-sync recheck: " + fresh.BlockReason}) continue } if strict && len(fresh.Dirty) > 0 { record(next.index, syncResult{Path: r.RelativePath, Skipped: true, Message: fmt.Sprintf("pre-sync recheck: dirty worktree (%d changes)", len(fresh.Dirty))}) continue } if progress != nil { progress(syncProgressEvent{Path: r.RelativePath, Started: true}) } if !quiet { outputMu.Lock() live.clear() fmt.Fprintf(stdout, "\nSTART %s (%s)\n", r.RelativePath, r.Branch) outputMu.Unlock() } live.update(func() { live.started(); live.draw() }) started := time.Now() report, err := syncRepositoryWith(r.Path, timeout, len(fresh.Dirty) > 0, fresh.NestedRepoEntries, syncOptions{CreateMissing: createMissing}) message := report.Message duration := time.Since(started).Round(time.Millisecond).String() if !quiet { outputMu.Lock() live.clear() 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() } record(next.index, syncResult{Path: r.RelativePath, Success: err == nil, Message: message, Duration: duration, Branches: report.Branches}) } }() } 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 } else if len(r.Dirty) > 0 { status = fmt.Sprintf("ready; dirty worktree (%d changes), checked-out branch protected", len(r.Dirty)) } 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) } } // actionOrder fixes the order actions are summarised in, so the same workspace // always reads the same way from one run to the next. var actionOrder = []string{"updated", "deleted", "created", "protected", "warning", "untracked"} // liveProgress redraws a single line in place while repositories sync, so // workspace totals are visible without waiting for the final summary. It is only // enabled when stdout is a terminal: writing cursor control into a pipe or a // --json document would corrupt the captured output, so callers get a nil // *liveProgress in those cases and every method here is a no-op. type liveProgress struct { w io.Writer mu *sync.Mutex total int done int active int changed int failed int skipped int counts map[string]int lastWidth int } func newLiveProgress(w io.Writer, mu *sync.Mutex, total int) *liveProgress { if !isTerminalWriter(w) { return nil } return &liveProgress{w: w, mu: mu, total: total, counts: make(map[string]int, len(actionOrder))} } // started records that a repository has begun syncing. func (p *liveProgress) started() { if p == nil { return } p.active++ } // finished folds a completed repository into the running totals. func (p *liveProgress) finished(result syncResult) { if p == nil { return } p.done++ p.active-- for _, branch := range result.Branches { p.counts[branch.Action]++ } switch { case result.Skipped: p.skipped++ case !result.Success: p.failed++ case repositoryChanged(result): p.changed++ } } func (p *liveProgress) text() string { parts := []string{fmt.Sprintf("%d/%d", p.done, p.total)} if p.active > 0 { parts = append(parts, fmt.Sprintf("%d active", p.active)) } parts = append(parts, fmt.Sprintf("%d changed", p.changed)) if line := formatActionCounts(p.counts); line != "" { parts = append(parts, line) } if p.skipped > 0 { parts = append(parts, fmt.Sprintf("%d skipped", p.skipped)) } if p.failed > 0 { parts = append(parts, fmt.Sprintf("%d FAILED", p.failed)) } return " " + strings.Join(parts, " · ") } // draw repaints the line. Callers must already hold p.mu when drawing. func (p *liveProgress) draw() { if p == nil { return } line := p.text() fmt.Fprintf(p.w, "\r%s\r%s", strings.Repeat(" ", p.lastWidth), line) p.lastWidth = utf8.RuneCountInString(line) } // clear erases the line so ordinary output can be written on top of it. func (p *liveProgress) clear() { if p == nil { return } p.lastWidth = 0 } func (p *liveProgress) update(fn func()) { if p == nil { return } p.mu.Lock() defer p.mu.Unlock() fn() } func printSummary(w io.Writer, results []syncResult) { counts := make(map[string]int, len(actionOrder)) var changed, unchanged, failed, skipped int for _, r := range results { for _, branch := range r.Branches { counts[branch.Action]++ } switch { case r.Skipped: skipped++ case !r.Success: failed++ case repositoryChanged(r): changed++ default: unchanged++ } } // "synced" counted every repository that ran, including ones where nothing // was actually touched, so a run that changed nothing still looked busy. fmt.Fprintf(w, "\nSummary: %d changed, %d unchanged, %d failed, %d skipped\n", changed, unchanged, failed, skipped) if line := formatActionCounts(counts); line != "" { fmt.Fprintf(w, " %s\n", line) } // Deletions and failures are destructive or unexpected, so they are listed // in full here rather than left to be found in the scrolling log above. if failed > 0 || counts["deleted"] > 0 { fmt.Fprintln(w, "\nNeeds review:") for _, r := range results { if !r.Success && !r.Skipped { fmt.Fprintf(w, " FAILED %s: %s\n", r.Path, firstLine(r.Message)) } for _, branch := range r.Branches { if branch.Action == "deleted" { fmt.Fprintf(w, " DELETED %s / %s: %s\n", r.Path, branch.Branch, branch.Message) } } } } } func repositoryChanged(r syncResult) bool { for _, branch := range r.Branches { switch branch.Action { case "updated", "deleted", "created": return true } } return false } func formatActionCounts(counts map[string]int) string { parts := make([]string, 0, len(actionOrder)) for _, action := range actionOrder { if n := counts[action]; n > 0 { parts = append(parts, fmt.Sprintf("%d %s", n, action)) } } return strings.Join(parts, " · ") } 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 strictUnsafeCount(repos []repo) int { n := 0 for _, r := range repos { if !r.Eligible || len(r.Dirty) > 0 { n++ } } return n } 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 } // isTerminalWriter reports whether w is a real terminal, so callers can avoid // emitting cursor control sequences into redirected or buffered output. func isTerminalWriter(w io.Writer) bool { file, ok := w.(*os.File) return ok && isTerminal(file) }