diff --git a/README.md b/README.md index 2f9582e..ef94112 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,17 @@ ccswitch/ - **Git** 2.20 or higher (for worktree support) - **Bash** or **Zsh** (for shell integration) +## 🪝 Hooks + +You can configure hooks in `~/.ccswitch/config.yaml` to run commands automatically after switching to a worktree: + +```yaml +hooks: + post_switch: "claude" +``` + +The `post_switch` hook runs after every worktree switch (`create`, `checkout`, `switch`, `list`). This is useful for automatically starting tools like Claude Code in the new worktree directory. + ## 💡 Tips - Use descriptive session names - they become your branch names! diff --git a/cmd/checkout.go b/cmd/checkout.go index cbec57c..75f1e24 100644 --- a/cmd/checkout.go +++ b/cmd/checkout.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" + "github.com/ksred/ccswitch/internal/config" "github.com/ksred/ccswitch/internal/errors" "github.com/ksred/ccswitch/internal/session" "github.com/ksred/ccswitch/internal/ui" @@ -63,6 +64,12 @@ func checkoutSession(cmd *cobra.Command, args []string) { // Output the cd command for the shell wrapper to execute on a separate line fmt.Printf("\ncd %s\n", worktreePath) + // Output post-switch hook if configured + cfg, _ := config.Load() + if cfg.Hooks.PostSwitch != "" { + fmt.Printf("#post-hook:%s\n", cfg.Hooks.PostSwitch) + } + // If shell integration is not active, show a helpful message if !utils.IsShellIntegrationActive() { fmt.Println() diff --git a/cmd/cleanup.go b/cmd/cleanup.go index eea9051..baac2fe 100644 --- a/cmd/cleanup.go +++ b/cmd/cleanup.go @@ -22,16 +22,20 @@ func newCleanupCmd() *cobra.Command { Without arguments: Shows an interactive list of sessions to cleanup With session name: Removes the specified session With --all flag: Removes all worktrees except main/master (bulk cleanup) +With --merged flag: Removes all worktrees whose branch is merged into the default branch Examples: ccswitch cleanup # Interactive selection ccswitch cleanup my-feature # Remove specific session - ccswitch cleanup --all # Remove all worktrees (with confirmation)`, + ccswitch cleanup --all # Remove all worktrees (with confirmation) + ccswitch cleanup --merged # Remove merged worktrees + branches (local & remote)`, Args: cobra.MaximumNArgs(1), Run: cleanupSession, } cmd.Flags().Bool("all", false, "Remove ALL worktrees except main/master (bulk cleanup)") + cmd.Flags().Bool("merged", false, "Remove worktrees whose branch is merged into the default branch") + cmd.MarkFlagsMutuallyExclusive("all", "merged") return cmd } @@ -59,8 +63,14 @@ func cleanupSession(cmd *cobra.Command, args []string) { return } - // Check if --all flag is set + // Check flags cleanupAll, _ := cmd.Flags().GetBool("all") + cleanupMerged, _ := cmd.Flags().GetBool("merged") + + if cleanupMerged { + cleanupMergedSessions(manager, sessions) + return + } if cleanupAll { cleanupAllSessions(manager, sessions) @@ -195,6 +205,76 @@ func cleanupAllSessions(manager *session.Manager, sessions []git.SessionInfo) { switchToMainBranch() } +func cleanupMergedSessions(manager *session.Manager, sessions []git.SessionInfo) { + defaultBranch := manager.DefaultBranch() + remote := "origin" + target := remote + "/" + defaultBranch + + // Fetch to get current remote state + ui.Infof("Fetching from %s...", remote) + if err := manager.Fetch(remote); err != nil { + ui.Errorf("✗ Failed to fetch: %v", err) + return + } + + // Filter to worktree sessions (not main/master) that are merged + var merged []git.SessionInfo + for _, s := range sessions { + if s.Name == "main" || s.Branch == "main" || s.Branch == "master" { + continue + } + if manager.IsMergedInto(s.Branch, target) { + merged = append(merged, s) + } + } + + if len(merged) == 0 { + ui.Info("No merged sessions to cleanup") + return + } + + // Show what will be removed + ui.Title("Merged sessions (will remove worktree + local & remote branch):") + fmt.Println() + for _, s := range merged { + remoteStatus := "remote already deleted" + if manager.RemoteBranchExists(remote, s.Branch) { + remoteStatus = "remote exists" + } + ui.Infof(" • %s (%s) [%s]", s.Name, s.Branch, remoteStatus) + } + fmt.Println() + + // Remove each merged session + successCount := 0 + for _, s := range merged { + // Remove worktree + local branch + if err := manager.RemoveSession(s.Path, true, s.Branch); err != nil { + ui.Errorf("✗ Failed to remove %s: %v", s.Name, err) + continue + } + + // Delete remote branch (ignore errors — may already be gone) + if err := manager.DeleteRemoteBranch(remote, s.Branch); err != nil { + ui.Infof(" ℹ Remote branch already deleted: %s", s.Branch) + } + + ui.Successf("✓ Removed: %s", s.Name) + successCount++ + } + + // Summary + fmt.Println() + if successCount == len(merged) { + ui.Successf("All %d merged sessions cleaned up!", successCount) + } else { + ui.Infof("Removed %d out of %d merged sessions", successCount, len(merged)) + } + + // Prune stale remote tracking refs + _ = manager.Fetch(remote) +} + func switchToMainBranch() { // Try to switch to main first, then master if main doesn't exist branches := []string{"main", "master"} diff --git a/cmd/create.go b/cmd/create.go index 4ab466a..5ed7af1 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -84,6 +84,11 @@ func createSession(cmd *cobra.Command, args []string) { // Output the cd command for the shell wrapper to execute on a separate line fmt.Printf("\ncd %s\n", worktreePath) + // Output post-switch hook if configured + if cfg.Hooks.PostSwitch != "" { + fmt.Printf("#post-hook:%s\n", cfg.Hooks.PostSwitch) + } + // If shell integration is not active, show a helpful message if !utils.IsShellIntegrationActive() { fmt.Println() diff --git a/cmd/list.go b/cmd/list.go index 59c626d..ba7928d 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -5,6 +5,7 @@ import ( "os" tea "github.com/charmbracelet/bubbletea" + "github.com/ksred/ccswitch/internal/config" "github.com/ksred/ccswitch/internal/session" "github.com/ksred/ccswitch/internal/ui" "github.com/ksred/ccswitch/internal/utils" @@ -68,6 +69,12 @@ func listSessions(cmd *cobra.Command, args []string) { // Output the cd command for shell evaluation fmt.Printf("\ncd %s\n", selected.Path) + // Output post-switch hook if configured + cfg, _ := config.Load() + if cfg.Hooks.PostSwitch != "" { + fmt.Printf("#post-hook:%s\n", cfg.Hooks.PostSwitch) + } + // If shell integration is not active, show a helpful message if !utils.IsShellIntegrationActive() { fmt.Println() diff --git a/cmd/shell_init.go b/cmd/shell_init.go index d330ee4..ac90a5f 100644 --- a/cmd/shell_init.go +++ b/cmd/shell_init.go @@ -56,6 +56,11 @@ ccswitch() { eval "$cd_cmd" fi + local hook_cmd=$(grep "^#post-hook:" "$temp_file" 2>/dev/null | tail -1 | sed 's/^#post-hook://') + if [ -n "$hook_cmd" ]; then + eval "$hook_cmd" + fi + # Clean up temp file rm -f "$temp_file" ;; @@ -76,6 +81,11 @@ ccswitch() { if [ -n "$cd_cmd" ]; then eval "$cd_cmd" fi + + local hook_cmd=$(grep "^#post-hook:" "$temp_file" 2>/dev/null | tail -1 | sed 's/^#post-hook://') + if [ -n "$hook_cmd" ]; then + eval "$hook_cmd" + fi # Clean up temp file rm -f "$temp_file" @@ -123,6 +133,11 @@ ccswitch() { eval "$cd_cmd" fi + local hook_cmd=$(grep "^#post-hook:" "$temp_file" 2>/dev/null | tail -1 | sed 's/^#post-hook://') + if [ -n "$hook_cmd" ]; then + eval "$hook_cmd" + fi + # Clean up temp file rm -f "$temp_file" ;; @@ -143,6 +158,11 @@ ccswitch() { eval "$cd_cmd" fi + local hook_cmd=$(grep "^#post-hook:" "$temp_file" 2>/dev/null | tail -1 | sed 's/^#post-hook://') + if [ -n "$hook_cmd" ]; then + eval "$hook_cmd" + fi + # Clean up temp file rm -f "$temp_file" ;; diff --git a/cmd/switch.go b/cmd/switch.go index c1eef00..0553c1f 100644 --- a/cmd/switch.go +++ b/cmd/switch.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/ksred/ccswitch/internal/config" "github.com/ksred/ccswitch/internal/git" "github.com/ksred/ccswitch/internal/session" "github.com/ksred/ccswitch/internal/ui" @@ -75,6 +76,12 @@ func switchSession(cmd *cobra.Command, args []string) { // Output the cd command for shell evaluation fmt.Printf("\ncd %s\n", selected.Path) + // Output post-switch hook if configured + cfg, _ := config.Load() + if cfg.Hooks.PostSwitch != "" { + fmt.Printf("#post-hook:%s\n", cfg.Hooks.PostSwitch) + } + // If shell integration is not active, show a helpful message if !utils.IsShellIntegrationActive() { fmt.Println() diff --git a/internal/config/config.go b/internal/config/config.go index d116acf..f8dfc22 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,9 @@ type Config struct { DefaultBranch string `yaml:"default_branch"` AutoFetch bool `yaml:"auto_fetch"` } `yaml:"git"` + Hooks struct { + PostSwitch string `yaml:"post_switch"` + } `yaml:"hooks"` } // DefaultConfig returns the default configuration diff --git a/internal/git/branch.go b/internal/git/branch.go index 2c7c219..fe1d688 100644 --- a/internal/git/branch.go +++ b/internal/git/branch.go @@ -68,3 +68,101 @@ func (bm *BranchManager) HasUncommittedChanges() bool { output, err := cmd.CombinedOutput() return err == nil && strings.TrimSpace(string(output)) != "" } + +// Fetch fetches from a remote and prunes stale tracking branches +func (bm *BranchManager) Fetch(remote string) error { + cmd := exec.Command("git", "fetch", remote, "--prune") + cmd.Dir = bm.repoPath + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to fetch: %w, output: %s", err, string(output)) + } + return nil +} + +// IsMergedInto checks if branch is merged into target. +// Handles both regular merges and squash merges. +func (bm *BranchManager) IsMergedInto(branch, target string) bool { + // Fast path: regular merge (branch tip is ancestor of target) + if bm.isAncestor(branch, target) { + return true + } + + // Slow path: squash merge detection. + // Squash merges create a new commit on main, so the branch commits + // are never ancestors. We detect this by collapsing the branch into + // a single diff and checking if that diff already exists in target. + return bm.isSquashMergedInto(branch, target) +} + +func (bm *BranchManager) isAncestor(branch, target string) bool { + cmd := exec.Command("git", "merge-base", "--is-ancestor", branch, target) + cmd.Dir = bm.repoPath + return cmd.Run() == nil +} + +func (bm *BranchManager) isSquashMergedInto(branch, target string) bool { + // 1. Find merge-base between target and branch + mbCmd := exec.Command("git", "merge-base", target, branch) + mbCmd.Dir = bm.repoPath + mbOut, err := mbCmd.CombinedOutput() + if err != nil { + return false + } + mergeBase := strings.TrimSpace(string(mbOut)) + + // 2. Get the branch's tree (snapshot of all files) + treeCmd := exec.Command("git", "rev-parse", branch+"^{tree}") + treeCmd.Dir = bm.repoPath + treeOut, err := treeCmd.CombinedOutput() + if err != nil { + return false + } + tree := strings.TrimSpace(string(treeOut)) + + // 3. Create a temporary dangling commit that squashes the branch + // into a single diff against merge-base + commitCmd := exec.Command("git", "commit-tree", tree, "-p", mergeBase, "-m", "temp") + commitCmd.Dir = bm.repoPath + commitOut, err := commitCmd.CombinedOutput() + if err != nil { + return false + } + tempCommit := strings.TrimSpace(string(commitOut)) + + // 4. Use git cherry to check if this patch already exists in target. + // "-" prefix = patch already applied, "+" = not applied. + cherryCmd := exec.Command("git", "cherry", target, tempCommit, mergeBase) + cherryCmd.Dir = bm.repoPath + cherryOut, err := cherryCmd.CombinedOutput() + if err != nil { + return false + } + + result := strings.TrimSpace(string(cherryOut)) + return strings.HasPrefix(result, "- ") +} + +// DeleteRemote deletes a branch from a remote. Returns nil if already deleted. +func (bm *BranchManager) DeleteRemote(remote, branch string) error { + cmd := exec.Command("git", "push", remote, "--delete", branch) + cmd.Dir = bm.repoPath + output, err := cmd.CombinedOutput() + if err != nil { + out := string(output) + // Branch already deleted on remote — not an error + if strings.Contains(out, "remote ref does not exist") { + return nil + } + return fmt.Errorf("failed to delete remote branch: %w, output: %s", err, out) + } + return nil +} + +// RemoteExists checks if a remote tracking branch exists +func (bm *BranchManager) RemoteExists(remote, branch string) bool { + ref := fmt.Sprintf("refs/remotes/%s/%s", remote, branch) + cmd := exec.Command("git", "rev-parse", "--verify", ref) + cmd.Dir = bm.repoPath + return cmd.Run() == nil +} diff --git a/internal/session/manager.go b/internal/session/manager.go index 71a18cd..dc060f5 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -59,19 +59,10 @@ func (m *Manager) CreateSession(description string) error { } // Get worktree path - homeDir, err := os.UserHomeDir() - if err != nil { - return errors.Wrap(err, "failed to get home directory") - } - - // Get repo name from the main repo path - mainRepoPath, err := git.GetMainRepoPath(m.repoPath) + worktreeBasePath, err := m.getWorktreeBasePath() if err != nil { - mainRepoPath = m.repoPath + return err } - repoName := filepath.Base(mainRepoPath) - - worktreeBasePath := filepath.Join(homeDir, ".ccswitch", "worktrees", repoName) worktreePath := filepath.Join(worktreeBasePath, sessionName) // Check if worktree directory already exists @@ -178,6 +169,40 @@ func (m *Manager) RemoveSession(sessionPath string, deleteBranch bool, branchNam return nil } +// DefaultBranch returns the configured default branch name +func (m *Manager) DefaultBranch() string { + return m.config.Git.DefaultBranch +} + +// Fetch fetches from a remote and prunes stale tracking branches +func (m *Manager) Fetch(remote string) error { + return m.branchManager.Fetch(remote) +} + +// IsMergedInto checks if a branch is merged into the target +func (m *Manager) IsMergedInto(branch, target string) bool { + return m.branchManager.IsMergedInto(branch, target) +} + +// DeleteRemoteBranch deletes a branch from a remote +func (m *Manager) DeleteRemoteBranch(remote, branch string) error { + return m.branchManager.DeleteRemote(remote, branch) +} + +// RemoteBranchExists checks if a remote tracking branch exists +func (m *Manager) RemoteBranchExists(remote, branch string) bool { + return m.branchManager.RemoteExists(remote, branch) +} + +// getWorktreeBasePath returns the base directory for worktrees of this repo +func (m *Manager) getWorktreeBasePath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", errors.Wrap(err, "failed to get home directory") + } + return filepath.Join(homeDir, ".ccswitch", "worktrees", m.repoName), nil +} + // GetSessionPath returns the path for a session func (m *Manager) GetSessionPath(sessionName string) string { homeDir, _ := os.UserHomeDir()