1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use crate::win_process::NoWindow;
5
6use uuid::Uuid;
7
8pub struct WorktreeGuard {
13 pub path: PathBuf,
15 pub branch: String,
17 repo_root: PathBuf,
19 pub base_hash: String,
21}
22
23impl Drop for WorktreeGuard {
24 fn drop(&mut self) {
25 remove_worktree_sync(&self.repo_root, &self.path, &self.branch);
26 }
27}
28
29pub fn create_worktree(repo_path: &Path) -> anyhow::Result<WorktreeGuard> {
32 create_worktree_in(repo_path, None)
33}
34
35fn sanitize_branch_name(raw: &str) -> Option<String> {
41 let trimmed = raw.trim();
42 if trimmed.is_empty() {
43 return None;
44 }
45 let mut out = String::with_capacity(trimmed.len());
46 for ch in trimmed.chars() {
47 if ch.is_whitespace() {
48 out.push('-');
49 } else if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/' | '.') {
50 out.push(ch);
51 }
52 }
54 while out.contains("..") {
56 out = out.replace("..", ".");
57 }
58 let cleaned = out
59 .trim_matches(|c| c == '/' || c == '.' || c == '-')
60 .to_string();
61 if cleaned.is_empty() {
62 None
63 } else {
64 Some(cleaned)
65 }
66}
67
68fn branch_exists(repo_path: &Path, branch: &str) -> bool {
70 Command::new("git")
71 .args(["show-ref", "--verify", "--quiet"])
72 .arg(format!("refs/heads/{branch}"))
73 .current_dir(repo_path)
74 .no_window()
75 .status()
76 .map(|s| s.success())
77 .unwrap_or(false)
78}
79
80pub fn create_worktree_in(
90 repo_path: &Path,
91 branch_name: Option<&str>,
92) -> anyhow::Result<WorktreeGuard> {
93 let run_id = Uuid::new_v4().to_string();
94 let branch = match branch_name.and_then(sanitize_branch_name) {
95 Some(name) if branch_exists(repo_path, &name) => {
96 format!("{name}-{}", &run_id[..8])
97 }
98 Some(name) => name,
99 None => format!("ryu/run-{run_id}"),
100 };
101
102 let base_hash = Command::new("git")
104 .args(["rev-parse", "HEAD"])
105 .current_dir(repo_path)
106 .no_window()
107 .output()
108 .ok()
109 .filter(|o| o.status.success())
110 .and_then(|o| String::from_utf8(o.stdout).ok())
111 .map(|s| s.trim().to_string())
112 .unwrap_or_default();
113
114 let worktree_base = repo_path.join(".ryu-worktrees");
116 std::fs::create_dir_all(&worktree_base)?;
117 let worktree_path = worktree_base.join(format!("ryu-run-{run_id}"));
118
119 let output = Command::new("git")
120 .args(["worktree", "add", "-b", &branch])
121 .arg(&worktree_path)
122 .arg("HEAD")
123 .current_dir(repo_path)
124 .no_window()
125 .output()
126 .map_err(|e| anyhow::anyhow!("git worktree add: {e}"))?;
127
128 if !output.status.success() {
129 let stderr = String::from_utf8_lossy(&output.stderr);
130 return Err(anyhow::anyhow!("git worktree add failed: {stderr}"));
131 }
132
133 tracing::info!(
134 branch = %branch,
135 path = %worktree_path.display(),
136 "worktree created"
137 );
138
139 Ok(WorktreeGuard {
140 path: worktree_path,
141 branch,
142 repo_root: repo_path.to_owned(),
143 base_hash,
144 })
145}
146
147fn remove_worktree_sync(repo_root: &Path, worktree_path: &Path, branch: &str) {
153 let rm = Command::new("git")
154 .args(["worktree", "remove", "--force"])
155 .arg(worktree_path)
156 .current_dir(repo_root)
157 .no_window()
158 .output();
159
160 match rm {
161 Ok(out) if out.status.success() => {
162 tracing::info!(path = %worktree_path.display(), "worktree removed");
163 }
164 Ok(out) => {
165 let stderr = String::from_utf8_lossy(&out.stderr);
166 tracing::warn!("git worktree remove failed: {stderr}");
167 }
168 Err(e) => tracing::warn!("git worktree remove exec error: {e}"),
169 }
170
171 let prune = Command::new("git")
172 .args(["worktree", "prune"])
173 .current_dir(repo_root)
174 .no_window()
175 .output();
176 if let Err(e) = prune {
177 tracing::warn!("git worktree prune exec error: {e}");
178 }
179
180 let del_branch = Command::new("git")
181 .args(["branch", "-D", branch])
182 .current_dir(repo_root)
183 .no_window()
184 .output();
185
186 match del_branch {
187 Ok(out) if out.status.success() => {
188 tracing::info!(branch = %branch, "run branch deleted");
189 }
190 Ok(out) => {
191 let stderr = String::from_utf8_lossy(&out.stderr);
192 tracing::warn!("git branch -D failed: {stderr}");
193 }
194 Err(e) => tracing::warn!("git branch -D exec error: {e}"),
195 }
196}
197
198pub fn is_git_repo(path: &Path) -> bool {
201 Command::new("git")
202 .args(["-C"])
203 .arg(path)
204 .args(["rev-parse", "--is-inside-work-tree"])
205 .no_window()
206 .output()
207 .map(|o| o.status.success())
208 .unwrap_or(false)
209}
210
211pub fn find_git_root(path: &Path) -> Option<PathBuf> {
213 let output = Command::new("git")
214 .args(["-C"])
215 .arg(path)
216 .args(["rev-parse", "--show-toplevel"])
217 .no_window()
218 .output()
219 .ok()?;
220 if output.status.success() {
221 let root = String::from_utf8(output.stdout).ok()?;
222 Some(PathBuf::from(root.trim()))
223 } else {
224 None
225 }
226}
227
228#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
232#[serde(rename_all = "snake_case")]
233pub enum FileChangeKind {
234 Added,
235 Modified,
236 Deleted,
237 Renamed,
238}
239
240#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
242pub struct FileSummary {
243 pub path: String,
245 pub kind: FileChangeKind,
246 pub additions: u32,
247 pub deletions: u32,
248}
249
250#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
253pub struct WorktreeDiff {
254 pub has_changes: bool,
257 pub files: Vec<FileSummary>,
259 pub unified_diff: String,
262}
263
264pub fn worktree_diff(worktree_path: &Path, base_ref: &str) -> WorktreeDiff {
279 let empty = WorktreeDiff {
280 has_changes: false,
281 files: vec![],
282 unified_diff: String::new(),
283 };
284
285 if !worktree_path.is_dir() {
286 return empty;
287 }
288
289 let cwd = worktree_path.to_str().unwrap_or(".");
290
291 let _ = Command::new("git")
295 .args(["add", "-A"])
296 .current_dir(cwd)
297 .no_window()
298 .output();
299
300 let committed_diff =
306 run_git_output(cwd, &["diff", &format!("{base_ref}...HEAD"), "--unified=3"]);
307 let staged_diff = run_git_output(cwd, &["diff", "--cached", "--unified=3"]);
309
310 let mut unified_diff = committed_diff.unwrap_or_default();
311 if let Some(staged) = staged_diff {
312 if !staged.is_empty() {
313 if !unified_diff.is_empty() {
314 unified_diff.push('\n');
315 }
316 unified_diff.push_str(&staged);
317 }
318 }
319
320 let committed_stat = run_git_output(cwd, &["diff", &format!("{base_ref}...HEAD"), "--numstat"]);
322 let staged_stat = run_git_output(cwd, &["diff", "--cached", "--numstat"]);
323
324 let mut file_map: std::collections::HashMap<String, FileSummary> = Default::default();
326
327 for stat_block in [committed_stat, staged_stat].into_iter().flatten() {
328 for line in stat_block.lines() {
329 if let Some(summary) = parse_numstat_line(line) {
330 file_map.insert(summary.path.clone(), summary);
331 }
332 }
333 }
334
335 let committed_range = format!("{base_ref}...HEAD");
339 let name_status_sources: [&[&str]; 2] = [
340 &["diff", &committed_range, "--name-status"],
341 &["diff", "--cached", "--name-status"],
342 ];
343 for ns_args in name_status_sources {
344 if let Some(ns_output) = run_git_output(cwd, ns_args) {
345 for line in ns_output.lines() {
346 let parts: Vec<&str> = line.splitn(2, '\t').collect();
347 if parts.len() < 2 {
348 continue;
349 }
350 let status = parts[0].trim();
351 let path = parts[1].trim().to_string();
352 let kind = match status.chars().next() {
354 Some('A') => FileChangeKind::Added,
355 Some('D') => FileChangeKind::Deleted,
356 Some('R') => FileChangeKind::Renamed,
357 _ => FileChangeKind::Modified,
358 };
359 file_map.entry(path.clone()).or_insert(FileSummary {
360 path,
361 kind,
362 additions: 0,
363 deletions: 0,
364 });
365 }
366 }
367 }
368
369 let mut files: Vec<FileSummary> = file_map.into_values().collect();
370 files.sort_by(|a, b| a.path.cmp(&b.path));
371
372 let has_changes = !files.is_empty() || !unified_diff.is_empty();
373 WorktreeDiff {
374 has_changes,
375 files,
376 unified_diff,
377 }
378}
379
380fn run_git_output(cwd: &str, args: &[&str]) -> Option<String> {
381 let out = Command::new("git")
382 .args(args)
383 .current_dir(cwd)
384 .no_window()
385 .output()
386 .ok()?;
387 if out.status.success() {
388 Some(String::from_utf8_lossy(&out.stdout).into_owned())
389 } else {
390 None
391 }
392}
393
394fn parse_numstat_line(line: &str) -> Option<FileSummary> {
397 let parts: Vec<&str> = line.splitn(3, '\t').collect();
398 if parts.len() < 3 {
399 return None;
400 }
401 let additions: u32 = parts[0].trim().parse().unwrap_or(0);
402 let deletions: u32 = parts[1].trim().parse().unwrap_or(0);
403 let path = parts[2].trim().replace('\\', "/");
404 if path.is_empty() {
405 return None;
406 }
407 let kind = if additions > 0 && deletions == 0 {
408 FileChangeKind::Added
409 } else if deletions > 0 && additions == 0 {
410 FileChangeKind::Deleted
411 } else {
412 FileChangeKind::Modified
413 };
414 Some(FileSummary {
415 path,
416 kind,
417 additions,
418 deletions,
419 })
420}
421
422#[derive(Debug, Clone, serde::Deserialize)]
426#[serde(rename_all = "snake_case")]
427pub enum ApplyMode {
428 Merge,
431 Pr,
433}
434
435#[derive(Debug, serde::Serialize)]
437pub struct ApplySuccess {
438 pub commit: Option<String>,
440 pub pr_url: Option<String>,
442}
443
444#[derive(Debug, serde::Serialize)]
446pub struct ConflictError {
447 pub conflicted_files: Vec<String>,
448}
449
450pub fn apply_worktree(
457 guard: &WorktreeGuard,
458 mode: ApplyMode,
459 message: &str,
460 base: Option<&str>,
461) -> Result<ApplySuccess, ConflictError> {
462 let wt = guard.path.as_path();
463 let repo = guard.repo_root.as_path();
464
465 let _ = Command::new("git")
467 .args(["add", "-A"])
468 .current_dir(wt)
469 .no_window()
470 .output();
471
472 let status = Command::new("git")
474 .args(["status", "--porcelain"])
475 .current_dir(wt)
476 .no_window()
477 .output()
478 .ok()
479 .and_then(|o| String::from_utf8(o.stdout).ok())
480 .unwrap_or_default();
481
482 if !status.trim().is_empty() {
483 let commit_out = Command::new("git")
484 .args(["commit", "-m", message])
485 .current_dir(wt)
486 .no_window()
487 .output();
488
489 if let Ok(out) = commit_out {
490 if !out.status.success() {
491 let err = String::from_utf8_lossy(&out.stderr);
492 tracing::warn!("apply: git commit failed in worktree: {err}");
493 }
494 }
495 }
496
497 let effective_base = base.map(str::to_string).unwrap_or_else(|| {
499 Command::new("git")
500 .args(["rev-parse", "--abbrev-ref", "HEAD"])
501 .current_dir(repo)
502 .no_window()
503 .output()
504 .ok()
505 .filter(|o| o.status.success())
506 .and_then(|o| String::from_utf8(o.stdout).ok())
507 .map(|s| s.trim().to_string())
508 .unwrap_or_else(|| "main".to_string())
509 });
510
511 match mode {
512 ApplyMode::Merge => {
513 let merge_out = Command::new("git")
514 .args(["merge", "--no-ff", &guard.branch, "-m", message])
515 .current_dir(repo)
516 .no_window()
517 .output();
518
519 match merge_out {
520 Ok(out) if out.status.success() => {
521 let commit_sha = Command::new("git")
522 .args(["rev-parse", "HEAD"])
523 .current_dir(repo)
524 .no_window()
525 .output()
526 .ok()
527 .filter(|o| o.status.success())
528 .and_then(|o| String::from_utf8(o.stdout).ok())
529 .map(|s| s.trim().to_string());
530
531 Ok(ApplySuccess {
532 commit: commit_sha,
533 pr_url: None,
534 })
535 }
536 Ok(out) => {
537 let stderr = String::from_utf8_lossy(&out.stderr);
538 tracing::warn!("apply: merge conflict: {stderr}");
539
540 let conflicted = Command::new("git")
542 .args(["diff", "--name-only", "--diff-filter=U"])
543 .current_dir(repo)
544 .no_window()
545 .output()
546 .ok()
547 .filter(|o| o.status.success())
548 .and_then(|o| String::from_utf8(o.stdout).ok())
549 .map(|s| {
550 s.lines()
551 .filter(|l| !l.is_empty())
552 .map(str::to_string)
553 .collect::<Vec<_>>()
554 })
555 .unwrap_or_default();
556
557 let _ = Command::new("git")
559 .args(["merge", "--abort"])
560 .current_dir(repo)
561 .no_window()
562 .output();
563
564 Err(ConflictError {
565 conflicted_files: conflicted,
566 })
567 }
568 Err(e) => {
569 tracing::error!("apply: merge exec error: {e}");
570 Err(ConflictError {
571 conflicted_files: vec![],
572 })
573 }
574 }
575 }
576
577 ApplyMode::Pr => {
578 let push_out = Command::new("git")
580 .args(["push", "-u", "origin", &guard.branch])
581 .current_dir(wt)
582 .no_window()
583 .output();
584
585 if let Ok(ref out) = push_out {
586 if !out.status.success() {
587 let err = String::from_utf8_lossy(&out.stderr);
588 tracing::warn!("apply: git push failed: {err}");
589 }
590 }
591
592 let gh_out = Command::new("gh")
595 .args([
596 "pr",
597 "create",
598 "--head",
599 &guard.branch,
600 "--base",
601 &effective_base,
602 "--title",
603 message,
604 "--body",
605 "",
606 ])
607 .current_dir(repo)
608 .no_window()
609 .output();
610
611 match gh_out {
612 Ok(out) if out.status.success() => {
613 let pr_url = String::from_utf8_lossy(&out.stdout).trim().to_string();
614 Ok(ApplySuccess {
615 commit: None,
616 pr_url: Some(pr_url),
617 })
618 }
619 Ok(out) => {
620 let err = String::from_utf8_lossy(&out.stderr);
621 tracing::warn!("apply: gh pr create failed: {err}");
622 Err(ConflictError {
624 conflicted_files: vec![],
625 })
626 }
627 Err(e) => {
628 tracing::error!("apply: gh exec error: {e}");
629 Err(ConflictError {
630 conflicted_files: vec![],
631 })
632 }
633 }
634 }
635 }
636}
637
638#[cfg(test)]
641mod tests {
642 use super::*;
643 use std::process::Command;
644 use tempfile::TempDir;
645
646 fn init_git_repo(dir: &Path) {
647 Command::new("git")
648 .args(["init"])
649 .current_dir(dir)
650 .output()
651 .expect("git init");
652 Command::new("git")
653 .args(["config", "user.email", "test@ryu"])
654 .current_dir(dir)
655 .output()
656 .expect("git config email");
657 Command::new("git")
658 .args(["config", "user.name", "Test"])
659 .current_dir(dir)
660 .output()
661 .expect("git config name");
662 let readme = dir.join("README");
664 std::fs::write(&readme, "init").expect("write README");
665 Command::new("git")
666 .args(["add", "."])
667 .current_dir(dir)
668 .output()
669 .expect("git add");
670 Command::new("git")
671 .args(["commit", "-m", "init"])
672 .current_dir(dir)
673 .output()
674 .expect("git commit");
675 }
676
677 #[test]
678 fn diff_captures_committed_changes_in_worktree() {
679 let tmp = TempDir::new().expect("tempdir");
680 let repo = tmp.path();
681 init_git_repo(repo);
682
683 let guard = create_worktree(repo).expect("create_worktree");
684 let wt_path = guard.path.clone();
685 let base_hash = guard.base_hash.clone();
686 assert!(
687 !base_hash.is_empty(),
688 "base_hash should be captured at worktree creation"
689 );
690
691 let diff_clean = worktree_diff(&wt_path, &base_hash);
693 assert!(
694 !diff_clean.has_changes,
695 "fresh worktree should report no changes"
696 );
697
698 std::fs::write(wt_path.join("alpha.txt"), "hello alpha").expect("write alpha");
700 std::fs::write(wt_path.join("beta.txt"), "hello beta").expect("write beta");
701 Command::new("git")
702 .args(["add", "."])
703 .current_dir(&wt_path)
704 .output()
705 .expect("git add");
706 Command::new("git")
707 .args(["commit", "-m", "add two files"])
708 .current_dir(&wt_path)
709 .output()
710 .expect("git commit");
711
712 let diff = worktree_diff(&wt_path, &base_hash);
714 assert!(diff.has_changes, "should see changes after commit");
715 assert_eq!(diff.files.len(), 2, "should report 2 changed files");
716 assert!(
717 diff.unified_diff.contains("alpha.txt") || diff.unified_diff.contains("beta.txt"),
718 "unified diff should mention at least one of the added files"
719 );
720
721 drop(guard);
722
723 assert!(!wt_path.exists(), "worktree dir should be gone after drop");
725 }
726
727 #[test]
728 fn diff_captures_untracked_files_in_worktree() {
729 let tmp = TempDir::new().expect("tempdir");
730 let repo = tmp.path();
731 init_git_repo(repo);
732
733 let guard = create_worktree(repo).expect("create_worktree");
734 let wt_path = guard.path.clone();
735 let base_hash = guard.base_hash.clone();
736
737 std::fs::write(wt_path.join("gamma.txt"), "hello gamma").expect("write gamma");
741 std::fs::write(wt_path.join("delta.txt"), "hello delta").expect("write delta");
742
743 let diff = worktree_diff(&wt_path, &base_hash);
744 assert!(diff.has_changes, "untracked files should be detected");
745 assert_eq!(
746 diff.files.len(),
747 2,
748 "should report 2 changed files from untracked"
749 );
750 assert!(
751 diff.unified_diff.contains("gamma.txt") || diff.unified_diff.contains("delta.txt"),
752 "unified diff should mention at least one of the new untracked files"
753 );
754
755 drop(guard);
756 }
757
758 #[test]
761 fn two_concurrent_worktrees_are_independent() {
762 let tmp = TempDir::new().expect("tempdir");
763 let repo = tmp.path();
764 init_git_repo(repo);
765
766 let guard_a = create_worktree(repo).expect("create worktree A");
767 let guard_b = create_worktree(repo).expect("create worktree B");
768
769 let path_a = guard_a.path.clone();
770 let path_b = guard_b.path.clone();
771 let branch_a = guard_a.branch.clone();
772 let branch_b = guard_b.branch.clone();
773
774 assert!(path_a.exists(), "worktree A should exist");
775 assert!(path_b.exists(), "worktree B should exist");
776 assert_ne!(path_a, path_b, "worktrees should be at distinct paths");
777 assert_ne!(branch_a, branch_b, "each run gets its own branch");
778
779 let list = Command::new("git")
781 .args(["worktree", "list"])
782 .current_dir(repo)
783 .output()
784 .expect("git worktree list");
785 let list_str = String::from_utf8_lossy(&list.stdout);
786 let norm_a = path_a.to_string_lossy().replace('\\', "/");
787 let norm_b = path_b.to_string_lossy().replace('\\', "/");
788 assert!(
789 list_str.contains(&*norm_a),
790 "worktree A should appear in list; got:\n{list_str}"
791 );
792 assert!(
793 list_str.contains(&*norm_b),
794 "worktree B should appear in list; got:\n{list_str}"
795 );
796
797 drop(guard_a);
799 assert!(!path_a.exists(), "worktree A should be gone after drop");
800 assert!(
801 path_b.exists(),
802 "worktree B should still exist after A is dropped"
803 );
804
805 let branches = Command::new("git")
807 .args(["branch", "--list"])
808 .current_dir(repo)
809 .output()
810 .expect("git branch list");
811 let branches_str = String::from_utf8_lossy(&branches.stdout);
812 assert!(
813 !branches_str.contains(&*branch_a),
814 "branch A should be deleted; got:\n{branches_str}"
815 );
816 assert!(
817 branches_str.contains(&*branch_b),
818 "branch B should still exist; got:\n{branches_str}"
819 );
820
821 drop(guard_b);
822 }
823
824 #[test]
825 fn apply_merge_lands_commit_on_base() {
826 let tmp = TempDir::new().expect("tempdir");
827 let repo = tmp.path();
828 init_git_repo(repo);
829
830 let guard = create_worktree(repo).expect("create_worktree");
831
832 std::fs::write(guard.path.join("feature.txt"), "hello").expect("write");
834 Command::new("git")
835 .args(["add", "feature.txt"])
836 .current_dir(&guard.path)
837 .output()
838 .expect("git add");
839
840 let result = apply_worktree(&guard, ApplyMode::Merge, "feat: add feature", None);
841 assert!(
842 result.is_ok(),
843 "merge should succeed on a clean repo: {result:?}"
844 );
845 let ok = result.unwrap();
846 assert!(ok.commit.is_some(), "should return commit SHA");
847
848 assert!(
850 repo.join("feature.txt").exists(),
851 "feature.txt should be in base repo"
852 );
853
854 drop(guard);
856 }
857
858 #[test]
859 fn apply_merge_conflict_returns_409_data_and_leaves_base_clean() {
860 let tmp = TempDir::new().expect("tempdir");
861 let repo = tmp.path();
862 init_git_repo(repo);
863
864 std::fs::write(repo.join("conflict.txt"), "base content").expect("write base");
866 Command::new("git")
867 .args(["add", "."])
868 .current_dir(repo)
869 .output()
870 .expect("add");
871 Command::new("git")
872 .args(["commit", "-m", "base commit"])
873 .current_dir(repo)
874 .output()
875 .expect("commit");
876
877 let guard = create_worktree(repo).expect("create_worktree");
879 std::fs::write(guard.path.join("conflict.txt"), "worktree content").expect("write wt");
880 Command::new("git")
881 .args(["add", "."])
882 .current_dir(&guard.path)
883 .output()
884 .expect("add");
885 Command::new("git")
886 .args(["commit", "-m", "wt commit"])
887 .current_dir(&guard.path)
888 .output()
889 .expect("commit");
890
891 std::fs::write(repo.join("conflict.txt"), "base diverged content").expect("write base2");
893 Command::new("git")
894 .args(["add", "."])
895 .current_dir(repo)
896 .output()
897 .expect("add");
898 Command::new("git")
899 .args(["commit", "-m", "base diverged"])
900 .current_dir(repo)
901 .output()
902 .expect("commit");
903
904 let result = apply_worktree(&guard, ApplyMode::Merge, "conflict merge", None);
905
906 if let Err(conflict) = result {
908 assert!(
909 !conflict.conflicted_files.is_empty(),
910 "conflict error should include conflicted files"
911 );
912 let status = Command::new("git")
914 .args(["status", "--porcelain"])
915 .current_dir(repo)
916 .output()
917 .expect("git status");
918 let status_str = String::from_utf8_lossy(&status.stdout);
919 assert!(
920 !status_str.contains("UU"),
921 "base repo should not have unmerged files after abort"
922 );
923 }
924 drop(guard);
927 }
928
929 #[test]
930 fn create_then_drop_removes_worktree_and_branch() {
931 let tmp = TempDir::new().expect("tempdir");
932 let repo = tmp.path();
933 init_git_repo(repo);
934
935 let guard = create_worktree(repo).expect("create_worktree");
936 let worktree_path = guard.path.clone();
937 let branch = guard.branch.clone();
938
939 assert!(
940 worktree_path.exists(),
941 "worktree dir should exist after create"
942 );
943
944 let list = Command::new("git")
946 .args(["worktree", "list"])
947 .current_dir(repo)
948 .output()
949 .expect("git worktree list");
950 let list_str = String::from_utf8_lossy(&list.stdout);
951 let normalized_path = worktree_path.to_string_lossy().replace('\\', "/");
953 assert!(
954 list_str.contains(&*normalized_path),
955 "worktree should appear in git worktree list; got:\n{list_str}"
956 );
957
958 drop(guard);
960
961 assert!(
962 !worktree_path.exists(),
963 "worktree dir should be gone after drop"
964 );
965
966 let branches = Command::new("git")
968 .args(["branch", "--list", &branch])
969 .current_dir(repo)
970 .output()
971 .expect("git branch list");
972 let branches_str = String::from_utf8_lossy(&branches.stdout);
973 assert!(
974 branches_str.trim().is_empty(),
975 "run branch should be deleted after drop"
976 );
977 }
978}