feat(tui): surface live sync progress and failures

Stream repository start and completion events into the Bubble Tea update loop
so long runs show elapsed time, active repositories, a progress bar, live
outcome counts, and the latest completed result.

Keep an actionable summary after completion, list failures before warnings,
focus the first failed repository automatically, and add the f shortcut for
cycling through failed, skipped, protected, or divergent repositories.
This commit is contained in:
2026-09-06 20:19:05 +01:00
parent b80713b334
commit dfc6218e45
6 changed files with 374 additions and 35 deletions
+24 -5
View File
@@ -18,7 +18,7 @@ import (
"time"
)
const version = "0.3.0"
const version = "0.3.1"
var defaultSkippedDirs = map[string]bool{
".cache": true, ".claude": true, ".codex": true, ".git": true, ".next": true, ".pnpm-store": true,
@@ -64,6 +64,12 @@ type syncResult struct {
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)
@@ -421,6 +427,10 @@ func syncAll(root string, repos []repo, jobs int, timeout time.Duration, stdout
}
func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict bool) []syncResult {
return syncSelectedWithProgress(root, repos, workspaceRepos, jobs, timeout, stdout, quiet, strict, nil)
}
func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict bool, progress func(syncProgressEvent)) []syncResult {
type item struct {
index int
repo repo
@@ -433,6 +443,12 @@ func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout t
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})
}
}
for range min(jobs, len(repos)) {
wg.Add(1)
@@ -441,19 +457,22 @@ func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout t
for next := range work {
r := next.repo
if !r.Eligible {
results[next.index] = syncResult{Path: r.RelativePath, Skipped: true, Message: r.BlockReason}
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 {
results[next.index] = syncResult{Path: r.RelativePath, Skipped: true, Message: "pre-sync recheck: " + fresh.BlockReason}
record(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))}
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()
fmt.Fprintf(stdout, "\nSTART %s (%s)\n", r.RelativePath, r.Branch)
@@ -478,7 +497,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, Branches: report.Branches}
record(next.index, syncResult{Path: r.RelativePath, Success: err == nil, Message: message, Duration: duration, Branches: report.Branches})
}
}()
}