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.
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