Skip to main content

claudectl_core/
worktree.rs

1//! A git worktree per supervisor task (#570).
2//!
3//! A task with a `git_ref` runs in its own worktree on its own branch, so
4//! parallel tasks over one repo never edit the same checkout. The worktree
5//! lives under `~/.claudectl/worktrees/<task-id>`, outside the repo, so it
6//! never shows up in that repo's `git status`.
7//!
8//! - **Reused, not recreated.** A retry or a resume of the same task finds
9//!   its worktree and keeps working in it.
10//! - **Removed after DONE only if clean.** An agent that left uncommitted
11//!   changes left its work there, and removing the worktree would destroy it.
12//!   The branch is always kept.
13//! - **No git means no worktree:** a `git_ref` on a non-repo is an error, not
14//!   a silent fallback to the plain cwd.
15
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19/// A worktree a task runs in.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Worktree {
22    pub path: PathBuf,
23    pub branch: String,
24}
25
26/// Where task `task_id`'s worktree goes.
27pub fn path_for(task_id: &str) -> PathBuf {
28    crate::helpers::dirs_home()
29        .join(".claudectl")
30        .join("worktrees")
31        .join(task_id)
32}
33
34/// The branch a task gets when it doesn't name one: `claudectl/<name>-<id>`,
35/// with the name reduced to lowercase letters, digits and `-`.
36pub 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    // `task_<secs>_<pid>_<n>`: the part after the prefix is what's unique.
53    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
66/// Check a ref or branch name before it goes on a git command line. It comes
67/// from a tasks file; a leading `-` would read as an option.
68pub 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
97/// The repo `cwd` is in.
98pub 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
111/// Make sure the task's worktree exists at `path` on `branch`, starting from
112/// `git_ref`, in the repo containing `cwd`. Reuses one already there.
113pub 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        // Ours if it's a checkout of this repo rooted exactly at `path`.
124        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        // A retry after the worktree was removed: check the branch out again.
158        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/// What `cleanup` did.
167#[derive(Debug, PartialEq, Eq)]
168pub enum Cleanup {
169    Removed,
170    /// Left in place because it holds uncommitted changes.
171    KeptDirty,
172    Missing,
173}
174
175/// Remove a finished task's worktree if nothing would be lost; keep the
176/// branch either way.
177pub 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        // The main checkout is untouched.
238        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}