use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result, bail};
use tokio::sync::Mutex;
use tracing::{info, debug};
use super::types::WorktreeInfo;
#[async_trait::async_trait]
pub trait WorktreeManager: Send + Sync {
async fn create(&self, task_id: &str) -> Result<PathBuf>;
async fn merge(&self, task_id: &str) -> Result<()>;
async fn cleanup(&self, task_id: &str) -> Result<()>;
async fn list_existing(&self) -> Result<Vec<WorktreeInfo>>;
async fn cleanup_stale(&self) -> Result<usize>;
}
#[derive(Debug)]
pub struct GitWorktreeManager {
repo_path: PathBuf,
worktree_base: PathBuf,
merge_lock: Arc<Mutex<()>>,
main_branch: String,
}
impl GitWorktreeManager {
pub fn new(repo_path: impl Into<PathBuf>) -> Self {
let repo = repo_path.into();
let worktree_base = std::env::temp_dir();
Self {
repo_path: repo,
worktree_base,
merge_lock: Arc::new(Mutex::new(())),
main_branch: "main".to_string(),
}
}
pub fn with_worktree_base(mut self, base: impl Into<PathBuf>) -> Self {
self.worktree_base = base.into();
self
}
pub fn with_main_branch(mut self, branch: impl Into<String>) -> Self {
self.main_branch = branch.into();
self
}
fn branch_name(task_id: &str) -> String {
format!("gid/task-{}", task_id)
}
fn worktree_path(&self, task_id: &str) -> PathBuf {
self.worktree_base.join(format!("gid-wt-{}", task_id))
}
async fn git(&self, args: &[&str]) -> Result<String> {
self.git_in(&self.repo_path, args).await
}
async fn git_in(&self, dir: &Path, args: &[&str]) -> Result<String> {
let output = tokio::process::Command::new("git")
.args(args)
.current_dir(dir)
.output()
.await
.context("Failed to execute git")?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
bail!("git {} failed (exit {}): {}", args.join(" "), output.status, stderr.trim());
}
debug!(cmd = %args.join(" "), "git command succeeded");
Ok(stdout.trim().to_string())
}
}
#[async_trait::async_trait]
impl WorktreeManager for GitWorktreeManager {
async fn create(&self, task_id: &str) -> Result<PathBuf> {
let branch = Self::branch_name(task_id);
let wt_path = self.worktree_path(task_id);
info!(task_id, branch = %branch, path = %wt_path.display(), "Creating worktree");
self.git(&["fetch", "origin", &self.main_branch]).await.ok();
self.git(&[
"worktree", "add",
wt_path.to_str().unwrap(),
"-b", &branch,
&self.main_branch,
]).await.context("Failed to create worktree")?;
Ok(wt_path)
}
async fn merge(&self, task_id: &str) -> Result<()> {
let branch = Self::branch_name(task_id);
let wt_path = self.worktree_path(task_id);
info!(task_id, branch = %branch, "Merging worktree (acquiring lock)");
let _lock = self.merge_lock.lock().await;
let rebase_result = self.git_in(&wt_path, &["rebase", &self.main_branch]).await;
if let Err(e) = rebase_result {
self.git_in(&wt_path, &["rebase", "--abort"]).await.ok();
bail!("Rebase conflict for task {}: {}", task_id, e);
}
self.git(&["checkout", &self.main_branch]).await?;
self.git(&["merge", "--no-ff", &branch, "-m", &format!("gid: merge task {}", task_id)]).await
.context(format!("Merge failed for task {}", task_id))?;
info!(task_id, "Merge successful");
Ok(())
}
async fn cleanup(&self, task_id: &str) -> Result<()> {
let branch = Self::branch_name(task_id);
let wt_path = self.worktree_path(task_id);
info!(task_id, "Cleaning up worktree");
self.git(&["worktree", "remove", "--force", wt_path.to_str().unwrap()]).await.ok();
self.git(&["branch", "-D", &branch]).await.ok();
if wt_path.exists() {
tokio::fs::remove_dir_all(&wt_path).await.ok();
}
Ok(())
}
async fn list_existing(&self) -> Result<Vec<WorktreeInfo>> {
let output = self.git(&["worktree", "list", "--porcelain"]).await?;
let mut worktrees = Vec::new();
let mut current_path = None;
let mut current_branch = None;
for line in output.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
current_path = Some(PathBuf::from(path));
} else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
current_branch = Some(branch.to_string());
} else if line.is_empty() {
if let (Some(path), Some(branch)) = (current_path.take(), current_branch.take()) {
if let Some(task_id) = branch.strip_prefix("gid/task-") {
worktrees.push(WorktreeInfo {
task_id: task_id.to_string(),
path,
branch,
});
}
}
current_path = None;
current_branch = None;
}
}
if let (Some(path), Some(branch)) = (current_path, current_branch) {
if let Some(task_id) = branch.strip_prefix("gid/task-") {
worktrees.push(WorktreeInfo {
task_id: task_id.to_string(),
path,
branch,
});
}
}
Ok(worktrees)
}
async fn cleanup_stale(&self) -> Result<usize> {
let existing = self.list_existing().await?;
let count = existing.len();
for wt in &existing {
info!(task_id = %wt.task_id, path = %wt.path.display(), "Cleaning up stale worktree");
self.cleanup(&wt.task_id).await.ok();
}
if count > 0 {
self.git(&["worktree", "prune"]).await.ok();
info!(count, "Cleaned up stale worktrees");
}
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_branch_name() {
assert_eq!(GitWorktreeManager::branch_name("auth-impl"), "gid/task-auth-impl");
assert_eq!(GitWorktreeManager::branch_name("123"), "gid/task-123");
}
#[test]
fn test_worktree_path() {
let mgr = GitWorktreeManager::new("/repo")
.with_worktree_base("/tmp/test");
assert_eq!(mgr.worktree_path("auth"), PathBuf::from("/tmp/test/gid-wt-auth"));
}
#[tokio::test]
async fn test_list_existing_empty_repo() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
tokio::process::Command::new("git")
.args(["init", "--initial-branch", "main"])
.current_dir(repo)
.output()
.await
.unwrap();
tokio::process::Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(repo)
.output()
.await
.unwrap();
let mgr = GitWorktreeManager::new(repo);
let existing = mgr.list_existing().await.unwrap();
assert!(existing.is_empty(), "New repo should have no gid worktrees");
}
#[tokio::test]
async fn test_create_and_cleanup_worktree() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
let wt_base = tempfile::tempdir().unwrap();
tokio::process::Command::new("git")
.args(["init", "--initial-branch", "main"])
.current_dir(repo)
.output()
.await
.unwrap();
tokio::process::Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(repo)
.output()
.await
.unwrap();
let mgr = GitWorktreeManager::new(repo)
.with_worktree_base(wt_base.path());
let wt_path = mgr.create("test-task").await.unwrap();
assert!(wt_path.exists(), "Worktree directory should exist");
let existing = mgr.list_existing().await.unwrap();
assert_eq!(existing.len(), 1);
assert_eq!(existing[0].task_id, "test-task");
mgr.cleanup("test-task").await.unwrap();
assert!(!wt_path.exists(), "Worktree should be removed after cleanup");
}
}