diff --git a/cmd/completions.go b/cmd/completions.go
index 2d8f91d..bf64322 100644
--- a/cmd/completions.go
+++ b/cmd/completions.go
@@ -33,7 +33,10 @@ 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 +45,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/cmd/repo.go b/cmd/repo.go
index d9097d6..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),
}
}
@@ -125,7 +126,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..892a186 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,76 +57,256 @@ 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",
},
},
- Action: repoScanAction(cfgPath),
+ Action: repoScanAddAction(cfgPath),
}
}
-func repoScanAction(cfgPath *string) func(context.Context, *cli.Command) error {
- return func(_ context.Context, cmd *cli.Command) error {
- if cmd.NArg() == 0 {
- return errAtLeastOnePath
+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.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",
+ },
+ &cli.BoolFlag{
+ Name: "untracked",
+ Usage: "show only repos not yet in config",
+ },
+ },
+ Action: repoScanListAction(cfgPath),
+ }
+}
+
+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 fmt.Errorf("repo scan: %w", err)
+ 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 err
}
- dryRun := cmd.Bool("dry-run")
group := stripGroupPrefix(cmd.String(cmdNameGroup))
tracked := trackedPaths(&cfg)
- added := 0
-
- for _, root := range cmd.Args().Slice() {
- abs, err := filepath.Abs(root)
- if err != nil {
- return fmt.Errorf("resolving %q: %w", root, err)
- }
+ pattern := cmd.String("pattern")
+ confirm := cmd.Bool("confirm")
- repoPaths, err := scanForRepos(abs, cmd.Int("depth"))
- if err != nil {
- return fmt.Errorf("scanning %q: %w", root, err)
- }
+ repoPaths, err := collectRepoPaths(cmd.Args().Slice(), cmd.Int("depth"))
+ if err != nil {
+ return 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 {
ui.Warnf("no new repos found")
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 {
+ cfg, err := loadScanConfig(cfgPath, cmd, "list")
+ if err != nil {
+ return err
+ }
+
+ tracked := trackedPaths(&cfg)
+ pattern := cmd.String("pattern")
+ group := stripGroupPrefix(cmd.String(cmdNameGroup))
+ onlyTracked := cmd.Bool("tracked")
+ onlyUntracked := cmd.Bool("untracked")
+
+ allPaths, err := collectRepoPaths(cmd.Args().Slice(), cmd.Int("depth"))
+ if err != nil {
+ return err
+ }
+
+ 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 groupTracked(&cfg, *cfgPath, filtered, tracked, group)
}
}
-// 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.
+// 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
+// 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
}
@@ -115,11 +317,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
}
@@ -128,14 +326,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)
}
- 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..c18408a 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,163 @@ 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 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()
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=
diff --git a/internal/tui/model.go b/internal/tui/model.go
index f329af1..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, fmt.Errorf("new model: %w", err)
+ return nil, fmt.Errorf("loading config: %w", 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)
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
}
diff --git a/internal/ui/confirm.go b/internal/ui/confirm.go
new file mode 100644
index 0000000..cccda47
--- /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]: ")
+}