1use std::path::{Path, PathBuf};
17use std::process::Command;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Worktree {
22 pub path: PathBuf,
23 pub branch: String,
24}
25
26pub fn path_for(task_id: &str) -> PathBuf {
28 crate::helpers::dirs_home()
29 .join(".claudectl")
30 .join("worktrees")
31 .join(task_id)
32}
33
34pub fn default_branch(task_name: &str, task_id: &str) -> String {
37 let mut name: String = task_name
38 .chars()
39 .map(|c| {
40 if c.is_ascii_alphanumeric() {
41 c.to_ascii_lowercase()
42 } else {
43 '-'
44 }
45 })
46 .collect();
47 while name.contains("--") {
48 name = name.replace("--", "-");
49 }
50 let name = name.trim_matches('-');
51 let name = if name.is_empty() { "task" } else { name };
52 let body = task_id.split_once('_').map_or(task_id, |(_, rest)| rest);
54 let short: String = body
55 .chars()
56 .rev()
57 .filter(|c| c.is_ascii_alphanumeric())
58 .take(8)
59 .collect::<Vec<_>>()
60 .into_iter()
61 .rev()
62 .collect();
63 format!("claudectl/{}-{short}", &name[..name.len().min(40)])
64}
65
66pub fn validate_ref(what: &str, value: &str) -> Result<(), String> {
69 let ok = !value.is_empty()
70 && value.len() <= 200
71 && !value.starts_with('-')
72 && value.chars().all(|c| {
73 c.is_ascii_graphic() && !matches!(c, '~' | '^' | ':' | '?' | '*' | '[' | '\\')
74 })
75 && !value.contains("..");
76 if ok {
77 Ok(())
78 } else {
79 Err(format!("{what} '{value}' is not a usable git ref"))
80 }
81}
82
83fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
84 let out = Command::new("git")
85 .arg("-C")
86 .arg(dir)
87 .args(args)
88 .output()
89 .map_err(|e| format!("git: {e}"))?;
90 if out.status.success() {
91 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
92 } else {
93 Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
94 }
95}
96
97pub fn repo_root(cwd: &Path) -> Result<PathBuf, String> {
99 git(cwd, &["rev-parse", "--show-toplevel"])
100 .map(PathBuf::from)
101 .map_err(|e| format!("{} is not in a git repo: {e}", cwd.display()))
102}
103
104fn same_dir(a: &Path, b: &Path) -> bool {
105 match (a.canonicalize(), b.canonicalize()) {
106 (Ok(x), Ok(y)) => x == y,
107 _ => false,
108 }
109}
110
111pub fn ensure(cwd: &Path, git_ref: &str, branch: &str, path: &Path) -> Result<Worktree, String> {
114 validate_ref("git_ref", git_ref)?;
115 validate_ref("branch", branch)?;
116 let root = repo_root(cwd)?;
117 let found = Worktree {
118 path: path.to_path_buf(),
119 branch: branch.to_string(),
120 };
121
122 if path.exists() {
123 let top = git(path, &["rev-parse", "--show-toplevel"]).ok();
125 let common = git(path, &["rev-parse", "--git-common-dir"]).ok();
126 let root_common = git(&root, &["rev-parse", "--git-common-dir"]).ok();
127 let same_repo = match (&common, &root_common) {
128 (Some(a), Some(b)) => {
129 same_dir(&path.join(a), &root.join(b)) || same_dir(Path::new(a), Path::new(b))
130 }
131 _ => false,
132 };
133 if top.as_deref().is_some_and(|t| same_dir(Path::new(t), path)) && same_repo {
134 return Ok(found);
135 }
136 return Err(format!(
137 "{} exists and is not this task's worktree; not touching it",
138 path.display()
139 ));
140 }
141 if let Some(parent) = path.parent() {
142 std::fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
143 }
144
145 let path_s = path.to_string_lossy().to_string();
146 let branch_exists = git(
147 &root,
148 &[
149 "rev-parse",
150 "--verify",
151 "--quiet",
152 &format!("refs/heads/{branch}"),
153 ],
154 )
155 .is_ok();
156 let result = if branch_exists {
157 git(&root, &["worktree", "add", &path_s, branch])
159 } else {
160 git(&root, &["worktree", "add", "-b", branch, &path_s, git_ref])
161 };
162 result.map_err(|e| format!("git worktree add for {branch} at {git_ref}: {e}"))?;
163 Ok(found)
164}
165
166#[derive(Debug, PartialEq, Eq)]
168pub enum Cleanup {
169 Removed,
170 KeptDirty,
172 Missing,
173}
174
175pub fn cleanup(path: &Path) -> Result<Cleanup, String> {
178 if !path.exists() {
179 return Ok(Cleanup::Missing);
180 }
181 let dirty = git(path, &["status", "--porcelain"])?;
182 if !dirty.is_empty() {
183 return Ok(Cleanup::KeptDirty);
184 }
185 let root = git(path, &["rev-parse", "--git-common-dir"])?;
186 let main = Path::new(&root)
187 .parent()
188 .map(Path::to_path_buf)
189 .unwrap_or_else(|| path.to_path_buf());
190 let main = if main.is_relative() {
191 path.join(main)
192 } else {
193 main
194 };
195 git(&main, &["worktree", "remove", &path.to_string_lossy()])?;
196 Ok(Cleanup::Removed)
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 fn scratch(tag: &str) -> PathBuf {
204 let d = std::env::temp_dir().join(format!("cctl-wt-{tag}-{}", std::process::id()));
205 let _ = std::fs::remove_dir_all(&d);
206 std::fs::create_dir_all(&d).unwrap();
207 d
208 }
209
210 fn repo(tag: &str) -> PathBuf {
211 let d = scratch(tag).join("repo");
212 std::fs::create_dir_all(&d).unwrap();
213 for args in [
214 vec!["init", "-q", "-b", "main"],
215 vec!["config", "user.email", "t@example.com"],
216 vec!["config", "user.name", "t"],
217 ] {
218 git(&d, &args).unwrap();
219 }
220 std::fs::write(d.join("README"), "hi\n").unwrap();
221 git(&d, &["add", "."]).unwrap();
222 git(&d, &["commit", "-q", "-m", "init"]).unwrap();
223 d
224 }
225
226 #[test]
227 fn a_worktree_is_created_on_its_branch_from_the_ref() {
228 let r = repo("create");
229 let wt = r.parent().unwrap().join("wt");
230 let got = ensure(&r, "main", "claudectl/t-1", &wt).unwrap();
231 assert_eq!(got.path, wt);
232 assert!(wt.join("README").exists());
233 assert_eq!(
234 git(&wt, &["branch", "--show-current"]).unwrap(),
235 "claudectl/t-1"
236 );
237 assert_eq!(git(&r, &["branch", "--show-current"]).unwrap(), "main");
239 }
240
241 #[test]
242 fn an_existing_worktree_is_reused() {
243 let r = repo("reuse");
244 let wt = r.parent().unwrap().join("wt");
245 ensure(&r, "main", "claudectl/t-2", &wt).unwrap();
246 std::fs::write(wt.join("work.txt"), "in progress").unwrap();
247 ensure(&r, "main", "claudectl/t-2", &wt).unwrap();
248 assert!(
249 wt.join("work.txt").exists(),
250 "reuse must not reset the worktree"
251 );
252 }
253
254 #[test]
255 fn a_removed_worktree_comes_back_on_its_existing_branch() {
256 let r = repo("again");
257 let wt = r.parent().unwrap().join("wt");
258 ensure(&r, "main", "claudectl/t-3", &wt).unwrap();
259 std::fs::write(wt.join("f"), "x").unwrap();
260 git(&wt, &["add", "f"]).unwrap();
261 git(&wt, &["commit", "-q", "-m", "work"]).unwrap();
262 assert_eq!(cleanup(&wt).unwrap(), Cleanup::Removed);
263 ensure(&r, "main", "claudectl/t-3", &wt).unwrap();
264 assert!(
265 wt.join("f").exists(),
266 "the branch's commit should be checked out"
267 );
268 }
269
270 #[test]
271 fn a_directory_that_isnt_ours_is_left_alone() {
272 let r = repo("foreign");
273 let wt = r.parent().unwrap().join("wt");
274 std::fs::create_dir_all(&wt).unwrap();
275 std::fs::write(wt.join("precious"), "x").unwrap();
276 assert!(ensure(&r, "main", "claudectl/t-4", &wt).is_err());
277 assert!(wt.join("precious").exists());
278 }
279
280 #[test]
281 fn cleanup_keeps_a_worktree_with_uncommitted_work() {
282 let r = repo("dirty");
283 let wt = r.parent().unwrap().join("wt");
284 ensure(&r, "main", "claudectl/t-5", &wt).unwrap();
285 std::fs::write(wt.join("HELLO.txt"), "ok").unwrap();
286 assert_eq!(cleanup(&wt).unwrap(), Cleanup::KeptDirty);
287 assert!(wt.join("HELLO.txt").exists());
288 }
289
290 #[test]
291 fn cleanup_removes_a_clean_worktree_and_keeps_the_branch() {
292 let r = repo("clean");
293 let wt = r.parent().unwrap().join("wt");
294 ensure(&r, "main", "claudectl/t-6", &wt).unwrap();
295 assert_eq!(cleanup(&wt).unwrap(), Cleanup::Removed);
296 assert!(!wt.exists());
297 assert!(git(&r, &["rev-parse", "--verify", "refs/heads/claudectl/t-6"]).is_ok());
298 assert_eq!(cleanup(&wt).unwrap(), Cleanup::Missing);
299 }
300
301 #[test]
302 fn a_ref_on_a_non_repo_is_an_error_not_a_fallback() {
303 let d = scratch("norepo");
304 assert!(ensure(&d, "main", "claudectl/t-7", &d.join("wt")).is_err());
305 }
306
307 #[test]
308 fn refs_that_could_be_options_or_odd_syntax_are_refused() {
309 for bad in ["-x", "--force", "a b", "a..b", "a~1", "", "x:y"] {
310 assert!(validate_ref("git_ref", bad).is_err(), "{bad}");
311 }
312 for good in [
313 "main",
314 "origin/main",
315 "v1.2.3",
316 "claudectl/t-1",
317 "abc123def",
318 ] {
319 assert!(validate_ref("git_ref", good).is_ok(), "{good}");
320 }
321 }
322
323 #[test]
324 fn default_branches_are_tidy_and_unique_per_task() {
325 assert_eq!(
326 default_branch("Fix the login bug!", "task_1792_77_3"),
327 "claudectl/fix-the-login-bug-1792773"
328 );
329 assert_eq!(default_branch("***", "t1"), "claudectl/task-t1");
330 assert!(validate_ref("branch", &default_branch("a/b c", "task_9")).is_ok());
331 }
332}