Skip to main content

agentstatedeveloper_cli/commands/
worktree.rs

1//! `asd worktree` — plan-scoped git worktrees (agent-worktree-workflow t-001).
2//!
3//! Mirror of CTXone's `ctx worktree`. Each unit of work gets its own worktree +
4//! `plan/<name>` branch, so parallel agents get isolated files + HEAD (they
5//! can't clobber each other) while sharing context via the hub. Lifecycle:
6//!   asd worktree start <plan>   -> add ../<repo>-wt-<plan> on plan/<plan>
7//!   (work there)                -> the CLI can't cd you; open your session in it
8//!   asd worktree finish <plan>  -> merge back + MANDATORY teardown
9//!
10//! The plan<->worktree binding is by CONVENTION (dir `<repo>-wt-<plan>`, branch
11//! `plan/<plan>`); `list` recovers it from `git worktree list`.
12
13use std::path::{Path, PathBuf};
14use std::process::Command;
15
16use anyhow::{Result, bail};
17use clap::Subcommand;
18
19#[derive(Debug, Subcommand)]
20pub enum WorktreeCmd {
21    /// Create a plan-scoped worktree + `plan/<name>` branch, then print its path.
22    Start {
23        /// Plan name (kebab-case).
24        plan: String,
25        /// Ref to branch from.
26        #[arg(long, default_value = "main")]
27        from: String,
28        /// Share one Rust build cache across worktrees: write a
29        /// `.cargo/config.toml` pointing `target-dir` at `<repo>/.wt-target`,
30        /// avoiding a multi-GB `target/` per worktree. Off by default.
31        #[arg(long)]
32        shared_target: bool,
33        /// Isolate via a fresh CLONE (its own .git) into `../<repo>-clone-<plan>`
34        /// instead of a worktree — for remote/cloud agents on another machine.
35        /// Merge-back is via origin (push the branch), not a local merge.
36        #[arg(long)]
37        clone: bool,
38        /// Clone URL (default: this repo's `origin` remote). Only with --clone.
39        #[arg(long)]
40        url: Option<String>,
41    },
42    /// List this repo's plan-scoped worktrees and clones.
43    List,
44    /// Merge the plan's branch back, then tear the worktree down (force-remove
45    /// + delete branch + prune). Run from anywhere; operates on the main checkout.
46    Finish {
47        /// Plan name.
48        plan: String,
49        /// `git push` the target branch after merging.
50        #[arg(long)]
51        push: bool,
52        /// Merge but KEEP the worktree + branch (skip teardown).
53        #[arg(long)]
54        keep: bool,
55        /// Branch to merge into.
56        #[arg(long, default_value = "main")]
57        into: String,
58        /// Finish a --clone workspace: push the branch to origin (with --push),
59        /// then remove the clone directory. Merge to main happens on origin/MR.
60        #[arg(long)]
61        clone: bool,
62    },
63}
64
65/// Derived worktree layout for a plan. Pure — unit-testable.
66#[derive(Debug, PartialEq)]
67struct Layout {
68    wt_dir: PathBuf,
69    branch: String,
70}
71
72/// `<parent>/<repo>-wt-<plan>` on branch `plan/<plan>`.
73fn layout(root: &Path, plan: &str) -> Layout {
74    let repo_name = root
75        .file_name()
76        .map(|s| s.to_string_lossy().into_owned())
77        .unwrap_or_else(|| "repo".into());
78    let parent = root.parent().unwrap_or(root);
79    Layout {
80        wt_dir: parent.join(format!("{repo_name}-wt-{plan}")),
81        branch: format!("plan/{plan}"),
82    }
83}
84
85fn git(args: &[&str], cwd: &Path) -> Result<std::process::Output> {
86    Ok(Command::new("git").args(args).current_dir(cwd).output()?)
87}
88
89fn git_checked(args: &[&str], cwd: &Path) -> Result<()> {
90    let out = git(args, cwd)?;
91    if !out.status.success() {
92        bail!(
93            "git {}: {}",
94            args.join(" "),
95            String::from_utf8_lossy(&out.stderr).trim()
96        );
97    }
98    Ok(())
99}
100
101fn git_stdout(args: &[&str], cwd: &Path) -> Result<String> {
102    let out = git(args, cwd)?;
103    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
104}
105
106/// The MAIN worktree's path (first entry of `git worktree list`), so `finish`
107/// operates on main from anywhere — and never switches a shared checkout's HEAD.
108fn main_root(cwd: &Path) -> Result<PathBuf> {
109    let list = git_stdout(&["worktree", "list", "--porcelain"], cwd)?;
110    for line in list.lines() {
111        if let Some(p) = line.strip_prefix("worktree ") {
112            return Ok(PathBuf::from(p));
113        }
114    }
115    bail!("not inside a git repository");
116}
117
118pub fn run(cmd: WorktreeCmd) -> Result<()> {
119    let cwd = std::env::current_dir()?;
120    let root = main_root(&cwd)?;
121    match cmd {
122        WorktreeCmd::Start {
123            plan,
124            from,
125            shared_target,
126            clone,
127            url,
128        } => {
129            if clone {
130                start_clone(&root, &plan, url.as_deref())
131            } else {
132                start(&root, &plan, &from, shared_target)
133            }
134        }
135        WorktreeCmd::List => list(&root),
136        WorktreeCmd::Finish {
137            plan,
138            push,
139            keep,
140            into,
141            clone,
142        } => {
143            if clone {
144                finish_clone(&root, &plan, push)
145            } else {
146                finish(&root, &plan, &into, push, keep)
147            }
148        }
149    }
150}
151
152/// `<parent>/<repo>-clone-<plan>` — the isolated clone directory.
153fn clone_dir(root: &Path, plan: &str) -> PathBuf {
154    let repo_name = root
155        .file_name()
156        .map(|s| s.to_string_lossy().into_owned())
157        .unwrap_or_else(|| "repo".into());
158    root.parent()
159        .unwrap_or(root)
160        .join(format!("{repo_name}-clone-{plan}"))
161}
162
163fn start_clone(root: &Path, plan: &str, url: Option<&str>) -> Result<()> {
164    let dir = clone_dir(root, plan);
165    let branch = format!("plan/{plan}");
166    if dir.exists() {
167        println!("Clone already exists: {}", dir.display());
168        println!("  cd {}", dir.display());
169        return Ok(());
170    }
171    let url = match url {
172        Some(u) => u.to_string(),
173        None => git_stdout(&["remote", "get-url", "origin"], root)?,
174    };
175    if url.is_empty() {
176        bail!("no `origin` remote — pass --url <remote>");
177    }
178    let parent = dir.parent().unwrap_or(root);
179    git_checked(&["clone", &url, &dir.to_string_lossy()], parent)?;
180    git_checked(&["checkout", "-b", &branch], &dir)?;
181    println!("\u{2713} clone {} on branch {}", dir.display(), branch);
182    println!("  Open your agent/session there:  cd {}", dir.display());
183    println!("  When done:  asd worktree finish {plan} --clone --push");
184    Ok(())
185}
186
187fn finish_clone(root: &Path, plan: &str, push: bool) -> Result<()> {
188    let dir = clone_dir(root, plan);
189    let branch = format!("plan/{plan}");
190    if !dir.exists() {
191        bail!(
192            "no clone at {} — run `asd worktree start {plan} --clone` first",
193            dir.display()
194        );
195    }
196    let dirty = git_stdout(&["status", "--porcelain"], &dir)?;
197    if !dirty.is_empty() {
198        bail!(
199            "clone {} has uncommitted changes — commit or stash them first",
200            dir.display()
201        );
202    }
203    if push {
204        git_checked(&["push", "origin", &branch], &dir)?;
205        println!("\u{2713} pushed {branch} to origin (merge it via MR / on the main checkout)");
206    } else {
207        println!("  (not pushed — pass --push to publish {branch} before teardown)");
208    }
209    std::fs::remove_dir_all(&dir)?;
210    println!("\u{2713} removed clone {}", dir.display());
211    Ok(())
212}
213
214fn start(root: &Path, plan: &str, from: &str, shared_target: bool) -> Result<()> {
215    let l = layout(root, plan);
216    if l.wt_dir.exists() {
217        println!("Worktree already exists: {}", l.wt_dir.display());
218        println!("  cd {}", l.wt_dir.display());
219        return Ok(());
220    }
221    let wt = l.wt_dir.to_string_lossy().into_owned();
222    git_checked(&["worktree", "add", &wt, "-b", &l.branch, from], root)?;
223
224    // Auto-enable the repo's tracked git hooks (e.g. the pre-push rustfmt gate)
225    // so agent worktrees inherit the same pre-push checks CI runs, without
226    // anyone remembering to run scripts/setup-hooks.sh. core.hooksPath lives in
227    // the shared config, so setting it once covers every worktree; a relative
228    // `.githooks` resolves against each worktree's own tree. No-op for repos
229    // that ship no `.githooks/`.
230    if l.wt_dir.join(".githooks").is_dir() {
231        let _ = git(&["config", "core.hooksPath", ".githooks"], root);
232        println!("  Hooks: pre-push fmt gate enabled (.githooks)");
233    }
234
235    if shared_target {
236        let cargo_dir = l.wt_dir.join(".cargo");
237        std::fs::create_dir_all(&cargo_dir)?;
238        let target = root.join(".wt-target");
239        std::fs::write(
240            cargo_dir.join("config.toml"),
241            format!("[build]\ntarget-dir = \"{}\"\n", target.display()),
242        )?;
243    }
244
245    println!(
246        "\u{2713} worktree {} on branch {}",
247        l.wt_dir.display(),
248        l.branch
249    );
250    println!(
251        "  Open your agent/session there:  cd {}",
252        l.wt_dir.display()
253    );
254    if shared_target {
255        println!("  Shared build cache: {}/.wt-target", root.display());
256    }
257    println!("  When done:  asd worktree finish {plan}");
258    Ok(())
259}
260
261fn list(root: &Path) -> Result<()> {
262    let text = git_stdout(&["worktree", "list", "--porcelain"], root)?;
263    let prefix = format!(
264        "{}-wt-",
265        root.file_name()
266            .map(|s| s.to_string_lossy().into_owned())
267            .unwrap_or_default()
268    );
269
270    let mut path: Option<String> = None;
271    let mut branch: Option<String> = None;
272    let mut rows: Vec<(String, String, String)> = Vec::new();
273    let mut flush = |path: &Option<String>, branch: &Option<String>| {
274        if let Some(p) = path
275            && let Some(name) = Path::new(p)
276                .file_name()
277                .map(|s| s.to_string_lossy().into_owned())
278            && let Some(plan) = name.strip_prefix(&prefix)
279        {
280            rows.push((
281                plan.to_string(),
282                branch.clone().unwrap_or_else(|| "?".into()),
283                p.clone(),
284            ));
285        }
286    };
287    for line in text.lines() {
288        if let Some(p) = line.strip_prefix("worktree ") {
289            flush(&path, &branch);
290            path = Some(p.to_string());
291            branch = None;
292        } else if let Some(b) = line.strip_prefix("branch ") {
293            branch = Some(b.trim_start_matches("refs/heads/").to_string());
294        }
295    }
296    flush(&path, &branch);
297
298    // Clones have their own .git and aren't in `git worktree list`; scan siblings.
299    let repo_name = prefix.trim_end_matches("-wt-").to_string();
300    let clone_prefix = format!("{repo_name}-clone-");
301    let mut clone_rows: Vec<(String, String)> = Vec::new();
302    if let Some(parent) = root.parent()
303        && let Ok(entries) = std::fs::read_dir(parent)
304    {
305        for e in entries.flatten() {
306            let name = e.file_name().to_string_lossy().into_owned();
307            if let Some(plan) = name.strip_prefix(&clone_prefix)
308                && e.path().is_dir()
309            {
310                clone_rows.push((plan.to_string(), e.path().to_string_lossy().into_owned()));
311            }
312        }
313    }
314    clone_rows.sort();
315
316    if rows.is_empty() && clone_rows.is_empty() {
317        println!("No plan-scoped worktrees or clones for {repo_name}.");
318        return Ok(());
319    }
320    for (plan, branch, p) in rows {
321        println!("  wt     {plan:<20} [{branch}]  {p}");
322    }
323    for (plan, p) in clone_rows {
324        println!("  clone  {plan:<20} [plan/{plan}]  {p}");
325    }
326    Ok(())
327}
328
329fn finish(root: &Path, plan: &str, into: &str, push: bool, keep: bool) -> Result<()> {
330    let l = layout(root, plan);
331    if !l.wt_dir.exists() {
332        bail!(
333            "no worktree at {} — run `asd worktree start {plan}` first",
334            l.wt_dir.display()
335        );
336    }
337
338    let dirty = git_stdout(&["status", "--porcelain"], &l.wt_dir)?;
339    if !dirty.is_empty() {
340        bail!(
341            "worktree {} has uncommitted changes — commit or stash them first",
342            l.wt_dir.display()
343        );
344    }
345
346    // Never switch the main checkout's HEAD — require it already on `into`.
347    let cur = git_stdout(&["rev-parse", "--abbrev-ref", "HEAD"], root)?;
348    if cur != into {
349        bail!(
350            "main checkout {} is on '{cur}', not '{into}' — checkout {into} there first \
351             (refusing to switch it: another agent may be using it)",
352            root.display()
353        );
354    }
355
356    let msg = format!("Merge {} ({plan})", l.branch);
357    let merge = git(&["merge", "--no-ff", &l.branch, "-m", &msg], root)?;
358    if !merge.status.success() {
359        let _ = git(&["merge", "--abort"], root);
360        bail!(
361            "merge conflict merging {} into {into} — resolve manually:\n{}",
362            l.branch,
363            String::from_utf8_lossy(&merge.stderr).trim()
364        );
365    }
366    println!("\u{2713} merged {} into {into}", l.branch);
367
368    if push {
369        git_checked(&["push", "origin", into], root)?;
370        println!("\u{2713} pushed {into}");
371    }
372
373    if keep {
374        println!("  (kept worktree + branch)");
375        return Ok(());
376    }
377
378    git_checked(
379        &["worktree", "remove", "--force", &l.wt_dir.to_string_lossy()],
380        root,
381    )?;
382    git_checked(&["branch", "-D", &l.branch], root)?;
383    let _ = git(&["worktree", "prune"], root);
384    println!(
385        "\u{2713} removed worktree {} and branch {}",
386        l.wt_dir.display(),
387        l.branch
388    );
389    Ok(())
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn layout_derives_sibling_dir_and_plan_branch() {
398        let l = layout(Path::new("/home/me/AgentStateDeveloper"), "cost-model");
399        assert_eq!(
400            l.wt_dir,
401            PathBuf::from("/home/me/AgentStateDeveloper-wt-cost-model")
402        );
403        assert_eq!(l.branch, "plan/cost-model");
404    }
405}