diff --git a/README.md b/README.md index 53315ce..2ebc88b 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,12 @@ - [๐Ÿ”ง Requirements](#-requirements) - [๐Ÿ—๏ธ Commands Reference](#%EF%B8%8F-commands-reference) - [create](#create) + - [add-repo](#add-repo) - [list](#list) - [complete](#complete) - [pubget](#pubget) - [๐Ÿ› ๏ธ Quickstart](#%EF%B8%8F-quickstart) +- [๐Ÿ†• Recent CLI adjustments](#-recent-cli-adjustments) - [โš™๏ธ Advanced Usage](#%EF%B8%8F-advanced-usage) - [๐Ÿ”„ Version and update](#-version-and-update) - [๐Ÿงช Testing](#-testing) @@ -74,6 +76,7 @@ Run from inside a Git repository: ```bash flutree create feature-login --branch feature/login --root-repo repo --scope . --yes --non-interactive +flutree add-repo feature-login --repo core-pkg --scope . --non-interactive flutree list flutree --version flutree update --check @@ -92,6 +95,19 @@ Default destination root is `~/Documents/worktrees`, generating: `~/Documents/worktrees//` +## ๐Ÿ†• 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`. +- `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. + ## โš™๏ธ Advanced Usage ### Two-phase Flow @@ -102,13 +118,16 @@ 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`. -When creating a new branch, `create` syncs the configured base branch first and fails fast if sync cannot be completed. -For deterministic package targeting, pass `--package` and optional `--package-base` overrides. +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. +For deterministic package targeting during `create`, use `--package` and optional `--package-base`. +If you forget to include a repository at create time, attach it later with `add-repo`. -Example with explicit package selectors and workspace output: +Example with root env propagation: ```bash -flutree create feature-login --scope . --root-repo root-app --package core-pkg --package-base core-pkg=develop --yes --non-interactive +flutree create feature-login --scope . --root-repo root-app --copy-root-file ".env.local" --yes --non-interactive ``` ### Workspace Control @@ -116,11 +135,11 @@ flutree create feature-login --scope . --root-repo root-app --package core-pkg - Disable workspace generation when needed: ```bash -flutree create feature-login --scope . --root-repo root-app --package core-pkg --yes --non-interactive --no-workspace +flutree create feature-login --scope . --root-repo root-app --yes --non-interactive --no-workspace ``` Package override generation rules: -- `flutree create` writes one `pubspec_override.yaml` in the selected root worktree. +- `flutree create` writes one `pubspec_overrides.yaml` in the selected root worktree. - `pubspec.yaml` is not modified. VSCode workspace output is MVP-only and includes `folders` entries only. @@ -129,6 +148,11 @@ Use `--no-workspace` to skip `.code-workspace` output entirely. ## ๐Ÿ—๏ธ Commands Reference +All subcommands support: + +- `--help` +- `-h` + ### create Creates a managed worktree and stores metadata in a global registry. @@ -151,6 +175,24 @@ flutree create [options] | `--reuse-existing-branch` | boolean | `false` | Reuse existing local branch in non-interactive mode | | `--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 | + +### add-repo + +Attaches additional repositories to an existing managed workspace and regenerates `pubspec_overrides.yaml`. + +Usage: +``` +flutree add-repo [options] +``` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--scope` | string | `.` | Directory scope used to discover Flutter repositories | +| `--repo` | string | | Repository selector to attach (repeatable). Required in non-interactive mode | +| `--package-base` | string | | Override package base branch as `=` (repeatable) | +| `--copy-root-file` | string | | Extra root-level file/pattern to copy into attached worktrees (repeatable). Default includes `.env` and `.env.*` | +| `--non-interactive` | boolean | `false` | Disable prompts | ### list @@ -183,7 +225,7 @@ flutree complete [options] ### pubget -Runs `pub get` for all managed package repos in parallel, then runs root last. +Runs `pub get` for all managed package repos in parallel, then runs root last. Includes interactive loading feedback on TTY. Usage: ``` @@ -290,4 +332,3 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) for interactive UI - Inspired by the need to manage complex Flutter monorepo workflows - diff --git a/cmd/flutree/main.go b/cmd/flutree/main.go index cc65a40..f61cab6 100644 --- a/cmd/flutree/main.go +++ b/cmd/flutree/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "flag" "fmt" "os" @@ -32,6 +33,8 @@ func main() { switch cmd { case "create": runtime.ExitOnError(runCreate(os.Args[2:])) + case "add-repo": + runtime.ExitOnError(runAddRepo(os.Args[2:])) case "list": runtime.ExitOnError(runList(os.Args[2:])) case "complete": @@ -50,11 +53,20 @@ func main() { } func runList(args []string) error { - fs := flag.NewFlagSet("list", flag.ContinueOnError) + fs := newFlagSet("list", printListHelp) showAll := fs.Bool("all", false, "Include unmanaged Git worktrees.") - if err := fs.Parse(args); err != nil { - return domain.NewError(domain.CategoryInput, 2, "Invalid list arguments.", "", err) + if len(args) > 0 && isHelpToken(args[0]) { + printListHelp() + return nil + } + helpRequested, err := parseFlagSet(fs, args, "Invalid list arguments.", "") + if err != nil { + return err } + if helpRequested { + return nil + } + service := app.NewListService(&infraGit.Gateway{}, registry.NewDefault()) rows, err := service.Run(*showAll) if err != nil { @@ -65,17 +77,26 @@ func runList(args []string) error { } func runComplete(args []string) error { + fs := newFlagSet("complete", printCompleteHelp) + yes := fs.Bool("yes", false, "Skip interactive confirmation.") + force := fs.Bool("force", false, "Force worktree removal.") + nonInteractive := fs.Bool("non-interactive", false, "Disable prompts.") + if len(args) > 0 && isHelpToken(args[0]) { + printCompleteHelp() + return nil + } if len(args) < 1 { return domain.NewError(domain.CategoryInput, 2, "Missing worktree name.", "Usage: flutree complete [options]", nil) } name := args[0] - fs := flag.NewFlagSet("complete", flag.ContinueOnError) - yes := fs.Bool("yes", false, "Skip interactive confirmation.") - force := fs.Bool("force", false, "Force worktree removal.") - nonInteractive := fs.Bool("non-interactive", false, "Disable prompts.") - if err := fs.Parse(args[1:]); err != nil { - return domain.NewError(domain.CategoryInput, 2, "Invalid complete arguments.", "", err) + helpRequested, err := parseFlagSet(fs, args[1:], "Invalid complete arguments.", "") + if err != nil { + return err } + if helpRequested { + return nil + } + service := app.NewCompleteService(&infraGit.Gateway{}, registry.NewDefault(), prompt.New()) result, err := service.Run(domain.CompleteInput{ Name: name, @@ -91,11 +112,7 @@ func runComplete(args []string) error { } func runCreate(args []string) error { - if len(args) < 1 { - return domain.NewError(domain.CategoryInput, 2, "Missing worktree name.", "Usage: flutree create [options]", nil) - } - name := args[0] - fs := flag.NewFlagSet("create", flag.ContinueOnError) + fs := newFlagSet("create", printCreateHelp) branch := fs.String("branch", "", "Target branch name.") baseBranch := fs.String("base-branch", "main", "Base branch for worktree creation.") scope := fs.String("scope", ".", "Directory scope used to discover Flutter repositories.") @@ -108,11 +125,25 @@ func runCreate(args []string) error { var packages multiFlag var packageBase multiFlag + var copyRootFiles multiFlag fs.Var(&packages, "package", "Package repository selector. Repeatable.") fs.Var(&packageBase, "package-base", "Override package base branch as =. Repeatable.") + fs.Var(©RootFiles, "copy-root-file", "Extra root-level file/pattern to copy into each worktree. Repeatable.") - if err := fs.Parse(args[1:]); err != nil { - return domain.NewError(domain.CategoryInput, 2, "Invalid create arguments.", "", err) + if len(args) > 0 && isHelpToken(args[0]) { + printCreateHelp() + return nil + } + if len(args) < 1 { + return domain.NewError(domain.CategoryInput, 2, "Missing worktree name.", "Usage: flutree create [options]", nil) + } + name := args[0] + helpRequested, err := parseFlagSet(fs, args[1:], "Invalid create arguments.", "") + if err != nil { + return err + } + if helpRequested { + return 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) @@ -146,6 +177,7 @@ func runCreate(args []string) error { RootSelector: *rootRepo, PackageSelectors: packages, PackageBaseBranch: baseMap, + RootFiles: copyRootFiles, GenerateWorkspace: genWorkspace, Yes: *yes, NonInteractive: *nonInteractive, @@ -195,6 +227,7 @@ func runCreate(args []string) error { RootSelector: createInput.RootSelector, PackageSelectors: createInput.PackageSelectors, PackageBaseBranch: createInput.PackageBaseBranch, + RootFiles: createInput.RootFiles, GenerateWorkspace: createInput.GenerateWorkspace, Yes: createInput.Yes, NonInteractive: createInput.NonInteractive, @@ -236,9 +269,23 @@ func runCreate(args []string) error { return nil } + syncWithRemote := false + if !*nonInteractive { + confirmSync, err := promptAdapter.Confirm( + "Update local branches from origin before creating worktrees?", + false, + false, + ) + if err != nil { + return err + } + syncWithRemote = confirmSync + } + result, err := service.Apply(plan, domain.CreateApplyOptions{ NonInteractive: createInput.NonInteractive, ReuseExistingBranch: *reuseExistingBranch, + SyncWithRemote: syncWithRemote, }) if err != nil { return err @@ -248,22 +295,32 @@ func runCreate(args []string) error { } func runPubGet(args []string) error { + fs := newFlagSet("pubget", printPubGetHelp) + force := fs.Bool("force", false, "Clean cache and remove pubspec.lock before pub get.") + if len(args) > 0 && isHelpToken(args[0]) { + printPubGetHelp() + return nil + } if len(args) < 1 { return domain.NewError(domain.CategoryInput, 2, "Missing workspace name.", "Usage: flutree pubget [--force]", nil) } name := args[0] - fs := flag.NewFlagSet("pubget", flag.ContinueOnError) - force := fs.Bool("force", false, "Clean cache and remove pubspec.lock before pub get.") - if err := fs.Parse(args[1:]); err != nil { - return domain.NewError(domain.CategoryInput, 2, "Invalid pubget arguments.", "", err) + helpRequested, err := parseFlagSet(fs, args[1:], "Invalid pubget arguments.", "") + if err != nil { + return err + } + if helpRequested { + return nil } service := app.NewPubGetService(registry.NewDefault(), &infraPub.Gateway{}) + stopLoading := ui.StartLoading("Running pub get across workspace...") result, err := service.Run(domain.PubGetInput{ Name: name, Force: *force, }) + stopLoading(err == nil) if err != nil { return err } @@ -272,8 +329,75 @@ func runPubGet(args []string) error { return nil } +func runAddRepo(args []string) error { + fs := newFlagSet("add-repo", printAddRepoHelp) + scope := fs.String("scope", ".", "Directory scope used to discover Flutter repositories.") + nonInteractive := fs.Bool("non-interactive", false, "Disable prompts.") + var repos multiFlag + var packageBase multiFlag + var copyRootFiles multiFlag + fs.Var(&repos, "repo", "Repository selector to attach. Repeatable.") + fs.Var(&packageBase, "package-base", "Override package base branch as =. Repeatable.") + fs.Var(©RootFiles, "copy-root-file", "Extra root-level file/pattern to copy into each attached worktree. Repeatable.") + if len(args) > 0 && isHelpToken(args[0]) { + printAddRepoHelp() + return nil + } + if len(args) < 1 { + return domain.NewError(domain.CategoryInput, 2, "Missing workspace name.", "Usage: flutree add-repo [options]", nil) + } + workspaceName := strings.TrimSpace(args[0]) + if workspaceName == "" { + return domain.NewError(domain.CategoryInput, 2, "Missing workspace name.", "Usage: flutree add-repo [options]", nil) + } + + helpRequested, err := parseFlagSet(fs, args[1:], "Invalid add-repo arguments.", "") + if err != nil { + return err + } + if helpRequested { + return nil + } + + baseMap := map[string]string{} + for _, entry := range packageBase { + parts := strings.SplitN(strings.TrimSpace(entry), "=", 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" { + return domain.NewError(domain.CategoryInput, 2, "Invalid --package-base format.", "Use --package-base =.", nil) + } + baseMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + + service := app.NewAddRepoService(&infraGit.Gateway{}, registry.NewDefault(), prompt.New()) + result, err := service.Run(domain.AddRepoInput{ + WorkspaceName: workspaceName, + ExecutionScope: *scope, + RepoSelectors: repos, + PackageBaseBranch: baseMap, + RootFiles: copyRootFiles, + NonInteractive: *nonInteractive, + }) + if err != nil { + return err + } + ui.RenderAddRepoSuccess(result) + return nil +} + func runVersion(args []string) error { - if len(args) != 0 { + fs := newFlagSet("version", printVersionHelp) + if len(args) > 0 && isHelpToken(args[0]) { + printVersionHelp() + return nil + } + helpRequested, err := parseFlagSet(fs, args, "Invalid version arguments.", "Usage: flutree version") + if err != nil { + return err + } + if helpRequested { + return nil + } + if fs.NArg() != 0 { return domain.NewError(domain.CategoryInput, 2, "Version command does not accept arguments.", "Use 'flutree version' or 'flutree --version'.", nil) } v := strings.TrimSpace(version) @@ -285,11 +409,19 @@ func runVersion(args []string) error { } func runUpdate(args []string) error { - fs := flag.NewFlagSet("update", flag.ContinueOnError) + fs := newFlagSet("update", printUpdateHelp) check := fs.Bool("check", false, "Check whether a brew update is available.") apply := fs.Bool("apply", false, "Apply brew update now.") - if err := fs.Parse(args); err != nil { - return domain.NewError(domain.CategoryInput, 2, "Invalid update arguments.", "Usage: flutree update [--check|--apply]", err) + if len(args) > 0 && isHelpToken(args[0]) { + printUpdateHelp() + return nil + } + helpRequested, err := parseFlagSet(fs, args, "Invalid update arguments.", "Usage: flutree update [--check|--apply]") + if err != nil { + return err + } + if helpRequested { + return nil } if fs.NArg() > 0 { return domain.NewError(domain.CategoryInput, 2, "Update command does not accept positional arguments.", "Usage: flutree update [--check|--apply]", nil) @@ -320,10 +452,13 @@ func printHelp() { fmt.Println(" flutree --version") fmt.Println(" flutree version") fmt.Println(" flutree create [options]") + fmt.Println(" flutree add-repo [options]") fmt.Println(" flutree list [--all]") fmt.Println(" flutree complete [options]") fmt.Println(" flutree pubget [--force]") fmt.Println(" flutree update [--check|--apply]") + fmt.Println("") + fmt.Println("Tip: Use `flutree --help` to inspect command flags.") } type multiFlag []string @@ -351,3 +486,108 @@ func safeVersion(value string) string { } return trimmed } + +func newFlagSet(name string, usage func()) *flag.FlagSet { + fs := flag.NewFlagSet(name, flag.ContinueOnError) + fs.Usage = usage + return fs +} + +func parseFlagSet(fs *flag.FlagSet, args []string, invalidMessage, hint string) (bool, error) { + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return true, nil + } + return false, domain.NewError(domain.CategoryInput, 2, invalidMessage, hint, err) + } + return false, nil +} + +func isHelpToken(token string) bool { + switch strings.TrimSpace(token) { + case "-h", "--help": + return true + default: + return false + } +} + +func printCreateHelp() { + fmt.Println("Usage:") + fmt.Println(" flutree create [options]") + fmt.Println("") + fmt.Println("Options:") + fmt.Println(" --branch Target branch name") + fmt.Println(" --base-branch Base branch for worktree creation (default: main)") + fmt.Println(" --scope Directory scope used to discover Flutter repositories (default: .)") + fmt.Println(" --root-repo Root repository selector") + fmt.Println(" --workspace Generate VSCode workspace file (default: true)") + fmt.Println(" --no-workspace Disable VSCode workspace generation") + 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(" --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)") + fmt.Println(" -h, --help Show this help") +} + +func printAddRepoHelp() { + fmt.Println("Usage:") + fmt.Println(" flutree add-repo [options]") + fmt.Println("") + fmt.Println("Options:") + fmt.Println(" --scope Directory scope used to discover Flutter repositories (default: .)") + fmt.Println(" --repo Repository selector to attach (repeatable)") + fmt.Println(" --package-base = Override package base branch (repeatable)") + fmt.Println(" --copy-root-file Extra root-level file/pattern to copy into each attached worktree (repeatable)") + fmt.Println(" --non-interactive Disable prompts") + fmt.Println(" -h, --help Show this help") +} + +func printListHelp() { + fmt.Println("Usage:") + fmt.Println(" flutree list [options]") + fmt.Println("") + fmt.Println("Options:") + fmt.Println(" --all Include unmanaged Git worktrees") + fmt.Println(" -h, --help Show this help") +} + +func printCompleteHelp() { + fmt.Println("Usage:") + fmt.Println(" flutree complete [options]") + fmt.Println("") + fmt.Println("Options:") + fmt.Println(" --yes Skip interactive confirmation") + fmt.Println(" --force Force worktree removal") + fmt.Println(" --non-interactive Disable prompts") + fmt.Println(" -h, --help Show this help") +} + +func printPubGetHelp() { + fmt.Println("Usage:") + fmt.Println(" flutree pubget [options]") + fmt.Println("") + fmt.Println("Options:") + fmt.Println(" --force Clean cache and remove pubspec.lock before pub get") + fmt.Println(" -h, --help Show this help") +} + +func printUpdateHelp() { + fmt.Println("Usage:") + fmt.Println(" flutree update [options]") + fmt.Println("") + fmt.Println("Options:") + fmt.Println(" --check Check whether a brew update is available") + fmt.Println(" --apply Apply brew update now") + fmt.Println(" -h, --help Show this help") +} + +func printVersionHelp() { + fmt.Println("Usage:") + fmt.Println(" flutree version") + fmt.Println("") + fmt.Println("Options:") + fmt.Println(" -h, --help Show this help") +} diff --git a/integration/cli_contract_test.go b/integration/cli_contract_test.go index 052d5e4..4cd5aeb 100644 --- a/integration/cli_contract_test.go +++ b/integration/cli_contract_test.go @@ -75,6 +75,11 @@ func runGit(t *testing.T, cwd string, args ...string) string { } func initRepo(t *testing.T, path string) { + t.Helper() + initRepoWithPackageName(t, path, "sample") +} + +func initRepoWithPackageName(t *testing.T, path, packageName string) { t.Helper() if err := os.MkdirAll(path, 0o755); err != nil { t.Fatal(err) @@ -85,7 +90,7 @@ func initRepo(t *testing.T, path string) { if err := os.WriteFile(filepath.Join(path, "README.md"), []byte("seed\n"), 0o644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(path, "pubspec.yaml"), []byte("name: sample\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(path, "pubspec.yaml"), []byte("name: "+packageName+"\n"), 0o644); err != nil { t.Fatal(err) } runGit(t, path, "add", "README.md", "pubspec.yaml") @@ -149,6 +154,101 @@ func TestCLIHelpListsExpectedCommands(t *testing.T) { if !strings.Contains(res.stdout, "create") || !strings.Contains(res.stdout, "list") || !strings.Contains(res.stdout, "complete") { t.Fatalf("unexpected help output: %s", res.stdout) } + if !strings.Contains(res.stdout, "flutree --help") { + t.Fatalf("expected subcommand help hint, got: %s", res.stdout) + } +} + +func TestSubcommandHelpContracts(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside") + _ = os.MkdirAll(outside, 0o755) + env := testEnvWithPath(home, "") + + cases := []struct { + name string + args []string + contains []string + }{ + { + name: "create long help", + args: []string{"create", "--help"}, + contains: []string{"flutree create [options]", "--branch", "--root-repo", "--package", "--package-base", "--copy-root-file"}, + }, + { + name: "create short help", + args: []string{"create", "-h"}, + contains: []string{"flutree create [options]", "--branch", "--root-repo", "--package"}, + }, + { + name: "add-repo help", + args: []string{"add-repo", "--help"}, + contains: []string{"flutree add-repo [options]", "--repo", "--package-base", "--copy-root-file"}, + }, + { + name: "complete help", + args: []string{"complete", "--help"}, + contains: []string{"flutree complete [options]", "--yes", "--force"}, + }, + { + name: "pubget help", + args: []string{"pubget", "--help"}, + contains: []string{"flutree pubget [options]", "--force"}, + }, + { + name: "list help", + args: []string{"list", "--help"}, + contains: []string{"flutree list [options]", "--all"}, + }, + { + name: "update help", + args: []string{"update", "--help"}, + contains: []string{"flutree update [options]", "--check", "--apply"}, + }, + { + name: "version help", + args: []string{"version", "--help"}, + contains: []string{"flutree version", "-h, --help"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := runCLI(t, bin, outside, env, "", tc.args...) + if res.code != 0 { + t.Fatalf("expected 0, got %d (%s)", res.code, res.stderr) + } + for _, want := range tc.contains { + if !strings.Contains(res.stdout, want) { + t.Fatalf("help output missing %q: %s", want, res.stdout) + } + } + }) + } +} + +func TestMissingPositionalStillFailsWithoutHelp(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside") + _ = os.MkdirAll(outside, 0o755) + + create := runCLI(t, bin, outside, testEnv(home), "", "create") + if create.code != 2 { + t.Fatalf("expected 2, got %d (%s)", create.code, create.stderr) + } + if !strings.Contains(create.stderr, "Missing worktree name") { + t.Fatalf("unexpected create stderr: %s", create.stderr) + } + + addRepo := runCLI(t, bin, outside, testEnv(home), "", "add-repo") + if addRepo.code != 2 { + t.Fatalf("expected 2, got %d (%s)", addRepo.code, addRepo.stderr) + } + if !strings.Contains(addRepo.stderr, "Missing workspace name") { + t.Fatalf("unexpected add-repo stderr: %s", addRepo.stderr) + } } func TestUnknownCommandExitsNonZero(t *testing.T) { @@ -240,6 +340,57 @@ func TestInteractiveCreateWithYesStillRequiresToken(t *testing.T) { } } +func TestInteractiveCreatePromptsForRemoteSyncBeforeApply(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), "APPLY\ny\n", + "create", "feature-login", + "--branch", "feature/login", + "--scope", scope, + "--root-repo", "root-app", + ) + if res.code != 0 { + t.Fatalf("expected 0, got %d (%s)", res.code, res.stderr) + } + if !strings.Contains(res.stdout, "Update local branches from origin before creating worktrees?") { + t.Fatalf("expected sync confirmation prompt, got: %s", res.stdout) + } +} + +func TestInteractiveCreateWithSyncDeclinedDoesNotFetchRemote(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + scope := filepath.Join(t.TempDir(), "workspace") + repo := filepath.Join(scope, "root-app") + initRepo(t, repo) + + runGit(t, repo, "remote", "set-url", "origin", filepath.Join(t.TempDir(), "missing-origin.git")) + + res := runCLI( + t, bin, repo, testEnv(home), "APPLY\nN\n", + "create", "feature-login", + "--branch", "feature/login", + "--scope", scope, + "--root-repo", "root-app", + ) + if res.code != 0 { + t.Fatalf("expected 0 when sync declined, got %d (%s)", res.code, res.stderr) + } + if strings.Contains(res.stderr, "Failed to sync base branch from origin before creating worktree") { + t.Fatalf("unexpected remote sync failure when user declined sync: %s", res.stderr) + } + + rootWorktree := filepath.Join(home, "Documents", "worktrees", "feature-login", "root", "root-app") + if _, err := os.Stat(rootWorktree); err != nil { + t.Fatalf("expected root worktree to be created without remote sync, err=%v", err) + } +} + func TestNonInteractiveCreateRequiresExplicitReuseFlagWhenBranchExists(t *testing.T) { bin := buildCLI(t) home := t.TempDir() @@ -288,6 +439,150 @@ func TestNonInteractiveCreateAllowsReuseWithExplicitFlag(t *testing.T) { } } +func TestCreateAcceptsPackageFlags(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", + "--package", "core-pkg", + "--package-base", "core-pkg=main", + "--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), "core:") || !strings.Contains(string(content), "packages/core-pkg") { + t.Fatalf("expected create override to include selected package, got: %s", string(content)) + } +} + +func TestCreateCopiesEnvFilesByDefault(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + scope := filepath.Join(t.TempDir(), "workspace") + repo := filepath.Join(scope, "root-app") + initRepoWithPackageName(t, repo, "root_app") + + if err := os.WriteFile(filepath.Join(repo, ".env"), []byte("TOKEN=abc\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".env.dev"), []byte("TOKEN=dev\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", ".env", ".env.dev") + runGit(t, repo, "commit", "-m", "add env fixtures") + runGit(t, repo, "push", "origin", "main") + + create := runCLI( + t, bin, repo, testEnv(home), "", + "create", "feature-login", + "--branch", "feature/login", + "--scope", scope, + "--root-repo", "root-app", + "--yes", + "--non-interactive", + ) + if create.code != 0 { + t.Fatalf("create failed: %d %s", create.code, create.stderr) + } + + rootWorktree := filepath.Join(home, "Documents", "worktrees", "feature-login", "root", "root-app") + if _, err := os.Stat(filepath.Join(rootWorktree, ".env")); err != nil { + t.Fatalf("expected .env copied, err=%v", err) + } + if _, err := os.Stat(filepath.Join(rootWorktree, ".env.dev")); err != nil { + t.Fatalf("expected .env.dev copied, err=%v", err) + } +} + +func TestCreateBranchHasNoUpstreamTracking(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + scope := filepath.Join(t.TempDir(), "workspace") + repo := filepath.Join(scope, "root-app") + initRepoWithPackageName(t, repo, "root_app") + + create := runCLI( + t, bin, repo, testEnv(home), "", + "create", "feature-login", + "--branch", "feature/login", + "--scope", scope, + "--root-repo", "root-app", + "--yes", + "--non-interactive", + ) + if create.code != 0 { + t.Fatalf("create failed: %d %s", create.code, create.stderr) + } + + rootWorktree := filepath.Join(home, "Documents", "worktrees", "feature-login", "root", "root-app") + upstream := strings.TrimSpace(runGit(t, rootWorktree, "for-each-ref", "--format=%(upstream:short)", "refs/heads/feature/login")) + if upstream != "" { + t.Fatalf("expected no upstream tracking for feature/login, got %q", upstream) + } +} + +func TestAddRepoAttachesRepositoryAndUpdatesOverride(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", + "--yes", + "--non-interactive", + ) + if create.code != 0 { + t.Fatalf("create failed: %d %s", create.code, create.stderr) + } + + add := runCLI( + t, bin, rootRepo, testEnv(home), "", + "add-repo", "feature-login", + "--scope", scope, + "--repo", "core-pkg", + "--non-interactive", + ) + if add.code != 0 { + t.Fatalf("add-repo failed: %d %s", add.code, add.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) + } + got := string(content) + if !strings.Contains(got, "core:") || !strings.Contains(got, "packages/core-pkg") { + t.Fatalf("override file missing attached repo entry: %s", got) + } +} + func TestCompleteWorksOutsideRepoAndRetainsBranch(t *testing.T) { bin := buildCLI(t) home := t.TempDir() diff --git a/internal/app/add_repo_service.go b/internal/app/add_repo_service.go new file mode 100644 index 0000000..4298333 --- /dev/null +++ b/internal/app/add_repo_service.go @@ -0,0 +1,267 @@ +package app + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/EndersonPro/flutree/internal/domain" +) + +type AddRepoService struct { + git GitPort + registry RegistryPort + prompt PromptPort +} + +func NewAddRepoService(git GitPort, registry RegistryPort, prompt PromptPort) *AddRepoService { + return &AddRepoService{git: git, registry: registry, prompt: prompt} +} + +func (s *AddRepoService) Run(input domain.AddRepoInput) (domain.AddRepoResult, error) { + workspaceName := strings.TrimSpace(input.WorkspaceName) + if workspaceName == "" { + return domain.AddRepoResult{}, domain.NewError(domain.CategoryInput, 2, "Missing workspace name.", "Usage: flutree add-repo --repo ", nil) + } + if _, isPackage := splitPackageRecordName(workspaceName); isPackage { + return domain.AddRepoResult{}, domain.NewError(domain.CategoryInput, 2, "Add-repo requires root workspace name.", "Use root workspace name shown by `flutree list`.", nil) + } + + records, err := s.registry.ListRecords() + if err != nil { + return domain.AddRepoResult{}, err + } + + rootRecord, ok := findRecordByName(records, workspaceName) + if !ok { + return domain.AddRepoResult{}, domain.NewError(domain.CategoryPrecondition, 3, "Managed workspace '"+workspaceName+"' was not found in registry.", "Run `flutree list` to inspect managed entries.", nil) + } + if _, isPackage := splitPackageRecordName(rootRecord.Name); isPackage { + return domain.AddRepoResult{}, domain.NewError(domain.CategoryInput, 2, "Add-repo requires root workspace name.", "Use root workspace name shown by `flutree list`.", nil) + } + + containerPath, removeContainer, err := completionContainerPath(rootRecord) + if err != nil { + return domain.AddRepoResult{}, err + } + if !removeContainer { + return domain.AddRepoResult{}, domain.NewError(domain.CategoryPrecondition, 3, "Unable to determine workspace container path.", "Expected root worktree path in '/root/'.", nil) + } + + discovered, err := s.git.DiscoverFlutterRepos(input.ExecutionScope) + if err != nil { + return domain.AddRepoResult{}, err + } + + rootRepo, ok := findRepoBySelector(discovered, rootRecord.RepoRoot) + if !ok { + return domain.AddRepoResult{}, domain.NewError(domain.CategoryPrecondition, 3, "Root repository is not discoverable in provided scope.", "Scope: "+input.ExecutionScope, nil) + } + + existingPackages := workspacePackageRecords(rootRecord.Name, records) + existingRepoRoots := map[string]struct{}{filepath.Clean(rootRecord.RepoRoot): {}} + for _, rec := range existingPackages { + existingRepoRoots[filepath.Clean(rec.RepoRoot)] = struct{}{} + } + + candidates := []domain.DiscoveredFlutterRepo{} + for _, repo := range discovered { + if filepath.Clean(repo.RepoRoot) == filepath.Clean(rootRepo.RepoRoot) { + continue + } + if _, exists := existingRepoRoots[filepath.Clean(repo.RepoRoot)]; exists { + continue + } + candidates = append(candidates, repo) + } + if len(candidates) == 0 { + return domain.AddRepoResult{}, domain.NewError(domain.CategoryPrecondition, 3, "No additional repositories available to attach.", "All discoverable repositories are already attached.", nil) + } + + selectors := dedupStringsPreservingOrder(input.RepoSelectors) + if len(selectors) == 0 { + if input.NonInteractive { + return domain.AddRepoResult{}, domain.NewError(domain.CategoryInput, 2, "Repository selection is required in non-interactive mode.", "Pass one or more --repo selectors.", nil) + } + choices := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + choices = append(choices, repoLabel(candidate)) + } + selectedChoices, err := s.prompt.SelectPackages("Select repositories to attach", choices, false) + if err != nil { + return domain.AddRepoResult{}, err + } + for _, selected := range selectedChoices { + for _, candidate := range candidates { + if repoLabel(candidate) == selected { + selectors = append(selectors, candidate.RepoRoot) + } + } + } + selectors = dedupStringsPreservingOrder(selectors) + } + + selectedRepos := make([]domain.DiscoveredFlutterRepo, 0, len(selectors)) + for _, selector := range selectors { + repo, found := findRepoBySelector(candidates, selector) + if !found { + return domain.AddRepoResult{}, domain.NewError(domain.CategoryInput, 2, "Unknown --repo selector: "+selector+".", "Use discoverable repository name/package/path.", nil) + } + selectedRepos = append(selectedRepos, repo) + } + selectedRepos = dedupRepos(selectedRepos) + sort.Slice(selectedRepos, func(i, j int) bool { return selectedRepos[i].Name < selectedRepos[j].Name }) + + createSvc := NewCreateService(s.git, s.registry, s.prompt) + newPlans := []domain.PlannedWorktree{} + for _, repo := range selectedRepos { + base := strings.TrimSpace(input.PackageBaseBranch[repo.Name]) + if base == "" { + base = strings.TrimSpace(input.PackageBaseBranch[repo.RepoRoot]) + } + if base == "" { + base = "main" + } + newPlans = append(newPlans, domain.PlannedWorktree{ + Repo: repo, + Role: "package", + Path: filepath.Join(containerPath, "packages", repo.Name), + Branch: rootRecord.Branch, + BaseBranch: normalizeBranchName(base), + }) + } + + created := []domain.PlannedWorktree{} + persistedNames := []string{} + rollback := func() { + for i := len(persistedNames) - 1; i >= 0; i-- { + _, _ = s.registry.Remove(persistedNames[i]) + } + for i := len(created) - 1; i >= 0; i-- { + _ = s.git.RemoveWorktree(created[i].Repo.RepoRoot, created[i].Path, true) + } + } + + rootFilePatterns := mergeRootFilePatterns(input.RootFiles) + for _, plan := range newPlans { + if err := os.MkdirAll(filepath.Dir(plan.Path), 0o755); err != nil { + rollback() + return domain.AddRepoResult{}, domain.NewError(domain.CategoryPersistence, 5, "Failed to create package worktree directory.", plan.Path, err) + } + if err := createSvc.createPlannedWorktree(plan, domain.CreateApplyOptions{NonInteractive: input.NonInteractive}); err != nil { + rollback() + return domain.AddRepoResult{}, err + } + if err := copyRootFiles(plan.Repo.RepoRoot, plan.Path, rootFilePatterns); err != nil { + rollback() + return domain.AddRepoResult{}, domain.NewError(domain.CategoryPersistence, 5, "Failed to copy root files into attached worktree.", plan.Path, err) + } + created = append(created, plan) + + record := domain.RegistryRecord{ + Name: rootRecord.Name + "__pkg__" + plan.Repo.Name, + Branch: plan.Branch, + Path: plan.Path, + RepoRoot: plan.Repo.RepoRoot, + Status: "active", + } + if err := s.registry.Upsert(record); err != nil { + rollback() + return domain.AddRepoResult{}, err + } + persistedNames = append(persistedNames, record.Name) + } + + allPackages := append([]domain.RegistryRecord{}, existingPackages...) + for _, plan := range newPlans { + allPackages = append(allPackages, domain.RegistryRecord{ + Name: rootRecord.Name + "__pkg__" + plan.Repo.Name, + Branch: plan.Branch, + Path: plan.Path, + RepoRoot: plan.Repo.RepoRoot, + Status: "active", + }) + } + + overridePackages := []domain.PlannedWorktree{} + for _, pkg := range allPackages { + repoName := filepath.Base(pkg.Path) + packageName := readPackageNameFromWorktree(pkg.Path) + overridePackages = append(overridePackages, domain.PlannedWorktree{ + Repo: domain.DiscoveredFlutterRepo{ + Name: repoName, + RepoRoot: pkg.RepoRoot, + PackageName: packageName, + }, + Role: "package", + Path: pkg.Path, + Branch: pkg.Branch, + }) + } + + rootPlan := domain.PlannedWorktree{ + Repo: domain.DiscoveredFlutterRepo{ + Name: filepath.Base(rootRecord.Path), + RepoRoot: rootRecord.RepoRoot, + PackageName: readPackageNameFromWorktree(rootRecord.Path), + }, + Role: "root", + Path: rootRecord.Path, + Branch: rootRecord.Branch, + } + + overridePath := filepath.Join(rootRecord.Path, overrideFileName) + overrideContent := buildOverrideContent(rootPlan, overridePackages) + if err := os.WriteFile(overridePath, []byte(overrideContent), 0o644); err != nil { + rollback() + return domain.AddRepoResult{}, domain.NewError(domain.CategoryPersistence, 5, "Failed to update pubspec_overrides.yaml.", overridePath, err) + } + if err := ensureGitignoreContains(rootRecord.Path, filepath.Base(overridePath)); err != nil { + rollback() + return domain.AddRepoResult{}, domain.NewError(domain.CategoryPersistence, 5, "Failed to update .gitignore for pubspec_overrides.yaml.", filepath.Join(rootRecord.Path, ".gitignore"), err) + } + + added := make([]string, 0, len(newPlans)) + for _, plan := range newPlans { + added = append(added, plan.Repo.Name) + } + return domain.AddRepoResult{ + WorkspaceName: rootRecord.Name, + AddedRepos: added, + OverridePath: overridePath, + SelectedBranch: rootRecord.Branch, + }, nil +} + +func workspacePackageRecords(rootName string, records []domain.RegistryRecord) []domain.RegistryRecord { + prefix := rootName + "__pkg__" + out := []domain.RegistryRecord{} + for _, record := range records { + if strings.HasPrefix(record.Name, prefix) { + out = append(out, record) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func readPackageNameFromWorktree(repoPath string) string { + pubspecPath := filepath.Join(repoPath, "pubspec.yaml") + content, err := os.ReadFile(pubspecPath) + if err != nil { + return filepath.Base(repoPath) + } + for _, rawLine := range strings.Split(string(content), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") || !strings.HasPrefix(line, "name:") { + continue + } + token := strings.TrimSpace(strings.TrimPrefix(line, "name:")) + token = strings.Trim(token, "\"'") + if token != "" { + return token + } + } + return filepath.Base(repoPath) +} diff --git a/internal/app/create_service.go b/internal/app/create_service.go index 44d8021..cbcc796 100644 --- a/internal/app/create_service.go +++ b/internal/app/create_service.go @@ -107,6 +107,7 @@ func (s *CreateService) BuildDryPlan(input domain.CreateInput) (domain.CreateDry ContainerPath: container, Root: rootPlan, Packages: packagePlans, + RootFiles: mergeRootFilePatterns(input.RootFiles), OverridePath: overridePath, OverrideContent: overrideContent, WorkspacePath: workspacePath, @@ -147,6 +148,10 @@ func (s *CreateService) Apply(plan domain.CreateDryPlan, options domain.CreateAp if err := s.createPlannedWorktree(plan.Root, options); err != nil { return domain.CreateResult{}, err } + if err := copyRootFiles(plan.Root.Repo.RepoRoot, plan.Root.Path, plan.RootFiles); err != nil { + rollback() + return domain.CreateResult{}, domain.NewError(domain.CategoryPersistence, 5, "Failed to copy root files into root worktree.", plan.Root.Path, err) + } created = append(created, plan.Root) for _, pkg := range plan.Packages { @@ -158,6 +163,10 @@ func (s *CreateService) Apply(plan domain.CreateDryPlan, options domain.CreateAp rollback() return domain.CreateResult{}, err } + if err := copyRootFiles(pkg.Repo.RepoRoot, pkg.Path, plan.RootFiles); err != nil { + rollback() + return domain.CreateResult{}, domain.NewError(domain.CategoryPersistence, 5, "Failed to copy root files into package worktree.", pkg.Path, err) + } created = append(created, pkg) } @@ -252,15 +261,24 @@ func (s *CreateService) createPlannedWorktree(target domain.PlannedWorktree, opt } } + if options.SyncWithRemote { + if err := s.git.SyncBranchWithRemote(target.Repo.RepoRoot, target.Branch); err != nil { + return err + } + } + if err := s.git.CreateWorktreeExisting(target.Repo.RepoRoot, target.Path, target.Branch); err != nil { return err } return nil } - startPoint, err := s.git.SyncBaseBranch(target.Repo.RepoRoot, target.BaseBranch) - if err != nil { - return err + startPoint := target.BaseBranch + if options.SyncWithRemote { + startPoint, err = s.git.SyncBaseBranch(target.Repo.RepoRoot, target.BaseBranch) + if err != nil { + return err + } } if err := s.git.CreateWorktreeNew(target.Repo.RepoRoot, target.Path, target.Branch, startPoint); err != nil { return err @@ -304,34 +322,37 @@ func (s *CreateService) resolvePackageRepos(repos []domain.DiscoveredFlutterRepo return []domain.DiscoveredFlutterRepo{}, nil } - if len(selectors) > 0 { + if len(selectors) == 0 { + if nonInteractive { + return []domain.DiscoveredFlutterRepo{}, nil + } + choices := []string{} + for _, c := range candidates { + choices = append(choices, repoLabel(c)) + } + selectedChoices, err := s.prompt.SelectPackages("Select package repositories", choices, false) + if err != nil { + return nil, err + } selected := []domain.DiscoveredFlutterRepo{} - for _, selector := range selectors { - repo, ok := findRepoBySelector(candidates, selector) - if !ok { - return nil, domain.NewError(domain.CategoryInput, 2, "Unknown package selector: "+selector+".", "Use --package with discovered repository name or path.", nil) + for _, ch := range selectedChoices { + for _, c := range candidates { + if repoLabel(c) == ch { + selected = append(selected, c) + break + } } - selected = append(selected, repo) } return dedupRepos(selected), nil } - choices := []string{} - for _, c := range candidates { - choices = append(choices, repoLabel(c)) - } - selectedChoices, err := s.prompt.SelectPackages("Select package repositories", choices, nonInteractive) - if err != nil { - return nil, err - } selected := []domain.DiscoveredFlutterRepo{} - for _, ch := range selectedChoices { - for _, c := range candidates { - if repoLabel(c) == ch { - selected = append(selected, c) - break - } + for _, selector := range selectors { + repo, ok := findRepoBySelector(candidates, selector) + if !ok { + return nil, domain.NewError(domain.CategoryInput, 2, "Unknown package selector: "+selector+".", "Use --package with discovered repository name or path.", nil) } + selected = append(selected, repo) } return dedupRepos(selected), nil } diff --git a/internal/app/create_service_test.go b/internal/app/create_service_test.go index caf7a09..058c89d 100644 --- a/internal/app/create_service_test.go +++ b/internal/app/create_service_test.go @@ -19,6 +19,8 @@ type fakeCreateGit struct { dirty bool worktree map[string][]domain.GitWorktreeEntry branchExistsByID map[string]bool + syncBranchCalls []string + syncBranchErr error syncCalls []string syncErr error } @@ -48,6 +50,13 @@ func (f *fakeCreateGit) BranchExists(repoRoot, branch string) (bool, error) { } return f.branchExistsByID[repoRoot+"::"+branch], nil } +func (f *fakeCreateGit) SyncBranchWithRemote(repoRoot, branch string) error { + f.syncBranchCalls = append(f.syncBranchCalls, repoRoot+"::"+branch) + if f.syncBranchErr != nil { + return f.syncBranchErr + } + return nil +} func (f *fakeCreateGit) SyncBaseBranch(repoRoot, baseBranch string) (string, error) { f.syncCalls = append(f.syncCalls, repoRoot+"::"+baseBranch) if f.syncErr != nil { @@ -211,6 +220,37 @@ func TestBuildDryPlanKeepsSameSelectionsForInteractiveAndNonInteractiveInputs(t } } +func TestBuildDryPlanInteractiveWithoutPackageSelectorsUsesPromptSelection(t *testing.T) { + root := t.TempDir() + repoRoot := filepath.Join(root, "root-app") + repoPkgA := filepath.Join(root, "core-pkg") + repoPkgB := filepath.Join(root, "design-pkg") + g := &fakeCreateGit{ + repos: []domain.DiscoveredFlutterRepo{ + {Name: "root-app", RepoRoot: repoRoot, PackageName: "root_app"}, + {Name: "core-pkg", RepoRoot: repoPkgA, PackageName: "core"}, + {Name: "design-pkg", RepoRoot: repoPkgB, PackageName: "design"}, + }, + } + + svc := NewCreateService(g, &fakeRegistry{}, &fakeCreatePrompt{}) + plan, err := svc.BuildDryPlan(domain.CreateInput{ + Name: "Feature Login", + Branch: "feature/feature-login", + BaseBranch: "main", + ExecutionScope: root, + RootSelector: "root-app", + NonInteractive: false, + }) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + + if len(plan.Packages) != 2 { + t.Fatalf("expected prompt-selected packages, got %d", len(plan.Packages)) + } +} + func withNonInteractive(input domain.CreateInput, nonInteractive bool) domain.CreateInput { input.NonInteractive = nonInteractive return input @@ -481,7 +521,7 @@ func TestApplySyncsBaseBranchBeforeNewBranchCreation(t *testing.T) { t.Fatalf("build plan failed: %v", err) } - if _, err := svc.Apply(plan, domain.CreateApplyOptions{NonInteractive: true}); err != nil { + if _, err := svc.Apply(plan, domain.CreateApplyOptions{NonInteractive: true, SyncWithRemote: true}); err != nil { t.Fatalf("apply failed: %v", err) } if len(g.syncCalls) == 0 { @@ -603,7 +643,7 @@ func TestApplyReusesExistingBranchWhenAllowed(t *testing.T) { } } -func TestApplySyncsBaseBeforeCreatingNewBranch(t *testing.T) { +func TestApplyOptionSyncWithRemoteSyncsExistingBranchBeforeReuse(t *testing.T) { root := t.TempDir() home := t.TempDir() t.Setenv("HOME", home) @@ -611,10 +651,55 @@ func TestApplySyncsBaseBeforeCreatingNewBranch(t *testing.T) { repoRoot := filepath.Join(root, "root-app") g := &fakeCreateGit{ repos: []domain.DiscoveredFlutterRepo{{Name: "root-app", RepoRoot: repoRoot, PackageName: "root_app"}}, + branchExistsByID: map[string]bool{ + repoRoot + "::feature/existing": true, + }, } svc := NewCreateService(g, &fakeRegistry{}, &fakeCreatePrompt{}) - name := "sync-before-create-" + strings.ReplaceAll(filepath.Base(root), "_", "-") + name := "existing-branch-sync-" + strings.ReplaceAll(filepath.Base(root), "_", "-") + _ = os.RemoveAll(filepath.Join(destinationRoot(), normalizeWorktreeName(name))) + + plan, err := svc.BuildDryPlan(domain.CreateInput{ + Name: name, + Branch: "feature/existing", + BaseBranch: "main", + ExecutionScope: root, + RootSelector: "root-app", + NonInteractive: true, + }) + if err != nil { + t.Fatalf("build plan failed: %v", err) + } + + if _, err := svc.Apply(plan, domain.CreateApplyOptions{ + NonInteractive: true, + ReuseExistingBranch: true, + SyncWithRemote: true, + }); err != nil { + t.Fatalf("expected reuse with sync to pass, got: %v", err) + } + + if len(g.syncBranchCalls) != 1 || g.syncBranchCalls[0] != repoRoot+"::feature/existing" { + t.Fatalf("expected branch sync call, got=%v", g.syncBranchCalls) + } + if len(g.createdExisting) != 1 { + t.Fatalf("expected existing branch create path, got=%v", g.createdExisting) + } +} + +func TestApplyCreatesNewBranchFromLocalBaseWhenSyncDisabled(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + t.Setenv("HOME", home) + + repoRoot := filepath.Join(root, "root-app") + g := &fakeCreateGit{ + repos: []domain.DiscoveredFlutterRepo{{Name: "root-app", RepoRoot: repoRoot, PackageName: "root_app"}}, + } + svc := NewCreateService(g, &fakeRegistry{}, &fakeCreatePrompt{}) + + name := "local-base-create-" + strings.ReplaceAll(filepath.Base(root), "_", "-") _ = os.RemoveAll(filepath.Join(destinationRoot(), normalizeWorktreeName(name))) plan, err := svc.BuildDryPlan(domain.CreateInput{ @@ -632,6 +717,46 @@ func TestApplySyncsBaseBeforeCreatingNewBranch(t *testing.T) { if _, err := svc.Apply(plan, domain.CreateApplyOptions{NonInteractive: true}); err != nil { t.Fatalf("expected apply success: %v", err) } + if len(g.syncCalls) != 0 { + t.Fatalf("expected no base sync when sync is disabled, got=%v", g.syncCalls) + } + if len(g.created) != 1 || !strings.HasPrefix(g.created[0], "new::") { + t.Fatalf("expected new branch create path, got=%v", g.created) + } + if !strings.HasSuffix(g.created[0], "::main") { + t.Fatalf("expected local base branch start point, got=%v", g.created) + } +} + +func TestApplySyncsBaseBeforeCreatingNewBranchWhenRequested(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + t.Setenv("HOME", home) + + repoRoot := filepath.Join(root, "root-app") + g := &fakeCreateGit{ + repos: []domain.DiscoveredFlutterRepo{{Name: "root-app", RepoRoot: repoRoot, PackageName: "root_app"}}, + } + svc := NewCreateService(g, &fakeRegistry{}, &fakeCreatePrompt{}) + + name := "sync-before-create-" + strings.ReplaceAll(filepath.Base(root), "_", "-") + _ = os.RemoveAll(filepath.Join(destinationRoot(), normalizeWorktreeName(name))) + + plan, err := svc.BuildDryPlan(domain.CreateInput{ + Name: name, + Branch: "feature/new-branch", + BaseBranch: "main", + ExecutionScope: root, + RootSelector: "root-app", + NonInteractive: true, + }) + if err != nil { + t.Fatalf("build plan failed: %v", err) + } + + if _, err := svc.Apply(plan, domain.CreateApplyOptions{NonInteractive: true, SyncWithRemote: true}); err != nil { + t.Fatalf("expected apply success: %v", err) + } if len(g.syncCalls) != 1 || g.syncCalls[0] != repoRoot+"::main" { t.Fatalf("expected one sync before create, got=%v", g.syncCalls) } @@ -667,7 +792,7 @@ func TestApplyReturnsErrorAndSkipsWorktreeCreationWhenSyncFails(t *testing.T) { t.Fatalf("build plan failed: %v", err) } - _, err = svc.Apply(plan, domain.CreateApplyOptions{NonInteractive: true}) + _, err = svc.Apply(plan, domain.CreateApplyOptions{NonInteractive: true, SyncWithRemote: true}) if err == nil { t.Fatalf("expected apply to fail when base branch sync fails") } diff --git a/internal/app/ports.go b/internal/app/ports.go index 0ac7212..156a2b2 100644 --- a/internal/app/ports.go +++ b/internal/app/ports.go @@ -9,6 +9,7 @@ type GitPort interface { CreateWorktreeNew(repoRoot, path, branch, startPoint string) error CreateWorktreeExisting(repoRoot, path, branch string) error BranchExists(repoRoot, branch string) (bool, error) + SyncBranchWithRemote(repoRoot, branch string) error SyncBaseBranch(repoRoot, baseBranch string) (string, error) RemoveWorktree(repoRoot, path string, force bool) error IsDirty(path string) (bool, error) diff --git a/internal/app/root_files.go b/internal/app/root_files.go new file mode 100644 index 0000000..15df880 --- /dev/null +++ b/internal/app/root_files.go @@ -0,0 +1,97 @@ +package app + +import ( + "io" + "os" + "path/filepath" + "sort" + "strings" +) + +var defaultRootFilePatterns = []string{".env", ".env.*"} + +func mergeRootFilePatterns(extra []string) []string { + patterns := append([]string{}, defaultRootFilePatterns...) + for _, item := range extra { + token := strings.TrimSpace(item) + if token == "" { + continue + } + patterns = append(patterns, token) + } + return dedupStringsPreservingOrder(patterns) +} + +func resolveRootFilesToCopy(sourceRoot string, patterns []string) []string { + resolved := []string{} + seen := map[string]struct{}{} + + for _, pattern := range patterns { + if strings.ContainsAny(pattern, "*?[]") { + matches, _ := filepath.Glob(filepath.Join(sourceRoot, pattern)) + sort.Strings(matches) + for _, match := range matches { + info, err := os.Stat(match) + if err != nil || info.IsDir() { + continue + } + if _, ok := seen[match]; ok { + continue + } + seen[match] = struct{}{} + resolved = append(resolved, match) + } + continue + } + + target := filepath.Join(sourceRoot, pattern) + info, err := os.Stat(target) + if err != nil || info.IsDir() { + continue + } + if _, ok := seen[target]; ok { + continue + } + seen[target] = struct{}{} + resolved = append(resolved, target) + } + + return resolved +} + +func copyRootFiles(sourceRoot, targetRoot string, patterns []string) error { + files := resolveRootFilesToCopy(sourceRoot, patterns) + for _, sourcePath := range files { + destPath := filepath.Join(targetRoot, filepath.Base(sourcePath)) + if err := copyFile(sourcePath, destPath); err != nil { + return err + } + } + return nil +} + +func copyFile(sourcePath, destinationPath string) error { + if err := os.MkdirAll(filepath.Dir(destinationPath), 0o755); err != nil { + return err + } + + src, err := os.Open(sourcePath) + if err != nil { + return err + } + defer src.Close() + + info, err := src.Stat() + if err != nil { + return err + } + + dst, err := os.OpenFile(destinationPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm()) + if err != nil { + return err + } + defer dst.Close() + + _, err = io.Copy(dst, src) + return err +} diff --git a/internal/app/services_test.go b/internal/app/services_test.go index 38233ef..563a9b2 100644 --- a/internal/app/services_test.go +++ b/internal/app/services_test.go @@ -26,6 +26,7 @@ func (f *fakeGit) CreateWorktreeNew(string, string, string, string) error { } func (f *fakeGit) CreateWorktreeExisting(string, string, string) error { return nil } func (f *fakeGit) BranchExists(string, string) (bool, error) { return false, nil } +func (f *fakeGit) SyncBranchWithRemote(string, string) error { return nil } func (f *fakeGit) SyncBaseBranch(string, string) (string, error) { return "origin/main", nil } func (f *fakeGit) RemoveWorktree(repoRoot, path string, force bool) error { f.removed = append(f.removed, repoRoot+"::"+path) diff --git a/internal/domain/types.go b/internal/domain/types.go index c042409..6d57618 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -114,6 +114,7 @@ type CreateInput struct { RootSelector string PackageSelectors []string PackageBaseBranch map[string]string + RootFiles []string GenerateWorkspace bool Yes bool NonInteractive bool @@ -132,6 +133,7 @@ type CreateDryPlan struct { ContainerPath string Root PlannedWorktree Packages []PlannedWorktree + RootFiles []string OverridePath string OverrideContent string WorkspacePath string @@ -141,6 +143,7 @@ type CreateDryPlan struct { type CreateApplyOptions struct { NonInteractive bool ReuseExistingBranch bool + SyncWithRemote bool } type CreateResult struct { @@ -150,6 +153,22 @@ type CreateResult struct { WorkspacePath string } +type AddRepoInput struct { + WorkspaceName string + ExecutionScope string + RepoSelectors []string + PackageBaseBranch map[string]string + RootFiles []string + NonInteractive bool +} + +type AddRepoResult struct { + WorkspaceName string + AddedRepos []string + OverridePath string + SelectedBranch string +} + type UpdateInput struct { Check bool Apply bool diff --git a/internal/infra/git/git.go b/internal/infra/git/git.go index 44781c7..f3cb556 100644 --- a/internal/infra/git/git.go +++ b/internal/infra/git/git.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "sort" + "strconv" "strings" "github.com/EndersonPro/flutree/internal/domain" @@ -41,8 +42,14 @@ func (g *Gateway) CreateWorktree(repoRoot, path, branch, baseBranch string) erro } func (g *Gateway) CreateWorktreeNew(repoRoot, path, branch, startPoint string) error { - _, err := g.run(repoRoot, "worktree", "add", "-b", branch, path, startPoint) - return err + if _, err := g.run(repoRoot, "worktree", "add", "--detach", path, startPoint); err != nil { + return err + } + if _, err := g.run(path, "switch", "-c", branch); err != nil { + _ = g.RemoveWorktree(repoRoot, path, true) + return err + } + return nil } func (g *Gateway) CreateWorktreeExisting(repoRoot, path, branch string) error { @@ -69,6 +76,60 @@ func (g *Gateway) BranchExists(repoRoot, branch string) (bool, error) { return false, domain.NewError(domain.CategoryGit, 1, "Failed to check local branch existence.", "Branch: "+branch, err) } +func (g *Gateway) SyncBranchWithRemote(repoRoot, branch string) error { + branch = strings.TrimSpace(branch) + if branch == "" { + return domain.NewError(domain.CategoryInput, 2, "Target branch cannot be empty.", "Pass --branch with a non-empty value.", nil) + } + + if _, err := g.run(repoRoot, "fetch", "--prune", "origin", branch); err != nil { + return domain.NewError(domain.CategoryGit, 1, "Failed to fetch branch from origin before creating worktree.", "Branch: "+branch, err) + } + + remoteRef := "refs/remotes/origin/" + branch + if _, err := g.run(repoRoot, "rev-parse", "--verify", "--quiet", remoteRef); err != nil { + return nil + } + + rangeSpec := "refs/heads/" + branch + "...refs/remotes/origin/" + branch + diff, err := g.run(repoRoot, "rev-list", "--left-right", "--count", rangeSpec) + if err != nil { + return domain.NewError(domain.CategoryGit, 1, "Failed to compare local branch against origin.", "Branch: "+branch, err) + } + + parts := strings.Fields(strings.TrimSpace(diff)) + if len(parts) != 2 { + return domain.NewError(domain.CategoryGit, 1, "Failed to parse branch divergence output.", "Branch: "+branch+" | Output: "+strings.TrimSpace(diff), nil) + } + + ahead, err := strconv.Atoi(parts[0]) + if err != nil { + return domain.NewError(domain.CategoryGit, 1, "Failed to parse local-ahead counter for branch sync.", "Branch: "+branch, err) + } + behind, err := strconv.Atoi(parts[1]) + if err != nil { + return domain.NewError(domain.CategoryGit, 1, "Failed to parse local-behind counter for branch sync.", "Branch: "+branch, err) + } + + if ahead > 0 && behind > 0 { + return domain.NewError( + domain.CategoryPrecondition, + 3, + "Local branch '"+branch+"' diverged from origin.", + "Rebase/merge the branch manually, or run create without remote sync.", + nil, + ) + } + if ahead > 0 || behind == 0 { + return nil + } + + if _, err := g.run(repoRoot, "branch", "-f", branch, "origin/"+branch); err != nil { + return domain.NewError(domain.CategoryGit, 1, "Failed to fast-forward local branch from origin before creating worktree.", "Branch: "+branch, err) + } + return nil +} + func (g *Gateway) SyncBaseBranch(repoRoot, baseBranch string) (string, error) { baseBranch = strings.TrimSpace(baseBranch) if baseBranch == "" { diff --git a/internal/ui/create_wizard.go b/internal/ui/create_wizard.go index 291c99d..04334f9 100644 --- a/internal/ui/create_wizard.go +++ b/internal/ui/create_wizard.go @@ -310,12 +310,6 @@ func (m createWizardModel) updateRootRepo(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "enter": selected := m.selectedPackageSelectors() m.refreshPackageCandidates(selected) - if len(m.packageCandidates) == 0 { - m.selectedPackages = nil - m.step = stepBranches - m.prepareBranchFieldInput() - return m, nil - } m.step = stepPackages return m, nil case "esc": diff --git a/internal/ui/create_wizard_test.go b/internal/ui/create_wizard_test.go index c0d5a2e..3fbd65c 100644 --- a/internal/ui/create_wizard_test.go +++ b/internal/ui/create_wizard_test.go @@ -59,6 +59,45 @@ func TestCreateWizardRequiresAtLeastOnePackage(t *testing.T) { } } +func TestCreateWizardRootEnterMovesToPackageStepWhenCandidatesExist(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"}, repos) + m.step = stepRootRepo + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + newModel := updated.(createWizardModel) + + if newModel.step != stepPackages { + t.Fatalf("expected to enter packages step, got %v", newModel.step) + } + if len(newModel.packageCandidates) == 0 { + t.Fatalf("expected package candidates to be available") + } +} + +func TestCreateWizardRootEnterShowsPackageStepEvenWithoutCandidates(t *testing.T) { + repos := []domain.DiscoveredFlutterRepo{ + {Name: "root-app", PackageName: "root_app", RepoRoot: "/repos/root-app"}, + } + + m := newCreateWizardModel(CreateWizardInput{RootSelector: "root-app"}, repos) + m.step = stepRootRepo + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + newModel := updated.(createWizardModel) + + if newModel.step != stepPackages { + t.Fatalf("expected to stay in package step even without candidates, got %v", newModel.step) + } + if len(newModel.packageCandidates) != 0 { + t.Fatalf("expected zero package candidates") + } +} + func TestCreateWizardReviewSelectsDryRun(t *testing.T) { repos := []domain.DiscoveredFlutterRepo{ {Name: "root-app", PackageName: "root_app", RepoRoot: "/repos/root-app"}, diff --git a/internal/ui/loading.go b/internal/ui/loading.go new file mode 100644 index 0000000..1b51bf4 --- /dev/null +++ b/internal/ui/loading.go @@ -0,0 +1,49 @@ +package ui + +import ( + "fmt" + "os" + "strings" + "time" +) + +var loadingFrames = []string{"โ ‹", "โ ™", "โ น", "โ ธ", "โ ผ", "โ ด", "โ ฆ", "โ ง", "โ ‡", "โ "} + +func StartLoading(message string) func(success bool) { + if !isTerminalFile(os.Stdout) { + fmt.Println(message) + return func(success bool) {} + } + + stop := make(chan bool, 1) + done := make(chan struct{}) + + go func() { + defer close(done) + ticker := time.NewTicker(90 * time.Millisecond) + defer ticker.Stop() + + frame := 0 + for { + select { + case success := <-stop: + line := strings.Repeat(" ", len(message)+4) + fmt.Fprintf(os.Stdout, "\r%s\r", line) + if success { + fmt.Fprintf(os.Stdout, "โœ” %s\n", message) + } else { + fmt.Fprintf(os.Stdout, "โœ– %s\n", message) + } + return + case <-ticker.C: + fmt.Fprintf(os.Stdout, "\r%s %s", loadingFrames[frame%len(loadingFrames)], message) + frame++ + } + } + }() + + return func(success bool) { + stop <- success + <-done + } +} diff --git a/internal/ui/loading_test.go b/internal/ui/loading_test.go new file mode 100644 index 0000000..087db34 --- /dev/null +++ b/internal/ui/loading_test.go @@ -0,0 +1,12 @@ +package ui + +import "testing" + +func TestStartLoadingReturnsStopFunctionInNonTTY(t *testing.T) { + stop := StartLoading("Running pub get across workspace...") + if stop == nil { + t.Fatalf("expected non-nil stop callback") + } + stop(true) + stop(false) +} diff --git a/internal/ui/render.go b/internal/ui/render.go index e886e51..ffe0341 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -77,6 +77,14 @@ func RenderPubGetSuccess(result domain.PubGetResult) { fmt.Println(outputBodyStyle.Render(fmt.Sprintf("root | %s | tool=%s | %s", result.Root.Name, result.Root.Tool, result.Root.Path))) } +func RenderAddRepoSuccess(result domain.AddRepoResult) { + fmt.Println(outputHeaderStyle.Render("Repository Attached")) + fmt.Println(outputBodyStyle.Render(fmt.Sprintf("Workspace: %s", result.WorkspaceName))) + fmt.Println(outputBodyStyle.Render(fmt.Sprintf("Branch: %s", result.SelectedBranch))) + fmt.Println(outputBodyStyle.Render(fmt.Sprintf("Added repos: %s", strings.Join(result.AddedRepos, ", ")))) + fmt.Println(outputBodyStyle.Render(fmt.Sprintf("Override updated: %s", result.OverridePath))) +} + func RenderList(rows []domain.ListRow, includeUnmanaged bool) { if len(rows) == 0 { next := "Run `flutree create --branch ` to start one."