1use std::collections::BTreeMap;
15use std::io::Write;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18use std::time::{Duration, Instant};
19
20use anyhow::{Context, Result};
21use clap::{Parser, Subcommand};
22use serde::Serialize;
23
24use crate::request_log::{self, WorktreeOutcome};
25
26#[derive(Parser)]
28#[command(
29 long_about = "Worktree operations: thin wrappers over `git worktree` that record \
30recovery-relevant metadata (path, branch, commit, uncommitted status) to the omni-dev \
31request log. If a worktree is removed by mistake, `omni-dev log --command 'git worktree \
32remove'` returns the row needed to recover the branch.\n\nNot to be confused with \
33`omni-dev worktrees`, which lists worktrees open across VS Code windows via the daemon."
34)]
35pub struct WorktreeCommand {
36 #[command(subcommand)]
38 pub command: WorktreeSubcommands,
39}
40
41#[derive(Subcommand)]
43pub enum WorktreeSubcommands {
44 Add(AddArgs),
46 Remove(RemoveArgs),
48 List(ListArgs),
50 Move(MoveArgs),
52 Prune(PruneArgs),
54 Repair(RepairArgs),
56}
57
58#[derive(Parser)]
60pub struct AddArgs {
61 pub path: PathBuf,
63 pub commit_ish: Option<String>,
65 #[arg(short = 'b', long = "branch", value_name = "BRANCH")]
67 pub branch: Option<String>,
68 #[arg(short = 'f', long)]
70 pub force: bool,
71 #[arg(long)]
73 pub detach: bool,
74}
75
76#[derive(Parser)]
78pub struct RemoveArgs {
79 pub path: PathBuf,
81 #[arg(short = 'f', long)]
83 pub force: bool,
84}
85
86#[derive(Parser)]
88pub struct ListArgs {
89 #[arg(long, conflicts_with = "output_json")]
91 pub porcelain: bool,
92 #[arg(long)]
94 pub output_json: bool,
95}
96
97#[derive(Parser)]
99pub struct MoveArgs {
100 pub from: PathBuf,
102 pub to: PathBuf,
104 #[arg(short = 'f', long)]
106 pub force: bool,
107}
108
109#[derive(Parser)]
111pub struct PruneArgs {
112 #[arg(short = 'n', long)]
114 pub dry_run: bool,
115 #[arg(short = 'v', long)]
117 pub verbose: bool,
118 #[arg(long, value_name = "TIME")]
120 pub expire: Option<String>,
121}
122
123#[derive(Parser)]
125pub struct RepairArgs {
126 pub paths: Vec<PathBuf>,
128}
129
130impl WorktreeCommand {
131 pub fn execute(self, repo: Option<&Path>) -> Result<()> {
137 let base = base_dir(repo)?;
138 match self.command {
139 WorktreeSubcommands::Add(args) => run_add(&base, &args),
140 WorktreeSubcommands::Remove(args) => run_remove(&base, &args),
141 WorktreeSubcommands::List(args) => run_list(&base, &args),
142 WorktreeSubcommands::Move(args) => run_move(&base, &args),
143 WorktreeSubcommands::Prune(args) => run_prune(&base, &args),
144 WorktreeSubcommands::Repair(args) => run_repair(&base, &args),
145 }
146 }
147}
148
149fn run_add(base: &Path, args: &AddArgs) -> Result<()> {
152 let argv = add_argv(args);
153 let mut context = base_context(base);
154 let abs = absolutize(base, &args.path);
155 let run = run_git(base, &argv);
156 echo(&run);
157 context.insert("path".to_string(), display_canonical(&abs));
158 if run.exit_code == Some(0) {
159 insert_snapshot(&mut context, &snapshot(&abs), false);
161 }
162 finish("add", argv, run, context)
163}
164
165fn run_remove(base: &Path, args: &RemoveArgs) -> Result<()> {
166 let argv = remove_argv(args);
167 let mut context = base_context(base);
168 let abs = absolutize(base, &args.path);
169 context.insert("path".to_string(), display_canonical(&abs));
172 context.insert("used_force".to_string(), args.force.to_string());
173 let snap = snapshot(&abs);
174 insert_snapshot(&mut context, &snap, true);
175 let run = run_git(base, &argv);
176 echo(&run);
177 finish("remove", argv, run, context)
178}
179
180fn run_list(base: &Path, args: &ListArgs) -> Result<()> {
181 let porcelain = args.porcelain || args.output_json;
182 let argv = list_argv(porcelain);
183 let mut context = base_context(base);
184 let run = run_git(base, &argv);
185 if args.output_json {
186 echo_stderr(&run);
188 if run.exit_code == Some(0) {
189 let stdout = String::from_utf8_lossy(&run.stdout);
190 let entries = parse_worktree_porcelain(&stdout);
191 context.insert("count".to_string(), entries.len().to_string());
192 let json = serde_json::to_string_pretty(&entries)
193 .context("Failed to serialize worktree list as JSON")?;
194 println!("{json}");
195 }
196 } else {
197 echo(&run);
198 if run.exit_code == Some(0) {
199 let stdout = String::from_utf8_lossy(&run.stdout);
200 let count = if porcelain {
201 parse_worktree_porcelain(&stdout).len()
202 } else {
203 stdout.lines().filter(|l| !l.trim().is_empty()).count()
204 };
205 context.insert("count".to_string(), count.to_string());
206 }
207 }
208 finish("list", argv, run, context)
209}
210
211fn run_move(base: &Path, args: &MoveArgs) -> Result<()> {
212 let argv = move_argv(args);
213 let mut context = base_context(base);
214 let from_abs = absolutize(base, &args.from);
215 context.insert("from_path".to_string(), display_canonical(&from_abs));
216 context.insert(
217 "to_path".to_string(),
218 absolutize(base, &args.to).display().to_string(),
219 );
220 let snap = snapshot(&from_abs);
222 if let Some(branch) = &snap.branch {
223 context.insert("branch".to_string(), branch.clone());
224 }
225 let run = run_git(base, &argv);
226 echo(&run);
227 finish("move", argv, run, context)
228}
229
230fn run_prune(base: &Path, args: &PruneArgs) -> Result<()> {
231 let argv = prune_argv(args);
232 let mut context = base_context(base);
233 if args.dry_run {
234 context.insert("dry_run".to_string(), "true".to_string());
235 }
236 let before = list_entries(base);
239 let run = run_git(base, &argv);
240 echo(&run);
241 if let (Some(before), Some(after)) = (before, list_entries(base)) {
242 let pruned = pruned_entries(&before, &after);
243 match serde_json::to_string(&pruned) {
244 Ok(json) => {
245 context.insert("pruned".to_string(), json);
246 }
247 Err(e) => tracing::debug!("Cannot serialize pruned worktree list: {e}"),
248 }
249 }
250 finish("prune", argv, run, context)
251}
252
253fn run_repair(base: &Path, args: &RepairArgs) -> Result<()> {
254 let argv = repair_argv(args);
255 let mut context = base_context(base);
256 let run = run_git(base, &argv);
257 echo(&run);
258 let combined = format!(
260 "{}{}",
261 String::from_utf8_lossy(&run.stdout),
262 String::from_utf8_lossy(&run.stderr)
263 );
264 let repaired = parse_repair_paths(&combined);
265 if !repaired.is_empty() {
266 match serde_json::to_string(&repaired) {
267 Ok(json) => {
268 context.insert("repaired".to_string(), json);
269 }
270 Err(e) => tracing::debug!("Cannot serialize repaired worktree list: {e}"),
271 }
272 }
273 finish("repair", argv, run, context)
274}
275
276fn finish(
279 verb: &str,
280 argv: Vec<String>,
281 run: GitRun,
282 context: BTreeMap<String, String>,
283) -> Result<()> {
284 request_log::record_worktree(WorktreeOutcome {
285 verb: verb.to_string(),
286 argv,
287 exit_code: run.exit_code,
288 duration: run.duration,
289 error: run.error.clone(),
290 context,
291 });
292 match (run.exit_code, run.error) {
293 (Some(0), _) => Ok(()),
294 (Some(code), _) => anyhow::bail!("git worktree {verb} failed with exit code {code}"),
296 (None, err) => anyhow::bail!(
297 "Failed to run git worktree {verb}: {}",
298 err.unwrap_or_else(|| "unknown spawn error".to_string())
299 ),
300 }
301}
302
303fn add_argv(args: &AddArgs) -> Vec<String> {
306 let mut argv = vec!["worktree".to_string(), "add".to_string()];
307 if args.force {
308 argv.push("--force".to_string());
309 }
310 if args.detach {
311 argv.push("--detach".to_string());
312 }
313 if let Some(branch) = &args.branch {
314 argv.push("-b".to_string());
315 argv.push(branch.clone());
316 }
317 argv.push(args.path.display().to_string());
318 if let Some(commit_ish) = &args.commit_ish {
319 argv.push(commit_ish.clone());
320 }
321 argv
322}
323
324fn remove_argv(args: &RemoveArgs) -> Vec<String> {
325 let mut argv = vec!["worktree".to_string(), "remove".to_string()];
326 if args.force {
327 argv.push("--force".to_string());
328 }
329 argv.push(args.path.display().to_string());
330 argv
331}
332
333fn list_argv(porcelain: bool) -> Vec<String> {
334 if porcelain {
335 vec![
337 "-c".to_string(),
338 "core.quotePath=false".to_string(),
339 "worktree".to_string(),
340 "list".to_string(),
341 "--porcelain".to_string(),
342 ]
343 } else {
344 vec!["worktree".to_string(), "list".to_string()]
345 }
346}
347
348fn move_argv(args: &MoveArgs) -> Vec<String> {
349 let mut argv = vec!["worktree".to_string(), "move".to_string()];
350 if args.force {
351 argv.push("--force".to_string());
352 }
353 argv.push(args.from.display().to_string());
354 argv.push(args.to.display().to_string());
355 argv
356}
357
358fn prune_argv(args: &PruneArgs) -> Vec<String> {
359 let mut argv = vec!["worktree".to_string(), "prune".to_string()];
360 if args.dry_run {
361 argv.push("--dry-run".to_string());
362 }
363 if args.verbose {
364 argv.push("--verbose".to_string());
365 }
366 if let Some(expire) = &args.expire {
367 argv.push("--expire".to_string());
368 argv.push(expire.clone());
369 }
370 argv
371}
372
373fn repair_argv(args: &RepairArgs) -> Vec<String> {
374 let mut argv = vec!["worktree".to_string(), "repair".to_string()];
375 argv.extend(args.paths.iter().map(|p| p.display().to_string()));
376 argv
377}
378
379struct GitRun {
383 exit_code: Option<i32>,
384 duration: Duration,
385 error: Option<String>,
386 stdout: Vec<u8>,
387 stderr: Vec<u8>,
388}
389
390fn git_command() -> Command {
395 let mut cmd = Command::new("git");
396 cmd.env_clear();
397 cmd.envs(std::env::vars_os());
398 cmd
399}
400
401fn run_git(base: &Path, argv: &[String]) -> GitRun {
404 let started = Instant::now();
405 let result = git_command().args(argv).current_dir(base).output();
406 let duration = started.elapsed();
407 match result {
408 Ok(output) => GitRun {
409 exit_code: output.status.code(),
410 duration,
411 error: None,
412 stdout: output.stdout,
413 stderr: output.stderr,
414 },
415 Err(e) => GitRun {
416 exit_code: None,
417 duration,
418 error: Some(e.to_string()),
419 stdout: Vec::new(),
420 stderr: Vec::new(),
421 },
422 }
423}
424
425fn echo(run: &GitRun) {
427 let _ = std::io::stdout().write_all(&run.stdout);
430 echo_stderr(run);
431}
432
433fn echo_stderr(run: &GitRun) {
435 let _ = std::io::stderr().write_all(&run.stderr);
437}
438
439fn base_dir(repo: Option<&Path>) -> Result<PathBuf> {
443 match repo {
444 Some(p) => Ok(p.to_path_buf()),
445 None => std::env::current_dir().context("Failed to resolve working directory"),
446 }
447}
448
449fn base_context(base: &Path) -> BTreeMap<String, String> {
451 let mut context = BTreeMap::new();
452 if let Some(toplevel) = toplevel(base) {
453 context.insert("repo".to_string(), toplevel);
454 }
455 context
456}
457
458fn toplevel(base: &Path) -> Option<String> {
460 let output = git_command()
461 .args(["rev-parse", "--show-toplevel"])
462 .current_dir(base)
463 .output()
464 .ok()?;
465 if !output.status.success() {
466 return None;
467 }
468 let stdout = String::from_utf8(output.stdout).ok()?;
469 let trimmed = stdout.trim();
470 (!trimmed.is_empty()).then(|| trimmed.to_string())
471}
472
473#[derive(Default)]
475struct WtSnapshot {
476 branch: Option<String>,
477 detached: bool,
478 commit: Option<String>,
479 dirty: Option<bool>,
480}
481
482fn snapshot(path: &Path) -> WtSnapshot {
484 let mut snap = WtSnapshot::default();
485 let repo = match git2::Repository::open(path) {
486 Ok(repo) => repo,
487 Err(e) => {
488 tracing::debug!("Cannot open {} as a git repository: {e}", path.display());
489 return snap;
490 }
491 };
492 match repo.head() {
493 Ok(head) => {
494 if head.is_branch() {
495 snap.branch = head.shorthand().ok().map(str::to_string);
497 } else {
498 snap.detached = true;
499 }
500 snap.commit = head.target().map(|oid| oid.to_string());
501 }
502 Err(e) => tracing::debug!("Cannot read HEAD of {}: {e}", path.display()),
503 }
504 let mut opts = git2::StatusOptions::new();
507 opts.include_untracked(true)
508 .recurse_untracked_dirs(true)
509 .include_ignored(false)
510 .exclude_submodules(true);
511 match repo.statuses(Some(&mut opts)) {
512 Ok(statuses) => snap.dirty = Some(!statuses.is_empty()),
513 Err(e) => tracing::debug!("Cannot read status of {}: {e}", path.display()),
514 }
515 snap
516}
517
518fn insert_snapshot(context: &mut BTreeMap<String, String>, snap: &WtSnapshot, with_dirty: bool) {
520 if let Some(branch) = &snap.branch {
521 context.insert("branch".to_string(), branch.clone());
522 }
523 if snap.detached {
524 context.insert("detached".to_string(), "true".to_string());
525 }
526 if let Some(commit) = &snap.commit {
527 context.insert("commit".to_string(), commit.clone());
528 }
529 if with_dirty {
530 if let Some(dirty) = snap.dirty {
531 context.insert("had_uncommitted".to_string(), dirty.to_string());
532 }
533 }
534}
535
536fn absolutize(base: &Path, path: &Path) -> PathBuf {
538 if path.is_absolute() {
539 path.to_path_buf()
540 } else {
541 base.join(path)
542 }
543}
544
545fn display_canonical(path: &Path) -> String {
548 std::fs::canonicalize(path)
549 .unwrap_or_else(|_| path.to_path_buf())
550 .display()
551 .to_string()
552}
553
554#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
558pub struct WorktreeEntry {
559 pub path: String,
561 #[serde(skip_serializing_if = "Option::is_none")]
563 pub head: Option<String>,
564 #[serde(skip_serializing_if = "Option::is_none")]
566 pub branch: Option<String>,
567 #[serde(skip_serializing_if = "std::ops::Not::not")]
569 pub detached: bool,
570 #[serde(skip_serializing_if = "std::ops::Not::not")]
572 pub bare: bool,
573 #[serde(skip_serializing_if = "Option::is_none")]
575 pub locked: Option<String>,
576 #[serde(skip_serializing_if = "Option::is_none")]
578 pub prunable: Option<String>,
579}
580
581impl WorktreeEntry {
582 fn new(path: String) -> Self {
583 Self {
584 path,
585 head: None,
586 branch: None,
587 detached: false,
588 bare: false,
589 locked: None,
590 prunable: None,
591 }
592 }
593}
594
595fn parse_worktree_porcelain(out: &str) -> Vec<WorktreeEntry> {
598 let mut entries = Vec::new();
599 let mut current: Option<WorktreeEntry> = None;
600 for line in out.lines() {
601 if let Some(path) = line.strip_prefix("worktree ") {
602 entries.extend(current.take());
603 current = Some(WorktreeEntry::new(path.to_string()));
604 continue;
605 }
606 let Some(entry) = current.as_mut() else {
607 continue;
608 };
609 if let Some(head) = line.strip_prefix("HEAD ") {
610 entry.head = Some(head.to_string());
611 } else if let Some(branch) = line.strip_prefix("branch ") {
612 entry.branch = Some(
613 branch
614 .strip_prefix("refs/heads/")
615 .unwrap_or(branch)
616 .to_string(),
617 );
618 } else if line == "detached" {
619 entry.detached = true;
620 } else if line == "bare" {
621 entry.bare = true;
622 } else if line == "locked" {
623 entry.locked = Some(String::new());
624 } else if let Some(reason) = line.strip_prefix("locked ") {
625 entry.locked = Some(reason.to_string());
626 } else if let Some(reason) = line.strip_prefix("prunable ") {
627 entry.prunable = Some(reason.to_string());
628 }
629 }
631 entries.extend(current);
632 entries
633}
634
635fn list_entries(base: &Path) -> Option<Vec<WorktreeEntry>> {
637 let output = git_command()
638 .args([
639 "-c",
640 "core.quotePath=false",
641 "worktree",
642 "list",
643 "--porcelain",
644 ])
645 .current_dir(base)
646 .output()
647 .ok()?;
648 if !output.status.success() {
649 return None;
650 }
651 Some(parse_worktree_porcelain(&String::from_utf8_lossy(
652 &output.stdout,
653 )))
654}
655
656#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
658struct PrunedEntry {
659 path: String,
660 #[serde(skip_serializing_if = "Option::is_none")]
661 branch: Option<String>,
662 #[serde(skip_serializing_if = "Option::is_none")]
663 commit: Option<String>,
664}
665
666fn pruned_entries(before: &[WorktreeEntry], after: &[WorktreeEntry]) -> Vec<PrunedEntry> {
669 before
670 .iter()
671 .filter(|b| !after.iter().any(|a| a.path == b.path))
672 .map(|b| PrunedEntry {
673 path: b.path.clone(),
674 branch: b.branch.clone(),
675 commit: b.head.clone(),
676 })
677 .collect()
678}
679
680fn parse_repair_paths(out: &str) -> Vec<String> {
684 out.lines()
685 .filter_map(|line| line.strip_prefix("repair: "))
686 .filter_map(|rest| rest.rsplit_once(": ").map(|(_, path)| path.to_string()))
687 .collect()
688}
689
690#[cfg(test)]
691#[allow(clippy::unwrap_used, clippy::expect_used)]
692mod tests {
693 use super::*;
694
695 fn argv(args: &[&str]) -> Vec<String> {
696 args.iter().copied().map(String::from).collect()
697 }
698
699 #[test]
702 fn porcelain_parses_main_linked_and_detached_stanzas() {
703 let out = "\
704worktree /repo
705HEAD 1111111111111111111111111111111111111111
706branch refs/heads/main
707
708worktree /wt/feature
709HEAD 2222222222222222222222222222222222222222
710branch refs/heads/issue-1392
711
712worktree /wt/detached
713HEAD 3333333333333333333333333333333333333333
714detached
715";
716 let entries = parse_worktree_porcelain(out);
717 assert_eq!(entries.len(), 3);
718 assert_eq!(entries[0].path, "/repo");
719 assert_eq!(entries[0].branch.as_deref(), Some("main"));
720 assert_eq!(
721 entries[0].head.as_deref(),
722 Some("1111111111111111111111111111111111111111")
723 );
724 assert_eq!(entries[1].branch.as_deref(), Some("issue-1392"));
725 assert!(entries[2].detached);
726 assert!(entries[2].branch.is_none());
727 }
728
729 #[test]
730 fn porcelain_parses_bare_locked_prunable_and_ignores_unknown() {
731 let out = "\
732worktree /bare
733bare
734
735worktree /locked-no-reason
736HEAD 4444444444444444444444444444444444444444
737locked
738
739worktree /locked-with-reason
740locked worktree is on a portable device
741future-attribute some value
742
743worktree /stale
744prunable gitdir file points to non-existent location";
745 let entries = parse_worktree_porcelain(out);
746 assert_eq!(entries.len(), 4);
747 assert!(entries[0].bare);
748 assert_eq!(entries[1].locked.as_deref(), Some(""));
749 assert_eq!(
750 entries[2].locked.as_deref(),
751 Some("worktree is on a portable device")
752 );
753 assert_eq!(
754 entries[3].prunable.as_deref(),
755 Some("gitdir file points to non-existent location")
756 );
757 }
758
759 #[test]
760 fn porcelain_entry_serializes_compactly() {
761 let entries = parse_worktree_porcelain("worktree /repo\nbranch refs/heads/main\n");
762 let json = serde_json::to_string(&entries).unwrap();
763 assert_eq!(json, r#"[{"path":"/repo","branch":"main"}]"#);
765 }
766
767 #[test]
768 fn porcelain_ignores_attributes_before_the_first_stanza() {
769 let entries = parse_worktree_porcelain("HEAD 9999\nworktree /a\n");
771 assert_eq!(entries.len(), 1);
772 assert_eq!(entries[0].path, "/a");
773 assert!(entries[0].head.is_none());
774 }
775
776 #[test]
779 fn snapshot_degrades_on_non_repo_unborn_and_detached_trees() {
780 let tmp = tempfile::tempdir().unwrap();
781
782 let snap = snapshot(tmp.path());
784 assert!(snap.branch.is_none());
785 assert!(snap.commit.is_none());
786 assert!(snap.dirty.is_none());
787 assert!(!snap.detached);
788
789 let unborn = tmp.path().join("unborn");
791 git2::Repository::init(&unborn).unwrap();
792 let snap = snapshot(&unborn);
793 assert!(snap.branch.is_none());
794 assert!(snap.commit.is_none());
795 assert_eq!(snap.dirty, Some(false));
796
797 let detached = tmp.path().join("detached");
799 let repo = git2::Repository::init(&detached).unwrap();
800 let sig = git2::Signature::now("Test User", "test@example.com").unwrap();
801 let tree_id = repo.index().unwrap().write_tree().unwrap();
802 let tree = repo.find_tree(tree_id).unwrap();
803 let oid = repo
804 .commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
805 .unwrap();
806 repo.set_head_detached(oid).unwrap();
807 let snap = snapshot(&detached);
808 assert!(snap.detached);
809 assert!(snap.branch.is_none());
810 assert_eq!(snap.commit, Some(oid.to_string()));
811 }
812
813 #[test]
814 fn insert_snapshot_records_detached_and_gates_dirty() {
815 let snap = WtSnapshot {
816 branch: None,
817 detached: true,
818 commit: Some("abc".to_string()),
819 dirty: Some(false),
820 };
821 let mut ctx = BTreeMap::new();
822 insert_snapshot(&mut ctx, &snap, false);
823 assert_eq!(ctx.get("detached").map(String::as_str), Some("true"));
824 assert_eq!(ctx.get("commit").map(String::as_str), Some("abc"));
825 assert!(!ctx.contains_key("branch"));
826 assert!(!ctx.contains_key("had_uncommitted"));
827
828 insert_snapshot(&mut ctx, &snap, true);
829 assert_eq!(
830 ctx.get("had_uncommitted").map(String::as_str),
831 Some("false")
832 );
833 }
834
835 #[test]
836 fn absolutize_and_display_canonical_handle_relative_and_missing_paths() {
837 let base = Path::new("/base");
838 assert_eq!(absolutize(base, Path::new("/abs")), PathBuf::from("/abs"));
839 assert_eq!(
840 absolutize(base, Path::new("rel")),
841 PathBuf::from("/base/rel")
842 );
843 assert_eq!(
845 display_canonical(Path::new("/no/such/dir/x")),
846 "/no/such/dir/x"
847 );
848 }
849
850 #[test]
853 fn add_argv_covers_flags_positionals_and_order() {
854 let args = AddArgs {
855 path: PathBuf::from("../wt"),
856 commit_ish: Some("origin/main".to_string()),
857 branch: Some("issue-1".to_string()),
858 force: true,
859 detach: false,
860 };
861 assert_eq!(
862 add_argv(&args),
863 argv(&[
864 "worktree",
865 "add",
866 "--force",
867 "-b",
868 "issue-1",
869 "../wt",
870 "origin/main"
871 ])
872 );
873 let minimal = AddArgs {
874 path: PathBuf::from("wt"),
875 commit_ish: None,
876 branch: None,
877 force: false,
878 detach: true,
879 };
880 assert_eq!(
881 add_argv(&minimal),
882 argv(&["worktree", "add", "--detach", "wt"])
883 );
884 }
885
886 #[test]
887 fn remove_move_prune_repair_argv() {
888 assert_eq!(
889 remove_argv(&RemoveArgs {
890 path: PathBuf::from("/wt"),
891 force: true,
892 }),
893 argv(&["worktree", "remove", "--force", "/wt"])
894 );
895 assert_eq!(
896 move_argv(&MoveArgs {
897 from: PathBuf::from("a"),
898 to: PathBuf::from("b"),
899 force: false,
900 }),
901 argv(&["worktree", "move", "a", "b"])
902 );
903 assert_eq!(
904 move_argv(&MoveArgs {
905 from: PathBuf::from("a"),
906 to: PathBuf::from("b"),
907 force: true,
908 }),
909 argv(&["worktree", "move", "--force", "a", "b"])
910 );
911 assert_eq!(
912 prune_argv(&PruneArgs {
913 dry_run: true,
914 verbose: true,
915 expire: Some("1.day".to_string()),
916 }),
917 argv(&[
918 "worktree",
919 "prune",
920 "--dry-run",
921 "--verbose",
922 "--expire",
923 "1.day"
924 ])
925 );
926 assert_eq!(
927 repair_argv(&RepairArgs {
928 paths: vec![PathBuf::from("a"), PathBuf::from("b")],
929 }),
930 argv(&["worktree", "repair", "a", "b"])
931 );
932 }
933
934 #[test]
935 fn list_argv_adds_quote_path_only_for_porcelain() {
936 assert_eq!(list_argv(false), argv(&["worktree", "list"]));
937 assert_eq!(
938 list_argv(true),
939 argv(&[
940 "-c",
941 "core.quotePath=false",
942 "worktree",
943 "list",
944 "--porcelain"
945 ])
946 );
947 }
948
949 #[test]
952 fn pruned_entries_diffs_by_path_and_keeps_branch_commit() {
953 let before = parse_worktree_porcelain(
954 "worktree /repo\nbranch refs/heads/main\n\n\
955 worktree /stale\nHEAD 5555555555555555555555555555555555555555\n\
956 branch refs/heads/gone\nprunable gitdir gone\n",
957 );
958 let after = parse_worktree_porcelain("worktree /repo\nbranch refs/heads/main\n");
959 let pruned = pruned_entries(&before, &after);
960 assert_eq!(pruned.len(), 1);
961 assert_eq!(pruned[0].path, "/stale");
962 assert_eq!(pruned[0].branch.as_deref(), Some("gone"));
963 assert_eq!(
964 pruned[0].commit.as_deref(),
965 Some("5555555555555555555555555555555555555555")
966 );
967 assert!(pruned_entries(&after, &after).is_empty());
968 }
969
970 #[test]
971 fn repair_paths_parsed_from_report_lines_best_effort() {
972 let out = "\
973repair: gitdir file points to non-existent location: /wt/one/.git
974not a repair line
975repair: .git file broken: /wt/two/.git";
976 assert_eq!(
977 parse_repair_paths(out),
978 argv(&["/wt/one/.git", "/wt/two/.git"])
979 );
980 assert!(parse_repair_paths("nothing to repair").is_empty());
981 }
982
983 #[derive(Parser)]
986 struct Wrapper {
987 #[command(subcommand)]
988 cmd: WorktreeSubcommands,
989 }
990
991 #[test]
992 fn clap_parses_every_verb() {
993 for args in [
994 vec!["omni-dev", "add", "wt", "-b", "branch", "origin/main"],
995 vec!["omni-dev", "remove", "--force", "wt"],
996 vec!["omni-dev", "list", "--porcelain"],
997 vec!["omni-dev", "list", "--output-json"],
998 vec!["omni-dev", "move", "a", "b"],
999 vec!["omni-dev", "prune", "-n", "--expire", "1.day"],
1000 vec!["omni-dev", "repair", "a", "b"],
1001 vec!["omni-dev", "repair"],
1002 ] {
1003 Wrapper::try_parse_from(&args)
1004 .unwrap_or_else(|e| panic!("failed to parse {args:?}: {e}"));
1005 }
1006 }
1007
1008 #[test]
1009 fn clap_rejects_porcelain_with_output_json() {
1010 assert!(
1011 Wrapper::try_parse_from(["omni-dev", "list", "--porcelain", "--output-json"]).is_err()
1012 );
1013 }
1014}