Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
7 changes: 7 additions & 0 deletions cmd/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
84 changes: 82 additions & 2 deletions cmd/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"}
Expand Down
5 changes: 5 additions & 0 deletions cmd/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions cmd/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
20 changes: 20 additions & 0 deletions cmd/shell_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
;;
Expand All @@ -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"
Expand Down Expand Up @@ -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"
;;
Expand All @@ -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"
;;
Expand Down
7 changes: 7 additions & 0 deletions cmd/switch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions internal/git/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading