1use anyhow::{bail, Context, Result};
41use serde::{Deserialize, Serialize};
42use std::fmt;
43use std::path::{Path, PathBuf};
44use tokio::process::Command;
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct WorktreeInfo {
51 pub path: PathBuf,
53 pub branch: Option<String>,
55 pub commit: String,
57 pub is_main: bool,
59 pub is_bare: bool,
61 pub is_detached: bool,
63 pub is_prunable: bool,
65 pub is_locked: bool,
67}
68
69impl WorktreeInfo {
70 pub fn commit_short(&self) -> String {
72 self.commit.chars().take(7).collect()
73 }
74
75 pub fn branch(&self) -> String {
77 self.branch
78 .clone()
79 .unwrap_or_else(|| format!("(detached {})", self.commit_short()))
80 }
81
82 pub fn dir_name(&self) -> String {
84 self.path
85 .file_name()
86 .map(|n| n.to_string_lossy().to_string())
87 .unwrap_or_else(|| self.path.display().to_string())
88 }
89}
90
91impl fmt::Display for WorktreeInfo {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 let main_flag = if self.is_main { " [main]" } else { "" };
94 let locked_flag = if self.is_locked { " [locked]" } else { "" };
95 let prunable_flag = if self.is_prunable { " [prunable]" } else { "" };
96 write!(
97 f,
98 "{} {} at {}{}{}{}",
99 self.branch(),
100 self.commit_short(),
101 self.path.display(),
102 main_flag,
103 locked_flag,
104 prunable_flag,
105 )
106 }
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct WorktreeCreateOpts {
114 pub branch: String,
116 pub path: PathBuf,
119 #[serde(skip_serializing_if = "Option::is_none")]
121 pub start_point: Option<String>,
122 #[serde(default)]
125 pub force_branch: bool,
126 #[serde(default)]
129 pub detach: bool,
130}
131
132impl WorktreeCreateOpts {
133 pub fn feature_branch(branch: &str, repo_root: &Path) -> Self {
138 let safe_name = branch.replace('/', "-");
139 let parent = repo_root
140 .parent()
141 .unwrap_or(repo_root);
142 let path = parent.join(safe_name);
143 Self {
144 branch: branch.to_string(),
145 path,
146 start_point: None,
147 force_branch: false,
148 detach: false,
149 }
150 }
151
152 pub fn hotfix(branch: &str, start_point: &str, repo_root: &Path) -> Self {
154 let safe_name = branch.replace('/', "-");
155 let parent = repo_root
156 .parent()
157 .unwrap_or(repo_root);
158 let path = parent.join(safe_name);
159 Self {
160 branch: branch.to_string(),
161 path,
162 start_point: Some(start_point.to_string()),
163 force_branch: false,
164 detach: false,
165 }
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct MergeResult {
174 pub source_branch: String,
176 pub target_branch: String,
178 pub was_merge_commit: bool,
180 pub merge_commit: Option<String>,
182 pub had_conflicts: bool,
184 pub conflicts: Vec<String>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct WorktreeList {
193 pub worktrees: Vec<WorktreeInfo>,
195 pub repo_root: PathBuf,
197}
198
199impl WorktreeList {
200 pub fn len(&self) -> usize {
202 self.worktrees.len()
203 }
204
205 pub fn is_empty(&self) -> bool {
207 self.worktrees.is_empty()
208 }
209
210 pub fn main(&self) -> Option<&WorktreeInfo> {
212 self.worktrees.iter().find(|w| w.is_main)
213 }
214
215 pub fn linked(&self) -> Vec<&WorktreeInfo> {
217 self.worktrees.iter().filter(|w| !w.is_main).collect()
218 }
219
220 pub fn by_branch(&self, branch: &str) -> Option<&WorktreeInfo> {
222 self.worktrees
223 .iter()
224 .find(|w| w.branch.as_deref() == Some(branch))
225 }
226
227 pub fn by_path(&self, path: &Path) -> Option<&WorktreeInfo> {
229 self.worktrees.iter().find(|w| w.path == path)
230 }
231}
232
233#[derive(Debug, Clone)]
239pub struct WorktreeManager {
240 repo_root: PathBuf,
242}
243
244impl WorktreeManager {
245 pub fn new(repo_root: &Path) -> Result<Self> {
249 let canonical = repo_root
251 .canonicalize()
252 .with_context(|| format!("Cannot resolve path: {}", repo_root.display()))?;
253
254 Ok(Self {
255 repo_root: canonical,
256 })
257 }
258
259 pub fn for_current_repo() -> Result<Self> {
262 let output = std::process::Command::new("git")
263 .args(["rev-parse", "--show-toplevel"])
264 .output()
265 .context("Failed to run `git rev-parse`. Is git installed?")?;
266
267 if !output.status.success() {
268 bail!(
269 "Not inside a git repository: {}",
270 String::from_utf8_lossy(&output.stderr).trim()
271 );
272 }
273
274 let root = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
275 Self::new(&root)
276 }
277
278 pub fn repo_root(&self) -> &Path {
280 &self.repo_root
281 }
282
283 pub async fn create(&self, opts: &WorktreeCreateOpts) -> Result<WorktreeInfo> {
287 let mut args = vec!["worktree".to_string(), "add".to_string()];
288
289 if opts.detach {
290 args.push("--detach".to_string());
291 } else if opts.force_branch {
292 args.push("-B".to_string());
293 args.push(opts.branch.clone());
294 } else {
295 args.push("-b".to_string());
296 args.push(opts.branch.clone());
297 }
298
299 args.push(opts.path.to_string_lossy().to_string());
300
301 if let Some(ref sp) = opts.start_point {
302 args.push(sp.clone());
303 }
304
305 let str_args: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
306 self.run_git(&str_args).await?;
307
308 let path = if opts.path.is_absolute() {
310 opts.path.clone()
311 } else {
312 self.repo_root.join(&opts.path)
313 };
314
315 let canonical = path
316 .canonicalize()
317 .context("Worktree path was created but cannot be resolved")?;
318
319 let list = self.list().await?;
321 list.by_path(&canonical)
322 .cloned()
323 .context("Worktree was created but not found in listing")
324 }
325
326 pub async fn create_worktree(
330 &self,
331 branch: &str,
332 target_path: &Path,
333 start_point: Option<&str>,
334 ) -> Result<WorktreeInfo> {
335 let opts = WorktreeCreateOpts {
336 branch: branch.to_string(),
337 path: target_path.to_path_buf(),
338 start_point: start_point.map(|s| s.to_string()),
339 force_branch: false,
340 detach: false,
341 };
342 self.create(&opts).await
343 }
344
345 pub async fn list(&self) -> Result<WorktreeList> {
351 let output = self
352 .run_git_output(&["worktree", "list", "--porcelain"])
353 .await?;
354
355 let worktrees = Self::parse_worktree_list(&output)?;
356
357 Ok(WorktreeList {
358 worktrees,
359 repo_root: self.repo_root.clone(),
360 })
361 }
362
363 pub async fn remove(&self, path: &Path, force: bool) -> Result<()> {
369 let mut args = vec!["worktree".to_string(), "remove".to_string()];
370 if force {
371 args.push("--force".to_string());
372 }
373 args.push(path.to_string_lossy().to_string());
374
375 let str_args: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
376 self.run_git(&str_args).await
377 }
378
379 pub async fn remove_by_branch(&self, branch: &str, force: bool) -> Result<()> {
383 let list = self.list().await?;
384 let wt = list
385 .by_branch(branch)
386 .with_context(|| format!("No worktree found for branch '{}'", branch))?;
387 self.remove(&wt.path, force).await
388 }
389
390 pub async fn prune(&self) -> Result<()> {
392 self.run_git(&["worktree", "prune"]).await
393 }
394
395 pub async fn remove_and_delete_branch(&self, branch: &str, force: bool) -> Result<()> {
397 self.remove_by_branch(branch, force).await?;
398 self.run_git(&["branch", "-d", branch]).await?;
399 Ok(())
400 }
401
402 pub async fn force_remove_and_delete_branch(&self, branch: &str) -> Result<()> {
404 self.remove_by_branch(branch, true).await?;
405 self.run_git(&["branch", "-D", branch]).await?;
406 Ok(())
407 }
408
409 pub async fn merge(
416 &self,
417 source_branch: &str,
418 target_branch: &str,
419 ) -> Result<MergeResult> {
420 self.run_git(&["checkout", target_branch]).await?;
422
423 let output = self
425 .run_git_output(&["merge", source_branch, "--no-edit"])
426 .await?;
427
428 let had_conflicts = output.contains("CONFLICT") || output.contains("Merge conflict");
430
431 let conflicts = if had_conflicts {
432 Self::extract_conflicts(&output)
433 } else {
434 Vec::new()
435 };
436
437 let was_merge_commit = output.contains("Merge made by")
439 || output.contains("Merge:") || !output.contains("Fast-forward");
441
442 let merge_commit = if was_merge_commit {
444 let rev_output = self.run_git_output(&["rev-parse", "HEAD"]).await?;
445 Some(rev_output.trim().to_string())
446 } else {
447 None
448 };
449
450 Ok(MergeResult {
451 source_branch: source_branch.to_string(),
452 target_branch: target_branch.to_string(),
453 was_merge_commit,
454 merge_commit,
455 had_conflicts,
456 conflicts,
457 })
458 }
459
460 pub async fn merge_and_remove(
466 &self,
467 source_branch: &str,
468 target_branch: &str,
469 ) -> Result<MergeResult> {
470 let result = self.merge(source_branch, target_branch).await?;
471
472 if result.had_conflicts {
473 return Ok(result);
475 }
476
477 self.remove_and_delete_branch(source_branch, false).await?;
479
480 Ok(result)
481 }
482
483 pub async fn branch(
489 &self,
490 branch: &str,
491 target_path: &Path,
492 start_point: Option<&str>,
493 ) -> Result<WorktreeInfo> {
494 self.create_worktree(branch, target_path, start_point).await
495 }
496
497 pub async fn feature(&self, branch: &str) -> Result<WorktreeInfo> {
499 let opts = WorktreeCreateOpts::feature_branch(branch, &self.repo_root);
500 self.create(&opts).await
501 }
502
503 pub async fn hotfix(&self, branch: &str, start_point: &str) -> Result<WorktreeInfo> {
505 let opts = WorktreeCreateOpts::hotfix(branch, start_point, &self.repo_root);
506 self.create(&opts).await
507 }
508
509 pub async fn is_dirty(&self, worktree_path: &Path) -> Result<bool> {
513 let output = self
514 .run_git_output_at(worktree_path, &["status", "--porcelain"])
515 .await?;
516 Ok(!output.trim().is_empty())
517 }
518
519 pub async fn current_branch(&self, worktree_path: &Path) -> Result<String> {
521 let output = self
522 .run_git_output_at(worktree_path, &["rev-parse", "--abbrev-ref", "HEAD"])
523 .await?;
524 let branch = output.trim().to_string();
525 if branch.is_empty() || branch == "HEAD" {
526 bail!("Worktree at {} has a detached HEAD", worktree_path.display());
527 }
528 Ok(branch)
529 }
530
531 pub async fn head_commit(&self, worktree_path: &Path) -> Result<String> {
533 let output = self
534 .run_git_output_at(worktree_path, &["rev-parse", "HEAD"])
535 .await?;
536 Ok(output.trim().to_string())
537 }
538
539 pub async fn branch_exists(&self, branch: &str) -> Result<bool> {
541 let output = self
542 .run_git_output(&["branch", "--list", branch])
543 .await?;
544 Ok(!output.trim().is_empty())
545 }
546
547 fn parse_worktree_list(porcelain: &str) -> Result<Vec<WorktreeInfo>> {
562 let mut worktrees = Vec::new();
563 let mut current_path: Option<PathBuf> = None;
564 let mut current_head: Option<String> = None;
565 let mut current_branch: Option<String> = None;
566 let mut current_is_bare = false;
567 let mut current_is_detached = false;
568 let mut current_is_prunable = false;
569 let mut current_is_locked = false;
570
571 for line in porcelain.lines() {
572 let line = line.trim();
573
574 if line.starts_with("worktree ") {
575 if let Some(path) = current_path.take() {
577 let branch = current_branch.take().map(|b| {
578 if let Some(stripped) = b.strip_prefix("refs/heads/") {
580 stripped.to_string()
581 } else {
582 b
583 }
584 });
585 let commit = current_head.take().unwrap_or_default();
586 let is_main = worktrees.is_empty(); worktrees.push(WorktreeInfo {
588 path,
589 branch,
590 commit,
591 is_main,
592 is_bare: current_is_bare,
593 is_detached: current_is_detached,
594 is_prunable: current_is_prunable,
595 is_locked: current_is_locked,
596 });
597 }
598
599 current_path = Some(PathBuf::from(line.strip_prefix("worktree ").unwrap()));
601 current_head = None;
602 current_branch = None;
603 current_is_bare = false;
604 current_is_detached = false;
605 current_is_prunable = false;
606 current_is_locked = false;
607 } else if line.starts_with("HEAD ") {
608 current_head = Some(line.strip_prefix("HEAD ").unwrap().to_string());
609 } else if line.starts_with("branch ") {
610 current_branch = Some(line.strip_prefix("branch ").unwrap().to_string());
611 } else if line == "bare" {
612 current_is_bare = true;
613 } else if line == "detached" {
614 current_is_detached = true;
615 } else if line.starts_with("prunable") {
616 current_is_prunable = true;
617 } else if line.starts_with("locked") {
618 current_is_locked = true;
619 }
620 }
621
622 if let Some(path) = current_path {
624 let branch = current_branch.map(|b| {
625 if let Some(stripped) = b.strip_prefix("refs/heads/") {
626 stripped.to_string()
627 } else {
628 b
629 }
630 });
631 let commit = current_head.unwrap_or_default();
632 let is_main = worktrees.is_empty();
633 worktrees.push(WorktreeInfo {
634 path,
635 branch,
636 commit,
637 is_main,
638 is_bare: current_is_bare,
639 is_detached: current_is_detached,
640 is_prunable: current_is_prunable,
641 is_locked: current_is_locked,
642 });
643 }
644
645 Ok(worktrees)
646 }
647
648 fn extract_conflicts(merge_output: &str) -> Vec<String> {
650 let mut conflicts = Vec::new();
651 for line in merge_output.lines() {
652 let trimmed = line.trim();
653 if trimmed.starts_with("CONFLICT") || trimmed.contains("Merge conflict") {
654 if let Some(pos) = trimmed.rfind(" in ") {
657 conflicts.push(trimmed[pos + 4..].trim().to_string());
658 } else if let Some(pos) = trimmed.rfind(": ") {
659 conflicts.push(trimmed[pos + 2..].trim().to_string());
660 }
661 }
662 }
663 conflicts
664 }
665
666 async fn run_git(&self, args: &[&str]) -> Result<()> {
670 let output = Command::new("git")
671 .args(args)
672 .current_dir(&self.repo_root)
673 .output()
674 .await
675 .with_context(|| format!("Failed to execute `git {}`", args.join(" ")))?;
676
677 if !output.status.success() {
678 let stderr = String::from_utf8_lossy(&output.stderr);
679 bail!(
680 "`git {}` failed (exit {}): {}",
681 args.join(" "),
682 output.status.code().unwrap_or(-1),
683 stderr.trim()
684 );
685 }
686
687 Ok(())
688 }
689
690 async fn run_git_output(&self, args: &[&str]) -> Result<String> {
692 let output = Command::new("git")
693 .args(args)
694 .current_dir(&self.repo_root)
695 .output()
696 .await
697 .with_context(|| format!("Failed to execute `git {}`", args.join(" ")))?;
698
699 if !output.status.success() {
700 let stderr = String::from_utf8_lossy(&output.stderr);
701 bail!(
702 "`git {}` failed (exit {}): {}",
703 args.join(" "),
704 output.status.code().unwrap_or(-1),
705 stderr.trim()
706 );
707 }
708
709 Ok(String::from_utf8_lossy(&output.stdout).to_string())
710 }
711
712 async fn run_git_output_at(&self, cwd: &Path, args: &[&str]) -> Result<String> {
714 let output = Command::new("git")
715 .args(args)
716 .current_dir(cwd)
717 .output()
718 .await
719 .with_context(|| {
720 format!(
721 "Failed to execute `git {}` in {}",
722 args.join(" "),
723 cwd.display()
724 )
725 })?;
726
727 if !output.status.success() {
728 let stderr = String::from_utf8_lossy(&output.stderr);
729 bail!(
730 "`git {}` failed (exit {}): {}",
731 args.join(" "),
732 output.status.code().unwrap_or(-1),
733 stderr.trim()
734 );
735 }
736
737 Ok(String::from_utf8_lossy(&output.stdout).to_string())
738 }
739}
740
741pub fn skill_instructions() -> String {
748 r#"# Git Worktree Skill
749
750You are now operating in **worktree mode**. Your goal is to manage git worktrees
751for parallel feature development, hotfixes, and branch isolation.
752
753## Capabilities
754
755### 1. Create Worktrees
756- Create linked worktrees for parallel feature branches
757- Create hotfix worktrees based on specific commits or tags
758- Create detached worktrees for temporary exploration
759- Automatic sibling-directory naming from branch names
760
761### 2. List Worktrees
762- List all worktrees with branch, commit, and status info
763- Identify main vs linked worktrees
764- Detect prunable (stale) and locked worktrees
765- Find worktrees by branch name or path
766
767### 3. Merge Worktrees
768- Merge a worktree's branch into a target branch (e.g., main)
769- Detect and report merge conflicts
770- Fast-forward and merge-commit strategies
771- Merge-and-remove workflow for completed features
772
773### 4. Clean Up Worktrees
774- Remove individual worktrees (with dirty-check protection)
775- Prune stale worktree metadata
776- Remove worktree and delete its branch in one step
777- Force removal for unmerged branches
778
779### 5. Worktree Branching
780- Create branch + worktree in a single operation
781- Feature and hotfix presets with sensible defaults
782- Branch-existence checking before creation
783
784## Workflow
785
786### Parallel Feature Development
787```bash
788# Create worktrees for two independent features
789git worktree add -b feat/auth ../auth-worktree
790git worktree add -b feat/dashboard ../dashboard-worktree
791
792# Work in each directory independently
793cd ../auth-worktree && git commit -am "Add auth module"
794cd ../dashboard-worktree && git commit -am "Add dashboard layout"
795
796# Merge when ready
797git checkout main && git merge feat/auth
798git checkout main && git merge feat/dashboard
799
800# Clean up
801git worktree remove ../auth-worktree && git branch -d feat/auth
802git worktree remove ../dashboard-worktree && git branch -d feat/dashboard
803```
804
805### Hotfix Workflow
806```bash
807# Create a hotfix worktree based on a tag
808git worktree add -b hotfix/fix-crash ../hotfix-crash v1.2.0
809
810# Fix the issue
811cd ../hotfix-crash && git commit -am "Fix null pointer crash"
812
813# Merge back to main and tag
814git checkout main && git merge hotfix/fix-crash
815git worktree remove ../hotfix-crash && git branch -d hotfix/fix-crash
816```
817
818## Guidelines
819
820- **Always clean up** — remove worktrees and delete branches after merging
821- **Check for dirty state** — don't remove worktrees with uncommitted changes
822- **Use descriptive branch names** — `feat/`, `fix/`, `hotfix/`, `refactor/` prefixes
823- **One feature per worktree** — keep concerns isolated
824- **Prune regularly** — run `git worktree prune` to clean up stale metadata
825- **Avoid nested worktrees** — place worktrees as sibling directories, not children
826- **Resolve conflicts before removing** — merge conflicts block cleanup
827
828## Common Commands
829
830```bash
831# List all worktrees
832git worktree list
833
834# Create a feature worktree
835git worktree add -b feat/new-feature ../new-feature
836
837# Create from specific commit
838git worktree add -b hotfix/urgent ../urgent abc123
839
840# Check status of a worktree
841cd ../worktree-dir && git status
842
843# Remove a clean worktree
844git worktree remove ../worktree-dir
845
846# Force remove (even if dirty)
847git worktree remove --force ../worktree-dir
848
849# Prune deleted worktrees
850git worktree prune
851```
852"#
853 .to_string()
854}
855
856#[cfg(test)]
859mod tests {
860 use super::*;
861
862 #[test]
865 fn test_commit_short() {
866 let info = WorktreeInfo {
867 path: PathBuf::from("/repo"),
868 branch: Some("main".to_string()),
869 commit: "abc123def456789".to_string(),
870 is_main: true,
871 is_bare: false,
872 is_detached: false,
873 is_prunable: false,
874 is_locked: false,
875 };
876 assert_eq!(info.commit_short(), "abc123d");
877 }
878
879 #[test]
880 fn test_branch_display() {
881 let mut info = WorktreeInfo {
882 path: PathBuf::from("/repo"),
883 branch: Some("feat/auth".to_string()),
884 commit: "abc123def456789".to_string(),
885 is_main: false,
886 is_bare: false,
887 is_detached: false,
888 is_prunable: false,
889 is_locked: false,
890 };
891
892 assert_eq!(info.branch(), "feat/auth");
893
894 info.branch = None;
896 info.is_detached = true;
897 assert!(info.branch().contains("detached"));
898 assert!(info.branch().contains("abc123d"));
899 }
900
901 #[test]
902 fn test_dir_name() {
903 let info = WorktreeInfo {
904 path: PathBuf::from("/projects/auth-worktree"),
905 branch: Some("feat/auth".to_string()),
906 commit: "abc123".to_string(),
907 is_main: false,
908 is_bare: false,
909 is_detached: false,
910 is_prunable: false,
911 is_locked: false,
912 };
913 assert_eq!(info.dir_name(), "auth-worktree");
914 }
915
916 #[test]
917 fn test_display() {
918 let info = WorktreeInfo {
919 path: PathBuf::from("/repo"),
920 branch: Some("main".to_string()),
921 commit: "abc123def456789".to_string(),
922 is_main: true,
923 is_bare: false,
924 is_detached: false,
925 is_prunable: false,
926 is_locked: false,
927 };
928 let display = format!("{}", info);
929 assert!(display.contains("main"));
930 assert!(display.contains("abc123d"));
931 assert!(display.contains("[main]"));
932 assert!(!display.contains("[locked]"));
933 }
934
935 #[test]
936 fn test_display_with_flags() {
937 let info = WorktreeInfo {
938 path: PathBuf::from("/repo"),
939 branch: Some("feat".to_string()),
940 commit: "def456".to_string(),
941 is_main: false,
942 is_bare: false,
943 is_detached: false,
944 is_prunable: true,
945 is_locked: true,
946 };
947 let display = format!("{}", info);
948 assert!(display.contains("[prunable]"));
949 assert!(display.contains("[locked]"));
950 }
951
952 #[test]
955 fn test_feature_branch_opts() {
956 let repo_root = PathBuf::from("/projects/myapp");
957 let opts = WorktreeCreateOpts::feature_branch("feat/auth", &repo_root);
958 assert_eq!(opts.branch, "feat/auth");
959 assert_eq!(opts.path, PathBuf::from("/projects/feat-auth"));
960 assert!(opts.start_point.is_none());
961 assert!(!opts.force_branch);
962 assert!(!opts.detach);
963 }
964
965 #[test]
966 fn test_hotfix_opts() {
967 let repo_root = PathBuf::from("/projects/myapp");
968 let opts = WorktreeCreateOpts::hotfix("hotfix/crash", "v1.2.0", &repo_root);
969 assert_eq!(opts.branch, "hotfix/crash");
970 assert_eq!(opts.path, PathBuf::from("/projects/hotfix-crash"));
971 assert_eq!(opts.start_point, Some("v1.2.0".to_string()));
972 }
973
974 #[test]
975 fn test_create_opts_serde_roundtrip() {
976 let opts = WorktreeCreateOpts {
977 branch: "feat/x".to_string(),
978 path: PathBuf::from("/tmp/wt"),
979 start_point: Some("main".to_string()),
980 force_branch: true,
981 detach: false,
982 };
983
984 let json = serde_json::to_string(&opts).unwrap();
985 let parsed: WorktreeCreateOpts = serde_json::from_str(&json).unwrap();
986 assert_eq!(parsed.branch, "feat/x");
987 assert_eq!(parsed.path, PathBuf::from("/tmp/wt"));
988 assert_eq!(parsed.start_point, Some("main".to_string()));
989 assert!(parsed.force_branch);
990 assert!(!parsed.detach);
991 }
992
993 #[test]
996 fn test_parse_worktree_list_single() {
997 let porcelain = "worktree /home/user/project\nHEAD abc123def456789012345678901234567890abcd\nbranch refs/heads/main\n";
998 let worktrees = WorktreeManager::parse_worktree_list(porcelain).unwrap();
999 assert_eq!(worktrees.len(), 1);
1000 assert_eq!(worktrees[0].path, PathBuf::from("/home/user/project"));
1001 assert_eq!(worktrees[0].branch, Some("main".to_string()));
1002 assert_eq!(worktrees[0].commit, "abc123def456789012345678901234567890abcd");
1003 assert!(worktrees[0].is_main);
1004 assert!(!worktrees[0].is_detached);
1005 }
1006
1007 #[test]
1008 fn test_parse_worktree_list_multiple() {
1009 let porcelain = "\
1010worktree /home/user/project
1011HEAD aaa111
1012branch refs/heads/main
1013
1014worktree /home/user/feat-auth
1015HEAD bbb222
1016branch refs/heads/feat/auth
1017
1018";
1019 let worktrees = WorktreeManager::parse_worktree_list(porcelain).unwrap();
1020 assert_eq!(worktrees.len(), 2);
1021
1022 assert!(worktrees[0].is_main);
1023 assert_eq!(worktrees[0].branch, Some("main".to_string()));
1024
1025 assert!(!worktrees[1].is_main);
1026 assert_eq!(worktrees[1].branch, Some("feat/auth".to_string()));
1027 assert_eq!(worktrees[1].path, PathBuf::from("/home/user/feat-auth"));
1028 }
1029
1030 #[test]
1031 fn test_parse_worktree_detached() {
1032 let porcelain = "\
1033worktree /home/user/project
1034HEAD abc123
1035branch refs/heads/main
1036
1037worktree /home/user/explore
1038HEAD def456
1039detached
1040
1041";
1042 let worktrees = WorktreeManager::parse_worktree_list(porcelain).unwrap();
1043 assert_eq!(worktrees.len(), 2);
1044 assert!(worktrees[1].is_detached);
1045 assert!(worktrees[1].branch.is_none());
1046 }
1047
1048 #[test]
1049 fn test_parse_worktree_bare() {
1050 let porcelain = "worktree /home/user/repo.git\nHEAD abc123\nbare\n";
1051 let worktrees = WorktreeManager::parse_worktree_list(porcelain).unwrap();
1052 assert_eq!(worktrees.len(), 1);
1053 assert!(worktrees[0].is_bare);
1054 }
1055
1056 #[test]
1057 fn test_parse_worktree_prunable() {
1058 let porcelain = "\
1059worktree /home/user/project
1060HEAD abc123
1061branch refs/heads/main
1062
1063worktree /home/user/deleted-dir
1064HEAD def456
1065branch refs/heads/orphan
1066prunable gitdir pointing to nowhere
1067
1068";
1069 let worktrees = WorktreeManager::parse_worktree_list(porcelain).unwrap();
1070 assert_eq!(worktrees.len(), 2);
1071 assert!(worktrees[1].is_prunable);
1072 }
1073
1074 #[test]
1075 fn test_parse_worktree_locked() {
1076 let porcelain = "\
1077worktree /home/user/project
1078HEAD abc123
1079branch refs/heads/main
1080
1081worktree /home/user/locked-wt
1082HEAD def456
1083branch refs/heads/locked-branch
1084locked
1085
1086";
1087 let worktrees = WorktreeManager::parse_worktree_list(porcelain).unwrap();
1088 assert!(worktrees[1].is_locked);
1089 }
1090
1091 #[test]
1092 fn test_parse_empty_list() {
1093 let porcelain = "";
1094 let worktrees = WorktreeManager::parse_worktree_list(porcelain).unwrap();
1095 assert!(worktrees.is_empty());
1096 }
1097
1098 #[test]
1101 fn test_worktree_list_queries() {
1102 let list = WorktreeList {
1103 worktrees: vec![
1104 WorktreeInfo {
1105 path: PathBuf::from("/main"),
1106 branch: Some("main".to_string()),
1107 commit: "aaa".to_string(),
1108 is_main: true,
1109 is_bare: false,
1110 is_detached: false,
1111 is_prunable: false,
1112 is_locked: false,
1113 },
1114 WorktreeInfo {
1115 path: PathBuf::from("/feat"),
1116 branch: Some("feat/x".to_string()),
1117 commit: "bbb".to_string(),
1118 is_main: false,
1119 is_bare: false,
1120 is_detached: false,
1121 is_prunable: false,
1122 is_locked: false,
1123 },
1124 ],
1125 repo_root: PathBuf::from("/main"),
1126 };
1127
1128 assert_eq!(list.len(), 2);
1129 assert!(list.main().is_some());
1130 assert_eq!(list.linked().len(), 1);
1131 assert!(list.by_branch("feat/x").is_some());
1132 assert!(list.by_branch("nonexistent").is_none());
1133 assert!(list.by_path(&PathBuf::from("/feat")).is_some());
1134 }
1135
1136 #[test]
1139 fn test_extract_conflicts() {
1140 let output = "\
1141Auto-merging src/main.rs
1142CONFLICT (content): Merge conflict in src/main.rs
1143Auto-merging src/lib.rs
1144CONFLICT (content): Merge conflict in src/lib.rs
1145Automatic merge failed; fix conflicts and then commit the result.
1146";
1147 let conflicts = WorktreeManager::extract_conflicts(output);
1148 assert_eq!(conflicts.len(), 2);
1149 assert!(conflicts.contains(&"src/main.rs".to_string()));
1150 assert!(conflicts.contains(&"src/lib.rs".to_string()));
1151 }
1152
1153 #[test]
1154 fn test_extract_conflicts_empty() {
1155 let output = "Merge made by the 'ort' strategy.\n src/main.rs | 2 +-";
1156 let conflicts = WorktreeManager::extract_conflicts(output);
1157 assert!(conflicts.is_empty());
1158 }
1159
1160 #[test]
1163 fn test_merge_result_serde() {
1164 let result = MergeResult {
1165 source_branch: "feat/auth".to_string(),
1166 target_branch: "main".to_string(),
1167 was_merge_commit: true,
1168 merge_commit: Some("abc123".to_string()),
1169 had_conflicts: false,
1170 conflicts: vec![],
1171 };
1172
1173 let json = serde_json::to_string(&result).unwrap();
1174 let parsed: MergeResult = serde_json::from_str(&json).unwrap();
1175 assert_eq!(parsed.source_branch, "feat/auth");
1176 assert!(parsed.was_merge_commit);
1177 assert!(!parsed.had_conflicts);
1178 }
1179
1180 #[test]
1183 fn test_skill_instructions_not_empty() {
1184 let instructions = skill_instructions();
1185 assert!(!instructions.is_empty());
1186 assert!(instructions.contains("worktree"));
1187 assert!(instructions.contains("Create"));
1188 assert!(instructions.contains("Merge"));
1189 assert!(instructions.contains("Clean Up"));
1190 }
1191
1192 #[tokio::test]
1195 async fn test_manager_for_current_repo() {
1196 let result = WorktreeManager::for_current_repo();
1198 assert!(result.is_ok());
1200 let mgr = result.unwrap();
1201 assert!(mgr.repo_root().exists());
1202 }
1203
1204 #[tokio::test]
1205 async fn test_list_worktrees() {
1206 let mgr = WorktreeManager::for_current_repo().unwrap();
1207 let list = mgr.list().await.unwrap();
1208 assert!(!list.is_empty());
1210 assert!(list.main().is_some());
1211 }
1212}