feat(tui): add interactive repository selection

Introduce a Bubble Tea control deck with eligible repositories preselected,
keyboard navigation, responsive paging, and detailed explanations for blocked
worktrees.

Require confirmation before synchronization, allow refreshing the workspace,
and retain each repository's latest hub output without changing the existing
CLI and JSON workflows.
This commit is contained in:
2026-09-06 18:34:05 +01:00
parent 295d694d78
commit 9bcaab4dc0
6 changed files with 569 additions and 6 deletions
+19 -4
View File
@@ -19,7 +19,7 @@ import (
"time"
)
const version = "0.1.1"
const version = "0.2.0"
var defaultSkippedDirs = map[string]bool{
".cache": true, ".claude": true, ".codex": true, ".git": true, ".next": true, ".pnpm-store": true,
@@ -82,7 +82,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fmt.Fprintf(stdout, "stack-sync %s\n", version)
return 0
}
if command != "scan" && command != "sync" {
if command != "scan" && command != "sync" && command != "tui" {
fmt.Fprintf(stderr, "unknown command %q\n\n", command)
usage(stderr)
return 2
@@ -113,6 +113,13 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
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 {
@@ -184,6 +191,8 @@ func parseFlags(command string, args []string, stderr io.Writer) (options, error
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")
}
if command == "sync" || command == "tui" {
fs.DurationVar(&opts.timeout, "timeout", opts.timeout, "timeout for each hub sync")
}
fs.Usage = func() { usage(stderr) }
@@ -214,11 +223,13 @@ func usage(w io.Writer) {
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 run hub sync in eligible repositories
tui Interactively review, select, and sync repositories
Safety:
Repositories with edits, untracked files, detached HEADs, no remotes, or an
@@ -412,6 +423,10 @@ func gitBytes(path string, args ...string) ([]byte, error) {
}
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 syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet bool) []syncResult {
type item struct {
index int
repo repo
@@ -420,8 +435,8 @@ func syncAll(root string, repos []repo, jobs int, timeout time.Duration, stdout
results := make([]syncResult, len(repos))
var wg sync.WaitGroup
var outputMu sync.Mutex
allPaths := make([]string, 0, len(repos))
for _, r := range repos {
allPaths := make([]string, 0, len(workspaceRepos))
for _, r := range workspaceRepos {
allPaths = append(allPaths, r.Path)
}