feat(sync): remove the hub dependency

- add sync.go to fetch and prune the selected upstream, github, origin, or sole remote
- fast-forward the current branch with merge --ff-only and inactive branches with update-ref
- protect a dirty current branch and branches checked out in linked worktrees
- warn without changing divergent branches
- delete gone-upstream branches only after confirming they are merged into the remote default branch
- allow dirty repositories by default and retain the all-clean policy behind --strict
- return per-branch results in CLI, JSON, and TUI output
- add bare-remote integration tests, the hub MIT notice, and version 0.3.0
This commit is contained in:
2026-09-06 19:41:53 +01:00
parent 88143838dd
commit bf67f1a005
8 changed files with 703 additions and 170 deletions
+41 -40
View File
@@ -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("", " ")