Skip to main content

git_worktree_manager/
git.rs

1/// Git operations wrapper utilities.
2///
3use std::path::{Path, PathBuf};
4use std::process::{Command, Output};
5
6use crate::constants::sanitize_branch_name;
7use crate::error::{CwError, Result};
8
9/// Canonicalize a path, falling back to the original path on failure.
10pub fn canonicalize_or(path: &Path) -> PathBuf {
11    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
12}
13
14/// Result of running a command, with stdout captured as String.
15#[derive(Debug)]
16pub struct CommandResult {
17    pub stdout: String,
18    pub returncode: i32,
19}
20
21/// Run a shell command.
22pub fn run_command(
23    cmd: &[&str],
24    cwd: Option<&Path>,
25    check: bool,
26    capture: bool,
27) -> Result<CommandResult> {
28    if cmd.is_empty() {
29        return Err(CwError::Other("Empty command".to_string()));
30    }
31
32    let mut command = Command::new(cmd[0]);
33    command.args(&cmd[1..]);
34
35    if let Some(dir) = cwd {
36        command.current_dir(dir);
37    }
38
39    if capture {
40        command.stdout(std::process::Stdio::piped());
41        command.stderr(std::process::Stdio::piped());
42    }
43
44    let output: Output = command.output().map_err(|e| {
45        if e.kind() == std::io::ErrorKind::NotFound {
46            CwError::Git(format!("Command not found: {}", cmd[0]))
47        } else {
48            CwError::Io(e)
49        }
50    })?;
51
52    let returncode = output.status.code().unwrap_or(-1);
53    let stdout = if capture {
54        // Merge stderr into stdout like Python's STDOUT redirect
55        let mut out = String::from_utf8_lossy(&output.stdout).to_string();
56        let err = String::from_utf8_lossy(&output.stderr);
57        if !err.is_empty() {
58            if !out.is_empty() {
59                out.push('\n');
60            }
61            out.push_str(&err);
62        }
63        out
64    } else {
65        String::new()
66    };
67
68    if check && returncode != 0 {
69        return Err(CwError::Git(format!(
70            "Command failed: {}\n{}",
71            cmd.join(" "),
72            stdout
73        )));
74    }
75
76    Ok(CommandResult { stdout, returncode })
77}
78
79/// Run a git command.
80pub fn git_command(
81    args: &[&str],
82    repo: Option<&Path>,
83    check: bool,
84    capture: bool,
85) -> Result<CommandResult> {
86    let mut cmd = vec!["git"];
87    cmd.extend_from_slice(args);
88    run_command(&cmd, repo, check, capture)
89}
90
91/// Get the root directory of the git repository.
92pub fn get_repo_root(path: Option<&Path>) -> Result<PathBuf> {
93    let result = git_command(&["rev-parse", "--show-toplevel"], path, true, true);
94    match result {
95        Ok(r) => Ok(PathBuf::from(r.stdout.trim())),
96        Err(_) => Err(CwError::Git("Not in a git repository".to_string())),
97    }
98}
99
100/// Get the current branch name.
101pub fn get_current_branch(repo: Option<&Path>) -> Result<String> {
102    let result = git_command(&["rev-parse", "--abbrev-ref", "HEAD"], repo, true, true)?;
103    let branch = result.stdout.trim().to_string();
104    if branch == "HEAD" {
105        return Err(CwError::InvalidBranch("In detached HEAD state".to_string()));
106    }
107    Ok(branch)
108}
109
110/// Auto-detect the repository's default branch.
111///
112/// Priority:
113/// 1. `origin/HEAD` symref (most reliable — set by `git clone`)
114/// 2. Local `main` branch exists
115/// 3. Local `master` branch exists
116/// 4. Config fallback
117pub fn detect_default_branch(repo: Option<&Path>) -> String {
118    // 1. origin/HEAD
119    if let Ok(r) = git_command(
120        &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
121        repo,
122        false,
123        true,
124    ) {
125        if r.returncode == 0 {
126            let branch = r
127                .stdout
128                .trim()
129                .strip_prefix("origin/")
130                .unwrap_or(r.stdout.trim());
131            if !branch.is_empty() {
132                return branch.to_string();
133            }
134        }
135    }
136
137    // 2. main
138    if branch_exists("main", repo) {
139        return "main".to_string();
140    }
141
142    // 3. master
143    if branch_exists("master", repo) {
144        return "master".to_string();
145    }
146
147    // 4. fallback
148    "main".to_string()
149}
150
151/// Check if a branch exists.
152pub fn branch_exists(branch: &str, repo: Option<&Path>) -> bool {
153    git_command(&["rev-parse", "--verify", branch], repo, false, true)
154        .map(|r| r.returncode == 0)
155        .unwrap_or(false)
156}
157
158/// Check if a branch exists on a remote.
159pub fn remote_branch_exists(branch: &str, repo: Option<&Path>, remote: &str) -> bool {
160    let ref_name = format!("{}/{}", remote, branch);
161    git_command(&["rev-parse", "--verify", &ref_name], repo, false, true)
162        .map(|r| r.returncode == 0)
163        .unwrap_or(false)
164}
165
166/// Get a git config value (local scope).
167pub fn get_config(key: &str, repo: Option<&Path>) -> Option<String> {
168    git_command(&["config", "--local", "--get", key], repo, false, true)
169        .ok()
170        .and_then(|r| {
171            if r.returncode == 0 {
172                Some(r.stdout.trim().to_string())
173            } else {
174                None
175            }
176        })
177}
178
179/// Set a git config value (local scope).
180pub fn set_config(key: &str, value: &str, repo: Option<&Path>) -> Result<()> {
181    git_command(&["config", "--local", key, value], repo, true, false)?;
182    Ok(())
183}
184
185/// Unset a git config value.
186pub fn unset_config(key: &str, repo: Option<&Path>) {
187    let _ = git_command(
188        &["config", "--local", "--unset-all", key],
189        repo,
190        false,
191        false,
192    );
193}
194
195/// Normalize branch name by removing refs/heads/ prefix if present.
196pub fn normalize_branch_name(branch: &str) -> &str {
197    branch.strip_prefix("refs/heads/").unwrap_or(branch)
198}
199
200/// Parsed worktree entry: (branch_or_detached, path).
201pub type WorktreeEntry = (String, PathBuf);
202
203/// Parse `git worktree list --porcelain` output.
204pub fn parse_worktrees(repo: &Path) -> Result<Vec<WorktreeEntry>> {
205    let result = git_command(&["worktree", "list", "--porcelain"], Some(repo), true, true)?;
206
207    let mut items: Vec<WorktreeEntry> = Vec::new();
208    let mut cur_path: Option<String> = None;
209    let mut cur_branch: Option<String> = None;
210
211    for line in result.stdout.lines() {
212        if let Some(path) = line.strip_prefix("worktree ") {
213            cur_path = Some(path.to_string());
214        } else if let Some(branch) = line.strip_prefix("branch ") {
215            cur_branch = Some(branch.to_string());
216        } else if line.trim().is_empty() {
217            if let Some(path) = cur_path.take() {
218                let branch = cur_branch
219                    .take()
220                    .unwrap_or_else(|| "(detached)".to_string());
221                items.push((branch, PathBuf::from(path)));
222            }
223        }
224    }
225    // Handle last entry (no trailing blank line)
226    if let Some(path) = cur_path {
227        let branch = cur_branch.unwrap_or_else(|| "(detached)".to_string());
228        items.push((branch, PathBuf::from(path)));
229    }
230
231    Ok(items)
232}
233
234/// Get feature worktrees, excluding main repo and detached entries.
235pub fn get_feature_worktrees(repo: Option<&Path>) -> Result<Vec<(String, PathBuf)>> {
236    let effective_repo = get_repo_root(repo)?;
237    let worktrees = parse_worktrees(&effective_repo)?;
238    if worktrees.is_empty() {
239        return Ok(Vec::new());
240    }
241
242    let main_path = canonicalize_or(&worktrees[0].1);
243
244    let mut result = Vec::new();
245    for (branch, path) in &worktrees {
246        let resolved = canonicalize_or(path);
247        if resolved == main_path {
248            continue;
249        }
250        if branch == "(detached)" {
251            continue;
252        }
253        let branch_name = normalize_branch_name(branch).to_string();
254        result.push((branch_name, path.clone()));
255    }
256    Ok(result)
257}
258
259/// Get main repository path, even when called from a worktree.
260pub fn get_main_repo_root(repo: Option<&Path>) -> Result<PathBuf> {
261    let current_root = get_repo_root(repo)?;
262    let worktrees = parse_worktrees(&current_root)?;
263    if let Some(first) = worktrees.first() {
264        Ok(first.1.clone())
265    } else {
266        Ok(current_root)
267    }
268}
269
270/// Find worktree path by branch name.
271pub fn find_worktree_by_branch(repo: &Path, branch: &str) -> Result<Option<PathBuf>> {
272    let worktrees = parse_worktrees(repo)?;
273    Ok(worktrees
274        .into_iter()
275        .find(|(br, _)| br == branch)
276        .map(|(_, path)| path))
277}
278
279/// Find worktree by directory name.
280pub fn find_worktree_by_name(repo: &Path, worktree_name: &str) -> Result<Option<PathBuf>> {
281    let worktrees = parse_worktrees(repo)?;
282    Ok(worktrees
283        .into_iter()
284        .find(|(_, path)| {
285            path.file_name()
286                .map(|n| n.to_string_lossy() == worktree_name)
287                .unwrap_or(false)
288        })
289        .map(|(_, path)| path))
290}
291
292/// Find worktree path by intended branch name (from metadata).
293pub fn find_worktree_by_intended_branch(
294    repo: &Path,
295    intended_branch: &str,
296) -> Result<Option<PathBuf>> {
297    let intended_branch = normalize_branch_name(intended_branch);
298
299    // Strategy 1: Direct lookup by current branch name
300    if let Some(path) = find_worktree_by_branch(repo, intended_branch)? {
301        return Ok(Some(path));
302    }
303    // Also try with refs/heads/ prefix
304    let with_prefix = format!("refs/heads/{}", intended_branch);
305    if let Some(path) = find_worktree_by_branch(repo, &with_prefix)? {
306        return Ok(Some(path));
307    }
308
309    // Strategy 2: Search all intended branch metadata
310    let result = git_command(
311        &[
312            "config",
313            "--local",
314            "--get-regexp",
315            r"^worktree\..*\.intendedBranch",
316        ],
317        Some(repo),
318        false,
319        true,
320    )?;
321
322    if result.returncode == 0 {
323        for line in result.stdout.trim().lines() {
324            let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
325            if parts.len() == 2 {
326                let key = parts[0];
327                let value = parts[1];
328                // Extract branch name from key: worktree.<branch>.intendedBranch
329                // Strip the known prefix and suffix instead of splitting on '.' to
330                // correctly handle branch names that contain dots (e.g. "feat-v2.0").
331                if let Some(branch_from_key) = key
332                    .strip_prefix("worktree.")
333                    .and_then(|s| s.strip_suffix(".intendedBranch"))
334                {
335                    if branch_from_key == intended_branch || value == intended_branch {
336                        let worktrees = parse_worktrees(repo)?;
337                        let repo_name = repo
338                            .file_name()
339                            .map(|n| n.to_string_lossy().to_string())
340                            .unwrap_or_default();
341                        let expected_suffix =
342                            format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
343                        for (_, path) in &worktrees {
344                            if let Some(name) = path.file_name() {
345                                if name.to_string_lossy() == expected_suffix {
346                                    return Ok(Some(path.clone()));
347                                }
348                            }
349                        }
350                    }
351                }
352            }
353        }
354    }
355
356    // Strategy 3: Fallback — check path naming convention
357    let repo_name = repo
358        .file_name()
359        .map(|n| n.to_string_lossy().to_string())
360        .unwrap_or_default();
361    let expected_suffix = format!("{}-{}", repo_name, sanitize_branch_name(intended_branch));
362    let worktrees = parse_worktrees(repo)?;
363    let repo_resolved = canonicalize_or(repo);
364
365    for (_, path) in &worktrees {
366        if let Some(name) = path.file_name() {
367            if name.to_string_lossy() == expected_suffix {
368                let path_resolved = canonicalize_or(path);
369                if path_resolved != repo_resolved {
370                    return Ok(Some(path.clone()));
371                }
372            }
373        }
374    }
375
376    Ok(None)
377}
378
379/// Fetch from remote and determine the rebase target for a base branch.
380///
381/// Returns `(fetch_ok, rebase_target)` where `rebase_target` is `origin/<base>`
382/// if fetch succeeded and the remote ref exists, otherwise just `<base>`.
383pub fn fetch_and_rebase_target(base_branch: &str, repo: &Path, cwd: &Path) -> (bool, String) {
384    let fetch_ok = git_command(&["fetch", "--all", "--prune"], Some(repo), false, true)
385        .map(|r| r.returncode == 0)
386        .unwrap_or(false);
387
388    let rebase_target = if fetch_ok {
389        let origin_ref = format!("origin/{}", base_branch);
390        if branch_exists(&origin_ref, Some(cwd)) {
391            origin_ref
392        } else {
393            base_branch.to_string()
394        }
395    } else {
396        base_branch.to_string()
397    };
398
399    (fetch_ok, rebase_target)
400}
401
402/// Return the list of files with unresolved merge/rebase conflicts at `path`
403/// as a newline-joined string. `None` when there are no conflicts or the git
404/// call itself fails — callers treat both cases as "no diagnostic available".
405pub fn list_conflicted_files(path: &Path) -> Option<String> {
406    git_command(
407        &["diff", "--name-only", "--diff-filter=U"],
408        Some(path),
409        false,
410        true,
411    )
412    .ok()
413    .and_then(|r| {
414        if r.returncode == 0 && !r.stdout.trim().is_empty() {
415            Some(r.stdout.trim().to_string())
416        } else {
417            None
418        }
419    })
420}
421
422/// Check if a command is available in PATH.
423pub fn has_command(name: &str) -> bool {
424    if let Ok(path_var) = std::env::var("PATH") {
425        for dir in std::env::split_paths(&path_var) {
426            let candidate = dir.join(name);
427            if candidate.is_file() {
428                return true;
429            }
430            // On Windows, try with .exe extension
431            #[cfg(target_os = "windows")]
432            {
433                let with_ext = dir.join(format!("{}.exe", name));
434                if with_ext.is_file() {
435                    return true;
436                }
437            }
438        }
439    }
440    false
441}
442
443/// Check if running in non-interactive environment.
444pub fn is_non_interactive() -> bool {
445    // Explicit flag
446    if let Ok(val) = std::env::var("CW_NON_INTERACTIVE") {
447        let val = val.to_lowercase();
448        if val == "1" || val == "true" || val == "yes" {
449            return true;
450        }
451    }
452
453    // Check stdin is not a TTY
454    if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
455        return true;
456    }
457
458    // CI environment variables
459    let ci_vars = [
460        "CI",
461        "GITHUB_ACTIONS",
462        "GITLAB_CI",
463        "JENKINS_HOME",
464        "CIRCLECI",
465        "TRAVIS",
466        "BUILDKITE",
467        "DRONE",
468        "BITBUCKET_PIPELINE",
469        "CODEBUILD_BUILD_ID",
470    ];
471
472    ci_vars.iter().any(|var| std::env::var(var).is_ok())
473}
474
475/// Check if a branch name is valid according to git rules.
476pub fn is_valid_branch_name(branch_name: &str, repo: Option<&Path>) -> bool {
477    if branch_name.is_empty() {
478        return false;
479    }
480    git_command(
481        &["check-ref-format", "--branch", branch_name],
482        repo,
483        false,
484        true,
485    )
486    .map(|r| r.returncode == 0)
487    .unwrap_or(false)
488}
489
490/// Get descriptive error message for invalid branch name.
491pub fn get_branch_name_error(branch_name: &str) -> String {
492    if branch_name.is_empty() {
493        return "Branch name cannot be empty".to_string();
494    }
495    if branch_name == "@" {
496        return "Branch name cannot be '@' alone".to_string();
497    }
498    if branch_name.ends_with(".lock") {
499        return "Branch name cannot end with '.lock'".to_string();
500    }
501    if branch_name.starts_with('/') || branch_name.ends_with('/') {
502        return "Branch name cannot start or end with '/'".to_string();
503    }
504    if branch_name.contains("//") {
505        return "Branch name cannot contain consecutive slashes '//'".to_string();
506    }
507    if branch_name.contains("..") {
508        return "Branch name cannot contain consecutive dots '..'".to_string();
509    }
510    if branch_name.contains("@{") {
511        return "Branch name cannot contain '@{'".to_string();
512    }
513
514    let invalid_chars: &[char] = &['~', '^', ':', '?', '*', '[', '\\'];
515    let found: Vec<char> = invalid_chars
516        .iter()
517        .filter(|&&c| branch_name.contains(c))
518        .copied()
519        .collect();
520    if !found.is_empty() {
521        let chars_display: Vec<String> = found.iter().map(|c| format!("{:?}", c)).collect();
522        return format!(
523            "Branch name contains invalid characters: {}",
524            chars_display.join(", ")
525        );
526    }
527
528    if branch_name.chars().any(|c| (c as u32) < 32 || c == ' ') {
529        return "Branch name cannot contain spaces or control characters".to_string();
530    }
531
532    format!(
533        "'{}' is not a valid branch name. See 'git check-ref-format --help' for rules",
534        branch_name
535    )
536}
537
538/// Parse a `(pid N)` substring from a `git worktree remove` lock-failure
539/// message. Returns the first PID found, or `None`.
540///
541/// Examples that match:
542///   `cannot remove a locked working tree, lock reason: agent worktree (pid 12345)`
543/// Examples that do not:
544///   `cannot remove a locked working tree, lock reason: WIP`
545///
546/// Match is intentionally narrow: we only recover stale locks when the locking
547/// process recorded its PID in the standard `(pid N)` shape. User-defined
548/// `git worktree lock --reason "WIP"` locks (no PID) fall through to the normal
549/// error path so we don't silently override a deliberate hold.
550pub(crate) fn parse_locked_lock_reason_pid(output: &str) -> Option<u32> {
551    // Find `(pid ` (case-sensitive, with the trailing space) then a numeric run.
552    let needle = "(pid ";
553    let start = output.find(needle)?;
554    let rest = &output[start + needle.len()..];
555    let end = rest.find(')')?;
556    rest[..end].trim().parse::<u32>().ok()
557}
558
559/// True if `git worktree remove` output indicates a locked-worktree failure.
560pub(crate) fn is_locked_worktree_error(output: &str) -> bool {
561    output.contains("cannot remove a locked working tree")
562}
563
564/// Remove a git worktree with platform-safe fallback.
565///
566/// If the underlying `git worktree remove` fails because the worktree is locked
567/// and the lock reason carries a `(pid N)` marker, we check whether that PID is
568/// still alive. If it is dead, the lock is stale: emit a one-line info notice,
569/// unlock the worktree, and retry. If the PID is live, surface a clearer error.
570///
571/// Trade-off: the parser matches `(pid N)` anywhere in the lock reason, so a
572/// user-authored reason like `"PR (pid 123) status check"` would also enter
573/// this branch. `pid_alive` is the second guard — auto-unlock only fires when
574/// that PID is also dead. Worst case is unlocking a deliberate hold whose PID
575/// happens to be dead, which we judge acceptable; the alternative
576/// (a strict gw-prefix allowlist) would miss real stale locks from other
577/// tools that use the same convention.
578///
579/// Non-Unix: `pid_alive` always returns `true`, so the dead-PID branch never
580/// fires and auto-recovery is effectively Unix-only. The live-PID error path
581/// still applies and yields a clearer message than git's raw error.
582pub fn remove_worktree_safe(worktree_path: &Path, repo: &Path, force: bool) -> Result<()> {
583    let worktree_str = canonicalize_or(worktree_path).to_string_lossy().to_string();
584    let result = run_remove(&worktree_str, repo, force)?;
585
586    if result.returncode == 0 {
587        return Ok(());
588    }
589
590    // Stale-lock recovery: attempt at most once per call.
591    if is_locked_worktree_error(&result.stdout) {
592        if let Some(pid) = parse_locked_lock_reason_pid(&result.stdout) {
593            if crate::operations::lockfile::pid_alive(pid) {
594                return Err(CwError::Git(crate::messages::worktree_locked_live_pid(
595                    &worktree_str,
596                    pid,
597                )));
598            }
599            eprintln!(
600                "{}",
601                console::style(crate::messages::worktree_unlocking_stale(
602                    &worktree_str,
603                    pid
604                ))
605                .dim()
606            );
607            git_command(
608                &["worktree", "unlock", &worktree_str],
609                Some(repo),
610                true,
611                true,
612            )
613            .map_err(|e| {
614                CwError::Git(format!(
615                    "auto-unlock failed during stale-lock recovery: {e}"
616                ))
617            })?;
618            let retried = run_remove(&worktree_str, repo, force)?;
619            if retried.returncode == 0 {
620                return Ok(());
621            }
622            return Err(CwError::Git(format!(
623                "Command failed (after auto-unlock): git worktree remove {}\n{}",
624                worktree_str, retried.stdout
625            )));
626        }
627    }
628
629    // Windows fallback for "Directory not empty"
630    #[cfg(target_os = "windows")]
631    {
632        if result.stdout.contains("Directory not empty") {
633            let path = PathBuf::from(&worktree_str);
634            if path.exists() {
635                std::fs::remove_dir_all(&path).map_err(|e| {
636                    CwError::Git(format!(
637                        "Failed to remove worktree directory on Windows: {}\nError: {}",
638                        worktree_str, e
639                    ))
640                })?;
641            }
642            git_command(&["worktree", "prune"], Some(repo), true, false)?;
643            return Ok(());
644        }
645    }
646
647    let mut args = vec!["worktree", "remove", &worktree_str];
648    if force {
649        args.push("--force");
650    }
651    Err(CwError::Git(format!(
652        "Command failed: {}\n{}",
653        args.join(" "),
654        result.stdout
655    )))
656}
657
658fn run_remove(worktree_str: &str, repo: &Path, force: bool) -> Result<CommandResult> {
659    let mut args = vec!["worktree", "remove", worktree_str];
660    if force {
661        args.push("--force");
662    }
663    git_command(&args, Some(repo), false, true)
664}
665
666/// Check if a branch has been merged into the base branch.
667///
668/// Uses `git branch --merged <base>` and checks if the feature branch is in the list.
669pub fn is_branch_merged(feature_branch: &str, base_branch: &str, repo: Option<&Path>) -> bool {
670    /// Strip the status prefix that `git branch` prepends to each line:
671    ///   `* ` — current branch in this checkout
672    ///   `+ ` — branch checked out in a linked worktree
673    ///   `  ` — ordinary branch (two leading spaces)
674    fn strip_branch_prefix(line: &str) -> &str {
675        let trimmed = line.trim();
676        trimmed
677            .strip_prefix("* ")
678            .or_else(|| trimmed.strip_prefix("+ "))
679            .unwrap_or(trimmed)
680    }
681
682    // First try against remote base
683    let remote_base = format!("origin/{}", base_branch);
684    if let Ok(r) = git_command(&["branch", "--merged", &remote_base], repo, false, true) {
685        if r.returncode == 0 {
686            for line in r.stdout.lines() {
687                if strip_branch_prefix(line) == feature_branch {
688                    return true;
689                }
690            }
691        }
692    }
693
694    // Fallback: check against local base
695    if let Ok(r) = git_command(&["branch", "--merged", base_branch], repo, false, true) {
696        if r.returncode == 0 {
697            for line in r.stdout.lines() {
698                if strip_branch_prefix(line) == feature_branch {
699                    return true;
700                }
701            }
702        }
703    }
704
705    false
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    #[test]
713    #[cfg(not(windows))]
714    fn test_canonicalize_or_existing_path() {
715        // /tmp should exist on all Unix systems
716        let path = Path::new("/tmp");
717        let result = canonicalize_or(path);
718        // Should resolve to a real path (e.g., /private/tmp on macOS)
719        assert!(result.is_absolute());
720    }
721
722    #[test]
723    fn test_canonicalize_or_nonexistent_path() {
724        let path = Path::new("/nonexistent/path/that/does/not/exist");
725        let result = canonicalize_or(path);
726        // Should return the original path as-is
727        assert_eq!(result, path);
728    }
729
730    #[test]
731    fn test_canonicalize_or_relative_path() {
732        let path = Path::new("relative/path");
733        let result = canonicalize_or(path);
734        // Non-existent relative path should return as-is
735        assert_eq!(result, path);
736    }
737
738    #[test]
739    fn test_normalize_branch_name() {
740        assert_eq!(normalize_branch_name("refs/heads/main"), "main");
741        assert_eq!(normalize_branch_name("feature-branch"), "feature-branch");
742        assert_eq!(normalize_branch_name("refs/heads/feat/auth"), "feat/auth");
743    }
744
745    #[test]
746    fn parse_pid_extracts_first_pid() {
747        let out = "fatal: cannot remove a locked working tree, lock reason: agent (pid 12345)";
748        assert_eq!(parse_locked_lock_reason_pid(out), Some(12345));
749    }
750
751    #[test]
752    fn parse_pid_handles_trailing_text() {
753        let out = "lock reason: foo (pid 42) and more text";
754        assert_eq!(parse_locked_lock_reason_pid(out), Some(42));
755    }
756
757    #[test]
758    fn parse_pid_returns_none_without_marker() {
759        let out = "fatal: cannot remove a locked working tree, lock reason: WIP";
760        assert_eq!(parse_locked_lock_reason_pid(out), None);
761    }
762
763    #[test]
764    fn parse_pid_returns_none_for_malformed() {
765        // No closing paren
766        assert_eq!(parse_locked_lock_reason_pid("(pid 99"), None);
767        // Non-numeric
768        assert_eq!(parse_locked_lock_reason_pid("(pid abc)"), None);
769        // Empty
770        assert_eq!(parse_locked_lock_reason_pid("(pid )"), None);
771    }
772
773    #[test]
774    fn parse_pid_picks_first_when_multiple() {
775        let out = "first (pid 11) second (pid 22)";
776        assert_eq!(parse_locked_lock_reason_pid(out), Some(11));
777    }
778
779    #[test]
780    fn is_locked_error_detects_git_message() {
781        assert!(is_locked_worktree_error(
782            "fatal: cannot remove a locked working tree, lock reason: x"
783        ));
784        assert!(!is_locked_worktree_error("fatal: some other error"));
785        assert!(!is_locked_worktree_error(""));
786    }
787
788    #[test]
789    fn test_get_branch_name_error() {
790        assert_eq!(get_branch_name_error(""), "Branch name cannot be empty");
791        assert_eq!(
792            get_branch_name_error("@"),
793            "Branch name cannot be '@' alone"
794        );
795        assert_eq!(
796            get_branch_name_error("foo.lock"),
797            "Branch name cannot end with '.lock'"
798        );
799        assert_eq!(
800            get_branch_name_error("/foo"),
801            "Branch name cannot start or end with '/'"
802        );
803        assert_eq!(
804            get_branch_name_error("foo//bar"),
805            "Branch name cannot contain consecutive slashes '//'"
806        );
807    }
808}