Files
stack-sync/main.go
T
LyAhn 8e86b7eadb feat: stream detailed TUI sync progress
Show live repository progress, elapsed time, active work, and recent outcomes during long syncs. Add a persistent post-run summary that prioritizes failures, reports protected and divergent branches, focuses the first issue, and lets users cycle through attention items with f.
2026-09-06 20:19:05 +01:00

608 lines
17 KiB
Go

package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
)
const version = "0.3.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"`
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
}
}
results := syncAll(root, repos, opts.jobs, opts.timeout, stdout, opts.json, opts.strict)
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.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 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.
Use "stack-sync <command> -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 bool) []syncResult {
return syncSelected(root, repos, repos, jobs, timeout, stdout, quiet, strict)
}
func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict bool) []syncResult {
return syncSelectedWithProgress(root, repos, workspaceRepos, jobs, timeout, stdout, quiet, strict, nil)
}
func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict bool, 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})
}
}
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()
fmt.Fprintf(stdout, "\nSTART %s (%s)\n", r.RelativePath, r.Branch)
outputMu.Unlock()
}
started := time.Now()
report, err := syncRepository(r.Path, timeout, len(fresh.Dirty) > 0, fresh.NestedRepoEntries)
message := report.Message
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()
}
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)
}
}
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 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
}