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#[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
48pub 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 } 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
104pub 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
126pub 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
148fn 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
163pub fn match_attached(panes: &[PaneEntry], worktree: &Path) -> Vec<AttachedSession> {
166 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
187pub 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
210pub fn resolve_target<'a>(entries: &'a [WorktreeEntry], target: &str) -> Result<&'a WorktreeEntry> {
212 resolve_target_inner(entries, target, false)
213}
214
215pub 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
272pub 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 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 pub script_ran: String,
305}
306
307pub 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 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 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 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 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}