//! 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.
/// Works for both regular and bare repositories.
pub async fn detect_default_branch(&self, repo_path: &Path) -> Result<String, WorktreeError> {
tracing::debug!("Detecting default branch for repo: {}", repo_path.display());
// First, try to read HEAD directly (works for bare repos)
// In bare repos, HEAD is a symbolic ref to the default branch
let output = Command::new("git")
.args(["symbolic-ref", "HEAD", "--short"])
.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() {
tracing::debug!("Detected default branch from HEAD: {}", branch);
return Ok(branch);
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::debug!("symbolic-ref HEAD failed: {}", stderr.trim());
}
// Try to get the branch that origin/HEAD points to (for regular clones)
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() {
tracing::debug!("Detected default branch from origin/HEAD: {}", branch);
return Ok(branch);
}
}
// Try common branch names in refs/heads (works for bare and regular repos)
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() {
tracing::debug!("Detected default branch from refs/heads: {}", branch);
return Ok(branch.to_string());
}
}
// Fall back to getting the current branch (for regular repos)
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" {
tracing::debug!("Detected default branch from rev-parse: {}", branch);
return Ok(branch);
}
}
// Final fallback: list all branches and pick the first one
let output = Command::new("git")
.args(["for-each-ref", "--format=%(refname:short)", "refs/heads/", "--count=1"])
.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() {
tracing::warn!("Using first available branch as fallback: {}", branch);
return Ok(branch);
}
}
// Log what branches exist for debugging
let output = Command::new("git")
.args(["for-each-ref", "--format=%(refname)", "refs/"])
.current_dir(repo_path)
.output()
.await?;
let available_refs = String::from_utf8_lossy(&output.stdout);
tracing::error!(
"Could not detect default branch. Available refs:\n{}",
available_refs
);
Err(WorktreeError::GitCommand(
format!("Could not detect default branch. Check if the repository at {} has any branches.", repo_path.display()),
))
}
/// 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?;
// Prune stale worktree entries first (handles "missing but registered" errors)
let _ = Command::new("git")
.args(["worktree", "prune"])
.current_dir(source_repo)
.output()
.await;
// Check if the branch already exists (e.g., from a previous run of the same task)
let branch_exists = Command::new("git")
.args(["rev-parse", "--verify", &format!("refs/heads/{}", branch_name)])
.current_dir(source_repo)
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if branch_exists {
tracing::info!(
task_id = %task_id,
worktree_path = %worktree_path.display(),
branch = %branch_name,
"Branch already exists, creating worktree from existing branch"
);
// Use existing branch - try without force first, then with force
let output = Command::new("git")
.args(["worktree", "add", "-f"])
.arg(&worktree_path)
.arg(&branch_name)
.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 from existing branch: {}",
stderr
)));
}
} else {
tracing::info!(
task_id = %task_id,
worktree_path = %worktree_path.display(),
branch = %branch_name,
base_branch = %base_branch,
"Creating worktree with new 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?;
// Prune stale worktree entries first (handles "missing but registered" errors)
let _ = Command::new("git")
.args(["worktree", "prune"])
.current_dir(&source_repo)
.output()
.await;
// Check if the branch already exists (e.g., from a previous run of the same task)
let branch_exists = Command::new("git")
.args(["rev-parse", "--verify", &format!("refs/heads/{}", branch_name)])
.current_dir(&source_repo)
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if branch_exists {
tracing::info!(
task_id = %task_id,
source_worktree = %source_worktree.display(),
worktree_path = %worktree_path.display(),
branch = %branch_name,
"Branch already exists, creating worktree from existing branch"
);
// Use existing branch with force flag
let output = Command::new("git")
.args(["worktree", "add", "-f"])
.arg(&worktree_path)
.arg(&branch_name)
.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 from existing branch: {}",
stderr
)));
}
} else {
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 with new branch"
);
// 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 crate::daemon::*;
#[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");
}
}