//! 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, /// 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, /// 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>>>> = 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 { 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 { // 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 ))); } // Check if 'origin' remote exists let origin_check = Command::new("git") .args(["remote", "get-url", "origin"]) .current_dir(&path) .output() .await; let has_origin = origin_check .map(|o| o.status.success()) .unwrap_or(false); if has_origin { // Fetch from origin specifically to get the latest changes tracing::info!("Fetching latest from origin for local repo: {}", repo_source); let fetch_output = Command::new("git") .args(["fetch", "origin"]) .current_dir(&path) .output() .await?; if !fetch_output.status.success() { let stderr = String::from_utf8_lossy(&fetch_output.stderr); tracing::warn!("Git fetch from origin failed (continuing anyway): {}", stderr); } else { tracing::info!("Successfully fetched latest changes from origin for {}", repo_source); } } else { tracing::debug!("Local repo has no origin remote: {}", repo_source); } // Fetch from all remotes (includes any other remotes besides origin) 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 --all for local repo (may not have remote): {}", stderr); } Ok(path) } } /// Clone a repository or fetch if already cloned. async fn clone_or_fetch_repo(&self, url: &str) -> Result { // 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() { // Verify this is actually a git repository before trying to fetch let is_git_repo = Command::new("git") .args(["rev-parse", "--is-bare-repository"]) .current_dir(&repo_path) .output() .await .map(|o| o.status.success()) .unwrap_or(false); if !is_git_repo { // Directory exists but is not a git repository - remove and re-clone tracing::warn!( "Directory {} exists but is not a git repository, removing and re-cloning", repo_path.display() ); tokio::fs::remove_dir_all(&repo_path).await?; // Fall through to clone below } else { // 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 } return Ok(repo_path); } } // 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 { // 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" ); // Fetch latest from remote to ensure origin refs are fresh let _ = Command::new("git") .args(["fetch", "origin", "--prune"]) .current_dir(source_repo) .output() .await; // 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(start_point) .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 { // 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(), }) } /// Find and create a worktree from a previously pushed branch for a task. /// /// Searches local and remote branches for ones matching the task's UUID, /// then creates a new worktree based on that branch. pub async fn create_worktree_from_task_branch( &self, source_repo: &Path, from_task_id: Uuid, new_task_id: Uuid, new_task_name: &str, ) -> Result { let from_short_id = short_uuid(from_task_id); let from_full_id = from_task_id.to_string(); tracing::info!( from_task_id = %from_task_id, new_task_id = %new_task_id, "Searching for pushed branch to continue from" ); // Fetch latest from remote first let _ = Command::new("git") .args(["fetch", "--all", "--prune"]) .current_dir(source_repo) .output() .await; // Search for branches matching the task ID (both local and remote) let output = Command::new("git") .args(["branch", "-a", "--format=%(refname:short)"]) .current_dir(source_repo) .output() .await?; if !output.status.success() { return Err(WorktreeError::GitCommand( "Failed to list branches".to_string(), )); } let branches_output = String::from_utf8_lossy(&output.stdout); let mut found_branch: Option = None; for line in branches_output.lines() { let b = line.trim(); if b.is_empty() { continue; } // Match branches containing the full UUID or short UUID if b.contains(&from_full_id) || b.contains(&from_short_id) { // Prefer local branches, but accept remote if let Some(remote_branch) = b.strip_prefix("origin/") { if found_branch.is_none() { found_branch = Some(remote_branch.to_string()); } } else { found_branch = Some(b.to_string()); } } } let from_branch = found_branch.ok_or_else(|| { WorktreeError::GitCommand(format!( "No branch found for task {} in repository", from_short_id )) })?; tracing::info!( from_task_id = %from_task_id, from_branch = %from_branch, "Found pushed branch, creating worktree from it" ); // Create a worktree using the found branch as the base self.create_worktree(source_repo, new_task_id, new_task_name, &from_branch) .await } /// 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 { 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 { // 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 varies by repo type: // Non-bare: "gitdir: /path/to/repo/.git/worktrees/name" // Bare: "gitdir: /path/to/bare-repo/worktrees/name" if let Some(gitdir) = content.strip_prefix("gitdir: ") { let gitdir = gitdir.trim(); let path = PathBuf::from(gitdir); // Navigate up from worktrees/name to the git directory if let Some(worktrees_dir) = path.parent() { if let Some(git_dir) = worktrees_dir.parent() { // For bare repos, git_dir IS the repo (e.g. ~/.makima/repos/foo/) // For non-bare repos, git_dir is /.git/, need one more parent if git_dir.file_name() == Some(std::ffi::OsStr::new(".git")) { // Non-bare: go up from .git/ to the repo root if let Some(repo_dir) = git_dir.parent() { return Ok(repo_dir.to_path_buf()); } } else { // Bare repo: git_dir is already the repo return Ok(git_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, 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 { 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, 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 = 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>, 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 { // 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, 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 = 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 { 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 { // 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 { // 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(()) } /// Push a worktree branch to origin (the upstream GitHub remote). /// Simpler than push_to_target_repo — just pushes to origin. pub async fn push_branch_to_origin( &self, worktree_path: &Path, branch_name: &str, task_name: &str, ) -> Result<(), WorktreeError> { tracing::info!( worktree = %worktree_path.display(), branch = %branch_name, "Pushing branch to origin" ); // Stage all changes 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 to origin"); 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(), )); } // Push to origin let output = Command::new("git") .args(["push", "-u", "origin", &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 origin: {}", stderr ))); } tracing::info!( branch = %branch_name, "Branch pushed to origin successfully" ); 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 { 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. /// If target_repo is None, uses the worktree's origin remote directly (for repos already cloned from remote). pub async fn create_pull_request( &self, worktree_path: &Path, target_repo: Option<&Path>, source_branch: &str, target_branch: &str, title: &str, body: &str, ) -> Result { tracing::info!( worktree = %worktree_path.display(), target_repo = ?target_repo.map(|p| p.display().to_string()), 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() }; // Get the origin URL - either from target_repo or from worktree directly let (origin_url, gh_working_dir) = if let Some(target_repo) = target_repo { // Use target_repo's origin 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 url = String::from_utf8_lossy(&output.stdout).trim().to_string(); (url, target_repo.to_path_buf()) } else { // Check if worktree has an origin remote directly let output = Command::new("git") .args(["remote", "get-url", "origin"]) .current_dir(worktree_path) .output() .await?; if !output.status.success() { return Err(WorktreeError::GitCommand( "Repository has no origin remote configured. Either set target_repo_path or ensure the worktree was cloned from a remote repository.".to_string(), )); } let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); (url, worktree_path.to_path_buf()) }; // Push the branch from worktree to the remote // First add the remote to worktree (if not using worktree's origin directly) if target_repo.is_some() { 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 ))); } } else { // Push directly to origin let output = Command::new("git") .args(["push", "-u", "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 let output = Command::new("gh") .args([ "pr", "create", "--title", title, "--body", body, "--head", source_branch, "--base", target_branch, ]) .current_dir(&gh_working_dir) .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 { 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() } /// Restore a worktree from a stored patch. /// /// This is used for task recovery when the local worktree has been lost. /// 1. Clone/fetch the source repo to get the base commit /// 2. Create a new worktree at the base commit /// 3. Apply the patch to restore the task's state pub async fn restore_from_patch( &self, source_repo: &str, task_id: Uuid, task_name: &str, base_commit_sha: &str, patch_data: &[u8], ) -> Result { use crate::daemon::storage; // Generate directory and branch names 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, sanitize_name(task_name), short_uuid(task_id) ); // Ensure base directory exists tokio::fs::create_dir_all(&self.base_dir).await?; // Remove existing worktree if present (we're restoring from scratch) if worktree_path.exists() { tracing::info!( task_id = %task_id, worktree_path = %worktree_path.display(), "Removing existing worktree before restore" ); tokio::fs::remove_dir_all(&worktree_path).await?; } // Clone the source repo if needed let repo_path = self.ensure_repo(source_repo).await?; // Create worktree at the base commit // First, we need to make sure the base commit is available let fetch_output = Command::new("git") .args(["fetch", "--all"]) .current_dir(&repo_path) .output() .await?; if !fetch_output.status.success() { tracing::warn!( task_id = %task_id, stderr = %String::from_utf8_lossy(&fetch_output.stderr), "git fetch failed, continuing anyway" ); } // Create the worktree from the base commit let output = Command::new("git") .args([ "worktree", "add", "-b", &branch_name, worktree_path.to_str().ok_or_else(|| { WorktreeError::InvalidPath("Invalid worktree path".to_string()) })?, base_commit_sha, ]) .current_dir(&repo_path) .output() .await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); // If branch already exists, try without -b flag if stderr.contains("already exists") { // Remove the branch and try again let _ = Command::new("git") .args(["branch", "-D", &branch_name]) .current_dir(&repo_path) .output() .await; let retry_output = Command::new("git") .args([ "worktree", "add", "-b", &branch_name, worktree_path.to_str().unwrap(), base_commit_sha, ]) .current_dir(&repo_path) .output() .await?; if !retry_output.status.success() { return Err(WorktreeError::GitCommand(format!( "Failed to create worktree after retry: {}", String::from_utf8_lossy(&retry_output.stderr) ))); } } else { return Err(WorktreeError::GitCommand(format!( "Failed to create worktree: {}", stderr ))); } } // Apply the patch to restore the task's state if let Err(e) = storage::apply_patch(&worktree_path, patch_data).await { tracing::error!( task_id = %task_id, error = %e, "Failed to apply patch, worktree is at base commit" ); // Don't fail - the worktree is usable, just at the base commit } else { tracing::info!( task_id = %task_id, worktree_path = %worktree_path.display(), "Successfully restored worktree from patch" ); } Ok(WorktreeInfo { path: worktree_path, branch: branch_name, source_repo: repo_path, }) } } /// 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::() .chars() .take(50) // Limit length .collect() } #[cfg(test)] mod tests { use super::*; use uuid::Uuid; #[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"); } }