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() +}