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#[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
49pub 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 } 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
105pub 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
127pub 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
149fn 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
164pub fn match_attached(panes: &[PaneEntry], worktree: &Path) -> Vec<AttachedSession> {
167 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
188pub 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
211pub fn resolve_target<'a>(entries: &'a [WorktreeEntry], target: &str) -> Result<&'a WorktreeEntry> {
213 resolve_target_inner(entries, target, false)
214}
215
216pub 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
273pub 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 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 pub script_ran: String,
306}
307
308pub 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 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 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 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 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 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}