use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub(crate) struct SubagentWorktree {
pub(crate) path: PathBuf,
pub(crate) branch: String,
repo: PathBuf,
base_commit: Option<String>,
}
fn is_git_repo(dir: &Path) -> bool {
Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(dir)
.output()
.map(|o| o.status.success() && o.stdout.starts_with(b"true"))
.unwrap_or(false)
}
fn worktree_root() -> PathBuf {
crate::config::profile::resolve_profile_home().join("worktrees")
}
fn clone_build_cache(from: &Path, to: &Path) {
let src = from.join("target");
if !src.is_dir() {
return;
}
match Command::new("cp")
.arg("-c")
.arg("-R")
.arg(&src)
.arg(to.join("target"))
.output()
{
Ok(out) if out.status.success() => {
tracing::debug!(
"sub-agent worktree: cloned build cache into {}",
to.display()
);
}
Ok(out) => {
tracing::debug!(
"sub-agent worktree: no clonefile support, starting without a build cache: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Err(e) => tracing::debug!("sub-agent worktree: could not clone build cache: {e}"),
}
}
pub(crate) fn create(parent_dir: &Path, agent_id: &str) -> Option<SubagentWorktree> {
if !is_git_repo(parent_dir) {
return None;
}
let branch = format!("subagent/{agent_id}");
let path = worktree_root().join(agent_id);
if let Err(e) = std::fs::create_dir_all(worktree_root()) {
tracing::warn!("sub-agent worktree: cannot create root: {e}");
return None;
}
if path.exists() {
tracing::info!(
"sub-agent worktree: clearing a stale tree at {}",
path.display()
);
let _ = Command::new("git")
.args(["worktree", "remove", "--force"])
.arg(&path)
.current_dir(parent_dir)
.output();
let _ = std::fs::remove_dir_all(&path);
let _ = Command::new("git")
.args(["worktree", "prune"])
.current_dir(parent_dir)
.output();
}
let out = Command::new("git")
.args(["worktree", "add", "--detach"])
.arg(&path)
.current_dir(parent_dir)
.output();
match out {
Ok(o) if o.status.success() => {}
Ok(o) => {
tracing::warn!(
"sub-agent worktree: git refused, child will share the parent tree: {}",
String::from_utf8_lossy(&o.stderr).trim()
);
return None;
}
Err(e) => {
tracing::warn!("sub-agent worktree: could not run git: {e}");
return None;
}
}
let _ = Command::new("git")
.args(["switch", "-c", &branch])
.current_dir(&path)
.output();
clone_build_cache(parent_dir, &path);
let base_commit = head_commit(&path);
tracing::info!(
"sub-agent worktree: {} on branch {}",
path.display(),
branch
);
Some(SubagentWorktree {
path,
branch,
repo: parent_dir.to_path_buf(),
base_commit,
})
}
fn head_commit(dir: &Path) -> Option<String> {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
}
impl SubagentWorktree {
pub(crate) fn has_work(&self) -> bool {
let dirty = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&self.path)
.output()
.map(|o| !o.stdout.is_empty())
.unwrap_or(true);
if dirty {
return true;
}
match (&self.base_commit, head_commit(&self.path)) {
(Some(base), Some(head)) => &head != base,
_ => true,
}
}
pub(crate) fn parent_notice(&self, removed: bool) -> Option<String> {
(!removed).then(|| {
format!(
"\n\n---\nThis agent worked in its own checkout and left changes there.\n\
Branch: `{}`\nPath: `{}`\n\
Review with `git -C {} status`, and land it with \
`git merge {}` from the main tree when you want it.",
self.branch,
self.path.display(),
self.path.display(),
self.branch,
)
})
}
pub(crate) fn cleanup(&self) -> bool {
if self.has_work() {
tracing::info!(
"sub-agent worktree kept: {} has work on branch {}",
self.path.display(),
self.branch
);
return false;
}
let removed = Command::new("git")
.args(["worktree", "remove", "--force"])
.arg(&self.path)
.current_dir(&self.repo)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !removed {
tracing::warn!(
"sub-agent worktree: could not remove {}, leaving it for `git worktree prune`",
self.path.display()
);
}
removed
}
}