summaryrefslogtreecommitdiff
path: root/makima/daemon/src/worktree
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-11 05:52:14 +0000
committersoryu <soryu@soryu.co>2026-01-15 00:21:16 +0000
commit87044a747b47bd83249d61a45842c7f7b2eae56d (patch)
treeef2000ce79ffcc2723ef841acef5aa1deb1d5378 /makima/daemon/src/worktree
parent077820c4167c168072d217a1b01df840463a12a8 (diff)
downloadsoryu-87044a747b47bd83249d61a45842c7f7b2eae56d.tar.gz
soryu-87044a747b47bd83249d61a45842c7f7b2eae56d.zip
Contract system
Diffstat (limited to 'makima/daemon/src/worktree')
-rw-r--r--makima/daemon/src/worktree/manager.rs1623
-rw-r--r--makima/daemon/src/worktree/mod.rs11
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,
-};