e397da2733
- add the tui command using Bubble Tea and Lip Gloss - preselect eligible repositories and disable selection for blocked repositories - add keyboard controls for navigation, selection, refresh, sync, and cancellation - require confirmation before sync and show the branch-deletion warning - show dirty paths, block reasons, and hub output for the focused repository - add TUI model tests and set the embedded version to 0.2.0
588 lines
16 KiB
Go
588 lines
16 KiB
Go
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.2.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"`
|
|
}
|
|
|
|
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 _, 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")
|
|
}
|
|
if command == "sync" || command == "tui" {
|
|
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 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 run hub sync in eligible repositories
|
|
tui Interactively review, select, and sync 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 <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)
|
|
}
|
|
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 {
|
|
return syncSelected(root, repos, repos, jobs, timeout, stdout, quiet)
|
|
}
|
|
|
|
func syncSelected(root string, repos, workspaceRepos []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(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 !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
|
|
}
|