1use std::collections::BTreeSet;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use crate::error::PawError;
11use crate::specs::SpecEntry;
12
13pub fn validate_repo(path: &Path) -> Result<PathBuf, PawError> {
17 let output = Command::new("git")
18 .current_dir(path)
19 .args(["rev-parse", "--show-toplevel"])
20 .output()
21 .map_err(|e| PawError::BranchError(format!("failed to run git: {e}")))?;
22
23 if !output.status.success() {
24 return Err(PawError::NotAGitRepo);
25 }
26
27 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
28 Ok(PathBuf::from(root))
29}
30
31pub fn list_branches(repo_root: &Path) -> Result<Vec<String>, PawError> {
38 let output = Command::new("git")
39 .current_dir(repo_root)
40 .args(["branch", "-a", "--format=%(refname:short)"])
41 .output()
42 .map_err(|e| PawError::BranchError(format!("failed to run git branch: {e}")))?;
43
44 if !output.status.success() {
45 let stderr = String::from_utf8_lossy(&output.stderr);
46 return Err(PawError::BranchError(format!(
47 "git branch failed: {stderr}"
48 )));
49 }
50
51 let stdout = String::from_utf8_lossy(&output.stdout);
52 let branches: BTreeSet<String> = stdout
53 .lines()
54 .filter(|line| !line.trim().is_empty() && !line.contains("HEAD"))
55 .map(|line| {
56 let mut branch_name = line.trim().to_string();
58
59 if let Some(stripped) = branch_name.strip_prefix("refs/remotes/") {
61 branch_name = stripped.to_string();
62 }
63 if let Some(stripped) = branch_name.strip_prefix("origin/") {
65 branch_name = stripped.to_string();
66 }
67
68 branch_name
69 })
70 .collect();
71
72 let mut unique: Vec<String> = branches.into_iter().collect();
74 unique.sort();
75 Ok(unique)
76}
77
78pub fn worktree_dir_name(project: &str, branch: &str) -> String {
82 let project_safe: String = project
83 .chars()
84 .map(|c| if c.is_alphanumeric() { c } else { '-' })
85 .collect();
86 let branch_safe: String = branch
87 .chars()
88 .map(|c| if c.is_alphanumeric() { c } else { '-' })
89 .collect();
90 format!("{project_safe}-{branch_safe}")
91}
92
93pub fn default_branch(repo_root: &Path) -> Result<String, PawError> {
95 let output = Command::new("git")
96 .current_dir(repo_root)
97 .args(["symbolic-ref", "refs/remotes/origin/HEAD"])
98 .output()
99 .map_err(|e| PawError::BranchError(format!("failed to run git symbolic-ref: {e}")))?;
100
101 if !output.status.success() {
102 let stderr = String::from_utf8_lossy(&output.stderr);
103 return Err(PawError::BranchError(format!(
104 "git symbolic-ref failed: {stderr}"
105 )));
106 }
107
108 let ref_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
109 if let Some(branch) = ref_name.strip_prefix("refs/remotes/origin/") {
110 Ok(branch.to_string())
111 } else {
112 Err(PawError::BranchError(format!(
113 "unexpected ref format: {ref_name}"
114 )))
115 }
116}
117
118pub fn current_branch(repo_root: &Path) -> Result<String, PawError> {
120 let output = Command::new("git")
121 .current_dir(repo_root)
122 .args(["branch", "--show-current"])
123 .output()
124 .map_err(|e| PawError::BranchError(format!("failed to run git branch: {e}")))?;
125
126 if !output.status.success() {
127 let stderr = String::from_utf8_lossy(&output.stderr);
128 return Err(PawError::BranchError(format!(
129 "git branch failed: {stderr}"
130 )));
131 }
132
133 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
134 if branch.is_empty() {
135 return Err(PawError::BranchError(
136 "not on any branch (detached HEAD)".to_string(),
137 ));
138 }
139 Ok(branch)
140}
141
142pub fn project_name(repo_root: &Path) -> String {
144 repo_root
145 .file_name()
146 .and_then(std::ffi::OsStr::to_str)
147 .unwrap_or("unknown")
148 .to_string()
149}
150
151#[derive(Debug)]
153pub struct WorktreeCreation {
154 pub path: PathBuf,
156 pub branch_created: bool,
158}
159
160fn find_worktree_for_branch(repo_root: &Path, branch: &str) -> Result<Option<PathBuf>, PawError> {
163 let list = Command::new("git")
164 .current_dir(repo_root)
165 .args(["worktree", "list", "--porcelain"])
166 .output()
167 .map_err(|e| PawError::WorktreeError(format!("failed to run git worktree list: {e}")))?;
168 if !list.status.success() {
169 return Ok(None);
170 }
171 let listing = String::from_utf8_lossy(&list.stdout);
172 let expected_branch_ref = format!("refs/heads/{branch}");
173 let mut current_path: Option<PathBuf> = None;
174 for line in listing.lines() {
175 if let Some(rest) = line.strip_prefix("worktree ") {
176 current_path = Some(PathBuf::from(rest));
177 } else if let Some(rest) = line.strip_prefix("branch ")
178 && rest == expected_branch_ref
179 && let Some(p) = current_path.take()
180 {
181 return Ok(Some(p));
182 }
183 }
184 Ok(None)
185}
186
187fn rebase_branch_onto_default(repo_root: &Path, branch: &str) -> Result<(), PawError> {
198 let default = default_branch(repo_root)?;
199
200 let occupied_at = find_worktree_for_branch(repo_root, branch)?;
201 let (workdir, original_head): (PathBuf, Option<String>) = if let Some(wt) = occupied_at {
202 (wt, None)
203 } else {
204 let original = Command::new("git")
205 .current_dir(repo_root)
206 .args(["symbolic-ref", "--short", "HEAD"])
207 .output()
208 .ok()
209 .filter(|o| o.status.success())
210 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
211 (repo_root.to_path_buf(), original)
212 };
213
214 let mut invocation = Command::new("git");
215 invocation.current_dir(&workdir);
216 if original_head.is_some() {
217 invocation.args(["rebase", &default, branch]);
218 } else {
219 invocation.args(["rebase", &default]);
220 }
221 let output = invocation
222 .output()
223 .map_err(|e| PawError::WorktreeError(format!("failed to run git rebase: {e}")))?;
224
225 if !output.status.success() {
226 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
227 let _ = Command::new("git")
228 .current_dir(&workdir)
229 .args(["rebase", "--abort"])
230 .output();
231 if let Some(orig) = &original_head
232 && orig != branch
233 {
234 let _ = Command::new("git")
235 .current_dir(repo_root)
236 .args(["checkout", orig])
237 .output();
238 }
239 return Err(PawError::WorktreeError(format!(
240 "rebase onto main failed: {stderr}"
241 )));
242 }
243
244 if let Some(orig) = original_head
245 && orig != branch
246 {
247 let _ = Command::new("git")
248 .current_dir(repo_root)
249 .args(["checkout", &orig])
250 .output();
251 }
252
253 Ok(())
254}
255
256pub fn create_worktree(
272 repo_root: &Path,
273 branch: &str,
274 rebase_onto_main: bool,
275) -> Result<WorktreeCreation, PawError> {
276 let project = project_name(repo_root);
277 let dir_name = worktree_dir_name(&project, branch);
278
279 let parent = repo_root.parent().ok_or_else(|| {
280 PawError::WorktreeError("cannot determine parent directory of repo".to_string())
281 })?;
282 let worktree_path = parent.join(&dir_name);
283
284 if rebase_onto_main {
292 let branch_exists = Command::new("git")
293 .current_dir(repo_root)
294 .args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
295 .output()
296 .is_ok_and(|o| o.status.success());
297 if branch_exists {
298 rebase_branch_onto_default(repo_root, branch)?;
299 }
300 }
301
302 if worktree_path.exists() {
308 let expected_canonical = std::fs::canonicalize(&worktree_path).ok();
312 let list = Command::new("git")
313 .current_dir(repo_root)
314 .args(["worktree", "list", "--porcelain"])
315 .output()
316 .map_err(|e| {
317 PawError::WorktreeError(format!("failed to run git worktree list: {e}"))
318 })?;
319 if list.status.success() {
320 let listing = String::from_utf8_lossy(&list.stdout);
321 let expected_branch_ref = format!("refs/heads/{branch}");
322 let mut current_path: Option<PathBuf> = None;
325 for line in listing.lines() {
326 if let Some(rest) = line.strip_prefix("worktree ") {
327 current_path = std::fs::canonicalize(PathBuf::from(rest)).ok();
328 } else if let Some(rest) = line.strip_prefix("branch ") {
329 let path_matches = match (¤t_path, &expected_canonical) {
330 (Some(p), Some(e)) => p == e,
331 _ => false,
332 };
333 if path_matches && rest == expected_branch_ref {
334 return Ok(WorktreeCreation {
335 path: worktree_path,
336 branch_created: false,
337 });
338 }
339 }
340 }
341 }
342 }
346
347 let output = Command::new("git")
349 .current_dir(repo_root)
350 .args(["worktree", "add", &worktree_path.to_string_lossy(), branch])
351 .output()
352 .map_err(|e| PawError::WorktreeError(format!("failed to run git worktree add: {e}")))?;
353
354 if output.status.success() {
355 return Ok(WorktreeCreation {
356 path: worktree_path,
357 branch_created: false,
358 });
359 }
360
361 let stderr = String::from_utf8_lossy(&output.stderr);
362
363 if stderr.contains("invalid reference") {
365 let output = Command::new("git")
366 .current_dir(repo_root)
367 .args([
368 "worktree",
369 "add",
370 "-b",
371 branch,
372 &worktree_path.to_string_lossy(),
373 ])
374 .output()
375 .map_err(|e| {
376 PawError::WorktreeError(format!("failed to run git worktree add -b: {e}"))
377 })?;
378
379 if output.status.success() {
380 return Ok(WorktreeCreation {
381 path: worktree_path,
382 branch_created: true,
383 });
384 }
385
386 let stderr = String::from_utf8_lossy(&output.stderr);
387 return Err(PawError::WorktreeError(format!(
388 "git worktree add -b failed for branch '{branch}': {stderr}"
389 )));
390 }
391
392 Err(PawError::WorktreeError(format!(
393 "git worktree add failed for branch '{branch}': {stderr}"
394 )))
395}
396
397pub fn remove_worktree(repo_root: &Path, worktree_path: &Path) -> Result<(), PawError> {
401 let output = Command::new("git")
408 .current_dir(repo_root)
409 .args(["worktree", "remove", "--force"])
410 .arg(worktree_path.as_os_str())
411 .output()
412 .map_err(|e| {
413 PawError::WorktreeError(format!(
414 "failed to remove worktree at {}: {e}",
415 worktree_path.display()
416 ))
417 })?;
418
419 if !output.status.success() {
420 let stderr = String::from_utf8_lossy(&output.stderr);
421 return Err(PawError::WorktreeError(format!(
422 "git worktree remove failed for worktree at {}: {stderr}",
423 worktree_path.display()
424 )));
425 }
426
427 Ok(())
428}
429
430pub fn prune_worktrees(repo_root: &Path) -> Result<(), PawError> {
434 let output = Command::new("git")
435 .current_dir(repo_root)
436 .args(["worktree", "prune"])
437 .output()
438 .map_err(|e| PawError::WorktreeError(format!("failed to prune worktrees: {e}")))?;
439
440 if !output.status.success() {
441 let stderr = String::from_utf8_lossy(&output.stderr);
442 return Err(PawError::WorktreeError(format!(
443 "git worktree prune failed: {stderr}"
444 )));
445 }
446
447 Ok(())
448}
449
450pub fn check_uncommitted_specs(
461 repo_root: &Path,
462 specs: &[SpecEntry],
463) -> Result<Vec<String>, PawError> {
464 let mut uncommitted_specs = Vec::new();
465
466 let specs_dir = repo_root.join("specs");
467
468 for spec in specs {
469 let dir_path = specs_dir.join(&spec.id);
470 let file_path = specs_dir.join(format!("{}.md", spec.id));
471
472 let porcelain_target = if dir_path.is_dir() {
473 format!("specs/{}", spec.id)
474 } else if file_path.is_file() {
475 format!("specs/{}.md", spec.id)
476 } else {
477 continue;
478 };
479
480 let output = Command::new("git")
481 .current_dir(repo_root)
482 .args(["status", "--porcelain", "--", &porcelain_target])
483 .output()
484 .map_err(|e| {
485 PawError::BranchError(format!(
486 "failed to run git status for spec {}: {e}",
487 spec.id
488 ))
489 })?;
490
491 if !output.status.success() {
492 let stderr = String::from_utf8_lossy(&output.stderr);
493 return Err(PawError::BranchError(format!(
494 "git status failed for spec {}: {stderr}",
495 spec.id
496 )));
497 }
498
499 let status_output = String::from_utf8_lossy(&output.stdout).trim().to_string();
500 if !status_output.is_empty() {
501 uncommitted_specs.push(spec.id.clone());
502 }
503 }
504
505 Ok(uncommitted_specs)
506}
507
508pub fn merge_branch(repo_root: &Path, branch: &str) -> Result<bool, PawError> {
512 let output = Command::new("git")
513 .current_dir(repo_root)
514 .args(["merge", "--no-ff", "--no-commit", branch])
515 .output()
516 .map_err(|e| {
517 PawError::WorktreeError(format!("failed to run git merge for branch {branch}: {e}"))
518 })?;
519
520 if !output.status.success() {
521 let stderr = String::from_utf8_lossy(&output.stderr);
522 if output.status.code() == Some(1) {
524 return Ok(false);
525 }
526 return Err(PawError::WorktreeError(format!(
527 "git merge failed for branch {branch}: {stderr}"
528 )));
529 }
530
531 Ok(true)
532}
533
534pub fn delete_branch(repo_root: &Path, branch: &str) -> Result<(), PawError> {
536 let output = Command::new("git")
537 .current_dir(repo_root)
538 .args(["branch", "-D", branch])
539 .output()
540 .map_err(|e| PawError::BranchError(format!("failed to delete branch {branch}: {e}")))?;
541
542 if !output.status.success() {
543 let stderr = String::from_utf8_lossy(&output.stderr);
544 return Err(PawError::BranchError(format!(
545 "git branch -D failed for branch {branch}: {stderr}"
546 )));
547 }
548
549 Ok(())
550}
551
552pub fn exclude_from_git(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
558 let exclude_file = worktree_root.join(".git/info/exclude");
559
560 let existing = if exclude_file.exists() {
562 std::fs::read_to_string(&exclude_file).unwrap_or_default()
563 } else {
564 String::new()
565 };
566
567 if !existing.lines().any(|line| line.trim() == filename) {
569 let mut updated = existing;
570 if !updated.ends_with('\n') && !updated.is_empty() {
571 updated.push('\n');
572 }
573 updated.push_str(filename);
574 updated.push('\n');
575
576 if let Some(parent) = exclude_file.parent() {
578 if let Some(git_dir) = parent.parent()
580 && git_dir.is_file()
581 {
582 let main_git_dir = std::fs::read_to_string(git_dir)
585 .ok()
586 .and_then(|s| s.strip_prefix("gitdir: ").map(|s| s.trim().to_owned()))
587 .unwrap_or_default();
588 let main_git_info = PathBuf::from(main_git_dir).join("info");
589 if !main_git_info.try_exists().unwrap_or(false) {
590 std::fs::create_dir_all(&main_git_info).map_err(|e| {
591 PawError::SessionError(format!("failed to create main .git/info: {e}"))
592 })?;
593 }
594 let main_exclude = main_git_info.join("exclude");
595 std::fs::write(&main_exclude, updated).map_err(|e| {
596 PawError::SessionError(format!(
597 "failed to write to main .git/info/exclude: {e}"
598 ))
599 })?;
600 return Ok(());
601 }
602 if parent.exists() && parent.is_file() {
603 std::fs::remove_file(parent).map_err(|e| {
604 PawError::SessionError(format!("failed to remove .git/info file: {e}"))
605 })?;
606 }
607 std::fs::create_dir_all(parent).map_err(|e| {
608 PawError::SessionError(format!("failed to create .git/info directory: {e}"))
609 })?;
610 }
611
612 std::fs::write(&exclude_file, updated).map_err(|e| {
613 PawError::SessionError(format!("failed to write to .git/info/exclude: {e}"))
614 })?;
615 }
616
617 Ok(())
618}
619
620pub fn assume_unchanged(worktree_root: &Path, filename: &str) -> Result<(), PawError> {
626 let _ = std::process::Command::new("git")
632 .current_dir(worktree_root)
633 .args(["update-index", "--assume-unchanged", filename])
634 .output();
635 Ok(())
636}
637
638#[cfg(test)]
639mod tests {
640 use std::path::{Path, PathBuf};
641 use std::process::Command;
642
643 use tempfile::TempDir;
644
645 use crate::error::PawError;
646 use crate::git::{WorktreeCreation, create_worktree};
647
648 struct RebaseRepo {
653 _sandbox: TempDir,
654 repo: PathBuf,
655 }
656
657 impl RebaseRepo {
658 fn path(&self) -> &Path {
659 &self.repo
660 }
661 }
662
663 fn run_git(dir: &Path, args: &[&str]) {
664 let output = Command::new("git")
665 .current_dir(dir)
666 .args(args)
667 .output()
668 .expect("run git command");
669 assert!(
670 output.status.success(),
671 "git {} failed: {}",
672 args.join(" "),
673 String::from_utf8_lossy(&output.stderr)
674 );
675 }
676
677 fn capture_git(dir: &Path, args: &[&str]) -> String {
678 let output = Command::new("git")
679 .current_dir(dir)
680 .args(args)
681 .output()
682 .expect("run git command");
683 assert!(
684 output.status.success(),
685 "git {} failed: {}",
686 args.join(" "),
687 String::from_utf8_lossy(&output.stderr)
688 );
689 String::from_utf8_lossy(&output.stdout).trim().to_string()
690 }
691
692 fn setup_rebase_repo() -> RebaseRepo {
696 let sandbox = TempDir::new().expect("tempdir");
697 let bare = sandbox.path().join("bare.git");
698 let repo = sandbox.path().join("repo");
699 std::fs::create_dir_all(&bare).unwrap();
700
701 run_git(&bare, &["init", "--bare", "-b", "main"]);
702
703 let status = Command::new("git")
705 .args([
706 "clone",
707 bare.to_str().unwrap(),
708 repo.to_str().unwrap(),
709 "--origin",
710 "origin",
711 ])
712 .status()
713 .expect("git clone");
714 assert!(status.success());
715
716 run_git(&repo, &["config", "user.email", "test@test.com"]);
717 run_git(&repo, &["config", "user.name", "Test"]);
718 run_git(&repo, &["checkout", "-b", "main"]);
719 std::fs::write(repo.join("a.txt"), "one\n").unwrap();
720 run_git(&repo, &["add", "."]);
721 run_git(&repo, &["commit", "-m", "init"]);
722 run_git(&repo, &["push", "-u", "origin", "main"]);
723 run_git(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"]);
724 run_git(&repo, &["remote", "set-head", "origin", "main"]);
725 run_git(&repo, &["branch", "feat/example"]);
726
727 RebaseRepo {
728 _sandbox: sandbox,
729 repo,
730 }
731 }
732
733 fn advance_main(repo: &Path, commits: usize) {
734 for i in 0..commits {
735 std::fs::write(repo.join(format!("main-{i}.txt")), format!("v{i}\n")).unwrap();
736 run_git(repo, &["add", "."]);
737 run_git(repo, &["commit", "-m", &format!("main commit {i}")]);
738 }
739 }
740
741 fn head_sha(repo: &Path, branch: &str) -> String {
742 capture_git(repo, &["rev-parse", branch])
743 }
744
745 #[test]
746 fn create_worktree_rebases_branch_when_behind_main() {
747 let r = setup_rebase_repo();
748 advance_main(r.path(), 2);
749
750 let result = create_worktree(r.path(), "feat/example", true).expect("rebase succeeds");
751 assert!(
752 matches!(
753 result,
754 WorktreeCreation {
755 branch_created: false,
756 ..
757 }
758 ),
759 "branch existed, branch_created must be false"
760 );
761 assert!(result.path.exists(), "worktree directory must be created");
762
763 let count = capture_git(r.path(), &["rev-list", "--count", "feat/example..main"]);
765 assert_eq!(count, "0", "feat/example must include main's commits");
766 }
767
768 #[test]
769 fn create_worktree_rebase_noop_when_branch_up_to_date() {
770 let r = setup_rebase_repo();
771 let before = head_sha(r.path(), "feat/example");
773 let _result =
774 create_worktree(r.path(), "feat/example", true).expect("noop rebase succeeds");
775 let after = head_sha(r.path(), "feat/example");
776 assert_eq!(before, after, "noop rebase must not change HEAD");
777 }
778
779 #[test]
780 fn create_worktree_rebase_conflict_aborts_and_errors() {
781 let r = setup_rebase_repo();
782
783 run_git(r.path(), &["checkout", "feat/example"]);
786 std::fs::write(r.path().join("a.txt"), "feat-version\n").unwrap();
787 run_git(r.path(), &["add", "."]);
788 run_git(r.path(), &["commit", "-m", "feat edit"]);
789 run_git(r.path(), &["checkout", "main"]);
790 std::fs::write(r.path().join("a.txt"), "main-version\n").unwrap();
791 run_git(r.path(), &["add", "."]);
792 run_git(r.path(), &["commit", "-m", "main edit"]);
793
794 let pre = head_sha(r.path(), "feat/example");
795 let result = create_worktree(r.path(), "feat/example", true);
796 let err = result.expect_err("rebase must error on conflict");
797 match err {
798 PawError::WorktreeError(msg) => assert!(
799 msg.contains("rebase onto main failed"),
800 "expected 'rebase onto main failed' in error, got: {msg}"
801 ),
802 other => panic!("expected WorktreeError, got {other:?}"),
803 }
804
805 let post = head_sha(r.path(), "feat/example");
806 assert_eq!(pre, post, "branch HEAD must be restored after abort");
807
808 let git_dir = r.path().join(".git");
809 assert!(
810 !git_dir.join("rebase-merge").exists(),
811 "rebase-merge dir must not survive abort"
812 );
813 assert!(
814 !git_dir.join("rebase-apply").exists(),
815 "rebase-apply dir must not survive abort"
816 );
817 }
818
819 #[test]
820 fn create_worktree_no_rebase_preserves_v0_5_behaviour() {
821 let r = setup_rebase_repo();
822 advance_main(r.path(), 2);
823
824 let before = head_sha(r.path(), "feat/example");
825 let result =
826 create_worktree(r.path(), "feat/example", false).expect("no-rebase path succeeds");
827 let after = head_sha(r.path(), "feat/example");
828 assert_eq!(before, after, "rebase_onto_main=false must not change HEAD");
829 assert!(result.path.exists(), "worktree directory must be created");
830 }
831
832 #[test]
833 fn create_worktree_new_branch_skips_rebase_regardless_of_flag() {
834 let r = setup_rebase_repo();
835 let result =
837 create_worktree(r.path(), "feat/new", true).expect("new-branch creation succeeds");
838 assert!(
839 matches!(
840 result,
841 WorktreeCreation {
842 branch_created: true,
843 ..
844 }
845 ),
846 "new branch must report branch_created=true"
847 );
848 assert!(result.path.exists(), "worktree directory must be created");
849 }
850
851 #[cfg(unix)]
852 #[test]
853 fn remove_worktree_does_not_panic_on_non_utf8_path() {
854 use std::ffi::OsString;
861 use std::os::unix::ffi::OsStringExt;
862 use std::path::PathBuf;
863
864 use super::remove_worktree;
865
866 let repo = tempfile::tempdir().expect("tempdir");
867
868 let non_utf8 = OsString::from_vec(vec![b'f', 0x80, b'f']);
870 let worktree_path = PathBuf::from(non_utf8);
871
872 let result = remove_worktree(repo.path(), &worktree_path);
876 assert!(result.is_err(), "expected Err for non-existent worktree");
877 }
878}