diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 3b06ee8..9b0501d 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -10,8 +10,11 @@
"Bash(declare:*)",
"Bash(ccswitch list:*)",
"Bash(ccswitch switch:*)",
- "Bash(gtimeout:*)"
+ "Bash(gtimeout:*)",
+ "Bash(make:*)",
+ "Bash(go clean:*)",
+ "Bash(go mod tidy:*)"
],
"deny": []
}
-}
\ No newline at end of file
+}
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..35410ca
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/ccswitch.iml b/.idea/ccswitch.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/ccswitch.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..81d6c34
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..6b4be15
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,100 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Development Commands
+
+```bash
+# Build
+make build
+
+# Run tests
+make test # All tests
+make test-unit # Unit tests only (fast, no git required)
+make test-integration # Integration tests (requires git)
+
+# Install to GOPATH/bin + shell integration
+make install
+
+# Lint and format
+make lint
+make fmt
+
+# Coverage
+make coverage # Generates coverage.html
+```
+
+To run a specific test: `go test -v -run TestSpecificName ./...`
+
+## Project Architecture
+
+ccswitch is a CLI tool for managing git worktrees through a "session" abstraction.
+
+### Layer Architecture
+
+```
+main.go (entry point)
+ └── cmd/ # Cobra command layer
+ ├── root.go # Command registration
+ └── *.go # Individual commands (create, switch, cleanup, rebase, fanout, etc.)
+ └── internal/ # Business logic layer
+ ├── session/ # Session Manager (orchestrates operations)
+ ├── git/ # Git operations (worktree, branch, commit, rebase)
+ ├── config/ # YAML configuration (~/.ccswitch/config.yaml)
+ ├── ui/ # Colored output helpers
+ ├── errors/ # Error types with hints
+ └── utils/ # Utilities (slugify, shell detection)
+```
+
+### Key Concepts
+
+**Sessions vs Worktrees**
+- A "worktree" is a git concept: a separate working directory for a branch
+- A "session" is ccswitch's abstraction: worktrees stored under `~/.ccswitch/worktrees/{repo-name}/{session-name}/`
+- The `GetSessionsFromWorktrees()` function in `internal/git/worktree.go` filters git's worktree list to identify ccswitch-managed sessions
+- Some commands (like `rebase`, `fanout`) operate on **all** git worktrees, not just ccswitch sessions
+
+**Session Manager** (`internal/session/manager.go`)
+- Orchestrates operations across WorktreeManager and BranchManager
+- `repoPath` is the current working directory (or main repo path for worktree listing)
+- `mainRepoPath` (derived via `GetMainRepoPath()`) is used for worktree operations
+- Always use `WorktreeManager.Create()` with paths under `~/.ccswitch/worktrees/` for new sessions
+
+**Git Operations Pattern**
+All git operations use `exec.Command` with `cmd.Dir` set to the target repository path:
+```go
+cmd := exec.Command("git", "subcommand", "args")
+cmd.Dir = repoPath // Critical: specifies which worktree/repo to operate on
+output, err := cmd.CombinedOutput()
+```
+
+**Shell Integration**
+- The `shell-init` command outputs shell code for eval
+- The bash wrapper captures command output and extracts paths for `cd`-ing
+- Commands output paths to stdout as the last line: `fmt.Printf("\ncd %s\n", path)`
+
+### Configuration
+
+Config stored in `~/.ccswitch/config.yaml` with defaults in `internal/config/config.go`:
+- `branch.prefix`: Default branch name prefix (default: "feature/")
+- `git.default_branch`: Main branch name (default: "main")
+- `ui.show_emoji`: Toggle emoji output
+
+### Branch Naming
+
+Session descriptions are converted to branch names using `utils.Slugify()`:
+- Converts to lowercase, replaces spaces with hyphens, removes special chars
+- Branch name: `{config.Branch.Prefix}{slugify(description)}`
+- Session name (directory): `{slugify(description)}`
+
+### Error Handling
+
+Errors from `internal/errors/` include hints via `ErrorHint(err)`. Wrap errors with context:
+```go
+return fmt.Errorf("failed to do something: %w", err)
+```
+
+### Recent Commands
+
+- `rebase`: Commit changes in a worktree and rebase to current branch. Checks for uncommitted changes first - only prompts for commit message if changes exist.
+- `fanout`: Propagate current branch commits to all other worktrees. Safety checks: no uncommitted changes, no worktree ahead of current.
diff --git a/README_ZH.md b/README_ZH.md
new file mode 100644
index 0000000..79d901b
--- /dev/null
+++ b/README_ZH.md
@@ -0,0 +1,202 @@
+# 🔀 ccswitch
+
+由 [Kyle Redelinghuys](https://ksred.com) 开发
+
+一个友好的 CLI 工具,用于管理多个 git worktree(工作树),非常适合同时处理不同功能、实验或 Claude Code 会话,而无需为上下文切换而烦恼。
+
+## 🎯 这是什么?
+
+`ccswitch` 帮助您通过简洁直观的界面创建和管理 git worktree。每个 worktree 都有自己独立的目录,让您可以同时在多个功能上工作,无需 stash 变更或就地切换分支。
+
+## ✨ 特性
+
+- **🚀 快速创建会话** - 描述您正在处理的内容,即可立即获得分支和 worktree
+- **📋 交互式会话列表** - 通过简洁的 TUI 界面查看所有活跃的工作会话
+- **🧹 智能清理** - 完成后删除 worktree,可选是否同时删除分支
+- **🗑️ 批量清理** - 使用 `cleanup --all` 一次性删除所有 worktree(适合大扫除!)
+- **🐚 Shell 集成** - 自动 `cd` 进入新的 worktree(无需复制粘贴路径!)
+- **🎨 美观输出** - 彩色消息和整洁的格式
+
+## 📦 安装
+
+### 使用 Make
+```bash
+# 克隆仓库
+git clone https://github.com/ksred/ccswitch.git
+cd ccswitch
+
+# 构建并安装
+make install
+
+# 将 shell 集成添加到您的 .bashrc 或 .zshrc
+cat bash.txt >> ~/.bashrc # 或 ~/.zshrc
+source ~/.bashrc # 或 ~/.zshrc
+```
+
+### 手动安装
+```bash
+# 构建二进制文件
+go build -o ccswitch .
+
+# 移动到您的 PATH
+sudo mv ccswitch /usr/local/bin/
+
+# 添加 shell 包装器
+source bash.txt
+```
+
+## 🚀 使用方法
+
+### 创建新的工作会话
+```bash
+ccswitch
+# 🚀 What are you working on? Fix authentication bug
+# ✓ Created session: feature/fix-authentication-bug
+# Branch: feature/fix-authentication-bug
+# Path: /home/user/project/../fix-authentication-bug
+#
+# Automatically switches to the new directory!
+```
+
+### 列出活跃会话
+```bash
+ccswitch list
+# 显示所有 worktree 的交互式列表
+# 使用方向键导航,回车选择,q 退出
+```
+
+### 在会话之间切换
+```bash
+ccswitch switch
+# 交互式选择要切换到的会话
+
+ccswitch switch fix-auth-bug
+# 直接切换到指定会话
+# 自动切换到会话目录!
+```
+
+### 完成后清理
+```bash
+ccswitch cleanup
+# 交互式选择会话,或:
+
+ccswitch cleanup fix-authentication-bug
+# Delete branch feature/fix-authentication-bug? (y/N): y
+# ✓ Removed session and branch: fix-authentication-bug
+
+# 批量清理 - 一次性删除所有 worktree!
+ccswitch cleanup --all
+# ⚠️ You are about to remove the following worktrees:
+# • feature-1 (feature/feature-1)
+# • feature-2 (feature/feature-2)
+# • bugfix-1 (feature/bugfix-1)
+# Press Enter to continue or Ctrl+C to cancel...
+# Delete associated branches as well? (y/N): y
+# ✓ Successfully removed: feature-1
+# ✓ Successfully removed: feature-2
+# ✓ Successfully removed: bugfix-1
+# ✅ All 3 worktrees removed successfully!
+# ✓ Switched to main branch
+```
+
+## 🛠️ 开发
+
+### 快速开始
+```bash
+# 直接运行
+make run
+
+# 运行测试
+make test
+
+# 查看所有命令
+make help
+```
+
+### 测试
+```bash
+# 仅单元测试(快速,无需 git)
+make test-unit
+
+# 包含集成的所有测试
+make test
+
+# 在 Docker 中运行测试(清洁环境)
+make test-docker
+
+# 生成覆盖率报告
+make coverage
+```
+
+### 项目结构
+```
+ccswitch/
+├── main.go # 主应用程序代码
+├── bash.txt # Shell 集成包装器
+├── Makefile # 构建自动化
+├── *_test.go # 测试文件
+├── Dockerfile.test # Docker 测试环境
+└── README.md # 您在这里!👋
+```
+
+## 🤔 工作原理
+
+1. **会话创建**:将您的描述转换为分支名(例如,"Fix login bug" → `feature/fix-login-bug`)
+2. **集中存储**:在 `~/.ccswitch/worktrees/repo-name/session-name` 中创建 worktree - 您的项目保持整洁!
+3. **自动导航**:bash 包装器捕获输出并将您 `cd` 到新目录
+4. **会话跟踪**:将除主 worktree 外的所有 worktree 列为活跃会话
+
+### 目录结构
+```
+~/.ccswitch/ # 所有 ccswitch 数据在您的主目录中
+└── worktrees/ # 集中的 worktree 存储
+ ├── my-project/ # 按仓库名称组织
+ │ ├── fix-login-bug/ # 各个会话
+ │ ├── add-new-feature/
+ │ └── refactor-ui/
+ └── another-project/
+ ├── update-deps/
+ └── new-feature/
+
+# 您的项目目录保持完全整洁!
+/Users/you/projects/
+├── my-project/ # 只有您的主仓库
+└── another-project/ # 没有杂乱!
+```
+
+## 🔧 系统要求
+
+- **Go** 1.21 或更高版本(用于构建)
+- **Git** 2.20 或更高版本(用于 worktree 支持)
+- **Bash** 或 **Zsh**(用于 shell 集成)
+
+## 💡 使用技巧
+
+- 使用描述性的会话名称 - 它们会成为您的分支名!
+- 定期清理保持工作区整洁
+- 每个 worktree 都是独立的 - 适合测试不同的方法
+- 创建新会话时,工具会尊重您当前的分支
+
+## 🐛 故障排除
+
+**"Failed to create worktree"(创建 worktree 失败)**
+- 检查分支是否已存在:`git branch -a`
+- 确保您在 git 仓库中
+- 验证您在父目录中有写入权限
+
+**Shell 集成不工作**
+- 确保已导入 bash 包装器
+- 检查 `ccswitch` 是否在您的 PATH 中
+- 尝试使用完整路径:`/usr/local/bin/ccswitch`
+
+## 📝 许可证
+
+MIT License - 欢迎在您的项目中使用!
+
+## 🤝 贡献
+
+发现了 bug?有想法?欢迎提交 issue 或 PR!
+
+---
+
+用 ❤️ 打造,献给同时处理多个功能的开发者
diff --git a/a_gogopathbincc.exe b/a_gogopathbincc.exe
new file mode 100644
index 0000000..327d17e
Binary files /dev/null and b/a_gogopathbincc.exe differ
diff --git a/cmd/create.go b/cmd/create.go
index 4ab466a..9eec922 100644
--- a/cmd/create.go
+++ b/cmd/create.go
@@ -9,6 +9,7 @@ import (
"github.com/ksred/ccswitch/internal/config"
"github.com/ksred/ccswitch/internal/errors"
+ "github.com/ksred/ccswitch/internal/git"
"github.com/ksred/ccswitch/internal/session"
"github.com/ksred/ccswitch/internal/ui"
"github.com/ksred/ccswitch/internal/utils"
@@ -24,6 +25,8 @@ func newCreateCmd() *cobra.Command {
}
func createSession(cmd *cobra.Command, args []string) {
+ scanner := bufio.NewScanner(os.Stdin)
+
// Get current directory
currentDir, err := os.Getwd()
if err != nil {
@@ -31,13 +34,48 @@ func createSession(cmd *cobra.Command, args []string) {
return
}
+ // Check if we're in a git repository root
+ mainRepoPath, err := git.GetMainRepoPath(currentDir)
+ if err != nil {
+ ui.Errorf("✗ Failed to get git repository: %v", err)
+ return
+ }
+
+ // Check if current directory is the git root
+ // Normalize paths for comparison (handle Windows path separators)
+ currentDirNormalized := filepath.Clean(currentDir)
+ mainRepoPathNormalized := filepath.Clean(mainRepoPath)
+
+ if currentDirNormalized != mainRepoPathNormalized {
+ // We're in a subdirectory
+ ui.Warningf("You are currently in a subdirectory of the git repository")
+ ui.Infof("Current directory: %s", currentDir)
+ ui.Infof("Git root: %s", mainRepoPath)
+ fmt.Println()
+
+ // Ask user if they want to continue
+ fmt.Print("Do you want to create a session from this subdirectory? (yes/no): ")
+
+ if !scanner.Scan() {
+ ui.Info("Session creation cancelled")
+ return
+ }
+
+ answer := strings.ToLower(strings.TrimSpace(scanner.Text()))
+ if answer != "yes" && answer != "y" {
+ ui.Info("Session creation cancelled")
+ ui.Info("Tip: Navigate to the git repository root first:")
+ fmt.Printf(" cd %s\n", mainRepoPath)
+ return
+ }
+ }
+
// Create session manager
manager := session.NewManager(currentDir)
// Get description from user
fmt.Print(ui.TitleStyle.Render("🚀 What are you working on? "))
- scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
return
}
@@ -71,7 +109,7 @@ func createSession(cmd *cobra.Command, args []string) {
sessionName := utils.Slugify(description)
cfg, _ := config.Load()
branchName := cfg.Branch.Prefix + sessionName
- repoName := filepath.Base(currentDir)
+ repoName := filepath.Base(mainRepoPath)
// Get the full worktree path
homeDir, _ := os.UserHomeDir()
diff --git a/cmd/fanout.go b/cmd/fanout.go
new file mode 100644
index 0000000..2e1774b
--- /dev/null
+++ b/cmd/fanout.go
@@ -0,0 +1,221 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+
+ "github.com/fatih/color"
+ "github.com/ksred/ccswitch/internal/git"
+ "github.com/ksred/ccswitch/internal/session"
+ "github.com/ksred/ccswitch/internal/ui"
+ "github.com/spf13/cobra"
+)
+
+func newFanoutCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "fanout",
+ Short: "Propagate current branch commits to all other worktrees",
+ Long: `Synchronously rebase all worktree branches onto the current branch.
+
+This is useful for synchronizing all feature branches with a core business branch.
+
+Safety checks before fanout:
+ 1. No other worktree has uncommitted changes
+ 2. No other worktree is ahead of current branch
+ 3. Auto-abort on any conflict
+
+Examples:
+ ccswitch fanout # Interactive confirmation and fanout`,
+ Run: fanoutBranches,
+ }
+
+ return cmd
+}
+
+func fanoutBranches(cmd *cobra.Command, args []string) {
+ // Get current directory
+ currentDir, err := os.Getwd()
+ if err != nil {
+ ui.Error("✗ Failed to get current directory")
+ return
+ }
+
+ // Create session manager
+ manager := session.NewManager(currentDir)
+
+ // Get current branch (source branch)
+ currentBranch, err := manager.GetCurrentBranch()
+ if err != nil {
+ ui.Errorf("✗ Failed to get current branch: %v", err)
+ return
+ }
+
+ // Get all worktrees
+ worktreeManager := git.NewWorktreeManager(currentDir)
+ worktrees, err := worktreeManager.List()
+ if err != nil {
+ ui.Errorf("✗ Failed to list worktrees: %v", err)
+ return
+ }
+
+ // Filter out current directory and find target worktrees
+ var targetWorktrees []git.Worktree
+ for _, wt := range worktrees {
+ // Skip current directory and empty branches
+ if wt.Path != currentDir && wt.Branch != "" && wt.Branch != currentBranch {
+ targetWorktrees = append(targetWorktrees, wt)
+ }
+ }
+
+ if len(targetWorktrees) == 0 {
+ ui.Info("No other worktrees found to fanout to")
+ return
+ }
+
+ ui.Title("Fanout Plan")
+ ui.Infof("Source: %s (current branch)", currentBranch)
+ ui.Infof("Targets: %d worktree(s)", len(targetWorktrees))
+ fmt.Println()
+
+ // Color definitions
+ yellow := color.New(color.FgYellow, color.Bold)
+ red := color.New(color.FgRed, color.Bold)
+ green := color.New(color.FgGreen)
+
+ // Safety checks
+ var unsafeWorktrees []string
+ var safeWorktrees []git.Worktree
+
+ for _, wt := range targetWorktrees {
+ // Check 1: Uncommitted changes
+ if git.HasUncommittedChanges(wt.Path) {
+ yellow.Printf(" ● %s (%s)\n", wt.Branch, wt.Path)
+ fmt.Println(" ⚠ Has uncommitted changes - cannot fanout")
+ unsafeWorktrees = append(unsafeWorktrees, wt.Branch)
+ continue
+ }
+
+ // Check 2: Branch is ahead of current
+ diff, err := git.GetCommitCountDifference(wt.Path, currentBranch)
+ if err != nil {
+ ui.Errorf(" ✗ %s: failed to check status - %v", wt.Branch, err)
+ unsafeWorktrees = append(unsafeWorktrees, wt.Branch)
+ continue
+ }
+
+ if diff > 0 {
+ red.Printf(" ↑ %s (%s)\n", wt.Branch, wt.Path)
+ fmt.Printf(" ⚠ Ahead of %s by %d commit(s) - cannot fanout\n", currentBranch, diff)
+ unsafeWorktrees = append(unsafeWorktrees, wt.Branch)
+ continue
+ }
+
+ // Safe to fanout
+ safeWorktrees = append(safeWorktrees, wt)
+ green.Printf(" ○ %s (%s)\n", wt.Branch, wt.Path)
+ if diff < 0 {
+ fmt.Printf(" Behind by %d commit(s)\n", -diff)
+ } else {
+ fmt.Println(" Up to date")
+ }
+ }
+
+ fmt.Println()
+
+ // Check if any worktree is unsafe
+ if len(unsafeWorktrees) > 0 {
+ ui.Errorf("✗ Cannot fanout: %d worktree(s) failed safety checks", len(unsafeWorktrees))
+ ui.Info("Please fix the issues above before running fanout")
+ return
+ }
+
+ if len(safeWorktrees) == 0 {
+ ui.Info("No worktrees to fanout to")
+ return
+ }
+
+ // Confirm with user
+ ui.Title("Ready to Fanout")
+ ui.Warningf("This will rebase %d worktree(s) onto %s", len(safeWorktrees), currentBranch)
+ ui.Info("Worktrees will be preserved after successful fanout")
+ fmt.Println()
+ fmt.Print("Continue? (yes/no): ")
+
+ var confirm string
+ fmt.Scanln(&confirm)
+ if strings.ToLower(confirm) != "yes" {
+ ui.Info("Fanout cancelled")
+ return
+ }
+
+ // Perform fanout
+ ui.Title("Fanout Progress")
+ fmt.Println()
+
+ successCount := 0
+ for _, wt := range safeWorktrees {
+ ui.Infof("Rebasing %s onto %s...", wt.Branch, currentBranch)
+
+ // Perform rebase directly in the worktree
+ success, hasConflict, errMsg := rebaseWorktree(wt.Path, currentBranch)
+
+ if errMsg != nil {
+ if hasConflict {
+ ui.Errorf(" ✗ Conflict detected, auto-aborted")
+ ui.Errorf("✗ Fanout stopped at %s due to conflict", wt.Branch)
+ ui.Info("Please resolve conflicts manually before continuing")
+ return
+ }
+ ui.Errorf(" ✗ Failed: %v", errMsg)
+ ui.Errorf("✗ Fanout stopped at %s", wt.Branch)
+ return
+ }
+
+ if !success {
+ ui.Errorf(" ✗ Rebase failed")
+ return
+ }
+
+ ui.Successf(" ✓ Success")
+ successCount++
+ }
+
+ // Summary
+ fmt.Println()
+ ui.Title("Fanout Complete")
+ ui.Successf("✓ Successfully fanned out to %d worktree(s)", successCount)
+ if successCount > 0 {
+ ui.Infof("All worktrees are now synchronized with %s", currentBranch)
+ }
+}
+
+// rebaseWorktree rebases a worktree onto the specified branch
+func rebaseWorktree(worktreePath, branch string) (success, conflict bool, err error) {
+ // Perform rebase
+ rebaseCmd := exec.Command("git", "rebase", branch)
+ rebaseCmd.Dir = worktreePath
+ output, e := rebaseCmd.CombinedOutput()
+
+ if e != nil {
+ outputStr := string(output)
+ // Check if it's a conflict error
+ if strings.Contains(outputStr, "conflict") || strings.Contains(outputStr, "CONFLICT") ||
+ strings.Contains(outputStr, "Failed to merge") {
+ // Auto-abort on conflict
+ abortRebaseInWorktree(worktreePath)
+ return false, true, fmt.Errorf("rebase conflict detected, auto-aborted")
+ }
+ return false, false, fmt.Errorf("rebase failed: %w, output: %s", e, outputStr)
+ }
+
+ return true, false, nil
+}
+
+// abortRebaseInWorktree aborts an ongoing rebase in a worktree
+func abortRebaseInWorktree(worktreePath string) {
+ cmd := exec.Command("git", "rebase", "--abort")
+ cmd.Dir = worktreePath
+ _ = cmd.Run()
+}
diff --git a/cmd/rebase.go b/cmd/rebase.go
new file mode 100644
index 0000000..fcf70bb
--- /dev/null
+++ b/cmd/rebase.go
@@ -0,0 +1,258 @@
+package cmd
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/fatih/color"
+ "github.com/ksred/ccswitch/internal/git"
+ "github.com/ksred/ccswitch/internal/session"
+ "github.com/ksred/ccswitch/internal/ui"
+ "github.com/spf13/cobra"
+)
+
+func newRebaseCmd() *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "rebase [worktree-path|branch-name]",
+ Short: "Commit changes in a worktree and rebase to current branch",
+ Long: `Commit changes in a worktree and rebase it to the current branch.
+
+This allows you to quickly merge work from any worktree into your current branch.
+Works with both ccswitch sessions and manually created git worktrees.
+
+The rebase will:
+1. Prompt for a commit message
+2. Stage and commit all changes in the worktree
+3. Rebase the commit onto the current branch
+4. Automatically abort if conflicts are detected
+
+Examples:
+ ccswitch rebase # Interactive selection from all worktrees
+ ccswitch rebase /path/to/worktree # Rebase specific worktree by path
+ ccswitch rebase feature-branch # Rebase worktree by branch name`,
+ Args: cobra.MaximumNArgs(1),
+ Run: rebaseSession,
+ }
+
+ return cmd
+}
+
+func rebaseSession(cmd *cobra.Command, args []string) {
+ // Get current directory
+ currentDir, err := os.Getwd()
+ if err != nil {
+ ui.Error("✗ Failed to get current directory")
+ return
+ }
+
+ // Create session manager
+ manager := session.NewManager(currentDir)
+
+ // Get current branch (target branch for rebase)
+ currentBranch, err := manager.GetCurrentBranch()
+ if err != nil {
+ ui.Errorf("✗ Failed to get current branch: %v", err)
+ return
+ }
+
+ // Get all worktrees
+ worktreeManager := git.NewWorktreeManager(currentDir)
+ worktrees, err := worktreeManager.List()
+ if err != nil {
+ ui.Errorf("✗ Failed to list worktrees: %v", err)
+ return
+ }
+
+ if len(worktrees) == 0 {
+ ui.Info("No worktrees found")
+ return
+ }
+
+ // Determine which worktree to rebase
+ var targetWorktree *git.Worktree
+ if len(args) > 0 {
+ target := args[0]
+ // Check if it's a path
+ if filepath.IsAbs(target) || strings.HasPrefix(target, ".") || strings.HasPrefix(target, "~") {
+ // Find by path
+ for _, wt := range worktrees {
+ if wt.Path == target || strings.HasSuffix(wt.Path, target) {
+ targetWorktree = &wt
+ break
+ }
+ }
+ } else {
+ // Find by branch name
+ for _, wt := range worktrees {
+ if wt.Branch == target {
+ targetWorktree = &wt
+ break
+ }
+ }
+ }
+ if targetWorktree == nil {
+ ui.Errorf("✗ Worktree '%s' not found", target)
+ ui.Info("Available worktrees:")
+ for _, wt := range worktrees {
+ name := getWorktreeDisplayName(wt, currentDir)
+ fmt.Printf(" %s (%s)\n", name, wt.Branch)
+ fmt.Printf(" Path: %s\n", wt.Path)
+ }
+ return
+ }
+ } else {
+ // Interactive selection
+ targetWorktree = selectWorktreeForRebase(worktrees, currentDir)
+ if targetWorktree == nil {
+ return // User quit
+ }
+ }
+
+ // Skip if trying to rebase current branch onto itself
+ if targetWorktree.Branch == currentBranch {
+ ui.Errorf("✗ Cannot rebase %s onto itself", currentBranch)
+ return
+ }
+
+ displayName := getWorktreeDisplayName(*targetWorktree, currentDir)
+ ui.Infof("Rebasing %s onto %s", displayName, currentBranch)
+ fmt.Println()
+
+ // Check if worktree has uncommitted changes
+ hasChanges := git.HasUncommittedChanges(targetWorktree.Path)
+
+ if hasChanges {
+ // Has uncommitted changes - need to commit first
+ commitMessage := promptForCommitMessage()
+ if commitMessage == "" {
+ ui.Error("✗ Commit message cannot be empty")
+ return
+ }
+
+ // Perform commit and rebase
+ ui.Info("Committing changes...")
+ if err := manager.CommitAndRebaseSession(targetWorktree.Path, commitMessage); err != nil {
+ ui.Errorf("✗ Failed: %v", err)
+ return
+ }
+ } else {
+ // No uncommitted changes - just rebase existing commits
+ ui.Info("No uncommitted changes, rebasing existing commits...")
+ if err := manager.RebaseSession(targetWorktree.Path); err != nil {
+ ui.Errorf("✗ Failed: %v", err)
+ return
+ }
+ }
+
+ ui.Successf("✓ Successfully rebased %s onto %s", displayName, currentBranch)
+ ui.Infof("Worktree preserved at: %s", targetWorktree.Path)
+}
+
+func selectWorktreeForRebase(worktrees []git.Worktree, currentDir string) *git.Worktree {
+ // Filter out current directory and main worktree
+ var availableWorktrees []git.Worktree
+
+ for _, wt := range worktrees {
+ // Skip current directory and empty branches (detached HEAD)
+ if wt.Path != currentDir && wt.Branch != "" {
+ availableWorktrees = append(availableWorktrees, wt)
+ }
+ }
+
+ if len(availableWorktrees) == 0 {
+ ui.Info("No worktrees available for rebase")
+ return nil
+ }
+
+ // Get current branch for comparison
+ currentBranch, _ := git.GetCurrentBranch(currentDir)
+
+ // Color definitions
+ yellow := color.New(color.FgYellow, color.Bold)
+ green := color.New(color.FgGreen)
+ gray := color.New(color.FgHiBlack)
+
+ // Show numbered list
+ ui.Title("Select worktree to rebase:")
+ fmt.Println()
+
+ for i, wt := range availableWorktrees {
+ name := getWorktreeDisplayName(wt, currentDir)
+
+ // Determine status and color
+ var statusColor *color.Color
+ var statusIcon string
+
+ if git.HasUncommittedChanges(wt.Path) {
+ // Has uncommitted changes - Yellow
+ statusColor = yellow
+ statusIcon = "●"
+ } else if diff, err := git.GetCommitCountDifference(wt.Path, currentBranch); err == nil && diff > 0 {
+ // Ahead of current branch - Green
+ statusColor = green
+ statusIcon = "↑"
+ } else {
+ // Behind or same - Gray/Default
+ statusColor = gray
+ statusIcon = "○"
+ }
+
+ // Print with status color
+ statusColor.Printf(" %d. %s %s (%s)\n", i+1, statusIcon, name, wt.Branch)
+ fmt.Printf(" Path: %s\n", wt.Path)
+ }
+
+ fmt.Println()
+ fmt.Print("Enter number (or q to quit): ")
+
+ scanner := bufio.NewScanner(os.Stdin)
+ if !scanner.Scan() {
+ return nil
+ }
+
+ input := strings.TrimSpace(scanner.Text())
+ if input == "q" || input == "" {
+ return nil
+ }
+
+ // Parse number
+ var choice int
+ if _, err := fmt.Sscanf(input, "%d", &choice); err != nil || choice < 1 || choice > len(availableWorktrees) {
+ ui.Error("✗ Invalid selection")
+ return nil
+ }
+
+ return &availableWorktrees[choice-1]
+}
+
+// getWorktreeDisplayName returns a friendly name for the worktree
+func getWorktreeDisplayName(wt git.Worktree, currentDir string) string {
+ // Check if it's a ccswitch session
+ if strings.Contains(wt.Path, ".ccswitch/worktrees/") {
+ parts := strings.Split(wt.Path, string(filepath.Separator))
+ for i, part := range parts {
+ if part == ".ccswitch" && i+2 < len(parts) {
+ // Return just the session name
+ return parts[i+2]
+ }
+ }
+ }
+
+ // For non-ccswitch worktrees, use branch name or basename
+ if wt.Branch != "" {
+ return wt.Branch
+ }
+ return filepath.Base(wt.Path)
+}
+
+func promptForCommitMessage() string {
+ fmt.Print("Enter commit message: ")
+ scanner := bufio.NewScanner(os.Stdin)
+ if !scanner.Scan() {
+ return ""
+ }
+ return strings.TrimSpace(scanner.Text())
+}
diff --git a/cmd/root.go b/cmd/root.go
index 84f6062..d004317 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -9,7 +9,7 @@ func NewRootCmd() *cobra.Command {
rootCmd := &cobra.Command{
Use: "ccswitch",
Short: "Manage development sessions across git worktrees",
- Long: `ccswitch helps you work on multiple features simultaneously without the
+ Long: `ccswitch helps you work on multiple features simultaneously without the
context-switching overhead of stashing changes or switching branches.
Key commands:
@@ -17,8 +17,11 @@ Key commands:
ccswitch checkout Checkout an existing branch into a new worktree
ccswitch list Show and switch between sessions
ccswitch switch Switch to a specific session
+ ccswitch work Execute a command in a selected session
ccswitch cleanup Remove a session interactively
ccswitch cleanup --all Remove ALL worktrees at once (bulk cleanup)
+ ccswitch rebase Commit changes and rebase a worktree to current branch
+ ccswitch fanout Propagate current branch commits to all other worktrees
ccswitch pr Create a pull request for current session`,
Run: createSession,
}
@@ -27,7 +30,10 @@ Key commands:
rootCmd.AddCommand(newCheckoutCmd())
rootCmd.AddCommand(newListCmd())
rootCmd.AddCommand(newSwitchCmd())
+ rootCmd.AddCommand(newWorkCmd())
rootCmd.AddCommand(newCleanupCmd())
+ rootCmd.AddCommand(newRebaseCmd())
+ rootCmd.AddCommand(newFanoutCmd())
rootCmd.AddCommand(newInfoCmd())
rootCmd.AddCommand(newConfigCmd())
rootCmd.AddCommand(newPRCmd())
diff --git a/cmd/work.go b/cmd/work.go
new file mode 100644
index 0000000..eda3e11
--- /dev/null
+++ b/cmd/work.go
@@ -0,0 +1,150 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/ksred/ccswitch/internal/session"
+ "github.com/ksred/ccswitch/internal/ui"
+ "github.com/spf13/cobra"
+)
+
+func newWorkCmd() *cobra.Command {
+ return &cobra.Command{
+ Use: "work [args...]",
+ Short: "Execute a command in a selected worktree session",
+ Long: `Execute a command in a selected worktree session.
+
+This command will:
+1. Show an interactive list of all sessions
+2. Let you select which session to work in
+3. Execute the specified command in that session's directory
+
+Examples:
+ ccswitch work make build
+ ccswitch work npm test
+ ccswitch work python script.py`,
+ Args: cobra.MinimumNArgs(1),
+ Run: workCommand,
+ }
+}
+
+func workCommand(cmd *cobra.Command, args []string) {
+ // Get current directory
+ currentDir, err := os.Getwd()
+ if err != nil {
+ ui.Error("✗ Failed to get current directory")
+ return
+ }
+
+ // Create session manager
+ manager := session.NewManager(currentDir)
+
+ // Get sessions
+ sessions, err := manager.ListSessions()
+ if err != nil {
+ ui.Errorf("✗ Failed to list sessions: %v", err)
+ return
+ }
+
+ if len(sessions) == 0 {
+ ui.Info("No active sessions")
+ return
+ }
+
+ // Use interactive selector
+ selector := ui.NewSessionSelector(sessions)
+ p := tea.NewProgram(selector)
+
+ if _, err := p.Run(); err != nil {
+ ui.Errorf("✗ Failed to run selector: %v", err)
+ return
+ }
+
+ if selector.IsQuit() {
+ return
+ }
+
+ selected := selector.GetSelected()
+ if selected == nil {
+ return
+ }
+
+ // Build the command to execute
+ commandName := args[0]
+ var commandArgs []string
+ if len(args) > 1 {
+ commandArgs = args[1:]
+ }
+
+ // Execute the command in the selected session directory
+ ui.Infof("→ Executing in session '%s': %s %s", selected.Name, commandName, strings.Join(commandArgs, " "))
+ ui.Infof(" Location: %s", selected.Path)
+ fmt.Println()
+
+ err = executeInDir(selected.Path, commandName, commandArgs)
+ if err != nil {
+ ui.Errorf("✗ Command execution failed: %v", err)
+ os.Exit(1)
+ }
+}
+
+// executeInDir executes a command in the specified directory
+func executeInDir(dir, command string, args []string) error {
+ // Create the command
+ cmd := exec.Command(command, args...)
+ cmd.Dir = dir
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ // For Windows, we might need to use shell execution for some commands
+ if runtime.GOOS == "windows" {
+ // Check if this is a shell built-in or batch file
+ if isShellBuiltin(command) {
+ return executeViaShell(dir, command, args)
+ }
+ }
+
+ return cmd.Run()
+}
+
+// isShellBuiltin checks if a command is a shell built-in (Windows)
+func isShellBuiltin(command string) bool {
+ shellBuiltins := map[string]bool{
+ "dir": true, "cd": true, "echo": true, "type": true,
+ "set": true, "if": true, "for": true, "call": true,
+ }
+ return shellBuiltins[strings.ToLower(command)]
+}
+
+// executeViaShell executes a command via the system shell
+func executeViaShell(dir, command string, args []string) error {
+ var shellCmd []string
+
+ if runtime.GOOS == "windows" {
+ shellCmd = []string{"cmd", "/c"}
+ } else {
+ shellCmd = []string{"/bin/sh", "-c"}
+ }
+
+ // Build the full command string
+ fullCommand := command
+ if len(args) > 0 {
+ fullCommand += " " + strings.Join(args, " ")
+ }
+
+ shellCmd = append(shellCmd, fullCommand)
+
+ cmd := exec.Command(shellCmd[0], shellCmd[1:]...)
+ cmd.Dir = dir
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+
+ return cmd.Run()
+}
diff --git a/go.mod b/go.mod
index 908c629..0c76834 100644
--- a/go.mod
+++ b/go.mod
@@ -6,20 +6,19 @@ require (
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.5
github.com/charmbracelet/lipgloss v1.1.0
+ github.com/fatih/color v1.18.0
github.com/spf13/cobra v1.9.1
- github.com/stretchr/testify v1.10.0
+ golang.org/x/text v0.3.8
+ gopkg.in/yaml.v3 v3.0.1
)
require (
- github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // 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/term v0.2.1 // indirect
- github.com/davecgh/go-spew v1.1.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
- github.com/fatih/color v1.18.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
@@ -29,14 +28,9 @@ require (
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/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
- github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
- github.com/stretchr/objx v0.5.2 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
- golang.org/x/text v0.3.8 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index b43b0da..877ae2e 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,5 @@
-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.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
-github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=
@@ -16,21 +12,15 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll
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/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/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
-github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@@ -48,22 +38,14 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
-github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
diff --git a/internal/git/commit.go b/internal/git/commit.go
new file mode 100644
index 0000000..e5d529c
--- /dev/null
+++ b/internal/git/commit.go
@@ -0,0 +1,58 @@
+package git
+
+import (
+ "fmt"
+ "os/exec"
+ "strings"
+)
+
+// CommitManager handles git commit operations
+type CommitManager struct {
+ repoPath string
+}
+
+// NewCommitManager creates a new CommitManager
+func NewCommitManager(repoPath string) *CommitManager {
+ return &CommitManager{repoPath: repoPath}
+}
+
+// HasChanges checks if there are uncommitted changes
+func (cm *CommitManager) HasChanges() bool {
+ cmd := exec.Command("git", "status", "--porcelain")
+ cmd.Dir = cm.repoPath
+ output, err := cmd.CombinedOutput()
+ return err == nil && strings.TrimSpace(string(output)) != ""
+}
+
+// StageAll stages all changes
+func (cm *CommitManager) StageAll() error {
+ cmd := exec.Command("git", "add", "-A")
+ cmd.Dir = cm.repoPath
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("failed to stage changes: %w, output: %s", err, string(output))
+ }
+ return nil
+}
+
+// Commit creates a commit with the given message
+func (cm *CommitManager) Commit(message string) error {
+ cmd := exec.Command("git", "commit", "-m", message)
+ cmd.Dir = cm.repoPath
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("failed to commit: %w, output: %s", err, string(output))
+ }
+ return nil
+}
+
+// GetLastCommitHash returns the hash of the last commit
+func (cm *CommitManager) GetLastCommitHash() (string, error) {
+ cmd := exec.Command("git", "rev-parse", "HEAD")
+ cmd.Dir = cm.repoPath
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("failed to get last commit: %w", err)
+ }
+ return strings.TrimSpace(string(output)), nil
+}
diff --git a/internal/git/rebase.go b/internal/git/rebase.go
new file mode 100644
index 0000000..3bddf3c
--- /dev/null
+++ b/internal/git/rebase.go
@@ -0,0 +1,51 @@
+package git
+
+import (
+ "fmt"
+ "os/exec"
+ "strings"
+)
+
+// RebaseManager handles git rebase operations
+type RebaseManager struct {
+ repoPath string
+}
+
+// NewRebaseManager creates a new RebaseManager
+func NewRebaseManager(repoPath string) *RebaseManager {
+ return &RebaseManager{repoPath: repoPath}
+}
+
+// RebaseCommit rebases a specific commit onto the current branch
+// Returns (success, conflictDetected, error)
+func (rm *RebaseManager) RebaseCommit(commitHash string) (bool, bool, error) {
+ // Perform rebase
+ rebaseCmd := exec.Command("git", "rebase", commitHash)
+ rebaseCmd.Dir = rm.repoPath
+ output, err := rebaseCmd.CombinedOutput()
+
+ if err != nil {
+ outputStr := string(output)
+ // Check if it's a conflict error
+ if strings.Contains(outputStr, "conflict") || strings.Contains(outputStr, "CONFLICT") ||
+ strings.Contains(outputStr, "Failed to merge") {
+ // Auto-abort on conflict
+ _ = rm.AbortRebase()
+ return false, true, fmt.Errorf("rebase conflict detected, auto-aborted")
+ }
+ return false, false, fmt.Errorf("rebase failed: %w, output: %s", err, outputStr)
+ }
+
+ return true, false, nil
+}
+
+// AbortRebase aborts the current rebase
+func (rm *RebaseManager) AbortRebase() error {
+ cmd := exec.Command("git", "rebase", "--abort")
+ cmd.Dir = rm.repoPath
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("failed to abort rebase: %w, output: %s", err, string(output))
+ }
+ return nil
+}
diff --git a/internal/git/repository.go b/internal/git/repository.go
index ac6320b..9716056 100644
--- a/internal/git/repository.go
+++ b/internal/git/repository.go
@@ -1,6 +1,7 @@
package git
import (
+ "fmt"
"os"
"os/exec"
"path/filepath"
@@ -84,3 +85,47 @@ func GetCurrentBranch(dir string) (string, error) {
}
return strings.TrimSpace(string(output)), nil
}
+
+// HasUncommittedChanges checks if a worktree has uncommitted changes
+func HasUncommittedChanges(dir string) bool {
+ cmd := exec.Command("git", "status", "--porcelain")
+ cmd.Dir = dir
+ output, err := cmd.CombinedOutput()
+ return err == nil && strings.TrimSpace(string(output)) != ""
+}
+
+// GetCommitCountDifference returns the number of commits the worktree branch
+// is ahead (+) or behind (-) relative to the base branch.
+// Positive values = ahead, Negative = behind, Zero = same
+func GetCommitCountDifference(worktreePath, baseBranch string) (int, error) {
+ // Get ahead count: commits in worktree that are not in baseBranch
+ aheadCmd := exec.Command("git", "rev-list", "--count", baseBranch+"..HEAD")
+ aheadCmd.Dir = worktreePath
+ aheadOutput, err := aheadCmd.CombinedOutput()
+ if err != nil {
+ return 0, err
+ }
+ ahead := strings.TrimSpace(string(aheadOutput))
+
+ // Get behind count: commits in baseBranch that are not in worktree
+ behindCmd := exec.Command("git", "rev-list", "--count", "HEAD.."+baseBranch)
+ behindCmd.Dir = worktreePath
+ behindOutput, err := behindCmd.CombinedOutput()
+ if err != nil {
+ return 0, err
+ }
+ behind := strings.TrimSpace(string(behindOutput))
+
+ // Parse counts (default to 0 if empty)
+ aheadCount := 0
+ behindCount := 0
+ if ahead != "" {
+ fmt.Sscanf(ahead, "%d", &aheadCount)
+ }
+ if behind != "" {
+ fmt.Sscanf(behind, "%d", &behindCount)
+ }
+
+ // Return net difference (positive = ahead, negative = behind)
+ return aheadCount - behindCount, nil
+}
diff --git a/internal/git/worktree.go b/internal/git/worktree.go
index 8f154fc..36f6d83 100644
--- a/internal/git/worktree.go
+++ b/internal/git/worktree.go
@@ -104,9 +104,11 @@ func GetSessionsFromWorktrees(worktrees []Worktree, repoName string) []SessionIn
// The pattern should match worktrees that belong to this repository
for _, wt := range worktrees {
// Check if it's a ccswitch worktree and extract the repo name from path
- if strings.Contains(wt.Path, ".ccswitch/worktrees/") && wt.Branch != "" {
- // Extract repo name from path to ensure we only show worktrees for current repo
- parts := strings.Split(wt.Path, string(filepath.Separator))
+ // Use both / and \ for cross-platform compatibility
+ if (strings.Contains(wt.Path, ".ccswitch/worktrees/") || strings.Contains(wt.Path, ".ccswitch\\worktrees\\")) && wt.Branch != "" {
+ // Normalize path separators to / for consistent parsing
+ normalizedPath := strings.ReplaceAll(wt.Path, "\\", "/")
+ parts := strings.Split(normalizedPath, "/")
for i, part := range parts {
if part == ".ccswitch" && i+2 < len(parts) && parts[i+1] == "worktrees" {
if i+2 < len(parts) && parts[i+2] == repoName {
diff --git a/internal/session/manager.go b/internal/session/manager.go
index 71a18cd..678262f 100644
--- a/internal/session/manager.go
+++ b/internal/session/manager.go
@@ -183,3 +183,76 @@ func (m *Manager) GetSessionPath(sessionName string) string {
homeDir, _ := os.UserHomeDir()
return filepath.Join(homeDir, ".ccswitch", "worktrees", m.repoName, sessionName)
}
+
+// CommitAndRebaseSession commits changes in a session and rebases to current branch
+func (m *Manager) CommitAndRebaseSession(sessionPath, commitMessage string) error {
+ // 1. Check for changes in the session
+ commitManager := git.NewCommitManager(sessionPath)
+ if !commitManager.HasChanges() {
+ return fmt.Errorf("no changes to commit in session")
+ }
+
+ // 2. Stage all changes
+ if err := commitManager.StageAll(); err != nil {
+ return fmt.Errorf("failed to stage changes: %w", err)
+ }
+
+ // 3. Commit changes
+ if err := commitManager.Commit(commitMessage); err != nil {
+ return fmt.Errorf("failed to commit: %w", err)
+ }
+
+ // 4. Get the commit hash
+ commitHash, err := commitManager.GetLastCommitHash()
+ if err != nil {
+ return fmt.Errorf("failed to get commit hash: %w", err)
+ }
+
+ // 5. Rebase to current branch (from main repo path)
+ rebaseManager := git.NewRebaseManager(m.repoPath)
+ success, hasConflict, err := rebaseManager.RebaseCommit(commitHash)
+
+ if err != nil {
+ if hasConflict {
+ return fmt.Errorf("rebase aborted due to conflicts: %w", err)
+ }
+ return err
+ }
+
+ if !success {
+ return fmt.Errorf("rebase failed")
+ }
+
+ return nil
+}
+
+// RebaseSession rebases a worktree's branch onto the current branch without committing
+func (m *Manager) RebaseSession(worktreePath string) error {
+ // Get the current branch name from the worktree
+ worktreeBranch, err := git.GetCurrentBranch(worktreePath)
+ if err != nil {
+ return fmt.Errorf("failed to get worktree branch: %w", err)
+ }
+
+ // Rebase the worktree branch onto current branch using rebase manager
+ rebaseManager := git.NewRebaseManager(m.repoPath)
+ success, hasConflict, err := rebaseManager.RebaseCommit(worktreeBranch)
+
+ if err != nil {
+ if hasConflict {
+ return fmt.Errorf("rebase aborted due to conflicts: %w", err)
+ }
+ return err
+ }
+
+ if !success {
+ return fmt.Errorf("rebase failed")
+ }
+
+ return nil
+}
+
+// GetCurrentBranch returns the current branch of the main repo
+func (m *Manager) GetCurrentBranch() (string, error) {
+ return m.branchManager.GetCurrent()
+}