diff --git a/README.md b/README.md index 2ebc88b..d402110 100644 --- a/README.md +++ b/README.md @@ -98,15 +98,15 @@ Default destination root is `~/Documents/worktrees`, generating: ## 🆕 Recent CLI adjustments - Every subcommand supports command-scoped help via `--help` and `-h`. -- `create` keeps package selection flexible: - - the interactive stepper still includes the package step, - - non-interactive runs still support `--package` and `--package-base`. +- `create --no-package` is now an explicit root-only mode: + - package selection is skipped in interactive mode, + - `--no-package` conflicts with `--package` and `--package-base` (fail-fast input error). - `add-repo` is the command for attaching repositories after a workspace already exists. - Before syncing branches from `origin` during `create`, the CLI now asks for confirmation: - **Yes** → sync from `origin` and continue with worktree creation. - **No** → skip remote sync entirely and continue from local refs. -- The package step in the `create` stepper is never skipped. - If no package candidates are found, the step shows the empty-state message and still waits for Enter. +- `list --global` always uses global registry scope, regardless of current directory. +- `list --global --all` includes unmanaged worktrees across all globally selected repositories. ## ⚙️ Advanced Usage @@ -118,6 +118,8 @@ Default destination root is `~/Documents/worktrees`, generating: For automation/non-interactive runs, `create --non-interactive` requires explicit `--yes` and `--root-repo`. If the target branch already exists, non-interactive runs also require `--reuse-existing-branch`. +Use `--no-package` when you need a root-only workspace and no package metadata flow. +`--no-package` cannot be combined with `--package` or `--package-base`. In interactive mode, after selecting **Apply changes**, `create` asks whether local branches should be synced from `origin` before worktree creation. If the answer is **Yes**, `create` syncs before worktree creation and fails fast if sync cannot be completed. If the answer is **No**, `create` skips remote sync and continues from local refs. @@ -173,6 +175,7 @@ flutree create [options] | `--yes` | boolean | `false` | Acknowledge dry plan automatically in non-interactive mode | | `--non-interactive` | boolean | `false` | Disable prompts | | `--reuse-existing-branch` | boolean | `false` | Reuse existing local branch in non-interactive mode | +| `--no-package` | boolean | `false` | Root-only mode (skip package selection and package metadata) | | `--package` | string | | Package repository selector (repeatable) | | `--package-base` | string | | Override package base branch as `=` (repeatable) | | `--copy-root-file` | string | | Extra root-level file/pattern to copy into each worktree (repeatable). By default `.env` and `.env.*` are copied when present | @@ -196,16 +199,18 @@ flutree add-repo [options] ### list -Lists managed worktrees (scoped to current repo when available, otherwise global registry scope). +Lists managed worktrees. By default, scope is current repo when available (fallback to global outside a repo). +Use `--global` to force global registry scope from any location. Usage: ``` -flutree list [--all] +flutree list [options] ``` | Flag | Type | Default | Description | |------|------|---------|-------------| | `--all` | boolean | `false` | Include unmanaged Git worktrees | +| `--global` | boolean | `false` | Force global registry scope regardless of current directory | ### complete diff --git a/cmd/flutree/main.go b/cmd/flutree/main.go index f61cab6..ede1dea 100644 --- a/cmd/flutree/main.go +++ b/cmd/flutree/main.go @@ -55,6 +55,7 @@ func main() { func runList(args []string) error { fs := newFlagSet("list", printListHelp) showAll := fs.Bool("all", false, "Include unmanaged Git worktrees.") + globalScope := fs.Bool("global", false, "List managed worktrees across all registered repositories.") if len(args) > 0 && isHelpToken(args[0]) { printListHelp() return nil @@ -68,7 +69,7 @@ func runList(args []string) error { } service := app.NewListService(&infraGit.Gateway{}, registry.NewDefault()) - rows, err := service.Run(*showAll) + rows, err := service.Run(domain.ListInput{ShowAll: *showAll, GlobalScope: *globalScope}) if err != nil { return err } @@ -122,6 +123,7 @@ func runCreate(args []string) error { yes := fs.Bool("yes", false, "Acknowledge dry plan automatically in non-interactive mode.") nonInteractive := fs.Bool("non-interactive", false, "Disable prompts.") reuseExistingBranch := fs.Bool("reuse-existing-branch", false, "Allow non-interactive reuse when target branch already exists.") + noPackage := fs.Bool("no-package", false, "Create root-only worktree without package selection.") var packages multiFlag var packageBase multiFlag @@ -145,6 +147,15 @@ func runCreate(args []string) error { if helpRequested { return nil } + if *noPackage && len(packages) > 0 && len(packageBase) > 0 { + return domain.NewError(domain.CategoryInput, 2, "Flag --no-package cannot be combined with --package or --package-base.", "Remove --no-package or remove package flags.", nil) + } + if *noPackage && len(packages) > 0 { + return domain.NewError(domain.CategoryInput, 2, "Flag --no-package cannot be combined with --package.", "Remove --no-package or remove --package.", nil) + } + if *noPackage && len(packageBase) > 0 { + return domain.NewError(domain.CategoryInput, 2, "Flag --no-package cannot be combined with --package-base.", "Remove --no-package or remove --package-base.", nil) + } if *nonInteractive && strings.TrimSpace(*rootRepo) == "" { return domain.NewError(domain.CategoryInput, 2, "Non-interactive mode requires explicit root repository selection.", "Pass --root-repo with a discovered repository name or path.", nil) } @@ -175,6 +186,7 @@ func runCreate(args []string) error { BaseBranch: *baseBranch, ExecutionScope: *scope, RootSelector: *rootRepo, + NoPackage: *noPackage, PackageSelectors: packages, PackageBaseBranch: baseMap, RootFiles: copyRootFiles, @@ -197,6 +209,7 @@ func runCreate(args []string) error { BaseBranch: *baseBranch, GenerateWorkspace: genWorkspace, RootSelector: *rootRepo, + NoPackage: *noPackage, PackageSelectors: packages, PackageBaseBranch: baseMap, }, repos) @@ -211,6 +224,7 @@ func runCreate(args []string) error { createInput.Branch = wizardResult.Branch createInput.BaseBranch = wizardResult.BaseBranch createInput.RootSelector = wizardResult.RootSelector + createInput.NoPackage = wizardResult.NoPackage createInput.PackageSelectors = wizardResult.PackageSelectors createInput.PackageBaseBranch = wizardResult.PackageBaseBranch createInput.GenerateWorkspace = wizardResult.GenerateWorkspace @@ -225,6 +239,7 @@ func runCreate(args []string) error { BaseBranch: createInput.BaseBranch, ExecutionScope: createInput.ExecutionScope, RootSelector: createInput.RootSelector, + NoPackage: createInput.NoPackage, PackageSelectors: createInput.PackageSelectors, PackageBaseBranch: createInput.PackageBaseBranch, RootFiles: createInput.RootFiles, @@ -453,7 +468,7 @@ func printHelp() { fmt.Println(" flutree version") fmt.Println(" flutree create [options]") fmt.Println(" flutree add-repo [options]") - fmt.Println(" flutree list [--all]") + fmt.Println(" flutree list [options]") fmt.Println(" flutree complete [options]") fmt.Println(" flutree pubget [--force]") fmt.Println(" flutree update [--check|--apply]") @@ -526,6 +541,7 @@ func printCreateHelp() { fmt.Println(" --yes Acknowledge dry plan automatically in non-interactive mode") fmt.Println(" --non-interactive Disable prompts") fmt.Println(" --reuse-existing-branch Allow non-interactive reuse when target branch already exists") + fmt.Println(" --no-package Create root-only worktree without package selection") fmt.Println(" --copy-root-file Extra root-level file/pattern to copy into each worktree (repeatable)") fmt.Println(" --package Package repository selector (repeatable)") fmt.Println(" --package-base = Override package base branch (repeatable)") @@ -551,6 +567,7 @@ func printListHelp() { fmt.Println("") fmt.Println("Options:") fmt.Println(" --all Include unmanaged Git worktrees") + fmt.Println(" --global List managed worktrees across all registered repositories") fmt.Println(" -h, --help Show this help") } diff --git a/docs/usage.md b/docs/usage.md index 35882f6..d787d3c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -41,12 +41,15 @@ go test ./... - Creates a new worktree and branch. - Persists metadata in the global registry. - Runs preflight checkpoints before any mutation is applied. +- `--no-package` enables explicit root-only creation and skips package metadata flow. +- `--no-package` conflicts with `--package` and `--package-base`. - If a target branch already exists, asks for explicit reuse confirmation (or requires `--reuse-existing-branch` in non-interactive mode). - Syncs the configured base branch before any new branch worktree creation. -`flutree list [--all]` +`flutree list [--all] [--global]` - Lists managed entries for the current repository when running inside a repo. - If running outside a repo, it falls back to the global registry view. +- `--global` forces global registry scope from any current directory. - `--all` also includes unmanaged Git worktrees discovered from `git worktree list --porcelain` for discovered managed repos. `flutree complete NAME [OPTIONS]` @@ -73,6 +76,7 @@ Options: - `--base-branch TEXT`: source branch for root worktree creation (default: `main`). - `--scope PATH`: execution directory scope used to discover Flutter repositories (default: current directory). - `--root-repo TEXT`: explicit root repository selector for non-interactive usage. +- `--no-package`: explicit root-only mode; skip package selection and package metadata prompts. - `--package, -p TEXT`: explicit package repository selector (repeatable). - `--package-base TEXT`: per-package base branch override in `=` format (repeatable, default `develop`). - `--workspace/--no-workspace`: enable or disable VSCode `.code-workspace` generation (enabled by default). @@ -88,6 +92,7 @@ Examples: ```bash flutree create auth-fix --branch feature/auth-fix --scope . +flutree create auth-fix --scope ~/code --root-repo app-root --no-package --yes --non-interactive flutree create auth-fix --scope ~/code --root-repo app-root --package package-core --package package-ui flutree create auth-fix --scope ~/code --root-repo app-root --package package-core --package-base package-core=develop --yes --non-interactive ``` @@ -101,7 +106,7 @@ Generated worktrees are grouped into: - packages: `~/Documents/worktrees//packages//` Package override output: -- `flutree create` writes exactly one `pubspec_override.yaml` in the selected root worktree. +- `flutree create` writes exactly one `pubspec_overrides.yaml` in the selected root worktree. - dependency paths target selected package worktree paths. - `pubspec.yaml` is never modified by this workflow. @@ -123,6 +128,12 @@ VSCode workspace output (MVP): Options: - `--all`: include unmanaged worktrees in the output table. +- `--global`: force global registry scope from any current directory. + +Scope behavior: +- `flutree list`: repo-scoped when running inside a repository, global fallback outside repositories. +- `flutree list --global`: always global scope, independent of CWD. +- `flutree list --global --all`: global scope plus unmanaged rows across all selected repositories. Output fields: - `Name`: managed name, or `-` for unmanaged rows. diff --git a/integration/cli_contract_test.go b/integration/cli_contract_test.go index 4cd5aeb..3abcdc3 100644 --- a/integration/cli_contract_test.go +++ b/integration/cli_contract_test.go @@ -174,12 +174,12 @@ func TestSubcommandHelpContracts(t *testing.T) { { name: "create long help", args: []string{"create", "--help"}, - contains: []string{"flutree create [options]", "--branch", "--root-repo", "--package", "--package-base", "--copy-root-file"}, + contains: []string{"flutree create [options]", "--branch", "--root-repo", "--no-package", "--package", "--package-base", "--copy-root-file"}, }, { name: "create short help", args: []string{"create", "-h"}, - contains: []string{"flutree create [options]", "--branch", "--root-repo", "--package"}, + contains: []string{"flutree create [options]", "--branch", "--root-repo", "--no-package", "--package"}, }, { name: "add-repo help", @@ -199,7 +199,7 @@ func TestSubcommandHelpContracts(t *testing.T) { { name: "list help", args: []string{"list", "--help"}, - contains: []string{"flutree list [options]", "--all"}, + contains: []string{"flutree list [options]", "--all", "--global"}, }, { name: "update help", @@ -292,6 +292,195 @@ func TestListWorksOutsideGitRepoUsingGlobalRegistry(t *testing.T) { } } +func TestCreateNoPackageRejectsPackageFlagDeterministically(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + scope := filepath.Join(t.TempDir(), "workspace") + repo := filepath.Join(scope, "root-app") + initRepo(t, repo) + + run := func() runResult { + return runCLI( + t, bin, repo, testEnv(home), "", + "create", "feature-login", + "--scope", scope, + "--root-repo", "root-app", + "--no-package", + "--package", "core", + "--yes", + "--non-interactive", + ) + } + + first := run() + second := run() + if first.code != 2 || second.code != 2 { + t.Fatalf("expected exit code 2 for both runs, got %d and %d", first.code, second.code) + } + if !strings.Contains(first.stderr, "--no-package") || !strings.Contains(first.stderr, "--package") { + t.Fatalf("expected deterministic conflict message, got: %s", first.stderr) + } + if first.stderr != second.stderr { + t.Fatalf("expected deterministic stderr for same invalid flags. first=%q second=%q", first.stderr, second.stderr) + } +} + +func TestCreateNoPackageRejectsPackageBaseFlagDeterministically(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + scope := filepath.Join(t.TempDir(), "workspace") + repo := filepath.Join(scope, "root-app") + initRepo(t, repo) + + res := runCLI( + t, bin, repo, testEnv(home), "", + "create", "feature-login", + "--scope", scope, + "--root-repo", "root-app", + "--no-package", + "--package-base", "core=main", + "--yes", + "--non-interactive", + ) + if res.code != 2 { + t.Fatalf("expected 2, got %d (%s)", res.code, res.stderr) + } + if !strings.Contains(res.stderr, "--no-package") || !strings.Contains(res.stderr, "--package-base") { + t.Fatalf("expected conflict message with incompatible flags, got: %s", res.stderr) + } +} + +func TestCreateNoPackageCreatesRootOnlyArtifacts(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + scope := filepath.Join(t.TempDir(), "workspace") + rootRepo := filepath.Join(scope, "root-app") + coreRepo := filepath.Join(scope, "core-pkg") + initRepoWithPackageName(t, rootRepo, "root_app") + initRepoWithPackageName(t, coreRepo, "core") + + create := runCLI( + t, bin, rootRepo, testEnv(home), "", + "create", "feature-login", + "--branch", "feature/login", + "--scope", scope, + "--root-repo", "root-app", + "--no-package", + "--yes", + "--non-interactive", + ) + if create.code != 0 { + t.Fatalf("create failed: %d %s", create.code, create.stderr) + } + + overridePath := filepath.Join(home, "Documents", "worktrees", "feature-login", "root", "root-app", "pubspec_overrides.yaml") + content, err := os.ReadFile(overridePath) + if err != nil { + t.Fatalf("failed to read override file: %v", err) + } + if !strings.Contains(string(content), "dependency_overrides:\n {}") { + t.Fatalf("expected empty override map in no-package mode, got: %s", string(content)) + } + + pkgPath := filepath.Join(home, "Documents", "worktrees", "feature-login", "packages") + if _, err := os.Stat(pkgPath); !os.IsNotExist(err) { + t.Fatalf("expected no packages directory in no-package mode, stat err=%v", err) + } +} + +func TestListGlobalIncludesRowsOutsideCurrentRepo(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + + repoA := filepath.Join(t.TempDir(), "repo-a") + repoB := filepath.Join(t.TempDir(), "repo-b") + initRepo(t, repoA) + initRepo(t, repoB) + repoARoot := strings.TrimSpace(runGit(t, repoA, "rev-parse", "--show-toplevel")) + repoBRoot := strings.TrimSpace(runGit(t, repoB, "rev-parse", "--show-toplevel")) + + writeRegistry(t, home, map[string]any{ + "version": 1, + "records": []map[string]any{ + { + "name": "feature-a", + "branch": "feature/a", + "path": "/tmp/worktrees/feature-a", + "repo_root": repoARoot, + "status": "active", + }, + { + "name": "feature-b", + "branch": "feature/b", + "path": "/tmp/worktrees/feature-b", + "repo_root": repoBRoot, + "status": "active", + }, + }, + }) + + defaultRes := runCLI(t, bin, repoA, testEnv(home), "", "list") + if defaultRes.code != 0 { + t.Fatalf("expected 0 for default list, got %d (%s)", defaultRes.code, defaultRes.stderr) + } + if !strings.Contains(defaultRes.stdout, "feature-a") || strings.Contains(defaultRes.stdout, "feature-b") { + t.Fatalf("expected current-repo scoped list by default, got: %s", defaultRes.stdout) + } + + globalRes := runCLI(t, bin, repoA, testEnv(home), "", "list", "--global") + if globalRes.code != 0 { + t.Fatalf("expected 0 for global list, got %d (%s)", globalRes.code, globalRes.stderr) + } + if !strings.Contains(globalRes.stdout, "feature-a") || !strings.Contains(globalRes.stdout, "feature-b") { + t.Fatalf("expected global list to include all repo rows, got: %s", globalRes.stdout) + } +} + +func TestListGlobalAllIncludesUnmanagedAcrossRepos(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + + repoA := filepath.Join(t.TempDir(), "repo-a") + repoB := filepath.Join(t.TempDir(), "repo-b") + initRepo(t, repoA) + initRepo(t, repoB) + repoARoot := strings.TrimSpace(runGit(t, repoA, "rev-parse", "--show-toplevel")) + repoBRoot := strings.TrimSpace(runGit(t, repoB, "rev-parse", "--show-toplevel")) + + worktreeA := filepath.Join(t.TempDir(), "wt-a-unmanaged") + worktreeB := filepath.Join(t.TempDir(), "wt-b-unmanaged") + runGit(t, repoA, "worktree", "add", "-b", "feature/unmanaged-a", worktreeA, "main") + runGit(t, repoB, "worktree", "add", "-b", "feature/unmanaged-b", worktreeB, "main") + + writeRegistry(t, home, map[string]any{ + "version": 1, + "records": []map[string]any{ + { + "name": "feature-a", + "branch": "feature/a", + "path": "/tmp/worktrees/feature-a", + "repo_root": repoARoot, + "status": "active", + }, + { + "name": "feature-b", + "branch": "feature/b", + "path": "/tmp/worktrees/feature-b", + "repo_root": repoBRoot, + "status": "active", + }, + }, + }) + + res := runCLI(t, bin, repoA, testEnv(home), "", "list", "--global", "--all") + if res.code != 0 { + t.Fatalf("expected 0, got %d (%s)", res.code, res.stderr) + } + if !strings.Contains(res.stdout, filepath.Base(worktreeA)) || !strings.Contains(res.stdout, filepath.Base(worktreeB)) { + t.Fatalf("expected unmanaged rows from both repositories, got: %s", res.stdout) + } +} + func TestNonInteractiveCreateRequiresYes(t *testing.T) { bin := buildCLI(t) home := t.TempDir() diff --git a/internal/app/create_service.go b/internal/app/create_service.go index cbcc796..b2364af 100644 --- a/internal/app/create_service.go +++ b/internal/app/create_service.go @@ -42,9 +42,12 @@ func (s *CreateService) BuildDryPlan(input domain.CreateInput) (domain.CreateDry return domain.CreateDryPlan{}, err } - packages, err := s.resolvePackageRepos(repos, rootRepo, input.PackageSelectors, input.NonInteractive) - if err != nil { - return domain.CreateDryPlan{}, err + packages := []domain.DiscoveredFlutterRepo{} + if !input.NoPackage { + packages, err = s.resolvePackageRepos(repos, rootRepo, input.PackageSelectors, input.NonInteractive) + if err != nil { + return domain.CreateDryPlan{}, err + } } container := filepath.Join(destinationRoot(), normalizedName) diff --git a/internal/app/create_service_test.go b/internal/app/create_service_test.go index 058c89d..b6bc038 100644 --- a/internal/app/create_service_test.go +++ b/internal/app/create_service_test.go @@ -73,7 +73,10 @@ func (f *fakeCreateGit) DiscoverFlutterRepos(scope string) ([]domain.DiscoveredF return f.repos, nil } -type fakeCreatePrompt struct{ forceDecline bool } +type fakeCreatePrompt struct { + forceDecline bool + askTextCalls int +} func (f *fakeCreatePrompt) Confirm(message string, nonInteractive, assumeYes bool) (bool, error) { if f.forceDecline { @@ -91,9 +94,46 @@ func (f *fakeCreatePrompt) SelectPackages(message string, choices []string, nonI return choices, nil } func (f *fakeCreatePrompt) AskText(message, defaultValue string, nonInteractive bool) (string, error) { + f.askTextCalls++ return defaultValue, nil } +func TestBuildDryPlanNoPackageCreatesRootOnlyPlan(t *testing.T) { + root := t.TempDir() + repoRoot := filepath.Join(root, "root-app") + repoPkg := filepath.Join(root, "core-pkg") + g := &fakeCreateGit{ + repos: []domain.DiscoveredFlutterRepo{ + {Name: "root-app", RepoRoot: repoRoot, PackageName: "root_app"}, + {Name: "core-pkg", RepoRoot: repoPkg, PackageName: "core"}, + }, + } + p := &fakeCreatePrompt{} + svc := NewCreateService(g, &fakeRegistry{}, p) + + plan, err := svc.BuildDryPlan(domain.CreateInput{ + Name: "root-only", + Branch: "feature/root-only", + BaseBranch: "main", + ExecutionScope: root, + RootSelector: "root-app", + NoPackage: true, + NonInteractive: true, + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(plan.Packages) != 0 { + t.Fatalf("expected no package plans in no-package mode, got %d", len(plan.Packages)) + } + if !strings.Contains(plan.OverrideContent, "dependency_overrides:\n {}") { + t.Fatalf("expected empty overrides in no-package mode, got: %s", plan.OverrideContent) + } + if p.askTextCalls != 0 { + t.Fatalf("expected no package base prompts in no-package mode, got %d", p.askTextCalls) + } +} + func TestBuildDryPlanBuildsRootAndPackagePlans(t *testing.T) { root := t.TempDir() repoRoot := filepath.Join(root, "root-app") diff --git a/internal/app/list_service.go b/internal/app/list_service.go index e1ae426..3246619 100644 --- a/internal/app/list_service.go +++ b/internal/app/list_service.go @@ -17,10 +17,13 @@ func NewListService(git GitPort, registry RegistryPort) *ListService { return &ListService{git: git, registry: registry} } -func (s *ListService) Run(showAll bool) ([]domain.ListRow, error) { - currentRepo, err := s.git.EnsureRepo() - if err != nil { - currentRepo = "" +func (s *ListService) Run(input domain.ListInput) ([]domain.ListRow, error) { + currentRepo := "" + if !input.GlobalScope { + repo, err := s.git.EnsureRepo() + if err == nil { + currentRepo = repo + } } allRecords, err := s.registry.ListRecords() @@ -96,7 +99,7 @@ func (s *ListService) Run(showAll bool) ([]domain.ListRow, error) { }) } - if showAll { + if input.ShowAll { for _, entries := range worktreesByRepo { for _, e := range entries { p := filepath.Clean(e.Path) diff --git a/internal/app/services_test.go b/internal/app/services_test.go index 563a9b2..70a6191 100644 --- a/internal/app/services_test.go +++ b/internal/app/services_test.go @@ -346,7 +346,7 @@ func TestListHidesPackageRowsAndAnnotatesRootCount(t *testing.T) { }} s := NewListService(g, r) - rows, err := s.Run(false) + rows, err := s.Run(domain.ListInput{ShowAll: false}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -383,7 +383,7 @@ func TestListOutsideRepoFallsBackToGlobalRegistry(t *testing.T) { }} s := NewListService(g, r) - rows, err := s.Run(false) + rows, err := s.Run(domain.ListInput{ShowAll: false}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -391,3 +391,62 @@ func TestListOutsideRepoFallsBackToGlobalRegistry(t *testing.T) { t.Fatalf("expected 2 rows, got %d", len(rows)) } } + +func TestListGlobalScopeIgnoresCurrentRepoFilter(t *testing.T) { + g := &fakeGit{ + currentRepo: "/tmp/repo-a", + worktrees: map[string][]domain.GitWorktreeEntry{ + "/tmp/repo-a": {{Path: "/tmp/wt/a", Branch: "feature/a"}}, + "/tmp/repo-b": {{Path: "/tmp/wt/b", Branch: "feature/b"}}, + }, + } + r := &fakeRegistry{records: []domain.RegistryRecord{ + {Name: "a", Branch: "feature/a", Path: "/tmp/wt/a", RepoRoot: "/tmp/repo-a", Status: "active"}, + {Name: "b", Branch: "feature/b", Path: "/tmp/wt/b", RepoRoot: "/tmp/repo-b", Status: "active"}, + }} + + s := NewListService(g, r) + rows, err := s.Run(domain.ListInput{GlobalScope: true}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rows) != 2 { + t.Fatalf("expected global rows from all repos, got %d", len(rows)) + } +} + +func TestListGlobalAllIncludesUnmanagedAcrossRegisteredRepos(t *testing.T) { + g := &fakeGit{ + currentRepo: "/tmp/repo-a", + worktrees: map[string][]domain.GitWorktreeEntry{ + "/tmp/repo-a": { + {Path: "/tmp/wt/a", Branch: "feature/a"}, + {Path: "/tmp/wt/a-unmanaged", Branch: "feature/unmanaged-a"}, + }, + "/tmp/repo-b": { + {Path: "/tmp/wt/b", Branch: "feature/b"}, + {Path: "/tmp/wt/b-unmanaged", Branch: "feature/unmanaged-b"}, + }, + }, + } + r := &fakeRegistry{records: []domain.RegistryRecord{ + {Name: "a", Branch: "feature/a", Path: "/tmp/wt/a", RepoRoot: "/tmp/repo-a", Status: "active"}, + {Name: "b", Branch: "feature/b", Path: "/tmp/wt/b", RepoRoot: "/tmp/repo-b", Status: "active"}, + }} + + s := NewListService(g, r) + rows, err := s.Run(domain.ListInput{ShowAll: true, GlobalScope: true}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + unmanaged := 0 + for _, row := range rows { + if row.Status == "unmanaged" { + unmanaged++ + } + } + if unmanaged != 2 { + t.Fatalf("expected unmanaged rows from both repos, got %d (%+v)", unmanaged, rows) + } +} diff --git a/internal/domain/types.go b/internal/domain/types.go index 6d57618..3aa388c 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -66,6 +66,11 @@ type ListRow struct { PackageCount int } +type ListInput struct { + ShowAll bool + GlobalScope bool +} + type CompleteInput struct { Name string Yes bool @@ -112,6 +117,7 @@ type CreateInput struct { BaseBranch string ExecutionScope string RootSelector string + NoPackage bool PackageSelectors []string PackageBaseBranch map[string]string RootFiles []string diff --git a/internal/ui/create_wizard.go b/internal/ui/create_wizard.go index 04334f9..3eb6daf 100644 --- a/internal/ui/create_wizard.go +++ b/internal/ui/create_wizard.go @@ -17,6 +17,7 @@ type CreateWizardInput struct { BaseBranch string GenerateWorkspace bool RootSelector string + NoPackage bool PackageSelectors []string PackageBaseBranch map[string]string } @@ -27,6 +28,7 @@ type CreateWizardResult struct { BaseBranch string GenerateWorkspace bool RootSelector string + NoPackage bool PackageSelectors []string PackageBaseBranch map[string]string Apply bool @@ -62,6 +64,7 @@ type createWizardModel struct { branch string baseBranch string generateWorkspace bool + noPackage bool rootIndex int @@ -114,6 +117,7 @@ func RunCreateWizard(input CreateWizardInput, repos []domain.DiscoveredFlutterRe BaseBranch: strings.TrimSpace(finalModel.baseBranch), GenerateWorkspace: finalModel.generateWorkspace, RootSelector: finalModel.repos[finalModel.rootIndex].Name, + NoPackage: finalModel.noPackage, PackageSelectors: selectors, PackageBaseBranch: copyStringMap(finalModel.pkgBaseBranch), Apply: finalModel.finalChoice == 1, @@ -163,6 +167,7 @@ func newCreateWizardModel(input CreateWizardInput, repos []domain.DiscoveredFlut branch: branch, baseBranch: baseBranch, generateWorkspace: input.GenerateWorkspace, + noPackage: input.NoPackage, rootIndex: rootIndex, pkgSelected: map[int]bool{}, pkgBaseBranch: copyStringMap(input.PackageBaseBranch), @@ -244,6 +249,11 @@ func (m createWizardModel) View() string { case stepPackages: b.WriteString(wizardSectionStyle.Render("Step 3 - Select packages")) b.WriteString("\n") + if m.noPackage { + b.WriteString("Package selection skipped (--no-package).\n") + b.WriteString(wizardHintStyle.Render("Enter to continue")) + break + } if len(m.packageCandidates) == 0 { b.WriteString("No package candidates found for this root.\n") b.WriteString(wizardHintStyle.Render("Enter to continue")) @@ -308,6 +318,13 @@ func (m createWizardModel) updateRootRepo(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.errMsg = "" case "enter": + if m.noPackage { + m.selectedPackages = nil + m.pkgBaseBranch = map[string]string{} + m.step = stepBranches + m.prepareBranchFieldInput() + return m, nil + } selected := m.selectedPackageSelectors() m.refreshPackageCandidates(selected) m.step = stepPackages @@ -321,6 +338,21 @@ func (m createWizardModel) updateRootRepo(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m createWizardModel) updatePackages(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.noPackage { + if msg.String() == "enter" { + m.selectedPackages = nil + m.pkgBaseBranch = map[string]string{} + m.step = stepBranches + m.prepareBranchFieldInput() + } + if msg.String() == "esc" { + m.cancelled = true + m.done = true + return m, tea.Quit + } + return m, nil + } + if len(m.packageCandidates) == 0 { if msg.String() == "enter" { m.selectedPackages = nil diff --git a/internal/ui/create_wizard_test.go b/internal/ui/create_wizard_test.go index 3fbd65c..a06c295 100644 --- a/internal/ui/create_wizard_test.go +++ b/internal/ui/create_wizard_test.go @@ -59,6 +59,46 @@ func TestCreateWizardRequiresAtLeastOnePackage(t *testing.T) { } } +func TestCreateWizardNoPackageSkipsPackageValidation(t *testing.T) { + repos := []domain.DiscoveredFlutterRepo{ + {Name: "root-app", PackageName: "root_app", RepoRoot: "/repos/root-app"}, + {Name: "core", PackageName: "core", RepoRoot: "/repos/core"}, + } + + m := newCreateWizardModel(CreateWizardInput{NoPackage: true}, repos) + m.step = stepPackages + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + newModel := updated.(createWizardModel) + + if newModel.errMsg != "" { + t.Fatalf("did not expect validation error in no-package mode: %q", newModel.errMsg) + } + if newModel.step != stepBranches { + t.Fatalf("expected branches step in no-package mode, got %v", newModel.step) + } +} + +func TestCreateWizardNoPackageRootStepJumpsToBranches(t *testing.T) { + repos := []domain.DiscoveredFlutterRepo{ + {Name: "root-app", PackageName: "root_app", RepoRoot: "/repos/root-app"}, + {Name: "core", PackageName: "core", RepoRoot: "/repos/core"}, + } + + m := newCreateWizardModel(CreateWizardInput{RootSelector: "root-app", NoPackage: true}, repos) + m.step = stepRootRepo + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + newModel := updated.(createWizardModel) + + if newModel.step != stepBranches { + t.Fatalf("expected to jump to branch step in no-package mode, got %v", newModel.step) + } + if len(newModel.selectedPackages) != 0 { + t.Fatalf("expected no selected packages in no-package mode") + } +} + func TestCreateWizardRootEnterMovesToPackageStepWhenCandidatesExist(t *testing.T) { repos := []domain.DiscoveredFlutterRepo{ {Name: "root-app", PackageName: "root_app", RepoRoot: "/repos/root-app"},