Skip to main content

autom8/
worktree.rs

1//! Git worktree operations for autom8.
2//!
3//! This module provides functions for managing git worktrees, enabling
4//! parallel execution of autom8 sessions on the same project.
5
6use crate::error::{Autom8Error, Result};
7use sha2::{Digest, Sha256};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11/// Information about a git worktree.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct WorktreeInfo {
14    /// Absolute path to the worktree directory
15    pub path: PathBuf,
16    /// The branch checked out in this worktree (None for detached HEAD)
17    pub branch: Option<String>,
18    /// The current commit hash
19    pub commit: String,
20    /// Whether this is the main worktree (the original repo)
21    pub is_main: bool,
22    /// Whether this worktree is bare (no working directory)
23    pub is_bare: bool,
24    /// Whether the worktree is currently locked
25    pub is_locked: bool,
26    /// Whether the worktree is prunable (missing directory)
27    pub is_prunable: bool,
28}
29
30impl WorktreeInfo {
31    /// Parse a single worktree from porcelain output lines.
32    ///
33    /// The porcelain format outputs one attribute per line, with worktrees
34    /// separated by blank lines.
35    fn from_porcelain_lines(lines: &[&str]) -> Option<Self> {
36        let mut path: Option<PathBuf> = None;
37        let mut branch: Option<String> = None;
38        let mut commit: Option<String> = None;
39        let mut is_bare = false;
40        let mut is_locked = false;
41        let mut is_prunable = false;
42
43        for line in lines {
44            if let Some(rest) = line.strip_prefix("worktree ") {
45                path = Some(PathBuf::from(rest));
46            } else if let Some(rest) = line.strip_prefix("HEAD ") {
47                commit = Some(rest.to_string());
48            } else if let Some(rest) = line.strip_prefix("branch ") {
49                // Branch is in format "refs/heads/branch-name"
50                let branch_name = rest.strip_prefix("refs/heads/").unwrap_or(rest).to_string();
51                branch = Some(branch_name);
52            } else if *line == "bare" {
53                is_bare = true;
54            } else if *line == "detached" {
55                // Detached HEAD - branch remains None
56            } else if line.starts_with("locked") {
57                is_locked = true;
58            } else if line.starts_with("prunable") {
59                is_prunable = true;
60            }
61        }
62
63        let path = path?;
64        let commit = commit?;
65
66        // The first worktree listed is always the main worktree
67        // We'll set this properly in list_worktrees()
68        Some(WorktreeInfo {
69            path,
70            branch,
71            commit,
72            is_main: false,
73            is_bare,
74            is_locked,
75            is_prunable,
76        })
77    }
78}
79
80/// List all worktrees for the current repository.
81///
82/// Returns information about each worktree including path, branch, and commit.
83/// The main repository is always included in the list with `is_main: true`.
84///
85/// # Returns
86/// * `Ok(Vec<WorktreeInfo>)` - List of worktrees (always has at least one - the main repo)
87/// * `Err` - If not in a git repository or git command fails
88pub fn list_worktrees() -> Result<Vec<WorktreeInfo>> {
89    let output = Command::new("git")
90        .args(["worktree", "list", "--porcelain"])
91        .output()?;
92
93    if !output.status.success() {
94        let stderr = String::from_utf8_lossy(&output.stderr);
95        return Err(Autom8Error::WorktreeError(format!(
96            "Failed to list worktrees: {}",
97            stderr.trim()
98        )));
99    }
100
101    let stdout = String::from_utf8_lossy(&output.stdout);
102    let worktrees = parse_worktree_list_porcelain(&stdout)?;
103
104    Ok(worktrees)
105}
106
107/// Parse the output of `git worktree list --porcelain`.
108///
109/// The porcelain format is machine-readable with one attribute per line,
110/// and worktrees separated by blank lines.
111fn parse_worktree_list_porcelain(output: &str) -> Result<Vec<WorktreeInfo>> {
112    let mut worktrees = Vec::new();
113    let mut current_lines: Vec<&str> = Vec::new();
114    let mut is_first = true;
115
116    for line in output.lines() {
117        if line.is_empty() {
118            // End of a worktree block
119            if !current_lines.is_empty() {
120                if let Some(mut wt) = WorktreeInfo::from_porcelain_lines(&current_lines) {
121                    // First worktree in the list is always the main worktree
122                    wt.is_main = is_first;
123                    is_first = false;
124                    worktrees.push(wt);
125                }
126                current_lines.clear();
127            }
128        } else {
129            current_lines.push(line);
130        }
131    }
132
133    // Don't forget the last worktree (output may not end with blank line)
134    if !current_lines.is_empty() {
135        if let Some(mut wt) = WorktreeInfo::from_porcelain_lines(&current_lines) {
136            wt.is_main = is_first;
137            worktrees.push(wt);
138        }
139    }
140
141    Ok(worktrees)
142}
143
144/// Create a new worktree at the specified path for the given branch.
145///
146/// If the branch already exists, it will be checked out in the new worktree.
147/// If the branch doesn't exist, it will be created from the current HEAD.
148///
149/// # Arguments
150/// * `path` - The path where the worktree should be created
151/// * `branch` - The branch name to checkout or create
152///
153/// # Returns
154/// * `Ok(())` - Worktree created successfully
155/// * `Err` - If creation fails (e.g., branch already checked out elsewhere)
156pub fn create_worktree<P: AsRef<Path>>(path: P, branch: &str) -> Result<()> {
157    let path = path.as_ref();
158
159    // First, check if branch exists
160    let branch_exists = Command::new("git")
161        .args([
162            "show-ref",
163            "--verify",
164            "--quiet",
165            &format!("refs/heads/{}", branch),
166        ])
167        .output()?
168        .status
169        .success();
170
171    let output = if branch_exists {
172        // Branch exists, just add worktree
173        Command::new("git")
174            .args(["worktree", "add", path.to_string_lossy().as_ref(), branch])
175            .output()?
176    } else {
177        // Create new branch with -b flag
178        Command::new("git")
179            .args([
180                "worktree",
181                "add",
182                "-b",
183                branch,
184                path.to_string_lossy().as_ref(),
185            ])
186            .output()?
187    };
188
189    if !output.status.success() {
190        let stderr = String::from_utf8_lossy(&output.stderr);
191        return Err(Autom8Error::WorktreeError(format!(
192            "Failed to create worktree at '{}' for branch '{}': {}",
193            path.display(),
194            branch,
195            stderr.trim()
196        )));
197    }
198
199    Ok(())
200}
201
202/// Remove a worktree at the specified path.
203///
204/// By default, this will fail if the worktree has uncommitted changes.
205/// Use `force: true` to remove even with uncommitted changes.
206///
207/// # Arguments
208/// * `path` - The path of the worktree to remove
209/// * `force` - If true, remove even if the worktree has uncommitted changes
210///
211/// # Returns
212/// * `Ok(())` - Worktree removed successfully
213/// * `Err` - If removal fails
214pub fn remove_worktree<P: AsRef<Path>>(path: P, force: bool) -> Result<()> {
215    let path = path.as_ref();
216    let path_str = path.to_string_lossy();
217
218    let mut args = vec!["worktree", "remove"];
219    if force {
220        args.push("--force");
221    }
222    args.push(path_str.as_ref());
223
224    let output = Command::new("git").args(&args).output()?;
225
226    if !output.status.success() {
227        let stderr = String::from_utf8_lossy(&output.stderr);
228        return Err(Autom8Error::WorktreeError(format!(
229            "Failed to remove worktree at '{}': {}",
230            path.display(),
231            stderr.trim()
232        )));
233    }
234
235    Ok(())
236}
237
238/// Get the worktree root for the current directory.
239///
240/// If the current directory is inside a linked worktree (not the main repo),
241/// returns the root path of that worktree. Returns None if in the main repo.
242///
243/// # Returns
244/// * `Ok(Some(path))` - The worktree root if in a linked worktree
245/// * `Ok(None)` - If in the main repository (not a linked worktree)
246/// * `Err` - If not in a git repository
247pub fn get_worktree_root() -> Result<Option<PathBuf>> {
248    // git rev-parse --git-common-dir returns the .git dir of the main repo
249    // git rev-parse --git-dir returns the .git dir of the current worktree
250    // If they're different, we're in a linked worktree
251
252    let git_dir_output = Command::new("git")
253        .args(["rev-parse", "--git-dir"])
254        .output()?;
255
256    if !git_dir_output.status.success() {
257        let stderr = String::from_utf8_lossy(&git_dir_output.stderr);
258        return Err(Autom8Error::WorktreeError(format!(
259            "Failed to get git directory: {}",
260            stderr.trim()
261        )));
262    }
263
264    let git_dir = String::from_utf8_lossy(&git_dir_output.stdout)
265        .trim()
266        .to_string();
267
268    // In a linked worktree, git-dir points to .git/worktrees/<name>
269    // The gitdir file inside contains the path we need to check
270    if git_dir.contains("/worktrees/") || git_dir.contains("\\worktrees\\") {
271        // We're in a worktree - get the toplevel
272        let toplevel_output = Command::new("git")
273            .args(["rev-parse", "--show-toplevel"])
274            .output()?;
275
276        if !toplevel_output.status.success() {
277            let stderr = String::from_utf8_lossy(&toplevel_output.stderr);
278            return Err(Autom8Error::WorktreeError(format!(
279                "Failed to get worktree root: {}",
280                stderr.trim()
281            )));
282        }
283
284        let toplevel = String::from_utf8_lossy(&toplevel_output.stdout)
285            .trim()
286            .to_string();
287        return Ok(Some(PathBuf::from(toplevel)));
288    }
289
290    Ok(None)
291}
292
293/// Get the main repository root (works from any worktree).
294///
295/// Returns the path to the main repository, regardless of whether
296/// the current directory is in the main repo or a linked worktree.
297///
298/// # Returns
299/// * `Ok(path)` - The main repository root path
300/// * `Err` - If not in a git repository
301pub fn get_main_repo_root() -> Result<PathBuf> {
302    // git rev-parse --git-common-dir gives us the path to the main .git directory
303    let output = Command::new("git")
304        .args(["rev-parse", "--git-common-dir"])
305        .output()?;
306
307    if !output.status.success() {
308        let stderr = String::from_utf8_lossy(&output.stderr);
309        return Err(Autom8Error::WorktreeError(format!(
310            "Failed to get main repo root: {}",
311            stderr.trim()
312        )));
313    }
314
315    let git_common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
316
317    // The common dir is the .git directory - we want its parent
318    let git_path = PathBuf::from(&git_common_dir);
319
320    // Handle both .git file (in worktrees) and .git directory cases
321    // Also handle absolute vs relative paths
322    let main_repo_path = if git_path.is_absolute() {
323        git_path.parent().map(|p| p.to_path_buf())
324    } else {
325        // Relative path - resolve it
326        let current_dir = std::env::current_dir()?;
327        let absolute_git = current_dir.join(&git_path);
328        absolute_git
329            .canonicalize()
330            .ok()
331            .and_then(|p| p.parent().map(|p| p.to_path_buf()))
332    };
333
334    main_repo_path.ok_or_else(|| {
335        Autom8Error::WorktreeError("Failed to determine main repository root".to_string())
336    })
337}
338
339/// Check if the current working directory is inside a linked worktree.
340///
341/// Returns true if the CWD is inside a linked worktree (not the main repository).
342///
343/// # Returns
344/// * `Ok(true)` - CWD is inside a linked worktree
345/// * `Ok(false)` - CWD is inside the main repository
346/// * `Err` - If not in a git repository
347pub fn is_in_worktree() -> Result<bool> {
348    Ok(get_worktree_root()?.is_some())
349}
350
351/// Get the git repository name (basename of the main repo root).
352///
353/// This function returns the name of the git repository, which is the
354/// basename of the main repository root directory. This ensures consistent
355/// project identification regardless of whether you're in the main repo
356/// or a linked worktree.
357///
358/// # Returns
359/// * `Ok(Some(name))` - The repository name if in a git repository
360/// * `Ok(None)` - If not in a git repository
361/// * `Err` - If there's an error determining the repo name
362///
363/// # Example
364/// ```no_run
365/// use autom8::worktree::get_git_repo_name;
366///
367/// if let Some(name) = get_git_repo_name().expect("git error") {
368///     println!("Repository: {}", name);
369/// }
370/// ```
371pub fn get_git_repo_name() -> Result<Option<String>> {
372    // First check if we're in a git repo
373    let output = Command::new("git")
374        .args(["rev-parse", "--git-common-dir"])
375        .output()?;
376
377    if !output.status.success() {
378        // Not in a git repository - this is not an error, just means no git
379        let stderr = String::from_utf8_lossy(&output.stderr);
380        if stderr.contains("not a git repository") {
381            return Ok(None);
382        }
383        return Err(Autom8Error::WorktreeError(format!(
384            "Failed to check git repository: {}",
385            stderr.trim()
386        )));
387    }
388
389    // Get the main repo root (works from both main repo and worktrees)
390    let main_root = get_main_repo_root()?;
391
392    // Extract the basename
393    main_root
394        .file_name()
395        .and_then(|n| n.to_str())
396        .map(|s| Some(s.to_string()))
397        .ok_or_else(|| {
398            Autom8Error::WorktreeError("Could not determine repository name from path".to_string())
399        })
400}
401
402// ============================================================================
403// Session Identity System (US-002)
404// ============================================================================
405
406/// Well-known session ID for the main repository.
407pub const MAIN_SESSION_ID: &str = "main";
408
409/// Generate a deterministic session ID from a worktree path.
410///
411/// The session ID is derived from the SHA-256 hash of the absolute path,
412/// taking the first 8 characters. This ensures:
413/// - Determinism: same path always produces the same ID
414/// - Uniqueness: different paths produce different IDs (with high probability)
415/// - Filesystem safety: only alphanumeric characters (hex digits)
416/// - Readability: 8 characters is short but sufficient
417///
418/// # Arguments
419/// * `worktree_path` - The absolute path to the worktree directory
420///
421/// # Returns
422/// An 8-character hexadecimal string that uniquely identifies the worktree.
423///
424/// # Example
425/// ```
426/// use autom8::worktree::generate_session_id;
427/// use std::path::Path;
428///
429/// let id = generate_session_id(Path::new("/home/user/project-feature"));
430/// assert_eq!(id.len(), 8);
431/// assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
432/// ```
433pub fn generate_session_id(worktree_path: &Path) -> String {
434    let path_str = worktree_path.to_string_lossy();
435    let mut hasher = Sha256::new();
436    hasher.update(path_str.as_bytes());
437    let result = hasher.finalize();
438    // Take first 8 characters of hex representation (4 bytes = 8 hex chars)
439    hex::encode(&result[..4])
440}
441
442/// Get the session ID for the current working directory.
443///
444/// This function determines the appropriate session ID based on the current
445/// location:
446/// - If in the main repository: returns the well-known "main" session ID
447/// - If in a linked worktree: returns a hash-based ID from the worktree path
448///
449/// # Returns
450/// * `Ok(String)` - The session ID for the current directory
451/// * `Err` - If not in a git repository
452///
453/// # Example
454/// ```no_run
455/// use autom8::worktree::get_current_session_id;
456///
457/// let session_id = get_current_session_id().expect("Not in a git repo");
458/// println!("Session ID: {}", session_id);
459/// ```
460pub fn get_current_session_id() -> Result<String> {
461    // Check if we're in a linked worktree
462    if let Some(worktree_root) = get_worktree_root()? {
463        // In a linked worktree - generate ID from path
464        Ok(generate_session_id(&worktree_root))
465    } else {
466        // In main repository - use well-known ID
467        Ok(MAIN_SESSION_ID.to_string())
468    }
469}
470
471/// Get the session ID for the main repository.
472///
473/// This function returns the session ID that would be used when running
474/// from the main repository (not a linked worktree). This is useful for
475/// operations that need to reference the main session regardless of
476/// the current working directory.
477///
478/// # Returns
479/// The well-known "main" session ID.
480pub fn get_main_session_id() -> String {
481    MAIN_SESSION_ID.to_string()
482}
483
484/// Get the session ID for a specific worktree path.
485///
486/// This is a convenience function that combines path resolution with
487/// session ID generation. For the main repository path, it returns "main".
488/// For linked worktree paths, it generates a hash-based ID.
489///
490/// # Arguments
491/// * `path` - The path to resolve a session ID for
492///
493/// # Returns
494/// * `Ok(String)` - The session ID for the given path
495/// * `Err` - If the path is not in a git repository or cannot be resolved
496pub fn get_session_id_for_path(path: &Path) -> Result<String> {
497    // Get the absolute path
498    let abs_path = if path.is_absolute() {
499        path.to_path_buf()
500    } else {
501        std::env::current_dir()?.join(path)
502    };
503
504    // Get the main repo root to compare
505    let main_root = get_main_repo_root()?;
506
507    // Canonicalize both paths for reliable comparison
508    let abs_canonical = abs_path.canonicalize().unwrap_or(abs_path);
509    let main_canonical = main_root.canonicalize().unwrap_or(main_root);
510
511    // Check if this is the main repo
512    if abs_canonical == main_canonical {
513        Ok(MAIN_SESSION_ID.to_string())
514    } else {
515        Ok(generate_session_id(&abs_canonical))
516    }
517}
518
519// ============================================================================
520// Worktree Path Generation (US-007)
521// ============================================================================
522
523/// Convert a branch name to a filesystem-safe slug.
524///
525/// Replaces slashes with dashes, removes unsafe characters, and normalizes
526/// to produce a valid directory name component.
527///
528/// # Arguments
529/// * `branch_name` - The git branch name to slugify
530///
531/// # Returns
532/// A filesystem-safe version of the branch name.
533///
534/// # Example
535/// ```
536/// use autom8::worktree::slugify_branch_name;
537///
538/// assert_eq!(slugify_branch_name("feature/login"), "feature-login");
539/// assert_eq!(slugify_branch_name("feat/add-user-auth"), "feat-add-user-auth");
540/// ```
541pub fn slugify_branch_name(branch_name: &str) -> String {
542    branch_name
543        .chars()
544        .map(|c| {
545            if c == '/' || c == '\\' {
546                '-'
547            } else if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
548                c
549            } else {
550                '-'
551            }
552        })
553        .collect::<String>()
554        // Collapse multiple dashes into one
555        .split('-')
556        .filter(|s| !s.is_empty())
557        .collect::<Vec<_>>()
558        .join("-")
559}
560
561/// Generate the worktree path from a pattern and parameters.
562///
563/// Replaces placeholders in the pattern:
564/// - `{repo}` - The repository name
565/// - `{branch}` - The branch name (slugified)
566///
567/// The worktree is created as a sibling directory of the main repository.
568///
569/// # Arguments
570/// * `pattern` - The path pattern (e.g., "{repo}-wt-{branch}")
571/// * `repo_name` - The repository name
572/// * `branch_name` - The branch name (will be slugified)
573///
574/// # Returns
575/// The generated worktree directory name.
576///
577/// # Example
578/// ```
579/// use autom8::worktree::generate_worktree_name;
580///
581/// let name = generate_worktree_name("{repo}-wt-{branch}", "myproject", "feature/login");
582/// assert_eq!(name, "myproject-wt-feature-login");
583/// ```
584pub fn generate_worktree_name(pattern: &str, repo_name: &str, branch_name: &str) -> String {
585    let slugified_branch = slugify_branch_name(branch_name);
586    pattern
587        .replace("{repo}", repo_name)
588        .replace("{branch}", &slugified_branch)
589}
590
591/// Generate the full path for a worktree.
592///
593/// Creates the worktree as a sibling directory of the main repository.
594///
595/// # Arguments
596/// * `pattern` - The path pattern (e.g., "{repo}-wt-{branch}")
597/// * `branch_name` - The branch name (will be slugified)
598///
599/// # Returns
600/// * `Ok(PathBuf)` - The full path where the worktree should be created
601/// * `Err` - If not in a git repository
602///
603/// # Example
604/// ```no_run
605/// use autom8::worktree::generate_worktree_path;
606///
607/// // If main repo is at /home/user/myproject, returns:
608/// // /home/user/myproject-wt-feature-login
609/// let path = generate_worktree_path("{repo}-wt-{branch}", "feature/login").unwrap();
610/// ```
611pub fn generate_worktree_path(pattern: &str, branch_name: &str) -> Result<PathBuf> {
612    let main_repo = get_main_repo_root()?;
613    let repo_name = main_repo
614        .file_name()
615        .and_then(|n| n.to_str())
616        .ok_or_else(|| {
617            Autom8Error::WorktreeError("Could not determine repository name".to_string())
618        })?;
619
620    let worktree_name = generate_worktree_name(pattern, repo_name, branch_name);
621
622    // Place worktree as sibling of main repo
623    let parent = main_repo.parent().ok_or_else(|| {
624        Autom8Error::WorktreeError("Could not determine repository parent directory".to_string())
625    })?;
626
627    Ok(parent.join(worktree_name))
628}
629
630/// Result of ensuring a worktree exists.
631#[derive(Debug, Clone, PartialEq, Eq)]
632pub enum WorktreeResult {
633    /// A new worktree was created at this path
634    Created(PathBuf),
635    /// An existing worktree was found and reused at this path
636    Reused(PathBuf),
637}
638
639impl WorktreeResult {
640    /// Get the path regardless of whether it was created or reused.
641    pub fn path(&self) -> &Path {
642        match self {
643            WorktreeResult::Created(p) | WorktreeResult::Reused(p) => p,
644        }
645    }
646
647    /// Returns true if the worktree was newly created.
648    pub fn was_created(&self) -> bool {
649        matches!(self, WorktreeResult::Created(_))
650    }
651}
652
653/// Ensure a worktree exists for the specified branch.
654///
655/// If a worktree already exists for this branch, it is reused.
656/// Otherwise, a new worktree is created.
657///
658/// # Arguments
659/// * `pattern` - The path pattern for the worktree name
660/// * `branch_name` - The branch to use for the worktree
661///
662/// # Returns
663/// * `Ok(WorktreeResult::Created(path))` - A new worktree was created
664/// * `Ok(WorktreeResult::Reused(path))` - An existing worktree was found
665/// * `Err` - If worktree creation fails
666pub fn ensure_worktree(pattern: &str, branch_name: &str) -> Result<WorktreeResult> {
667    let target_path = generate_worktree_path(pattern, branch_name)?;
668
669    // Check if a worktree already exists at this path or for this branch
670    let worktrees = list_worktrees()?;
671    for wt in &worktrees {
672        // Check if there's a worktree at our target path
673        if wt.path == target_path {
674            // Verify it has the right branch
675            if let Some(ref wt_branch) = wt.branch {
676                if wt_branch == branch_name {
677                    return Ok(WorktreeResult::Reused(target_path));
678                }
679            }
680            // Path exists but with wrong branch - this is a conflict
681            return Err(Autom8Error::WorktreeError(format!(
682                "Worktree at '{}' exists but uses branch '{}', not '{}'",
683                target_path.display(),
684                wt.branch.as_deref().unwrap_or("(detached)"),
685                branch_name
686            )));
687        }
688
689        // Check if there's already a worktree for this branch at a different path
690        if let Some(ref wt_branch) = wt.branch {
691            if wt_branch == branch_name && !wt.is_main {
692                // Branch is already checked out in a worktree - reuse it
693                return Ok(WorktreeResult::Reused(wt.path.clone()));
694            }
695        }
696    }
697
698    // No existing worktree found - create a new one
699    create_worktree(&target_path, branch_name)?;
700    Ok(WorktreeResult::Created(target_path))
701}
702
703/// Get detailed information about worktree creation failure.
704///
705/// Provides suggestions for how to resolve common worktree creation issues.
706/// Error messages follow the pattern: what happened → why → how to fix.
707///
708/// # Arguments
709/// * `error` - The error message from the failed git command
710/// * `branch_name` - The branch that was being created
711/// * `worktree_path` - The path where the worktree was being created
712///
713/// # Returns
714/// A user-friendly error message with suggestions.
715pub fn format_worktree_error(error: &str, branch_name: &str, worktree_path: &Path) -> String {
716    let mut message = format!(
717        "Failed to create worktree for branch '{}' at '{}'.\n\n",
718        branch_name,
719        worktree_path.display()
720    );
721
722    // Analyze the error and provide specific suggestions
723    if error.contains("already checked out") {
724        message.push_str("Reason: Branch is already checked out in another worktree.\n\n");
725        message.push_str("To resolve this, try one of the following:\n");
726        message.push_str("  1. Use a different branch name in your spec\n");
727        message.push_str("  2. Run `git worktree list` to see existing worktrees\n");
728        message
729            .push_str("  3. Remove the conflicting worktree with `git worktree remove <path>`\n");
730        message.push_str("\nManual worktree creation steps:\n");
731        message.push_str(&format!(
732            "  git worktree add -b {} '{}'\n",
733            branch_name,
734            worktree_path.display()
735        ));
736    } else if error.contains("already exists") {
737        message.push_str("Reason: A directory or worktree already exists at this path.\n\n");
738        message.push_str("To resolve this, try one of the following:\n");
739        message.push_str(&format!(
740            "  1. Remove the existing directory: rm -rf '{}'\n",
741            worktree_path.display()
742        ));
743        message.push_str("  2. Use a different branch name in your spec\n");
744        message.push_str("  3. Configure a different worktree_path_pattern in config\n");
745        message.push_str("\nManual worktree creation steps (after removing existing):\n");
746        message.push_str(&format!(
747            "  git worktree add '{}' {}\n",
748            worktree_path.display(),
749            branch_name
750        ));
751    } else if error.contains("permission denied") || error.contains("Permission denied") {
752        message.push_str("Reason: Insufficient permissions to create the worktree directory.\n\n");
753        message.push_str("To resolve this, try one of the following:\n");
754        message.push_str(&format!(
755            "  1. Check write permissions on: {}\n",
756            worktree_path
757                .parent()
758                .map(|p| p.display().to_string())
759                .unwrap_or_else(|| "parent directory".to_string())
760        ));
761        message.push_str("  2. Run with appropriate permissions (e.g., sudo if needed)\n");
762        message
763            .push_str("  3. Choose a different location in your config's worktree_path_pattern\n");
764    } else {
765        message.push_str(&format!("Error: {}\n\n", error));
766        message.push_str("To resolve this, try one of the following:\n");
767        message.push_str("  1. Ensure you're in a git repository\n");
768        message.push_str("  2. Run `git worktree list` to check current worktrees\n");
769        message.push_str("  3. Check git configuration and permissions\n");
770        message.push_str("\nManual worktree creation steps:\n");
771        message.push_str(&format!(
772            "  git worktree add '{}' {}\n",
773            worktree_path.display(),
774            branch_name
775        ));
776    }
777
778    message
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784
785    // ========================================================================
786    // Porcelain parsing tests - these test actual parsing logic
787    // ========================================================================
788
789    #[test]
790    fn test_parse_porcelain_single_worktree() {
791        let output = "worktree /home/user/project\nHEAD abc1234567890abcdef1234567890abcdef12345678\nbranch refs/heads/main\n\n";
792
793        let worktrees = parse_worktree_list_porcelain(output).unwrap();
794        assert_eq!(worktrees.len(), 1);
795
796        let wt = &worktrees[0];
797        assert_eq!(wt.path, PathBuf::from("/home/user/project"));
798        assert_eq!(wt.branch, Some("main".to_string()));
799        assert_eq!(wt.commit, "abc1234567890abcdef1234567890abcdef12345678");
800        assert!(wt.is_main);
801        assert!(!wt.is_bare);
802    }
803
804    #[test]
805    fn test_parse_porcelain_multiple_worktrees() {
806        let output = concat!(
807            "worktree /home/user/project\n",
808            "HEAD abc1234567890abcdef1234567890abcdef12345678\n",
809            "branch refs/heads/main\n",
810            "\n",
811            "worktree /home/user/project-feature\n",
812            "HEAD def5678901234abcdef5678901234abcdef56789012\n",
813            "branch refs/heads/feature/test\n",
814            "\n"
815        );
816
817        let worktrees = parse_worktree_list_porcelain(output).unwrap();
818        assert_eq!(worktrees.len(), 2);
819
820        assert!(worktrees[0].is_main);
821        assert_eq!(worktrees[0].branch, Some("main".to_string()));
822        assert!(!worktrees[1].is_main);
823        assert_eq!(worktrees[1].branch, Some("feature/test".to_string()));
824    }
825
826    #[test]
827    fn test_parse_porcelain_special_states() {
828        // Detached HEAD
829        let output = "worktree /path\nHEAD abc123\ndetached\n\n";
830        let wt = &parse_worktree_list_porcelain(output).unwrap()[0];
831        assert!(wt.branch.is_none());
832
833        // Bare repo
834        let output = "worktree /path.git\nHEAD abc123\nbare\n\n";
835        let wt = &parse_worktree_list_porcelain(output).unwrap()[0];
836        assert!(wt.is_bare);
837
838        // Locked
839        let output = "worktree /path\nHEAD abc123\nbranch refs/heads/main\nlocked\n\n";
840        let wt = &parse_worktree_list_porcelain(output).unwrap()[0];
841        assert!(wt.is_locked);
842
843        // Prunable
844        let output = "worktree /path\nHEAD abc123\nbranch refs/heads/main\nprunable\n\n";
845        let wt = &parse_worktree_list_porcelain(output).unwrap()[0];
846        assert!(wt.is_prunable);
847    }
848
849    #[test]
850    fn test_parse_porcelain_edge_cases() {
851        // No trailing newline
852        let output = "worktree /path\nHEAD abc123\nbranch refs/heads/main";
853        assert_eq!(parse_worktree_list_porcelain(output).unwrap().len(), 1);
854
855        // Empty output
856        assert!(parse_worktree_list_porcelain("").unwrap().is_empty());
857
858        // Path with spaces
859        let output = "worktree /home/user/my project/repo\nHEAD abc123\nbranch refs/heads/main\n\n";
860        assert_eq!(
861            parse_worktree_list_porcelain(output).unwrap()[0].path,
862            PathBuf::from("/home/user/my project/repo")
863        );
864    }
865
866    #[test]
867    fn test_from_porcelain_lines_missing_required_fields() {
868        // Missing path
869        assert!(
870            WorktreeInfo::from_porcelain_lines(&["HEAD abc123", "branch refs/heads/main"])
871                .is_none()
872        );
873        // Missing commit
874        assert!(
875            WorktreeInfo::from_porcelain_lines(&["worktree /path", "branch refs/heads/main"])
876                .is_none()
877        );
878    }
879
880    // ========================================================================
881    // Session ID tests - test determinism and uniqueness
882    // ========================================================================
883
884    #[test]
885    fn test_generate_session_id_properties() {
886        let path = Path::new("/home/user/project-feature");
887        let id = generate_session_id(path);
888
889        // 8 hex characters
890        assert_eq!(id.len(), 8);
891        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
892
893        // Deterministic
894        assert_eq!(id, generate_session_id(path));
895
896        // Different paths produce different IDs
897        let id2 = generate_session_id(Path::new("/home/user/other-project"));
898        assert_ne!(id, id2);
899    }
900
901    #[test]
902    fn test_generate_session_id_uniqueness() {
903        let paths = [
904            "/home/user/project1",
905            "/home/user/project2",
906            "/tmp/worktree-a",
907            "/tmp/worktree-b",
908        ];
909
910        let ids: Vec<String> = paths
911            .iter()
912            .map(|p| generate_session_id(Path::new(p)))
913            .collect();
914
915        let unique_ids: std::collections::HashSet<_> = ids.iter().collect();
916        assert_eq!(ids.len(), unique_ids.len());
917    }
918
919    #[test]
920    fn test_main_session_id() {
921        assert_eq!(MAIN_SESSION_ID, "main");
922        assert_eq!(get_main_session_id(), "main");
923    }
924
925    // ========================================================================
926    // Branch name slugification tests
927    // ========================================================================
928
929    #[test]
930    fn test_slugify_branch_name() {
931        assert_eq!(slugify_branch_name("feature/login"), "feature-login");
932        assert_eq!(
933            slugify_branch_name("feature/user/auth"),
934            "feature-user-auth"
935        );
936        assert_eq!(slugify_branch_name("main"), "main");
937        assert_eq!(slugify_branch_name("v1.0.0"), "v1.0.0");
938        assert_eq!(slugify_branch_name("feature//login"), "feature-login"); // collapses multiple
939        assert_eq!(slugify_branch_name("feature@login"), "feature-login"); // removes special chars
940    }
941
942    #[test]
943    fn test_generate_worktree_name() {
944        assert_eq!(
945            generate_worktree_name("{repo}-wt-{branch}", "myproject", "feature/login"),
946            "myproject-wt-feature-login"
947        );
948        assert_eq!(
949            generate_worktree_name("{repo}_worktree_{branch}", "myproject", "main"),
950            "myproject_worktree_main"
951        );
952    }
953
954    // ========================================================================
955    // WorktreeResult tests
956    // ========================================================================
957
958    #[test]
959    fn test_worktree_result() {
960        let path = PathBuf::from("/test/path");
961        let created = WorktreeResult::Created(path.clone());
962        let reused = WorktreeResult::Reused(path.clone());
963
964        assert_eq!(created.path(), &path);
965        assert_eq!(reused.path(), &path);
966        assert!(created.was_created());
967        assert!(!reused.was_created());
968    }
969
970    // ========================================================================
971    // Error formatting tests
972    // ========================================================================
973
974    #[test]
975    fn test_format_worktree_error_messages() {
976        // Already checked out
977        let msg = format_worktree_error(
978            "fatal: branch 'main' is already checked out",
979            "main",
980            Path::new("/new/worktree"),
981        );
982        assert!(msg.contains("already checked out"));
983        assert!(msg.contains("To resolve"));
984        assert!(msg.contains("git worktree"));
985
986        // Already exists
987        let msg = format_worktree_error(
988            "fatal: already exists",
989            "feature",
990            Path::new("/new/worktree"),
991        );
992        assert!(msg.contains("already exists"));
993        assert!(msg.contains("after removing existing"));
994
995        // Permission denied
996        let msg = format_worktree_error(
997            "error: permission denied",
998            "feature",
999            Path::new("/restricted"),
1000        );
1001        assert!(msg.contains("permissions"));
1002
1003        // Generic error includes manual steps
1004        let msg = format_worktree_error("unknown error", "feature/login", Path::new("/path/to/wt"));
1005        assert!(msg.contains("Manual worktree creation"));
1006        assert!(msg.contains("feature/login"));
1007    }
1008}