1use 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 Start {
23 plan: String,
25 #[arg(long, default_value = "main")]
27 from: String,
28 #[arg(long)]
32 shared_target: bool,
33 #[arg(long)]
37 clone: bool,
38 #[arg(long)]
40 url: Option<String>,
41 },
42 List,
44 Finish {
47 plan: String,
49 #[arg(long)]
51 push: bool,
52 #[arg(long)]
54 keep: bool,
55 #[arg(long, default_value = "main")]
57 into: String,
58 #[arg(long)]
61 clone: bool,
62 },
63}
64
65#[derive(Debug, PartialEq)]
67struct Layout {
68 wt_dir: PathBuf,
69 branch: String,
70}
71
72fn 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
106fn 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
152fn 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 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 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 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}