1use std::collections::BTreeSet;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use crate::config::WorktreePlacement;
11use crate::error::PawError;
12use crate::specs::SpecEntry;
13
14pub fn validate_repo(path: &Path) -> Result<PathBuf, PawError> {
18 let output = Command::new("git")
19 .current_dir(path)
20 .args(["rev-parse", "--show-toplevel"])
21 .output()
22 .map_err(|e| PawError::BranchError(format!("failed to run git: {e}")))?;
23
24 if !output.status.success() {
25 return Err(PawError::NotAGitRepo);
26 }
27
28 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
29 Ok(PathBuf::from(root))
30}
31
32pub fn list_branches(repo_root: &Path) -> Result<Vec<String>, PawError> {
39 let output = Command::new("git")
40 .current_dir(repo_root)
41 .args(["branch", "-a", "--format=%(refname:short)"])
42 .output()
43 .map_err(|e| PawError::BranchError(format!("failed to run git branch: {e}")))?;
44
45 if !output.status.success() {
46 let stderr = String::from_utf8_lossy(&output.stderr);
47 return Err(PawError::BranchError(format!(
48 "git branch failed: {stderr}"
49 )));
50 }
51
52 let stdout = String::from_utf8_lossy(&output.stdout);
53 let branches: BTreeSet<String> = stdout
54 .lines()
55 .filter(|line| !line.trim().is_empty() && !line.contains("HEAD"))
56 .map(|line| {
57 let mut branch_name = line.trim().to_string();
59
60 if let Some(stripped) = branch_name.strip_prefix("refs/remotes/") {
62 branch_name = stripped.to_string();
63 }
64 if let Some(stripped) = branch_name.strip_prefix("origin/") {
66 branch_name = stripped.to_string();
67 }
68
69 branch_name
70 })
71 .collect();
72
73 let mut unique: Vec<String> = branches.into_iter().collect();
75 unique.sort();
76 Ok(unique)
77}
78
79pub fn worktree_dir_name(project: &str, branch: &str) -> String {
83 let project_safe: String = project
84 .chars()
85 .map(|c| if c.is_alphanumeric() { c } else { '-' })
86 .collect();
87 let branch_safe: String = branch
88 .chars()
89 .map(|c| if c.is_alphanumeric() { c } else { '-' })
90 .collect();
91 format!("{project_safe}-{branch_safe}")
92}
93
94#[must_use]
103pub fn branch_slug(branch: &str) -> String {
104 branch
105 .chars()
106 .filter_map(|c| match c {
107 '/' => Some('-'),
108 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '_' | '-' => Some(c),
109 _ => None,
110 })
111 .collect()
112}
113
114pub fn default_branch(repo_root: &Path) -> Result<String, PawError> {
116 let output = Command::new("git")
117 .current_dir(repo_root)
118 .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
119 .output()
120 .map_err(|e| PawError::BranchError(format!("failed to run git symbolic-ref: {e}")))?;
121
122 if !output.status.success() {
123 let stderr = String::from_utf8_lossy(&output.stderr);
124 return Err(PawError::BranchError(format!(
125 "git symbolic-ref failed: {stderr}"
126 )));
127 }
128
129 let ref_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
130 if let Some(branch) = ref_name.strip_prefix("refs/remotes/origin/") {
131 Ok(branch.to_string())
132 } else {
133 Err(PawError::BranchError(format!(
134 "unexpected ref format: {ref_name}"
135 )))
136 }
137}
138
139pub fn current_branch(repo_root: &Path) -> Result<String, PawError> {
141 let output = Command::new("git")
142 .current_dir(repo_root)
143 .args(["branch", "--show-current"])
144 .output()
145 .map_err(|e| PawError::BranchError(format!("failed to run git branch: {e}")))?;
146
147 if !output.status.success() {
148 let stderr = String::from_utf8_lossy(&output.stderr);
149 return Err(PawError::BranchError(format!(
150 "git branch failed: {stderr}"
151 )));
152 }
153
154 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
155 if branch.is_empty() {
156 return Err(PawError::BranchError(
157 "not on any branch (detached HEAD)".to_string(),
158 ));
159 }
160 Ok(branch)
161}
162
163pub fn project_name(repo_root: &Path) -> String {
165 repo_root
166 .file_name()
167 .and_then(std::ffi::OsStr::to_str)
168 .unwrap_or("unknown")
169 .to_string()
170}
171
172#[derive(Debug)]
174pub struct WorktreeCreation {
175 pub path: PathBuf,
177 pub branch_created: bool,
179}
180
181fn find_worktree_for_branch(repo_root: &Path, branch: &str) -> Result<Option<PathBuf>, PawError> {
184 let list = Command::new("git")
185 .current_dir(repo_root)
186 .args(["worktree", "list", "--porcelain"])
187 .output()
188 .map_err(|e| PawError::WorktreeError(format!("failed to run git worktree list: {e}")))?;
189 if !list.status.success() {
190 return Ok(None);
191 }
192 let listing = String::from_utf8_lossy(&list.stdout);
193 let expected_branch_ref = format!("refs/heads/{branch}");
194 let mut current_path: Option<PathBuf> = None;
195 for line in listing.lines() {
196 if let Some(rest) = line.strip_prefix("worktree ") {
197 current_path = Some(PathBuf::from(rest));
198 } else if let Some(rest) = line.strip_prefix("branch ")
199 && rest == expected_branch_ref
200 && let Some(p) = current_path.take()
201 {
202 return Ok(Some(p));
203 }
204 }
205 Ok(None)
206}
207
208fn rebase_branch_onto_default(repo_root: &Path, branch: &str) -> Result<(), PawError> {
219 let default = default_branch(repo_root)?;
220
221 let occupied_at = find_worktree_for_branch(repo_root, branch)?;
222 let (workdir, original_head): (PathBuf, Option<String>) = if let Some(wt) = occupied_at {
223 (wt, None)
224 } else {
225 let original = Command::new("git")
226 .current_dir(repo_root)
227 .args(["symbolic-ref", "--short", "HEAD"])
228 .output()
229 .ok()
230 .filter(|o| o.status.success())
231 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
232 (repo_root.to_path_buf(), original)
233 };
234
235 let mut invocation = Command::new("git");
236 invocation.current_dir(&workdir);
237 if original_head.is_some() {
238 invocation.args(["rebase", &default, branch]);
239 } else {
240 invocation.args(["rebase", &default]);
241 }
242 let output = invocation
243 .output()
244 .map_err(|e| PawError::WorktreeError(format!("failed to run git rebase: {e}")))?;
245
246 if !output.status.success() {
247 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
248 let _ = Command::new("git")
249 .current_dir(&workdir)
250 .args(["rebase", "--abort"])
251 .output();
252 if let Some(orig) = &original_head
253 && orig != branch
254 {
255 let _ = Command::new("git")
256 .current_dir(repo_root)
257 .args(["checkout", orig])
258 .output();
259 }
260 return Err(PawError::WorktreeError(format!(
261 "rebase onto main failed: {stderr}"
262 )));
263 }
264
265 if let Some(orig) = original_head
266 && orig != branch
267 {
268 let _ = Command::new("git")
269 .current_dir(repo_root)
270 .args(["checkout", &orig])
271 .output();
272 }
273
274 Ok(())
275}
276
277fn resolve_worktree_path(
287 repo_root: &Path,
288 branch: &str,
289 placement: WorktreePlacement,
290) -> Result<PathBuf, PawError> {
291 match placement {
292 WorktreePlacement::Child => {
293 let worktrees_dir = repo_root.join(".git-paw").join("worktrees");
294 std::fs::create_dir_all(&worktrees_dir).map_err(|e| {
295 PawError::WorktreeError(format!(
296 "failed to create '{}': {e}",
297 worktrees_dir.display()
298 ))
299 })?;
300 Ok(worktrees_dir.join(branch_slug(branch)))
301 }
302 WorktreePlacement::Sibling => {
303 let project = project_name(repo_root);
304 let dir_name = worktree_dir_name(&project, branch);
305 let parent = repo_root.parent().ok_or_else(|| {
306 PawError::WorktreeError("cannot determine parent directory of repo".to_string())
307 })?;
308 Ok(parent.join(&dir_name))
309 }
310 }
311}
312
313pub fn create_worktree(
337 repo_root: &Path,
338 branch: &str,
339 rebase_onto_main: bool,
340 placement: WorktreePlacement,
341) -> Result<WorktreeCreation, PawError> {
342 let worktree_path = resolve_worktree_path(repo_root, branch, placement)?;
343
344 if rebase_onto_main {
352 let branch_exists = Command::new("git")
353 .current_dir(repo_root)
354 .args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
355 .output()
356 .is_ok_and(|o| o.status.success());
357 if branch_exists {
358 rebase_branch_onto_default(repo_root, branch)?;
359 }
360 }
361
362 if worktree_path.exists() {
368 let expected_canonical = std::fs::canonicalize(&worktree_path).ok();
372 let list = Command::new("git")
373 .current_dir(repo_root)
374 .args(["worktree", "list", "--porcelain"])
375 .output()
376 .map_err(|e| {
377 PawError::WorktreeError(format!("failed to run git worktree list: {e}"))
378 })?;
379 if list.status.success() {
380 let listing = String::from_utf8_lossy(&list.stdout);
381 let expected_branch_ref = format!("refs/heads/{branch}");
382 let mut current_path: Option<PathBuf> = None;
385 for line in listing.lines() {
386 if let Some(rest) = line.strip_prefix("worktree ") {
387 current_path = std::fs::canonicalize(PathBuf::from(rest)).ok();
388 } else if let Some(rest) = line.strip_prefix("branch ") {
389 let path_matches = match (¤t_path, &expected_canonical) {
390 (Some(p), Some(e)) => p == e,
391 _ => false,
392 };
393 if path_matches && rest == expected_branch_ref {
394 return Ok(WorktreeCreation {
395 path: worktree_path,
396 branch_created: false,
397 });
398 }
399 }
400 }
401 }
402 }
406
407 let output = Command::new("git")
409 .current_dir(repo_root)
410 .args(["worktree", "add", &worktree_path.to_string_lossy(), branch])
411 .output()
412 .map_err(|e| PawError::WorktreeError(format!("failed to run git worktree add: {e}")))?;
413
414 if output.status.success() {
415 return Ok(WorktreeCreation {
416 path: worktree_path,
417 branch_created: false,
418 });
419 }
420
421 let stderr = String::from_utf8_lossy(&output.stderr);
422
423 if stderr.contains("invalid reference") {
425 let output = Command::new("git")
426 .current_dir(repo_root)
427 .args([
428 "worktree",
429 "add",
430 "-b",
431 branch,
432 &worktree_path.to_string_lossy(),
433 ])
434 .output()
435 .map_err(|e| {
436 PawError::WorktreeError(format!("failed to run git worktree add -b: {e}"))
437 })?;
438
439 if output.status.success() {
440 return Ok(WorktreeCreation {
441 path: worktree_path,
442 branch_created: true,
443 });
444 }
445
446 let stderr = String::from_utf8_lossy(&output.stderr);
447 return Err(PawError::WorktreeError(format!(
448 "git worktree add -b failed for branch '{branch}': {stderr}"
449 )));
450 }
451
452 Err(PawError::WorktreeError(format!(
453 "git worktree add failed for branch '{branch}': {stderr}"
454 )))
455}
456
457pub fn remove_worktree(repo_root: &Path, worktree_path: &Path) -> Result<(), PawError> {
461 let output = Command::new("git")
468 .current_dir(repo_root)
469 .args(["worktree", "remove", "--force"])
470 .arg(worktree_path.as_os_str())
471 .output()
472 .map_err(|e| {
473 PawError::WorktreeError(format!(
474 "failed to remove worktree at {}: {e}",
475 worktree_path.display()
476 ))
477 })?;
478
479 if !output.status.success() {
480 let stderr = String::from_utf8_lossy(&output.stderr);
481 return Err(PawError::WorktreeError(format!(
482 "git worktree remove failed for worktree at {}: {stderr}",
483 worktree_path.display()
484 )));
485 }
486
487 Ok(())
488}
489
490pub fn prune_worktrees(repo_root: &Path) -> Result<(), PawError> {
494 let output = Command::new("git")
495 .current_dir(repo_root)
496 .args(["worktree", "prune"])
497 .output()
498 .map_err(|e| PawError::WorktreeError(format!("failed to prune worktrees: {e}")))?;
499
500 if !output.status.success() {
501 let stderr = String::from_utf8_lossy(&output.stderr);
502 return Err(PawError::WorktreeError(format!(
503 "git worktree prune failed: {stderr}"
504 )));
505 }
506
507 Ok(())
508}
509
510pub fn check_uncommitted_specs(
521 repo_root: &Path,
522 specs: &[SpecEntry],
523) -> Result<Vec<String>, PawError> {
524 let mut uncommitted_specs = Vec::new();
525
526 let specs_dir = repo_root.join("specs");
527
528 for spec in specs {
529 let dir_path = specs_dir.join(&spec.id);
530 let file_path = specs_dir.join(format!("{}.md", spec.id));
531
532 let porcelain_target = if dir_path.is_dir() {
533 format!("specs/{}", spec.id)
534 } else if file_path.is_file() {
535 format!("specs/{}.md", spec.id)
536 } else {
537 continue;
538 };
539
540 let output = Command::new("git")
541 .current_dir(repo_root)
542 .args(["status", "--porcelain", "--", &porcelain_target])
543 .output()
544 .map_err(|e| {
545 PawError::BranchError(format!(
546 "failed to run git status for spec {}: {e}",
547 spec.id
548 ))
549 })?;
550
551 if !output.status.success() {
552 let stderr = String::from_utf8_lossy(&output.stderr);
553 return Err(PawError::BranchError(format!(
554 "git status failed for spec {}: {stderr}",
555 spec.id
556 )));
557 }
558
559 let status_output = String::from_utf8_lossy(&output.stdout).trim().to_string();
560 if !status_output.is_empty() {
561 uncommitted_specs.push(spec.id.clone());
562 }
563 }
564
565 Ok(uncommitted_specs)
566}
567
568pub fn uncommitted_files(worktree_root: &Path) -> Result<Vec<String>, PawError> {
576 let output = Command::new("git")
577 .current_dir(worktree_root)
578 .args(["status", "--porcelain"])
579 .output()
580 .map_err(|e| {
581 PawError::WorktreeError(format!(
582 "failed to run git status in {}: {e}",
583 worktree_root.display()
584 ))
585 })?;
586
587 if !output.status.success() {
588 let stderr = String::from_utf8_lossy(&output.stderr);
589 return Err(PawError::WorktreeError(format!(
590 "git status failed in {}: {stderr}",
591 worktree_root.display()
592 )));
593 }
594
595 let stdout = String::from_utf8_lossy(&output.stdout);
596 let mut files = Vec::new();
597 for line in stdout.lines() {
598 if line.len() <= 3 {
599 continue;
600 }
601 let path = &line[3..];
605 let reported = path.rsplit(" -> ").next().unwrap_or(path);
606 files.push(reported.trim().to_string());
607 }
608 Ok(files)
609}
610
611pub fn merge_branch(repo_root: &Path, branch: &str) -> Result<bool, PawError> {
615 let output = Command::new("git")
616 .current_dir(repo_root)
617 .args(["merge", "--no-ff", "--no-commit", branch])
618 .output()
619 .map_err(|e| {
620 PawError::WorktreeError(format!("failed to run git merge for branch {branch}: {e}"))
621 })?;
622
623 if !output.status.success() {
624 let stderr = String::from_utf8_lossy(&output.stderr);
625 if output.status.code() == Some(1) {
627 return Ok(false);
628 }
629 return Err(PawError::WorktreeError(format!(
630 "git merge failed for branch {branch}: {stderr}"
631 )));
632 }
633
634 Ok(true)
635}
636
637pub fn delete_branch(repo_root: &Path, branch: &str) -> Result<(), PawError> {
639 let output = Command::new("git")
640 .current_dir(repo_root)
641 .args(["branch", "-D", branch])
642 .output()
643 .map_err(|e| PawError::BranchError(format!("failed to delete branch {branch}: {e}")))?;
644
645 if !output.status.success() {
646 let stderr = String::from_utf8_lossy(&output.stderr);
647 return Err(PawError::BranchError(format!(
648 "git branch -D failed for branch {branch}: {stderr}"
649 )));
650 }
651
652 Ok(())
653}
654
655pub fn exclude_from_git(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
669 let dot_git = worktree_root.join(".git");
673 let exclude_file = if dot_git.is_file() {
674 let gitdir = std::fs::read_to_string(&dot_git)
675 .ok()
676 .and_then(|s| s.strip_prefix("gitdir: ").map(|s| s.trim().to_owned()))
677 .unwrap_or_default();
678 let worktree_git_dir = PathBuf::from(&gitdir);
681 let common_dir = worktree_git_dir
682 .parent()
683 .and_then(Path::parent)
684 .map_or_else(|| worktree_git_dir.clone(), Path::to_path_buf);
685 common_dir.join("info").join("exclude")
686 } else {
687 dot_git.join("info").join("exclude")
688 };
689
690 let existing = if exclude_file.exists() {
692 std::fs::read_to_string(&exclude_file).unwrap_or_default()
693 } else {
694 String::new()
695 };
696
697 if !existing.lines().any(|line| line.trim() == filename) {
699 let mut updated = existing;
700 if !updated.ends_with('\n') && !updated.is_empty() {
701 updated.push('\n');
702 }
703 updated.push_str(filename);
704 updated.push('\n');
705
706 if let Some(parent) = exclude_file.parent() {
708 std::fs::create_dir_all(parent).map_err(|e| {
709 PawError::SessionError(format!("failed to create .git/info directory: {e}"))
710 })?;
711 }
712
713 std::fs::write(&exclude_file, updated).map_err(|e| {
714 PawError::SessionError(format!("failed to write to .git/info/exclude: {e}"))
715 })?;
716 }
717
718 Ok(())
719}
720
721pub fn assume_unchanged(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
727 let _ = std::process::Command::new("git")
733 .current_dir(worktree_root)
734 .args(["update-index", "--assume-unchanged", filename])
735 .output();
736 Ok(())
737}
738
739pub fn no_assume_unchanged(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
748 let _ = std::process::Command::new("git")
751 .current_dir(worktree_root)
752 .args(["update-index", "--no-assume-unchanged", filename])
753 .output();
754 Ok(())
755}
756
757#[cfg(test)]
758mod tests {
759 use std::path::{Path, PathBuf};
760 use std::process::Command;
761
762 use tempfile::TempDir;
763
764 use crate::config::WorktreePlacement;
765 use crate::error::PawError;
766 use crate::git::{WorktreeCreation, branch_slug, create_worktree};
767
768 struct RebaseRepo {
773 _sandbox: TempDir,
774 repo: PathBuf,
775 }
776
777 impl RebaseRepo {
778 fn path(&self) -> &Path {
779 &self.repo
780 }
781 }
782
783 fn run_git(dir: &Path, args: &[&str]) {
784 let output = Command::new("git")
785 .current_dir(dir)
786 .args(args)
787 .output()
788 .expect("run git command");
789 assert!(
790 output.status.success(),
791 "git {} failed: {}",
792 args.join(" "),
793 String::from_utf8_lossy(&output.stderr)
794 );
795 }
796
797 fn capture_git(dir: &Path, args: &[&str]) -> String {
798 let output = Command::new("git")
799 .current_dir(dir)
800 .args(args)
801 .output()
802 .expect("run git command");
803 assert!(
804 output.status.success(),
805 "git {} failed: {}",
806 args.join(" "),
807 String::from_utf8_lossy(&output.stderr)
808 );
809 String::from_utf8_lossy(&output.stdout).trim().to_string()
810 }
811
812 fn setup_rebase_repo() -> RebaseRepo {
816 let sandbox = TempDir::new().expect("tempdir");
817 let bare = sandbox.path().join("bare.git");
818 let repo = sandbox.path().join("repo");
819 std::fs::create_dir_all(&bare).unwrap();
820
821 run_git(&bare, &["init", "--bare", "-b", "main"]);
822
823 let status = Command::new("git")
825 .args([
826 "clone",
827 bare.to_str().unwrap(),
828 repo.to_str().unwrap(),
829 "--origin",
830 "origin",
831 ])
832 .status()
833 .expect("git clone");
834 assert!(status.success());
835
836 run_git(&repo, &["config", "user.email", "test@test.com"]);
837 run_git(&repo, &["config", "user.name", "Test"]);
838 run_git(&repo, &["checkout", "-b", "main"]);
839 std::fs::write(repo.join("a.txt"), "one\n").unwrap();
840 run_git(&repo, &["add", "."]);
841 run_git(&repo, &["commit", "-m", "init"]);
842 run_git(&repo, &["push", "-u", "origin", "main"]);
843 run_git(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"]);
844 run_git(&repo, &["remote", "set-head", "origin", "main"]);
845 run_git(&repo, &["branch", "feat/example"]);
846
847 RebaseRepo {
848 _sandbox: sandbox,
849 repo,
850 }
851 }
852
853 fn advance_main(repo: &Path, commits: usize) {
854 for i in 0..commits {
855 std::fs::write(repo.join(format!("main-{i}.txt")), format!("v{i}\n")).unwrap();
856 run_git(repo, &["add", "."]);
857 run_git(repo, &["commit", "-m", &format!("main commit {i}")]);
858 }
859 }
860
861 fn head_sha(repo: &Path, branch: &str) -> String {
862 capture_git(repo, &["rev-parse", branch])
863 }
864
865 #[test]
866 fn create_worktree_rebases_branch_when_behind_main() {
867 let r = setup_rebase_repo();
868 advance_main(r.path(), 2);
869
870 let result = create_worktree(r.path(), "feat/example", true, WorktreePlacement::Sibling)
871 .expect("rebase succeeds");
872 assert!(
873 matches!(
874 result,
875 WorktreeCreation {
876 branch_created: false,
877 ..
878 }
879 ),
880 "branch existed, branch_created must be false"
881 );
882 assert!(result.path.exists(), "worktree directory must be created");
883
884 let count = capture_git(r.path(), &["rev-list", "--count", "feat/example..main"]);
886 assert_eq!(count, "0", "feat/example must include main's commits");
887 }
888
889 #[test]
890 fn create_worktree_rebase_noop_when_branch_up_to_date() {
891 let r = setup_rebase_repo();
892 let before = head_sha(r.path(), "feat/example");
894 let _result = create_worktree(r.path(), "feat/example", true, WorktreePlacement::Sibling)
895 .expect("noop rebase succeeds");
896 let after = head_sha(r.path(), "feat/example");
897 assert_eq!(before, after, "noop rebase must not change HEAD");
898 }
899
900 #[test]
901 fn create_worktree_rebase_conflict_aborts_and_errors() {
902 let r = setup_rebase_repo();
903
904 run_git(r.path(), &["checkout", "feat/example"]);
907 std::fs::write(r.path().join("a.txt"), "feat-version\n").unwrap();
908 run_git(r.path(), &["add", "."]);
909 run_git(r.path(), &["commit", "-m", "feat edit"]);
910 run_git(r.path(), &["checkout", "main"]);
911 std::fs::write(r.path().join("a.txt"), "main-version\n").unwrap();
912 run_git(r.path(), &["add", "."]);
913 run_git(r.path(), &["commit", "-m", "main edit"]);
914
915 let pre = head_sha(r.path(), "feat/example");
916 let result = create_worktree(r.path(), "feat/example", true, WorktreePlacement::Sibling);
917 let err = result.expect_err("rebase must error on conflict");
918 match err {
919 PawError::WorktreeError(msg) => assert!(
920 msg.contains("rebase onto main failed"),
921 "expected 'rebase onto main failed' in error, got: {msg}"
922 ),
923 other => panic!("expected WorktreeError, got {other:?}"),
924 }
925
926 let post = head_sha(r.path(), "feat/example");
927 assert_eq!(pre, post, "branch HEAD must be restored after abort");
928
929 let git_dir = r.path().join(".git");
930 assert!(
931 !git_dir.join("rebase-merge").exists(),
932 "rebase-merge dir must not survive abort"
933 );
934 assert!(
935 !git_dir.join("rebase-apply").exists(),
936 "rebase-apply dir must not survive abort"
937 );
938 }
939
940 #[test]
941 fn create_worktree_no_rebase_preserves_v0_5_behaviour() {
942 let r = setup_rebase_repo();
943 advance_main(r.path(), 2);
944
945 let before = head_sha(r.path(), "feat/example");
946 let result = create_worktree(r.path(), "feat/example", false, WorktreePlacement::Sibling)
947 .expect("no-rebase path succeeds");
948 let after = head_sha(r.path(), "feat/example");
949 assert_eq!(before, after, "rebase_onto_main=false must not change HEAD");
950 assert!(result.path.exists(), "worktree directory must be created");
951 }
952
953 #[test]
954 fn create_worktree_new_branch_skips_rebase_regardless_of_flag() {
955 let r = setup_rebase_repo();
956 let result = create_worktree(r.path(), "feat/new", true, WorktreePlacement::Sibling)
958 .expect("new-branch creation succeeds");
959 assert!(
960 matches!(
961 result,
962 WorktreeCreation {
963 branch_created: true,
964 ..
965 }
966 ),
967 "new branch must report branch_created=true"
968 );
969 assert!(result.path.exists(), "worktree directory must be created");
970 }
971
972 #[cfg(unix)]
973 #[test]
974 fn remove_worktree_does_not_panic_on_non_utf8_path() {
975 use std::ffi::OsString;
982 use std::os::unix::ffi::OsStringExt;
983 use std::path::PathBuf;
984
985 use super::remove_worktree;
986
987 let repo = tempfile::tempdir().expect("tempdir");
988
989 let non_utf8 = OsString::from_vec(vec![b'f', 0x80, b'f']);
991 let worktree_path = PathBuf::from(non_utf8);
992
993 let result = remove_worktree(repo.path(), &worktree_path);
997 assert!(result.is_err(), "expected Err for non-existent worktree");
998 }
999
1000 #[test]
1003 fn branch_slug_replaces_slash_with_dash() {
1004 assert_eq!(branch_slug("feat/auth-flow"), "feat-auth-flow");
1005 assert_eq!(branch_slug("a/b/c"), "a-b-c");
1006 }
1007
1008 #[test]
1009 fn branch_slug_strips_unsafe_characters() {
1010 assert_eq!(branch_slug("fix/issue#42"), "fix-issue42");
1012 }
1013
1014 #[test]
1015 fn branch_slug_preserves_safe_punctuation() {
1016 assert_eq!(branch_slug("release/v1.2_rc-3"), "release-v1.2_rc-3");
1018 }
1019
1020 #[test]
1021 fn create_worktree_child_placement_creates_inside_repo() {
1022 let r = setup_rebase_repo();
1023
1024 let result = create_worktree(r.path(), "feat/auth-flow", false, WorktreePlacement::Child)
1025 .expect("child worktree creation succeeds");
1026
1027 let expected = r
1028 .path()
1029 .join(".git-paw")
1030 .join("worktrees")
1031 .join("feat-auth-flow");
1032 assert_eq!(result.path, expected, "child worktree path mismatch");
1033 assert!(result.path.exists(), "child worktree directory must exist");
1034 assert!(
1035 r.path().join(".git-paw").join("worktrees").is_dir(),
1036 ".git-paw/worktrees/ must be created"
1037 );
1038 }
1039
1040 #[test]
1041 fn create_worktree_child_slug_strips_unsafe_characters() {
1042 let r = setup_rebase_repo();
1043
1044 let result = create_worktree(r.path(), "fix/issue#42", false, WorktreePlacement::Child)
1045 .expect("child worktree creation succeeds");
1046
1047 assert!(
1048 result.path.ends_with(".git-paw/worktrees/fix-issue42"),
1049 "expected slug-derived child path, got {}",
1050 result.path.display()
1051 );
1052 }
1053
1054 #[test]
1055 fn create_worktree_sibling_placement_creates_beside_repo() {
1056 let r = setup_rebase_repo();
1057 let project = super::project_name(r.path());
1058
1059 let result = create_worktree(r.path(), "feature/test", false, WorktreePlacement::Sibling)
1060 .expect("sibling worktree creation succeeds");
1061
1062 let expected = r
1063 .path()
1064 .parent()
1065 .unwrap()
1066 .join(format!("{project}-feature-test"));
1067 assert_eq!(result.path, expected, "sibling worktree path mismatch");
1068 assert!(
1069 result.path.exists(),
1070 "sibling worktree directory must exist"
1071 );
1072 }
1073
1074 #[test]
1075 fn create_worktree_child_and_sibling_differ_for_same_branch() {
1076 let r = setup_rebase_repo();
1079
1080 let child =
1081 create_worktree(r.path(), "feat/x", false, WorktreePlacement::Child).expect("child");
1082 let sibling = create_worktree(r.path(), "feat/y", false, WorktreePlacement::Sibling)
1083 .expect("sibling");
1084
1085 assert!(
1086 child.path.starts_with(r.path()),
1087 "child must be inside repo"
1088 );
1089 assert!(
1090 !sibling.path.starts_with(r.path()),
1091 "sibling must be outside repo"
1092 );
1093 }
1094}