use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Result, bail};
use clap::Subcommand;
#[derive(Debug, Subcommand)]
pub enum WorktreeCmd {
Start {
plan: String,
#[arg(long, default_value = "main")]
from: String,
#[arg(long)]
shared_target: bool,
#[arg(long)]
clone: bool,
#[arg(long)]
url: Option<String>,
},
List,
Finish {
plan: String,
#[arg(long)]
push: bool,
#[arg(long)]
keep: bool,
#[arg(long, default_value = "main")]
into: String,
#[arg(long)]
clone: bool,
},
}
#[derive(Debug, PartialEq)]
struct Layout {
wt_dir: PathBuf,
branch: String,
}
fn layout(root: &Path, plan: &str) -> Layout {
let repo_name = root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "repo".into());
let parent = root.parent().unwrap_or(root);
Layout {
wt_dir: parent.join(format!("{repo_name}-wt-{plan}")),
branch: format!("plan/{plan}"),
}
}
fn git(args: &[&str], cwd: &Path) -> Result<std::process::Output> {
Ok(Command::new("git").args(args).current_dir(cwd).output()?)
}
fn git_checked(args: &[&str], cwd: &Path) -> Result<()> {
let out = git(args, cwd)?;
if !out.status.success() {
bail!(
"git {}: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
fn git_stdout(args: &[&str], cwd: &Path) -> Result<String> {
let out = git(args, cwd)?;
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn main_root(cwd: &Path) -> Result<PathBuf> {
let list = git_stdout(&["worktree", "list", "--porcelain"], cwd)?;
for line in list.lines() {
if let Some(p) = line.strip_prefix("worktree ") {
return Ok(PathBuf::from(p));
}
}
bail!("not inside a git repository");
}
pub fn run(cmd: WorktreeCmd) -> Result<()> {
let cwd = std::env::current_dir()?;
let root = main_root(&cwd)?;
match cmd {
WorktreeCmd::Start {
plan,
from,
shared_target,
clone,
url,
} => {
if clone {
start_clone(&root, &plan, url.as_deref())
} else {
start(&root, &plan, &from, shared_target)
}
}
WorktreeCmd::List => list(&root),
WorktreeCmd::Finish {
plan,
push,
keep,
into,
clone,
} => {
if clone {
finish_clone(&root, &plan, push)
} else {
finish(&root, &plan, &into, push, keep)
}
}
}
}
fn clone_dir(root: &Path, plan: &str) -> PathBuf {
let repo_name = root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "repo".into());
root.parent()
.unwrap_or(root)
.join(format!("{repo_name}-clone-{plan}"))
}
fn start_clone(root: &Path, plan: &str, url: Option<&str>) -> Result<()> {
let dir = clone_dir(root, plan);
let branch = format!("plan/{plan}");
if dir.exists() {
println!("Clone already exists: {}", dir.display());
println!(" cd {}", dir.display());
return Ok(());
}
let url = match url {
Some(u) => u.to_string(),
None => git_stdout(&["remote", "get-url", "origin"], root)?,
};
if url.is_empty() {
bail!("no `origin` remote — pass --url <remote>");
}
let parent = dir.parent().unwrap_or(root);
git_checked(&["clone", &url, &dir.to_string_lossy()], parent)?;
git_checked(&["checkout", "-b", &branch], &dir)?;
println!("\u{2713} clone {} on branch {}", dir.display(), branch);
println!(" Open your agent/session there: cd {}", dir.display());
println!(" When done: asd worktree finish {plan} --clone --push");
Ok(())
}
fn finish_clone(root: &Path, plan: &str, push: bool) -> Result<()> {
let dir = clone_dir(root, plan);
let branch = format!("plan/{plan}");
if !dir.exists() {
bail!(
"no clone at {} — run `asd worktree start {plan} --clone` first",
dir.display()
);
}
let dirty = git_stdout(&["status", "--porcelain"], &dir)?;
if !dirty.is_empty() {
bail!(
"clone {} has uncommitted changes — commit or stash them first",
dir.display()
);
}
if push {
git_checked(&["push", "origin", &branch], &dir)?;
println!("\u{2713} pushed {branch} to origin (merge it via MR / on the main checkout)");
} else {
println!(" (not pushed — pass --push to publish {branch} before teardown)");
}
std::fs::remove_dir_all(&dir)?;
println!("\u{2713} removed clone {}", dir.display());
Ok(())
}
fn start(root: &Path, plan: &str, from: &str, shared_target: bool) -> Result<()> {
let l = layout(root, plan);
if l.wt_dir.exists() {
println!("Worktree already exists: {}", l.wt_dir.display());
println!(" cd {}", l.wt_dir.display());
return Ok(());
}
let wt = l.wt_dir.to_string_lossy().into_owned();
git_checked(&["worktree", "add", &wt, "-b", &l.branch, from], root)?;
if l.wt_dir.join(".githooks").is_dir() {
let _ = git(&["config", "core.hooksPath", ".githooks"], root);
println!(" Hooks: pre-push fmt gate enabled (.githooks)");
}
if shared_target {
let cargo_dir = l.wt_dir.join(".cargo");
std::fs::create_dir_all(&cargo_dir)?;
let target = root.join(".wt-target");
std::fs::write(
cargo_dir.join("config.toml"),
format!("[build]\ntarget-dir = \"{}\"\n", target.display()),
)?;
}
println!(
"\u{2713} worktree {} on branch {}",
l.wt_dir.display(),
l.branch
);
println!(
" Open your agent/session there: cd {}",
l.wt_dir.display()
);
if shared_target {
println!(" Shared build cache: {}/.wt-target", root.display());
}
println!(" When done: asd worktree finish {plan}");
Ok(())
}
fn list(root: &Path) -> Result<()> {
let text = git_stdout(&["worktree", "list", "--porcelain"], root)?;
let prefix = format!(
"{}-wt-",
root.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
);
let mut path: Option<String> = None;
let mut branch: Option<String> = None;
let mut rows: Vec<(String, String, String)> = Vec::new();
let mut flush = |path: &Option<String>, branch: &Option<String>| {
if let Some(p) = path
&& let Some(name) = Path::new(p)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
&& let Some(plan) = name.strip_prefix(&prefix)
{
rows.push((
plan.to_string(),
branch.clone().unwrap_or_else(|| "?".into()),
p.clone(),
));
}
};
for line in text.lines() {
if let Some(p) = line.strip_prefix("worktree ") {
flush(&path, &branch);
path = Some(p.to_string());
branch = None;
} else if let Some(b) = line.strip_prefix("branch ") {
branch = Some(b.trim_start_matches("refs/heads/").to_string());
}
}
flush(&path, &branch);
let repo_name = prefix.trim_end_matches("-wt-").to_string();
let clone_prefix = format!("{repo_name}-clone-");
let mut clone_rows: Vec<(String, String)> = Vec::new();
if let Some(parent) = root.parent()
&& let Ok(entries) = std::fs::read_dir(parent)
{
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if let Some(plan) = name.strip_prefix(&clone_prefix)
&& e.path().is_dir()
{
clone_rows.push((plan.to_string(), e.path().to_string_lossy().into_owned()));
}
}
}
clone_rows.sort();
if rows.is_empty() && clone_rows.is_empty() {
println!("No plan-scoped worktrees or clones for {repo_name}.");
return Ok(());
}
for (plan, branch, p) in rows {
println!(" wt {plan:<20} [{branch}] {p}");
}
for (plan, p) in clone_rows {
println!(" clone {plan:<20} [plan/{plan}] {p}");
}
Ok(())
}
fn finish(root: &Path, plan: &str, into: &str, push: bool, keep: bool) -> Result<()> {
let l = layout(root, plan);
if !l.wt_dir.exists() {
bail!(
"no worktree at {} — run `asd worktree start {plan}` first",
l.wt_dir.display()
);
}
let dirty = git_stdout(&["status", "--porcelain"], &l.wt_dir)?;
if !dirty.is_empty() {
bail!(
"worktree {} has uncommitted changes — commit or stash them first",
l.wt_dir.display()
);
}
let cur = git_stdout(&["rev-parse", "--abbrev-ref", "HEAD"], root)?;
if cur != into {
bail!(
"main checkout {} is on '{cur}', not '{into}' — checkout {into} there first \
(refusing to switch it: another agent may be using it)",
root.display()
);
}
let msg = format!("Merge {} ({plan})", l.branch);
let merge = git(&["merge", "--no-ff", &l.branch, "-m", &msg], root)?;
if !merge.status.success() {
let _ = git(&["merge", "--abort"], root);
bail!(
"merge conflict merging {} into {into} — resolve manually:\n{}",
l.branch,
String::from_utf8_lossy(&merge.stderr).trim()
);
}
println!("\u{2713} merged {} into {into}", l.branch);
if push {
git_checked(&["push", "origin", into], root)?;
println!("\u{2713} pushed {into}");
}
if keep {
println!(" (kept worktree + branch)");
return Ok(());
}
git_checked(
&["worktree", "remove", "--force", &l.wt_dir.to_string_lossy()],
root,
)?;
git_checked(&["branch", "-D", &l.branch], root)?;
let _ = git(&["worktree", "prune"], root);
println!(
"\u{2713} removed worktree {} and branch {}",
l.wt_dir.display(),
l.branch
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layout_derives_sibling_dir_and_plan_branch() {
let l = layout(Path::new("/home/me/AgentStateDeveloper"), "cost-model");
assert_eq!(
l.wt_dir,
PathBuf::from("/home/me/AgentStateDeveloper-wt-cost-model")
);
assert_eq!(l.branch, "plan/cost-model");
}
}