diff options
Diffstat (limited to 'makima/daemon/src/worktree')
| -rw-r--r-- | makima/daemon/src/worktree/manager.rs | 1623 | ||||
| -rw-r--r-- | makima/daemon/src/worktree/mod.rs | 11 |
2 files changed, 0 insertions, 1634 deletions
diff --git a/makima/daemon/src/worktree/manager.rs b/makima/daemon/src/worktree/manager.rs deleted file mode 100644 index 266b970..0000000 --- a/makima/daemon/src/worktree/manager.rs +++ /dev/null @@ -1,1623 +0,0 @@ -//! 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"); - } -} diff --git a/makima/daemon/src/worktree/mod.rs b/makima/daemon/src/worktree/mod.rs deleted file mode 100644 index eb9f031..0000000 --- a/makima/daemon/src/worktree/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Git worktree management for task isolation. -//! -//! Each task gets a unique git worktree with its own branch, -//! providing isolation without the overhead of Docker containers. - -mod manager; - -pub use manager::{ - expand_tilde, is_new_repo_request, sanitize_name, short_uuid, ConflictResolution, MergeState, - TaskBranchInfo, WorktreeError, WorktreeInfo, WorktreeManager, -}; |
