diff --git a/cmd/wtp/app.go b/cmd/wtp/app.go index 63c78b1..6269cde 100644 --- a/cmd/wtp/app.go +++ b/cmd/wtp/app.go @@ -21,6 +21,7 @@ func newApp() *cli.Command { NewAddCommand(), NewListCommand(), NewRemoveCommand(), + NewCleanCommand(), NewInitCommand(), NewCdCommand(), NewExecCommand(), diff --git a/cmd/wtp/clean.go b/cmd/wtp/clean.go new file mode 100644 index 0000000..a1bcd3d --- /dev/null +++ b/cmd/wtp/clean.go @@ -0,0 +1,428 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "sync" + + "github.com/charmbracelet/huh" + "github.com/urfave/cli/v3" + + "github.com/satococoa/wtp/v2/internal/command" + "github.com/satococoa/wtp/v2/internal/config" + "github.com/satococoa/wtp/v2/internal/errors" + "github.com/satococoa/wtp/v2/internal/git" +) + +// worktreeCleanStatus holds the validation results for a worktree +type worktreeCleanStatus struct { + worktree git.Worktree + isMerged bool + isClean bool + isPushed bool + isSafe bool + reason string + reasons []string +} + +// Variable to allow mocking in tests +var cleanGetwd = os.Getwd + +// NewCleanCommand creates the clean command definition +func NewCleanCommand() *cli.Command { + return &cli.Command{ + Name: "clean", + Usage: "Interactively clean up worktrees", + Description: "Shows an interactive checklist of worktrees that can be safely removed.\n\n" + + "Worktrees are pre-selected if they are:\n" + + " • Fully merged into the main branch\n" + + " • Have no uncommitted changes\n" + + " • Have no unpushed commits\n\n" + + "Examples:\n" + + " wtp clean # Show interactive clean UI\n" + + " wtp clean --force # Force remove even with uncommitted changes", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "force", + Usage: "Force removal even if worktree is dirty", + Aliases: []string{"f"}, + }, + }, + Action: cleanCommand, + } +} + +func cleanCommand(_ context.Context, cmd *cli.Command) error { + w := cmd.Root().Writer + if w == nil { + w = os.Stdout + } + + force := cmd.Bool("force") + + cwd, err := cleanGetwd() + if err != nil { + return errors.DirectoryAccessFailed("access current", ".", err) + } + + _, err = git.NewRepository(cwd) + if err != nil { + return errors.NotInGitRepository() + } + + executor := command.NewRealExecutor() + return cleanCommandWithExecutor(w, executor, force) +} + +func cleanCommandWithExecutor( + w io.Writer, + executor command.Executor, + force bool, +) error { + worktrees, mainWorktreePath, err := getWorktreesForClean(executor) + if err != nil { + return err + } + + cfg := loadCleanConfig(mainWorktreePath) + managedWorktrees := filterManagedWorktrees(worktrees, cfg, mainWorktreePath) + + if len(managedWorktrees) == 0 { + _, ferr := fmt.Fprintln(w, "No managed worktrees found") + return ferr + } + + statuses := validateWorktrees(managedWorktrees, executor) + opts := buildCleanOptions(statuses, cfg, mainWorktreePath) + + selected, err := runCleanForm(opts) + if err != nil { + return err + } + + if len(selected) == 0 { + _, ferr := fmt.Fprintln(w, "No worktrees selected for removal") + return ferr + } + + _, err = fmt.Fprintf(w, "\nRemoving %d worktree(s)...\n", len(selected)) + if err != nil { + return err + } + + removeSelectedWorktrees(w, executor, managedWorktrees, selected, cfg, mainWorktreePath, force) + return nil +} + +func getWorktreesForClean(executor command.Executor) ([]git.Worktree, string, error) { + listCmd := command.GitWorktreeList() + result, err := executor.Execute([]command.Command{listCmd}) + if err != nil { + return nil, "", errors.GitCommandFailed("git worktree list", err.Error()) + } + if result == nil || len(result.Results) == 0 { + return nil, "", errors.GitCommandFailed("git worktree list", "no output") + } + + worktrees := parseWorktreesFromOutput(result.Results[0].Output) + + mainWorktreePath := "" + for _, wt := range worktrees { + if wt.IsMain { + mainWorktreePath = wt.Path + break + } + } + + return worktrees, mainWorktreePath, nil +} + +func loadCleanConfig(mainWorktreePath string) *config.Config { + cfg, err := config.LoadConfig(mainWorktreePath) + if err != nil { + cfg = &config.Config{ + Defaults: config.Defaults{ + BaseDir: config.DefaultBaseDir, + }, + } + } + return cfg +} + +func filterManagedWorktrees( + worktrees []git.Worktree, + cfg *config.Config, + mainWorktreePath string, +) []git.Worktree { + var managed []git.Worktree + for _, wt := range worktrees { + if wt.IsMain { + continue + } + if !isWorktreeManagedCommon(wt.Path, cfg, mainWorktreePath, wt.IsMain) { + continue + } + managed = append(managed, wt) + } + return managed +} + +func validateWorktrees( + worktrees []git.Worktree, + executor command.Executor, +) []worktreeCleanStatus { + mainBranch := detectMainBranch(executor) + + statuses := make([]worktreeCleanStatus, len(worktrees)) + var wg sync.WaitGroup + for i, wt := range worktrees { + wg.Add(1) + go func(idx int, w git.Worktree) { + defer wg.Done() + statuses[idx] = validateWorktree(w, executor, mainBranch) + }(i, wt) + } + wg.Wait() + return statuses +} + +func runCleanForm(opts cleanOptions) ([]string, error) { + var selected []string + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Select worktrees to remove:"). + Description(opts.columnHeader). + Options(opts.options...). + Value(&selected), + ), + ) + + if err := form.Run(); err != nil { + return nil, err + } + + return selected, nil +} + +func removeSelectedWorktrees( + w io.Writer, + executor command.Executor, + worktrees []git.Worktree, + selected []string, + cfg *config.Config, + mainWorktreePath string, + force bool, +) { + for _, name := range selected { + wt, findErr := findWorktreeByName(worktrees, name, cfg, mainWorktreePath) + if findErr != nil { + _, _ = fmt.Fprintf(w, "Failed to find worktree '%s': %v\n", name, findErr) + continue + } + + removeCmd := command.GitWorktreeRemove(wt.Path, force) + result, execErr := executor.Execute([]command.Command{removeCmd}) + if execErr != nil { + _, _ = fmt.Fprintf(w, "Failed to remove '%s': %v\n", name, execErr) + continue + } + if len(result.Results) > 0 && result.Results[0].Error != nil { + _, _ = fmt.Fprintf(w, "Failed to remove '%s': %v\n", name, result.Results[0].Error) + continue + } + + _, _ = fmt.Fprintf(w, "✓ Removed '%s'\n", name) + } +} + +func validateWorktree(wt git.Worktree, executor command.Executor, mainBranch string) worktreeCleanStatus { + status := worktreeCleanStatus{ + worktree: wt, + isMerged: true, + isClean: true, + isPushed: true, + isSafe: true, + } + + status.checkMergeStatus(wt, mainBranch, executor) + status.checkCleanStatus(wt, executor) + status.checkPushStatus(wt, mainBranch, executor) + status.buildReason() + + return status +} + +func detectMainBranch(executor command.Executor) string { + mainCmd := command.Command{ + Name: "git", + Args: []string{"rev-parse", "--verify", "--quiet", "main"}, + } + result, err := executor.Execute([]command.Command{mainCmd}) + if err == nil && result != nil && len(result.Results) > 0 && result.Results[0].Error == nil { + return "main" + } + + masterCmd := command.Command{ + Name: "git", + Args: []string{"rev-parse", "--verify", "--quiet", "master"}, + } + result, err = executor.Execute([]command.Command{masterCmd}) + if err == nil && result != nil && len(result.Results) > 0 && result.Results[0].Error == nil { + return "master" + } + + return "main" +} + +func (s *worktreeCleanStatus) checkMergeStatus( + wt git.Worktree, + mainBranch string, + executor command.Executor, +) { + if wt.Branch == "" || wt.Branch == "detached" { + s.isMerged = false + s.isSafe = false + s.reasons = append(s.reasons, "detached HEAD") + return + } + + mergeBaseCmd := command.Command{ + Name: "git", + Args: []string{"merge-base", "--is-ancestor", wt.Branch, mainBranch}, + } + result, err := executor.Execute([]command.Command{mergeBaseCmd}) + if err != nil || result == nil || len(result.Results) == 0 || result.Results[0].Error != nil { + s.isMerged = false + s.isSafe = false + s.reasons = append(s.reasons, "unmerged") + } +} + +func (s *worktreeCleanStatus) checkCleanStatus(wt git.Worktree, executor command.Executor) { + statusCmd := command.Command{ + Name: "git", + Args: []string{"status", "--porcelain"}, + WorkDir: wt.Path, + } + result, err := executor.Execute([]command.Command{statusCmd}) + if err != nil || result == nil || len(result.Results) == 0 { + return + } + + output := strings.TrimSpace(result.Results[0].Output) + if output != "" { + s.isClean = false + s.isSafe = false + s.reasons = append(s.reasons, "uncommitted changes") + } +} + +func (s *worktreeCleanStatus) checkPushStatus( + wt git.Worktree, + mainBranch string, + executor command.Executor, +) { + if wt.Branch == "" || wt.Branch == "detached" { + return + } + + pushCheckCmd := command.Command{ + Name: "git", + Args: []string{"rev-list", "--count", fmt.Sprintf("origin/%s..%s", wt.Branch, wt.Branch)}, + } + result, err := executor.Execute([]command.Command{pushCheckCmd}) + if err != nil || result == nil || len(result.Results) == 0 { + return + } + + count := strings.TrimSpace(result.Results[0].Output) + if count == "0" || count == "" { + return + } + + s.isPushed = false + s.isSafe = false + + aheadCmd := command.Command{ + Name: "git", + Args: []string{"rev-list", "--count", fmt.Sprintf("%s..%s", mainBranch, wt.Branch)}, + } + result, err = executor.Execute([]command.Command{aheadCmd}) + + aheadCount := "" + if err == nil && result != nil && len(result.Results) > 0 { + aheadCount = strings.TrimSpace(result.Results[0].Output) + } + + if aheadCount != "" && aheadCount != "0" { + s.reasons = append(s.reasons, fmt.Sprintf("unpushed commits (%s ahead)", aheadCount)) + } else { + s.reasons = append(s.reasons, "unpushed commits") + } +} + +func (s *worktreeCleanStatus) buildReason() { + if s.isSafe { + s.reason = "safe: merged, clean, pushed" + } else { + s.reason = fmt.Sprintf("unsafe: %s", strings.Join(s.reasons, ", ")) + } +} + +type cleanOptions struct { + options []huh.Option[string] + columnHeader string +} + +func buildCleanOptions(statuses []worktreeCleanStatus, cfg *config.Config, mainRepoPath string) cleanOptions { + maxNameLen := len("WORKTREE") + names := make([]string, len(statuses)) + for i, status := range statuses { + names[i] = getWorktreeNameFromPath(status.worktree.Path, cfg, mainRepoPath, status.worktree.IsMain) + if len(names[i]) > maxNameLen { + maxNameLen = len(names[i]) + } + } + + options := make([]huh.Option[string], 0, len(statuses)) + for i, status := range statuses { + statusText := "safe" + note := "merged, clean, pushed" + if !status.isSafe { + statusText = "unsafe" + note = strings.Join(status.reasons, ", ") + } + + label := fmt.Sprintf("%-*s %-6s %s", maxNameLen, names[i], statusText, note) + option := huh.NewOption(label, names[i]) + if status.isSafe { + option = option.Selected(true) + } + options = append(options, option) + } + + columnHeader := fmt.Sprintf(" %-*s %-6s %s", maxNameLen, "WORKTREE", "STATUS", "NOTE") + return cleanOptions{ + options: options, + columnHeader: columnHeader, + } +} + +func findWorktreeByName( + worktrees []git.Worktree, + name string, + cfg *config.Config, + mainRepoPath string, +) (*git.Worktree, error) { + for _, wt := range worktrees { + displayName := getWorktreeNameFromPath(wt.Path, cfg, mainRepoPath, wt.IsMain) + if displayName == name { + return &wt, nil + } + } + return nil, fmt.Errorf("worktree not found: %s", name) +} diff --git a/cmd/wtp/clean_test.go b/cmd/wtp/clean_test.go new file mode 100644 index 0000000..9a2c15d --- /dev/null +++ b/cmd/wtp/clean_test.go @@ -0,0 +1,939 @@ +package main + +import ( + "bytes" + "context" + "os" + "strings" + "sync" + "testing" + + "github.com/charmbracelet/huh" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" + + "github.com/satococoa/wtp/v2/internal/command" + "github.com/satococoa/wtp/v2/internal/config" + "github.com/satococoa/wtp/v2/internal/git" +) + +// ===== Command Structure Tests ===== + +func TestNewCleanCommand(t *testing.T) { + cmd := NewCleanCommand() + + assert.NotNil(t, cmd) + assert.Equal(t, "clean", cmd.Name) + assert.Equal(t, "Interactively clean up worktrees", cmd.Usage) + assert.NotEmpty(t, cmd.Description) + assert.NotNil(t, cmd.Action) + + // Check flags exist + flagNames := []string{"force"} + for _, name := range flagNames { + found := false + for _, flag := range cmd.Flags { + if flag.Names()[0] == name { + found = true + break + } + } + assert.True(t, found, "Flag %s should exist", name) + } +} + +// ===== Pure Business Logic Tests ===== + +//nolint:dupl +func TestValidateWorktree(t *testing.T) { + tests := []struct { + name string + worktree git.Worktree + mergeBaseOutput command.ExecutionResult + statusOutput command.ExecutionResult + revListOutput command.ExecutionResult + aheadCmdOutput command.ExecutionResult + expectedIsSafe bool + expectedIsMerged bool + expectedIsClean bool + expectedIsPushed bool + expectedReasonSub string + }{ + { + name: "safe worktree - merged, clean, pushed", + worktree: git.Worktree{Path: "/test/repo/.worktrees/feature", Branch: "feature"}, + mergeBaseOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: nil}}, + }, + statusOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: nil}}, + }, + revListOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "0", Error: nil}}, + }, + aheadCmdOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "0", Error: nil}}, + }, + expectedIsSafe: true, + expectedIsMerged: true, + expectedIsClean: true, + expectedIsPushed: true, + }, + { + name: "unmerged worktree", + worktree: git.Worktree{Path: "/test/repo/.worktrees/feature", Branch: "feature"}, + mergeBaseOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: &mockCleanError{message: "not ancestor"}}}, + }, + statusOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: nil}}, + }, + revListOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "0", Error: nil}}, + }, + aheadCmdOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "0", Error: nil}}, + }, + expectedIsSafe: false, + expectedIsMerged: false, + expectedIsClean: true, + expectedIsPushed: true, + expectedReasonSub: "unmerged", + }, + { + name: "dirty worktree", + worktree: git.Worktree{Path: "/test/repo/.worktrees/feature", Branch: "feature"}, + mergeBaseOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: nil}}, + }, + statusOutput: command.ExecutionResult{ + Results: []command.Result{{Output: " M file.txt", Error: nil}}, + }, + revListOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "0", Error: nil}}, + }, + aheadCmdOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "0", Error: nil}}, + }, + expectedIsSafe: false, + expectedIsMerged: true, + expectedIsClean: false, + expectedIsPushed: true, + expectedReasonSub: "uncommitted changes", + }, + { + name: "unpushed commits worktree", + worktree: git.Worktree{Path: "/test/repo/.worktrees/feature", Branch: "feature"}, + mergeBaseOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: nil}}, + }, + statusOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: nil}}, + }, + revListOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "3", Error: nil}}, + }, + aheadCmdOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "3", Error: nil}}, + }, + expectedIsSafe: false, + expectedIsMerged: true, + expectedIsClean: true, + expectedIsPushed: false, + expectedReasonSub: "unpushed", + }, + { + name: "detached HEAD worktree", + worktree: git.Worktree{Path: "/test/repo/.worktrees/feature", Branch: "detached"}, + mergeBaseOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: nil}}, + }, + statusOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: nil}}, + }, + revListOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "0", Error: nil}}, + }, + aheadCmdOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "0", Error: nil}}, + }, + expectedIsSafe: false, + expectedIsMerged: false, + expectedIsClean: true, + expectedIsPushed: true, + expectedReasonSub: "detached", + }, + { + name: "empty branch worktree", + worktree: git.Worktree{Path: "/test/repo/.worktrees/feature", Branch: ""}, + mergeBaseOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: nil}}, + }, + statusOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: nil}}, + }, + revListOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "0", Error: nil}}, + }, + aheadCmdOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "0", Error: nil}}, + }, + expectedIsSafe: false, + expectedIsMerged: false, + expectedIsClean: true, + expectedIsPushed: true, + expectedReasonSub: "detached", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &mockCleanCommandExecutor{ + results: map[string]command.ExecutionResult{ + "merge-base": tt.mergeBaseOutput, + "status": tt.statusOutput, + "rev-list": tt.revListOutput, + "ahead-cmd": tt.aheadCmdOutput, + }, + } + + status := validateWorktree(tt.worktree, mockExec, "main") + + assert.Equal(t, tt.expectedIsSafe, status.isSafe, "isSafe mismatch") + assert.Equal(t, tt.expectedIsMerged, status.isMerged, "isMerged mismatch") + assert.Equal(t, tt.expectedIsClean, status.isClean, "isClean mismatch") + assert.Equal(t, tt.expectedIsPushed, status.isPushed, "isPushed mismatch") + + if !tt.expectedIsSafe && tt.expectedReasonSub != "" { + assert.Contains(t, status.reason, tt.expectedReasonSub) + } + }) + } +} + +func TestValidateWorktrees(t *testing.T) { + tests := []struct { + name string + worktrees []git.Worktree + expectedStatuses int + expectedAllSafe bool + expectedAllUnsafe bool + }{ + { + name: "all safe worktrees", + worktrees: []git.Worktree{ + {Path: "/test/repo/.worktrees/feature1", Branch: "feature1"}, + {Path: "/test/repo/.worktrees/feature2", Branch: "feature2"}, + }, + expectedStatuses: 2, + expectedAllSafe: true, + }, + { + name: "empty worktrees", + worktrees: []git.Worktree{}, + expectedStatuses: 0, + expectedAllSafe: false, + expectedAllUnsafe: false, + }, + { + name: "single worktree", + worktrees: []git.Worktree{ + {Path: "/test/repo/.worktrees/feature", Branch: "feature"}, + }, + expectedStatuses: 1, + expectedAllSafe: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &mockCleanCommandExecutor{ + results: map[string]command.ExecutionResult{ + "merge-base": { + Results: []command.Result{{Output: "", Error: nil}}, + }, + "status": { + Results: []command.Result{{Output: "", Error: nil}}, + }, + "rev-list": { + Results: []command.Result{{Output: "0", Error: nil}}, + }, + "ahead-cmd": { + Results: []command.Result{{Output: "0", Error: nil}}, + }, + "main": { + Results: []command.Result{{Output: "main", Error: nil}}, + }, + "master": { + Results: []command.Result{{Output: "", Error: nil}}, + }, + }, + } + + statuses := validateWorktrees(tt.worktrees, mockExec) + + assert.Equal(t, tt.expectedStatuses, len(statuses)) + }) + } +} + +func TestBuildCleanOptions(t *testing.T) { + tests := []struct { + name string + statuses []worktreeCleanStatus + expectedOptions int + expectedSelected int + expectedUnselected int + }{ + { + name: "all safe worktrees - all pre-selected", + statuses: []worktreeCleanStatus{ + {worktree: git.Worktree{Path: "/test/repo/.worktrees/feature1"}, isSafe: true, reason: "safe"}, + {worktree: git.Worktree{Path: "/test/repo/.worktrees/feature2"}, isSafe: true, reason: "safe"}, + }, + expectedOptions: 2, + expectedSelected: 2, + expectedUnselected: 0, + }, + { + name: "mix of safe and unsafe - only safe pre-selected", + statuses: []worktreeCleanStatus{ + {worktree: git.Worktree{Path: "/test/repo/.worktrees/safe"}, isSafe: true, reason: "safe"}, + {worktree: git.Worktree{Path: "/test/repo/.worktrees/unsafe"}, isSafe: false, reason: "unsafe"}, + }, + expectedOptions: 2, + expectedSelected: 1, + expectedUnselected: 1, + }, + { + name: "all unsafe worktrees - none pre-selected", + statuses: []worktreeCleanStatus{ + {worktree: git.Worktree{Path: "/test/repo/.worktrees/unsafe1"}, isSafe: false, reason: "unsafe"}, + {worktree: git.Worktree{Path: "/test/repo/.worktrees/unsafe2"}, isSafe: false, reason: "unsafe"}, + }, + expectedOptions: 2, + expectedSelected: 0, + expectedUnselected: 2, + }, + { + name: "empty statuses", + statuses: []worktreeCleanStatus{}, + expectedOptions: 0, + expectedSelected: 0, + expectedUnselected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{ + Defaults: config.Defaults{BaseDir: ".worktrees"}, + } + + opts := buildCleanOptions(tt.statuses, cfg, "/test/repo") + + assert.Equal(t, tt.expectedOptions, len(opts.options)) + + // Verify options were created + for _, opt := range opts.options { + assert.NotEmpty(t, opt.Key) + } + }) + } +} + +func TestDetectMainBranch(t *testing.T) { + tests := []struct { + name string + mainCmdOutput command.ExecutionResult + masterCmdOutput command.ExecutionResult + expectedBranch string + }{ + { + name: "main branch exists", + mainCmdOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: nil}}, + }, + masterCmdOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: nil}}, + }, + expectedBranch: "main", + }, + { + name: "only master exists", + mainCmdOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: &mockCleanError{message: "not found"}}}, + }, + masterCmdOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: nil}}, + }, + expectedBranch: "master", + }, + { + name: "no main or master - defaults to main", + mainCmdOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: &mockCleanError{message: "not found"}}}, + }, + masterCmdOutput: command.ExecutionResult{ + Results: []command.Result{{Output: "", Error: &mockCleanError{message: "not found"}}}, + }, + expectedBranch: "main", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &mockCleanCommandExecutor{ + results: map[string]command.ExecutionResult{ + "main": tt.mainCmdOutput, + "master": tt.masterCmdOutput, + }, + } + + branch := detectMainBranch(mockExec) + + assert.Equal(t, tt.expectedBranch, branch) + }) + } +} + +func TestFindWorktreeByName(t *testing.T) { + tests := []struct { + name string + worktrees []git.Worktree + searchName string + shouldFind bool + expectedPath string + }{ + { + name: "find existing worktree", + worktrees: []git.Worktree{ + {Path: "/test/repo/.worktrees/feature"}, + }, + searchName: "feature", + shouldFind: true, + expectedPath: "/test/repo/.worktrees/feature", + }, + { + name: "worktree not found", + worktrees: []git.Worktree{ + {Path: "/test/repo/.worktrees/feature"}, + }, + searchName: "nonexistent", + shouldFind: false, + }, + { + name: "empty worktrees", + worktrees: []git.Worktree{}, + searchName: "feature", + shouldFind: false, + expectedPath: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{ + Defaults: config.Defaults{BaseDir: ".worktrees"}, + } + + wt, err := findWorktreeByName(tt.worktrees, tt.searchName, cfg, "/test/repo") + + if tt.shouldFind { + assert.NoError(t, err) + assert.Equal(t, tt.expectedPath, wt.Path) + } else { + assert.Error(t, err) + assert.Nil(t, wt) + } + }) + } +} + +// ===== Command Execution Tests ===== + +func TestCleanCommand_CommandConstruction(t *testing.T) { + tests := []struct { + name string + mockWorktreeList string + expectedCommands []command.Command + }{ + { + name: "empty worktree list", + mockWorktreeList: "", + expectedCommands: []command.Command{ + { + Name: "git", + Args: []string{"worktree", "list", "--porcelain"}, + }, + }, + }, + { + name: "single worktree", + mockWorktreeList: "worktree /test/repo\nHEAD abc123\nbranch refs/heads/main\n\n" + + "worktree /test/repo/.worktrees/feature\nHEAD def456\nbranch refs/heads/feature\n\n", + expectedCommands: []command.Command{ + { + Name: "git", + Args: []string{"worktree", "list", "--porcelain"}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &mockCleanCommandExecutor{ + results: map[string]command.ExecutionResult{ + "worktree list": { + Results: []command.Result{{Output: tt.mockWorktreeList, Error: nil}}, + }, + "merge-base": { + Results: []command.Result{{Output: "", Error: nil}}, + }, + "status": { + Results: []command.Result{{Output: "", Error: nil}}, + }, + "rev-list": { + Results: []command.Result{{Output: "0", Error: nil}}, + }, + "ahead-cmd": { + Results: []command.Result{{Output: "0", Error: nil}}, + }, + "main": { + Results: []command.Result{{Output: "main", Error: nil}}, + }, + "master": { + Results: []command.Result{{Output: "", Error: nil}}, + }, + }, + } + + // We can't easily test the full flow without mocking the form + // So we'll test the command construction at least + worktrees, mainPath, err := getWorktreesForClean(mockExec) + + if tt.mockWorktreeList == "" { + assert.NoError(t, err) + assert.Equal(t, 0, len(worktrees)) + } else { + assert.NoError(t, err) + assert.Equal(t, "/test/repo", mainPath) + } + }) + } +} + +func TestGetWorktreesForClean(t *testing.T) { + tests := []struct { + name string + mockOutput string + expectedCount int + expectedMainPath string + shouldError bool + }{ + { + name: "worktrees with main", + mockOutput: "worktree /test/repo\nHEAD abc123\nbranch refs/heads/main\n\n" + + "worktree /test/repo/.worktrees/feature\nHEAD def456\nbranch refs/heads/feature\n\n", + expectedCount: 2, + expectedMainPath: "/test/repo", + shouldError: false, + }, + { + name: "empty output", + mockOutput: "", + expectedCount: 0, + expectedMainPath: "", + shouldError: false, + }, + { + name: "only main worktree", + mockOutput: "worktree /test/repo\nHEAD abc123\nbranch refs/heads/main\n\n", + expectedCount: 1, + expectedMainPath: "/test/repo", + shouldError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockExec := &mockCleanCommandExecutor{ + results: map[string]command.ExecutionResult{ + "worktree list": { + Results: []command.Result{{Output: tt.mockOutput, Error: nil}}, + }, + }, + } + + worktrees, mainPath, err := getWorktreesForClean(mockExec) + + if tt.shouldError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedCount, len(worktrees)) + assert.Equal(t, tt.expectedMainPath, mainPath) + } + }) + } +} + +func TestFilterManagedWorktrees(t *testing.T) { + tests := []struct { + name string + worktrees []git.Worktree + cfg *config.Config + mainRepoPath string + expectedManaged int + }{ + { + name: "all managed worktrees", + worktrees: []git.Worktree{ + {Path: "/test/repo", IsMain: true}, + {Path: "/test/repo/.worktrees/feature", IsMain: false}, + {Path: "/test/repo/.worktrees/bugfix", IsMain: false}, + }, + cfg: &config.Config{ + Defaults: config.Defaults{BaseDir: ".worktrees"}, + }, + mainRepoPath: "/test/repo", + expectedManaged: 2, + }, + { + name: "mix of managed and unmanaged", + worktrees: []git.Worktree{ + {Path: "/test/repo", IsMain: true}, + {Path: "/test/repo/.worktrees/feature", IsMain: false}, + {Path: "/other/path/worktree", IsMain: false}, + }, + cfg: &config.Config{ + Defaults: config.Defaults{BaseDir: ".worktrees"}, + }, + mainRepoPath: "/test/repo", + expectedManaged: 1, + }, + { + name: "empty worktrees", + worktrees: []git.Worktree{ + {Path: "/test/repo", IsMain: true}, + }, + cfg: &config.Config{ + Defaults: config.Defaults{BaseDir: ".worktrees"}, + }, + mainRepoPath: "/test/repo", + expectedManaged: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + managed := filterManagedWorktrees(tt.worktrees, tt.cfg, tt.mainRepoPath) + + assert.Equal(t, tt.expectedManaged, len(managed)) + }) + } +} + +func TestLoadCleanConfig(t *testing.T) { + tests := []struct { + name string + mainRepoPath string + shouldReturn bool + }{ + { + name: "valid repo path", + mainRepoPath: "/test/repo", + shouldReturn: true, + }, + { + name: "non-existent path returns default config", + mainRepoPath: "/non/existent/path", + shouldReturn: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := loadCleanConfig(tt.mainRepoPath) + + assert.NotNil(t, cfg) + // Config should always be returned, even for non-existent paths + assert.Equal(t, config.DefaultBaseDir, cfg.Defaults.BaseDir) + }) + } +} + +// ===== Error Handling Tests ===== + +func TestCleanCommand_NotInGitRepo(t *testing.T) { + // Create a temporary directory that is not a git repo + tempDir := t.TempDir() + oldDir, _ := os.Getwd() + defer func() { _ = os.Chdir(oldDir) }() + err := os.Chdir(tempDir) + assert.NoError(t, err) + + app := &cli.Command{ + Commands: []*cli.Command{ + NewCleanCommand(), + }, + } + + ctx := context.Background() + err = app.Run(ctx, []string{"wtp", "clean"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not in a git repository") +} + +func TestCleanCommand_ExecutionError(t *testing.T) { + mockExec := &mockCleanCommandExecutor{ + shouldFail: true, + errorMsg: "git command failed", + } + + var buf bytes.Buffer + + err := cleanCommandWithExecutor(&buf, mockExec, false) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "git command failed") +} + +func TestCleanCommand_NoManagedWorktrees(t *testing.T) { + mockExec := &mockCleanCommandExecutor{ + results: map[string]command.ExecutionResult{ + "worktree list": { + Results: []command.Result{{ + Output: "worktree /test/repo\nHEAD abc123\nbranch refs/heads/main\n\n", + Error: nil, + }}, + }, + }, + } + + var buf bytes.Buffer + + err := cleanCommandWithExecutor(&buf, mockExec, false) + + assert.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "No managed worktrees found") +} + +// ===== Mock Implementations ===== + +type mockCleanCommandExecutor struct { + mu sync.Mutex + executedCommands []command.Command + results map[string]command.ExecutionResult + shouldFail bool + errorMsg string + callCount int +} + +func (m *mockCleanCommandExecutor) Execute(commands []command.Command) (*command.ExecutionResult, error) { + m.mu.Lock() + defer m.mu.Unlock() + + m.executedCommands = append(m.executedCommands, commands...) + + if m.shouldFail { + return nil, &mockCleanError{message: m.errorMsg} + } + + if len(commands) == 0 { + return &command.ExecutionResult{Results: []command.Result{}}, nil + } + + cmd := commands[0] + result := m.matchCommand(cmd) + m.callCount++ + + results := make([]command.Result, len(result.Results)) + for i, r := range result.Results { + results[i] = r + results[i].Command = cmd + } + + return &command.ExecutionResult{Results: results}, nil +} + +func (m *mockCleanCommandExecutor) matchCommand(cmd command.Command) command.ExecutionResult { + if len(cmd.Args) == 0 { + return command.ExecutionResult{} + } + + arg0 := cmd.Args[0] + + if arg0 == "worktree" && len(cmd.Args) > 1 && cmd.Args[1] == "list" { + return m.results["worktree list"] + } + if arg0 == "merge-base" { + return m.results["merge-base"] + } + if arg0 == "status" { + return m.results["status"] + } + if arg0 == "rev-list" { + for _, a := range cmd.Args { + if strings.HasPrefix(a, "origin/") { + return m.results["rev-list"] + } + } + return m.results["ahead-cmd"] + } + if arg0 == "rev-parse" { + for _, a := range cmd.Args { + if a == "main" { + return m.results["main"] + } + if a == "master" { + return m.results["master"] + } + } + } + + return command.ExecutionResult{} +} + +type mockCleanError struct { + message string +} + +func (e *mockCleanError) Error() string { + return e.message +} + +// ===== Edge Cases Tests ===== + +func TestCleanCommand_EmptyWorktreeList(t *testing.T) { + mockExec := &mockCleanCommandExecutor{ + results: map[string]command.ExecutionResult{ + "worktree list": { + Results: []command.Result{{ + Output: "worktree /test/repo\nHEAD abc123\nbranch refs/heads/main\n\n", + Error: nil, + }}, + }, + }, + } + + var buf bytes.Buffer + + err := cleanCommandWithExecutor(&buf, mockExec, false) + + assert.NoError(t, err) + output := buf.String() + assert.Contains(t, output, "No managed worktrees found") +} + +func TestCleanCommand_InternationalCharacters(t *testing.T) { + tests := []struct { + name string + branchName string + worktreePath string + }{ + { + name: "Japanese characters", + branchName: "機能/ログイン", + worktreePath: "/test/repo/.worktrees/機能/ログイン", + }, + { + name: "Spanish accents", + branchName: "función/añadir", + worktreePath: "/test/repo/.worktrees/función/añadir", + }, + { + name: "Emoji characters", + branchName: "feature/🚀-rocket", + worktreePath: "/test/repo/.worktrees/feature/🚀-rocket", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockOutput := "worktree /test/repo\nHEAD abc123\nbranch refs/heads/main\n\n" + + "worktree " + tt.worktreePath + "\nHEAD def456\nbranch refs/heads/" + tt.branchName + "\n\n" + + mockExec := &mockCleanCommandExecutor{ + results: map[string]command.ExecutionResult{ + "worktree list": { + Results: []command.Result{{Output: mockOutput, Error: nil}}, + }, + }, + } + + worktrees, _, err := getWorktreesForClean(mockExec) + + assert.NoError(t, err) + // Should parse without error + assert.GreaterOrEqual(t, len(worktrees), 1) + }) + } +} + +func TestWorktreeCleanStatus_BuildReason(t *testing.T) { + tests := []struct { + name string + status worktreeCleanStatus + expectedReason string + }{ + { + name: "safe status", + status: worktreeCleanStatus{ + isSafe: true, + isMerged: true, + isClean: true, + isPushed: true, + }, + expectedReason: "safe: merged, clean, pushed", + }, + { + name: "single unsafe reason", + status: worktreeCleanStatus{ + isSafe: false, + isMerged: false, + isClean: true, + isPushed: true, + reasons: []string{"unmerged"}, + }, + expectedReason: "unsafe: unmerged", + }, + { + name: "multiple unsafe reasons", + status: worktreeCleanStatus{ + isSafe: false, + isMerged: false, + isClean: false, + isPushed: true, + reasons: []string{"unmerged", "uncommitted changes"}, + }, + expectedReason: "unsafe: unmerged, uncommitted changes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.status.buildReason() + assert.Equal(t, tt.expectedReason, tt.status.reason) + }) + } +} + +// TestRunCleanForm is skipped as it requires interactive input +func TestRunCleanForm(t *testing.T) { + t.Skip("Skipping interactive form test - requires user input") + + opts := cleanOptions{ + options: []huh.Option[string]{ + huh.NewOption("feature safe merged, clean, pushed", "feature"), + huh.NewOption("bugfix unsafe unmerged", "bugfix"), + }, + columnHeader: "WORKTREE STATUS NOTE", + } + + _, err := runCleanForm(opts) + // This will fail in tests due to no TTY, but we're just checking the function exists + assert.Error(t, err) +} diff --git a/go.mod b/go.mod index bb6f719..23785da 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/satococoa/wtp/v2 go 1.24.4 require ( + github.com/charmbracelet/huh v0.8.0 github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v3 v3.3.8 go.yaml.in/yaml/v3 v3.0.4 @@ -41,6 +42,7 @@ require ( github.com/alingse/nilnesserr v0.2.0 // indirect github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect github.com/ashanbrown/makezero/v2 v2.1.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bkielbasa/cyclop v1.2.3 // indirect @@ -52,14 +54,18 @@ require ( github.com/butuzov/ireturn v0.4.0 // indirect github.com/butuzov/mirror v1.3.0 // indirect github.com/catenacyber/perfsprint v0.10.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/ccojocar/zxcvbn-go v1.0.4 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chainguard-dev/git-urls v1.0.2 // indirect github.com/charithe/durationcheck v0.0.11 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/ckaznocha/intrange v0.3.1 // indirect github.com/cloudflare/circl v1.6.1 // indirect @@ -71,8 +77,10 @@ require ( github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dominikbraun/graph v0.23.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fatih/structtag v1.2.0 // indirect @@ -154,12 +162,15 @@ require ( github.com/matoous/godox v1.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mgechev/revive v1.12.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moricho/tparallel v0.3.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/nakabonne/nestif v0.3.1 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect diff --git a/go.sum b/go.sum index 9986d8d..ec73944 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k= github.com/Ladicle/tabwriter v1.0.0 h1:DZQqPvMumBDwVNElso13afjYLNp0Z7pHqHnu0r4t9Dg= github.com/Ladicle/tabwriter v1.0.0/go.mod h1:c4MdCjxQyTbGuQO/gvqJ+IA/89UEwrsD6hUCW98dyp4= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -67,8 +69,12 @@ github.com/ashanbrown/forbidigo/v2 v2.3.0 h1:OZZDOchCgsX5gvToVtEBoV2UWbFfI6RKQTi github.com/ashanbrown/forbidigo/v2 v2.3.0/go.mod h1:5p6VmsG5/1xx3E785W9fouMxIOkvY2rRV9nMdWadd6c= github.com/ashanbrown/makezero/v2 v2.1.0 h1:snuKYMbqosNokUKm+R6/+vOPs8yVAi46La7Ck6QYSaE= github.com/ashanbrown/makezero/v2 v2.1.0/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5w= @@ -89,6 +95,8 @@ github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI= github.com/catenacyber/perfsprint v0.10.0 h1:AZj1mYyxbxLRqmnYOeguZXEQwWOgQGm2wzLI5d7Hl/0= github.com/catenacyber/perfsprint v0.10.0/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc= github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -97,16 +105,34 @@ github.com/chainguard-dev/git-urls v1.0.2 h1:pSpT7ifrpc5X55n4aTTm7FFUE+ZQHKiqpiw github.com/chainguard-dev/git-urls v1.0.2/go.mod h1:rbGgj10OS7UgZlbzdUQIQpT0k/D4+An04HJY7Ol+Y/o= github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk= github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7LspvJs= github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= @@ -134,12 +160,16 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo= github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +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/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -338,6 +368,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mgechev/revive v1.12.0 h1:Q+/kkbbwerrVYPv9d9efaPGmAO/NsxwW/nE6ahpQaCU= @@ -350,6 +382,10 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= @@ -612,6 +648,7 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/command/builders.go b/internal/command/builders.go index ef56ac3..78b7ec9 100644 --- a/internal/command/builders.go +++ b/internal/command/builders.go @@ -87,6 +87,22 @@ func GitWorktreeList() Command { } } +// GitStatusPorcelain builds a git status command to check for uncommitted changes +func GitStatusPorcelain(path string) Command { + return Command{ + Name: "git", + Args: []string{"-C", path, "status", "--porcelain"}, + } +} + +// GitLogNotPushed builds a git log command to check for unpushed commits +func GitLogNotPushed(branch string) Command { + return Command{ + Name: "git", + Args: []string{"log", branch, "--not", "--remotes", "--oneline"}, + } +} + // extractBranchName extracts branch name from a remote reference // e.g., "origin/feature" -> "feature" func extractBranchName(ref string) string {