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