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