use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tracing;
#[derive(Debug)]
pub enum WorktreeResult {
Created(PathBuf),
NotGitRepo,
NoGit,
}
pub async fn provision(project_root: &Path, session_id: &str) -> Result<WorktreeResult> {
if session_id.is_empty()
|| session_id.contains('/')
|| session_id.contains('\\')
|| session_id.contains("..")
{
anyhow::bail!("Invalid session ID for worktree: {session_id}");
}
let git_check = Command::new("git")
.arg("--version")
.current_dir(project_root)
.output()
.await;
if git_check.is_err() {
tracing::debug!("git not found in PATH, skipping worktree isolation");
return Ok(WorktreeResult::NoGit);
}
let is_git = Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(project_root)
.output()
.await?;
if !is_git.status.success() {
tracing::debug!("Not a git repo, skipping worktree isolation");
return Ok(WorktreeResult::NotGitRepo);
}
let worktree_dir = project_root.join(".koda").join("worktrees");
std::fs::create_dir_all(&worktree_dir)
.with_context(|| format!("Failed to create worktree dir: {}", worktree_dir.display()))?;
let worktree_path = worktree_dir.join(session_id);
if worktree_path.exists() {
tracing::debug!("Reusing existing worktree: {}", worktree_path.display());
return Ok(WorktreeResult::Created(worktree_path));
}
let output = Command::new("git")
.args([
"worktree",
"add",
"--detach", &worktree_path.to_string_lossy(),
"HEAD",
])
.current_dir(project_root)
.output()
.await
.context("Failed to run git worktree add")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree add failed: {stderr}");
}
tracing::info!("Provisioned worktree: {}", worktree_path.display());
Ok(WorktreeResult::Created(worktree_path))
}
pub async fn cleanup(project_root: &Path, worktree_path: &Path) -> Result<Option<String>> {
if !worktree_path.exists() {
return Ok(None);
}
let status = Command::new("git")
.args(["status", "--short"])
.current_dir(worktree_path)
.output()
.await
.context("Failed to check worktree status")?;
let status_output = String::from_utf8_lossy(&status.stdout);
if status_output.trim().is_empty() {
let remove = Command::new("git")
.args([
"worktree",
"remove",
"--force",
&worktree_path.to_string_lossy(),
])
.current_dir(project_root)
.output()
.await
.context("Failed to remove worktree")?;
if remove.status.success() {
tracing::info!("Removed clean worktree: {}", worktree_path.display());
} else {
let stderr = String::from_utf8_lossy(&remove.stderr);
tracing::warn!("git worktree remove failed ({stderr}), trying rm -rf");
let _ = tokio::fs::remove_dir_all(worktree_path).await;
}
Ok(None)
} else {
let diff = Command::new("git")
.args(["diff", "--stat"])
.current_dir(worktree_path)
.output()
.await
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default();
let summary = format!(
"Worktree has uncommitted changes:\n{}\n\
Review: git -C {} diff\n\
Apply: git -C {} stash && git stash pop",
diff.trim(),
worktree_path.display(),
worktree_path.display(),
);
tracing::info!("Worktree has changes, keeping: {}", worktree_path.display());
Ok(Some(summary))
}
}
pub async fn prune(project_root: &Path) -> Result<(usize, usize)> {
let worktree_dir = project_root.join(".koda").join("worktrees");
if !worktree_dir.exists() {
return Ok((0, 0));
}
let mut removed = 0;
let mut kept = 0;
let mut entries = tokio::fs::read_dir(&worktree_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if !path.is_dir() {
continue;
}
match cleanup(project_root, &path).await? {
None => removed += 1,
Some(_) => kept += 1,
}
}
Ok((removed, kept))
}
#[cfg(test)]
mod tests {
use super::*;
async fn init_test_repo(path: &Path) {
Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.await
.unwrap();
Command::new("git")
.args([
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(path)
.output()
.await
.unwrap();
}
#[tokio::test]
async fn provision_not_git_repo() {
let tmp = tempfile::tempdir().unwrap();
let result = provision(tmp.path(), "test-session").await.unwrap();
assert!(matches!(result, WorktreeResult::NotGitRepo));
}
#[tokio::test]
async fn provision_in_git_repo() {
let tmp = tempfile::tempdir().unwrap();
init_test_repo(tmp.path()).await;
let result = provision(tmp.path(), "test-session-123").await.unwrap();
match result {
WorktreeResult::Created(path) => {
assert!(path.exists());
assert!(path.ends_with("test-session-123"));
}
other => panic!("Expected Created, got {other:?}"),
}
}
#[tokio::test]
async fn provision_reuses_existing() {
let tmp = tempfile::tempdir().unwrap();
init_test_repo(tmp.path()).await;
let r1 = provision(tmp.path(), "reuse-test").await.unwrap();
let r2 = provision(tmp.path(), "reuse-test").await.unwrap();
match (r1, r2) {
(WorktreeResult::Created(p1), WorktreeResult::Created(p2)) => {
assert_eq!(p1, p2);
}
_ => panic!("Expected both to be Created"),
}
}
#[tokio::test]
async fn cleanup_removes_clean_worktree() {
let tmp = tempfile::tempdir().unwrap();
init_test_repo(tmp.path()).await;
let result = provision(tmp.path(), "clean-wt").await.unwrap();
let wt_path = match result {
WorktreeResult::Created(p) => p,
_ => panic!("Expected Created"),
};
let changes = cleanup(tmp.path(), &wt_path).await.unwrap();
assert!(changes.is_none(), "Clean worktree should have no changes");
assert!(!wt_path.exists(), "Clean worktree should be removed");
}
#[tokio::test]
async fn cleanup_keeps_dirty_worktree() {
let tmp = tempfile::tempdir().unwrap();
init_test_repo(tmp.path()).await;
let result = provision(tmp.path(), "dirty-wt").await.unwrap();
let wt_path = match result {
WorktreeResult::Created(p) => p,
_ => panic!("Expected Created"),
};
std::fs::write(wt_path.join("dirty.txt"), "changes").unwrap();
let changes = cleanup(tmp.path(), &wt_path).await.unwrap();
assert!(changes.is_some(), "Dirty worktree should report changes");
assert!(wt_path.exists(), "Dirty worktree should be kept");
}
#[tokio::test]
async fn invalid_session_id_rejected() {
let tmp = tempfile::tempdir().unwrap();
assert!(provision(tmp.path(), "../escape").await.is_err());
assert!(provision(tmp.path(), "").await.is_err());
assert!(provision(tmp.path(), "foo/bar").await.is_err());
}
}