feat(tui): show per-repository sync progress

- emit start and finish events from the concurrent sync worker pool
- show elapsed time, a progress bar, active repositories, result counts, and the latest result
- retain failures, skips, protected branches, and divergence warnings after the run
- focus the first failed repository and add f to cycle through repositories requiring attention
- add progress and summary tests, document the new controls, and set version 0.3.1
This commit is contained in:
2026-09-06 20:19:05 +01:00
parent bf67f1a005
commit 4f6e9c9991
6 changed files with 374 additions and 35 deletions
+1
View File
@@ -1,2 +1,3 @@
/stack-sync
/stack-sync.exe
/stack-sync.exe~
+3 -1
View File
@@ -78,7 +78,9 @@ PowerShell accepts the same options with a Windows path:
stack-sync tui --root C:\Users\you\Coding\jwtf
```
Every eligible repository starts selected. Dirty repositories remain selectable and are labelled `ready (dirty)`; their checked-out branches are protected if an update would affect them. Move with the arrow keys or `j`/`k`, toggle the focused repository with Space, select all with `a`, clear the selection with `n`, and refresh with `r`. Press `s` or Enter to review the branch-deletion warning, then `y` to begin. Blocked repositories cannot be selected. After a run, focusing a repository shows its latest per-branch results.
Every eligible repository starts selected. Dirty repositories remain selectable and are labelled `ready (dirty)`; their checked-out branches are protected if an update would affect them. Move with the arrow keys or `j`/`k`, toggle the focused repository with Space, select all with `a`, clear the selection with `n`, and refresh with `r`. Press `s` or Enter to review the branch-deletion warning, then `y` to begin. Blocked repositories cannot be selected.
During synchronization, the TUI shows overall progress, elapsed time, active repositories, live success/failure/skip totals, and the latest completed result. The final view lists failures and protected or divergent branches, prioritizes and focuses the first failure automatically, and retains per-repository branch details. Press `f` to cycle through every repository that needs attention.
### CLI and automation
+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})
}
}()
}
+7 -1
View File
@@ -123,10 +123,16 @@ func TestStrictSyncRechecksAndSkipsNewlyDirtyRepository(t *testing.T) {
t.Fatal(err)
}
results := syncAll(root, []repo{planned}, 1, time.Minute, io.Discard, true, true)
var progress []syncProgressEvent
results := syncSelectedWithProgress(root, []repo{planned}, []repo{planned}, 1, time.Minute, io.Discard, true, true, func(event syncProgressEvent) {
progress = append(progress, event)
})
if len(results) != 1 || !results[0].Skipped || results[0].Success {
t.Fatalf("syncAll() = %+v", results)
}
if len(progress) != 1 || progress[0].Started || !progress[0].Result.Skipped {
t.Fatalf("progress events = %+v, want one finished skip", progress)
}
}
func TestScanReportsDirtyEligibleRepository(t *testing.T) {
+253 -13
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"io"
"sort"
"strings"
"time"
@@ -40,6 +41,8 @@ type syncDoneMsg struct {
repos []repo
err error
}
type syncStartedMsg struct{ path string }
type syncFinishedMsg struct{ result syncResult }
type tuiModel struct {
root string
@@ -54,6 +57,15 @@ type tuiModel struct {
frame int
status string
lastResult []syncResult
syncEvents <-chan tea.Msg
syncTotal int
syncDone int
syncOK int
syncFailed int
syncSkipped int
syncActive map[string]bool
syncRecent []syncResult
syncStarted time.Time
}
func runTUI(root string, repos []repo, opts options) error {
@@ -95,8 +107,28 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = "Workspace refreshed"
m.clampViewport()
return m, nil
case syncStartedMsg:
m.syncActive[msg.path] = true
return m, waitSyncEventCmd(m.syncEvents)
case syncFinishedMsg:
delete(m.syncActive, msg.result.Path)
m.syncDone++
switch {
case msg.result.Success:
m.syncOK++
case msg.result.Skipped:
m.syncSkipped++
default:
m.syncFailed++
}
m.syncRecent = append(m.syncRecent, msg.result)
if len(m.syncRecent) > 4 {
m.syncRecent = m.syncRecent[len(m.syncRecent)-4:]
}
return m, waitSyncEventCmd(m.syncEvents)
case syncDoneMsg:
m.mode = modeReady
m.syncEvents = nil
m.lastResult = msg.results
if msg.err != nil {
m.status = "Sync failed: " + msg.err.Error()
@@ -116,6 +148,10 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
m.status = fmt.Sprintf("Finished: %d synced, %d failed, %d safety-skipped", ok, failed, skipped)
if path := firstAttentionPath(msg.results); path != "" {
m.focusPath(path)
m.status += "; focused first issue"
}
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
@@ -136,8 +172,16 @@ func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
case "y", "Y":
selected := m.selectedRepos()
m.mode = modeSyncing
m.status = fmt.Sprintf("Syncing %d repositories", len(selected))
return m, tea.Batch(m.syncCmd(selected), tickCmd())
m.status = ""
m.lastResult = nil
m.syncTotal = len(selected)
m.syncDone, m.syncOK, m.syncFailed, m.syncSkipped = 0, 0, 0, 0
m.syncActive = make(map[string]bool)
m.syncRecent = nil
m.syncStarted = time.Now()
events := make(chan tea.Msg, max(4, len(selected)*2+1))
m.syncEvents = events
return m, tea.Batch(m.startSyncCmd(selected, events), tickCmd())
case "n", "N", "esc", "q":
m.mode = modeReady
m.status = "Sync cancelled; nothing changed"
@@ -176,6 +220,10 @@ func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
case "r":
m.status = "Refreshing workspace…"
return m, m.scanCmd()
case "f":
if !m.focusNextAttention() {
m.status = "The last run has no failures, skips, warnings, or protected branches"
}
case "s", "enter":
count := len(m.selectedRepos())
if count == 0 {
@@ -253,16 +301,17 @@ func (m tuiModel) View() string {
b.WriteString("\n")
b.WriteString(mutedStyle.Render("Dirty checked-out branches are protected; merged branches with deleted upstreams may be removed. y confirm • n/esc cancel"))
case modeSyncing:
spinner := []string{"◐", "◓", "◑", "◒"}[m.frame%4]
b.WriteString(titleStyle.Render(fmt.Sprintf("%s Syncing %d repositories…", spinner, selected)))
b.WriteString("\n")
b.WriteString(mutedStyle.Render("Safety is rechecked immediately before each repository. Please wait."))
b.WriteString(m.syncProgressView(contentWidth))
default:
if m.status != "" {
b.WriteString(m.status)
b.WriteString("\n")
}
b.WriteString(mutedStyle.Render("↑/k ↓/j move • space toggle • a all • n none • s/enter sync • r refresh • q quit"))
if len(m.lastResult) > 0 {
b.WriteString(m.lastRunView(contentWidth))
b.WriteString("\n")
}
b.WriteString(mutedStyle.Render("↑/k ↓/j move • space toggle • a all • n none • s/enter sync • r refresh • f next issue • q quit"))
}
return panel.Width(contentWidth).Render(b.String())
}
@@ -270,7 +319,13 @@ func (m tuiModel) View() string {
type rowRange struct{ start, end int }
func (m tuiModel) visibleRows() rowRange {
available := m.height - 15
reserved := 15
if m.mode == modeSyncing {
reserved = 20
} else if len(m.lastResult) > 0 {
reserved = 20
}
available := m.height - reserved
if available < 4 {
available = 4
}
@@ -284,7 +339,11 @@ func (m *tuiModel) clampViewport() {
return
}
m.cursor = max(0, min(m.cursor, len(m.repos)-1))
available := max(4, m.height-15)
reserved := 15
if m.mode == modeSyncing || len(m.lastResult) > 0 {
reserved = 20
}
available := max(4, m.height-reserved)
if m.cursor < m.offset {
m.offset = m.cursor
}
@@ -373,17 +432,198 @@ func (m tuiModel) scanCmd() tea.Cmd {
}
}
func (m tuiModel) syncCmd(selected []repo) tea.Cmd {
func (m tuiModel) startSyncCmd(selected []repo, events chan tea.Msg) tea.Cmd {
return func() tea.Msg {
results := syncSelected(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true, false)
go func() {
results := syncSelectedWithProgress(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true, false, func(event syncProgressEvent) {
if event.Started {
events <- syncStartedMsg{path: event.Path}
} else {
events <- syncFinishedMsg{result: event.Result}
}
})
paths, err := discover(m.root, m.opts.exclusions)
if err != nil {
return syncDoneMsg{results: results, err: err}
events <- syncDoneMsg{results: results, err: err}
return
}
return syncDoneMsg{results: results, repos: inspectAll(m.root, paths, m.opts.jobs)}
events <- syncDoneMsg{results: results, repos: inspectAll(m.root, paths, m.opts.jobs)}
}()
return <-events
}
}
func waitSyncEventCmd(events <-chan tea.Msg) tea.Cmd {
if events == nil {
return nil
}
return func() tea.Msg { return <-events }
}
func (m tuiModel) syncProgressView(width int) string {
spinner := []string{"◐", "◓", "◑", "◒"}[m.frame%4]
phase := "Synchronizing repositories"
if m.syncDone == m.syncTotal && m.syncTotal > 0 {
phase = "Refreshing workspace"
}
elapsed := time.Since(m.syncStarted).Round(time.Second)
if elapsed < 0 {
elapsed = 0
}
barWidth := max(12, min(32, width-30))
filled := 0
if m.syncTotal > 0 {
filled = barWidth * m.syncDone / m.syncTotal
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
var lines []string
lines = append(lines, titleStyle.Render(fmt.Sprintf("%s %s", spinner, phase))+mutedStyle.Render(" "+elapsed.String()))
lines = append(lines, readyStyle.Render(bar)+fmt.Sprintf(" %d/%d", m.syncDone, m.syncTotal))
lines = append(lines, fmt.Sprintf("%s %s %s", readyStyle.Render(fmt.Sprintf("%d synced", m.syncOK)), blockStyle.Render(fmt.Sprintf("%d failed", m.syncFailed)), mutedStyle.Render(fmt.Sprintf("%d skipped", m.syncSkipped))))
if len(m.syncActive) > 0 {
active := make([]string, 0, len(m.syncActive))
for path := range m.syncActive {
active = append(active, path)
}
sort.Strings(active)
lines = append(lines, mutedStyle.Render("Active: ")+truncateMiddle(strings.Join(active, ", "), max(10, width-8)))
}
if len(m.syncRecent) > 0 {
recent := m.syncRecent[len(m.syncRecent)-1]
label := readyStyle.Render("Latest ✓")
if recent.Skipped {
label = titleStyle.Render("Latest ↷")
} else if !recent.Success {
label = blockStyle.Render("Latest ✗")
}
lines = append(lines, label+" "+truncateMiddle(recent.Path+resultHeadline(recent), max(10, width-12)))
}
return strings.Join(lines, "\n")
}
func (m tuiModel) lastRunView(width int) string {
var ok, failed, skipped, protected, warnings int
var critical, caution []string
for _, result := range m.lastResult {
switch {
case result.Success:
ok++
case result.Skipped:
skipped++
critical = append(critical, "↷ "+result.Path+": "+firstLine(result.Message))
default:
failed++
critical = append(critical, "✗ "+result.Path+": "+firstLine(result.Message))
}
for _, branch := range result.Branches {
switch branch.Action {
case "protected":
protected++
caution = append(caution, "◆ "+result.Path+" / "+branch.Branch+": "+branch.Message)
case "warning":
warnings++
caution = append(caution, "! "+result.Path+" / "+branch.Branch+": "+branch.Message)
}
}
}
details := append(critical, caution...)
header := titleStyle.Render("LAST RUN") + fmt.Sprintf(" %s %s %s", readyStyle.Render(fmt.Sprintf("%d synced", ok)), blockStyle.Render(fmt.Sprintf("%d failed", failed)), mutedStyle.Render(fmt.Sprintf("%d skipped", skipped)))
if protected+warnings > 0 {
header += titleStyle.Render(fmt.Sprintf(" %d protected · %d warnings", protected, warnings))
}
lines := []string{header}
shown := min(3, len(details))
for _, detail := range details[:shown] {
lines = append(lines, " "+truncateMiddle(detail, max(10, width-2)))
}
if len(details) > shown {
lines = append(lines, mutedStyle.Render(fmt.Sprintf(" … %d more; press f to cycle through issues", len(details)-shown)))
}
return strings.Join(lines, "\n")
}
func resultHeadline(result syncResult) string {
if result.Success && len(result.Branches) == 0 {
return " · up to date"
}
if result.Message == "" {
return ""
}
return " · " + firstLine(result.Message)
}
func resultNeedsAttention(result syncResult) bool {
if !result.Success || result.Skipped {
return true
}
for _, branch := range result.Branches {
if branch.Action == "warning" || branch.Action == "protected" {
return true
}
}
return false
}
func firstAttentionPath(results []syncResult) string {
for _, result := range results {
if !result.Success && !result.Skipped {
return result.Path
}
}
for _, result := range results {
if result.Skipped {
return result.Path
}
}
for _, result := range results {
for _, branch := range result.Branches {
if branch.Action == "warning" || branch.Action == "protected" {
return result.Path
}
}
}
return ""
}
func (m *tuiModel) focusPath(path string) bool {
for i, repo := range m.repos {
if repo.RelativePath == path {
m.cursor = i
m.clampViewport()
return true
}
}
return false
}
func (m *tuiModel) focusNextAttention() bool {
var paths []string
for _, result := range m.lastResult {
if resultNeedsAttention(result) {
paths = append(paths, result.Path)
}
}
if len(paths) == 0 {
return false
}
current := ""
if len(m.repos) > 0 {
current = m.repos[m.cursor].RelativePath
}
next := paths[0]
for i, path := range paths {
if path == current {
next = paths[(i+1)%len(paths)]
break
}
}
m.focusPath(next)
m.status = "Focused issue in " + next
return true
}
func tickCmd() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
}
+71
View File
@@ -3,6 +3,7 @@ package main
import (
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
)
@@ -78,6 +79,76 @@ func TestTUIViewShowsLastSyncOutputForFocusedRepository(t *testing.T) {
}
}
func TestTUISyncProgressShowsLiveCountsAndLatestResult(t *testing.T) {
events := make(chan tea.Msg, 4)
m := newTUIModel("/workspace", testTUIRepos(), options{})
m.width, m.height = 100, 30
m.mode = modeSyncing
m.syncEvents = events
m.syncTotal = 3
m.syncActive = make(map[string]bool)
m.syncStarted = time.Now().Add(-5 * time.Second)
updated, wait := m.Update(syncStartedMsg{path: "clean"})
m = updated.(tuiModel)
if wait == nil || !m.syncActive["clean"] {
t.Fatalf("start event did not register an active repository: %+v", m.syncActive)
}
updated, wait = m.Update(syncFinishedMsg{result: syncResult{Path: "clean", Success: true, Message: "Already up to date."}})
m = updated.(tuiModel)
if wait == nil || m.syncDone != 1 || m.syncOK != 1 || m.syncActive["clean"] {
t.Fatalf("finish event did not update progress: done=%d ok=%d active=%+v", m.syncDone, m.syncOK, m.syncActive)
}
view := m.View()
for _, want := range []string{"1/3", "1 synced", "0 failed", "Latest", "clean", "up to date"} {
if !strings.Contains(view, want) {
t.Fatalf("progress view missing %q:\n%s", want, view)
}
}
}
func TestTUICompletionSummarizesAndFocusesFirstIssue(t *testing.T) {
m := newTUIModel("/workspace", testTUIRepos(), options{})
m.width, m.height = 100, 30
m.mode = modeSyncing
results := []syncResult{
{Path: "clean", Success: true, Branches: []branchSyncResult{{Branch: "main", Action: "protected", Message: "checked out elsewhere"}}},
{Path: "dirty", Message: "fetch origin: authentication failed"},
}
updated, _ := m.Update(syncDoneMsg{results: results, repos: testTUIRepos()})
m = updated.(tuiModel)
if m.mode != modeReady || m.cursor != 1 || !strings.Contains(m.status, "focused first issue") {
t.Fatalf("completion did not focus the first issue: mode=%v cursor=%d status=%q", m.mode, m.cursor, m.status)
}
view := m.View()
for _, want := range []string{"LAST RUN", "1 synced", "1 failed", "dirty", "authentication failed", "f next"} {
if !strings.Contains(view, want) {
t.Fatalf("completion view missing %q:\n%s", want, view)
}
}
}
func TestTUIFocusNextIssueCyclesThroughAttentionResults(t *testing.T) {
m := newTUIModel("/workspace", testTUIRepos(), options{})
m.lastResult = []syncResult{
{Path: "clean", Success: true, Branches: []branchSyncResult{{Branch: "main", Action: "protected", Message: "checked out elsewhere"}}},
{Path: "dirty", Message: "fetch failed"},
}
m.cursor = 0
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}})
m = updated.(tuiModel)
if got := m.repos[m.cursor].RelativePath; got != "dirty" {
t.Fatalf("focused repository = %q, want dirty", got)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}})
m = updated.(tuiModel)
if got := m.repos[m.cursor].RelativePath; got != "clean" {
t.Fatalf("focused repository = %q, want clean after cycling", got)
}
}
func TestTruncateMiddle(t *testing.T) {
if got := truncateMiddle("abcdefghij", 7); got != "abc…hij" {
t.Fatalf("truncateMiddle() = %q", got)