a221c63d94
Two defects in the in-place progress line introduced with the reporting work: - started() only ran for repositories that actually synced, but finished() decrements for every repository that records a result. Any repository skipped by the eligibility or pre-sync checks therefore decremented a counter it had never incremented, so the running "active" count went negative and was displayed as such. Count a repository as active before the skip checks can exit. - clear() mutates the line width but ran under outputMu, while draw() mutates the same field under the progress mutex. With parallel jobs one worker could erase the line while another repainted it. Route clear() through the same lock, keeping outputMu around the surrounding writes. Verified by reverting the first fix and watching the new test report "active = -3". 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
793 lines
23 KiB
Go
793 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const version = "0.4.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
|
|
createMissing bool
|
|
}
|
|
|
|
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"`
|
|
Branches []branchSyncResult `json:"branches,omitempty"`
|
|
}
|
|
|
|
type syncProgressEvent struct {
|
|
Path string
|
|
Started bool
|
|
Result syncResult
|
|
}
|
|
|
|
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 !opts.json {
|
|
printScan(stdout, root, repos)
|
|
}
|
|
eligible := eligibleCount(repos)
|
|
if eligible == 0 {
|
|
fmt.Fprintln(stderr, "nothing to sync: every repository is blocked")
|
|
return 3
|
|
}
|
|
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 {
|
|
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, "\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" {
|
|
fmt.Fprintln(stdout, "Cancelled; no repositories were changed.")
|
|
return 0
|
|
}
|
|
}
|
|
|
|
var liveMu sync.Mutex
|
|
live := newLiveProgress(stdout, &liveMu, len(repos))
|
|
results := syncAllWithLive(root, repos, opts.jobs, opts.timeout, stdout, opts.json, opts.strict, opts.createMissing, live)
|
|
liveMu.Lock()
|
|
live.clear()
|
|
liveMu.Unlock()
|
|
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 or dirty")
|
|
}
|
|
if command == "sync" || command == "tui" {
|
|
fs.DurationVar(&opts.timeout, "timeout", opts.timeout, "timeout for each repository sync")
|
|
fs.BoolVar(&opts.createMissing, "create-missing", false, "create local branches that exist only on the remote")
|
|
}
|
|
fs.Usage = func() {
|
|
usage(stderr)
|
|
// PrintDefaults was dropped along with the stock usage function, which
|
|
// left "use <command> -h for command options" promising flags that
|
|
// were never actually listed.
|
|
fmt.Fprintf(stderr, "\nOptions for %s:\n", command)
|
|
fs.PrintDefaults()
|
|
}
|
|
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 synchronizes Git branches 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 sync branches in eligible repositories
|
|
tui Interactively review, select, and sync repositories
|
|
|
|
Safety:
|
|
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.
|
|
|
|
Branch handling:
|
|
Outdated local branches are fast-forwarded, and a local branch whose upstream
|
|
was deleted is removed once it is merged into the remote default branch.
|
|
A local branch with no upstream and no matching remote branch cannot be
|
|
fast-forwarded, so it is reported as UNTRACKED and left alone. Pass
|
|
--create-missing to also create branches that exist only on the remote.
|
|
|
|
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)
|
|
}
|
|
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, strict, createMissing bool) []syncResult {
|
|
return syncSelected(root, repos, repos, jobs, timeout, stdout, quiet, strict, createMissing, nil)
|
|
}
|
|
|
|
// syncAllWithLive is syncAll with the in-place progress line attached.
|
|
func syncAllWithLive(root string, repos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict, createMissing bool, live *liveProgress) []syncResult {
|
|
return syncSelected(root, repos, repos, jobs, timeout, stdout, quiet, strict, createMissing, live)
|
|
}
|
|
|
|
func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict, createMissing bool, live *liveProgress) []syncResult {
|
|
return syncSelectedWithProgress(root, repos, workspaceRepos, jobs, timeout, stdout, quiet, strict, createMissing, live, nil)
|
|
}
|
|
|
|
func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict, createMissing bool, live *liveProgress, progress func(syncProgressEvent)) []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)
|
|
}
|
|
record := func(index int, result syncResult) {
|
|
results[index] = result
|
|
if progress != nil {
|
|
progress(syncProgressEvent{Path: result.Path, Result: result})
|
|
}
|
|
live.update(func() { live.finished(result); live.draw() })
|
|
}
|
|
|
|
for range min(jobs, len(repos)) {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for next := range work {
|
|
r := next.repo
|
|
// Counted active before any skip path can record: finished()
|
|
// decrements again, so an early exit that skipped started()
|
|
// would drive the active count negative.
|
|
live.update(func() { live.started(); live.draw() })
|
|
if !r.Eligible {
|
|
record(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 {
|
|
record(next.index, syncResult{Path: r.RelativePath, Skipped: true, Message: "pre-sync recheck: " + fresh.BlockReason})
|
|
continue
|
|
}
|
|
if strict && len(fresh.Dirty) > 0 {
|
|
record(next.index, syncResult{Path: r.RelativePath, Skipped: true, Message: fmt.Sprintf("pre-sync recheck: dirty worktree (%d changes)", len(fresh.Dirty))})
|
|
continue
|
|
}
|
|
if progress != nil {
|
|
progress(syncProgressEvent{Path: r.RelativePath, Started: true})
|
|
}
|
|
if !quiet {
|
|
outputMu.Lock()
|
|
live.update(func() { live.clear() })
|
|
fmt.Fprintf(stdout, "\nSTART %s (%s)\n", r.RelativePath, r.Branch)
|
|
outputMu.Unlock()
|
|
}
|
|
started := time.Now()
|
|
report, err := syncRepositoryWith(r.Path, timeout, len(fresh.Dirty) > 0, fresh.NestedRepoEntries, syncOptions{CreateMissing: createMissing})
|
|
message := report.Message
|
|
duration := time.Since(started).Round(time.Millisecond).String()
|
|
if !quiet {
|
|
outputMu.Lock()
|
|
live.update(func() { live.clear() })
|
|
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()
|
|
}
|
|
record(next.index, syncResult{Path: r.RelativePath, Success: err == nil, Message: message, Duration: duration, Branches: report.Branches})
|
|
}
|
|
}()
|
|
}
|
|
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
|
|
} 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))
|
|
}
|
|
branch := r.Branch
|
|
if branch == "" {
|
|
branch = "-"
|
|
}
|
|
fmt.Fprintf(w, "%-*s %-10s %s\n", width, r.RelativePath, truncate(branch, 10), status)
|
|
}
|
|
}
|
|
|
|
// actionOrder fixes the order actions are summarised in, so the same workspace
|
|
// always reads the same way from one run to the next.
|
|
var actionOrder = []string{"updated", "deleted", "created", "protected", "warning", "untracked"}
|
|
|
|
// liveProgress redraws a single line in place while repositories sync, so
|
|
// workspace totals are visible without waiting for the final summary. It is only
|
|
// enabled when stdout is a terminal: writing cursor control into a pipe or a
|
|
// --json document would corrupt the captured output, so callers get a nil
|
|
// *liveProgress in those cases and every method here is a no-op.
|
|
type liveProgress struct {
|
|
w io.Writer
|
|
mu *sync.Mutex
|
|
total int
|
|
|
|
done int
|
|
active int
|
|
changed int
|
|
failed int
|
|
skipped int
|
|
counts map[string]int
|
|
lastWidth int
|
|
}
|
|
|
|
func newLiveProgress(w io.Writer, mu *sync.Mutex, total int) *liveProgress {
|
|
if !isTerminalWriter(w) {
|
|
return nil
|
|
}
|
|
return &liveProgress{w: w, mu: mu, total: total, counts: make(map[string]int, len(actionOrder))}
|
|
}
|
|
|
|
// started records that a repository has begun syncing.
|
|
func (p *liveProgress) started() {
|
|
if p == nil {
|
|
return
|
|
}
|
|
p.active++
|
|
}
|
|
|
|
// finished folds a completed repository into the running totals.
|
|
func (p *liveProgress) finished(result syncResult) {
|
|
if p == nil {
|
|
return
|
|
}
|
|
p.done++
|
|
p.active--
|
|
for _, branch := range result.Branches {
|
|
p.counts[branch.Action]++
|
|
}
|
|
switch {
|
|
case result.Skipped:
|
|
p.skipped++
|
|
case !result.Success:
|
|
p.failed++
|
|
case repositoryChanged(result):
|
|
p.changed++
|
|
}
|
|
}
|
|
|
|
func (p *liveProgress) text() string {
|
|
parts := []string{fmt.Sprintf("%d/%d", p.done, p.total)}
|
|
if p.active > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d active", p.active))
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%d changed", p.changed))
|
|
if line := formatActionCounts(p.counts); line != "" {
|
|
parts = append(parts, line)
|
|
}
|
|
if p.skipped > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d skipped", p.skipped))
|
|
}
|
|
if p.failed > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d FAILED", p.failed))
|
|
}
|
|
return " " + strings.Join(parts, " · ")
|
|
}
|
|
|
|
// draw repaints the line. Callers must already hold p.mu when drawing.
|
|
func (p *liveProgress) draw() {
|
|
if p == nil {
|
|
return
|
|
}
|
|
line := p.text()
|
|
fmt.Fprintf(p.w, "\r%s\r%s", strings.Repeat(" ", p.lastWidth), line)
|
|
p.lastWidth = utf8.RuneCountInString(line)
|
|
}
|
|
|
|
// clear erases the line so ordinary output can be written on top of it.
|
|
func (p *liveProgress) clear() {
|
|
if p == nil {
|
|
return
|
|
}
|
|
p.lastWidth = 0
|
|
}
|
|
|
|
func (p *liveProgress) update(fn func()) {
|
|
if p == nil {
|
|
return
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
fn()
|
|
}
|
|
|
|
func printSummary(w io.Writer, results []syncResult) {
|
|
counts := make(map[string]int, len(actionOrder))
|
|
var changed, unchanged, failed, skipped int
|
|
for _, r := range results {
|
|
for _, branch := range r.Branches {
|
|
counts[branch.Action]++
|
|
}
|
|
switch {
|
|
case r.Skipped:
|
|
skipped++
|
|
case !r.Success:
|
|
failed++
|
|
case repositoryChanged(r):
|
|
changed++
|
|
default:
|
|
unchanged++
|
|
}
|
|
}
|
|
// "synced" counted every repository that ran, including ones where nothing
|
|
// was actually touched, so a run that changed nothing still looked busy.
|
|
fmt.Fprintf(w, "\nSummary: %d changed, %d unchanged, %d failed, %d skipped\n", changed, unchanged, failed, skipped)
|
|
if line := formatActionCounts(counts); line != "" {
|
|
fmt.Fprintf(w, " %s\n", line)
|
|
}
|
|
// Deletions and failures are destructive or unexpected, so they are listed
|
|
// in full here rather than left to be found in the scrolling log above.
|
|
if failed > 0 || counts["deleted"] > 0 {
|
|
fmt.Fprintln(w, "\nNeeds review:")
|
|
for _, r := range results {
|
|
if !r.Success && !r.Skipped {
|
|
fmt.Fprintf(w, " FAILED %s: %s\n", r.Path, firstLine(r.Message))
|
|
}
|
|
for _, branch := range r.Branches {
|
|
if branch.Action == "deleted" {
|
|
fmt.Fprintf(w, " DELETED %s / %s: %s\n", r.Path, branch.Branch, branch.Message)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func repositoryChanged(r syncResult) bool {
|
|
for _, branch := range r.Branches {
|
|
switch branch.Action {
|
|
case "updated", "deleted", "created":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func formatActionCounts(counts map[string]int) string {
|
|
parts := make([]string, 0, len(actionOrder))
|
|
for _, action := range actionOrder {
|
|
if n := counts[action]; n > 0 {
|
|
parts = append(parts, fmt.Sprintf("%d %s", n, action))
|
|
}
|
|
}
|
|
return strings.Join(parts, " · ")
|
|
}
|
|
|
|
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 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("", " ")
|
|
_ = 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
|
|
}
|
|
|
|
// isTerminalWriter reports whether w is a real terminal, so callers can avoid
|
|
// emitting cursor control sequences into redirected or buffered output.
|
|
func isTerminalWriter(w io.Writer) bool {
|
|
file, ok := w.(*os.File)
|
|
return ok && isTerminal(file)
|
|
}
|