Skip to main content

tmx/
worktree.rs

1use std::io::Write;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use anyhow::{Context, Result, anyhow, bail};
6
7use crate::config::{Config, expand_home};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct WorktreeEntry {
11    pub path: PathBuf,
12    pub branch: Option<String>,
13    pub head: Option<String>,
14    pub locked: bool,
15    pub prunable: bool,
16    pub is_main: bool,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct PaneEntry {
21    pub session_id: String,
22    pub session_name: String,
23    pub path: String,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct AttachedSession {
28    pub id: String,
29    pub name: String,
30}
31
32/// Decision returned by the remove preflight. CLI layer is responsible for
33/// applying it (printing messages, killing sessions, invoking git).
34#[derive(Debug, PartialEq, Eq)]
35pub enum RemoveDecision {
36    LockedRefuse,
37    Proceed,
38}
39
40pub fn remove_decision(entry: &WorktreeEntry) -> RemoveDecision {
41    if entry.locked {
42        RemoveDecision::LockedRefuse
43    } else {
44        RemoveDecision::Proceed
45    }
46}
47
48/// Parse `git worktree list --porcelain` output. The first `worktree` block
49/// is always the main worktree per git's documented contract.
50pub fn parse_porcelain(stdout: &str) -> Vec<WorktreeEntry> {
51    let mut entries: Vec<WorktreeEntry> = Vec::new();
52    let mut current: Option<WorktreeEntry> = None;
53
54    let flush = |cur: &mut Option<WorktreeEntry>, out: &mut Vec<WorktreeEntry>| {
55        if let Some(e) = cur.take() {
56            out.push(e);
57        }
58    };
59
60    for raw in stdout.lines() {
61        let line = raw.trim_end();
62        if line.is_empty() {
63            flush(&mut current, &mut entries);
64            continue;
65        }
66        if let Some(path) = line.strip_prefix("worktree ") {
67            flush(&mut current, &mut entries);
68            current = Some(WorktreeEntry {
69                path: PathBuf::from(path),
70                branch: None,
71                head: None,
72                locked: false,
73                prunable: false,
74                is_main: false,
75            });
76        } else if let Some(sha) = line.strip_prefix("HEAD ")
77            && let Some(e) = current.as_mut()
78        {
79            e.head = Some(sha.to_string());
80        } else if let Some(branch) = line.strip_prefix("branch ")
81            && let Some(e) = current.as_mut()
82        {
83            e.branch = Some(branch.to_string());
84        } else if line == "detached" {
85            // No branch; leave entry.branch as None.
86        } else if (line == "locked" || line.starts_with("locked "))
87            && let Some(e) = current.as_mut()
88        {
89            e.locked = true;
90        } else if (line == "prunable" || line.starts_with("prunable "))
91            && let Some(e) = current.as_mut()
92        {
93            e.prunable = true;
94        }
95    }
96    flush(&mut current, &mut entries);
97
98    if let Some(first) = entries.first_mut() {
99        first.is_main = true;
100    }
101    entries
102}
103
104/// Short branch name from `refs/heads/foo` → `foo`. Returns the original if
105/// it doesn't carry the prefix.
106pub fn short_branch(branch: &str) -> &str {
107    branch.strip_prefix("refs/heads/").unwrap_or(branch)
108}
109
110pub fn list_entries(start_in: &Path) -> Result<Vec<WorktreeEntry>> {
111    let output = Command::new("git")
112        .arg("-C")
113        .arg(start_in)
114        .args(["worktree", "list", "--porcelain"])
115        .output()
116        .with_context(|| format!("invoking git in {}", start_in.display()))?;
117    if !output.status.success() {
118        bail!(
119            "git worktree list failed: {}",
120            String::from_utf8_lossy(&output.stderr).trim()
121        );
122    }
123    Ok(parse_porcelain(&String::from_utf8_lossy(&output.stdout)))
124}
125
126/// Parse `tmux list-panes` output formatted as
127/// `#{session_id}\t#{session_name}\t#{pane_current_path}`.
128pub fn parse_pane_lines(stdout: &str) -> Vec<PaneEntry> {
129    stdout
130        .lines()
131        .filter_map(|line| {
132            let mut parts = line.splitn(3, '\t');
133            let id = parts.next()?.to_string();
134            let name = parts.next()?.to_string();
135            let path = parts.next()?.to_string();
136            if path.is_empty() {
137                return None;
138            }
139            Some(PaneEntry {
140                session_id: id,
141                session_name: name,
142                path,
143            })
144        })
145        .collect()
146}
147
148/// True if `pane_path` is `worktree` itself or a descendant, with `/` boundary.
149fn pane_path_inside(pane_path: &str, worktree: &Path) -> bool {
150    let wt = worktree.to_string_lossy();
151    let wt = wt.trim_end_matches('/');
152    if pane_path == wt {
153        return true;
154    }
155    if let Some(rest) = pane_path.strip_prefix(wt)
156        && rest.starts_with('/')
157    {
158        return true;
159    }
160    false
161}
162
163/// Returns offenders (sessions whose any pane is inside `worktree`),
164/// de-duplicated by session id, in first-seen order.
165pub fn match_attached(panes: &[PaneEntry], worktree: &Path) -> Vec<AttachedSession> {
166    // Try canonicalized comparison if possible; otherwise fall back to raw.
167    let wt_canon = worktree.canonicalize().ok();
168    let mut seen_ids: Vec<String> = Vec::new();
169    let mut out: Vec<AttachedSession> = Vec::new();
170    for p in panes {
171        let pane_canon = PathBuf::from(&p.path).canonicalize().ok();
172        let matches = match (&wt_canon, &pane_canon) {
173            (Some(w), Some(pc)) => pane_path_inside(&pc.to_string_lossy(), w),
174            _ => pane_path_inside(&p.path, worktree),
175        };
176        if matches && !seen_ids.iter().any(|id| id == &p.session_id) {
177            seen_ids.push(p.session_id.clone());
178            out.push(AttachedSession {
179                id: p.session_id.clone(),
180                name: p.session_name.clone(),
181            });
182        }
183    }
184    out
185}
186
187/// Shell out to tmux. Returns Err on command failure so caller can apply
188/// fail-open / fail-closed policy based on `$TMUX`.
189pub fn sessions_attached_to(worktree: &Path) -> Result<Vec<AttachedSession>> {
190    let output = Command::new("tmux")
191        .args([
192            "list-panes",
193            "-as",
194            "-F",
195            "#{session_id}\t#{session_name}\t#{pane_current_path}",
196        ])
197        .output()
198        .context("invoking tmux list-panes")?;
199    if !output.status.success() {
200        bail!(
201            "tmux list-panes failed: {}",
202            String::from_utf8_lossy(&output.stderr).trim()
203        );
204    }
205    let stdout = String::from_utf8_lossy(&output.stdout);
206    let panes = parse_pane_lines(&stdout);
207    Ok(match_attached(&panes, worktree))
208}
209
210/// Find the unique non-main entry matching `target` (exact path or short branch).
211pub fn resolve_target<'a>(entries: &'a [WorktreeEntry], target: &str) -> Result<&'a WorktreeEntry> {
212    resolve_target_inner(entries, target, false)
213}
214
215/// Same matching rules as `resolve_target` but includes the main worktree.
216/// Used by read-only commands like `worktree list` where the main worktree is
217/// a valid target.
218pub fn resolve_target_any<'a>(
219    entries: &'a [WorktreeEntry],
220    target: &str,
221) -> Result<&'a WorktreeEntry> {
222    resolve_target_inner(entries, target, true)
223}
224
225fn resolve_target_inner<'a>(
226    entries: &'a [WorktreeEntry],
227    target: &str,
228    include_main: bool,
229) -> Result<&'a WorktreeEntry> {
230    let target_norm = target.trim_end_matches('/');
231    let target_canon = PathBuf::from(target).canonicalize().ok();
232
233    let mut matches: Vec<&WorktreeEntry> = Vec::new();
234    for e in entries.iter().filter(|e| include_main || !e.is_main) {
235        let mut hit = false;
236
237        let entry_str = e.path.to_string_lossy();
238        let entry_norm = entry_str.trim_end_matches('/');
239        if entry_norm == target_norm {
240            hit = true;
241        }
242        if !hit
243            && let (Some(t), Some(ec)) = (&target_canon, e.path.canonicalize().ok())
244            && t == &ec
245        {
246            hit = true;
247        }
248        if !hit
249            && let Some(b) = e.branch.as_deref()
250            && short_branch(b) == target
251        {
252            hit = true;
253        }
254        if hit && !matches.iter().any(|m| std::ptr::eq(*m, e)) {
255            matches.push(e);
256        }
257    }
258
259    match matches.len() {
260        0 => bail!("no worktree matches '{target}'"),
261        1 => Ok(matches[0]),
262        _ => {
263            let paths: Vec<String> = matches
264                .iter()
265                .map(|m| m.path.display().to_string())
266                .collect();
267            bail!("'{target}' is ambiguous, matches: {}", paths.join(", "))
268        }
269    }
270}
271
272/// Run `git worktree remove [--force] <path>`. Surfaces git's stderr verbatim.
273pub fn remove(worktree_path: &Path, force: bool) -> Result<()> {
274    let mut cmd = Command::new("git");
275    cmd.args(["worktree", "remove"]);
276    if force {
277        cmd.arg("--force");
278    }
279    cmd.arg(worktree_path);
280    let output = cmd.output().context("invoking git worktree remove")?;
281    let mut err = std::io::stderr().lock();
282    err.write_all(&output.stdout).ok();
283    err.write_all(&output.stderr).ok();
284    if !output.status.success() {
285        bail!("git worktree remove failed (status {})", output.status);
286    }
287    Ok(())
288}
289
290#[derive(Debug, Clone, Default)]
291pub struct Options {
292    pub branch: String,
293    pub from: String,
294    /// Working directory to resolve the source repo from; empty → cwd.
295    pub start_in: String,
296}
297
298#[derive(Debug, Clone)]
299pub struct CreateResult {
300    pub repo_root: PathBuf,
301    pub repo_name: String,
302    pub worktree_path: PathBuf,
303    /// Post-create script that was executed (empty if none).
304    pub script_ran: String,
305}
306
307/// Run the full worktree-creation flow: resolve the source repo, compute the
308/// sibling path, run `git worktree add`, and execute the matching post-create
309/// script. The caller (CLI layer) is responsible for any tmux integration.
310pub fn create(opts: Options, cfg: &Config) -> Result<CreateResult> {
311    if opts.branch.trim().is_empty() {
312        bail!("branch name is required");
313    }
314
315    let start_in = if opts.start_in.is_empty() {
316        std::env::current_dir().context("resolve cwd")?
317    } else {
318        PathBuf::from(&opts.start_in)
319    };
320
321    let repo_root = resolve_repo_root(&start_in)?;
322    let repo_name = repo_root
323        .file_name()
324        .map(|n| n.to_string_lossy().into_owned())
325        .ok_or_else(|| anyhow!("could not determine repo name from {}", repo_root.display()))?;
326
327    let dir_name = render_naming(&cfg.worktree.naming, &repo_name, &opts.branch);
328    let worktree_path = repo_root
329        .parent()
330        .map(|p| p.join(&dir_name))
331        .ok_or_else(|| anyhow!("repo root has no parent: {}", repo_root.display()))?;
332
333    if worktree_path.exists() {
334        bail!("target path already exists: {}", worktree_path.display());
335    }
336
337    run_git_worktree_add(&repo_root, &worktree_path, &opts.branch, &opts.from)?;
338
339    let mut result = CreateResult {
340        repo_root: repo_root.clone(),
341        repo_name,
342        worktree_path: worktree_path.clone(),
343        script_ran: String::new(),
344    };
345
346    if let Some(script) = cfg
347        .worktree
348        .scripts
349        .get(repo_root.to_string_lossy().as_ref())
350        && !script.is_empty()
351    {
352        run_post_create(script, &worktree_path).context("post_create script failed")?;
353        result.script_ran = script.clone();
354    }
355
356    Ok(result)
357}
358
359pub fn resolve_repo_root(start_in: &Path) -> Result<PathBuf> {
360    let output = Command::new("git")
361        .arg("-C")
362        .arg(start_in)
363        .args(["rev-parse", "--show-toplevel"])
364        .output()
365        .with_context(|| format!("invoking git in {}", start_in.display()))?;
366
367    if !output.status.success() {
368        bail!("not inside a git repository: {}", start_in.display());
369    }
370
371    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
372    Ok(PathBuf::from(path))
373}
374
375pub fn render_naming(pattern: &str, repo: &str, branch: &str) -> String {
376    let effective = if pattern.is_empty() {
377        "{repo}-{branch}"
378    } else {
379        pattern
380    };
381    let safe_branch = branch.replace('/', "-");
382    effective
383        .replace("{repo}", repo)
384        .replace("{branch}", &safe_branch)
385}
386
387fn run_git_worktree_add(
388    repo_root: &Path,
389    worktree_path: &Path,
390    branch: &str,
391    from: &str,
392) -> Result<()> {
393    let mut cmd = Command::new("git");
394    cmd.arg("-C")
395        .arg(repo_root)
396        .args(["worktree", "add", "-b", branch])
397        .arg(worktree_path);
398    if !from.is_empty() {
399        cmd.arg(from);
400    }
401    let output = cmd.output().context("invoking git worktree add")?;
402    // Mirror Go's behaviour: git's stdout + stderr both go to our stderr so the
403    // worktree path printed on stdout stays clean for `cd $(...)` callers.
404    let mut err = std::io::stderr().lock();
405    err.write_all(&output.stdout).ok();
406    err.write_all(&output.stderr).ok();
407    if !output.status.success() {
408        bail!("git worktree add failed (status {})", output.status);
409    }
410    Ok(())
411}
412
413fn run_post_create(script: &str, worktree_path: &Path) -> Result<()> {
414    let resolved = resolve_script_path(script, worktree_path);
415    let output = Command::new("bash")
416        .arg(&resolved)
417        .current_dir(worktree_path)
418        .env("WORKTREE_PATH", worktree_path)
419        .output()
420        .with_context(|| format!("invoking script {}", resolved.display()))?;
421    let mut err = std::io::stderr().lock();
422    err.write_all(&output.stdout).ok();
423    err.write_all(&output.stderr).ok();
424    if !output.status.success() {
425        bail!("post-create script exited with status {}", output.status);
426    }
427    Ok(())
428}
429
430pub fn resolve_script_path(script: &str, worktree_path: &Path) -> PathBuf {
431    let expanded = expand_home(script);
432    let expanded_path = PathBuf::from(&expanded);
433    if expanded_path.is_absolute() {
434        return expanded_path;
435    }
436    worktree_path.join(expanded)
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use std::collections::HashMap;
443    use std::process::{Command, Stdio};
444    use tempfile::TempDir;
445
446    fn git_available() -> bool {
447        Command::new("git")
448            .arg("--version")
449            .stdout(Stdio::null())
450            .stderr(Stdio::null())
451            .status()
452            .map(|s| s.success())
453            .unwrap_or(false)
454    }
455
456    fn init_repo(path: &Path) {
457        let run = |args: &[&str]| {
458            Command::new("git")
459                .arg("-C")
460                .arg(path)
461                .args(args)
462                .stdout(Stdio::null())
463                .stderr(Stdio::null())
464                .status()
465                .expect("git command")
466        };
467        std::fs::create_dir_all(path).unwrap();
468        assert!(run(&["init", "-q", "-b", "main"]).success());
469        assert!(run(&["config", "user.email", "test@example.com"]).success());
470        assert!(run(&["config", "user.name", "Test"]).success());
471        assert!(run(&["commit", "--allow-empty", "-m", "init"]).success());
472    }
473
474    #[test]
475    fn render_naming_default_pattern() {
476        assert_eq!(render_naming("", "repo", "feat"), "repo-feat");
477    }
478
479    #[test]
480    fn render_naming_replaces_slashes_in_branch() {
481        assert_eq!(
482            render_naming("{repo}-{branch}", "repo", "feat/x"),
483            "repo-feat-x"
484        );
485    }
486
487    #[test]
488    fn render_naming_custom_pattern() {
489        assert_eq!(
490            render_naming("wt_{repo}_{branch}_v2", "my-project", "feat/cool"),
491            "wt_my-project_feat-cool_v2"
492        );
493    }
494
495    #[test]
496    fn resolve_script_path_absolute_is_kept() {
497        let wt = Path::new("/tmp/worktree");
498        assert_eq!(
499            resolve_script_path("/etc/script.sh", wt),
500            PathBuf::from("/etc/script.sh")
501        );
502    }
503
504    #[test]
505    fn resolve_script_path_relative_resolves_against_worktree() {
506        let wt = Path::new("/tmp/worktree");
507        assert_eq!(
508            resolve_script_path("scripts/setup.sh", wt),
509            PathBuf::from("/tmp/worktree/scripts/setup.sh")
510        );
511    }
512
513    #[test]
514    fn resolve_script_path_tilde_expands() {
515        let wt = Path::new("/tmp/worktree");
516        let home = dirs::home_dir().unwrap();
517        assert_eq!(
518            resolve_script_path("~/scripts/setup.sh", wt),
519            home.join("scripts/setup.sh")
520        );
521    }
522
523    #[test]
524    fn create_rejects_empty_branch() {
525        let cfg = Config::default();
526        let err = create(
527            Options {
528                branch: "  ".into(),
529                ..Default::default()
530            },
531            &cfg,
532        )
533        .unwrap_err();
534        assert!(err.to_string().contains("branch name is required"));
535    }
536
537    #[test]
538    fn create_rejects_non_repo_path() {
539        if !git_available() {
540            return;
541        }
542        let dir = TempDir::new().unwrap();
543        let cfg = Config::default();
544        let err = create(
545            Options {
546                branch: "feat".into(),
547                start_in: dir.path().to_string_lossy().into_owned(),
548                ..Default::default()
549            },
550            &cfg,
551        )
552        .unwrap_err();
553        assert!(err.to_string().contains("not inside a git repository"));
554    }
555
556    #[test]
557    fn create_makes_worktree_and_runs_script() {
558        if !git_available() {
559            return;
560        }
561        let dir = TempDir::new().unwrap();
562        let repo_root = dir.path().join("source-repo");
563        init_repo(&repo_root);
564
565        let script_path = repo_root.join("setup.sh");
566        let marker = dir.path().join("ran.txt");
567        std::fs::write(
568            &script_path,
569            format!(
570                "#!/usr/bin/env bash\necho ok > {}\n",
571                marker.to_string_lossy()
572            ),
573        )
574        .unwrap();
575        use std::os::unix::fs::PermissionsExt;
576        let mut perms = std::fs::metadata(&script_path).unwrap().permissions();
577        perms.set_mode(0o755);
578        std::fs::set_permissions(&script_path, perms).unwrap();
579
580        let mut scripts = HashMap::new();
581        scripts.insert(
582            repo_root.to_string_lossy().into_owned(),
583            script_path.to_string_lossy().into_owned(),
584        );
585        let cfg = Config {
586            roots: vec![],
587            worktree: crate::config::WorktreeConfig {
588                naming: "{repo}-{branch}".into(),
589                scripts,
590            },
591        };
592
593        let res = create(
594            Options {
595                branch: "feat/x".into(),
596                start_in: repo_root.to_string_lossy().into_owned(),
597                ..Default::default()
598            },
599            &cfg,
600        )
601        .unwrap();
602
603        let expected_wt = dir.path().join("source-repo-feat-x");
604        assert_eq!(res.worktree_path, expected_wt);
605        assert!(expected_wt.exists());
606        assert_eq!(res.repo_name, "source-repo");
607        assert_eq!(res.script_ran, script_path.to_string_lossy());
608        assert!(marker.exists());
609    }
610
611    #[test]
612    fn parse_porcelain_marks_first_as_main_and_extracts_fields() {
613        let input = "\
614worktree /home/user/repo
615HEAD abc123
616branch refs/heads/main
617
618worktree /home/user/repo-feat
619HEAD def456
620branch refs/heads/feat
621
622worktree /home/user/repo-detached
623HEAD ff00ff
624detached
625
626worktree /home/user/repo-locked
627HEAD 111111
628branch refs/heads/lk
629locked needed offline
630
631worktree /home/user/repo-prunable
632HEAD 222222
633branch refs/heads/old
634prunable gitdir file points to nonexistent location
635";
636        let v = parse_porcelain(input);
637        assert_eq!(v.len(), 5);
638        assert!(v[0].is_main);
639        assert!(!v[1].is_main);
640        assert_eq!(v[1].branch.as_deref(), Some("refs/heads/feat"));
641        assert_eq!(v[2].branch, None);
642        assert!(v[3].locked);
643        assert!(v[4].prunable);
644        assert_eq!(v[0].head.as_deref(), Some("abc123"));
645    }
646
647    #[test]
648    fn parse_porcelain_handles_locked_without_reason() {
649        let input = "worktree /a\nHEAD aa\nlocked\n";
650        let v = parse_porcelain(input);
651        assert_eq!(v.len(), 1);
652        assert!(v[0].locked);
653    }
654
655    #[test]
656    fn short_branch_strips_refs_heads() {
657        assert_eq!(short_branch("refs/heads/feat-x"), "feat-x");
658        assert_eq!(short_branch("feat-x"), "feat-x");
659    }
660
661    #[test]
662    fn parse_pane_lines_splits_on_first_two_tabs() {
663        let input = "\
664$0\tmain\t/home/me/a
665$1\tfoo\t/home/me/b
666$2\tbar\t/path/with\ttab/in/it
667$3\tempty\t
668";
669        let v = parse_pane_lines(input);
670        assert_eq!(v.len(), 3);
671        assert_eq!(v[0].session_id, "$0");
672        assert_eq!(v[0].session_name, "main");
673        assert_eq!(v[0].path, "/home/me/a");
674        // path with embedded tab: only the first two tabs are split, the rest stays.
675        assert_eq!(v[2].path, "/path/with\ttab/in/it");
676    }
677
678    #[test]
679    fn match_attached_finds_direct_and_descendant_paths() {
680        let panes = vec![
681            PaneEntry {
682                session_id: "$0".into(),
683                session_name: "a".into(),
684                path: "/tmp/wt".into(),
685            },
686            PaneEntry {
687                session_id: "$1".into(),
688                session_name: "b".into(),
689                path: "/tmp/wt/sub".into(),
690            },
691            PaneEntry {
692                session_id: "$2".into(),
693                session_name: "c".into(),
694                path: "/tmp/wt-other".into(),
695            },
696        ];
697        let attached = match_attached(&panes, Path::new("/tmp/wt"));
698        let ids: Vec<&str> = attached.iter().map(|s| s.id.as_str()).collect();
699        assert_eq!(ids, vec!["$0", "$1"]);
700    }
701
702    #[test]
703    fn match_attached_dedups_by_session_id() {
704        let panes = vec![
705            PaneEntry {
706                session_id: "$0".into(),
707                session_name: "a".into(),
708                path: "/tmp/wt".into(),
709            },
710            PaneEntry {
711                session_id: "$0".into(),
712                session_name: "a".into(),
713                path: "/tmp/wt/sub".into(),
714            },
715        ];
716        let attached = match_attached(&panes, Path::new("/tmp/wt"));
717        assert_eq!(attached.len(), 1);
718        assert_eq!(attached[0].id, "$0");
719    }
720
721    fn make_entry(path: &str, branch: Option<&str>, is_main: bool) -> WorktreeEntry {
722        WorktreeEntry {
723            path: PathBuf::from(path),
724            branch: branch.map(|s| s.to_string()),
725            head: None,
726            locked: false,
727            prunable: false,
728            is_main,
729        }
730    }
731
732    #[test]
733    fn resolve_target_exact_path_match() {
734        let entries = vec![
735            make_entry("/main", Some("refs/heads/main"), true),
736            make_entry("/wt-a", Some("refs/heads/feat-a"), false),
737        ];
738        let m = resolve_target(&entries, "/wt-a").unwrap();
739        assert_eq!(m.path, PathBuf::from("/wt-a"));
740    }
741
742    #[test]
743    fn resolve_target_short_branch_match() {
744        let entries = vec![
745            make_entry("/main", Some("refs/heads/main"), true),
746            make_entry("/wt-a", Some("refs/heads/feat-a"), false),
747        ];
748        let m = resolve_target(&entries, "feat-a").unwrap();
749        assert_eq!(m.path, PathBuf::from("/wt-a"));
750    }
751
752    #[test]
753    fn resolve_target_excludes_main_worktree() {
754        let entries = vec![make_entry("/main", Some("refs/heads/main"), true)];
755        let err = resolve_target(&entries, "/main").unwrap_err();
756        assert!(err.to_string().contains("no worktree matches"));
757    }
758
759    #[test]
760    fn resolve_target_any_includes_main_worktree() {
761        let entries = vec![
762            make_entry("/main", Some("refs/heads/main"), true),
763            make_entry("/wt", Some("refs/heads/feat"), false),
764        ];
765        let by_path = resolve_target_any(&entries, "/main").unwrap();
766        assert_eq!(by_path.path, PathBuf::from("/main"));
767        let by_branch = resolve_target_any(&entries, "main").unwrap();
768        assert_eq!(by_branch.path, PathBuf::from("/main"));
769    }
770
771    #[test]
772    fn resolve_target_not_found_errors() {
773        let entries = vec![make_entry("/wt-a", Some("refs/heads/a"), false)];
774        let err = resolve_target(&entries, "/nope").unwrap_err();
775        assert!(err.to_string().contains("no worktree matches"));
776    }
777
778    #[test]
779    fn resolve_target_ambiguous_duplicate_branch_errors() {
780        // Two entries with the same branch — real git forbids this but the
781        // helper must still surface ambiguity rather than silently picking.
782        let entries = vec![
783            make_entry("/main", Some("refs/heads/main"), true),
784            make_entry("/wt-a", Some("refs/heads/feat-a"), false),
785            make_entry("/wt-b", Some("refs/heads/feat-a"), false),
786        ];
787        let err = resolve_target(&entries, "feat-a").unwrap_err();
788        assert!(err.to_string().contains("ambiguous"));
789    }
790
791    #[test]
792    fn resolve_target_ambiguous_cross_class_errors() {
793        // target "feat-a" matches entry 1 by lexical path (no leading slash),
794        // and entry 2 by short branch. Resolver must report ambiguous.
795        let entries = vec![
796            make_entry("/main", Some("refs/heads/main"), true),
797            make_entry("feat-a", Some("refs/heads/other"), false),
798            make_entry("/other-path", Some("refs/heads/feat-a"), false),
799        ];
800        let err = resolve_target(&entries, "feat-a").unwrap_err();
801        assert!(err.to_string().contains("ambiguous"));
802    }
803
804    #[test]
805    fn resolve_target_detached_skipped_for_branch_match() {
806        let entries = vec![
807            make_entry("/main", Some("refs/heads/main"), true),
808            make_entry("/det", None, false),
809        ];
810        let err = resolve_target(&entries, "det").unwrap_err();
811        assert!(err.to_string().contains("no worktree matches"));
812    }
813
814    #[test]
815    fn remove_decision_locked_refuses() {
816        let mut e = make_entry("/a", Some("refs/heads/a"), false);
817        e.locked = true;
818        assert_eq!(remove_decision(&e), RemoveDecision::LockedRefuse);
819    }
820
821    #[test]
822    fn remove_decision_clean_proceeds() {
823        let e = make_entry("/a", Some("refs/heads/a"), false);
824        assert_eq!(remove_decision(&e), RemoveDecision::Proceed);
825    }
826
827    #[test]
828    fn create_rejects_existing_target() {
829        if !git_available() {
830            return;
831        }
832        let dir = TempDir::new().unwrap();
833        let repo_root = dir.path().join("source-repo");
834        init_repo(&repo_root);
835        std::fs::create_dir(dir.path().join("source-repo-feat")).unwrap();
836
837        let cfg = Config::default();
838        let err = create(
839            Options {
840                branch: "feat".into(),
841                start_in: repo_root.to_string_lossy().into_owned(),
842                ..Default::default()
843            },
844            &cfg,
845        )
846        .unwrap_err();
847        assert!(err.to_string().contains("target path already exists"));
848    }
849}