diff --git a/cmd/entire/cli/config.go b/cmd/entire/cli/config.go index c8bb499df1..19853b9341 100644 --- a/cmd/entire/cli/config.go +++ b/cmd/entire/cli/config.go @@ -24,26 +24,6 @@ const ( EntireSettingsLocalFile = ".entire/settings.local.json" ) -// StartSettings contains configuration for the 'entire start' command -type StartSettings struct { - // BranchPrefix is prepended to feature names when creating branches - // Default: "feature/" - BranchPrefix string `json:"branchPrefix,omitempty"` - - // BaseBranch is the default branch to create features from - // Default: "main" - BaseBranch string `json:"baseBranch,omitempty"` - - // RequirementsTemplate is the path to the requirements template file - // Required for --scaffold flag to work - RequirementsTemplate string `json:"requirementsTemplate,omitempty"` - - // UseWorktrees controls whether to create git worktrees for parallel work. - // When false (default), creates branch and checks it out in current directory. - // When true, creates branch in a separate worktree at .worktrees/. - UseWorktrees bool `json:"useWorktrees,omitempty"` -} - // EntireSettings represents the .entire/settings.json configuration type EntireSettings struct { // Strategy is the name of the git strategy to use @@ -76,9 +56,6 @@ type EntireSettings struct { // AgentOptions contains agent-specific configuration // Keyed by agent name, e.g., {"claude-code": {"ignore_untracked": false}} AgentOptions map[string]interface{} `json:"agent_options,omitempty"` - - // Start contains configuration for the 'entire start' command - Start *StartSettings `json:"start,omitempty"` } // LoadEntireSettings loads the Entire settings from .entire/settings.json, @@ -283,45 +260,6 @@ func IsEnabled() (bool, error) { return settings.Enabled, nil } -// GetStartSettings returns the start command settings with defaults applied. -// Returns default values if not configured or if settings cannot be loaded. -// Logs a warning if settings file exists but cannot be parsed. -func GetStartSettings() *StartSettings { - defaults := &StartSettings{ - BranchPrefix: "feature/", - BaseBranch: "main", - RequirementsTemplate: "", - UseWorktrees: false, - } - - settings, err := LoadEntireSettings() - if err != nil { - // Log the error so users know their config file has issues - slog.Warn("failed to load settings, using defaults", slog.Any("error", err)) - return defaults - } - if settings.Start == nil { - return defaults - } - - // Apply defaults for missing fields - result := &StartSettings{ - BranchPrefix: settings.Start.BranchPrefix, - BaseBranch: settings.Start.BaseBranch, - RequirementsTemplate: settings.Start.RequirementsTemplate, - UseWorktrees: settings.Start.UseWorktrees, - } - - if result.BranchPrefix == "" { - result.BranchPrefix = defaults.BranchPrefix - } - if result.BaseBranch == "" { - result.BaseBranch = defaults.BaseBranch - } - - return result -} - // GetStrategy returns the configured strategy instance. // Falls back to default if the configured strategy is not found. // diff --git a/cmd/entire/cli/config_test.go b/cmd/entire/cli/config_test.go index db5e058e4d..0d65516097 100644 --- a/cmd/entire/cli/config_test.go +++ b/cmd/entire/cli/config_test.go @@ -10,11 +10,9 @@ import ( ) const ( - testSettingsStrategy = `{"strategy": "manual-commit"}` - testSettingsEnabled = `{"strategy": "manual-commit", "enabled": true}` - testSettingsDisabled = `{"strategy": "manual-commit", "enabled": false}` - testDefaultBranchPrefix = "feature/" - testDefaultBaseBranch = "main" + testSettingsStrategy = `{"strategy": "manual-commit"}` + testSettingsEnabled = `{"strategy": "manual-commit", "enabled": true}` + testSettingsDisabled = `{"strategy": "manual-commit", "enabled": false}` ) func TestLoadEntireSettings_EnabledDefaultsToTrue(t *testing.T) { @@ -135,146 +133,6 @@ func TestSaveEntireSettings_PreservesEnabled(t *testing.T) { } } -func TestGetStartSettings_DefaultsWhenNotConfigured(t *testing.T) { - tmpDir := t.TempDir() - t.Chdir(tmpDir) - - // No settings file exists - should return defaults - startSettings := GetStartSettings() - - if startSettings.BranchPrefix != testDefaultBranchPrefix { - t.Errorf("BranchPrefix = %q, want %q", startSettings.BranchPrefix, testDefaultBranchPrefix) - } - if startSettings.BaseBranch != testDefaultBaseBranch { - t.Errorf("BaseBranch = %q, want %q", startSettings.BaseBranch, testDefaultBaseBranch) - } - if startSettings.RequirementsTemplate != "" { - t.Errorf("RequirementsTemplate = %q, want empty string", startSettings.RequirementsTemplate) - } - if startSettings.UseWorktrees != false { - t.Errorf("UseWorktrees = %v, want false", startSettings.UseWorktrees) - } -} - -func TestGetStartSettings_LoadsFromFile(t *testing.T) { - tmpDir := t.TempDir() - t.Chdir(tmpDir) - - // Create settings file with start config - settingsDir := filepath.Dir(EntireSettingsFile) - if err := os.MkdirAll(settingsDir, 0o755); err != nil { - t.Fatalf("Failed to create settings dir: %v", err) - } - - settingsContent := `{ - "strategy": "manual-commit", - "enabled": true, - "start": { - "branchPrefix": "feat/", - "baseBranch": "develop", - "requirementsTemplate": "templates/req.md", - "useWorktrees": true - } - }` - if err := os.WriteFile(EntireSettingsFile, []byte(settingsContent), 0o644); err != nil { - t.Fatalf("Failed to write settings file: %v", err) - } - - startSettings := GetStartSettings() - - if startSettings.BranchPrefix != "feat/" { - t.Errorf("BranchPrefix = %q, want %q", startSettings.BranchPrefix, "feat/") - } - if startSettings.BaseBranch != "develop" { - t.Errorf("BaseBranch = %q, want %q", startSettings.BaseBranch, "develop") - } - if startSettings.RequirementsTemplate != "templates/req.md" { - t.Errorf("RequirementsTemplate = %q, want %q", startSettings.RequirementsTemplate, "templates/req.md") - } - if startSettings.UseWorktrees != true { - t.Errorf("UseWorktrees = %v, want true", startSettings.UseWorktrees) - } -} - -func TestGetStartSettings_PartialConfig(t *testing.T) { - tmpDir := t.TempDir() - t.Chdir(tmpDir) - - // Create settings file with partial start config - settingsDir := filepath.Dir(EntireSettingsFile) - if err := os.MkdirAll(settingsDir, 0o755); err != nil { - t.Fatalf("Failed to create settings dir: %v", err) - } - - // Only branchPrefix is set, others should use defaults - settingsContent := `{ - "strategy": "manual-commit", - "start": { - "branchPrefix": "fix/" - } - }` - if err := os.WriteFile(EntireSettingsFile, []byte(settingsContent), 0o644); err != nil { - t.Fatalf("Failed to write settings file: %v", err) - } - - startSettings := GetStartSettings() - - if startSettings.BranchPrefix != "fix/" { - t.Errorf("BranchPrefix = %q, want %q", startSettings.BranchPrefix, "fix/") - } - // Should use default for missing fields - if startSettings.BaseBranch != testDefaultBaseBranch { - t.Errorf("BaseBranch = %q, want default %q", startSettings.BaseBranch, testDefaultBaseBranch) - } - if startSettings.RequirementsTemplate != "" { - t.Errorf("RequirementsTemplate = %q, want empty string", startSettings.RequirementsTemplate) - } - if startSettings.UseWorktrees != false { - t.Errorf("UseWorktrees = %v, want false (default)", startSettings.UseWorktrees) - } -} - -func TestSaveEntireSettings_PreservesStartSettings(t *testing.T) { - tmpDir := t.TempDir() - t.Chdir(tmpDir) - - // Save settings with Start config - settings := &EntireSettings{ - Strategy: "manual-commit", - Enabled: true, - Start: &StartSettings{ - BranchPrefix: "feature/", - BaseBranch: "main", - RequirementsTemplate: "templates/requirements.md", - UseWorktrees: true, - }, - } - if err := SaveEntireSettings(settings); err != nil { - t.Fatalf("SaveEntireSettings() error = %v", err) - } - - // Load and verify - loaded, err := LoadEntireSettings() - if err != nil { - t.Fatalf("LoadEntireSettings() error = %v", err) - } - if loaded.Start == nil { - t.Fatal("Start settings should not be nil after loading") - } - if loaded.Start.BranchPrefix != testDefaultBranchPrefix { - t.Errorf("Start.BranchPrefix = %q, want %q", loaded.Start.BranchPrefix, testDefaultBranchPrefix) - } - if loaded.Start.BaseBranch != testDefaultBaseBranch { - t.Errorf("Start.BaseBranch = %q, want %q", loaded.Start.BaseBranch, testDefaultBaseBranch) - } - if loaded.Start.RequirementsTemplate != "templates/requirements.md" { - t.Errorf("Start.RequirementsTemplate = %q, want %q", loaded.Start.RequirementsTemplate, "templates/requirements.md") - } - if loaded.Start.UseWorktrees != true { - t.Errorf("Start.UseWorktrees = %v, want true", loaded.Start.UseWorktrees) - } -} - func TestIsEnabled(t *testing.T) { tmpDir := t.TempDir() t.Chdir(tmpDir) diff --git a/cmd/entire/cli/integration_test/setup_claude_hooks_test.go b/cmd/entire/cli/integration_test/setup_claude_hooks_test.go index c848c44104..4b1e2644dd 100644 --- a/cmd/entire/cli/integration_test/setup_claude_hooks_test.go +++ b/cmd/entire/cli/integration_test/setup_claude_hooks_test.go @@ -47,7 +47,7 @@ func TestSetupClaudeHooks_AddsAllRequiredHooks(t *testing.T) { env.GitCommit("Initial commit") // Run entire enable claude-hooks (non-interactive) - output, err := env.runEntireCmd("enable", "claude-hooks") + output, err := env.RunCLIWithError("enable", "claude-hooks") if err != nil { t.Fatalf("enable claude-hooks command failed: %v\nOutput: %s", err, output) } @@ -116,7 +116,7 @@ func TestSetupClaudeHooks_PreservesExistingSettings(t *testing.T) { } // Run enable claude-hooks - output, err := env.runEntireCmd("enable", "claude-hooks") + output, err := env.RunCLIWithError("enable", "claude-hooks") if err != nil { t.Fatalf("enable claude-hooks failed: %v\nOutput: %s", err, output) } diff --git a/cmd/entire/cli/integration_test/start_test.go b/cmd/entire/cli/integration_test/start_test.go deleted file mode 100644 index ec303df100..0000000000 --- a/cmd/entire/cli/integration_test/start_test.go +++ /dev/null @@ -1,207 +0,0 @@ -//go:build integration - -package integration - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "entire.io/cli/cmd/entire/cli/paths" - "entire.io/cli/cmd/entire/cli/strategy" -) - -// TestStartDefaultMode tests creating a branch and checking it out (default behavior). -func TestStartDefaultMode(t *testing.T) { - t.Parallel() - env := NewTestEnv(t) - env.InitRepo() - env.InitEntire("manual-commit") - - env.WriteFile("README.md", "# Test") - env.GitAdd("README.md", ".entire") - env.GitCommit("Initial commit") - - // Run entire start test-feature (use master as base since go-git creates "master" not "main") - output, err := env.runEntireCmd("start", "test-feature", "--base", "master") - if err != nil { - t.Fatalf("start command failed: %v\nOutput: %s", err, output) - } - - // Verify we're on the new branch - currentBranch := env.GetCurrentBranch() - if currentBranch != "feature/test-feature" { - t.Errorf("expected branch feature/test-feature, got %s", currentBranch) - } - - // Verify branch exists - if !env.BranchExists("feature/test-feature") { - t.Error("branch feature/test-feature should exist") - } -} - -// TestStartWorktreeFromMainRepo tests creating a worktree from the main repo. -func TestStartWorktreeFromMainRepo(t *testing.T) { - t.Parallel() - env := NewTestEnv(t) - env.InitRepo() - env.InitEntire("manual-commit") - - env.WriteFile("README.md", "# Test") - env.GitAdd("README.md", ".entire") - env.GitCommit("Initial commit") - - worktreePath := filepath.Join(env.RepoDir, ".worktrees", "worktree-feature") - - // Clean up worktree at end - t.Cleanup(func() { - cmd := exec.Command("git", "worktree", "remove", worktreePath, "--force") - cmd.Dir = env.RepoDir - _ = cmd.Run() //nolint:errcheck - }) - - // Run entire start --worktree (use master as base since go-git creates "master" not "main") - output, err := env.runEntireCmd("start", "worktree-feature", "--worktree", "--base", "master") - if err != nil { - t.Fatalf("start --worktree failed: %v\nOutput: %s", err, output) - } - - // Verify worktree directory exists - if _, err := os.Stat(worktreePath); os.IsNotExist(err) { - t.Errorf("worktree should exist at %s", worktreePath) - } - - // Verify .gitignore contains .worktrees - gitignore := env.ReadFile(".gitignore") - if !strings.Contains(gitignore, ".worktrees") { - t.Error(".gitignore should contain .worktrees") - } - - // Verify branch exists - if !env.BranchExists("feature/worktree-feature") { - t.Error("branch feature/worktree-feature should exist") - } -} - -// TestStartSiblingWorktreeFromWorktree tests creating a sibling worktree -// from inside an existing worktree. -// -// NOTE: This test uses os.Chdir() because it tests the strategy.IsInsideWorktree() -// function which reads the current working directory. This is intentional and -// the test cannot be parallelized. The CLI subprocess execution uses cmd.Dir -// so it's not affected by the chdir. -func TestStartSiblingWorktreeFromWorktree(t *testing.T) { - env := NewTestEnv(t) - env.InitRepo() - env.InitEntire("manual-commit") - - env.WriteFile("README.md", "# Test") - env.GitAdd("README.md", ".entire") - env.GitCommit("Initial commit") - - // Create first worktree using native git - worktree1 := filepath.Join(env.RepoDir, ".worktrees", "feature-one") - worktree2 := filepath.Join(env.RepoDir, ".worktrees", "feature-two") - - if err := os.MkdirAll(filepath.Dir(worktree1), 0o755); err != nil { - t.Fatalf("failed to create .worktrees dir: %v", err) - } - - cmd := exec.Command("git", "worktree", "add", "-b", "feature/feature-one", worktree1, "HEAD") - cmd.Dir = env.RepoDir - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to create first worktree: %v\nOutput: %s", err, output) - } - - // Clean up worktrees at end - t.Cleanup(func() { - cmd := exec.Command("git", "worktree", "remove", worktree1, "--force") - cmd.Dir = env.RepoDir - _ = cmd.Run() //nolint:errcheck - - cmd = exec.Command("git", "worktree", "remove", worktree2, "--force") - cmd.Dir = env.RepoDir - _ = cmd.Run() //nolint:errcheck - }) - - // Verify we're inside a worktree when we chdir there - originalWd, _ := os.Getwd() - if err := os.Chdir(worktree1); err != nil { - t.Fatalf("failed to chdir to worktree: %v", err) - } - defer func() { _ = os.Chdir(originalWd) }() - - if !strategy.IsInsideWorktree() { - t.Fatal("expected IsInsideWorktree() to return true") - } - - // Copy .entire settings to worktree - worktreeEntireDir := filepath.Join(worktree1, ".entire") - if err := os.MkdirAll(worktreeEntireDir, 0o755); err != nil { - t.Fatalf("failed to create .entire in worktree: %v", err) - } - settingsSrc := filepath.Join(env.RepoDir, ".entire", paths.SettingsFileName) - settingsDst := filepath.Join(worktreeEntireDir, paths.SettingsFileName) - settingsData, err := os.ReadFile(settingsSrc) - if err != nil { - t.Fatalf("failed to read settings: %v", err) - } - if err := os.WriteFile(settingsDst, settingsData, 0o644); err != nil { - t.Fatalf("failed to write settings to worktree: %v", err) - } - - // Create sibling worktree using the start command FROM INSIDE first worktree - output, err := env.runEntireCmdInDir(worktree1, "start", "feature-two", "--worktree", "--base", "master") - if err != nil { - t.Fatalf("start --worktree from inside worktree failed: %v\nOutput: %s", err, output) - } - - // Verify sibling was created in main repo's .worktrees (not nested) - if _, err := os.Stat(worktree2); os.IsNotExist(err) { - t.Errorf("sibling worktree should exist at %s", worktree2) - } - - // Verify branch exists - if !env.BranchExists("feature/feature-two") { - t.Error("branch feature/feature-two should exist") - } -} - -// TestStartMissingArgument tests that the command errors without a feature name. -func TestStartMissingArgument(t *testing.T) { - t.Parallel() - env := NewTestEnv(t) - env.InitRepo() - env.InitEntire("manual-commit") - - env.WriteFile("README.md", "# Test") - env.GitAdd("README.md", ".entire") - env.GitCommit("Initial commit") - - // Run entire start (no name) - _, err := env.runEntireCmd("start") - if err == nil { - t.Error("expected error when no feature name provided") - } -} - -// runEntireCmd builds and runs the entire CLI with the given arguments. -func (env *TestEnv) runEntireCmd(args ...string) (string, error) { - return env.runEntireCmdInDir(env.RepoDir, args...) -} - -// runEntireCmdInDir runs the entire CLI in a specific directory using the shared test binary. -func (env *TestEnv) runEntireCmdInDir(dir string, args ...string) (string, error) { - env.T.Helper() - - cmd := exec.Command(getTestBinary(), args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), - "ENTIRE_TEST_CLAUDE_PROJECT_DIR="+env.ClaudeProjectDir, - ) - - output, err := cmd.CombinedOutput() - return string(output), err -} diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 69a3833b6d..d0d757981a 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -49,7 +49,6 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newStatusCmd()) cmd.AddCommand(newHooksCmd()) cmd.AddCommand(newSummarizeCmd()) - cmd.AddCommand(newStartCmd()) cmd.AddCommand(newVersionCmd()) cmd.AddCommand(newExplainCmd()) cmd.AddCommand(newCleanCmd()) diff --git a/cmd/entire/cli/start.go b/cmd/entire/cli/start.go deleted file mode 100644 index 4c0fccd1bb..0000000000 --- a/cmd/entire/cli/start.go +++ /dev/null @@ -1,392 +0,0 @@ -package cli - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - - "entire.io/cli/cmd/entire/cli/strategy" - - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/spf13/cobra" -) - -// featureNameRegex matches valid feature names: alphanumeric, hyphens, underscores only -var featureNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) - -// ValidateFeatureName validates that the feature name contains only valid characters. -// Valid characters are: alphanumeric, hyphens, and underscores. -func ValidateFeatureName(name string) error { - if name == "" { - return errors.New("feature name is required") - } - - if !featureNameRegex.MatchString(name) { - return fmt.Errorf("invalid feature name '%s': use alphanumeric characters, hyphens, and underscores only", name) - } - - return nil -} - -// GetWorktreePath returns the path where the worktree will be created. -// Worktrees are always created in the .worktrees directory at the repo root. -func GetWorktreePath(name string) string { - return filepath.Join(".worktrees", name) -} - -// GetBranchName returns the full branch name with the configured prefix. -// If prefix is empty, defaults to "feature/". -func GetBranchName(name string, prefix string) string { - if prefix == "" { - prefix = "feature/" - } - return prefix + name -} - -// worktreesEntryRegex matches .worktrees entries in .gitignore -// Matches: .worktrees, .worktrees/, .worktrees/* -var worktreesEntryRegex = regexp.MustCompile(`(?m)^\.worktrees(?:/\*?)?$`) - -// hasWorktreesEntry checks if the .gitignore content contains a .worktrees entry. -func hasWorktreesEntry(content string) bool { - return worktreesEntryRegex.MatchString(content) -} - -// EnsureWorktreesInGitignore ensures that .worktrees is in the root .gitignore file. -// Creates the file if it doesn't exist, appends if .worktrees is not present. -func EnsureWorktreesInGitignore() error { - return ensureWorktreesInGitignoreAt(".gitignore") -} - -// ensureWorktreesInGitignoreAt ensures .worktrees is in the specified .gitignore file. -func ensureWorktreesInGitignoreAt(gitignorePath string) error { - content, err := os.ReadFile(gitignorePath) //nolint:gosec // path is controlled by caller - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to read .gitignore: %w", err) - } - - contentStr := string(content) - if hasWorktreesEntry(contentStr) { - return nil - } - - var newContent string - switch { - case len(content) == 0: - newContent = ".worktrees\n" - case strings.HasSuffix(contentStr, "\n"): - newContent = contentStr + ".worktrees\n" - default: - newContent = contentStr + "\n.worktrees\n" - } - - if err := os.WriteFile(gitignorePath, []byte(newContent), 0o600); err != nil { - return fmt.Errorf("failed to write .gitignore: %w", err) - } - - return nil -} - -// newStartCmd creates the start command -func newStartCmd() *cobra.Command { - var baseBranch string - var scaffold bool - var openEditor bool - var useWorktree bool - - cmd := &cobra.Command{ - Use: "start ", - Short: "Start a new feature branch", - Long: `Create a new feature branch and check it out. - -By default, creates a branch and checks it out in the current directory. -Use --worktree to create an isolated worktree for parallel feature development.`, - Example: ` # Basic usage - create and checkout feature branch - entire start feature-x - - # Branch from develop instead of main - entire start feature-x --base develop - - # Create in a worktree for parallel work - entire start feature-x --worktree - - # With requirements scaffolding (requires configured template) - entire start feature-x --scaffold - - # Open in editor after creation (with worktree) - entire start feature-x --worktree --open`, - Args: func(_ *cobra.Command, args []string) error { - if len(args) == 0 { - return errors.New("feature name required: entire start ") - } - if len(args) > 1 { - return fmt.Errorf("expected 1 feature name, got %d", len(args)) - } - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - name := args[0] - startSettings := GetStartSettings() - - // Resolve base branch at runtime if not explicitly provided - resolvedBaseBranch := baseBranch - if !cmd.Flags().Changed("base") { - resolvedBaseBranch = startSettings.BaseBranch - } - - // Resolve worktree mode: flag overrides setting - resolvedUseWorktree := useWorktree - if !cmd.Flags().Changed("worktree") { - resolvedUseWorktree = startSettings.UseWorktrees - } - - return runStart(name, resolvedBaseBranch, scaffold, openEditor, resolvedUseWorktree, cmd) - }, - } - - // Use empty default for --base flag; actual default is resolved at runtime - // from settings to ensure we read current settings, not settings at init time - cmd.Flags().StringVar(&baseBranch, "base", "", "Base branch to create feature from (default: from settings or 'main')") - cmd.Flags().BoolVar(&scaffold, "scaffold", false, "Create requirements doc from configured template") - cmd.Flags().BoolVar(&openEditor, "open", false, "Open in editor ($EDITOR) - only with --worktree") - cmd.Flags().BoolVar(&useWorktree, "worktree", false, "Create in a separate worktree for parallel work") - - return cmd -} - -// runStart executes the start command logic -func runStart(name, baseBranch string, scaffold, openEditor, useWorktree bool, cmd *cobra.Command) error { - // Validate feature name - if err := ValidateFeatureName(name); err != nil { - return err - } - - repo, err := strategy.OpenRepository() - if err != nil { - return fmt.Errorf("not in a git repository: %w", err) - } - - // Get settings - startSettings := GetStartSettings() - - // Generate branch name - branchName := GetBranchName(name, startSettings.BranchPrefix) - - // Validate branch doesn't already exist - if branchExists(repo, branchName) { - return fmt.Errorf("branch '%s' already exists", branchName) - } - - // Validate base branch exists and get its hash - baseHash, err := resolveBaseBranch(repo, baseBranch) - if err != nil { - return err - } - - // Handle scaffold flag validation - if scaffold { - if startSettings.RequirementsTemplate == "" { - return errors.New("no requirements template configured. Set 'start.requirementsTemplate' in .entire/settings.json") - } - if _, err := os.Stat(startSettings.RequirementsTemplate); os.IsNotExist(err) { - return fmt.Errorf("requirements template not found: %s", startSettings.RequirementsTemplate) - } - } - - ctx := cmd.Context() - if ctx == nil { - ctx = context.Background() - } - - if useWorktree { - return runStartWithWorktree(ctx, repo, name, branchName, baseBranch, baseHash, scaffold, openEditor, startSettings, cmd) - } - return runStartWithCheckout(repo, name, branchName, baseBranch, baseHash, scaffold, startSettings, cmd) -} - -// resolveBaseBranch resolves the base branch to a commit hash. -// Checks local branches first, then remote branches. -func resolveBaseBranch(repo *git.Repository, baseBranch string) (plumbing.Hash, error) { - // Try local branch first - localRef := plumbing.NewBranchReferenceName(baseBranch) - ref, err := repo.Reference(localRef, true) - if err == nil { - return ref.Hash(), nil - } - - // Try remote branch (origin) - remoteRef := plumbing.NewRemoteReferenceName("origin", baseBranch) - ref, err = repo.Reference(remoteRef, true) - if err == nil { - return ref.Hash(), nil - } - - return plumbing.ZeroHash, fmt.Errorf("base branch '%s' not found", baseBranch) -} - -// runStartWithCheckout creates a branch and checks it out in the current directory -func runStartWithCheckout(repo *git.Repository, name, branchName, baseBranch string, baseHash plumbing.Hash, scaffold bool, settings *StartSettings, cmd *cobra.Command) error { - worktree, err := repo.Worktree() - if err != nil { - return fmt.Errorf("failed to get worktree: %w", err) - } - - // Check for uncommitted changes before creating branch - // This prevents leaving the repo in a broken state if checkout fails - status, err := worktree.Status() - if err != nil { - return fmt.Errorf("failed to get worktree status: %w", err) - } - if !status.IsClean() { - return errors.New("working directory has uncommitted changes; commit or stash them first") - } - - // Create new branch reference pointing to base commit - branchRef := plumbing.NewBranchReferenceName(branchName) - ref := plumbing.NewHashReference(branchRef, baseHash) - if err := repo.Storer.SetReference(ref); err != nil { - return fmt.Errorf("failed to create branch: %w", err) - } - - // Checkout the new branch using git CLI instead of go-git to work around - // go-git v5 bug where Checkout deletes untracked files - // (see https://github.com/go-git/go-git/issues/970) - checkoutCmd := exec.CommandContext(context.Background(), "git", "checkout", branchName) - if output, err := checkoutCmd.CombinedOutput(); err != nil { - // Rollback: delete the branch we just created - _ = repo.Storer.RemoveReference(branchRef) //nolint:errcheck // best-effort rollback - return fmt.Errorf("failed to checkout branch: %s: %w", strings.TrimSpace(string(output)), err) - } - - // Handle scaffold if requested (in current directory) - if scaffold { - if err := scaffoldRequirements(".", name, settings.RequirementsTemplate); err != nil { - return fmt.Errorf("failed to scaffold requirements: %w", err) - } - } - - // Print success message - fmt.Fprintf(cmd.OutOrStdout(), "Created and checked out branch: %s (from %s)\n", branchName, baseBranch) - if scaffold { - fmt.Fprintf(cmd.OutOrStdout(), "Requirements: docs/requirements/%s/README.md\n", name) - } - fmt.Fprintln(cmd.OutOrStdout()) - fmt.Fprintln(cmd.OutOrStdout(), "Next steps:") - fmt.Fprintln(cmd.OutOrStdout(), " claude") - - return nil -} - -// runStartWithWorktree creates a branch in a separate worktree -func runStartWithWorktree(ctx context.Context, repo *git.Repository, name, branchName, baseBranch string, baseHash plumbing.Hash, scaffold, openEditor bool, settings *StartSettings, cmd *cobra.Command) error { - // Suppress unused parameter warning - baseHash reserved for future go-git worktree support - _ = repo - _ = baseHash - - // Get main repo root (allows creating sibling worktrees from inside a worktree) - mainRoot, err := strategy.GetMainRepoRoot() - if err != nil { - return fmt.Errorf("failed to find main repository: %w", err) - } - - // Worktree path is relative to main repo root - worktreePath := filepath.Join(mainRoot, GetWorktreePath(name)) - - // Validate worktree doesn't already exist - if _, err := os.Stat(worktreePath); err == nil { - return fmt.Errorf("worktree '%s' already exists at %s", name, worktreePath) - } - - // Ensure .worktrees is in .gitignore (in main repo) - if err := ensureWorktreesInGitignoreAt(filepath.Join(mainRoot, ".gitignore")); err != nil { - return fmt.Errorf("failed to update .gitignore: %w", err) - } - - // Create the worktree directory - if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil { //nolint:gosec // worktree needs standard dir permissions - return fmt.Errorf("failed to create .worktrees directory: %w", err) - } - - // Create worktree with new branch using git command - // Note: go-git doesn't support `git worktree add`, so we use native git - gitCmd := exec.CommandContext(ctx, "git", "worktree", "add", "-b", branchName, worktreePath, baseBranch) //nolint:gosec // args are validated - gitCmd.Dir = mainRoot - if output, err := gitCmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to create worktree: %s", strings.TrimSpace(string(output))) - } - - // Handle scaffold if requested (in worktree) - if scaffold { - if err := scaffoldRequirements(worktreePath, name, settings.RequirementsTemplate); err != nil { - return fmt.Errorf("failed to scaffold requirements: %w", err) - } - } - - // Print success message - fmt.Fprintf(cmd.OutOrStdout(), "Created worktree at %s\n", worktreePath) - fmt.Fprintf(cmd.OutOrStdout(), "Branch: %s (from %s)\n", branchName, baseBranch) - if scaffold { - fmt.Fprintf(cmd.OutOrStdout(), "Requirements: docs/requirements/%s/README.md\n", name) - } - fmt.Fprintln(cmd.OutOrStdout()) - fmt.Fprintln(cmd.OutOrStdout(), "Next steps:") - fmt.Fprintf(cmd.OutOrStdout(), " cd %s\n", worktreePath) - fmt.Fprintln(cmd.OutOrStdout(), " claude") - - // Open in editor if requested - if openEditor { - editor := os.Getenv("EDITOR") - if editor == "" { - fmt.Fprintln(cmd.OutOrStdout(), "\nNote: $EDITOR not set, skipping --open") - } else { - editorCmd := exec.CommandContext(ctx, editor, worktreePath) //nolint:gosec // EDITOR is user-configured - editorCmd.Stdout = os.Stdout - editorCmd.Stderr = os.Stderr - editorCmd.Stdin = os.Stdin - // Intentionally using Start() instead of Run() to allow the editor to run - // in parallel with the terminal. The process becomes orphaned when we exit, - // which is expected behavior for GUI editors (VS Code, Sublime, etc.). - if err := editorCmd.Start(); err != nil { - fmt.Fprintf(cmd.OutOrStdout(), "\nNote: failed to open editor: %v\n", err) - } - } - } - - return nil -} - -// branchExists checks if a local branch exists -func branchExists(repo *git.Repository, branchName string) bool { - refName := plumbing.NewBranchReferenceName(branchName) - _, err := repo.Reference(refName, true) - return err == nil -} - -// scaffoldRequirements creates the requirements documentation from template -func scaffoldRequirements(worktreePath, name, templatePath string) error { - // Read template (path is validated before calling this function) - templateContent, err := os.ReadFile(templatePath) //nolint:gosec // path validated in caller - if err != nil { - return fmt.Errorf("failed to read template: %w", err) - } - - // Create requirements directory in worktree - reqDir := filepath.Join(worktreePath, "docs", "requirements", name) - if err := os.MkdirAll(reqDir, 0o755); err != nil { //nolint:gosec // standard dir permissions - return fmt.Errorf("failed to create requirements directory: %w", err) - } - - // Write README.md - readmePath := filepath.Join(reqDir, "README.md") - if err := os.WriteFile(readmePath, templateContent, 0o644); err != nil { //nolint:gosec // standard file permissions - return fmt.Errorf("failed to write requirements file: %w", err) - } - - return nil -} diff --git a/cmd/entire/cli/start_test.go b/cmd/entire/cli/start_test.go deleted file mode 100644 index 44030a8977..0000000000 --- a/cmd/entire/cli/start_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestValidateFeatureName(t *testing.T) { - tests := []struct { - name string - input string - wantErr bool - errMsg string - }{ - // Valid names - {name: "simple lowercase", input: "feature", wantErr: false}, - {name: "with hyphen", input: "my-feature", wantErr: false}, - {name: "with underscore", input: "my_feature", wantErr: false}, - {name: "with numbers", input: "feature123", wantErr: false}, - {name: "uppercase", input: "Feature", wantErr: false}, - {name: "mixed case with hyphens", input: "My-Feature-2", wantErr: false}, - {name: "all valid chars", input: "abc_123-XYZ", wantErr: false}, - - // Invalid names - {name: "empty", input: "", wantErr: true, errMsg: "feature name is required"}, - {name: "with space", input: "my feature", wantErr: true, errMsg: "invalid feature name 'my feature': use alphanumeric characters, hyphens, and underscores only"}, - {name: "with dot", input: "my.feature", wantErr: true, errMsg: "invalid feature name 'my.feature': use alphanumeric characters, hyphens, and underscores only"}, - {name: "with slash", input: "my/feature", wantErr: true, errMsg: "invalid feature name 'my/feature': use alphanumeric characters, hyphens, and underscores only"}, - {name: "with special char", input: "my@feature", wantErr: true, errMsg: "invalid feature name 'my@feature': use alphanumeric characters, hyphens, and underscores only"}, - {name: "with exclamation", input: "feature!", wantErr: true, errMsg: "invalid feature name 'feature!': use alphanumeric characters, hyphens, and underscores only"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := ValidateFeatureName(tt.input) - if tt.wantErr { - if err == nil { - t.Errorf("ValidateFeatureName(%q) expected error, got nil", tt.input) - return - } - if tt.errMsg != "" && err.Error() != tt.errMsg { - t.Errorf("ValidateFeatureName(%q) error = %q, want %q", tt.input, err.Error(), tt.errMsg) - } - } else if err != nil { - t.Errorf("ValidateFeatureName(%q) unexpected error: %v", tt.input, err) - } - }) - } -} - -func TestGetWorktreePath(t *testing.T) { - tests := []struct { - name string - want string - }{ - {name: "simple", want: ".worktrees/simple"}, - {name: "my-feature", want: ".worktrees/my-feature"}, - {name: "feature_123", want: ".worktrees/feature_123"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := GetWorktreePath(tt.name) - if got != tt.want { - t.Errorf("GetWorktreePath(%q) = %q, want %q", tt.name, got, tt.want) - } - }) - } -} - -func TestGetBranchName(t *testing.T) { - tests := []struct { - name string - prefix string - want string - }{ - {name: "simple", prefix: "feature/", want: "feature/simple"}, - {name: "my-feature", prefix: "feature/", want: "feature/my-feature"}, - {name: "bugfix", prefix: "fix/", want: "fix/bugfix"}, - {name: "test", prefix: "", want: "feature/test"}, // empty prefix uses default - } - - for _, tt := range tests { - t.Run(tt.name+"_"+tt.prefix, func(t *testing.T) { - got := GetBranchName(tt.name, tt.prefix) - if got != tt.want { - t.Errorf("GetBranchName(%q, %q) = %q, want %q", tt.name, tt.prefix, got, tt.want) - } - }) - } -} - -func TestEnsureWorktreesInGitignore(t *testing.T) { - tests := []struct { - name string - createFile bool // whether to create the gitignore file before the test - existingContent string // content to write if createFile is true - wantContains string - shouldModify bool - }{ - { - name: "no gitignore file", - createFile: false, - existingContent: "", - wantContains: ".worktrees", - shouldModify: true, - }, - { - name: "empty gitignore", - createFile: true, - existingContent: "", - wantContains: ".worktrees", - shouldModify: true, - }, - { - name: "gitignore without worktrees", - createFile: true, - existingContent: "node_modules/\n.env\n", - wantContains: ".worktrees", - shouldModify: true, - }, - { - name: "gitignore with worktrees already", - createFile: true, - existingContent: "node_modules/\n.worktrees\n.env\n", - wantContains: ".worktrees", - shouldModify: false, - }, - { - name: "gitignore with worktrees/ variant", - createFile: true, - existingContent: "node_modules/\n.worktrees/\n", - wantContains: ".worktrees/", - shouldModify: false, - }, - { - name: "gitignore without trailing newline", - createFile: true, - existingContent: "node_modules/", - wantContains: ".worktrees", - shouldModify: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tmpDir := t.TempDir() - t.Chdir(tmpDir) - - gitignorePath := filepath.Join(tmpDir, ".gitignore") - - // Create existing gitignore if specified - if tt.createFile { - if err := os.WriteFile(gitignorePath, []byte(tt.existingContent), 0o644); err != nil { - t.Fatalf("Failed to write initial .gitignore: %v", err) - } - } - - // Run the function - err := EnsureWorktreesInGitignore() - if err != nil { - t.Fatalf("EnsureWorktreesInGitignore() error = %v", err) - } - - // Read the result - content, err := os.ReadFile(gitignorePath) - if err != nil { - t.Fatalf("Failed to read .gitignore: %v", err) - } - - contentStr := string(content) - if !strings.Contains(contentStr, tt.wantContains) { - t.Errorf("gitignore content = %q, want to contain %q", contentStr, tt.wantContains) - } - - // Check that worktrees appears only once (not duplicated) - count := strings.Count(contentStr, ".worktrees") - if count > 1 { - t.Errorf("gitignore has %d occurrences of .worktrees, want 1", count) - } - }) - } -} - -func TestHasWorktreesEntry(t *testing.T) { - tests := []struct { - name string - content string - want bool - }{ - {name: "empty", content: "", want: false}, - {name: "no worktrees", content: "node_modules/\n.env\n", want: false}, - {name: "has .worktrees", content: ".worktrees\n", want: true}, - {name: "has .worktrees/", content: ".worktrees/\n", want: true}, - {name: "has .worktrees/*", content: ".worktrees/*\n", want: true}, - {name: "worktrees in middle", content: "a\n.worktrees\nb\n", want: true}, - {name: "worktrees with comment", content: "# worktrees\n.worktrees\n", want: true}, - {name: "partial match - not worktrees", content: "worktrees\n", want: false}, - {name: "partial match - my.worktrees", content: "my.worktrees\n", want: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := hasWorktreesEntry(tt.content) - if got != tt.want { - t.Errorf("hasWorktreesEntry(%q) = %v, want %v", tt.content, got, tt.want) - } - }) - } -}