diff --git a/cmd/sync.go b/cmd/sync.go index f9db1ec..a8e0c6d 100644 --- a/cmd/sync.go +++ b/cmd/sync.go @@ -429,7 +429,7 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { } // Wait for parallel network operations to complete - if err := spinner.WrapWithSuccess("Fetching from origin and loading PRs...", "Fetched from origin and loaded PRs", func() error { + if err := spinner.WrapWithSuccess("Fetching from origin and loading PRs...", "✓ Fetched from origin and loaded PRs", func() error { wg.Wait() return nil }); err != nil { @@ -493,7 +493,7 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { continue } - fmt.Printf("%s Processing %s...\n", progress, ui.Branch(branch.Name)) + fmt.Printf("\n%s %s\n", progress, ui.Branch(branch.Name)) // Check if parent PR is merged oldParent := "" // Track old parent for --onto rebase @@ -870,8 +870,6 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { } else { fmt.Printf(" No PR found (create one with '%s')\n", ui.Command("gh pr create")) } - - fmt.Println() } // Return to original branch @@ -880,9 +878,8 @@ func runSync(gitClient git.GitClient, githubClient github.GitHubClient) error { fmt.Fprintf(os.Stderr, "Warning: failed to return to original branch: %v\n", err) } - fmt.Println() - // Display the updated stack status (reuse prCache to avoid redundant API call) + fmt.Println() if err := displayStatusAfterSync(gitClient, githubClient, prCache); err != nil { // Don't fail if we can't display status, just warn fmt.Fprintf(os.Stderr, "Warning: failed to display stack status: %v\n", err) diff --git a/cmd/worktree.go b/cmd/worktree.go index d18e300..6bd9cb1 100644 --- a/cmd/worktree.go +++ b/cmd/worktree.go @@ -1,7 +1,6 @@ package cmd import ( - "bufio" "fmt" "os" "path/filepath" @@ -15,16 +14,20 @@ import ( ) var worktreePrune bool +var worktreePruneAll bool +var worktreeList bool var worktreeCmd = &cobra.Command{ Use: "worktree [base-branch]", - Short: "Create a worktree in .worktrees/ directory", - Long: `Create a git worktree in the .worktrees/ directory for the specified branch. + Short: "Create a worktree in ~/.stack/worktrees/ directory", + Long: `Create a git worktree in the ~/.stack/worktrees/ directory for the specified branch. If the branch exists locally or on the remote, it will be used. If the branch doesn't exist, a new branch will be created from the current branch (or from base-branch if specified) and stack tracking will be set up automatically. -Use --prune to clean up worktrees for branches with merged PRs.`, +Use --list to show worktrees for this repository, or --list --all for all repos. +Use --prune to clean up worktrees for branches with merged PRs. +Use --prune --all to remove all worktrees for this repository.`, Example: ` # Create worktree for new branch (from current branch, with stack tracking) stack worktree my-feature @@ -34,12 +37,27 @@ Use --prune to clean up worktrees for branches with merged PRs.`, # Create worktree for existing local or remote branch stack worktree existing-branch + # List worktrees for this repository + stack worktree --list + + # List worktrees for all repositories + stack worktree --list --all + # Clean up worktrees for merged branches stack worktree --prune + # Remove all worktrees for this repository + stack worktree --prune --all + # Preview without executing stack worktree my-feature --dry-run`, Args: func(cmd *cobra.Command, args []string) error { + if worktreeList { + if len(args) > 0 { + return fmt.Errorf("--list does not take arguments") + } + return nil + } if worktreePrune { if len(args) > 0 { return fmt.Errorf("--prune does not take a branch argument") @@ -57,7 +75,9 @@ Use --prune to clean up worktrees for branches with merged PRs.`, githubClient := github.NewGitHubClient(repo) var err error - if worktreePrune { + if worktreeList { + err = runWorktreeList(gitClient) + } else if worktreePrune { err = runWorktreePrune(gitClient, githubClient) } else { var baseBranch string @@ -74,23 +94,26 @@ Use --prune to clean up worktrees for branches with merged PRs.`, } func init() { + worktreeCmd.Flags().BoolVarP(&worktreeList, "list", "l", false, "List all worktrees for this repository") worktreeCmd.Flags().BoolVar(&worktreePrune, "prune", false, "Remove worktrees for branches with merged PRs") + worktreeCmd.Flags().BoolVarP(&worktreePruneAll, "all", "a", false, "With --list: show all repos. With --prune: remove all worktrees") } func runWorktree(gitClient git.GitClient, githubClient github.GitHubClient, branchName, baseBranch string) error { - // Get repo root - repoRoot, err := gitClient.GetRepoRoot() + // Get home directory + homeDir, err := os.UserHomeDir() if err != nil { - return fmt.Errorf("failed to get repo root: %w", err) + return fmt.Errorf("failed to get home directory: %w", err) } - // Ensure .worktrees is in .gitignore - if err := ensureWorktreesIgnored(repoRoot); err != nil { - return fmt.Errorf("failed to update .gitignore: %w", err) + // Get repository name + repoName, err := gitClient.GetRepoName() + if err != nil { + return fmt.Errorf("failed to get repo name: %w", err) } - // Worktree path - worktreePath := filepath.Join(repoRoot, ".worktrees", branchName) + // Worktree path: ~/.stack/worktrees// + worktreePath := filepath.Join(homeDir, ".stack", "worktrees", repoName, branchName) // Check if worktree already exists if _, err := os.Stat(worktreePath); err == nil { @@ -106,6 +129,126 @@ func runWorktree(gitClient git.GitClient, githubClient github.GitHubClient, bran return createWorktreeForExisting(gitClient, branchName, worktreePath) } +func runWorktreeList(gitClient git.GitClient) error { + // Get home directory + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + worktreesBaseDir := filepath.Join(homeDir, ".stack", "worktrees") + + // Check if ~/.stack/worktrees directory exists + if _, err := os.Stat(worktreesBaseDir); os.IsNotExist(err) { + fmt.Printf("No worktrees found in %s\n", worktreesBaseDir) + return nil + } + + if worktreePruneAll { + // List worktrees for all repositories + return listAllWorktrees(worktreesBaseDir) + } + + // List worktrees for current repository only + repoName, err := gitClient.GetRepoName() + if err != nil { + return fmt.Errorf("failed to get repo name: %w", err) + } + + worktreesDir := filepath.Join(worktreesBaseDir, repoName) + + // Check if ~/.stack/worktrees/ directory exists + if _, err := os.Stat(worktreesDir); os.IsNotExist(err) { + fmt.Printf("No worktrees found in %s\n", worktreesDir) + return nil + } + + // Get all worktrees and their branches + worktreeBranches, err := gitClient.GetWorktreeBranches() + if err != nil { + return fmt.Errorf("failed to list worktrees: %w", err) + } + + // Filter to only worktrees in ~/.stack/worktrees/ directory + var worktrees []struct { + path string + branch string + } + for branch, path := range worktreeBranches { + if strings.HasPrefix(path, worktreesDir) { + worktrees = append(worktrees, struct { + path string + branch string + }{path: path, branch: branch}) + } + } + + if len(worktrees) == 0 { + fmt.Printf("No worktrees found in %s\n", worktreesDir) + return nil + } + + fmt.Printf("Worktrees in %s:\n\n", worktreesDir) + for _, wt := range worktrees { + fmt.Printf(" %s\n %s\n\n", ui.Branch(wt.branch), wt.path) + } + + return nil +} + +func listAllWorktrees(worktreesBaseDir string) error { + // Read all repo directories + entries, err := os.ReadDir(worktreesBaseDir) + if err != nil { + return fmt.Errorf("failed to read worktrees directory: %w", err) + } + + if len(entries) == 0 { + fmt.Println("No worktrees found.") + return nil + } + + totalCount := 0 + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + repoName := entry.Name() + repoWorktreesDir := filepath.Join(worktreesBaseDir, repoName) + + // Read worktree directories for this repo + worktreeEntries, err := os.ReadDir(repoWorktreesDir) + if err != nil { + continue + } + + var branches []string + for _, wt := range worktreeEntries { + if wt.IsDir() { + branches = append(branches, wt.Name()) + } + } + + if len(branches) == 0 { + continue + } + + fmt.Printf("%s:\n", repoName) + for _, branch := range branches { + path := filepath.Join(repoWorktreesDir, branch) + fmt.Printf(" %s\n %s\n\n", ui.Branch(branch), path) + } + totalCount += len(branches) + } + + if totalCount == 0 { + fmt.Printf("No worktrees found in %s\n", worktreesBaseDir) + } + + return nil +} + func createNewBranchWorktree(gitClient git.GitClient, branchName, baseBranch, worktreePath string) error { // Check if branch already exists if gitClient.BranchExists(branchName) { @@ -198,17 +341,23 @@ func createWorktreeForExisting(gitClient git.GitClient, branchName, worktreePath } func runWorktreePrune(gitClient git.GitClient, githubClient github.GitHubClient) error { - // Get repo root - repoRoot, err := gitClient.GetRepoRoot() + // Get home directory + homeDir, err := os.UserHomeDir() if err != nil { - return fmt.Errorf("failed to get repo root: %w", err) + return fmt.Errorf("failed to get home directory: %w", err) } - worktreesDir := filepath.Join(repoRoot, ".worktrees") + // Get repository name + repoName, err := gitClient.GetRepoName() + if err != nil { + return fmt.Errorf("failed to get repo name: %w", err) + } - // Check if .worktrees directory exists + worktreesDir := filepath.Join(homeDir, ".stack", "worktrees", repoName) + + // Check if ~/.stack/worktrees/ directory exists if _, err := os.Stat(worktreesDir); os.IsNotExist(err) { - fmt.Println("No .worktrees directory found.") + fmt.Printf("No ~/.stack/worktrees/%s directory found.\n", repoName) return nil } @@ -218,7 +367,7 @@ func runWorktreePrune(gitClient git.GitClient, githubClient github.GitHubClient) return fmt.Errorf("failed to list worktrees: %w", err) } - // Filter to only worktrees in .worktrees/ directory + // Filter to only worktrees in ~/.stack/worktrees/ directory var worktreesToCheck []struct { path string branch string @@ -233,44 +382,56 @@ func runWorktreePrune(gitClient git.GitClient, githubClient github.GitHubClient) } if len(worktreesToCheck) == 0 { - fmt.Println("No worktrees found in .worktrees/ directory.") + fmt.Printf("No worktrees found in ~/.stack/worktrees/%s directory.\n", repoName) return nil } - // Fetch PR info - var prCache map[string]*github.PRInfo - if err := spinner.WrapWithSuccess("Fetching PRs...", "Fetched PRs", func() error { - var prErr error - prCache, prErr = githubClient.GetAllPRs() - return prErr - }); err != nil { - return fmt.Errorf("failed to fetch PRs: %w", err) - } - - // Find worktrees with merged PRs - var mergedWorktrees []struct { + // Determine which worktrees to prune + var worktreesToPrune []struct { path string branch string } - for _, wt := range worktreesToCheck { - if pr, exists := prCache[wt.branch]; exists && pr.State == "MERGED" { - mergedWorktrees = append(mergedWorktrees, wt) + + if worktreePruneAll { + // Prune all worktrees + worktreesToPrune = worktreesToCheck + + fmt.Println() + fmt.Printf("Found %d worktree(s) to remove:\n", len(worktreesToPrune)) + for _, wt := range worktreesToPrune { + fmt.Printf(" - %s (%s)\n", wt.branch, wt.path) + } + fmt.Println() + } else { + // Prune only worktrees with merged PRs + var prCache map[string]*github.PRInfo + if err := spinner.WrapWithSuccess("Fetching PRs...", "Fetched PRs", func() error { + var prErr error + prCache, prErr = githubClient.GetAllPRs() + return prErr + }); err != nil { + return fmt.Errorf("failed to fetch PRs: %w", err) } - } - if len(mergedWorktrees) == 0 { - fmt.Println("\nNo worktrees with merged PRs to prune.") - return nil - } + for _, wt := range worktreesToCheck { + if pr, exists := prCache[wt.branch]; exists && pr.State == "MERGED" { + worktreesToPrune = append(worktreesToPrune, wt) + } + } - // Show what will be pruned - fmt.Println() - fmt.Printf("Found %d worktree(s) with merged PRs:\n", len(mergedWorktrees)) - for _, wt := range mergedWorktrees { - pr := prCache[wt.branch] - fmt.Printf(" - %s (%s, PR #%d)\n", ui.Branch(wt.branch), wt.path, pr.Number) + if len(worktreesToPrune) == 0 { + fmt.Println("\nNo worktrees with merged PRs to prune.") + return nil + } + + fmt.Println() + fmt.Printf("Found %d worktree(s) with merged PRs:\n", len(worktreesToPrune)) + for _, wt := range worktreesToPrune { + pr := prCache[wt.branch] + fmt.Printf(" - %s (%s, PR #%d)\n", ui.Branch(wt.branch), wt.path, pr.Number) + } + fmt.Println() } - fmt.Println() if dryRun { fmt.Println("Dry run - no changes made.") @@ -278,8 +439,8 @@ func runWorktreePrune(gitClient git.GitClient, githubClient github.GitHubClient) } // Remove each worktree - for i, wt := range mergedWorktrees { - fmt.Printf("%s Removing worktree for %s...\n", ui.Progress(i+1, len(mergedWorktrees)), ui.Branch(wt.branch)) + for i, wt := range worktreesToPrune { + fmt.Printf("%s Removing worktree for %s...\n", ui.Progress(i+1, len(worktreesToPrune)), ui.Branch(wt.branch)) if err := gitClient.RemoveWorktree(wt.path); err != nil { fmt.Fprintf(os.Stderr, " Warning: failed to remove worktree: %v\n", err) @@ -290,76 +451,9 @@ func runWorktreePrune(gitClient git.GitClient, githubClient github.GitHubClient) fmt.Println() fmt.Println(ui.Success("Worktree prune complete!")) - fmt.Printf("Tip: Run '%s' to also delete the merged branches.\n", ui.Command("stack prune")) - - return nil -} - -func ensureWorktreesIgnored(repoRoot string) error { - gitignorePath := filepath.Join(repoRoot, ".gitignore") - - // Check if .worktrees is already in .gitignore - if _, err := os.Stat(gitignorePath); err == nil { - file, err := os.Open(gitignorePath) - if err != nil { - return err - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == ".worktrees" || line == ".worktrees/" { - return nil // Already ignored - } - } - if err := scanner.Err(); err != nil { - return err - } - } - - if dryRun { - fmt.Println(" [DRY RUN] Adding .worktrees to .gitignore") - return nil - } - - // Append .worktrees to .gitignore - file, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return err - } - defer file.Close() - - // Check if file ends with newline, if not add one - info, err := file.Stat() - if err != nil { - return err - } - - var prefix string - if info.Size() > 0 { - // Read last byte to check for newline - tempFile, err := os.Open(gitignorePath) - if err != nil { - return err - } - defer tempFile.Close() - - buf := make([]byte, 1) - _, err = tempFile.ReadAt(buf, info.Size()-1) - if err != nil { - return err - } - if buf[0] != '\n' { - prefix = "\n" - } - } - - _, err = file.WriteString(prefix + ".worktrees/\n") - if err != nil { - return err + if !worktreePruneAll { + fmt.Printf("Tip: Run '%s' to also delete the merged branches.\n", ui.Command("stack prune")) } - fmt.Println("Added .worktrees/ to .gitignore") return nil } diff --git a/internal/git/git.go b/internal/git/git.go index 9e537ed..7c5f9bf 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "os/exec" + "path/filepath" "strings" ) @@ -58,6 +59,15 @@ func (c *gitClient) GetRepoRoot() (string, error) { return c.runCmd("rev-parse", "--show-toplevel") } +// GetRepoName returns the name of the git repository (directory name) +func (c *gitClient) GetRepoName() (string, error) { + repoRoot, err := c.GetRepoRoot() + if err != nil { + return "", err + } + return filepath.Base(repoRoot), nil +} + // GetCurrentBranch returns the name of the currently checked out branch func (c *gitClient) GetCurrentBranch() (string, error) { return c.runCmd("branch", "--show-current") diff --git a/internal/git/interface.go b/internal/git/interface.go index 6fdafde..10303b8 100644 --- a/internal/git/interface.go +++ b/internal/git/interface.go @@ -3,6 +3,7 @@ package git // GitClient defines the interface for all git operations type GitClient interface { GetRepoRoot() (string, error) + GetRepoName() (string, error) GetCurrentBranch() (string, error) ListBranches() ([]string, error) GetConfig(key string) string diff --git a/internal/testutil/mocks.go b/internal/testutil/mocks.go index 2938279..073b7ce 100644 --- a/internal/testutil/mocks.go +++ b/internal/testutil/mocks.go @@ -15,6 +15,11 @@ func (m *MockGitClient) GetRepoRoot() (string, error) { return args.String(0), args.Error(1) } +func (m *MockGitClient) GetRepoName() (string, error) { + args := m.Called() + return args.String(0), args.Error(1) +} + func (m *MockGitClient) GetCurrentBranch() (string, error) { args := m.Called() return args.String(0), args.Error(1)