feat: own the branch synchronization engine
Replace the external hub sync dependency with an attributed internal implementation that fetches and updates branches independently. Allow dirty repositories while protecting affected checked-out branches, retain all-clean strict mode, add real-remote safety tests, and update the CLI, TUI, JSON output, version, and documentation.
This commit is contained in:
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
@@ -19,7 +18,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const version = "0.2.0"
|
||||
const version = "0.3.0"
|
||||
|
||||
var defaultSkippedDirs = map[string]bool{
|
||||
".cache": true, ".claude": true, ".codex": true, ".git": true, ".next": true, ".pnpm-store": true,
|
||||
@@ -57,11 +56,12 @@ type repo struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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() {
|
||||
@@ -130,21 +130,17 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
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)
|
||||
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 {
|
||||
@@ -152,7 +148,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
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)
|
||||
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" {
|
||||
@@ -161,7 +157,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
}
|
||||
}
|
||||
|
||||
results := syncAll(root, repos, opts.jobs, opts.timeout, stdout, opts.json)
|
||||
results := syncAll(root, repos, opts.jobs, opts.timeout, stdout, opts.json, opts.strict)
|
||||
if opts.json {
|
||||
writeJSON(stdout, results)
|
||||
} else {
|
||||
@@ -190,10 +186,10 @@ func parseFlags(command string, args []string, stderr io.Writer) (options, error
|
||||
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.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 hub sync")
|
||||
fs.DurationVar(&opts.timeout, "timeout", opts.timeout, "timeout for each repository sync")
|
||||
}
|
||||
fs.Usage = func() { usage(stderr) }
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -218,7 +214,7 @@ func parseFlags(command string, args []string, stderr io.Writer) (options, error
|
||||
}
|
||||
|
||||
func usage(w io.Writer) {
|
||||
fmt.Fprintln(w, `stack-sync safely runs hub sync across a directory tree.
|
||||
fmt.Fprintln(w, `stack-sync safely synchronizes Git branches across a directory tree.
|
||||
|
||||
Usage:
|
||||
stack-sync [scan] [options]
|
||||
@@ -228,13 +224,14 @@ Usage:
|
||||
|
||||
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
|
||||
sync Scan, confirm, re-check safety, then sync branches 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.
|
||||
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.`)
|
||||
}
|
||||
@@ -336,9 +333,6 @@ func inspect(root, path string, allRepos []string) repo {
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -422,11 +416,11 @@ func gitBytes(path string, args ...string) ([]byte, error) {
|
||||
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 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 bool) []syncResult {
|
||||
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
|
||||
@@ -456,22 +450,18 @@ func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout t
|
||||
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()
|
||||
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()
|
||||
}
|
||||
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()
|
||||
@@ -488,7 +478,7 @@ func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout t
|
||||
if err != nil && message == "" {
|
||||
message = err.Error()
|
||||
}
|
||||
results[next.index] = syncResult{Path: r.RelativePath, Success: err == nil, Message: message, Duration: duration}
|
||||
results[next.index] = syncResult{Path: r.RelativePath, Success: err == nil, Message: message, Duration: duration, Branches: report.Branches}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -513,6 +503,8 @@ func printScan(w io.Writer, root string, repos []repo) {
|
||||
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))
|
||||
@@ -555,6 +547,15 @@ func eligibleCount(repos []repo) int {
|
||||
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("", " ")
|
||||
|
||||
Reference in New Issue
Block a user