Files
stack-sync/main.go
T
LyAhn b80713b334 feat(sync): replace hub with an internal branch engine
Fetch and prune the preferred remote, then reconcile every local branch
independently. Fast-forward branches that are behind, preserve divergent work,
and delete a branch only when its configured upstream is gone and its commits
are already contained by the remote default branch.

Keep dirty repositories eligible while protecting the active branch and refs
checked out in linked worktrees. Preserve the previous all-clean policy behind
--strict, expose structured per-branch results, add real bare-remote integration
tests, and retain the upstream MIT notice for the adapted hub sync behavior.
2026-09-06 19:41:53 +01:00

589 lines
16 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.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
}
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"`
}
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 {
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)
}
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 strict && len(fresh.Dirty) > 0 {
results[next.index] = syncResult{Path: r.RelativePath, Skipped: true, Message: fmt.Sprintf("pre-sync recheck: dirty worktree (%d changes)", len(fresh.Dirty))}
continue
}
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()
}
results[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
}