From 7acdcdf080e1d470c64beb8ad67186d696234b79 Mon Sep 17 00:00:00 2001 From: Enderson Vizcaino Date: Wed, 22 Apr 2026 14:01:38 -0500 Subject: [PATCH 1/4] feat(app): add clean/config services and add-repo wizard core --- internal/app/add_repo_service.go | 160 +++++-- internal/app/add_repo_service_test.go | 98 ++++ internal/app/clean_service.go | 74 +++ internal/app/clean_service_test.go | 132 ++++++ internal/app/config_service.go | 101 +++++ internal/app/config_service_test.go | 140 ++++++ internal/app/ports.go | 5 + internal/app/scope_resolver.go | 41 ++ internal/app/scope_resolver_test.go | 64 +++ internal/domain/types.go | 20 + internal/infra/config/repository.go | 93 ++++ internal/infra/config/repository_test.go | 151 ++++++ internal/ui/add_repo_wizard.go | 555 +++++++++++++++++++++++ internal/ui/add_repo_wizard_test.go | 145 ++++++ 14 files changed, 1732 insertions(+), 47 deletions(-) create mode 100644 internal/app/clean_service.go create mode 100644 internal/app/clean_service_test.go create mode 100644 internal/app/config_service.go create mode 100644 internal/app/config_service_test.go create mode 100644 internal/app/scope_resolver.go create mode 100644 internal/app/scope_resolver_test.go create mode 100644 internal/infra/config/repository.go create mode 100644 internal/infra/config/repository_test.go create mode 100644 internal/ui/add_repo_wizard.go create mode 100644 internal/ui/add_repo_wizard_test.go diff --git a/internal/app/add_repo_service.go b/internal/app/add_repo_service.go index a4474ad..d49c6d8 100644 --- a/internal/app/add_repo_service.go +++ b/internal/app/add_repo_service.go @@ -15,10 +15,41 @@ type AddRepoService struct { prompt PromptPort } +type AddRepoSelectionContextInput struct { + WorkspaceName string + ExecutionScope string +} + +type AddRepoSelectionContext struct { + WorkspaceName string + RootBranch string + Candidates []domain.DiscoveredFlutterRepo +} + +type addRepoRuntimeContext struct { + rootRecord domain.RegistryRecord + containerPath string + existingPackages []domain.RegistryRecord + candidates []domain.DiscoveredFlutterRepo +} + func NewAddRepoService(git GitPort, registry RegistryPort, prompt PromptPort) *AddRepoService { return &AddRepoService{git: git, registry: registry, prompt: prompt} } +func (s *AddRepoService) BuildSelectionContext(input AddRepoSelectionContextInput) (AddRepoSelectionContext, error) { + ctx, err := s.buildRuntimeContext(input) + if err != nil { + return AddRepoSelectionContext{}, err + } + + return AddRepoSelectionContext{ + WorkspaceName: ctx.rootRecord.Name, + RootBranch: ctx.rootRecord.Branch, + Candidates: append([]domain.DiscoveredFlutterRepo(nil), ctx.candidates...), + }, nil +} + func (s *AddRepoService) Run(input domain.AddRepoInput) (domain.AddRepoResult, error) { workspaceName := strings.TrimSpace(input.WorkspaceName) if workspaceName == "" { @@ -28,56 +59,15 @@ func (s *AddRepoService) Run(input domain.AddRepoInput) (domain.AddRepoResult, e 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() + ctx, err := s.buildRuntimeContext(AddRepoSelectionContextInput{WorkspaceName: workspaceName, ExecutionScope: input.ExecutionScope}) 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) - } + rootRecord := ctx.rootRecord + containerPath := ctx.containerPath + existingPackages := ctx.existingPackages + candidates := ctx.candidates selectors := dedupStringsPreservingOrder(input.RepoSelectors) if len(selectors) == 0 { @@ -111,7 +101,12 @@ func (s *AddRepoService) Run(input domain.AddRepoInput) (domain.AddRepoResult, e selectedRepos = append(selectedRepos, repo) } selectedRepos = dedupRepos(selectedRepos) - sort.Slice(selectedRepos, func(i, j int) bool { return selectedRepos[i].Name < selectedRepos[j].Name }) + sort.Slice(selectedRepos, func(i, j int) bool { + if selectedRepos[i].Name != selectedRepos[j].Name { + return selectedRepos[i].Name < selectedRepos[j].Name + } + return selectedRepos[i].RepoRoot < selectedRepos[j].RepoRoot + }) if input.NonInteractive && strings.TrimSpace(rootRecord.Branch) == "" { return domain.AddRepoResult{}, domain.NewError( @@ -281,6 +276,77 @@ func (s *AddRepoService) Run(input domain.AddRepoInput) (domain.AddRepoResult, e }, nil } +func (s *AddRepoService) buildRuntimeContext(input AddRepoSelectionContextInput) (addRepoRuntimeContext, error) { + workspaceName := strings.TrimSpace(input.WorkspaceName) + if workspaceName == "" { + return addRepoRuntimeContext{}, domain.NewError(domain.CategoryInput, 2, "Missing workspace name.", "Usage: flutree add-repo --repo ", nil) + } + + records, err := s.registry.ListRecords() + if err != nil { + return addRepoRuntimeContext{}, err + } + + rootRecord, ok := findRecordByName(records, workspaceName) + if !ok { + return addRepoRuntimeContext{}, 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 addRepoRuntimeContext{}, 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 addRepoRuntimeContext{}, err + } + if !removeContainer { + return addRepoRuntimeContext{}, 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 addRepoRuntimeContext{}, err + } + + rootRepo, ok := findRepoBySelector(discovered, rootRecord.RepoRoot) + if !ok { + return addRepoRuntimeContext{}, 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 addRepoRuntimeContext{}, domain.NewError(domain.CategoryPrecondition, 3, "No additional repositories available to attach.", "All discoverable repositories are already attached.", nil) + } + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].Name != candidates[j].Name { + return candidates[i].Name < candidates[j].Name + } + return candidates[i].RepoRoot < candidates[j].RepoRoot + }) + + return addRepoRuntimeContext{ + rootRecord: rootRecord, + containerPath: containerPath, + existingPackages: existingPackages, + candidates: candidates, + }, nil +} + func (s *AddRepoService) resolveSyncPolicy(input domain.AddRepoInput) (bool, error) { policy := input.SyncPolicy if policy == "" { diff --git a/internal/app/add_repo_service_test.go b/internal/app/add_repo_service_test.go index 35c8fb8..2f7c6b2 100644 --- a/internal/app/add_repo_service_test.go +++ b/internal/app/add_repo_service_test.go @@ -858,3 +858,101 @@ func TestReadPackageNameFromWorktreeParsesNameAndFallsBack(t *testing.T) { } }) } + +func TestBuildSelectionContextExcludesRootAndAttachedReposDeterministically(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + containerPath := filepath.Join(home, "Documents", "worktrees", "feature-login") + rootPath := filepath.Join(containerPath, "root", "root-app") + attachedPath := filepath.Join(containerPath, "packages", "core-pkg") + if err := os.MkdirAll(rootPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(attachedPath, 0o755); err != nil { + t.Fatal(err) + } + + rootRepo := filepath.Join(t.TempDir(), "root-repo") + coreRepo := filepath.Join(t.TempDir(), "core-repo") + designRepo := filepath.Join(t.TempDir(), "design-repo") + apiRepo := filepath.Join(t.TempDir(), "api-repo") + + git := &fakeAddRepoGit{discovered: []domain.DiscoveredFlutterRepo{ + {Name: "root-app", RepoRoot: rootRepo, PackageName: "root_app"}, + {Name: "core-pkg", RepoRoot: coreRepo, PackageName: "core"}, + {Name: "design-pkg", RepoRoot: designRepo, PackageName: "design"}, + {Name: "api-pkg", RepoRoot: apiRepo, PackageName: "api"}, + }} + registry := &fakeAddRepoRegistry{records: []domain.RegistryRecord{ + {Name: "feature-login", Branch: "feature/login", Path: rootPath, RepoRoot: rootRepo, Status: "active"}, + {Name: "feature-login__pkg__core-pkg", Branch: "feature/login", Path: attachedPath, RepoRoot: coreRepo, Status: "active"}, + }} + + service := NewAddRepoService(git, registry, &fakeAddRepoPrompt{}) + ctx, err := service.BuildSelectionContext(AddRepoSelectionContextInput{ + WorkspaceName: "feature-login", + ExecutionScope: ".", + }) + if err != nil { + t.Fatalf("expected context build success, got %v", err) + } + + if ctx.RootBranch != "feature/login" { + t.Fatalf("expected root branch from registry, got %q", ctx.RootBranch) + } + if len(ctx.Candidates) != 2 { + t.Fatalf("expected only unattached candidates, got %+v", ctx.Candidates) + } + if ctx.Candidates[0].Name != "api-pkg" || ctx.Candidates[1].Name != "design-pkg" { + t.Fatalf("expected deterministic order by name, got %+v", ctx.Candidates) + } +} + +func TestAddRepoNonInteractiveAcceptsWizardShapedPayloadWithRepoRootKeys(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + rootPath := filepath.Join(home, "Documents", "worktrees", "feature-login", "root", "root-app") + if err := os.MkdirAll(rootPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(rootPath, "pubspec.yaml"), []byte("name: root_app\n"), 0o644); err != nil { + t.Fatal(err) + } + + rootRepo := filepath.Join(t.TempDir(), "root-repo") + coreRepo := filepath.Join(t.TempDir(), "core-repo") + git := &fakeAddRepoGit{discovered: []domain.DiscoveredFlutterRepo{ + {Name: "root-app", RepoRoot: rootRepo, PackageName: "root_app"}, + {Name: "core-pkg", RepoRoot: coreRepo, PackageName: "core"}, + }} + registry := &fakeAddRepoRegistry{records: []domain.RegistryRecord{{ + Name: "feature-login", Branch: "feature/login", Path: rootPath, RepoRoot: rootRepo, Status: "active", + }}} + prompt := &fakeAddRepoPrompt{} + + service := NewAddRepoService(git, registry, prompt) + _, err := service.Run(domain.AddRepoInput{ + WorkspaceName: "feature-login", + ExecutionScope: ".", + RepoSelectors: []string{coreRepo, "core-pkg", coreRepo}, + PackageBranchSource: map[string]string{coreRepo: "feature/core"}, + PackageBaseBranch: map[string]string{coreRepo: "release/1.0"}, + SyncPolicy: domain.AddRepoSyncNever, + NonInteractive: true, + }) + if err != nil { + t.Fatalf("expected success, got %v", err) + } + + if len(git.createNewCalls) != 1 { + t.Fatalf("expected deduped single worktree creation, got %v", git.createNewCalls) + } + if !strings.Contains(git.createNewCalls[0], "::feature/core::release/1.0") { + t.Fatalf("expected repo-root keyed branch/base from wizard payload, got %s", git.createNewCalls[0]) + } + if len(prompt.askTextCalls) != 0 || len(prompt.confirmCalls) != 0 { + t.Fatalf("expected no prompts in deterministic non-interactive execution, ask=%v confirm=%v", prompt.askTextCalls, prompt.confirmCalls) + } +} diff --git a/internal/app/clean_service.go b/internal/app/clean_service.go new file mode 100644 index 0000000..45df75c --- /dev/null +++ b/internal/app/clean_service.go @@ -0,0 +1,74 @@ +package app + +import ( + "path/filepath" + + "github.com/EndersonPro/flutree/internal/domain" +) + +type CleanService struct { + git GitPort + registry RegistryPort + pub PubPort +} + +func NewCleanService(git GitPort, registry RegistryPort, pub PubPort) *CleanService { + return &CleanService{git: git, registry: registry, pub: pub} +} + +func (s *CleanService) Run(input domain.CleanInput) (domain.CleanResult, error) { + currentRepo, err := s.git.EnsureRepo() + if err != nil { + return domain.CleanResult{}, err + } + currentRepo = filepath.Clean(currentRepo) + + records, err := s.registry.ListRecords() + if err != nil { + return domain.CleanResult{}, err + } + + record, ok := findRecordByPath(records, currentRepo) + if !ok { + return domain.CleanResult{}, domain.NewError( + domain.CategoryPrecondition, + 3, + "Current repository is not a managed flutree worktree.", + "Run this command from a managed worktree shown by 'flutree list'.", + nil, + ) + } + + tool, err := s.pub.DetectTool(record.Path) + if err != nil { + return domain.CleanResult{}, err + } + if err := s.pub.Clean(record.Path, tool); err != nil { + return domain.CleanResult{}, err + } + + lockRemoved := false + if input.Force { + if err := s.pub.RemoveLock(record.Path); err != nil { + return domain.CleanResult{}, err + } + lockRemoved = true + } + + return domain.CleanResult{ + Record: record, + Tool: tool, + Force: input.Force, + LockRemoved: lockRemoved, + }, nil +} + +func findRecordByPath(records []domain.RegistryRecord, path string) (domain.RegistryRecord, bool) { + cleanPath := filepath.Clean(path) + for _, record := range records { + if filepath.Clean(record.Path) == cleanPath { + return record, true + } + } + return domain.RegistryRecord{}, false +} diff --git a/internal/app/clean_service_test.go b/internal/app/clean_service_test.go new file mode 100644 index 0000000..d7fc4a1 --- /dev/null +++ b/internal/app/clean_service_test.go @@ -0,0 +1,132 @@ +package app + +import ( + "errors" + "reflect" + "testing" + + "github.com/EndersonPro/flutree/internal/domain" +) + +type fakeCleanPub struct { + toolByPath map[string]domain.PubTool + errByStep map[string]error + opsByPath map[string][]string +} + +func (f *fakeCleanPub) DetectTool(repoPath string) (domain.PubTool, error) { + f.record(repoPath, "detect") + if err := f.stepErr("detect"); err != nil { + return "", err + } + if tool, ok := f.toolByPath[repoPath]; ok { + return tool, nil + } + return domain.PubToolFlutter, nil +} + +func (f *fakeCleanPub) Clean(repoPath string, tool domain.PubTool) error { + f.record(repoPath, "clean") + return f.stepErr("clean") +} + +func (f *fakeCleanPub) RemoveLock(repoPath string) error { + f.record(repoPath, "remove-lock") + return f.stepErr("remove-lock") +} + +func (f *fakeCleanPub) Get(repoPath string, tool domain.PubTool) error { return nil } + +func (f *fakeCleanPub) record(repoPath, step string) { + if f.opsByPath == nil { + f.opsByPath = map[string][]string{} + } + f.opsByPath[repoPath] = append(f.opsByPath[repoPath], step) +} + +func (f *fakeCleanPub) stepErr(step string) error { + if f.errByStep == nil { + return nil + } + return f.errByStep[step] +} + +func TestCleanRunsOnCurrentManagedWorktree(t *testing.T) { + repoPath := "/tmp/worktrees/demo/root/root-app" + g := &fakeGit{currentRepo: repoPath} + r := &fakeRegistry{records: []domain.RegistryRecord{ + {Name: "demo", Path: repoPath, RepoRoot: "/tmp/repo-root", Status: "active"}, + }} + p := &fakeCleanPub{} + + svc := NewCleanService(g, r, p) + result, err := svc.Run(domain.CleanInput{}) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + if result.Record.Name != "demo" || result.Tool != domain.PubToolFlutter { + t.Fatalf("unexpected result: %+v", result) + } + if !reflect.DeepEqual(p.opsByPath[repoPath], []string{"detect", "clean"}) { + t.Fatalf("unexpected operations: %v", p.opsByPath[repoPath]) + } +} + +func TestCleanForceRemovesLockAfterClean(t *testing.T) { + repoPath := "/tmp/worktrees/demo/root/root-app" + g := &fakeGit{currentRepo: repoPath} + r := &fakeRegistry{records: []domain.RegistryRecord{ + {Name: "demo", Path: repoPath, RepoRoot: "/tmp/repo-root", Status: "active"}, + }} + p := &fakeCleanPub{} + + svc := NewCleanService(g, r, p) + result, err := svc.Run(domain.CleanInput{Force: true}) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + if !result.LockRemoved || !result.Force { + t.Fatalf("expected force+lock removal metadata, got %+v", result) + } + if !reflect.DeepEqual(p.opsByPath[repoPath], []string{"detect", "clean", "remove-lock"}) { + t.Fatalf("unexpected operations: %v", p.opsByPath[repoPath]) + } +} + +func TestCleanFailsOutsideManagedWorktree(t *testing.T) { + g := &fakeGit{currentRepo: "/tmp/unmanaged/repo"} + r := &fakeRegistry{records: []domain.RegistryRecord{ + {Name: "demo", Path: "/tmp/worktrees/demo/root/root-app", RepoRoot: "/tmp/repo-root", Status: "active"}, + }} + p := &fakeCleanPub{} + + svc := NewCleanService(g, r, p) + _, err := svc.Run(domain.CleanInput{}) + if err == nil { + t.Fatalf("expected error") + } + if err.Error() != "Current repository is not a managed flutree worktree." { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCleanStopsOnCleanError(t *testing.T) { + repoPath := "/tmp/worktrees/demo/root/root-app" + g := &fakeGit{currentRepo: repoPath} + r := &fakeRegistry{records: []domain.RegistryRecord{ + {Name: "demo", Path: repoPath, RepoRoot: "/tmp/repo-root", Status: "active"}, + }} + p := &fakeCleanPub{errByStep: map[string]error{"clean": errors.New("flutter not found")}} + + svc := NewCleanService(g, r, p) + _, err := svc.Run(domain.CleanInput{Force: true}) + if err == nil { + t.Fatalf("expected error") + } + if err.Error() != "flutter not found" { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(p.opsByPath[repoPath], []string{"detect", "clean"}) { + t.Fatalf("expected lock removal to be skipped, got %v", p.opsByPath[repoPath]) + } +} diff --git a/internal/app/config_service.go b/internal/app/config_service.go new file mode 100644 index 0000000..b66c4b5 --- /dev/null +++ b/internal/app/config_service.go @@ -0,0 +1,101 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + + "github.com/EndersonPro/flutree/internal/domain" +) + +const scopeRootKey = "scope.root" + +type ConfigService struct { + config ConfigPort +} + +func NewConfigService(config ConfigPort) *ConfigService { + return &ConfigService{config: config} +} + +func (s *ConfigService) SetScopeRoot(key, value string) (string, error) { + if err := validateScopeRootKey(key); err != nil { + return "", err + } + + normalized, err := normalizeAndValidateScopePath(value, domain.CategoryInput, "scope.root") + if err != nil { + return "", err + } + + doc, err := s.config.Load() + if err != nil { + return "", err + } + if doc.Version == 0 { + doc.Version = 1 + } + doc.Scope.Root = normalized + + if err := s.config.Save(doc); err != nil { + return "", err + } + + return normalized, nil +} + +func (s *ConfigService) GetScopeRoot(key string) (string, error) { + if err := validateScopeRootKey(key); err != nil { + return "", err + } + + doc, err := s.config.Load() + if err != nil { + return "", err + } + return strings.TrimSpace(doc.Scope.Root), nil +} + +func validateScopeRootKey(key string) error { + trimmed := strings.TrimSpace(key) + if trimmed == scopeRootKey { + return nil + } + return domain.NewError( + domain.CategoryInput, + 2, + "Unsupported config key '"+trimmed+"'.", + "Only 'scope.root' is currently supported.", + nil, + ) +} + +func normalizeAndValidateScopePath(raw string, category domain.ErrorCategory, source string) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", domain.NewError(category, 2, "Scope root path is required.", "Provide a non-empty path for "+source+".", nil) + } + + abs, err := filepath.Abs(trimmed) + if err != nil { + return "", domain.NewError(category, 2, "Scope root path is invalid.", "Failed to normalize path for "+source+".", err) + } + abs = filepath.Clean(abs) + + info, err := os.Stat(abs) + if err != nil { + if os.IsNotExist(err) { + return "", domain.NewError(category, 2, "Scope root path does not exist.", abs, nil) + } + return "", domain.NewError(category, 2, "Scope root path is invalid.", abs, err) + } + if !info.IsDir() { + return "", domain.NewError(category, 2, "Scope root path must be a directory.", abs, nil) + } + + if _, err := os.ReadDir(abs); err != nil { + return "", domain.NewError(category, 2, "Scope root path is not reachable.", abs, err) + } + + return abs, nil +} diff --git a/internal/app/config_service_test.go b/internal/app/config_service_test.go new file mode 100644 index 0000000..adcafd9 --- /dev/null +++ b/internal/app/config_service_test.go @@ -0,0 +1,140 @@ +package app + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/EndersonPro/flutree/internal/domain" +) + +type fakeConfigPort struct { + loadDoc domain.UserConfigDocument + loadErr error + savedDoc domain.UserConfigDocument + saveErr error + saved bool +} + +func (f *fakeConfigPort) Load() (domain.UserConfigDocument, error) { + if f.loadErr != nil { + return domain.UserConfigDocument{}, f.loadErr + } + return f.loadDoc, nil +} + +func (f *fakeConfigPort) Save(doc domain.UserConfigDocument) error { + f.saved = true + f.savedDoc = doc + return f.saveErr +} + +func TestConfigServiceSetGetScopeRootRoundTrip(t *testing.T) { + root := filepath.Clean(t.TempDir()) + store := &fakeConfigPort{loadDoc: domain.UserConfigDocument{Version: 1}} + svc := NewConfigService(store) + + stored, err := svc.SetScopeRoot("scope.root", root) + if err != nil { + t.Fatalf("set scope.root failed: %v", err) + } + if stored != root { + t.Fatalf("expected normalized root %q, got %q", root, stored) + } + if !store.saved { + t.Fatalf("expected save call") + } + if store.savedDoc.Scope.Root != root { + t.Fatalf("expected persisted root %q, got %q", root, store.savedDoc.Scope.Root) + } + + store.loadDoc = store.savedDoc + got, err := svc.GetScopeRoot("scope.root") + if err != nil { + t.Fatalf("get scope.root failed: %v", err) + } + if got != root { + t.Fatalf("expected %q, got %q", root, got) + } +} + +func TestConfigServiceRejectsUnsupportedKey(t *testing.T) { + store := &fakeConfigPort{loadDoc: domain.UserConfigDocument{Version: 1}} + svc := NewConfigService(store) + + if _, err := svc.SetScopeRoot("scope.invalid", t.TempDir()); err == nil { + t.Fatalf("expected unsupported key error on set") + } + if _, err := svc.GetScopeRoot("scope.invalid"); err == nil { + t.Fatalf("expected unsupported key error on get") + } +} + +func TestConfigServiceSetRejectsInvalidPathAndDoesNotMutate(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + store := &fakeConfigPort{loadDoc: domain.UserConfigDocument{Version: 1}} + svc := NewConfigService(store) + + _, err := svc.SetScopeRoot("scope.root", missing) + if err == nil { + t.Fatalf("expected invalid path error") + } + if !strings.Contains(strings.ToLower(err.Error()), "does not exist") { + t.Fatalf("expected does-not-exist validation, got: %v", err) + } + if store.saved { + t.Fatalf("save should not be called when validation fails") + } +} + +func TestConfigServiceSetRejectsNonDirectoryPath(t *testing.T) { + file := filepath.Join(t.TempDir(), "file.txt") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + store := &fakeConfigPort{loadDoc: domain.UserConfigDocument{Version: 1}} + svc := NewConfigService(store) + + _, err := svc.SetScopeRoot("scope.root", file) + if err == nil { + t.Fatalf("expected non-directory error") + } + if !strings.Contains(strings.ToLower(err.Error()), "directory") { + t.Fatalf("expected non-directory validation error, got: %v", err) + } + if store.saved { + t.Fatalf("save should not be called for non-directory path") + } +} + +func TestConfigServiceSetRejectsUnreachableDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission bits not reliable on windows") + } + + parent := t.TempDir() + unreachable := filepath.Join(parent, "private") + if err := os.MkdirAll(unreachable, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(unreachable, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(unreachable, 0o700) }) + + store := &fakeConfigPort{loadDoc: domain.UserConfigDocument{Version: 1}} + svc := NewConfigService(store) + + _, err := svc.SetScopeRoot("scope.root", unreachable) + if err == nil { + t.Fatalf("expected unreachable path error") + } + if !strings.Contains(strings.ToLower(err.Error()), "reachable") { + t.Fatalf("expected reachable-path validation, got: %v", err) + } + if store.saved { + t.Fatalf("save should not be called for unreachable path") + } +} diff --git a/internal/app/ports.go b/internal/app/ports.go index f5b8e5e..1cc85da 100644 --- a/internal/app/ports.go +++ b/internal/app/ports.go @@ -25,6 +25,11 @@ type RegistryPort interface { MarkCompleted(name string) (domain.RegistryRecord, error) } +type ConfigPort interface { + Load() (domain.UserConfigDocument, error) + Save(domain.UserConfigDocument) error +} + type PromptPort interface { Confirm(message string, nonInteractive, assumeYes bool) (bool, error) ConfirmWithToken(message, token string, nonInteractive, assumeYes bool) (bool, error) diff --git a/internal/app/scope_resolver.go b/internal/app/scope_resolver.go new file mode 100644 index 0000000..6567fd7 --- /dev/null +++ b/internal/app/scope_resolver.go @@ -0,0 +1,41 @@ +package app + +import ( + "strings" + + "github.com/EndersonPro/flutree/internal/domain" +) + +type ScopeResolver struct { + config ConfigPort +} + +func NewScopeResolver(config ConfigPort) *ScopeResolver { + return &ScopeResolver{config: config} +} + +func (r *ScopeResolver) Resolve(scopeFlag string, scopeFlagProvided bool) (string, error) { + if scopeFlagProvided { + return normalizeAndValidateScopePath(scopeFlag, domain.CategoryInput, "--scope") + } + + doc, err := r.config.Load() + if err != nil { + return "", err + } + if persisted := strings.TrimSpace(doc.Scope.Root); persisted != "" { + resolved, resolveErr := normalizeAndValidateScopePath(persisted, domain.CategoryPrecondition, "persisted scope.root") + if resolveErr == nil { + return resolved, nil + } + return "", domain.NewError( + domain.CategoryPrecondition, + 3, + "Persisted scope.root is invalid for discovery.", + "Run `flutree config set scope.root ` with a valid reachable directory.", + resolveErr, + ) + } + + return normalizeAndValidateScopePath(".", domain.CategoryPrecondition, "default scope") +} diff --git a/internal/app/scope_resolver_test.go b/internal/app/scope_resolver_test.go new file mode 100644 index 0000000..83c366d --- /dev/null +++ b/internal/app/scope_resolver_test.go @@ -0,0 +1,64 @@ +package app + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/EndersonPro/flutree/internal/domain" +) + +func TestScopeResolverPrefersExplicitFlag(t *testing.T) { + explicit := filepath.Clean(t.TempDir()) + persisted := filepath.Clean(t.TempDir()) + + resolver := NewScopeResolver(&fakeConfigPort{loadDoc: domain.UserConfigDocument{Version: 1, Scope: domain.UserScopeConfig{Root: persisted}}}) + got, err := resolver.Resolve(explicit, true) + if err != nil { + t.Fatalf("resolve with explicit flag should succeed: %v", err) + } + if got != explicit { + t.Fatalf("expected explicit scope %q, got %q", explicit, got) + } +} + +func TestScopeResolverUsesPersistedWhenFlagOmitted(t *testing.T) { + persisted := filepath.Clean(t.TempDir()) + + resolver := NewScopeResolver(&fakeConfigPort{loadDoc: domain.UserConfigDocument{Version: 1, Scope: domain.UserScopeConfig{Root: persisted}}}) + got, err := resolver.Resolve(".", false) + if err != nil { + t.Fatalf("resolve without explicit flag should use persisted root: %v", err) + } + if got != persisted { + t.Fatalf("expected persisted scope %q, got %q", persisted, got) + } +} + +func TestScopeResolverFallsBackToDotWhenPersistedEmpty(t *testing.T) { + resolver := NewScopeResolver(&fakeConfigPort{loadDoc: domain.UserConfigDocument{Version: 1}}) + got, err := resolver.Resolve(".", false) + if err != nil { + t.Fatalf("resolve fallback should succeed: %v", err) + } + want, err := filepath.Abs(".") + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("expected fallback scope %q, got %q", want, got) + } +} + +func TestScopeResolverReturnsClearErrorForInvalidPersistedPath(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing") + resolver := NewScopeResolver(&fakeConfigPort{loadDoc: domain.UserConfigDocument{Version: 1, Scope: domain.UserScopeConfig{Root: missing}}}) + + _, err := resolver.Resolve(".", false) + if err == nil { + t.Fatalf("expected invalid persisted path error") + } + if !strings.Contains(strings.ToLower(err.Error()), "persisted") { + t.Fatalf("expected persisted-path context in error, got: %v", err) + } +} diff --git a/internal/domain/types.go b/internal/domain/types.go index 7f0678d..4b4da54 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -47,6 +47,15 @@ type RegistryDocument struct { Records []RegistryRecord `json:"records"` } +type UserScopeConfig struct { + Root string `json:"root,omitempty"` +} + +type UserConfigDocument struct { + Version int `json:"version"` + Scope UserScopeConfig `json:"scope"` +} + type GitWorktreeEntry struct { Path string Head string @@ -111,6 +120,17 @@ type PubGetResult struct { Force bool } +type CleanInput struct { + Force bool +} + +type CleanResult struct { + Record RegistryRecord + Tool PubTool + Force bool + LockRemoved bool +} + type CreateInput struct { Name string Branch string diff --git a/internal/infra/config/repository.go b/internal/infra/config/repository.go new file mode 100644 index 0000000..d6d5ca8 --- /dev/null +++ b/internal/infra/config/repository.go @@ -0,0 +1,93 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/EndersonPro/flutree/internal/domain" +) + +const supportedVersion = 1 + +type Repository struct { + Path string +} + +func NewDefault() *Repository { + home, _ := os.UserHomeDir() + return &Repository{Path: filepath.Join(home, "Documents", "worktrees", ".flutree_config.json")} +} + +func (r *Repository) Load() (domain.UserConfigDocument, error) { + if err := r.ensureExists(); err != nil { + return domain.UserConfigDocument{}, err + } + + b, err := os.ReadFile(r.Path) + if err != nil { + return domain.UserConfigDocument{}, domain.NewError(domain.CategoryPersistence, 5, "Failed to read config file.", r.Path, err) + } + + var doc domain.UserConfigDocument + if err := json.Unmarshal(b, &doc); err != nil { + return domain.UserConfigDocument{}, domain.NewError(domain.CategoryPersistence, 5, "Failed to parse config file.", r.Path, err) + } + + if doc.Version == 0 { + doc.Version = supportedVersion + } + if doc.Version != supportedVersion { + return domain.UserConfigDocument{}, domain.NewError( + domain.CategoryPersistence, + 5, + fmt.Sprintf("Unsupported config version '%d'.", doc.Version), + fmt.Sprintf("Supported version: %d.", supportedVersion), + nil, + ) + } + + return doc, nil +} + +func (r *Repository) Save(doc domain.UserConfigDocument) error { + if doc.Version == 0 { + doc.Version = supportedVersion + } + if doc.Version != supportedVersion { + return domain.NewError( + domain.CategoryPersistence, + 5, + fmt.Sprintf("Unsupported config version '%d'.", doc.Version), + fmt.Sprintf("Supported version: %d.", supportedVersion), + nil, + ) + } + + if err := os.MkdirAll(filepath.Dir(r.Path), 0o755); err != nil { + return domain.NewError(domain.CategoryPersistence, 5, "Failed to create config directory.", r.Path, err) + } + + b, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return domain.NewError(domain.CategoryPersistence, 5, "Failed to serialize config file.", r.Path, err) + } + + tmp := r.Path + ".tmp" + if err := os.WriteFile(tmp, append(b, '\n'), 0o644); err != nil { + return domain.NewError(domain.CategoryPersistence, 5, "Failed to write config temp file.", tmp, err) + } + if err := os.Rename(tmp, r.Path); err != nil { + return domain.NewError(domain.CategoryPersistence, 5, "Failed to atomically replace config file.", r.Path, err) + } + + return nil +} + +func (r *Repository) ensureExists() error { + if _, err := os.Stat(r.Path); err == nil { + return nil + } + return r.Save(domain.UserConfigDocument{Version: supportedVersion}) +} diff --git a/internal/infra/config/repository_test.go b/internal/infra/config/repository_test.go new file mode 100644 index 0000000..bb8a74d --- /dev/null +++ b/internal/infra/config/repository_test.go @@ -0,0 +1,151 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/EndersonPro/flutree/internal/domain" +) + +func TestRepositoryLoadCreatesDefaultDocument(t *testing.T) { + repo := &Repository{Path: filepath.Join(t.TempDir(), ".flutree_config.json")} + + first, err := repo.Load() + if err != nil { + t.Fatalf("load should create default document: %v", err) + } + second, err := repo.Load() + if err != nil { + t.Fatalf("second load should also succeed: %v", err) + } + + if first.Version != 1 || second.Version != 1 { + t.Fatalf("expected version 1 defaults, got first=%d second=%d", first.Version, second.Version) + } + if first.Scope.Root != "" || second.Scope.Root != "" { + t.Fatalf("expected empty scope root on default document, got first=%q second=%q", first.Scope.Root, second.Scope.Root) + } +} + +func TestRepositorySaveLoadRoundtrip(t *testing.T) { + repo := &Repository{Path: filepath.Join(t.TempDir(), ".flutree_config.json")} + want := domain.UserConfigDocument{ + Version: 1, + Scope: domain.UserScopeConfig{Root: filepath.Clean(t.TempDir())}, + } + + if err := repo.Save(want); err != nil { + t.Fatalf("save failed: %v", err) + } + + got, err := repo.Load() + if err != nil { + t.Fatalf("load failed: %v", err) + } + if got.Version != want.Version || got.Scope.Root != want.Scope.Root { + t.Fatalf("unexpected persisted value: got=%+v want=%+v", got, want) + } +} + +func TestRepositoryLoadRejectsMalformedJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), ".flutree_config.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{invalid"), 0o644); err != nil { + t.Fatal(err) + } + + repo := &Repository{Path: path} + _, err := repo.Load() + if err == nil { + t.Fatalf("expected parse error for malformed json") + } + if !strings.Contains(strings.ToLower(err.Error()), "parse") { + t.Fatalf("expected parse error message, got: %v", err) + } +} + +func TestRepositoryLoadRejectsUnsupportedVersion(t *testing.T) { + path := filepath.Join(t.TempDir(), ".flutree_config.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + b, err := json.Marshal(map[string]any{"version": 999, "scope": map[string]any{"root": "/tmp"}}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, b, 0o644); err != nil { + t.Fatal(err) + } + + repo := &Repository{Path: path} + _, err = repo.Load() + if err == nil { + t.Fatalf("expected unsupported version error") + } + if !strings.Contains(strings.ToLower(err.Error()), "unsupported") { + t.Fatalf("expected unsupported version in error, got: %v", err) + } +} + +func TestRepositorySaveUsesAtomicReplaceForReaders(t *testing.T) { + path := filepath.Join(t.TempDir(), ".flutree_config.json") + repo := &Repository{Path: path} + + if err := repo.Save(domain.UserConfigDocument{Version: 1}); err != nil { + t.Fatalf("initial save failed: %v", err) + } + + var wg sync.WaitGroup + errCh := make(chan error, 32) + stop := make(chan struct{}) + + reader := func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + b, err := os.ReadFile(path) + if err != nil { + errCh <- err + return + } + var doc domain.UserConfigDocument + if err := json.Unmarshal(b, &doc); err != nil { + errCh <- err + return + } + } + } + } + + for i := 0; i < 4; i++ { + wg.Add(1) + go reader() + } + + for i := 0; i < 40; i++ { + root := filepath.Join(t.TempDir(), "scope", string(rune('a'+(i%26)))) + if err := repo.Save(domain.UserConfigDocument{Version: 1, Scope: domain.UserScopeConfig{Root: root}}); err != nil { + t.Fatalf("save iteration %d failed: %v", i, err) + } + } + + close(stop) + wg.Wait() + close(errCh) + for err := range errCh { + t.Fatalf("reader observed invalid intermediate state: %v", err) + } + + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Fatalf("temp file should not remain after save, stat err=%v", err) + } +} diff --git a/internal/ui/add_repo_wizard.go b/internal/ui/add_repo_wizard.go new file mode 100644 index 0000000..c8a9249 --- /dev/null +++ b/internal/ui/add_repo_wizard.go @@ -0,0 +1,555 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + + "github.com/EndersonPro/flutree/internal/domain" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type AddRepoWizardInput struct { + WorkspaceName string + RootBranch string + InitialSelectors []string + InitialPackageBranchSource map[string]string + InitialPackageBase map[string]string + InitialSyncPolicy domain.AddRepoSyncPolicy +} + +type AddRepoWizardResult struct { + Cancelled bool + Apply bool + RepoSelectors []string + PackageBranchSource map[string]string + PackageBaseBranch map[string]string + SyncPolicy domain.AddRepoSyncPolicy +} + +type addRepoWizardStep int + +const ( + addRepoWizardStepSelectRepos addRepoWizardStep = iota + addRepoWizardStepReview + addRepoWizardStepRepoOptions +) + +const ( + addRepoWizardFinalCancel = iota + addRepoWizardFinalApply +) + +type addRepoOption struct { + SourceBranch string + BaseBranch string +} + +type addRepoWizardModel struct { + step addRepoWizardStep + + workspaceName string + rootBranch string + repos []domain.DiscoveredFlutterRepo + + cursor int + selected map[int]bool + + selectedRepos []domain.DiscoveredFlutterRepo + repoOptions map[string]addRepoOption + repoIndex int + optionField int + + finalChoice int + syncPolicy domain.AddRepoSyncPolicy + + input textinput.Model + errMsg string + + done bool + cancelled bool +} + +func RunAddRepoWizard(input AddRepoWizardInput, repos []domain.DiscoveredFlutterRepo) (AddRepoWizardResult, error) { + if len(repos) == 0 { + return AddRepoWizardResult{}, domain.NewError(domain.CategoryPrecondition, 3, "No additional repositories available to attach.", "All discoverable repositories are already attached.", nil) + } + + model := newAddRepoWizardModel(input, repos) + resultModel, err := tea.NewProgram(model).Run() + if err != nil { + return AddRepoWizardResult{}, domain.NewError(domain.CategoryUnexpected, 1, "Interactive add-repo flow failed.", "Retry the command or switch to --non-interactive mode.", err) + } + + finalModel, ok := resultModel.(addRepoWizardModel) + if !ok { + return AddRepoWizardResult{}, domain.NewError(domain.CategoryUnexpected, 1, "Invalid interactive add-repo state.", "Retry the command.", nil) + } + + return finalModel.result(), nil +} + +func newAddRepoWizardModel(input AddRepoWizardInput, repos []domain.DiscoveredFlutterRepo) addRepoWizardModel { + orderedRepos := append([]domain.DiscoveredFlutterRepo(nil), repos...) + sort.Slice(orderedRepos, func(i, j int) bool { + if orderedRepos[i].Name != orderedRepos[j].Name { + return orderedRepos[i].Name < orderedRepos[j].Name + } + return orderedRepos[i].RepoRoot < orderedRepos[j].RepoRoot + }) + + selected := map[int]bool{} + for i, repo := range orderedRepos { + for _, selector := range input.InitialSelectors { + if matchesSelector(repo, selector) { + selected[i] = true + break + } + } + } + if len(selected) == 0 && len(orderedRepos) > 0 { + selected[0] = true + } + + typed := textinput.New() + typed.Focus() + + syncPolicy := input.InitialSyncPolicy + if syncPolicy == "" { + syncPolicy = domain.AddRepoSyncAuto + } + + m := addRepoWizardModel{ + step: addRepoWizardStepSelectRepos, + workspaceName: strings.TrimSpace(input.WorkspaceName), + rootBranch: normalizeWizardBranch(input.RootBranch, "main"), + repos: orderedRepos, + selected: selected, + repoOptions: map[string]addRepoOption{}, + finalChoice: addRepoWizardFinalApply, + syncPolicy: syncPolicy, + input: typed, + } + + m.selectedRepos = m.selectedReposFromMap() + m.seedRepoOptions(input.InitialPackageBranchSource, input.InitialPackageBase) + return m +} + +func (m addRepoWizardModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m addRepoWizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + m.cancelled = true + m.done = true + return m, tea.Quit + } + + switch m.step { + case addRepoWizardStepSelectRepos: + return m.updateSelectRepos(msg) + case addRepoWizardStepRepoOptions: + return m.updateRepoOptions(msg) + case addRepoWizardStepReview: + return m.updateReview(msg) + } + } + + if m.step == addRepoWizardStepRepoOptions { + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } + + return m, nil +} + +func (m addRepoWizardModel) View() string { + var b strings.Builder + b.WriteString(wizardTitleStyle.Render("flutree add-repo")) + b.WriteString("\n") + b.WriteString(wizardSubtitleStyle.Render("Interactive repository attachment wizard")) + b.WriteString(m.progressLabel()) + b.WriteString("\n\n") + + if m.errMsg != "" { + b.WriteString(wizardErrorStyle.Render("Error: " + m.errMsg)) + b.WriteString("\n\n") + } + + switch m.step { + case addRepoWizardStepSelectRepos: + b.WriteString(m.selectReposView()) + case addRepoWizardStepRepoOptions: + b.WriteString(m.repoOptionsView()) + case addRepoWizardStepReview: + b.WriteString(m.reviewView()) + } + + return b.String() +} + +func (m addRepoWizardModel) updateSelectRepos(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + m.errMsg = "" + case "down", "j": + if m.cursor < len(m.repos)-1 { + m.cursor++ + } + m.errMsg = "" + case " ": + m.selected[m.cursor] = !m.selected[m.cursor] + m.errMsg = "" + case "enter": + selected := m.selectedReposFromMap() + if len(selected) == 0 { + m.errMsg = "Select at least one repository before continuing." + return m, nil + } + m.selectedRepos = selected + m.ensureSelectedRepoOptions() + m.step = addRepoWizardStepReview + m.errMsg = "" + return m, nil + case "esc": + m.cancelled = true + m.done = true + return m, tea.Quit + } + + return m, nil +} + +func (m addRepoWizardModel) updateRepoOptions(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEsc: + m.cancelled = true + m.done = true + return m, tea.Quit + case tea.KeyEnter: + value := strings.TrimSpace(m.input.Value()) + if value == "" { + if m.optionField == 0 { + m.errMsg = "Source branch cannot be empty." + } else { + m.errMsg = "Base branch cannot be empty." + } + return m, nil + } + + repo := m.selectedRepos[m.repoIndex] + option := m.repoOptions[repo.RepoRoot] + if m.optionField == 0 { + option.SourceBranch = value + m.optionField = 1 + } else { + option.BaseBranch = value + m.repoOptions[repo.RepoRoot] = option + m.repoIndex++ + m.optionField = 0 + if m.repoIndex >= len(m.selectedRepos) { + m.done = true + m.errMsg = "" + return m, tea.Quit + } + } + m.repoOptions[repo.RepoRoot] = option + m.errMsg = "" + m.prepareRepoOptionInput() + return m, nil + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd +} + +func (m addRepoWizardModel) updateReview(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "left", "h": + m.syncPolicy = cycleSyncPolicy(m.syncPolicy, -1) + case "right", "l": + m.syncPolicy = cycleSyncPolicy(m.syncPolicy, 1) + case "up", "k": + m.finalChoice = addRepoWizardFinalCancel + case "down", "j": + m.finalChoice = addRepoWizardFinalApply + case "s": + m.syncPolicy = cycleSyncPolicy(m.syncPolicy, 1) + case "enter": + if m.finalChoice == addRepoWizardFinalCancel { + m.cancelled = true + m.done = true + return m, tea.Quit + } + m.repoIndex = 0 + m.optionField = 0 + m.ensureSelectedRepoOptions() + m.prepareRepoOptionInput() + m.step = addRepoWizardStepRepoOptions + m.errMsg = "" + return m, nil + case "esc": + m.cancelled = true + m.done = true + return m, tea.Quit + } + + return m, nil +} + +func (m addRepoWizardModel) progressLabel() string { + labels := []string{"1.Select repos", "2.Review", "3.Branches"} + for i := range labels { + if i == int(m.step) { + labels[i] = wizardProgressActiveStyle.Render(labels[i]) + continue + } + labels[i] = wizardProgressIdleStyle.Render(labels[i]) + } + return "\n" + strings.Join(labels, " ") +} + +func (m addRepoWizardModel) selectReposView() string { + var b strings.Builder + b.WriteString(wizardSectionStyle.Render("Step 1 - Select repositories")) + b.WriteString("\n") + for i, repo := range m.repos { + cursor := " " + if i == m.cursor { + cursor = ">" + } + marker := "[ ]" + if m.selected[i] { + marker = "[x]" + } + b.WriteString(fmt.Sprintf("%s %s %s [%s] (%s)\n", cursor, marker, repo.Name, repo.PackageName, repo.RepoRoot)) + } + b.WriteString("\n") + b.WriteString(wizardHintStyle.Render("Arrow keys or j/k to move • Space to toggle • Enter to continue • Esc to cancel")) + return b.String() +} + +func (m addRepoWizardModel) repoOptionsView() string { + if len(m.selectedRepos) == 0 || m.repoIndex >= len(m.selectedRepos) { + return wizardSectionStyle.Render("Step 3 - Configure branches") + "\n" + wizardHintStyle.Render("Finalizing selection...") + } + + repo := m.selectedRepos[m.repoIndex] + fieldLabel := "Source branch" + if m.optionField == 1 { + fieldLabel = "Base branch" + } + + var b strings.Builder + b.WriteString(wizardSectionStyle.Render("Step 3 - Configure branches")) + b.WriteString("\n") + b.WriteString(fmt.Sprintf("Repository %d/%d: %s\n", m.repoIndex+1, len(m.selectedRepos), repo.Name)) + b.WriteString(fmt.Sprintf("%s for %s\n", fieldLabel, repo.Name)) + b.WriteString(m.input.View()) + b.WriteString("\n\n") + b.WriteString(wizardHintStyle.Render("Enter to continue • Esc to cancel")) + return b.String() +} + +func (m addRepoWizardModel) reviewView() string { + choiceCancel := "(*) Cancel" + choiceApply := "( ) Continue" + if m.finalChoice == addRepoWizardFinalApply { + choiceCancel = "( ) Cancel" + choiceApply = "(*) Continue" + } + + var b strings.Builder + b.WriteString(wizardSectionStyle.Render("Step 2 - Review and confirm")) + b.WriteString("\n") + b.WriteString(renderTable( + []string{"Setting", "Value"}, + [][]string{ + {"Workspace", m.workspaceName}, + {"Root branch", m.rootBranch}, + {"Sync policy", string(m.syncPolicy)}, + }, + )) + b.WriteString("\n") + b.WriteString(renderTable([]string{"Repository", "Package"}, m.reviewRows())) + b.WriteString("\n") + b.WriteString(choiceCancel) + b.WriteString("\n") + b.WriteString(choiceApply) + b.WriteString("\n\n") + b.WriteString(wizardHintStyle.Render("Up/Down to choose Cancel or Continue • Left/Right (or s) to change sync policy • Enter to proceed")) + return b.String() +} + +func (m *addRepoWizardModel) prepareRepoOptionInput() { + if !m.input.Focused() { + m.input.Focus() + } + + repo := m.selectedRepos[m.repoIndex] + current := m.repoOptions[repo.RepoRoot] + if m.optionField == 0 { + m.input.Prompt = "Source branch: " + m.input.SetValue(normalizeWizardBranch(current.SourceBranch, m.rootBranch)) + } else { + m.input.Prompt = "Base branch: " + m.input.SetValue(normalizeWizardBranch(current.BaseBranch, "main")) + } + m.input.CursorEnd() +} + +func (m *addRepoWizardModel) seedRepoOptions(initialSource, initialBase map[string]string) { + if initialSource == nil { + initialSource = map[string]string{} + } + if initialBase == nil { + initialBase = map[string]string{} + } + + for _, repo := range m.selectedRepos { + source := strings.TrimSpace(initialSource[repo.RepoRoot]) + if source == "" { + source = strings.TrimSpace(initialSource[repo.Name]) + } + base := strings.TrimSpace(initialBase[repo.RepoRoot]) + if base == "" { + base = strings.TrimSpace(initialBase[repo.Name]) + } + m.repoOptions[repo.RepoRoot] = addRepoOption{ + SourceBranch: normalizeWizardBranch(source, m.rootBranch), + BaseBranch: normalizeWizardBranch(base, "main"), + } + } +} + +func (m *addRepoWizardModel) ensureSelectedRepoOptions() { + options := map[string]addRepoOption{} + for _, repo := range m.selectedRepos { + current := m.repoOptions[repo.RepoRoot] + options[repo.RepoRoot] = addRepoOption{ + SourceBranch: normalizeWizardBranch(current.SourceBranch, m.rootBranch), + BaseBranch: normalizeWizardBranch(current.BaseBranch, "main"), + } + } + m.repoOptions = options +} + +func (m addRepoWizardModel) selectedReposFromMap() []domain.DiscoveredFlutterRepo { + selected := make([]domain.DiscoveredFlutterRepo, 0, len(m.selected)) + seenRoots := map[string]struct{}{} + for i, repo := range m.repos { + if !m.selected[i] { + continue + } + key := domain.NormalizePath(repo.RepoRoot) + if _, exists := seenRoots[key]; exists { + continue + } + seenRoots[key] = struct{}{} + selected = append(selected, repo) + } + sort.Slice(selected, func(i, j int) bool { + if selected[i].Name != selected[j].Name { + return selected[i].Name < selected[j].Name + } + return selected[i].RepoRoot < selected[j].RepoRoot + }) + return selected +} + +func (m addRepoWizardModel) reviewRows() [][]string { + rows := make([][]string, 0, len(m.selectedRepos)) + for _, repo := range m.selectedRepos { + rows = append(rows, []string{ + repo.Name, + repo.PackageName, + }) + } + return rows +} + +func (m addRepoWizardModel) result() AddRepoWizardResult { + if m.cancelled { + return AddRepoWizardResult{Cancelled: true} + } + + repos := m.selectedRepos + if len(repos) == 0 { + repos = m.selectedReposFromMap() + } + + selectors := make([]string, 0, len(repos)) + sourceMap := map[string]string{} + baseMap := map[string]string{} + for _, repo := range repos { + selectors = append(selectors, repo.RepoRoot) + option := m.repoOptions[repo.RepoRoot] + sourceMap[repo.RepoRoot] = normalizeWizardBranch(option.SourceBranch, m.rootBranch) + baseMap[repo.RepoRoot] = normalizeWizardBranch(option.BaseBranch, "main") + } + + return AddRepoWizardResult{ + Cancelled: false, + Apply: m.done && !m.cancelled, + RepoSelectors: dedupWizardSelectors(selectors), + PackageBranchSource: sourceMap, + PackageBaseBranch: baseMap, + SyncPolicy: m.syncPolicy, + } +} + +func dedupWizardSelectors(values []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + normalized := domain.NormalizePath(trimmed) + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + out = append(out, trimmed) + } + return out +} + +func cycleSyncPolicy(current domain.AddRepoSyncPolicy, direction int) domain.AddRepoSyncPolicy { + values := []domain.AddRepoSyncPolicy{domain.AddRepoSyncAuto, domain.AddRepoSyncAlways, domain.AddRepoSyncNever} + index := 0 + for i, value := range values { + if value == current { + index = i + break + } + } + + index += direction + if index < 0 { + index = len(values) - 1 + } + if index >= len(values) { + index = 0 + } + return values[index] +} + +func normalizeWizardBranch(value, fallback string) string { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + return strings.TrimSpace(fallback) +} diff --git a/internal/ui/add_repo_wizard_test.go b/internal/ui/add_repo_wizard_test.go new file mode 100644 index 0000000..6200a56 --- /dev/null +++ b/internal/ui/add_repo_wizard_test.go @@ -0,0 +1,145 @@ +package ui + +import ( + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/EndersonPro/flutree/internal/domain" + tea "github.com/charmbracelet/bubbletea" +) + +func TestAddRepoWizardBlocksContinueWhenSelectionEmpty(t *testing.T) { + repos := []domain.DiscoveredFlutterRepo{ + {Name: "core-pkg", PackageName: "core", RepoRoot: "/repos/core"}, + {Name: "design-pkg", PackageName: "design", RepoRoot: "/repos/design"}, + } + + m := newAddRepoWizardModel(AddRepoWizardInput{RootBranch: "feature/login"}, repos) + m.selected = map[int]bool{} + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next := updated.(addRepoWizardModel) + + if !strings.Contains(next.errMsg, "Select at least one repository") { + t.Fatalf("expected empty-selection validation, got %q", next.errMsg) + } + if next.step != addRepoWizardStepSelectRepos { + t.Fatalf("expected to remain on selection step, got %v", next.step) + } +} + +func TestAddRepoWizardSupportsNavigationAndToggle(t *testing.T) { + repos := []domain.DiscoveredFlutterRepo{ + {Name: "core-pkg", PackageName: "core", RepoRoot: "/repos/core"}, + {Name: "design-pkg", PackageName: "design", RepoRoot: "/repos/design"}, + } + + m := newAddRepoWizardModel(AddRepoWizardInput{RootBranch: "feature/login"}, repos) + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + next := updated.(addRepoWizardModel) + if next.cursor != 1 { + t.Fatalf("expected cursor to move down, got %d", next.cursor) + } + + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + next = updated.(addRepoWizardModel) + if !next.selected[1] { + t.Fatalf("expected current repo to be toggled") + } +} + +func TestAddRepoWizardReviewApplyProducesRepoRootKeyedResult(t *testing.T) { + repos := []domain.DiscoveredFlutterRepo{ + {Name: "design-pkg", PackageName: "design", RepoRoot: "/repos/design"}, + {Name: "core-pkg", PackageName: "core", RepoRoot: "/repos/core"}, + } + + m := newAddRepoWizardModel(AddRepoWizardInput{RootBranch: "feature/login"}, repos) + m.selected = map[int]bool{0: true, 1: true} + m.selectedRepos = m.selectedReposFromMap() + m.repoOptions = map[string]addRepoOption{ + "/repos/design": {SourceBranch: "feature/design", BaseBranch: "main"}, + "/repos/core": {SourceBranch: "feature/core", BaseBranch: "release/1.0"}, + } + m.step = addRepoWizardStepReview + m.finalChoice = addRepoWizardFinalApply + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next := updated.(addRepoWizardModel) + if next.step != addRepoWizardStepRepoOptions { + t.Fatalf("expected review confirm to continue into branch/base prompts, got step=%v", next.step) + } + if next.done { + t.Fatalf("expected wizard to continue after review confirmation") + } + + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next = updated.(addRepoWizardModel) + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next = updated.(addRepoWizardModel) + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next = updated.(addRepoWizardModel) + updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyEnter}) + next = updated.(addRepoWizardModel) + + if !next.done { + t.Fatalf("expected wizard to finish after branch/base prompts") + } + + result := next.result() + if !result.Apply || result.Cancelled { + t.Fatalf("expected apply=true and cancelled=false, got %+v", result) + } + + if len(result.RepoSelectors) != 2 { + t.Fatalf("expected 2 selected repos, got %v", result.RepoSelectors) + } + + if result.RepoSelectors[0] != "/repos/core" || result.RepoSelectors[1] != "/repos/design" { + t.Fatalf("expected deterministic sorted selectors by repo name, got %v", result.RepoSelectors) + } + + if result.PackageBranchSource["/repos/core"] != "feature/core" { + t.Fatalf("expected repo-root keyed source map, got %v", result.PackageBranchSource) + } + if result.PackageBaseBranch["/repos/design"] != "main" { + t.Fatalf("expected repo-root keyed base map, got %v", result.PackageBaseBranch) + } +} + +func TestAddRepoWizardCancelOnEscHasNoApply(t *testing.T) { + repos := []domain.DiscoveredFlutterRepo{{Name: "core-pkg", PackageName: "core", RepoRoot: "/repos/core"}} + m := newAddRepoWizardModel(AddRepoWizardInput{RootBranch: "feature/login"}, repos) + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + next := updated.(addRepoWizardModel) + result := next.result() + + if !result.Cancelled { + t.Fatalf("expected cancelled result on esc") + } + if result.Apply { + t.Fatalf("expected apply=false when cancelled") + } +} + +func TestAddRepoWizardViewShowsReviewTableAndEnglishCopy(t *testing.T) { + repos := []domain.DiscoveredFlutterRepo{{Name: "core-pkg", PackageName: "core", RepoRoot: filepath.Clean("/repos/core")}} + m := newAddRepoWizardModel(AddRepoWizardInput{RootBranch: "feature/login"}, repos) + m.step = addRepoWizardStepReview + m.selected = map[int]bool{0: true} + m.selectedRepos = m.selectedReposFromMap() + + view := cleanANSI(m.View()) + if !strings.Contains(view, "Step 2 - Review and confirm") { + t.Fatalf("expected review title in english, got %q", view) + } + if !regexp.MustCompile(`\|\s*Repository\s*\|\s*Package\s*\|`).MatchString(view) { + t.Fatalf("expected review repository table headers, got %q", view) + } + if !strings.Contains(view, "Continue") { + t.Fatalf("expected continue option in review view, got %q", view) + } +} From c98a1457803c97233b69cc319fbb86b9b1640e2a Mon Sep 17 00:00:00 2001 From: Enderson Vizcaino Date: Wed, 22 Apr 2026 14:01:45 -0500 Subject: [PATCH 2/4] feat(cli): wire config, clean, interactive add-repo, and table UX --- README.md | 26 +- cmd/flutree/main.go | 171 ++++++++++- docs/architecture.md | 2 + docs/usage.md | 36 ++- go.mod | 1 + go.sum | 2 + integration/cli_contract_test.go | 470 ++++++++++++++++++++++++++++++- internal/ui/render.go | 21 ++ internal/ui/render_test.go | 116 +++++++- internal/ui/styles.go | 2 +- internal/ui/table.go | 178 ++++++------ 11 files changed, 931 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 5a686bb..c29ef69 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ - [🏗️ Commands Reference](#%EF%B8%8F-commands-reference) - [create](#create) - [add-repo](#add-repo) + - [config](#config) - [list](#list) - [complete](#complete) - [pubget](#pubget) @@ -77,6 +78,8 @@ 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 config set scope.root ~/code +flutree config get scope.root flutree list flutree --version flutree update --check @@ -102,6 +105,8 @@ Default destination root is `~/Documents/worktrees`, generating: - 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. +- `config set/get scope.root` lets you persist a default discovery scope for `create` and `add-repo`. +- Discovery scope precedence is now: explicit `--scope` > persisted `scope.root` > `.`. - 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. @@ -183,6 +188,7 @@ flutree create [options] ### add-repo Attaches additional repositories to an existing managed workspace and regenerates `pubspec_overrides.yaml`. +In interactive TTY mode (without `--repo` and without `--non-interactive`) it launches a multiselect wizard with an explicit final review/apply gate. Usage: ``` @@ -192,10 +198,26 @@ 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 | +| `--repo` | string | | Repository selector to attach (repeatable). Required in non-interactive mode; bypasses interactive wizard when provided | | `--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 | +| `--non-interactive` | boolean | `false` | Disable interactive wizard/prompts and enforce deterministic execution | + +### config + +Manages persisted CLI configuration. + +Usage: +``` +flutree config set scope.root +flutree config get scope.root +``` + +Supported keys: + +| Key | Description | +|-----|-------------| +| `scope.root` | Default discovery root used by `create` and `add-repo` when `--scope` is omitted | ### list diff --git a/cmd/flutree/main.go b/cmd/flutree/main.go index 100679a..312ded3 100644 --- a/cmd/flutree/main.go +++ b/cmd/flutree/main.go @@ -10,6 +10,7 @@ import ( "github.com/EndersonPro/flutree/internal/app" "github.com/EndersonPro/flutree/internal/domain" + infraConfig "github.com/EndersonPro/flutree/internal/infra/config" infraGit "github.com/EndersonPro/flutree/internal/infra/git" "github.com/EndersonPro/flutree/internal/infra/prompt" infraPub "github.com/EndersonPro/flutree/internal/infra/pub" @@ -42,8 +43,12 @@ func main() { runtime.ExitOnError(runComplete(os.Args[2:])) case "pubget": runtime.ExitOnError(runPubGet(os.Args[2:])) + case "clean": + runtime.ExitOnError(runClean(os.Args[2:])) case "update": runtime.ExitOnError(runUpdate(os.Args[2:])) + case "config": + runtime.ExitOnError(runConfig(os.Args[2:])) case "version", "--version": runtime.ExitOnError(runVersion(os.Args[2:])) case "--help", "-h", "help": @@ -176,6 +181,13 @@ func runCreate(args []string) error { } genWorkspace := *workspace && !*noWorkspace + scopeFlagProvided := wasFlagProvided(fs, "scope") + configRepo := infraConfig.NewDefault() + scopeResolver := app.NewScopeResolver(configRepo) + resolvedScope, err := scopeResolver.Resolve(*scope, scopeFlagProvided) + if err != nil { + return err + } gitGateway := &infraGit.Gateway{} promptAdapter := prompt.New() @@ -185,7 +197,7 @@ func runCreate(args []string) error { Name: name, Branch: branchName, BaseBranch: *baseBranch, - ExecutionScope: *scope, + ExecutionScope: resolvedScope, RootSelector: *rootRepo, NoPackage: *noPackage, PackageSelectors: packages, @@ -199,7 +211,7 @@ func runCreate(args []string) error { applyAfterDryRun := true wizardUsed := false if !*nonInteractive && ui.SupportsInteractiveWizard() { - repos, err := gitGateway.DiscoverFlutterRepos(*scope) + repos, err := gitGateway.DiscoverFlutterRepos(resolvedScope) if err != nil { return err } @@ -345,6 +357,31 @@ func runPubGet(args []string) error { return nil } +func runClean(args []string) error { + fs := newFlagSet("clean", printCleanHelp) + force := fs.Bool("force", false, "Remove pubspec.lock after clean.") + if len(args) > 0 && isHelpToken(args[0]) { + printCleanHelp() + return nil + } + helpRequested, err := parseFlagSet(fs, args, "Invalid clean arguments.", "") + if err != nil { + return err + } + if helpRequested { + return nil + } + + service := app.NewCleanService(&infraGit.Gateway{}, registry.NewDefault(), &infraPub.Gateway{}) + result, err := service.Run(domain.CleanInput{Force: *force}) + if err != nil { + return err + } + + ui.RenderCleanSuccess(result) + return nil +} + func runAddRepo(args []string) error { fs := newFlagSet("add-repo", printAddRepoHelp) scope := fs.String("scope", ".", "Directory scope used to discover Flutter repositories.") @@ -383,6 +420,13 @@ func runAddRepo(args []string) error { if err != nil { return err } + scopeFlagProvided := wasFlagProvided(fs, "scope") + configRepo := infraConfig.NewDefault() + scopeResolver := app.NewScopeResolver(configRepo) + resolvedScope, err := scopeResolver.Resolve(*scope, scopeFlagProvided) + if err != nil { + return err + } branchSourceMap := map[string]string{} for _, entry := range packageBranchSource { @@ -403,9 +447,43 @@ func runAddRepo(args []string) error { } service := app.NewAddRepoService(&infraGit.Gateway{}, registry.NewDefault(), prompt.New()) + wizardGate := ui.SupportsInteractiveWizard() && !*nonInteractive && len(repos) == 0 + if wizardGate { + selectionContext, err := service.BuildSelectionContext(app.AddRepoSelectionContextInput{ + WorkspaceName: workspaceName, + ExecutionScope: resolvedScope, + }) + if err != nil { + return err + } + + wizardResult, err := ui.RunAddRepoWizard(ui.AddRepoWizardInput{ + WorkspaceName: workspaceName, + RootBranch: selectionContext.RootBranch, + InitialSelectors: repos, + InitialPackageBranchSource: branchSourceMap, + InitialPackageBase: baseMap, + InitialSyncPolicy: parsedSyncPolicy, + }, selectionContext.Candidates) + if err != nil { + return err + } + if wizardResult.Cancelled || !wizardResult.Apply { + return domain.NewError(domain.CategoryInput, 2, "Add-repo cancelled before execution.", "Re-run add-repo to open the interactive flow again.", nil) + } + + repos = append([]string{}, wizardResult.RepoSelectors...) + branchSourceMap = wizardResult.PackageBranchSource + baseMap = wizardResult.PackageBaseBranch + if !(wasFlagProvided(fs, "sync-policy") && parsedSyncPolicy != domain.AddRepoSyncAuto) { + parsedSyncPolicy = wizardResult.SyncPolicy + } + *nonInteractive = true + } + result, err := service.Run(domain.AddRepoInput{ WorkspaceName: workspaceName, - ExecutionScope: *scope, + ExecutionScope: resolvedScope, RepoSelectors: repos, PackageBranchSource: branchSourceMap, PackageBaseBranch: baseMap, @@ -448,6 +526,42 @@ func runVersion(args []string) error { return nil } +func runConfig(args []string) error { + if len(args) == 0 || isHelpToken(args[0]) { + printConfigHelp() + return nil + } + + configRepo := infraConfig.NewDefault() + service := app.NewConfigService(configRepo) + + action := strings.TrimSpace(args[0]) + switch action { + case "set": + if len(args) != 3 { + return domain.NewError(domain.CategoryInput, 2, "Invalid config set arguments.", "Usage: flutree config set scope.root ", nil) + } + stored, err := service.SetScopeRoot(args[1], args[2]) + if err != nil { + return err + } + fmt.Println(stored) + return nil + case "get": + if len(args) != 2 { + return domain.NewError(domain.CategoryInput, 2, "Invalid config get arguments.", "Usage: flutree config get scope.root", nil) + } + value, err := service.GetScopeRoot(args[1]) + if err != nil { + return err + } + fmt.Println(value) + return nil + default: + return domain.NewError(domain.CategoryInput, 2, "Invalid config action.", "Use `flutree config set scope.root ` or `flutree config get scope.root`.", nil) + } +} + func runUpdate(args []string) error { fs := newFlagSet("update", printUpdateHelp) check := fs.Bool("check", false, "Check whether a brew update is available.") @@ -496,9 +610,11 @@ func printHelp() { fmt.Println(accent.Render("Commands:")) fmt.Println(" " + cmdStyle.Render("create") + " [options] " + muted.Render("Create a new worktree with interactive wizard")) fmt.Println(" " + cmdStyle.Render("add-repo") + " [options] " + muted.Render("Attach repositories to an existing worktree")) + fmt.Println(" " + cmdStyle.Render("config") + " ... " + muted.Render("Manage persisted CLI configuration")) fmt.Println(" " + cmdStyle.Render("list") + " [options] " + muted.Render("List managed worktrees")) fmt.Println(" " + cmdStyle.Render("complete") + " [options] " + muted.Render("Complete and remove a worktree")) fmt.Println(" " + cmdStyle.Render("pubget") + " [--force] " + muted.Render("Run pub get across workspace packages")) + fmt.Println(" " + cmdStyle.Render("clean") + " [--force] " + muted.Render("Clean current managed worktree")) fmt.Println(" " + cmdStyle.Render("update") + " [--check|--apply] " + muted.Render("Check or apply brew updates")) fmt.Println(" " + cmdStyle.Render("version") + " " + muted.Render("Show version")) fmt.Println("") @@ -547,6 +663,16 @@ func parseFlagSet(fs *flag.FlagSet, args []string, invalidMessage, hint string) return false, nil } +func wasFlagProvided(fs *flag.FlagSet, name string) bool { + provided := false + fs.Visit(func(f *flag.Flag) { + if f.Name == name { + provided = true + } + }) + return provided +} + func isHelpToken(token string) bool { switch strings.TrimSpace(token) { case "-h", "--help": @@ -590,20 +716,37 @@ func printAddRepoHelp() { flagStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#0F4C81", Dark: "#8BC6FF"}) fmt.Println(accent.Render("🌳 flutree add-repo")) - fmt.Println(muted.Render("Attach repositories to an existing worktree.")) + fmt.Println(muted.Render("Attach repositories to an existing worktree with interactive multiselect + final review when TTY is available.")) fmt.Println("") fmt.Println(accent.Render("Usage:")) fmt.Println(" flutree add-repo [options]") fmt.Println("") fmt.Println(accent.Render("Options:")) fmt.Println(" " + flagStyle.Render("--scope") + " " + muted.Render("Directory scope for Flutter repo discovery (default: .)")) - fmt.Println(" " + flagStyle.Render("--repo") + " " + muted.Render("Repository selector to attach (repeatable)")) + fmt.Println(" " + flagStyle.Render("--repo") + " " + muted.Render("Repository selector to attach (repeatable). Skips interactive wizard when provided")) fmt.Println(" " + flagStyle.Render("--package-branch-source") + " = " + muted.Render("Override package target branch (repeatable)")) fmt.Println(" " + flagStyle.Render("--package-base") + " = " + muted.Render("Override package base branch (repeatable)")) fmt.Println(" " + flagStyle.Render("--sync-policy") + " " + muted.Render("Sync behavior before create (default: auto)")) fmt.Println(" " + flagStyle.Render("--reuse-existing-branch") + " " + muted.Render("Allow non-interactive reuse of existing branch")) fmt.Println(" " + flagStyle.Render("--copy-root-file") + " " + muted.Render("Extra root file/pattern to copy (repeatable)")) - fmt.Println(" " + flagStyle.Render("--non-interactive") + " " + muted.Render("Disable prompts")) + fmt.Println(" " + flagStyle.Render("--non-interactive") + " " + muted.Render("Disable interactive wizard/prompts and require deterministic selectors")) + fmt.Println(" " + flagStyle.Render("-h, --help") + " " + muted.Render("Show this help")) +} + +func printConfigHelp() { + accent := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "#0F4C81", Dark: "#8BC6FF"}) + muted := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#6B7280", Dark: "#A1A1AA"}) + flagStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#0F4C81", Dark: "#8BC6FF"}) + + fmt.Println(accent.Render("🌳 flutree config")) + fmt.Println(muted.Render("Manage persisted CLI configuration values.")) + fmt.Println("") + fmt.Println(accent.Render("Usage:")) + fmt.Println(" flutree config set scope.root ") + fmt.Println(" flutree config get scope.root") + fmt.Println("") + fmt.Println(accent.Render("Supported keys:")) + fmt.Println(" " + flagStyle.Render("scope.root") + " " + muted.Render("Default discovery root for create/add-repo when --scope is omitted")) fmt.Println(" " + flagStyle.Render("-h, --help") + " " + muted.Render("Show this help")) } @@ -677,6 +820,22 @@ func printPubGetHelp() { fmt.Println(" " + flagStyle.Render("-h, --help") + " " + muted.Render("Show this help")) } +func printCleanHelp() { + accent := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "#0F4C81", Dark: "#8BC6FF"}) + muted := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#6B7280", Dark: "#A1A1AA"}) + flagStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#0F4C81", Dark: "#8BC6FF"}) + + fmt.Println(accent.Render("🌳 flutree clean")) + fmt.Println(muted.Render("Clean the current managed worktree.")) + fmt.Println("") + fmt.Println(accent.Render("Usage:")) + fmt.Println(" flutree clean [options]") + fmt.Println("") + fmt.Println(accent.Render("Options:")) + fmt.Println(" " + flagStyle.Render("--force") + " " + muted.Render("Also remove pubspec.lock")) + fmt.Println(" " + flagStyle.Render("-h, --help") + " " + muted.Render("Show this help")) +} + func printUpdateHelp() { accent := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "#0F4C81", Dark: "#8BC6FF"}) muted := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#6B7280", Dark: "#A1A1AA"}) diff --git a/docs/architecture.md b/docs/architecture.md index 67f7b72..343eb50 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,6 +8,7 @@ - CLI handlers map arguments/options to typed input models. - Commands call services and then render output. - They do not execute Git subprocesses or parse files directly. +- Discovery scope is resolved in command handlers with precedence: explicit `--scope` > persisted `scope.root` > `.`. ## 2) Domain Layer (`internal/domain`) - Typed contracts for inputs, registry documents, read models. @@ -16,6 +17,7 @@ ## 3) Adapter Layer (`internal/infra`) - `git/`: subprocess interaction and porcelain parsing. - `registry/`: global registry repository + integrity checks. +- `config/`: user config repository (`~/Documents/worktrees/.flutree_config.json`) with versioned schema and atomic writes. - `prompt/`: confirmation boundary with non-interactive fail-fast behavior. ## 4) UI Layer (`internal/ui`) diff --git a/docs/usage.md b/docs/usage.md index 7e9d975..4395359 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -52,6 +52,11 @@ go test ./... - In non-interactive mode, the command never prompts and uses deterministic defaults (`source=`, `base=main`) unless explicit overrides are provided. - `--sync-policy auto|always|never` controls pre-create remote sync behavior for attached repos. +`flutree config set scope.root PATH` / `flutree config get scope.root` +- Persists and reads the default discovery scope root. +- Scope resolution precedence for `create` and `add-repo`: explicit `--scope` > persisted `scope.root` > `.`. +- `scope.root` must be an existing reachable directory. + `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. @@ -81,6 +86,7 @@ Options: - `--branch, -b TEXT`: target branch name for the root worktree. If omitted, defaults to `feature/`. - `--base-branch TEXT`: source branch for root worktree creation (default: `main`). - `--scope PATH`: execution directory scope used to discover Flutter repositories (default: current directory). + - if omitted, `create` uses persisted `scope.root` when configured. - `--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). @@ -213,13 +219,19 @@ Registry/persistence issues: Options: - `--scope PATH`: execution directory scope used to discover Flutter repositories (default: current directory). -- `--repo TEXT`: repository selector to attach (repeatable). + - if omitted, `add-repo` uses persisted `scope.root` when configured. +- `--repo TEXT`: repository selector to attach (repeatable). When omitted in interactive TTY mode, `add-repo` opens the multiselect wizard. - `--package-branch-source TEXT`: per-repository target branch override in `=` format (repeatable). - `--package-base TEXT`: per-repository base branch override in `=` format (repeatable). - `--sync-policy TEXT`: sync behavior before creation: `auto` (interactive confirm, non-interactive false), `always`, `never`. - `--reuse-existing-branch`: allow non-interactive branch reuse when target branch already exists. - `--copy-root-file TEXT`: extra root-level file/pattern copied into each attached worktree (repeatable). -- `--non-interactive`: disable prompts and enforce deterministic execution. +- `--non-interactive`: disable interactive wizard/prompts and enforce deterministic execution. + +Interactive flow (TTY + no `--repo` + no `--non-interactive`): +- Step 1: repository multiselect (`↑/↓` or `j/k`, `space` to toggle). +- Step 2: per-selected-repo source/base branch inputs. +- Step 3: final review + explicit apply/cancel gate before any mutation. Examples: @@ -228,3 +240,23 @@ flutree add-repo feature-login --scope ~/code --repo core-pkg flutree add-repo feature-login --scope ~/code --repo core-pkg --package-branch-source core-pkg=feature/core --package-base core-pkg=main --sync-policy always --non-interactive --reuse-existing-branch flutree add-repo feature-login --scope ~/code --repo core-pkg --sync-policy never --non-interactive ``` + +## config + +Supported key: +- `scope.root`: default discovery root for `create` and `add-repo`. + +Examples: + +```bash +flutree config set scope.root ~/code +flutree config get scope.root +``` + +Persistence file: +- `~/Documents/worktrees/.flutree_config.json` +- JSON schema v1: + +```json +{ "version": 1, "scope": { "root": "/absolute/path" } } +``` diff --git a/go.mod b/go.mod index 5ed8ff9..32c3947 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/x/ansi v0.4.5 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index bbfb7de..35b47b0 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSe github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= diff --git a/integration/cli_contract_test.go b/integration/cli_contract_test.go index 393ecbc..2b4ccd6 100644 --- a/integration/cli_contract_test.go +++ b/integration/cli_contract_test.go @@ -1,13 +1,19 @@ package integration_test import ( + "bytes" "encoding/json" + "io" "os" "os/exec" "path/filepath" "runtime" "strings" + "sync" "testing" + "time" + + "github.com/creack/pty" ) func buildCLI(t *testing.T) string { @@ -41,6 +47,12 @@ type runResult struct { stderr string } +type writerFunc func(p []byte) (int, error) + +func (f writerFunc) Write(p []byte) (int, error) { + return f(p) +} + func runCLI(t *testing.T, bin, cwd string, env []string, stdin string, args ...string) runResult { t.Helper() cmd := exec.Command(bin, args...) @@ -63,6 +75,109 @@ func runCLI(t *testing.T, bin, cwd string, env []string, stdin string, args ...s return runResult{code: code, stdout: string(out)} } +func runCLIWithPTY(t *testing.T, bin, cwd string, env []string, scriptedInputs []string, args ...string) runResult { + t.Helper() + + cmd := exec.Command(bin, args...) + cmd.Dir = cwd + cmd.Env = append(append([]string{}, env...), "TERM=dumb", "COLORTERM=") + + ptmx, err := pty.Start(cmd) + if err != nil { + t.Fatalf("failed to start PTY command: %v", err) + } + t.Cleanup(func() { _ = ptmx.Close() }) + + var ( + mu sync.Mutex + buf bytes.Buffer + ) + readDone := make(chan struct{}) + go func() { + safeWriter := writerFunc(func(p []byte) (int, error) { + mu.Lock() + defer mu.Unlock() + return buf.Write(p) + }) + _, _ = io.Copy(safeWriter, ptmx) + close(readDone) + }() + + readOutput := func() string { + mu.Lock() + defer mu.Unlock() + return buf.String() + } + respondedCursorPos := false + respondedBackground := false + for _, step := range scriptedInputs { + parts := strings.SplitN(step, "::", 2) + if len(parts) != 2 { + t.Fatalf("invalid PTY script step %q, expected ::", step) + } + waitFor := parts[0] + input := parts[1] + + deadline := time.Now().Add(5 * time.Second) + for { + output := readOutput() + if !respondedCursorPos && strings.Contains(output, "\x1b[6n") { + if _, err := ptmx.Write([]byte("\x1b[1;1R")); err != nil { + t.Fatalf("failed to answer cursor position probe: %v", err) + } + respondedCursorPos = true + } + if !respondedBackground && strings.Contains(output, "\x1b]11;?") { + if _, err := ptmx.Write([]byte("\x1b]11;rgb:0000/0000/0000\a")); err != nil { + t.Fatalf("failed to answer background color probe: %v", err) + } + respondedBackground = true + } + if strings.Contains(output, waitFor) { + break + } + if time.Now().After(deadline) { + t.Fatalf("timeout waiting for %q in PTY output. Output so far: %s", waitFor, output) + } + time.Sleep(20 * time.Millisecond) + } + + if _, err := ptmx.Write([]byte(input)); err != nil { + t.Fatalf("failed to write PTY input %q: %v", input, err) + } + } + + waitDone := make(chan error, 1) + go func() { + waitDone <- cmd.Wait() + }() + + var waitErr error + select { + case waitErr = <-waitDone: + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + waitErr = <-waitDone + t.Fatalf("timeout waiting for PTY command completion") + } + + _ = ptmx.Close() + <-readDone + + output := readOutput() + if waitErr != nil { + code := 1 + if ee, ok := waitErr.(*exec.ExitError); ok && ee.ProcessState != nil { + code = ee.ProcessState.ExitCode() + } else if cmd.ProcessState != nil { + code = cmd.ProcessState.ExitCode() + } + return runResult{code: code, stderr: output} + } + + return runResult{code: 0, stdout: output} +} + func runGit(t *testing.T, cwd string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) @@ -151,7 +266,7 @@ func TestCLIHelpListsExpectedCommands(t *testing.T) { if res.code != 0 { t.Fatalf("expected 0, got %d (%s)", res.code, res.stderr) } - if !strings.Contains(res.stdout, "create") || !strings.Contains(res.stdout, "list") || !strings.Contains(res.stdout, "complete") { + if !strings.Contains(res.stdout, "create") || !strings.Contains(res.stdout, "config") || !strings.Contains(res.stdout, "list") || !strings.Contains(res.stdout, "complete") || !strings.Contains(res.stdout, "clean") { t.Fatalf("unexpected help output: %s", res.stdout) } if !strings.Contains(res.stdout, "flutree --help") { @@ -171,6 +286,11 @@ func TestSubcommandHelpContracts(t *testing.T) { args []string contains []string }{ + { + name: "config help", + args: []string{"config", "--help"}, + contains: []string{"flutree config set scope.root ", "flutree config get scope.root", "scope.root"}, + }, { name: "create long help", args: []string{"create", "--help"}, @@ -196,6 +316,11 @@ func TestSubcommandHelpContracts(t *testing.T) { args: []string{"pubget", "--help"}, contains: []string{"flutree pubget [options]", "--force"}, }, + { + name: "clean help", + args: []string{"clean", "--help"}, + contains: []string{"flutree clean [options]", "--force"}, + }, { name: "list help", args: []string{"list", "--help"}, @@ -228,6 +353,225 @@ func TestSubcommandHelpContracts(t *testing.T) { } } +func TestConfigSetGetScopeRootRoundTrip(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + scope := t.TempDir() + + setRes := runCLI(t, bin, projectRoot(t), testEnv(home), "", "config", "set", "scope.root", scope) + if setRes.code != 0 { + t.Fatalf("expected config set success, got %d (%s)", setRes.code, setRes.stderr) + } + want := filepath.Clean(scope) + if strings.TrimSpace(setRes.stdout) != want { + t.Fatalf("expected normalized set output %q, got %q", want, setRes.stdout) + } + + getRes := runCLI(t, bin, projectRoot(t), testEnv(home), "", "config", "get", "scope.root") + if getRes.code != 0 { + t.Fatalf("expected config get success, got %d (%s)", getRes.code, getRes.stderr) + } + if strings.TrimSpace(getRes.stdout) != want { + t.Fatalf("expected persisted get output %q, got %q", want, getRes.stdout) + } +} + +func TestConfigRejectsUnsupportedKeys(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + + setRes := runCLI(t, bin, projectRoot(t), testEnv(home), "", "config", "set", "other.key", "/tmp") + if setRes.code != 2 { + t.Fatalf("expected config set unsupported key to fail with 2, got %d (%s)", setRes.code, setRes.stderr) + } + if !strings.Contains(setRes.stderr, "Unsupported config key") { + t.Fatalf("unexpected set stderr: %s", setRes.stderr) + } + + getRes := runCLI(t, bin, projectRoot(t), testEnv(home), "", "config", "get", "other.key") + if getRes.code != 2 { + t.Fatalf("expected config get unsupported key to fail with 2, got %d (%s)", getRes.code, getRes.stderr) + } + if !strings.Contains(getRes.stderr, "Unsupported config key") { + t.Fatalf("unexpected get stderr: %s", getRes.stderr) + } +} + +func TestConfigSetRejectsInvalidAndNonDirectoryPaths(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + missing := filepath.Join(t.TempDir(), "missing") + + missingRes := runCLI(t, bin, projectRoot(t), testEnv(home), "", "config", "set", "scope.root", missing) + if missingRes.code != 2 { + t.Fatalf("expected missing path failure with code 2, got %d (%s)", missingRes.code, missingRes.stderr) + } + if !strings.Contains(missingRes.stderr, "does not exist") { + t.Fatalf("unexpected missing-path stderr: %s", missingRes.stderr) + } + + file := filepath.Join(t.TempDir(), "file.txt") + if err := os.WriteFile(file, []byte("content"), 0o644); err != nil { + t.Fatal(err) + } + nonDirRes := runCLI(t, bin, projectRoot(t), testEnv(home), "", "config", "set", "scope.root", file) + if nonDirRes.code != 2 { + t.Fatalf("expected non-directory failure with code 2, got %d (%s)", nonDirRes.code, nonDirRes.stderr) + } + if !strings.Contains(nonDirRes.stderr, "must be a directory") { + t.Fatalf("unexpected non-directory stderr: %s", nonDirRes.stderr) + } + + if runtime.GOOS != "windows" { + unreachable := filepath.Join(t.TempDir(), "private") + if err := os.MkdirAll(unreachable, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(unreachable, 0o000); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(unreachable, 0o700) }) + + unreachableRes := runCLI(t, bin, projectRoot(t), testEnv(home), "", "config", "set", "scope.root", unreachable) + if unreachableRes.code != 2 { + t.Fatalf("expected unreachable path failure with code 2, got %d (%s)", unreachableRes.code, unreachableRes.stderr) + } + if !strings.Contains(unreachableRes.stderr, "not reachable") { + t.Fatalf("unexpected unreachable-path stderr: %s", unreachableRes.stderr) + } + } +} + +func TestCreateUsesPersistedScopeWhenFlagOmitted(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + scope := filepath.Join(t.TempDir(), "workspace") + repo := filepath.Join(scope, "root-app") + initRepo(t, repo) + + setRes := runCLI(t, bin, projectRoot(t), testEnv(home), "", "config", "set", "scope.root", scope) + if setRes.code != 0 { + t.Fatalf("config set failed: %d (%s)", setRes.code, setRes.stderr) + } + + create := runCLI( + t, bin, repo, testEnv(home), "", + "create", "feature-login", + "--root-repo", "root-app", + "--yes", + "--non-interactive", + ) + if create.code != 0 { + t.Fatalf("create without --scope should use persisted root, got %d (%s)", create.code, create.stderr) + } +} + +func TestCreateExplicitScopeOverridesPersistedScope(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + persistedScope := filepath.Join(t.TempDir(), "persisted") + explicitScope := filepath.Join(t.TempDir(), "explicit") + persistedRepo := filepath.Join(persistedScope, "persisted-root") + explicitRepo := filepath.Join(explicitScope, "explicit-root") + initRepo(t, persistedRepo) + initRepo(t, explicitRepo) + + setRes := runCLI(t, bin, projectRoot(t), testEnv(home), "", "config", "set", "scope.root", persistedScope) + if setRes.code != 0 { + t.Fatalf("config set failed: %d (%s)", setRes.code, setRes.stderr) + } + + create := runCLI( + t, bin, explicitRepo, testEnv(home), "", + "create", "feature-login", + "--scope", explicitScope, + "--root-repo", "explicit-root", + "--yes", + "--non-interactive", + ) + if create.code != 0 { + t.Fatalf("create with explicit --scope should override persisted root, got %d (%s)", create.code, create.stderr) + } +} + +func TestAddRepoUsesPersistedScopeWhenFlagOmitted(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") + + setRes := runCLI(t, bin, projectRoot(t), testEnv(home), "", "config", "set", "scope.root", scope) + if setRes.code != 0 { + t.Fatalf("config set failed: %d (%s)", setRes.code, setRes.stderr) + } + + create := runCLI( + t, bin, rootRepo, testEnv(home), "", + "create", "feature-login", + "--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", + "--repo", "core-pkg", + "--non-interactive", + ) + if add.code != 0 { + t.Fatalf("add-repo without --scope should use persisted root, got %d (%s)", add.code, add.stderr) + } +} + +func TestAddRepoExplicitScopeOverridesPersistedScope(t *testing.T) { + bin := buildCLI(t) + home := t.TempDir() + persistedScope := filepath.Join(t.TempDir(), "persisted") + explicitScope := filepath.Join(t.TempDir(), "explicit") + + persistedRoot := filepath.Join(persistedScope, "root-app") + explicitRoot := filepath.Join(explicitScope, "root-app") + explicitCore := filepath.Join(explicitScope, "core-pkg") + initRepoWithPackageName(t, persistedRoot, "root_app") + initRepoWithPackageName(t, explicitRoot, "root_app") + initRepoWithPackageName(t, explicitCore, "core") + + setRes := runCLI(t, bin, projectRoot(t), testEnv(home), "", "config", "set", "scope.root", persistedScope) + if setRes.code != 0 { + t.Fatalf("config set failed: %d (%s)", setRes.code, setRes.stderr) + } + + create := runCLI( + t, bin, explicitRoot, testEnv(home), "", + "create", "feature-login", + "--scope", explicitScope, + "--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, explicitRoot, testEnv(home), "", + "add-repo", "feature-login", + "--scope", explicitScope, + "--repo", "core-pkg", + "--non-interactive", + ) + if add.code != 0 { + t.Fatalf("add-repo with explicit --scope should override persisted scope, got %d (%s)", add.code, add.stderr) + } +} + func TestMissingPositionalStillFailsWithoutHelp(t *testing.T) { bin := buildCLI(t) home := t.TempDir() @@ -846,6 +1190,130 @@ func TestAddRepoInteractiveAutoSyncPromptsBeforeAttach(t *testing.T) { } } +func TestAddRepoInteractiveWizardApplyAttachesSelectedRepo(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 := runCLIWithPTY(t, bin, rootRepo, testEnv(home), []string{ + "Step 1 - Select repositories::\r", + "Step 2 - Review and confirm::\r", + "Step 3 - Configure branches::\r", + "Step 3 - Configure branches::\r", + }, "add-repo", "feature-login", "--scope", scope) + if add.code != 0 { + t.Fatalf("expected interactive wizard apply success, got %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 after interactive apply: %s", got) + } +} + +func TestAddRepoInteractiveWizardCancelFromReviewHasNoSideEffects(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 := runCLIWithPTY(t, bin, rootRepo, testEnv(home), []string{ + "Step 1 - Select repositories::\r", + "Step 2 - Review and confirm::\x1b[A\r", + }, "add-repo", "feature-login", "--scope", scope) + if add.code != 2 { + t.Fatalf("expected cancellation error code 2, got %d (%s)", add.code, add.stderr) + } + if !strings.Contains(add.stderr, "Add-repo cancelled before execution") { + t.Fatalf("expected cancellation guidance, got: %s", add.stderr) + } + + pkgWorktree := filepath.Join(home, "Documents", "worktrees", "feature-login", "packages", "core-pkg") + if _, err := os.Stat(pkgWorktree); !os.IsNotExist(err) { + t.Fatalf("expected no package worktree after review cancel, stat err=%v", err) + } + + overridePath := filepath.Join(home, "Documents", "worktrees", "feature-login", "root", "root-app", "pubspec_overrides.yaml") + content, err := os.ReadFile(overridePath) + if err == nil && strings.Contains(string(content), "core:") { + t.Fatalf("expected no override entry for canceled attach, got: %s", string(content)) + } +} + +func TestAddRepoNonInteractiveWithoutRepoSelectorsFailsDeterministically(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, + "--non-interactive", + ) + if add.code != 2 { + t.Fatalf("expected deterministic non-interactive validation failure, got %d (%s)", add.code, add.stderr) + } + if !strings.Contains(add.stderr, "Repository selection is required in non-interactive mode") { + t.Fatalf("expected missing-selector guidance, got: %s", add.stderr) + } +} + func TestAddRepoNonInteractiveSyncAlwaysFailsFastAndRollsBack(t *testing.T) { bin := buildCLI(t) home := t.TempDir() diff --git a/internal/ui/render.go b/internal/ui/render.go index 3d28e33..19b9ec1 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -119,6 +119,27 @@ func RenderPubGetSuccess(result domain.PubGetResult) { fmt.Println(SuccessBox(b.String())) } +func RenderCleanSuccess(result domain.CleanResult) { + var b strings.Builder + b.WriteString(KeyValue("Worktree", result.Record.Name)) + b.WriteString("\n") + b.WriteString(KeyValue("Path", result.Record.Path)) + b.WriteString("\n") + b.WriteString(KeyValue("Tool", string(result.Tool))) + if result.Force { + b.WriteString("\n") + b.WriteString(KeyValue("Mode", "force")) + } + if result.LockRemoved { + b.WriteString("\n") + b.WriteString(KeyValue("Lock", "pubspec.lock removed")) + } + + header := uiIconStyle.Render("✅") + uiSuccessHeader.Render("Worktree Clean Completed") + fmt.Println(header) + fmt.Println(SuccessBox(b.String())) +} + func RenderAddRepoSuccess(result domain.AddRepoResult) { var b strings.Builder b.WriteString(KeyValue("Workspace", result.WorkspaceName)) diff --git a/internal/ui/render_test.go b/internal/ui/render_test.go index e079ab2..a620649 100644 --- a/internal/ui/render_test.go +++ b/internal/ui/render_test.go @@ -100,7 +100,7 @@ func TestRenderListIncludesPackageAssociationHint(t *testing.T) { output := captureStdout(t, func() { RenderList(rows, false) }) - if !regexp.MustCompile(`\|\s*Name\s*\|\s*Branch\s*\|\s*Status\s*\|\s*Path\s*\|`).MatchString(output) { + if !regexp.MustCompile(`│\s*Name\s*│\s*Branch\s*│\s*Status\s*│\s*Path\s*│`).MatchString(output) { t.Fatalf("expected list table header, got: %q", output) } if !strings.Contains(output, "feature-login (+2 packages)") { @@ -108,6 +108,96 @@ func TestRenderListIncludesPackageAssociationHint(t *testing.T) { } } +func TestRenderListFourColumnsInOrder(t *testing.T) { + rows := []domain.ListRow{ + {Name: "alpha", Branch: "main", Status: "active", Path: "/tmp/alpha"}, + {Name: "beta", Branch: "feature/beta", Status: "completed", Path: "/tmp/beta"}, + } + + output := captureStdout(t, func() { RenderList(rows, false) }) + + // Verify four columns appear in order: Name, Branch, Status, Path + if !regexp.MustCompile(`│\s*Name\s*│\s*Branch\s*│\s*Status\s*│\s*Path\s*│`).MatchString(output) { + t.Fatalf("expected four columns in order, got: %q", output) + } + if !strings.Contains(output, "alpha") { + t.Fatalf("expected alpha row, got: %q", output) + } + if !strings.Contains(output, "beta") { + t.Fatalf("expected beta row, got: %q", output) + } +} + +func TestRenderListStatusCellsHaveIconsAndColors(t *testing.T) { + rows := []domain.ListRow{ + {Name: "a", Branch: "main", Status: "active", Path: "/tmp/a"}, + {Name: "b", Branch: "main", Status: "completed", Path: "/tmp/b"}, + {Name: "c", Branch: "main", Status: "error", Path: "/tmp/c"}, + } + + output := captureStdout(t, func() { RenderList(rows, false) }) + + if !strings.Contains(output, "● active") { + t.Fatalf("expected active status with icon, got: %q", output) + } + if !strings.Contains(output, "○ completed") { + t.Fatalf("expected completed status with icon, got: %q", output) + } + if !strings.Contains(output, "✖ error") { + t.Fatalf("expected error status with icon, got: %q", output) + } +} + +func TestRenderListNarrowTerminalTruncatesPath(t *testing.T) { + oldDefault := defaultTerminalWidth + defaultTerminalWidth = 50 + defer func() { defaultTerminalWidth = oldDefault }() + + rows := []domain.ListRow{ + {Name: "feature-login", Branch: "feature/login", Status: "active", Path: "/very/long/path/to/somewhere/over/the/rainbow"}, + } + + output := captureStdout(t, func() { RenderList(rows, false) }) + + // Path should be truncated (contains ellipsis) + if !strings.Contains(output, "…") { + t.Fatalf("expected truncated path with ellipsis in narrow terminal, got: %q", output) + } + // Name and Branch should still be present (not truncated) + if !strings.Contains(output, "feature-login") { + t.Fatalf("expected name to be present, got: %q", output) + } + if !strings.Contains(output, "feature/login") { + t.Fatalf("expected branch to be present, got: %q", output) + } +} + +func TestRenderListPipedOutputNoANSI(t *testing.T) { + rows := []domain.ListRow{ + {Name: "feature-login", Branch: "feature/login", Status: "active", Path: "/tmp/path"}, + } + + // Capture raw output directly to check for ANSI codes + originalStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create stdout pipe: %v", err) + } + os.Stdout = w + RenderList(rows, false) + _ = w.Close() + os.Stdout = originalStdout + out, err := io.ReadAll(r) + if err != nil { + t.Fatalf("failed to read stdout output: %v", err) + } + rawOutput := string(out) + + if ansiOutputRegex.MatchString(rawOutput) { + t.Fatalf("expected no ANSI escape codes in piped output, got: %q", rawOutput) + } +} + func TestRenderPubGetSuccessIncludesForceAndExecutionRows(t *testing.T) { result := domain.PubGetResult{ WorkspaceName: "feature-login", @@ -141,3 +231,27 @@ func TestRenderPubGetSuccessIncludesForceAndExecutionRows(t *testing.T) { t.Fatalf("expected root execution line, got: %q", output) } } + +func TestRenderCleanSuccessIncludesForceMetadata(t *testing.T) { + result := domain.CleanResult{ + Record: domain.RegistryRecord{ + Name: "feature-login", + Path: "/tmp/worktrees/feature-login/root/root-app", + }, + Tool: domain.PubToolFlutter, + Force: true, + LockRemoved: true, + } + + output := captureStdout(t, func() { RenderCleanSuccess(result) }) + + if !strings.Contains(output, "Worktree Clean Completed") { + t.Fatalf("expected clean header, got: %q", output) + } + if !strings.Contains(output, "Mode: force") { + t.Fatalf("expected force mode line, got: %q", output) + } + if !strings.Contains(output, "Lock: pubspec.lock removed") { + t.Fatalf("expected lock removal line, got: %q", output) + } +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index b615c31..d4a876b 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -40,7 +40,7 @@ var ( // Table styles uiTableHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(uiAccentColor) uiTableRowStyle = lipgloss.NewStyle() - uiTableRowAltStyle = lipgloss.NewStyle() + uiTableRowAltStyle = lipgloss.NewStyle().Background(lipgloss.AdaptiveColor{Light: "#F3F4F6", Dark: "#374151"}) // Badge and icon styles uiBadgeStyle = lipgloss.NewStyle().Bold(true).Padding(0, 1).MarginRight(1) diff --git a/internal/ui/table.go b/internal/ui/table.go index 9782e4f..f1e839f 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -1,52 +1,123 @@ package ui import ( + "os" "strings" "github.com/EndersonPro/flutree/internal/domain" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" + "github.com/charmbracelet/x/term" + "github.com/mattn/go-runewidth" ) -// renderStyledTable builds a styled table with lipgloss-colored headers, -// status-aware cell rendering, and Unicode separator lines. -// Cell delimiters use ASCII | to maintain test compatibility. +var defaultTerminalWidth = 120 + +func terminalWidth() int { + width, _, err := term.GetSize(os.Stdout.Fd()) + if err != nil { + return defaultTerminalWidth + } + return width +} + +// renderStyledTable builds a styled table using lipgloss/table with adaptive +// colors, status-aware cell rendering, zebra striping, and terminal-width +// awareness. func renderStyledTable(headers []string, rows [][]string, listRows []domain.ListRow) string { if len(headers) == 0 { return "" } - widths := make([]int, len(headers)) - for i, header := range headers { - widths[i] = len(header) + targetWidth := terminalWidth() + + // Apply status-based styling to the Status column (index 2) + styledRows := make([][]string, len(rows)) + for i, row := range rows { + styledRow := make([]string, len(row)) + copy(styledRow, row) + if len(styledRow) > 2 && i < len(listRows) { + styledRow[2] = statusCell(styledRow[2], listRows[i].Status) + } + styledRows[i] = styledRow } - normalizedRows := make([][]string, 0, len(rows)) - for _, row := range rows { - normalized := make([]string, len(headers)) - for i := range headers { - if i < len(row) { - normalized[i] = row[i] + // Truncate Path column first when terminal width is insufficient + styledRows = truncatePathFirst(headers, styledRows, targetWidth) + + t := table.New(). + Headers(headers...). + Rows(styledRows...). + StyleFunc(func(row, col int) lipgloss.Style { + if row == table.HeaderRow { + return uiTableHeaderStyle } - if len(normalized[i]) > widths[i] { - widths[i] = len(normalized[i]) + if row%2 == 0 { + return uiTableRowStyle + } + return uiTableRowAltStyle + }) + + return t.Render() +} + +// truncatePathFirst reduces the Path column content when the estimated total +// table width exceeds the target width. Other columns are preserved. +func truncatePathFirst(headers []string, rows [][]string, targetWidth int) [][]string { + if targetWidth <= 0 { + return rows + } + + // Overhead: left border (1) + right border (1) + internal separators (cols-1) + cell padding (cols*2) + overhead := 2 + (len(headers) - 1) + (len(headers) * 2) + + colWidths := make([]int, len(headers)) + for i, h := range headers { + colWidths[i] = lipgloss.Width(h) + } + for _, row := range rows { + for i, cell := range row { + if i < len(colWidths) { + if w := lipgloss.Width(cell); w > colWidths[i] { + colWidths[i] = w + } } } - normalizedRows = append(normalizedRows, normalized) } - var b strings.Builder - writeTableSeparatorUnicode(&b, widths) - writeTableStyledHeader(&b, headers, widths) - writeTableSeparatorUnicode(&b, widths) - for i, row := range normalizedRows { - if i < len(listRows) { - writeTableStyledRow(&b, row, widths, listRows[i].Status, i%2 == 0) - } else { - writeTableStyledRow(&b, row, widths, "", i%2 == 0) + totalWidth := overhead + for _, w := range colWidths { + totalWidth += w + } + + if totalWidth <= targetWidth { + return rows + } + + excess := totalWidth - targetWidth + pathIdx := 3 // Path is the 4th column + + newRows := make([][]string, len(rows)) + for i, row := range rows { + newRow := make([]string, len(row)) + copy(newRow, row) + if pathIdx < len(row) { + pathWidth := lipgloss.Width(row[pathIdx]) + if pathWidth > excess { + newWidth := pathWidth - excess + if newWidth < 1 { + newWidth = 1 + } + // Truncate from the left so the basename (end of path) is preserved. + newRow[pathIdx] = runewidth.TruncateLeft(row[pathIdx], newWidth, "…") + } else { + newRow[pathIdx] = "…" + } } + newRows[i] = newRow } - writeTableSeparatorUnicode(&b, widths) - return b.String() + + return newRows } // renderTable builds a plain table with ASCII delimiters. @@ -86,18 +157,6 @@ func renderTable(headers []string, rows [][]string) string { return b.String() } -func writeTableSeparatorUnicode(b *strings.Builder, widths []int) { - b.WriteString("├") - for _, width := range widths { - b.WriteString(strings.Repeat("─", width+2)) - b.WriteString("┼") - } - result := b.String() - b.Reset() - b.WriteString(result[:len(result)-1]) - b.WriteString("┤\n") -} - func writeTableSeparatorASCII(b *strings.Builder, widths []int) { b.WriteString("+") for _, width := range widths { @@ -107,49 +166,6 @@ func writeTableSeparatorASCII(b *strings.Builder, widths []int) { b.WriteString("\n") } -func writeTableStyledHeader(b *strings.Builder, headers []string, widths []int) { - b.WriteString("|") - for i, header := range headers { - cell := uiTableHeaderStyle.Render(header) - b.WriteString(" ") - b.WriteString(cell) - cellWidth := lipgloss.Width(cell) - if cellWidth < widths[i] { - b.WriteString(strings.Repeat(" ", widths[i]-cellWidth)) - } - if i < len(headers)-1 { - b.WriteString(" |") - } else { - b.WriteString(" |") - } - } - b.WriteString("\n") -} - -func writeTableStyledRow(b *strings.Builder, values []string, widths []int, status string, isEven bool) { - b.WriteString("|") - for i := range widths { - value := "" - if i < len(values) { - value = values[i] - } - - // Apply status-based coloring for the Status column - if i == 2 && status != "" { - value = statusCell(value, status) - } - - cellWidth := lipgloss.Width(value) - b.WriteString(" ") - b.WriteString(value) - if cellWidth < widths[i] { - b.WriteString(strings.Repeat(" ", widths[i]-cellWidth)) - } - b.WriteString(" |") - } - b.WriteString("\n") -} - func statusCell(display, status string) string { switch status { case "active": From fd944105e974f5f5d458c236a8a14b2f58d467bd Mon Sep 17 00:00:00 2001 From: Enderson Vizcaino Date: Wed, 22 Apr 2026 14:14:20 -0500 Subject: [PATCH 3/4] fix(test): set COLUMNS=200 in testEnv for list table tests --- integration/cli_contract_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/integration/cli_contract_test.go b/integration/cli_contract_test.go index 2b4ccd6..20a4aa3 100644 --- a/integration/cli_contract_test.go +++ b/integration/cli_contract_test.go @@ -220,6 +220,8 @@ func initRepoWithPackageName(t *testing.T, path, packageName string) { func testEnv(home string) []string { env := os.Environ() env = append(env, "HOME="+home) + // Ensure terminal is wide enough for list table output in CI + env = append(env, "COLUMNS=200") return env } From 1e671e98a774d49caff9e27b85ec7ff9475b475d Mon Sep 17 00:00:00 2001 From: Enderson Vizcaino Date: Wed, 22 Apr 2026 14:18:12 -0500 Subject: [PATCH 4/4] fix(ui): terminalWidth reads COLUMNS env var for CI/non-TTY compatibility --- internal/ui/table.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/ui/table.go b/internal/ui/table.go index f1e839f..efd2143 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -2,6 +2,7 @@ package ui import ( "os" + "strconv" "strings" "github.com/EndersonPro/flutree/internal/domain" @@ -14,6 +15,12 @@ import ( var defaultTerminalWidth = 120 func terminalWidth() int { + // Respect COLUMNS env var (standard way to override terminal size in CI/non-TTY). + if cols := os.Getenv("COLUMNS"); cols != "" { + if w, err := strconv.Atoi(cols); err == nil && w > 0 { + return w + } + } width, _, err := term.GetSize(os.Stdout.Fd()) if err != nil { return defaultTerminalWidth