feat(tui): add repository picker

- add the tui command using Bubble Tea and Lip Gloss
- preselect eligible repositories and disable selection for blocked repositories
- add keyboard controls for navigation, selection, refresh, sync, and cancellation
- require confirmation before sync and show the branch-deletion warning
- show dirty paths, block reasons, and hub output for the focused repository
- add TUI model tests and set the embedded version to 0.2.0
This commit is contained in:
2026-09-06 18:34:05 +01:00
parent 717ae0addb
commit e397da2733
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)
}