From f6322c41edfd3d5a3b15af93ad1bc7be487461a1 Mon Sep 17 00:00:00 2001 From: Hugo Haas Date: Sun, 28 Jun 2026 10:31:55 -0500 Subject: [PATCH 01/11] fix(tui): remove "new model:" prefix from user-facing errors Co-Authored-By: Claude Sonnet 4.6 --- internal/tui/model.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index f329af1..111ef95 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -207,7 +207,7 @@ func newModel(ctx context.Context, opts Options) (*model, error) { cfg, err := config.Load(opts.ConfigPath) if err != nil { - return nil, fmt.Errorf("new model: %w", err) + return nil, err } persState, err := loadState(statePath) @@ -222,7 +222,7 @@ func newModel(ctx context.Context, opts Options) (*model, error) { if len(opts.Repos) > 0 { resolved, err := cfg.ResolveScope(opts.Repos) if err != nil { - return nil, fmt.Errorf("new model: resolving repos: %w", err) + return nil, fmt.Errorf("resolving repos: %w", err) } selected = makeSelectedMap(resolved) From 9396108011ed41be37f32c116532edc8b580dde2 Mon Sep 17 00:00:00 2001 From: Hugo Haas Date: Sun, 28 Jun 2026 10:39:28 -0500 Subject: [PATCH 02/11] fix(cmd): include group name in repo-add success message --- cmd/repo.go | 6 +++++- cmd/scan.go | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cmd/repo.go b/cmd/repo.go index d9097d6..a9a99f9 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -125,7 +125,11 @@ func addRepo(cfg *config.Config, path, explicitName, group string) error { cfg.AddRepoToGroup(name, group) } - ui.Success("added %s as %q", abs, name) + if group != "" { + ui.Success("added %s as %q in group %s", abs, name, group) + } else { + ui.Success("added %s as %q", abs, name) + } return nil } diff --git a/cmd/scan.go b/cmd/scan.go index b5d9327..f064f28 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -130,7 +130,11 @@ func addScanned( cfg.AddRepoToGroup(name, group) } - ui.Success("added %s as %q", path, name) + if group != "" { + ui.Success("added %s as %q in group %s", path, name, group) + } else { + ui.Success("added %s as %q", path, name) + } } return added From d545bc5c17b3276b4a9ff91d9c59ef3e96ccf74e Mon Sep 17 00:00:00 2001 From: Hugo Haas Date: Sun, 28 Jun 2026 10:44:52 -0500 Subject: [PATCH 03/11] fix(completion): fall back to file completion for repo add and scan --- cmd/repo.go | 3 ++- cmd/scan.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/repo.go b/cmd/repo.go index a9a99f9..0c2ac7d 100644 --- a/cmd/repo.go +++ b/cmd/repo.go @@ -62,7 +62,8 @@ func repoAddCmd(cfgPath *string) *cli.Command { Usage: "add the repo(s) to this group", }, }, - Action: repoAddAction(cfgPath), + ShellComplete: func(_ context.Context, _ *cli.Command) {}, + Action: repoAddAction(cfgPath), } } diff --git a/cmd/scan.go b/cmd/scan.go index f064f28..587844f 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -39,7 +39,8 @@ func repoScanCmd(cfgPath *string) *cli.Command { Usage: "print what would be added without saving", }, }, - Action: repoScanAction(cfgPath), + ShellComplete: func(_ context.Context, _ *cli.Command) {}, + Action: repoScanAction(cfgPath), } } From 495c4ec641f2f7d820be53359f8edd8421694970 Mon Sep 17 00:00:00 2001 From: Hugo Haas Date: Sun, 28 Jun 2026 11:00:01 -0500 Subject: [PATCH 04/11] feat(scan): replace flat scan with add/list subcommands - `repo scan add`: replaces old scan; adds --pattern (glob filter on basename) and --confirm/-i (interactive per-repo y/N prompt via bubbletea); removes --dry-run; silently skips already-tracked repos - `repo scan list`: shows discovered repos with tracked/untracked status using the same RenderTable layout as repo ls; supports --tracked and --untracked filters - `ui.Confirm`: new bubbletea-backed y/N prompt in internal/ui Co-Authored-By: Claude Sonnet 4.6 --- cmd/scan.go | 235 ++++++++++++++++++++++++++++++++++++----- cmd/scan_test.go | 181 ++++++++++++++++++++++++++----- internal/tui/model.go | 2 +- internal/ui/confirm.go | 51 +++++++++ 4 files changed, 411 insertions(+), 58 deletions(-) create mode 100644 internal/ui/confirm.go diff --git a/cmd/scan.go b/cmd/scan.go index 587844f..ed1709f 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io/fs" + "os" "path/filepath" "strings" @@ -16,14 +17,35 @@ import ( const ( defaultScanDepth = 5 cmdNameScan = "scan" + cmdNameScanAdd = "add" + cmdNameScanList = "list" ) +//nolint:gochecknoglobals // package-level variable required for test injection +var confirmFn func(string) bool = ui.Confirm + func repoScanCmd(cfgPath *string) *cli.Command { return &cli.Command{ - Name: cmdNameScan, + Name: cmdNameScan, + Usage: "discover repositories under one or more directories", + Commands: []*cli.Command{ + repoScanAddCmd(cfgPath), + repoScanListCmd(cfgPath), + }, + } +} + +func repoScanAddCmd(cfgPath *string) *cli.Command { + return &cli.Command{ + Name: cmdNameScanAdd, Usage: "discover and add repositories under one or more directories", ArgsUsage: "...", Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "pattern", + Aliases: []string{"p"}, + Usage: "glob pattern matched against repo directory name to filter results", + }, &cli.StringFlag{ Name: cmdNameGroup, Aliases: []string{"g"}, @@ -35,16 +57,45 @@ func repoScanCmd(cfgPath *string) *cli.Command { Usage: "maximum directory depth to descend", }, &cli.BoolFlag{ - Name: "dry-run", - Usage: "print what would be added without saving", + Name: "confirm", + Aliases: []string{"i"}, + Usage: "prompt before adding each repo", }, }, - ShellComplete: func(_ context.Context, _ *cli.Command) {}, - Action: repoScanAction(cfgPath), + Action: repoScanAddAction(cfgPath), } } -func repoScanAction(cfgPath *string) func(context.Context, *cli.Command) error { +func repoScanListCmd(cfgPath *string) *cli.Command { + return &cli.Command{ + Name: cmdNameScanList, + Usage: "list repositories discovered under one or more directories", + ArgsUsage: "...", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "pattern", + Aliases: []string{"p"}, + Usage: "glob pattern matched against repo directory name to filter results", + }, + &cli.IntFlag{ + Name: "depth", + Value: defaultScanDepth, + Usage: "maximum directory depth to descend", + }, + &cli.BoolFlag{ + Name: "tracked", + Usage: "show only repos already in config", + }, + &cli.BoolFlag{ + Name: "untracked", + Usage: "show only repos not yet in config", + }, + }, + Action: repoScanListAction(cfgPath), + } +} + +func repoScanAddAction(cfgPath *string) func(context.Context, *cli.Command) error { return func(_ context.Context, cmd *cli.Command) error { if cmd.NArg() == 0 { return errAtLeastOnePath @@ -52,12 +103,13 @@ func repoScanAction(cfgPath *string) func(context.Context, *cli.Command) error { cfg, err := config.Load(*cfgPath) if err != nil { - return fmt.Errorf("repo scan: %w", err) + return fmt.Errorf("repo scan add: %w", err) } - dryRun := cmd.Bool("dry-run") group := stripGroupPrefix(cmd.String(cmdNameGroup)) tracked := trackedPaths(&cfg) + pattern := cmd.String("pattern") + confirm := cmd.Bool("confirm") added := 0 for _, root := range cmd.Args().Slice() { @@ -71,7 +123,12 @@ func repoScanAction(cfgPath *string) func(context.Context, *cli.Command) error { return fmt.Errorf("scanning %q: %w", root, err) } - added += addScanned(&cfg, tracked, repoPaths, group, dryRun) + filtered, err := filterByPattern(repoPaths, pattern) + if err != nil { + return err + } + + added += addScanned(&cfg, tracked, filtered, group, confirm) } if added == 0 { @@ -80,32 +137,129 @@ func repoScanAction(cfgPath *string) func(context.Context, *cli.Command) error { return nil } - if dryRun { - ui.Outf("would add %d repo(s); re-run without --dry-run to save", added) + return config.Save(*cfgPath, cfg) + } +} + +func repoScanListAction(cfgPath *string) func(context.Context, *cli.Command) error { + return func(_ context.Context, cmd *cli.Command) error { + if cmd.NArg() == 0 { + return errAtLeastOnePath + } + + cfg, err := config.Load(*cfgPath) + if err != nil { + return fmt.Errorf("repo scan list: %w", err) + } + + tracked := trackedPaths(&cfg) + pattern := cmd.String("pattern") + onlyTracked := cmd.Bool("tracked") + onlyUntracked := cmd.Bool("untracked") + + var allPaths []string + + for _, root := range cmd.Args().Slice() { + abs, err := filepath.Abs(root) + if err != nil { + return fmt.Errorf("resolving %q: %w", root, err) + } + + repoPaths, err := scanForRepos(abs, cmd.Int("depth")) + if err != nil { + return fmt.Errorf("scanning %q: %w", root, err) + } + + allPaths = append(allPaths, repoPaths...) + } + + filtered, err := filterByPattern(allPaths, pattern) + if err != nil { + return err + } + + const nameWidth, statusWidth, gap = 30, 10, 2 + + pathWidth := ui.GetTermWidth() - nameWidth - statusWidth - gap + + header := []string{"NAME", "PATH", "STATUS"} + maxWidths := []int{nameWidth, pathWidth, statusWidth} + + rows := buildScanListRows(filtered, tracked, &cfg, onlyTracked, onlyUntracked) + + if len(rows) == 0 { + ui.Warnf("no repos found") return nil } - return config.Save(*cfgPath, cfg) + _, _ = fmt.Fprint(os.Stdout, ui.RenderTable( + header, rows, ui.EffectiveWidths(header, rows, maxWidths), + )) + + return nil } } -// addScanned registers each discovered path in cfg (unless dryRun), -// returning how many were added. Already-tracked paths are skipped -// silently; unresolvable name conflicts are skipped with a warning. +// buildScanListRows classifies discovered paths as tracked or untracked and +// returns table rows filtered by the caller's onlyTracked/onlyUntracked flags. +func buildScanListRows( + paths []string, + tracked map[string]string, + cfg *config.Config, + onlyTracked, onlyUntracked bool, +) [][]string { + showAll := !onlyTracked && !onlyUntracked + + var rows [][]string + + for _, path := range paths { + trackedName, isTracked := tracked[path] + + name := resolveDisplayName(cfg, path, trackedName, isTracked) + + status := "tracked" + if !isTracked { + status = "untracked" + } + + if showAll || (onlyTracked && isTracked) || (onlyUntracked && !isTracked) { + rows = append(rows, []string{name, path, status}) + } + } + + return rows +} + +// resolveDisplayName returns the config name for a tracked path, or the +// would-be auto-generated name for an untracked one. +func resolveDisplayName(cfg *config.Config, path, trackedName string, isTracked bool) string { + if isTracked { + return trackedName + } + + if name, ok := scanRepoName(cfg, path); ok { + return name + } + + return filepath.Base(path) + " (!)" +} + +// addScanned registers each discovered path in cfg, returning how many were +// added. Already-tracked paths are skipped silently; unresolvable name +// conflicts are skipped with a warning. When confirm is true the user is +// prompted before each addition. func addScanned( cfg *config.Config, tracked map[string]string, repoPaths []string, group string, - dryRun bool, + confirm bool, ) int { added := 0 for _, path := range repoPaths { - if name, ok := tracked[path]; ok { - ui.Outf("%s already tracked as %q", path, name) - + if _, ok := tracked[path]; ok { continue } @@ -116,11 +270,7 @@ func addScanned( continue } - added++ - - if dryRun { - ui.Outf("would add %s as %q", path, name) - + if confirm && !promptYN(fmt.Sprintf("Add %s as %q?", path, name)) { continue } @@ -129,18 +279,45 @@ func addScanned( if group != "" { cfg.AddRepoToGroup(name, group) + ui.Success("added %s as %q in group %s", path, name, group) + } else { + ui.Success("added %s as %q", path, name) } - if group != "" { - ui.Success("added %s as %q in group %s", path, name, group) - } else { - ui.Success("added %s as %q", path, name) - } + added++ } return added } +// promptYN asks the user a yes/no question via confirmFn. +func promptYN(prompt string) bool { + return confirmFn(prompt) +} + +// filterByPattern returns paths whose base name matches the given glob +// pattern. Returns all paths unchanged when pattern is empty. +func filterByPattern(paths []string, pattern string) ([]string, error) { + if pattern == "" { + return paths, nil + } + + var out []string + + for _, p := range paths { + matched, err := filepath.Match(pattern, filepath.Base(p)) + if err != nil { + return nil, fmt.Errorf("invalid pattern %q: %w", pattern, err) + } + + if matched { + out = append(out, p) + } + } + + return out, nil +} + // scanForRepos walks root up to maxDepth levels deep and returns the // directories managed by a known VCS. Detected repos are not descended // into (nested checkouts like vendored deps stay untracked), and hidden diff --git a/cmd/scan_test.go b/cmd/scan_test.go index eba1560..e507bed 100644 --- a/cmd/scan_test.go +++ b/cmd/scan_test.go @@ -42,13 +42,13 @@ func scanTree(t *testing.T) string { return root } -func TestRepoScan(t *testing.T) { +func TestRepoScanAdd(t *testing.T) { backend.ResetDetectCache() root := scanTree(t) cfgPath := setupTestConfig(t, config.Config{}) - err := runApp(t, cfgPath, []string{"repo", cmdNameScan, root}) + err := runApp(t, cfgPath, []string{"repo", cmdNameScan, cmdNameScanAdd, root}) require.NoError(t, err) cfg, err := config.Load(cfgPath) @@ -59,48 +59,37 @@ func TestRepoScan(t *testing.T) { assert.Equal(t, filepath.Join(root, "work", "app"), cfg.Repos["work-app"].Path) } -func TestRepoScanIdempotent(t *testing.T) { +func TestRepoScanAddIdempotent(t *testing.T) { backend.ResetDetectCache() root := scanTree(t) cfgPath := setupTestConfig(t, config.Config{}) - require.NoError(t, runApp(t, cfgPath, []string{"repo", cmdNameScan, root})) - require.NoError(t, runApp(t, cfgPath, []string{"repo", cmdNameScan, root})) + require.NoError(t, runApp(t, cfgPath, []string{"repo", cmdNameScan, cmdNameScanAdd, root})) + require.NoError(t, runApp(t, cfgPath, []string{"repo", cmdNameScan, cmdNameScanAdd, root})) cfg, err := config.Load(cfgPath) require.NoError(t, err) assert.Len(t, cfg.Repos, 2) } -func TestRepoScanDryRun(t *testing.T) { +func TestRepoScanAddGroup(t *testing.T) { backend.ResetDetectCache() root := scanTree(t) cfgPath := setupTestConfig(t, config.Config{}) - out := runAppCapture(t, cfgPath, []string{"repo", cmdNameScan, "--dry-run", root}) - assert.Contains(t, out, "would add") - - cfg, err := config.Load(cfgPath) - require.NoError(t, err) - assert.Empty(t, cfg.Repos) -} - -func TestRepoScanGroup(t *testing.T) { - backend.ResetDetectCache() - - root := scanTree(t) - cfgPath := setupTestConfig(t, config.Config{}) - - require.NoError(t, runApp(t, cfgPath, []string{"repo", cmdNameScan, "-g", "work", root})) + require.NoError( + t, + runApp(t, cfgPath, []string{"repo", cmdNameScan, cmdNameScanAdd, "-g", "work", root}), + ) cfg, err := config.Load(cfgPath) require.NoError(t, err) assert.ElementsMatch(t, []string{"app", "work-app"}, cfg.Groups["work"].Repos) } -func TestRepoScanDepthLimit(t *testing.T) { +func TestRepoScanAddDepthLimit(t *testing.T) { backend.ResetDetectCache() root := t.TempDir() @@ -110,26 +99,29 @@ func TestRepoScanDepthLimit(t *testing.T) { cfgPath := setupTestConfig(t, config.Config{}) - require.NoError(t, runApp(t, cfgPath, []string{"repo", cmdNameScan, "--depth", "2", root})) + require.NoError( + t, + runApp(t, cfgPath, []string{"repo", cmdNameScan, cmdNameScanAdd, "--depth", "2", root}), + ) cfg, err := config.Load(cfgPath) require.NoError(t, err) assert.Empty(t, cfg.Repos, "repo below --depth should not be added") - require.NoError(t, runApp(t, cfgPath, []string{"repo", cmdNameScan, root})) + require.NoError(t, runApp(t, cfgPath, []string{"repo", cmdNameScan, cmdNameScanAdd, root})) cfg, err = config.Load(cfgPath) require.NoError(t, err) assert.Len(t, cfg.Repos, 1) } -func TestRepoScanNoArgs(t *testing.T) { +func TestRepoScanAddNoArgs(t *testing.T) { cfgPath := setupTestConfig(t, config.Config{}) - err := runApp(t, cfgPath, []string{"repo", cmdNameScan}) + err := runApp(t, cfgPath, []string{"repo", cmdNameScan, cmdNameScanAdd}) require.ErrorIs(t, err, errAtLeastOnePath) } -func TestRepoScanNameConflictSkipped(t *testing.T) { +func TestRepoScanAddNameConflictSkipped(t *testing.T) { backend.ResetDetectCache() root := t.TempDir() @@ -144,7 +136,7 @@ func TestRepoScanNameConflictSkipped(t *testing.T) { } cfgPath := setupTestConfig(t, config.Config{}) - require.NoError(t, runApp(t, cfgPath, []string{"repo", cmdNameScan, root})) + require.NoError(t, runApp(t, cfgPath, []string{"repo", cmdNameScan, cmdNameScanAdd, root})) cfg, err := config.Load(cfgPath) require.NoError(t, err) @@ -153,6 +145,139 @@ func TestRepoScanNameConflictSkipped(t *testing.T) { assert.Contains(t, cfg.Repos, "x-app") } +func TestRepoScanAddPattern(t *testing.T) { + backend.ResetDetectCache() + + root := scanTree(t) + cfgPath := setupTestConfig(t, config.Config{}) + + // "app" matches both repos by basename; filter to confirm both are found + require.NoError( + t, + runApp(t, cfgPath, []string{"repo", cmdNameScan, cmdNameScanAdd, "--pattern", "app", root}), + ) + + cfg, err := config.Load(cfgPath) + require.NoError(t, err) + assert.Len(t, cfg.Repos, 2) + + // Reset and use a non-matching pattern + cfgPath2 := setupTestConfig(t, config.Config{}) + require.NoError( + t, + runApp( + t, + cfgPath2, + []string{"repo", cmdNameScan, cmdNameScanAdd, "--pattern", "nomatch*", root}, + ), + ) + + cfg2, err := config.Load(cfgPath2) + require.NoError(t, err) + assert.Empty(t, cfg2.Repos) +} + +func TestRepoScanAddConfirm(t *testing.T) { + backend.ResetDetectCache() + + root := scanTree(t) + cfgPath := setupTestConfig(t, config.Config{}) + + // WalkDir visits oss/app before work/app (lexicographic order). + // Accept the first repo, reject the second. + responses := []bool{true, false} + i := 0 + old := confirmFn + confirmFn = func(string) bool { + r := responses[i] + i++ + + return r + } + + t.Cleanup(func() { confirmFn = old }) + + require.NoError( + t, + runApp(t, cfgPath, []string{"repo", cmdNameScan, cmdNameScanAdd, "--confirm", root}), + ) + + cfg, err := config.Load(cfgPath) + require.NoError(t, err) + assert.Len(t, cfg.Repos, 1) + assert.Contains(t, cfg.Repos, "app", "only the confirmed repo should be added") +} + +func TestRepoScanListNoArgs(t *testing.T) { + cfgPath := setupTestConfig(t, config.Config{}) + err := runApp(t, cfgPath, []string{"repo", cmdNameScan, cmdNameScanList}) + require.ErrorIs(t, err, errAtLeastOnePath) +} + +func TestRepoScanList(t *testing.T) { + backend.ResetDetectCache() + + root := scanTree(t) + + // Pre-populate config with one of the two repos. + ossApp := filepath.Join(root, "oss", "app") + cfgPath := setupTestConfig(t, config.Config{ + Repos: map[string]config.Repo{ + "app": {Path: ossApp}, + }, + }) + + out := runAppCapture(t, cfgPath, []string{"repo", cmdNameScan, cmdNameScanList, root}) + + // Both status values and both names should appear in the output. + assert.Contains(t, out, "tracked") + assert.Contains(t, out, "untracked") + assert.Contains(t, out, "work-app") +} + +func TestRepoScanListTracked(t *testing.T) { + backend.ResetDetectCache() + + root := scanTree(t) + ossApp := filepath.Join(root, "oss", "app") + cfgPath := setupTestConfig(t, config.Config{ + Repos: map[string]config.Repo{ + "app": {Path: ossApp}, + }, + }) + + out := runAppCapture( + t, + cfgPath, + []string{"repo", cmdNameScan, cmdNameScanList, "--tracked", root}, + ) + + // Only the tracked repo ("app") should appear; work-app is untracked. + assert.NotContains(t, out, "work-app") + assert.NotContains(t, out, "untracked") +} + +func TestRepoScanListUntracked(t *testing.T) { + backend.ResetDetectCache() + + root := scanTree(t) + ossApp := filepath.Join(root, "oss", "app") + cfgPath := setupTestConfig(t, config.Config{ + Repos: map[string]config.Repo{ + "app": {Path: ossApp}, + }, + }) + + out := runAppCapture( + t, + cfgPath, + []string{"repo", cmdNameScan, cmdNameScanList, "--untracked", root}, + ) + + // Only the untracked repo should appear; "app" (ossApp) is already tracked. + assert.Contains(t, out, "work-app") +} + func TestRepoAddWithGroup(t *testing.T) { backend.ResetDetectCache() diff --git a/internal/tui/model.go b/internal/tui/model.go index 111ef95..5359305 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -207,7 +207,7 @@ func newModel(ctx context.Context, opts Options) (*model, error) { cfg, err := config.Load(opts.ConfigPath) if err != nil { - return nil, err + return nil, fmt.Errorf("loading config: %w", err) } persState, err := loadState(statePath) diff --git a/internal/ui/confirm.go b/internal/ui/confirm.go new file mode 100644 index 0000000..8beea4b --- /dev/null +++ b/internal/ui/confirm.go @@ -0,0 +1,51 @@ +package ui + +import ( + "os" + + tea "charm.land/bubbletea/v2" + "golang.org/x/term" +) + +// Confirm runs an interactive y/N prompt using bubbletea and returns true only +// if the user presses "y" or "Y". Returns false when stdin is not a TTY. +func Confirm(prompt string) bool { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return false + } + + m, err := tea.NewProgram(confirmModel{prompt: prompt}).Run() + if err != nil { + return false + } + + result, ok := m.(confirmModel) + if !ok { + return false + } + + return result.confirmed +} + +type confirmModel struct { + prompt string + confirmed bool +} + +func (confirmModel) Init() tea.Cmd { return nil } + +func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + if key.String() == "y" || key.String() == "Y" { + m.confirmed = true + } + + return m, tea.Quit + } + + return m, nil +} + +func (m confirmModel) View() tea.View { + return tea.NewView(m.prompt + " [y/N]: ") +} From 5f23dc11d04e9d41223605aa0a2d773ad10bc621 Mon Sep 17 00:00:00 2001 From: Hugo Haas Date: Sun, 28 Jun 2026 11:11:24 -0500 Subject: [PATCH 05/11] feat(scan): add --group to scan list to bulk-assign tracked repos `repo scan list -p --tracked -g ` assigns all already-tracked repos matching the pattern to the given group, closing the gap where `scan add -g` skips repos that are already in config. Co-Authored-By: Claude Sonnet 4.6 --- cmd/scan.go | 41 +++++++++++++++++++++++++++++++++++++++++ cmd/scan_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/cmd/scan.go b/cmd/scan.go index ed1709f..90eb41d 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -82,6 +82,11 @@ func repoScanListCmd(cfgPath *string) *cli.Command { Value: defaultScanDepth, Usage: "maximum directory depth to descend", }, + &cli.StringFlag{ + Name: cmdNameGroup, + Aliases: []string{"g"}, + Usage: "assign tracked repos in results to this group", + }, &cli.BoolFlag{ Name: "tracked", Usage: "show only repos already in config", @@ -154,6 +159,7 @@ func repoScanListAction(cfgPath *string) func(context.Context, *cli.Command) err tracked := trackedPaths(&cfg) pattern := cmd.String("pattern") + group := stripGroupPrefix(cmd.String(cmdNameGroup)) onlyTracked := cmd.Bool("tracked") onlyUntracked := cmd.Bool("untracked") @@ -197,8 +203,43 @@ func repoScanListAction(cfgPath *string) func(context.Context, *cli.Command) err header, rows, ui.EffectiveWidths(header, rows, maxWidths), )) + return groupTracked(&cfg, *cfgPath, filtered, tracked, group) + } +} + +// groupTracked assigns all tracked paths in filtered to group and saves config. +// A no-op when group is empty or no tracked paths are found. +func groupTracked( + cfg *config.Config, + cfgPath string, + filtered []string, + tracked map[string]string, + group string, +) error { + if group == "" { + return nil + } + + grouped := 0 + + for _, path := range filtered { + name, isTracked := tracked[path] + if !isTracked { + continue + } + + cfg.AddRepoToGroup(name, group) + + grouped++ + } + + if grouped == 0 { return nil } + + ui.Outf("assigned %d repo(s) to group %s", grouped, displayGroup(group)) + + return config.Save(cfgPath, *cfg) //nolint:wrapcheck // caller provides context } // buildScanListRows classifies discovered paths as tracked or untracked and diff --git a/cmd/scan_test.go b/cmd/scan_test.go index e507bed..c18408a 100644 --- a/cmd/scan_test.go +++ b/cmd/scan_test.go @@ -278,6 +278,30 @@ func TestRepoScanListUntracked(t *testing.T) { assert.Contains(t, out, "work-app") } +func TestRepoScanListGroup(t *testing.T) { + backend.ResetDetectCache() + + root := scanTree(t) + ossApp := filepath.Join(root, "oss", "app") + cfgPath := setupTestConfig(t, config.Config{ + Repos: map[string]config.Repo{ + "app": {Path: ossApp}, + "work-app": {Path: filepath.Join(root, "work", "app")}, + }, + }) + + // Assign only the "app" repo (oss/app) to the spoon group via scan list. + args := []string{ + "repo", cmdNameScan, cmdNameScanList, + "-p", "app", "-g", "spoon", "--tracked", root, + } + require.NoError(t, runApp(t, cfgPath, args)) + + cfg, err := config.Load(cfgPath) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"app", "work-app"}, cfg.Groups["spoon"].Repos) +} + func TestRepoAddWithGroup(t *testing.T) { backend.ResetDetectCache() From 3d8f5b2266fb50ecae61ef106ebb8abd9ce41a22 Mon Sep 17 00:00:00 2001 From: Hugo Haas Date: Sun, 28 Jun 2026 11:16:09 -0500 Subject: [PATCH 06/11] refactor(scan): extract loadScanConfig and collectRepoPaths helpers Both action functions shared identical arg-check/config-load boilerplate and filepath.Abs/scanForRepos loop. Extracting helpers eliminates the duplication and reduces the two action functions to their distinct logic. Co-Authored-By: Claude Sonnet 4.6 --- cmd/scan.go | 92 ++++++++++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/cmd/scan.go b/cmd/scan.go index 90eb41d..892a186 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -100,42 +100,63 @@ func repoScanListCmd(cfgPath *string) *cli.Command { } } -func repoScanAddAction(cfgPath *string) func(context.Context, *cli.Command) error { - return func(_ context.Context, cmd *cli.Command) error { - if cmd.NArg() == 0 { - return errAtLeastOnePath +func loadScanConfig(cfgPath *string, cmd *cli.Command, op string) (config.Config, error) { + if cmd.NArg() == 0 { + return config.Config{}, errAtLeastOnePath + } + + cfg, err := config.Load(*cfgPath) + if err != nil { + return config.Config{}, fmt.Errorf("repo scan %s: %w", op, err) + } + + return cfg, nil +} + +func collectRepoPaths(roots []string, depth int) ([]string, error) { + var all []string + + for _, root := range roots { + abs, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("resolving %q: %w", root, err) } - cfg, err := config.Load(*cfgPath) + paths, err := scanForRepos(abs, depth) + if err != nil { + return nil, fmt.Errorf("scanning %q: %w", root, err) + } + + all = append(all, paths...) + } + + return all, nil +} + +func repoScanAddAction(cfgPath *string) func(context.Context, *cli.Command) error { + return func(_ context.Context, cmd *cli.Command) error { + cfg, err := loadScanConfig(cfgPath, cmd, "add") if err != nil { - return fmt.Errorf("repo scan add: %w", err) + return err } group := stripGroupPrefix(cmd.String(cmdNameGroup)) tracked := trackedPaths(&cfg) pattern := cmd.String("pattern") confirm := cmd.Bool("confirm") - added := 0 - - for _, root := range cmd.Args().Slice() { - abs, err := filepath.Abs(root) - if err != nil { - return fmt.Errorf("resolving %q: %w", root, err) - } - repoPaths, err := scanForRepos(abs, cmd.Int("depth")) - if err != nil { - return fmt.Errorf("scanning %q: %w", root, err) - } - - filtered, err := filterByPattern(repoPaths, pattern) - if err != nil { - return err - } + repoPaths, err := collectRepoPaths(cmd.Args().Slice(), cmd.Int("depth")) + if err != nil { + return err + } - added += addScanned(&cfg, tracked, filtered, group, confirm) + filtered, err := filterByPattern(repoPaths, pattern) + if err != nil { + return err } + added := addScanned(&cfg, tracked, filtered, group, confirm) + if added == 0 { ui.Warnf("no new repos found") @@ -148,13 +169,9 @@ func repoScanAddAction(cfgPath *string) func(context.Context, *cli.Command) erro func repoScanListAction(cfgPath *string) func(context.Context, *cli.Command) error { return func(_ context.Context, cmd *cli.Command) error { - if cmd.NArg() == 0 { - return errAtLeastOnePath - } - - cfg, err := config.Load(*cfgPath) + cfg, err := loadScanConfig(cfgPath, cmd, "list") if err != nil { - return fmt.Errorf("repo scan list: %w", err) + return err } tracked := trackedPaths(&cfg) @@ -163,20 +180,9 @@ func repoScanListAction(cfgPath *string) func(context.Context, *cli.Command) err onlyTracked := cmd.Bool("tracked") onlyUntracked := cmd.Bool("untracked") - var allPaths []string - - for _, root := range cmd.Args().Slice() { - abs, err := filepath.Abs(root) - if err != nil { - return fmt.Errorf("resolving %q: %w", root, err) - } - - repoPaths, err := scanForRepos(abs, cmd.Int("depth")) - if err != nil { - return fmt.Errorf("scanning %q: %w", root, err) - } - - allPaths = append(allPaths, repoPaths...) + allPaths, err := collectRepoPaths(cmd.Args().Slice(), cmd.Int("depth")) + if err != nil { + return err } filtered, err := filterByPattern(allPaths, pattern) From ee904d3dd8082ae17cdd9cd7e9acb790b72c00e6 Mon Sep 17 00:00:00 2001 From: Hugo Haas Date: Sun, 28 Jun 2026 11:21:15 -0500 Subject: [PATCH 07/11] refactor: deduplicate completer factory and mode-toggle logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd/completions.go: extract makeCompleter variadic factory — the three completer functions shared identical config-load/loop boilerplate and now each reduce to a one-liner. internal/tui/selection.go: extract toggleMode helper — handleSelectToggle and handleSingleToggle shared 29 identical lines for cursor save/restore and table restyle; the only difference was target mode and whether to snapshot m.selected. Co-Authored-By: Claude Sonnet 4.6 --- cmd/completions.go | 42 ++++++++++----------------------------- internal/tui/selection.go | 41 ++++++++++---------------------------- 2 files changed, 22 insertions(+), 61 deletions(-) diff --git a/cmd/completions.go b/cmd/completions.go index 2d8f91d..ef255b8 100644 --- a/cmd/completions.go +++ b/cmd/completions.go @@ -33,7 +33,7 @@ func completeGroups(cfg *config.Config) []string { return names } -func repoGroupCompleter(cfgPath *string) func(context.Context, *cli.Command) { +func makeCompleter(cfgPath *string, getters ...func(*config.Config) []string) func(context.Context, *cli.Command) { return func(_ context.Context, cmd *cli.Command) { cfg, err := config.Load(*cfgPath) if err != nil { @@ -42,42 +42,22 @@ func repoGroupCompleter(cfgPath *string) func(context.Context, *cli.Command) { w := cmd.Root().Writer - for _, name := range completeRepos(&cfg) { - _, _ = fmt.Fprintln(w, name) - } - - for _, name := range completeGroups(&cfg) { - _, _ = fmt.Fprintln(w, name) + for _, get := range getters { + for _, name := range get(&cfg) { + _, _ = fmt.Fprintln(w, name) + } } } } -func reposOnlyCompleter(cfgPath *string) func(context.Context, *cli.Command) { - return func(_ context.Context, cmd *cli.Command) { - cfg, err := config.Load(*cfgPath) - if err != nil { - return - } - - w := cmd.Root().Writer +func repoGroupCompleter(cfgPath *string) func(context.Context, *cli.Command) { + return makeCompleter(cfgPath, completeRepos, completeGroups) +} - for _, name := range completeRepos(&cfg) { - _, _ = fmt.Fprintln(w, name) - } - } +func reposOnlyCompleter(cfgPath *string) func(context.Context, *cli.Command) { + return makeCompleter(cfgPath, completeRepos) } func groupsOnlyCompleter(cfgPath *string) func(context.Context, *cli.Command) { - return func(_ context.Context, cmd *cli.Command) { - cfg, err := config.Load(*cfgPath) - if err != nil { - return - } - - w := cmd.Root().Writer - - for _, name := range completeGroups(&cfg) { - _, _ = fmt.Fprintln(w, name) - } - } + return makeCompleter(cfgPath, completeGroups) } diff --git a/internal/tui/selection.go b/internal/tui/selection.go index be6accf..1e4d088 100644 --- a/internal/tui/selection.go +++ b/internal/tui/selection.go @@ -6,7 +6,7 @@ import ( tea "charm.land/bubbletea/v2" ) -func (m *model) handleSelectToggle() (tea.Model, tea.Cmd) { +func (m *model) toggleMode(target mode, saveSelection bool) { cur := m.tableRepos() saved := "" @@ -14,12 +14,14 @@ func (m *model) handleSelectToggle() (tea.Model, tea.Cmd) { saved = cur[m.cursor] } - if m.mode == modeSelect { + if m.mode == target { m.mode = modeNormal } else { - m.selectSaved = maps.Clone(m.selected) + if saveSelection { + m.selectSaved = maps.Clone(m.selected) + } - m.mode = modeSelect + m.mode = target } m.repoTable.SetStyles(tableStyles(m.mode != modeNormal)) @@ -35,6 +37,10 @@ func (m *model) handleSelectToggle() (tea.Model, tea.Cmd) { } } } +} + +func (m *model) handleSelectToggle() (tea.Model, tea.Cmd) { + m.toggleMode(modeSelect, true) return m, nil } @@ -57,32 +63,7 @@ func (m *model) handleSelectOne() (tea.Model, tea.Cmd) { } func (m *model) handleSingleToggle() (tea.Model, tea.Cmd) { - cur := m.tableRepos() - - saved := "" - if m.cursor >= 0 && m.cursor < len(cur) { - saved = cur[m.cursor] - } - - if m.mode == modeSingle { - m.mode = modeNormal - } else { - m.mode = modeSingle - } - - m.repoTable.SetStyles(tableStyles(m.mode != modeNormal)) - m.updateTableRows() - - if saved != "" { - for i, name := range m.tableRepos() { - if name == saved { - m.cursor = i - m.repoTable.SetCursor(i) - - break - } - } - } + m.toggleMode(modeSingle, false) return m, nil } From 81c1f35b6f788ece0ea884b413c6fc726f0eac8f Mon Sep 17 00:00:00 2001 From: Hugo Haas Date: Sun, 28 Jun 2026 11:25:29 -0500 Subject: [PATCH 08/11] chore(deps): upgrade --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index e3ba884..b93375e 100644 --- a/go.mod +++ b/go.mod @@ -7,13 +7,13 @@ require ( charm.land/bubbletea/v2 v2.0.7 charm.land/lipgloss/v2 v2.0.4 charm.land/log/v2 v2.0.0 - github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260615092313-b57e5e6d29bb + github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260628005914-6eb80f72a239 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/muesli/reflow v0.3.0 - github.com/pelletier/go-toml/v2 v2.4.0 + github.com/pelletier/go-toml/v2 v2.4.2 github.com/sahilm/fuzzy v0.1.3 github.com/stretchr/testify v1.11.1 - github.com/urfave/cli/v3 v3.10.0 + github.com/urfave/cli/v3 v3.10.1 github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04 golang.org/x/sync v0.21.0 golang.org/x/term v0.44.0 @@ -24,9 +24,9 @@ require ( github.com/aymanbagabas/go-udiff v0.4.1 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260622092850-f39628c8a989 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect - github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20260628005914-6eb80f72a239 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect diff --git a/go.sum b/go.sum index 7991c94..a493221 100644 --- a/go.sum +++ b/go.sum @@ -14,14 +14,14 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk= -github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= +github.com/charmbracelet/ultraviolet v0.0.0-20260622092850-f39628c8a989 h1:aLA9AmFNKnFr86XM3/Jm9g4xLOVjEgRuttBWUFujdVw= +github.com/charmbracelet/ultraviolet v0.0.0-20260622092850-f39628c8a989/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb h1:m1Uub9uM7meZ8ofVyeRfK7M/knCi3sF2gKn9kldVW0I= -github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0= -github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260615092313-b57e5e6d29bb h1:u5HNfdRadHwgAj3wSyuPcOmMyfXL/Vto0KXA5/a2UjM= -github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260615092313-b57e5e6d29bb/go.mod h1:aRoQwQWmN9LBG2xi3sVByMFt2fdkPCagd0GAJ1qwOfw= +github.com/charmbracelet/x/exp/golden v0.0.0-20260628005914-6eb80f72a239 h1:ZkqtDjeyqKAvk07Hpj7aTa1YbiEx7Az9ksnjOs1k1rI= +github.com/charmbracelet/x/exp/golden v0.0.0-20260628005914-6eb80f72a239/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0= +github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260628005914-6eb80f72a239 h1:959BR5Zmnr1ehu77fPswMKaZD76aCEa4Rqu0EV4lpQk= +github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260628005914-6eb80f72a239/go.mod h1:aRoQwQWmN9LBG2xi3sVByMFt2fdkPCagd0GAJ1qwOfw= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= @@ -51,8 +51,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/pelletier/go-toml/v2 v2.4.0 h1:Mwu0mAkUKbittDs3/ADDWXqMmq3EOK2VHiuCkV00Row= -github.com/pelletier/go-toml/v2 v2.4.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q= +github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -63,8 +63,8 @@ github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/urfave/cli/v3 v3.10.0 h1:0aU8yOObVDMkM13Cj4G+zb4P0PdeJMec65f81Ak1ioM= -github.com/urfave/cli/v3 v3.10.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY= +github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04 h1:qXafrlZL1WsJW5OokjraLLRURHiw0OzKHD/RNdspp4w= From a809ae1090d8c04ee7909c2af8ab54535b6ba75f Mon Sep 17 00:00:00 2001 From: Hugo Haas Date: Sun, 28 Jun 2026 11:25:56 -0500 Subject: [PATCH 09/11] refactor: style --- cmd/completions.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/completions.go b/cmd/completions.go index ef255b8..bf64322 100644 --- a/cmd/completions.go +++ b/cmd/completions.go @@ -33,7 +33,10 @@ func completeGroups(cfg *config.Config) []string { return names } -func makeCompleter(cfgPath *string, getters ...func(*config.Config) []string) func(context.Context, *cli.Command) { +func makeCompleter( + cfgPath *string, + getters ...func(*config.Config) []string, +) func(context.Context, *cli.Command) { return func(_ context.Context, cmd *cli.Command) { cfg, err := config.Load(*cfgPath) if err != nil { From b70da9897923f1ca8f36860ec2718eeb63f139a0 Mon Sep 17 00:00:00 2001 From: Hugo Haas Date: Sun, 28 Jun 2026 16:26:49 -0500 Subject: [PATCH 10/11] fix(ui): use pointer receiver on confirmModel to avoid value-copy mutation --- internal/ui/confirm.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/ui/confirm.go b/internal/ui/confirm.go index 8beea4b..a0f6d7b 100644 --- a/internal/ui/confirm.go +++ b/internal/ui/confirm.go @@ -14,12 +14,12 @@ func Confirm(prompt string) bool { return false } - m, err := tea.NewProgram(confirmModel{prompt: prompt}).Run() + m, err := tea.NewProgram(&confirmModel{prompt: prompt}).Run() if err != nil { return false } - result, ok := m.(confirmModel) + result, ok := m.(*confirmModel) if !ok { return false } @@ -34,7 +34,7 @@ type confirmModel struct { func (confirmModel) Init() tea.Cmd { return nil } -func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { if key.String() == "y" || key.String() == "Y" { m.confirmed = true @@ -46,6 +46,6 @@ func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -func (m confirmModel) View() tea.View { +func (m *confirmModel) View() tea.View { return tea.NewView(m.prompt + " [y/N]: ") } From a9f7178e1e18795f6f7e4b1ca12295ded0bb8785 Mon Sep 17 00:00:00 2001 From: Hugo Haas Date: Sun, 28 Jun 2026 16:29:47 -0500 Subject: [PATCH 11/11] fix(ui): use pointer receiver on confirmModel.Init for consistency --- internal/ui/confirm.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/confirm.go b/internal/ui/confirm.go index a0f6d7b..cccda47 100644 --- a/internal/ui/confirm.go +++ b/internal/ui/confirm.go @@ -32,7 +32,7 @@ type confirmModel struct { confirmed bool } -func (confirmModel) Init() tea.Cmd { return nil } +func (*confirmModel) Init() tea.Cmd { return nil } func (m *confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok {