use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
pub fn worktree_path_for(workspace: &Path, name: &str) -> PathBuf {
workspace
.join(".claude")
.join("worktrees")
.join(name)
}
pub fn is_git_repo(workspace: &Path) -> bool {
Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(workspace)
.output()
.ok()
.map_or(false, |o| o.status.success())
}
pub fn branch_exists(workspace: &Path, branch: &str) -> bool {
if Command::new("git")
.args(["show-ref", "--verify", "--quiet", &format!("refs/heads/{branch}")])
.current_dir(workspace)
.status()
.ok()
.map_or(false, |s| s.success())
{
return true;
}
Command::new("git")
.args(["ls-remote", "--exit-code", "origin", &format!("refs/heads/{branch}")])
.current_dir(workspace)
.status()
.ok()
.map_or(false, |s| s.success())
}
pub fn ensure_worktree_gitignore(workspace: &Path) {
let gitignore_path = workspace.join(".gitignore");
let pattern = "/.claude/worktrees/";
let contents = match std::fs::read_to_string(&gitignore_path) {
Ok(c) => c,
Err(_) => {
let _ = std::fs::write(&gitignore_path, format!("{pattern}\n"));
return;
}
};
if contents.lines().any(|l| {
let t = l.trim();
t == pattern || t == "/.claude/worktrees" || t == ".claude/worktrees/"
}) {
return;
}
if let Ok(mut file) = std::fs::OpenOptions::new().append(true).open(&gitignore_path) {
use std::io::Write;
let _ = writeln!(file, "{pattern}");
}
}
pub fn create_worktree(workspace: &Path, name: &str, branch: &str) -> Result<PathBuf> {
anyhow::ensure!(
is_git_repo(workspace),
"not a git repository — worktrees require git.\n\
Use `git init` or `git clone` first."
);
let wt_path = worktree_path_for(workspace, name);
anyhow::ensure!(
!wt_path.exists(),
"worktree for session '{}' already exists at {}\n\
Use `ccsm resume {}` to continue.",
name, wt_path.display(), name,
);
let is_ancestor = Command::new("git")
.args(["merge-base", "--is-ancestor", "origin/main", "HEAD"])
.current_dir(workspace)
.status()
.ok()
.map_or(false, |s| s.success());
if !is_ancestor {
let remote_reachable = Command::new("git")
.args(["ls-remote", "--exit-code", "origin", "HEAD"])
.current_dir(workspace)
.status()
.ok()
.map_or(false, |s| s.success());
if remote_reachable {
let fetch_ok = Command::new("git")
.args(["fetch", "origin", "main"])
.current_dir(workspace)
.output()
.ok()
.map(|o| o.status.success())
.unwrap_or(false);
if fetch_ok {
let behind = Command::new("git")
.args(["rev-list", "--count", "--left-right", &format!("{branch}...origin/main")])
.current_dir(workspace)
.output()
.ok()
.and_then(|o| {
let s = String::from_utf8_lossy(&o.stdout);
let parts: Vec<&str> = s.trim().split('\t').collect();
parts.get(1).and_then(|v| v.trim().parse::<i64>().ok())
})
.unwrap_or(0);
if behind > 0 {
eprintln!(" branch '{branch}' is {behind} commit(s) behind origin/main — rebasing...");
let is_dirty = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(workspace)
.output()
.ok()
.map(|o| !o.stdout.is_empty())
.unwrap_or(false);
let stashed = if is_dirty {
anyhow::bail!(
"branch '{}' has uncommitted changes. Commit or stash first, then run `ccsm start {}` again.",
branch, name,
);
} else {
false
};
let rebase_ok = Command::new("git")
.args(["rebase", "origin/main"])
.current_dir(workspace)
.output()
.ok()
.map(|o| o.status.success())
.unwrap_or(false);
if !rebase_ok {
let _ = Command::new("git").args(["rebase", "--abort"]).current_dir(workspace).output();
if stashed {
let _ = Command::new("git").args(["stash", "pop"]).current_dir(workspace).output();
}
anyhow::bail!("failed to rebase '{}' onto origin/main.\nResolve conflicts manually, then run `ccsm start` again.", branch);
}
eprintln!(" rebase complete — '{branch}' is now up-to-date with main");
if stashed {
if let Some(parent) = wt_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating worktree parent: {}", parent.display()))?;
}
let add_ok = Command::new("git")
.args(["worktree", "add", &wt_path.to_string_lossy(), branch])
.current_dir(workspace)
.output()
.ok()
.map(|o| o.status.success())
.unwrap_or(false);
if !add_ok {
let _ = std::fs::remove_dir_all(&wt_path);
let _ = Command::new("git").args(["stash", "pop"]).current_dir(workspace).output();
anyhow::bail!("failed to create worktree after rebase.");
}
eprintln!(" restoring stashed changes into worktree...");
let pop_ok = Command::new("git")
.args(["stash", "pop"])
.current_dir(&wt_path)
.output()
.ok()
.map(|o| o.status.success())
.unwrap_or(false);
if !pop_ok {
eprintln!(" warning: stash had conflicts in worktree. Resolve manually (git stash list).");
} else {
eprintln!(" changes moved to worktree successfully");
}
ensure_worktree_gitignore(workspace);
return Ok(wt_path);
}
}
} else {
eprintln!(" warning: fetch failed — worktree may be stale");
}
}
}
if let Some(parent) = wt_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating worktree parent: {}", parent.display()))?;
}
let branch_exists = Command::new("git")
.args(["show-ref", "--verify", "--quiet", &format!("refs/heads/{branch}")])
.current_dir(workspace)
.status()
.ok()
.map_or(false, |s| s.success());
let result = if branch_exists {
Command::new("git")
.args(["worktree", "add", &wt_path.to_string_lossy(), branch])
.current_dir(workspace)
.output()
.context("failed to run `git worktree add`")?
} else {
eprintln!(" creating new branch '{}' from HEAD...", branch);
Command::new("git")
.args(["worktree", "add", "-b", branch, &wt_path.to_string_lossy(), "HEAD"])
.current_dir(workspace)
.output()
.context("failed to run `git worktree add -b`")?
};
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
let _ = std::fs::remove_dir_all(&wt_path);
anyhow::bail!(
"git worktree add failed for branch '{}' at {}:\n{}",
branch, wt_path.display(), stderr.trim(),
);
}
ensure_worktree_gitignore(workspace);
Ok(wt_path)
}
pub fn remove_worktree(workspace: &Path, name: &str, force: bool) -> Result<()> {
let wt_path = worktree_path_for(workspace, name);
if !wt_path.exists() {
return Ok(()); }
let wt_str = wt_path.to_string_lossy().to_string();
let mut args = vec!["worktree", "remove"];
if force {
args.push("--force");
}
args.push(&wt_str);
let result = Command::new("git")
.args(&args)
.current_dir(workspace)
.output()
.with_context(|| format!("failed to run `git worktree remove` for '{}'", name))?;
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
anyhow::bail!(
"failed to remove worktree for '{}' at {}:\n{}\n\
Use `ccsm complete {} --force` to force remove.",
name, wt_path.display(), stderr.trim(), name,
);
}
if wt_path.exists() {
let _ = std::fs::remove_dir_all(&wt_path);
}
Ok(())
}