use anyhow::{Context, Result};
use serde::Serialize;
use std::path::Path;
use std::time::SystemTime;
const STALE_WORKTREE_AGE_SECS: u64 = 24 * 60 * 60;
#[derive(Serialize)]
struct AidWorktreeEntry {
path: String,
branch: String,
active: bool,
lock_pid: Option<u32>,
lock_task_id: Option<String>,
modified_age_secs: u64,
}
struct WorktreeLock {
pid: u32,
task_id: Option<String>,
}
fn is_aid_worktree(path: &str) -> bool {
crate::worktree::is_aid_managed_worktree_path(Path::new(path))
}
pub fn create(branch: &str, base: Option<&str>, repo: Option<&str>) -> Result<()> {
let repo_dir = repo.unwrap_or(".");
let repo_path = Path::new(repo_dir).canonicalize()?;
let info = crate::worktree::create_worktree(&repo_path, branch, base)?;
println!("{}", info.path.display());
aid_info!(
"[aid] Created worktree on branch '{}' at {}",
info.branch,
info.path.display()
);
Ok(())
}
pub fn list(repo: Option<&str>, json: bool, active_only: bool) -> Result<()> {
let repo_dir = repo.unwrap_or(".");
if json {
println!("{}", list_json(Some(repo_dir), active_only)?);
return Ok(());
}
let entries = filtered_worktree_entries(repo_dir, active_only)?;
let mut count = 0;
for entry in entries {
println!("{:<50} {}", entry.path, entry.branch);
count += 1;
}
if count == 0 {
println!("No active aid worktrees.");
}
Ok(())
}
pub fn prune(repo: Option<&str>) -> Result<()> {
let repo_dir = repo.unwrap_or(".");
let entries = aid_worktree_entries(repo_dir)?;
let mut locks_cleared = 0usize;
let mut pruned = 0usize;
for entry in entries {
let path = Path::new(&entry.path);
let was_stale = is_stale_worktree_path(path);
if let Some(lock) = read_worktree_lock(path) {
if crate::worktree::process_alive_check(lock.pid) {
if was_stale {
let task = lock.task_id.as_deref().unwrap_or("unknown");
aid_warn!(
"[aid] Skipping prune: {} has active task {} (pid {})",
entry.path,
task,
lock.pid
);
}
continue;
}
let task = lock.task_id.as_deref().unwrap_or("unknown");
println!(
"[aid] Cleared stale lock in {} (task={}, pid={} dead)",
entry.path,
task,
lock.pid
);
let _ = std::fs::remove_file(path.join(".aid-lock"));
locks_cleared += 1;
}
if !was_stale {
continue;
}
match super::merge::remove_worktree(repo_dir, &entry.path) {
Ok(()) => {
println!("[aid] Pruned stale worktree: {}", entry.path);
pruned += 1;
}
Err(err) => aid_warn!("[aid] Failed to prune {}: {err}", entry.path),
}
}
if pruned == 0 && locks_cleared == 0 {
println!("[aid] No stale worktrees found");
} else {
if pruned > 0 { println!("[aid] Pruned {pruned} stale worktree(s)"); }
if locks_cleared > 0 { println!("[aid] Cleared {locks_cleared} stale lock(s)"); }
}
Ok(())
}
pub(crate) fn stale_worktree_count(repo: Option<&str>) -> Result<usize> {
Ok(stale_worktree_paths(repo.unwrap_or("."))?.len())
}
pub fn remove(branch: &str, repo: Option<&str>) -> Result<()> {
let repo_dir = repo.unwrap_or(".");
let repo_path = Path::new(repo_dir).canonicalize()?;
let mut wt_path = crate::worktree::aid_worktree_path(&repo_path, branch);
if !wt_path.exists() {
let legacy_path = Path::new("/tmp").join(format!("aid-wt-{branch}"));
if legacy_path.exists() {
wt_path = legacy_path;
}
}
if !wt_path.exists() {
anyhow::bail!("Worktree not found: {}", wt_path.display());
}
let wt_path = wt_path.to_string_lossy().to_string();
super::merge::remove_worktree(repo_dir, &wt_path)?;
Ok(())
}
fn stale_worktree_paths(repo_dir: &str) -> Result<Vec<String>> {
Ok(aid_worktree_entries(repo_dir)?
.into_iter()
.map(|entry| entry.path)
.filter(|path| should_prune_worktree(path))
.collect())
}
fn list_json(repo: Option<&str>, active_only: bool) -> Result<String> {
let entries = filtered_worktree_entries(repo.unwrap_or("."), active_only)?;
serde_json::to_string_pretty(&entries).map_err(Into::into)
}
fn filtered_worktree_entries(repo_dir: &str, active_only: bool) -> Result<Vec<AidWorktreeEntry>> {
Ok(aid_worktree_entries(repo_dir)?
.into_iter()
.filter(|entry| !active_only || entry.active)
.collect())
}
fn aid_worktree_entries(repo_dir: &str) -> Result<Vec<AidWorktreeEntry>> {
let output = std::process::Command::new("git")
.args(["-C", repo_dir, "worktree", "list", "--porcelain"])
.output()
.context("Failed to list worktrees")?;
if !output.status.success() {
anyhow::bail!("git worktree list failed");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut entries = Vec::new();
let mut current_path = String::new();
let mut current_branch = String::new();
for line in stdout.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
current_path = path.to_string();
} else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
current_branch = branch.to_string();
} else if line.is_empty() {
push_aid_worktree_entry(&mut entries, &mut current_path, &mut current_branch);
}
}
push_aid_worktree_entry(&mut entries, &mut current_path, &mut current_branch);
Ok(entries)
}
fn push_aid_worktree_entry(
entries: &mut Vec<AidWorktreeEntry>,
current_path: &mut String,
current_branch: &mut String,
) {
if is_aid_worktree(current_path) && !current_path.is_empty() {
entries.push(aid_worktree_entry(current_path, current_branch));
}
current_path.clear();
current_branch.clear();
}
fn aid_worktree_entry(path: &str, branch: &str) -> AidWorktreeEntry {
let lock = live_worktree_lock(Path::new(path));
AidWorktreeEntry {
path: path.to_string(),
branch: branch.to_string(),
active: lock.is_some(),
lock_pid: lock.as_ref().map(|lock| lock.pid),
lock_task_id: lock.and_then(|lock| lock.task_id),
modified_age_secs: modified_age_secs(Path::new(path)),
}
}
fn read_worktree_lock(wt_path: &Path) -> Option<WorktreeLock> {
let content = std::fs::read_to_string(wt_path.join(".aid-lock")).ok()?;
let mut pid = None;
let mut task_id = None;
for line in content.lines() {
if let Some(value) = line.strip_prefix("pid=") {
pid = value.trim().parse::<u32>().ok();
} else if let Some(value) = line.strip_prefix("task=") {
task_id = Some(value.trim().to_string());
}
}
Some(WorktreeLock { pid: pid?, task_id })
}
fn live_worktree_lock(wt_path: &Path) -> Option<WorktreeLock> {
let lock = read_worktree_lock(wt_path)?;
crate::worktree::process_alive_check(lock.pid).then_some(lock)
}
fn should_prune_worktree(wt_path: &str) -> bool {
if live_worktree_lock(Path::new(wt_path)).is_some() {
return false;
}
is_stale_worktree_path(Path::new(wt_path))
}
fn is_stale_worktree_path(wt_path: &Path) -> bool {
std::fs::metadata(wt_path)
.ok()
.and_then(|meta| meta.modified().ok())
.map(is_stale_worktree_time)
.unwrap_or(true)
}
fn modified_age_secs(wt_path: &Path) -> u64 {
std::fs::metadata(wt_path)
.ok()
.and_then(|meta| meta.modified().ok())
.and_then(|modified| SystemTime::now().duration_since(modified).ok())
.map(|age| age.as_secs())
.unwrap_or(0)
}
fn is_stale_worktree_time(modified: SystemTime) -> bool {
SystemTime::now()
.duration_since(modified)
.map(|age| age.as_secs() > STALE_WORKTREE_AGE_SECS)
.unwrap_or(false)
}
#[cfg(test)]
#[path = "worktree/tests.rs"]
mod tests;