use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Worktree {
pub path: PathBuf,
pub branch: String,
}
pub fn path_for(task_id: &str) -> PathBuf {
crate::helpers::dirs_home()
.join(".claudectl")
.join("worktrees")
.join(task_id)
}
pub fn default_branch(task_name: &str, task_id: &str) -> String {
let mut name: String = task_name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
while name.contains("--") {
name = name.replace("--", "-");
}
let name = name.trim_matches('-');
let name = if name.is_empty() { "task" } else { name };
let body = task_id.split_once('_').map_or(task_id, |(_, rest)| rest);
let short: String = body
.chars()
.rev()
.filter(|c| c.is_ascii_alphanumeric())
.take(8)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("claudectl/{}-{short}", &name[..name.len().min(40)])
}
pub fn validate_ref(what: &str, value: &str) -> Result<(), String> {
let ok = !value.is_empty()
&& value.len() <= 200
&& !value.starts_with('-')
&& value.chars().all(|c| {
c.is_ascii_graphic() && !matches!(c, '~' | '^' | ':' | '?' | '*' | '[' | '\\')
})
&& !value.contains("..");
if ok {
Ok(())
} else {
Err(format!("{what} '{value}' is not a usable git ref"))
}
}
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
let out = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.map_err(|e| format!("git: {e}"))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
pub fn repo_root(cwd: &Path) -> Result<PathBuf, String> {
git(cwd, &["rev-parse", "--show-toplevel"])
.map(PathBuf::from)
.map_err(|e| format!("{} is not in a git repo: {e}", cwd.display()))
}
fn same_dir(a: &Path, b: &Path) -> bool {
match (a.canonicalize(), b.canonicalize()) {
(Ok(x), Ok(y)) => x == y,
_ => false,
}
}
pub fn ensure(cwd: &Path, git_ref: &str, branch: &str, path: &Path) -> Result<Worktree, String> {
validate_ref("git_ref", git_ref)?;
validate_ref("branch", branch)?;
let root = repo_root(cwd)?;
let found = Worktree {
path: path.to_path_buf(),
branch: branch.to_string(),
};
if path.exists() {
let top = git(path, &["rev-parse", "--show-toplevel"]).ok();
let common = git(path, &["rev-parse", "--git-common-dir"]).ok();
let root_common = git(&root, &["rev-parse", "--git-common-dir"]).ok();
let same_repo = match (&common, &root_common) {
(Some(a), Some(b)) => {
same_dir(&path.join(a), &root.join(b)) || same_dir(Path::new(a), Path::new(b))
}
_ => false,
};
if top.as_deref().is_some_and(|t| same_dir(Path::new(t), path)) && same_repo {
return Ok(found);
}
return Err(format!(
"{} exists and is not this task's worktree; not touching it",
path.display()
));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
}
let path_s = path.to_string_lossy().to_string();
let branch_exists = git(
&root,
&[
"rev-parse",
"--verify",
"--quiet",
&format!("refs/heads/{branch}"),
],
)
.is_ok();
let result = if branch_exists {
git(&root, &["worktree", "add", &path_s, branch])
} else {
git(&root, &["worktree", "add", "-b", branch, &path_s, git_ref])
};
result.map_err(|e| format!("git worktree add for {branch} at {git_ref}: {e}"))?;
Ok(found)
}
#[derive(Debug, PartialEq, Eq)]
pub enum Cleanup {
Removed,
KeptDirty,
Missing,
}
pub fn cleanup(path: &Path) -> Result<Cleanup, String> {
if !path.exists() {
return Ok(Cleanup::Missing);
}
let dirty = git(path, &["status", "--porcelain"])?;
if !dirty.is_empty() {
return Ok(Cleanup::KeptDirty);
}
let root = git(path, &["rev-parse", "--git-common-dir"])?;
let main = Path::new(&root)
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| path.to_path_buf());
let main = if main.is_relative() {
path.join(main)
} else {
main
};
git(&main, &["worktree", "remove", &path.to_string_lossy()])?;
Ok(Cleanup::Removed)
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("cctl-wt-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn repo(tag: &str) -> PathBuf {
let d = scratch(tag).join("repo");
std::fs::create_dir_all(&d).unwrap();
for args in [
vec!["init", "-q", "-b", "main"],
vec!["config", "user.email", "t@example.com"],
vec!["config", "user.name", "t"],
] {
git(&d, &args).unwrap();
}
std::fs::write(d.join("README"), "hi\n").unwrap();
git(&d, &["add", "."]).unwrap();
git(&d, &["commit", "-q", "-m", "init"]).unwrap();
d
}
#[test]
fn a_worktree_is_created_on_its_branch_from_the_ref() {
let r = repo("create");
let wt = r.parent().unwrap().join("wt");
let got = ensure(&r, "main", "claudectl/t-1", &wt).unwrap();
assert_eq!(got.path, wt);
assert!(wt.join("README").exists());
assert_eq!(
git(&wt, &["branch", "--show-current"]).unwrap(),
"claudectl/t-1"
);
assert_eq!(git(&r, &["branch", "--show-current"]).unwrap(), "main");
}
#[test]
fn an_existing_worktree_is_reused() {
let r = repo("reuse");
let wt = r.parent().unwrap().join("wt");
ensure(&r, "main", "claudectl/t-2", &wt).unwrap();
std::fs::write(wt.join("work.txt"), "in progress").unwrap();
ensure(&r, "main", "claudectl/t-2", &wt).unwrap();
assert!(
wt.join("work.txt").exists(),
"reuse must not reset the worktree"
);
}
#[test]
fn a_removed_worktree_comes_back_on_its_existing_branch() {
let r = repo("again");
let wt = r.parent().unwrap().join("wt");
ensure(&r, "main", "claudectl/t-3", &wt).unwrap();
std::fs::write(wt.join("f"), "x").unwrap();
git(&wt, &["add", "f"]).unwrap();
git(&wt, &["commit", "-q", "-m", "work"]).unwrap();
assert_eq!(cleanup(&wt).unwrap(), Cleanup::Removed);
ensure(&r, "main", "claudectl/t-3", &wt).unwrap();
assert!(
wt.join("f").exists(),
"the branch's commit should be checked out"
);
}
#[test]
fn a_directory_that_isnt_ours_is_left_alone() {
let r = repo("foreign");
let wt = r.parent().unwrap().join("wt");
std::fs::create_dir_all(&wt).unwrap();
std::fs::write(wt.join("precious"), "x").unwrap();
assert!(ensure(&r, "main", "claudectl/t-4", &wt).is_err());
assert!(wt.join("precious").exists());
}
#[test]
fn cleanup_keeps_a_worktree_with_uncommitted_work() {
let r = repo("dirty");
let wt = r.parent().unwrap().join("wt");
ensure(&r, "main", "claudectl/t-5", &wt).unwrap();
std::fs::write(wt.join("HELLO.txt"), "ok").unwrap();
assert_eq!(cleanup(&wt).unwrap(), Cleanup::KeptDirty);
assert!(wt.join("HELLO.txt").exists());
}
#[test]
fn cleanup_removes_a_clean_worktree_and_keeps_the_branch() {
let r = repo("clean");
let wt = r.parent().unwrap().join("wt");
ensure(&r, "main", "claudectl/t-6", &wt).unwrap();
assert_eq!(cleanup(&wt).unwrap(), Cleanup::Removed);
assert!(!wt.exists());
assert!(git(&r, &["rev-parse", "--verify", "refs/heads/claudectl/t-6"]).is_ok());
assert_eq!(cleanup(&wt).unwrap(), Cleanup::Missing);
}
#[test]
fn a_ref_on_a_non_repo_is_an_error_not_a_fallback() {
let d = scratch("norepo");
assert!(ensure(&d, "main", "claudectl/t-7", &d.join("wt")).is_err());
}
#[test]
fn refs_that_could_be_options_or_odd_syntax_are_refused() {
for bad in ["-x", "--force", "a b", "a..b", "a~1", "", "x:y"] {
assert!(validate_ref("git_ref", bad).is_err(), "{bad}");
}
for good in [
"main",
"origin/main",
"v1.2.3",
"claudectl/t-1",
"abc123def",
] {
assert!(validate_ref("git_ref", good).is_ok(), "{good}");
}
}
#[test]
fn default_branches_are_tidy_and_unique_per_task() {
assert_eq!(
default_branch("Fix the login bug!", "task_1792_77_3"),
"claudectl/fix-the-login-bug-1792773"
);
assert_eq!(default_branch("***", "t1"), "claudectl/task-t1");
assert!(validate_ref("branch", &default_branch("a/b c", "task_9")).is_ok());
}
}