diff options
| author | soryu <soryu@soryu.co> | 2026-01-06 04:08:11 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-01-11 03:01:13 +0000 |
| commit | 8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch) | |
| tree | 7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33 /makima/daemon/src/worktree/manager.rs | |
| parent | f79c416c58557d2f946aa5332989afdfa8c021cd (diff) | |
| download | soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip | |
Initial Control system
Diffstat (limited to 'makima/daemon/src/worktree/manager.rs')
| -rw-r--r-- | makima/daemon/src/worktree/manager.rs | 1623 |
1 files changed, 1623 insertions, 0 deletions
diff --git a/makima/daemon/src/worktree/manager.rs b/makima/daemon/src/worktree/manager.rs new file mode 100644 index 0000000..266b970 --- /dev/null +++ b/makima/daemon/src/worktree/manager.rs @@ -0,0 +1,1623 @@ +//! Worktree manager implementation. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use tokio::process::Command; +use tokio::sync::Mutex; +use uuid::Uuid; + +/// Errors that can occur during worktree operations. +#[derive(Debug, thiserror::Error)] +pub enum WorktreeError { + #[error("Git command failed: {0}")] + GitCommand(String), + + #[error("Repository not found: {0}")] + RepoNotFound(String), + + #[error("Failed to create directory: {0}")] + CreateDir(#[from] std::io::Error), + + #[error("Invalid repository path: {0}")] + InvalidPath(String), + + #[error("Worktree already exists: {0}")] + AlreadyExists(String), + + #[error("Clone failed: {0}")] + CloneFailed(String), + + #[error("Merge in progress")] + MergeInProgress, + + #[error("No merge in progress")] + NoMergeInProgress, + + #[error("Merge has conflicts: {0}")] + MergeConflicts(String), +} + +/// Strategy for resolving a merge conflict. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConflictResolution { + /// Use our version (the branch being merged into). + Ours, + /// Use their version (the branch being merged). + Theirs, +} + +/// State of an in-progress merge. +#[derive(Debug, Clone)] +pub struct MergeState { + /// The branch being merged. + pub source_branch: String, + /// Files with unresolved conflicts. + pub conflicted_files: Vec<String>, + /// Whether a merge is currently in progress. + pub in_progress: bool, +} + +/// Information about a task branch. +#[derive(Debug, Clone)] +pub struct TaskBranchInfo { + /// Full branch name. + pub name: String, + /// Task ID extracted from branch name (if parseable). + pub task_id: Option<Uuid>, + /// Whether this branch has been merged into the current branch. + pub is_merged: bool, + /// Short SHA of the last commit. + pub last_commit: String, + /// Subject line of the last commit. + pub last_commit_message: String, +} + +/// Information about a created worktree. +#[derive(Debug, Clone)] +pub struct WorktreeInfo { + /// Path to the worktree directory. + pub path: PathBuf, + /// Git branch name for this worktree. + pub branch: String, + /// Source repository path. + pub source_repo: PathBuf, +} + +/// Manages git worktrees for task isolation. +pub struct WorktreeManager { + /// Base directory for all worktrees (~/.makima/worktrees). + base_dir: PathBuf, + /// Base directory for cloned repos (~/.makima/repos). + repos_dir: PathBuf, + /// Branch prefix for task branches. + branch_prefix: String, +} + +/// Per-worktree locks to prevent concurrent creation issues. +static WORKTREE_LOCKS: LazyLock<Mutex<HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +impl WorktreeManager { + /// Create a new WorktreeManager with the given base directory. + pub fn new(base_dir: PathBuf) -> Self { + let repos_dir = base_dir.parent() + .map(|p| p.join("repos")) + .unwrap_or_else(|| base_dir.join("repos")); + + Self { + base_dir, + repos_dir, + branch_prefix: "makima/task-".to_string(), + } + } + + /// Get the default worktree base directory (~/.makima/worktrees). + pub fn default_base_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".makima") + .join("worktrees") + } + + /// Get the base directory for worktrees. + pub fn base_dir(&self) -> &Path { + &self.base_dir + } + + /// Detect the default branch of a repository. + /// Tries to find HEAD's target, falling back to common branch names. + pub async fn detect_default_branch(&self, repo_path: &Path) -> Result<String, WorktreeError> { + // Try to get the branch that HEAD points to + let output = Command::new("git") + .args(["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]) + .current_dir(repo_path) + .output() + .await?; + + if output.status.success() { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + // Remove "origin/" prefix if present + let branch = branch.strip_prefix("origin/").unwrap_or(&branch).to_string(); + if !branch.is_empty() { + return Ok(branch); + } + } + + // Try common branch names + for branch in ["main", "master", "develop", "trunk"] { + let output = Command::new("git") + .args(["rev-parse", "--verify", &format!("refs/heads/{}", branch)]) + .current_dir(repo_path) + .output() + .await?; + + if output.status.success() { + return Ok(branch.to_string()); + } + } + + // Fall back to getting the current branch + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(repo_path) + .output() + .await?; + + if output.status.success() { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !branch.is_empty() && branch != "HEAD" { + return Ok(branch); + } + } + + Err(WorktreeError::GitCommand( + "Could not detect default branch".to_string(), + )) + } + + /// Ensure the source repository exists locally and is up-to-date. + /// If repo_source is a URL, clone it. If it's a path, verify it exists. + /// For both cases, fetch latest changes from remote if available. + pub async fn ensure_repo(&self, repo_source: &str) -> Result<PathBuf, WorktreeError> { + // Check if it's a URL (simple heuristic) + if repo_source.starts_with("http://") + || repo_source.starts_with("https://") + || repo_source.starts_with("git@") + || repo_source.starts_with("ssh://") + { + self.clone_or_fetch_repo(repo_source).await + } else { + // Treat as local path - expand tilde if present + let path = expand_tilde(repo_source); + if !path.exists() { + return Err(WorktreeError::RepoNotFound(repo_source.to_string())); + } + // Verify it's a git repo + let git_dir = path.join(".git"); + if !git_dir.exists() { + return Err(WorktreeError::InvalidPath(format!( + "{} is not a git repository", + repo_source + ))); + } + + // Fetch latest changes from remote if configured + tracing::info!("Fetching latest changes for local repo: {}", repo_source); + let output = Command::new("git") + .args(["fetch", "--all", "--prune"]) + .current_dir(&path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Don't fail - repo might not have a remote configured + tracing::debug!("Git fetch for local repo (may not have remote): {}", stderr); + } else { + tracing::info!("Fetched latest changes for {}", repo_source); + } + + Ok(path) + } + } + + /// Clone a repository or fetch if already cloned. + async fn clone_or_fetch_repo(&self, url: &str) -> Result<PathBuf, WorktreeError> { + // Extract repo name from URL + let repo_name = extract_repo_name(url); + let repo_path = self.repos_dir.join(&repo_name); + + // Create repos directory if needed + tokio::fs::create_dir_all(&self.repos_dir).await?; + + if repo_path.exists() { + // Fetch latest changes + tracing::info!("Fetching updates for existing repo: {}", repo_name); + let output = Command::new("git") + .args(["fetch", "--all", "--prune"]) + .current_dir(&repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!("Git fetch warning: {}", stderr); + // Don't fail on fetch errors, repo might still be usable + } + } else { + // Clone the repository + tracing::info!("Cloning repository: {} -> {}", url, repo_path.display()); + let output = Command::new("git") + .args(["clone", "--bare", url]) + .arg(&repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::CloneFailed(stderr.to_string())); + } + } + + Ok(repo_path) + } + + /// Create a worktree for a task. + /// + /// This creates a unique directory with a git worktree checked out to a new branch. + pub async fn create_worktree( + &self, + source_repo: &Path, + task_id: Uuid, + task_name: &str, + base_branch: &str, + ) -> Result<WorktreeInfo, WorktreeError> { + // Generate unique directory name and branch + let dir_name = format!("{}-{}", short_uuid(task_id), sanitize_name(task_name)); + let worktree_path = self.base_dir.join(&dir_name); + // Branch name: makima/{task-name-with-dashes}-{short-id} + let branch_name = format!("{}{}-{}", self.branch_prefix, sanitize_name(task_name), short_uuid(task_id)); + + // Acquire lock for this worktree path + let lock = { + let mut locks = WORKTREE_LOCKS.lock().await; + locks + .entry(worktree_path.to_string_lossy().to_string()) + .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + let _guard = lock.lock().await; + + // Check if worktree already exists - reuse it if so + if worktree_path.exists() { + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Worktree already exists, reusing" + ); + + // Verify it's a valid git directory + let git_dir = worktree_path.join(".git"); + if git_dir.exists() { + // Get the current branch name + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(&worktree_path) + .output() + .await?; + + let current_branch = if output.status.success() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + branch_name.clone() + }; + + return Ok(WorktreeInfo { + path: worktree_path, + branch: current_branch, + source_repo: source_repo.to_path_buf(), + }); + } else { + // Directory exists but isn't a git worktree - remove and recreate + tracing::warn!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Directory exists but is not a git worktree, removing" + ); + tokio::fs::remove_dir_all(&worktree_path).await?; + } + } + + // Create base directory + tokio::fs::create_dir_all(&self.base_dir).await?; + + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + branch = %branch_name, + base_branch = %base_branch, + "Creating worktree from local branch" + ); + + // Create the worktree with a new branch based on the local base_branch + let output = Command::new("git") + .args([ + "worktree", + "add", + "-b", + &branch_name, + ]) + .arg(&worktree_path) + .arg(base_branch) + .current_dir(source_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to create worktree: {}", + stderr + ))); + } + + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Worktree created successfully" + ); + + Ok(WorktreeInfo { + path: worktree_path, + branch: branch_name, + source_repo: source_repo.to_path_buf(), + }) + } + + /// Create a worktree for a task by copying from another task's worktree. + /// + /// This allows sequential subtasks where one continues from another's work, + /// including uncommitted changes. + pub async fn create_worktree_from_task( + &self, + source_worktree: &Path, + task_id: Uuid, + task_name: &str, + ) -> Result<WorktreeInfo, WorktreeError> { + // Verify source worktree exists + if !source_worktree.exists() { + return Err(WorktreeError::RepoNotFound(format!( + "Source worktree not found: {}", + source_worktree.display() + ))); + } + + // Get the source repo from the source worktree + let source_repo = self.get_worktree_source(source_worktree).await?; + + // Get the base branch from source worktree's current HEAD + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(source_worktree) + .output() + .await?; + + if !output.status.success() { + return Err(WorktreeError::GitCommand( + "Failed to get source worktree HEAD".to_string(), + )); + } + let source_commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // Generate unique directory name and branch for new worktree + let dir_name = format!("{}-{}", short_uuid(task_id), sanitize_name(task_name)); + let worktree_path = self.base_dir.join(&dir_name); + let branch_name = format!("{}{}", self.branch_prefix, task_id); + + // Acquire lock for this worktree path + let lock = { + let mut locks = WORKTREE_LOCKS.lock().await; + locks + .entry(worktree_path.to_string_lossy().to_string()) + .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))) + .clone() + }; + let _guard = lock.lock().await; + + // Remove existing worktree if present + if worktree_path.exists() { + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Removing existing worktree before creating from source" + ); + tokio::fs::remove_dir_all(&worktree_path).await?; + } + + // Create base directory + tokio::fs::create_dir_all(&self.base_dir).await?; + + tracing::info!( + task_id = %task_id, + source_worktree = %source_worktree.display(), + worktree_path = %worktree_path.display(), + branch = %branch_name, + source_commit = %source_commit, + "Creating worktree from source task" + ); + + // Create a new worktree based on the source commit + let output = Command::new("git") + .args([ + "worktree", + "add", + "-b", + &branch_name, + ]) + .arg(&worktree_path) + .arg(&source_commit) + .current_dir(&source_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to create worktree: {}", + stderr + ))); + } + + // Now copy uncommitted changes from source worktree + // Use rsync to copy all files except .git + let output = Command::new("rsync") + .args([ + "-a", + "--exclude", ".git", + "--exclude", ".makima", + &format!("{}/", source_worktree.display()), + &format!("{}/", worktree_path.display()), + ]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + task_id = %task_id, + "rsync warning (continuing anyway): {}", + stderr + ); + } + + tracing::info!( + task_id = %task_id, + worktree_path = %worktree_path.display(), + "Worktree created from source task successfully" + ); + + Ok(WorktreeInfo { + path: worktree_path, + branch: branch_name, + source_repo: source_repo.to_path_buf(), + }) + } + + /// Remove a worktree and optionally its branch. + pub async fn remove_worktree( + &self, + worktree_path: &Path, + delete_branch: bool, + ) -> Result<(), WorktreeError> { + if !worktree_path.exists() { + return Ok(()); // Already gone + } + + // Get the branch name before removing + let branch_name = if delete_branch { + self.get_worktree_branch(worktree_path).await.ok() + } else { + None + }; + + // Find the source repo from worktree + let source_repo = self.get_worktree_source(worktree_path).await?; + + tracing::info!( + worktree_path = %worktree_path.display(), + delete_branch = delete_branch, + "Removing worktree" + ); + + // Remove the worktree + let output = Command::new("git") + .args(["worktree", "remove", "--force"]) + .arg(worktree_path) + .current_dir(&source_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Try force removal of directory if git worktree remove fails + if worktree_path.exists() { + tokio::fs::remove_dir_all(worktree_path).await?; + } + tracing::warn!("Git worktree remove warning: {}", stderr); + } + + // Prune worktree references + let _ = Command::new("git") + .args(["worktree", "prune"]) + .current_dir(&source_repo) + .output() + .await; + + // Delete the branch if requested + if let Some(branch) = branch_name { + let output = Command::new("git") + .args(["branch", "-D", &branch]) + .current_dir(&source_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!("Failed to delete branch {}: {}", branch, stderr); + } + } + + Ok(()) + } + + /// Get the branch name of a worktree. + async fn get_worktree_branch(&self, worktree_path: &Path) -> Result<String, WorktreeError> { + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to get branch: {}", + stderr + ))); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + + /// Get the source repository path for a worktree. + async fn get_worktree_source(&self, worktree_path: &Path) -> Result<PathBuf, WorktreeError> { + // Read the .git file in the worktree which contains the path to the main repo + let git_file = worktree_path.join(".git"); + + if git_file.is_file() { + let content = tokio::fs::read_to_string(&git_file).await?; + // Format: "gitdir: /path/to/repo/.git/worktrees/name" + if let Some(gitdir) = content.strip_prefix("gitdir: ") { + let gitdir = gitdir.trim(); + // Navigate from worktrees/name back to the main repo + let path = PathBuf::from(gitdir); + if let Some(worktrees_dir) = path.parent() { + if let Some(git_dir) = worktrees_dir.parent() { + if let Some(repo_dir) = git_dir.parent() { + return Ok(repo_dir.to_path_buf()); + } + } + } + } + } + + // Fallback: try to find it in our repos directory + Err(WorktreeError::InvalidPath(format!( + "Could not determine source repo for worktree: {}", + worktree_path.display() + ))) + } + + /// List all worktrees in the base directory. + pub async fn list_worktrees(&self) -> Result<Vec<PathBuf>, WorktreeError> { + let mut worktrees = Vec::new(); + + if !self.base_dir.exists() { + return Ok(worktrees); + } + + let mut entries = tokio::fs::read_dir(&self.base_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if path.is_dir() && path.join(".git").exists() { + worktrees.push(path); + } + } + + Ok(worktrees) + } + + /// Initialize a new git repository for a task. + /// + /// This creates a fresh git repo (not a worktree) for tasks that don't need + /// an existing codebase. Use this when `repository_url` is `new://` or `new://project-name`. + pub async fn init_new_repo( + &self, + task_id: Uuid, + repo_source: &str, + ) -> Result<WorktreeInfo, WorktreeError> { + let project_name = extract_new_repo_name(repo_source); + let dir_name = match project_name { + Some(name) => format!("{}-{}", short_uuid(task_id), sanitize_name(name)), + None => format!("{}-new", short_uuid(task_id)), + }; + let repo_path = self.repos_dir.join(&dir_name); + + tracing::info!( + task_id = %task_id, + path = %repo_path.display(), + project_name = ?project_name, + "Initializing new git repository" + ); + + // Create directory + tokio::fs::create_dir_all(&repo_path).await?; + + // git init + let output = Command::new("git") + .args(["init"]) + .current_dir(&repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to init repository: {}", + stderr + ))); + } + + // Configure git user (needed for commits) + let _ = Command::new("git") + .args(["config", "user.email", "makima@localhost"]) + .current_dir(&repo_path) + .output() + .await; + let _ = Command::new("git") + .args(["config", "user.name", "Makima"]) + .current_dir(&repo_path) + .output() + .await; + + // Initial commit (required for worktrees to work later if needed) + let output = Command::new("git") + .args(["commit", "--allow-empty", "-m", "Initial commit"]) + .current_dir(&repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to create initial commit: {}", + stderr + ))); + } + + tracing::info!( + task_id = %task_id, + path = %repo_path.display(), + "New git repository initialized" + ); + + Ok(WorktreeInfo { + path: repo_path.clone(), + branch: "main".to_string(), + source_repo: repo_path, + }) + } + + // ========== Merge Operations ========== + + /// List all task branches in a repository. + /// + /// Returns branches matching the pattern `makima/task-*`. + pub async fn list_task_branches( + &self, + repo_path: &Path, + ) -> Result<Vec<TaskBranchInfo>, WorktreeError> { + // Get all branches matching our prefix + let output = Command::new("git") + .args([ + "branch", + "--list", + &format!("{}*", self.branch_prefix), + "--format=%(refname:short)|%(objectname:short)|%(subject)", + ]) + .current_dir(repo_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to list branches: {}", + stderr + ))); + } + + // Get list of merged branches + let merged_output = Command::new("git") + .args(["branch", "--merged", "HEAD", "--format=%(refname:short)"]) + .current_dir(repo_path) + .output() + .await?; + + let merged_branches: std::collections::HashSet<String> = if merged_output.status.success() { + String::from_utf8_lossy(&merged_output.stdout) + .lines() + .map(|s| s.trim().to_string()) + .collect() + } else { + std::collections::HashSet::new() + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut branches = Vec::new(); + + for line in stdout.lines() { + let parts: Vec<&str> = line.split('|').collect(); + if parts.len() >= 3 { + let name = parts[0].trim().to_string(); + let last_commit = parts[1].trim().to_string(); + let last_commit_message = parts[2].trim().to_string(); + + // Try to extract task ID from branch name + let task_id = name + .strip_prefix(&self.branch_prefix) + .and_then(|s| Uuid::parse_str(s).ok()); + + let is_merged = merged_branches.contains(&name); + + branches.push(TaskBranchInfo { + name, + task_id, + is_merged, + last_commit, + last_commit_message, + }); + } + } + + Ok(branches) + } + + /// Start a merge of a branch into the current worktree. + /// + /// Uses `--no-commit` to allow conflict resolution before committing. + /// Returns Ok(None) if merge succeeds without conflicts, or Ok(Some(files)) + /// with the list of conflicted files. + pub async fn merge_branch( + &self, + worktree_path: &Path, + source_branch: &str, + ) -> Result<Option<Vec<String>>, WorktreeError> { + // Check if there's already a merge in progress + if self.is_merge_in_progress(worktree_path).await? { + return Err(WorktreeError::MergeInProgress); + } + + tracing::info!( + worktree = %worktree_path.display(), + source_branch = %source_branch, + "Starting merge" + ); + + // Attempt the merge with --no-commit --no-ff + let output = Command::new("git") + .args(["merge", "--no-commit", "--no-ff", source_branch]) + .current_dir(worktree_path) + .output() + .await?; + + if output.status.success() { + tracing::info!("Merge completed without conflicts"); + return Ok(None); + } + + // Check if there are conflicts + let conflicts = self.get_conflicted_files(worktree_path).await?; + if !conflicts.is_empty() { + tracing::info!( + conflicts = ?conflicts, + "Merge has conflicts" + ); + return Ok(Some(conflicts)); + } + + // Other error + let stderr = String::from_utf8_lossy(&output.stderr); + Err(WorktreeError::GitCommand(format!( + "Merge failed: {}", + stderr + ))) + } + + /// Check if a merge is currently in progress. + pub async fn is_merge_in_progress(&self, worktree_path: &Path) -> Result<bool, WorktreeError> { + // Check for MERGE_HEAD file + let merge_head = worktree_path.join(".git").join("MERGE_HEAD"); + if merge_head.exists() { + return Ok(true); + } + + // Also check in .git file (for worktrees) + let git_file = worktree_path.join(".git"); + if git_file.is_file() { + if let Ok(content) = tokio::fs::read_to_string(&git_file).await { + if let Some(gitdir) = content.strip_prefix("gitdir: ") { + let gitdir = PathBuf::from(gitdir.trim()); + let merge_head = gitdir.join("MERGE_HEAD"); + if merge_head.exists() { + return Ok(true); + } + } + } + } + + Ok(false) + } + + /// Get the list of files with unresolved conflicts. + pub async fn get_conflicted_files( + &self, + worktree_path: &Path, + ) -> Result<Vec<String>, WorktreeError> { + let output = Command::new("git") + .args(["diff", "--name-only", "--diff-filter=U"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + // No conflicts or not in merge state + return Ok(Vec::new()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let files: Vec<String> = stdout + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + Ok(files) + } + + /// Get the current merge state. + pub async fn get_merge_state( + &self, + worktree_path: &Path, + ) -> Result<MergeState, WorktreeError> { + let in_progress = self.is_merge_in_progress(worktree_path).await?; + + if !in_progress { + return Ok(MergeState { + source_branch: String::new(), + conflicted_files: Vec::new(), + in_progress: false, + }); + } + + // Get the branch being merged from MERGE_HEAD + let source_branch = self.get_merge_source_branch(worktree_path).await?; + let conflicted_files = self.get_conflicted_files(worktree_path).await?; + + Ok(MergeState { + source_branch, + conflicted_files, + in_progress: true, + }) + } + + /// Get the branch name being merged (from MERGE_HEAD). + async fn get_merge_source_branch(&self, worktree_path: &Path) -> Result<String, WorktreeError> { + // Get MERGE_HEAD commit + let output = Command::new("git") + .args(["rev-parse", "MERGE_HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + return Ok("unknown".to_string()); + } + + let commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // Try to find branch name for this commit + let output = Command::new("git") + .args(["name-rev", "--name-only", &commit]) + .current_dir(worktree_path) + .output() + .await?; + + if output.status.success() { + let name = String::from_utf8_lossy(&output.stdout).trim().to_string(); + // Clean up the name (remove ~N suffixes, etc.) + let name = name.split('~').next().unwrap_or(&name); + let name = name.split('^').next().unwrap_or(name); + return Ok(name.to_string()); + } + + Ok(commit[..8.min(commit.len())].to_string()) + } + + /// Resolve a conflict in a specific file. + pub async fn resolve_conflict( + &self, + worktree_path: &Path, + file_path: &str, + resolution: ConflictResolution, + ) -> Result<(), WorktreeError> { + if !self.is_merge_in_progress(worktree_path).await? { + return Err(WorktreeError::NoMergeInProgress); + } + + let strategy = match resolution { + ConflictResolution::Ours => "--ours", + ConflictResolution::Theirs => "--theirs", + }; + + tracing::info!( + worktree = %worktree_path.display(), + file = %file_path, + strategy = %strategy, + "Resolving conflict" + ); + + // Checkout the chosen version + let output = Command::new("git") + .args(["checkout", strategy, "--", file_path]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to resolve conflict: {}", + stderr + ))); + } + + // Stage the resolved file + let output = Command::new("git") + .args(["add", file_path]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to stage resolved file: {}", + stderr + ))); + } + + Ok(()) + } + + /// Abort the current merge. + pub async fn abort_merge(&self, worktree_path: &Path) -> Result<(), WorktreeError> { + if !self.is_merge_in_progress(worktree_path).await? { + return Err(WorktreeError::NoMergeInProgress); + } + + tracing::info!( + worktree = %worktree_path.display(), + "Aborting merge" + ); + + let output = Command::new("git") + .args(["merge", "--abort"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to abort merge: {}", + stderr + ))); + } + + Ok(()) + } + + /// Commit the current merge. + pub async fn commit_merge( + &self, + worktree_path: &Path, + message: &str, + ) -> Result<String, WorktreeError> { + // Check for remaining conflicts + let conflicts = self.get_conflicted_files(worktree_path).await?; + if !conflicts.is_empty() { + return Err(WorktreeError::MergeConflicts(conflicts.join(", "))); + } + + tracing::info!( + worktree = %worktree_path.display(), + message = %message, + "Committing merge" + ); + + let output = Command::new("git") + .args(["commit", "-m", message]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to commit merge: {}", + stderr + ))); + } + + // Get the new commit SHA + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + if output.status.success() { + let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); + return Ok(sha); + } + + Ok("unknown".to_string()) + } + + // ========== Completion Action Operations ========== + + /// Push task branch from worktree to an external target repository. + /// + /// This stages and commits any uncommitted changes, then pushes to the target repo. + pub async fn push_to_target_repo( + &self, + worktree_path: &Path, + target_repo: &Path, + branch_name: &str, + task_name: &str, + ) -> Result<(), WorktreeError> { + tracing::info!( + worktree = %worktree_path.display(), + target_repo = %target_repo.display(), + branch = %branch_name, + "Pushing branch to target repository" + ); + + // First, stage all changes (including new files) + let output = Command::new("git") + .args(["add", "-A"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to stage changes: {}", + stderr + ))); + } + + // Check if there are staged changes to commit + let output = Command::new("git") + .args(["diff", "--cached", "--quiet"]) + .current_dir(worktree_path) + .output() + .await?; + + // Exit code 1 means there are staged changes + if !output.status.success() { + tracing::info!("Committing staged changes before push"); + + let commit_message = format!("feat: {}", task_name); + let output = Command::new("git") + .args(["commit", "-m", &commit_message]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to commit changes: {}", + stderr + ))); + } + } + + // Ensure there are commits to push + let output = Command::new("git") + .args(["log", "--oneline", "-1"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + return Err(WorktreeError::GitCommand( + "No commits in worktree".to_string(), + )); + } + + // Add target repo as a remote in the worktree (if not already) + let remote_name = "target"; + let target_path_str = target_repo.to_string_lossy(); + + // Remove existing remote if any (ignore errors) + let _ = Command::new("git") + .args(["remote", "remove", remote_name]) + .current_dir(worktree_path) + .output() + .await; + + // Add the target as a remote + let output = Command::new("git") + .args(["remote", "add", remote_name, &target_path_str]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to add remote: {}", + stderr + ))); + } + + // Push the branch to the target + let output = Command::new("git") + .args(["push", "-u", remote_name, &format!("HEAD:{}", branch_name)]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to push to target: {}", + stderr + ))); + } + + tracing::info!( + branch = %branch_name, + target_repo = %target_repo.display(), + "Branch pushed successfully" + ); + + // Detach HEAD in the worktree to release the branch + // This allows the branch to be checked out in the target repo + let output = Command::new("git") + .args(["checkout", "--detach", "HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + // Non-fatal: log but don't fail the push + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "Failed to detach HEAD in worktree (branch may not be checkable in target): {}", + stderr + ); + } else { + tracing::info!("Detached HEAD in worktree to release branch"); + } + + Ok(()) + } + + /// Merge a branch into the target branch in the target repository. + /// + /// This pushes the branch first (if needed), then performs a merge in the target repo. + pub async fn merge_to_target( + &self, + worktree_path: &Path, + target_repo: &Path, + source_branch: &str, + target_branch: &str, + task_name: &str, + ) -> Result<String, WorktreeError> { + tracing::info!( + worktree = %worktree_path.display(), + target_repo = %target_repo.display(), + source_branch = %source_branch, + target_branch = %target_branch, + "Merging branch to target" + ); + + // First, push the branch to target repo + self.push_to_target_repo(worktree_path, target_repo, source_branch, task_name) + .await?; + + // In target repo, checkout the target branch + let output = Command::new("git") + .args(["checkout", target_branch]) + .current_dir(target_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to checkout target branch: {}", + stderr + ))); + } + + // Pull latest changes first + let _ = Command::new("git") + .args(["pull", "--ff-only"]) + .current_dir(target_repo) + .output() + .await; + + // Merge the source branch + let merge_message = format!("feat: {}", task_name); + let output = Command::new("git") + .args(["merge", "--no-ff", source_branch, "-m", &merge_message]) + .current_dir(target_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + + // Check if it's a conflict + let conflicts = self.get_conflicted_files(target_repo).await?; + if !conflicts.is_empty() { + // Abort the merge + let _ = Command::new("git") + .args(["merge", "--abort"]) + .current_dir(target_repo) + .output() + .await; + + return Err(WorktreeError::MergeConflicts(format!( + "Merge conflicts in: {}. Consider creating a PR instead.", + conflicts.join(", ") + ))); + } + + return Err(WorktreeError::GitCommand(format!( + "Failed to merge: {}", + stderr + ))); + } + + // Get the merge commit SHA + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(target_repo) + .output() + .await?; + + let commit_sha = if output.status.success() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + "unknown".to_string() + }; + + tracing::info!( + commit_sha = %commit_sha, + "Merge completed successfully" + ); + + Ok(commit_sha) + } + + /// Create a GitHub pull request using the gh CLI. + /// + /// This pushes the branch first, then creates a PR. + pub async fn create_pull_request( + &self, + worktree_path: &Path, + target_repo: &Path, + source_branch: &str, + target_branch: &str, + title: &str, + body: &str, + ) -> Result<String, WorktreeError> { + tracing::info!( + worktree = %worktree_path.display(), + target_repo = %target_repo.display(), + source_branch = %source_branch, + target_branch = %target_branch, + title = %title, + "Creating pull request" + ); + + // First, push the branch to the target repo's remote + // For PRs, we need to push to origin (the GitHub remote) + + // Get the worktree's current branch + let output = Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(worktree_path) + .output() + .await?; + + let current_branch = if output.status.success() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + source_branch.to_string() + }; + + // Push to the target repo's origin + // First, check if target_repo has an origin remote + let output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .current_dir(target_repo) + .output() + .await?; + + if !output.status.success() { + return Err(WorktreeError::GitCommand( + "Target repository has no origin remote configured".to_string(), + )); + } + + let origin_url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + // Push the branch from worktree to the remote + // First add the remote to worktree + let _ = Command::new("git") + .args(["remote", "remove", "pr-origin"]) + .current_dir(worktree_path) + .output() + .await; + + let output = Command::new("git") + .args(["remote", "add", "pr-origin", &origin_url]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to add remote: {}", + stderr + ))); + } + + // Push to the remote + let output = Command::new("git") + .args(["push", "-u", "pr-origin", &format!("{}:{}", current_branch, source_branch)]) + .current_dir(worktree_path) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to push branch: {}", + stderr + ))); + } + + // Create PR using gh CLI in the target repo + let output = Command::new("gh") + .args([ + "pr", + "create", + "--title", title, + "--body", body, + "--head", source_branch, + "--base", target_branch, + ]) + .current_dir(target_repo) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::GitCommand(format!( + "Failed to create PR: {}", + stderr + ))); + } + + // The gh CLI outputs the PR URL + let pr_url = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + tracing::info!( + pr_url = %pr_url, + "Pull request created successfully" + ); + + Ok(pr_url) + } + + /// Clone/copy the worktree contents to a target directory. + /// + /// This creates a new git repository at the target path with the same contents + /// as the worktree. Returns (success, message). + pub async fn clone_worktree_to_directory( + &self, + worktree_path: &Path, + target_dir: &Path, + ) -> Result<String, WorktreeError> { + tracing::info!( + worktree = %worktree_path.display(), + target = %target_dir.display(), + "Cloning worktree to target directory" + ); + + // Check if target directory already exists + if target_dir.exists() { + return Err(WorktreeError::AlreadyExists(format!( + "Target directory already exists: {}", + target_dir.display() + ))); + } + + // Get parent directory to ensure it exists + if let Some(parent) = target_dir.parent() { + if !parent.exists() { + tokio::fs::create_dir_all(parent).await?; + } + } + + // Use git clone --local to efficiently copy the repository + // This is more efficient than cp -r for git repos + let output = Command::new("git") + .args([ + "clone", + "--local", + "--no-hardlinks", + &worktree_path.to_string_lossy(), + &target_dir.to_string_lossy(), + ]) + .output() + .await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(WorktreeError::CloneFailed(format!( + "Failed to clone worktree: {}", + stderr + ))); + } + + // Remove the 'origin' remote that points back to the worktree + let _ = Command::new("git") + .args(["remote", "remove", "origin"]) + .current_dir(target_dir) + .output() + .await; + + tracing::info!( + target = %target_dir.display(), + "Worktree cloned successfully" + ); + + Ok(format!("Cloned to {}", target_dir.display())) + } + + /// Check if a target directory exists. + pub async fn target_directory_exists(&self, target_dir: &Path) -> bool { + target_dir.exists() + } +} + +/// Check if repo_source is a "new repo" request. +/// +/// Accepts `new://` or `new://project-name` to create a fresh git repository. +pub fn is_new_repo_request(source: &str) -> bool { + source == "new" || source == "new://" || source.starts_with("new://") +} + +/// Extract optional project name from new:// URL. +fn extract_new_repo_name(source: &str) -> Option<&str> { + source.strip_prefix("new://").filter(|s| !s.is_empty()) +} + +/// Extract repository name from URL. +fn extract_repo_name(url: &str) -> String { + // Handle various URL formats: + // https://github.com/user/repo.git -> repo + // git@github.com:user/repo.git -> repo + // https://github.com/user/repo -> repo + + let url = url.trim_end_matches('/'); + let url = url.trim_end_matches(".git"); + + url.rsplit('/') + .next() + .or_else(|| url.rsplit(':').next()) + .unwrap_or("repo") + .to_string() +} + +/// Create a short UUID string for directory naming. +pub fn short_uuid(id: Uuid) -> String { + id.to_string()[..8].to_string() +} + +/// Expand tilde (~) in path to home directory. +pub fn expand_tilde(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(rest); + } + } else if path == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + } + PathBuf::from(path) +} + +/// Sanitize a name for use in directory/branch names. +pub fn sanitize_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::<String>() + .chars() + .take(50) // Limit length + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_repo_name() { + assert_eq!( + extract_repo_name("https://github.com/user/repo.git"), + "repo" + ); + assert_eq!( + extract_repo_name("https://github.com/user/repo"), + "repo" + ); + assert_eq!( + extract_repo_name("git@github.com:user/repo.git"), + "repo" + ); + } + + #[test] + fn test_sanitize_name() { + assert_eq!(sanitize_name("Hello World!"), "hello-world-"); + assert_eq!(sanitize_name("test_name-123"), "test_name-123"); + assert_eq!(sanitize_name("A".repeat(100).as_str()).len(), 50); + } + + #[test] + fn test_short_uuid() { + let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + assert_eq!(short_uuid(id), "550e8400"); + } +} |
