feat(cli): add scan and sync commands
- discover nested Git repositories and skip dependency and build directories - add scan and sync commands with root, exclude, jobs, timeout, JSON, strict, and confirmation options - block detached heads, missing remotes, active Git operations, and dirty worktrees - exclude nested repositories from their parent's dirty-worktree check - recheck repository safety before running hub sync - add discovery, safety, sync, JSON, and output tests - set the embedded version to 0.1.1 and document installation and usage
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
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 <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 {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user