1use crate::error::{Autom8Error, Result};
7use sha2::{Digest, Sha256};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct WorktreeInfo {
14 pub path: PathBuf,
16 pub branch: Option<String>,
18 pub commit: String,
20 pub is_main: bool,
22 pub is_bare: bool,
24 pub is_locked: bool,
26 pub is_prunable: bool,
28}
29
30impl WorktreeInfo {
31 fn from_porcelain_lines(lines: &[&str]) -> Option<Self> {
36 let mut path: Option<PathBuf> = None;
37 let mut branch: Option<String> = None;
38 let mut commit: Option<String> = None;
39 let mut is_bare = false;
40 let mut is_locked = false;
41 let mut is_prunable = false;
42
43 for line in lines {
44 if let Some(rest) = line.strip_prefix("worktree ") {
45 path = Some(PathBuf::from(rest));
46 } else if let Some(rest) = line.strip_prefix("HEAD ") {
47 commit = Some(rest.to_string());
48 } else if let Some(rest) = line.strip_prefix("branch ") {
49 let branch_name = rest.strip_prefix("refs/heads/").unwrap_or(rest).to_string();
51 branch = Some(branch_name);
52 } else if *line == "bare" {
53 is_bare = true;
54 } else if *line == "detached" {
55 } else if line.starts_with("locked") {
57 is_locked = true;
58 } else if line.starts_with("prunable") {
59 is_prunable = true;
60 }
61 }
62
63 let path = path?;
64 let commit = commit?;
65
66 Some(WorktreeInfo {
69 path,
70 branch,
71 commit,
72 is_main: false,
73 is_bare,
74 is_locked,
75 is_prunable,
76 })
77 }
78}
79
80pub fn list_worktrees() -> Result<Vec<WorktreeInfo>> {
89 let output = Command::new("git")
90 .args(["worktree", "list", "--porcelain"])
91 .output()?;
92
93 if !output.status.success() {
94 let stderr = String::from_utf8_lossy(&output.stderr);
95 return Err(Autom8Error::WorktreeError(format!(
96 "Failed to list worktrees: {}",
97 stderr.trim()
98 )));
99 }
100
101 let stdout = String::from_utf8_lossy(&output.stdout);
102 let worktrees = parse_worktree_list_porcelain(&stdout)?;
103
104 Ok(worktrees)
105}
106
107fn parse_worktree_list_porcelain(output: &str) -> Result<Vec<WorktreeInfo>> {
112 let mut worktrees = Vec::new();
113 let mut current_lines: Vec<&str> = Vec::new();
114 let mut is_first = true;
115
116 for line in output.lines() {
117 if line.is_empty() {
118 if !current_lines.is_empty() {
120 if let Some(mut wt) = WorktreeInfo::from_porcelain_lines(¤t_lines) {
121 wt.is_main = is_first;
123 is_first = false;
124 worktrees.push(wt);
125 }
126 current_lines.clear();
127 }
128 } else {
129 current_lines.push(line);
130 }
131 }
132
133 if !current_lines.is_empty() {
135 if let Some(mut wt) = WorktreeInfo::from_porcelain_lines(¤t_lines) {
136 wt.is_main = is_first;
137 worktrees.push(wt);
138 }
139 }
140
141 Ok(worktrees)
142}
143
144pub fn create_worktree<P: AsRef<Path>>(path: P, branch: &str) -> Result<()> {
157 let path = path.as_ref();
158
159 let branch_exists = Command::new("git")
161 .args([
162 "show-ref",
163 "--verify",
164 "--quiet",
165 &format!("refs/heads/{}", branch),
166 ])
167 .output()?
168 .status
169 .success();
170
171 let output = if branch_exists {
172 Command::new("git")
174 .args(["worktree", "add", path.to_string_lossy().as_ref(), branch])
175 .output()?
176 } else {
177 Command::new("git")
179 .args([
180 "worktree",
181 "add",
182 "-b",
183 branch,
184 path.to_string_lossy().as_ref(),
185 ])
186 .output()?
187 };
188
189 if !output.status.success() {
190 let stderr = String::from_utf8_lossy(&output.stderr);
191 return Err(Autom8Error::WorktreeError(format!(
192 "Failed to create worktree at '{}' for branch '{}': {}",
193 path.display(),
194 branch,
195 stderr.trim()
196 )));
197 }
198
199 Ok(())
200}
201
202pub fn remove_worktree<P: AsRef<Path>>(path: P, force: bool) -> Result<()> {
215 let path = path.as_ref();
216 let path_str = path.to_string_lossy();
217
218 let mut args = vec!["worktree", "remove"];
219 if force {
220 args.push("--force");
221 }
222 args.push(path_str.as_ref());
223
224 let output = Command::new("git").args(&args).output()?;
225
226 if !output.status.success() {
227 let stderr = String::from_utf8_lossy(&output.stderr);
228 return Err(Autom8Error::WorktreeError(format!(
229 "Failed to remove worktree at '{}': {}",
230 path.display(),
231 stderr.trim()
232 )));
233 }
234
235 Ok(())
236}
237
238pub fn get_worktree_root() -> Result<Option<PathBuf>> {
248 let git_dir_output = Command::new("git")
253 .args(["rev-parse", "--git-dir"])
254 .output()?;
255
256 if !git_dir_output.status.success() {
257 let stderr = String::from_utf8_lossy(&git_dir_output.stderr);
258 return Err(Autom8Error::WorktreeError(format!(
259 "Failed to get git directory: {}",
260 stderr.trim()
261 )));
262 }
263
264 let git_dir = String::from_utf8_lossy(&git_dir_output.stdout)
265 .trim()
266 .to_string();
267
268 if git_dir.contains("/worktrees/") || git_dir.contains("\\worktrees\\") {
271 let toplevel_output = Command::new("git")
273 .args(["rev-parse", "--show-toplevel"])
274 .output()?;
275
276 if !toplevel_output.status.success() {
277 let stderr = String::from_utf8_lossy(&toplevel_output.stderr);
278 return Err(Autom8Error::WorktreeError(format!(
279 "Failed to get worktree root: {}",
280 stderr.trim()
281 )));
282 }
283
284 let toplevel = String::from_utf8_lossy(&toplevel_output.stdout)
285 .trim()
286 .to_string();
287 return Ok(Some(PathBuf::from(toplevel)));
288 }
289
290 Ok(None)
291}
292
293pub fn get_main_repo_root() -> Result<PathBuf> {
302 let output = Command::new("git")
304 .args(["rev-parse", "--git-common-dir"])
305 .output()?;
306
307 if !output.status.success() {
308 let stderr = String::from_utf8_lossy(&output.stderr);
309 return Err(Autom8Error::WorktreeError(format!(
310 "Failed to get main repo root: {}",
311 stderr.trim()
312 )));
313 }
314
315 let git_common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
316
317 let git_path = PathBuf::from(&git_common_dir);
319
320 let main_repo_path = if git_path.is_absolute() {
323 git_path.parent().map(|p| p.to_path_buf())
324 } else {
325 let current_dir = std::env::current_dir()?;
327 let absolute_git = current_dir.join(&git_path);
328 absolute_git
329 .canonicalize()
330 .ok()
331 .and_then(|p| p.parent().map(|p| p.to_path_buf()))
332 };
333
334 main_repo_path.ok_or_else(|| {
335 Autom8Error::WorktreeError("Failed to determine main repository root".to_string())
336 })
337}
338
339pub fn is_in_worktree() -> Result<bool> {
348 Ok(get_worktree_root()?.is_some())
349}
350
351pub fn get_git_repo_name() -> Result<Option<String>> {
372 let output = Command::new("git")
374 .args(["rev-parse", "--git-common-dir"])
375 .output()?;
376
377 if !output.status.success() {
378 let stderr = String::from_utf8_lossy(&output.stderr);
380 if stderr.contains("not a git repository") {
381 return Ok(None);
382 }
383 return Err(Autom8Error::WorktreeError(format!(
384 "Failed to check git repository: {}",
385 stderr.trim()
386 )));
387 }
388
389 let main_root = get_main_repo_root()?;
391
392 main_root
394 .file_name()
395 .and_then(|n| n.to_str())
396 .map(|s| Some(s.to_string()))
397 .ok_or_else(|| {
398 Autom8Error::WorktreeError("Could not determine repository name from path".to_string())
399 })
400}
401
402pub const MAIN_SESSION_ID: &str = "main";
408
409pub fn generate_session_id(worktree_path: &Path) -> String {
434 let path_str = worktree_path.to_string_lossy();
435 let mut hasher = Sha256::new();
436 hasher.update(path_str.as_bytes());
437 let result = hasher.finalize();
438 hex::encode(&result[..4])
440}
441
442pub fn get_current_session_id() -> Result<String> {
461 if let Some(worktree_root) = get_worktree_root()? {
463 Ok(generate_session_id(&worktree_root))
465 } else {
466 Ok(MAIN_SESSION_ID.to_string())
468 }
469}
470
471pub fn get_main_session_id() -> String {
481 MAIN_SESSION_ID.to_string()
482}
483
484pub fn get_session_id_for_path(path: &Path) -> Result<String> {
497 let abs_path = if path.is_absolute() {
499 path.to_path_buf()
500 } else {
501 std::env::current_dir()?.join(path)
502 };
503
504 let main_root = get_main_repo_root()?;
506
507 let abs_canonical = abs_path.canonicalize().unwrap_or(abs_path);
509 let main_canonical = main_root.canonicalize().unwrap_or(main_root);
510
511 if abs_canonical == main_canonical {
513 Ok(MAIN_SESSION_ID.to_string())
514 } else {
515 Ok(generate_session_id(&abs_canonical))
516 }
517}
518
519pub fn slugify_branch_name(branch_name: &str) -> String {
542 branch_name
543 .chars()
544 .map(|c| {
545 if c == '/' || c == '\\' {
546 '-'
547 } else if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
548 c
549 } else {
550 '-'
551 }
552 })
553 .collect::<String>()
554 .split('-')
556 .filter(|s| !s.is_empty())
557 .collect::<Vec<_>>()
558 .join("-")
559}
560
561pub fn generate_worktree_name(pattern: &str, repo_name: &str, branch_name: &str) -> String {
585 let slugified_branch = slugify_branch_name(branch_name);
586 pattern
587 .replace("{repo}", repo_name)
588 .replace("{branch}", &slugified_branch)
589}
590
591pub fn generate_worktree_path(pattern: &str, branch_name: &str) -> Result<PathBuf> {
612 let main_repo = get_main_repo_root()?;
613 let repo_name = main_repo
614 .file_name()
615 .and_then(|n| n.to_str())
616 .ok_or_else(|| {
617 Autom8Error::WorktreeError("Could not determine repository name".to_string())
618 })?;
619
620 let worktree_name = generate_worktree_name(pattern, repo_name, branch_name);
621
622 let parent = main_repo.parent().ok_or_else(|| {
624 Autom8Error::WorktreeError("Could not determine repository parent directory".to_string())
625 })?;
626
627 Ok(parent.join(worktree_name))
628}
629
630#[derive(Debug, Clone, PartialEq, Eq)]
632pub enum WorktreeResult {
633 Created(PathBuf),
635 Reused(PathBuf),
637}
638
639impl WorktreeResult {
640 pub fn path(&self) -> &Path {
642 match self {
643 WorktreeResult::Created(p) | WorktreeResult::Reused(p) => p,
644 }
645 }
646
647 pub fn was_created(&self) -> bool {
649 matches!(self, WorktreeResult::Created(_))
650 }
651}
652
653pub fn ensure_worktree(pattern: &str, branch_name: &str) -> Result<WorktreeResult> {
667 let target_path = generate_worktree_path(pattern, branch_name)?;
668
669 let worktrees = list_worktrees()?;
671 for wt in &worktrees {
672 if wt.path == target_path {
674 if let Some(ref wt_branch) = wt.branch {
676 if wt_branch == branch_name {
677 return Ok(WorktreeResult::Reused(target_path));
678 }
679 }
680 return Err(Autom8Error::WorktreeError(format!(
682 "Worktree at '{}' exists but uses branch '{}', not '{}'",
683 target_path.display(),
684 wt.branch.as_deref().unwrap_or("(detached)"),
685 branch_name
686 )));
687 }
688
689 if let Some(ref wt_branch) = wt.branch {
691 if wt_branch == branch_name && !wt.is_main {
692 return Ok(WorktreeResult::Reused(wt.path.clone()));
694 }
695 }
696 }
697
698 create_worktree(&target_path, branch_name)?;
700 Ok(WorktreeResult::Created(target_path))
701}
702
703pub fn format_worktree_error(error: &str, branch_name: &str, worktree_path: &Path) -> String {
716 let mut message = format!(
717 "Failed to create worktree for branch '{}' at '{}'.\n\n",
718 branch_name,
719 worktree_path.display()
720 );
721
722 if error.contains("already checked out") {
724 message.push_str("Reason: Branch is already checked out in another worktree.\n\n");
725 message.push_str("To resolve this, try one of the following:\n");
726 message.push_str(" 1. Use a different branch name in your spec\n");
727 message.push_str(" 2. Run `git worktree list` to see existing worktrees\n");
728 message
729 .push_str(" 3. Remove the conflicting worktree with `git worktree remove <path>`\n");
730 message.push_str("\nManual worktree creation steps:\n");
731 message.push_str(&format!(
732 " git worktree add -b {} '{}'\n",
733 branch_name,
734 worktree_path.display()
735 ));
736 } else if error.contains("already exists") {
737 message.push_str("Reason: A directory or worktree already exists at this path.\n\n");
738 message.push_str("To resolve this, try one of the following:\n");
739 message.push_str(&format!(
740 " 1. Remove the existing directory: rm -rf '{}'\n",
741 worktree_path.display()
742 ));
743 message.push_str(" 2. Use a different branch name in your spec\n");
744 message.push_str(" 3. Configure a different worktree_path_pattern in config\n");
745 message.push_str("\nManual worktree creation steps (after removing existing):\n");
746 message.push_str(&format!(
747 " git worktree add '{}' {}\n",
748 worktree_path.display(),
749 branch_name
750 ));
751 } else if error.contains("permission denied") || error.contains("Permission denied") {
752 message.push_str("Reason: Insufficient permissions to create the worktree directory.\n\n");
753 message.push_str("To resolve this, try one of the following:\n");
754 message.push_str(&format!(
755 " 1. Check write permissions on: {}\n",
756 worktree_path
757 .parent()
758 .map(|p| p.display().to_string())
759 .unwrap_or_else(|| "parent directory".to_string())
760 ));
761 message.push_str(" 2. Run with appropriate permissions (e.g., sudo if needed)\n");
762 message
763 .push_str(" 3. Choose a different location in your config's worktree_path_pattern\n");
764 } else {
765 message.push_str(&format!("Error: {}\n\n", error));
766 message.push_str("To resolve this, try one of the following:\n");
767 message.push_str(" 1. Ensure you're in a git repository\n");
768 message.push_str(" 2. Run `git worktree list` to check current worktrees\n");
769 message.push_str(" 3. Check git configuration and permissions\n");
770 message.push_str("\nManual worktree creation steps:\n");
771 message.push_str(&format!(
772 " git worktree add '{}' {}\n",
773 worktree_path.display(),
774 branch_name
775 ));
776 }
777
778 message
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784
785 #[test]
790 fn test_parse_porcelain_single_worktree() {
791 let output = "worktree /home/user/project\nHEAD abc1234567890abcdef1234567890abcdef12345678\nbranch refs/heads/main\n\n";
792
793 let worktrees = parse_worktree_list_porcelain(output).unwrap();
794 assert_eq!(worktrees.len(), 1);
795
796 let wt = &worktrees[0];
797 assert_eq!(wt.path, PathBuf::from("/home/user/project"));
798 assert_eq!(wt.branch, Some("main".to_string()));
799 assert_eq!(wt.commit, "abc1234567890abcdef1234567890abcdef12345678");
800 assert!(wt.is_main);
801 assert!(!wt.is_bare);
802 }
803
804 #[test]
805 fn test_parse_porcelain_multiple_worktrees() {
806 let output = concat!(
807 "worktree /home/user/project\n",
808 "HEAD abc1234567890abcdef1234567890abcdef12345678\n",
809 "branch refs/heads/main\n",
810 "\n",
811 "worktree /home/user/project-feature\n",
812 "HEAD def5678901234abcdef5678901234abcdef56789012\n",
813 "branch refs/heads/feature/test\n",
814 "\n"
815 );
816
817 let worktrees = parse_worktree_list_porcelain(output).unwrap();
818 assert_eq!(worktrees.len(), 2);
819
820 assert!(worktrees[0].is_main);
821 assert_eq!(worktrees[0].branch, Some("main".to_string()));
822 assert!(!worktrees[1].is_main);
823 assert_eq!(worktrees[1].branch, Some("feature/test".to_string()));
824 }
825
826 #[test]
827 fn test_parse_porcelain_special_states() {
828 let output = "worktree /path\nHEAD abc123\ndetached\n\n";
830 let wt = &parse_worktree_list_porcelain(output).unwrap()[0];
831 assert!(wt.branch.is_none());
832
833 let output = "worktree /path.git\nHEAD abc123\nbare\n\n";
835 let wt = &parse_worktree_list_porcelain(output).unwrap()[0];
836 assert!(wt.is_bare);
837
838 let output = "worktree /path\nHEAD abc123\nbranch refs/heads/main\nlocked\n\n";
840 let wt = &parse_worktree_list_porcelain(output).unwrap()[0];
841 assert!(wt.is_locked);
842
843 let output = "worktree /path\nHEAD abc123\nbranch refs/heads/main\nprunable\n\n";
845 let wt = &parse_worktree_list_porcelain(output).unwrap()[0];
846 assert!(wt.is_prunable);
847 }
848
849 #[test]
850 fn test_parse_porcelain_edge_cases() {
851 let output = "worktree /path\nHEAD abc123\nbranch refs/heads/main";
853 assert_eq!(parse_worktree_list_porcelain(output).unwrap().len(), 1);
854
855 assert!(parse_worktree_list_porcelain("").unwrap().is_empty());
857
858 let output = "worktree /home/user/my project/repo\nHEAD abc123\nbranch refs/heads/main\n\n";
860 assert_eq!(
861 parse_worktree_list_porcelain(output).unwrap()[0].path,
862 PathBuf::from("/home/user/my project/repo")
863 );
864 }
865
866 #[test]
867 fn test_from_porcelain_lines_missing_required_fields() {
868 assert!(
870 WorktreeInfo::from_porcelain_lines(&["HEAD abc123", "branch refs/heads/main"])
871 .is_none()
872 );
873 assert!(
875 WorktreeInfo::from_porcelain_lines(&["worktree /path", "branch refs/heads/main"])
876 .is_none()
877 );
878 }
879
880 #[test]
885 fn test_generate_session_id_properties() {
886 let path = Path::new("/home/user/project-feature");
887 let id = generate_session_id(path);
888
889 assert_eq!(id.len(), 8);
891 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
892
893 assert_eq!(id, generate_session_id(path));
895
896 let id2 = generate_session_id(Path::new("/home/user/other-project"));
898 assert_ne!(id, id2);
899 }
900
901 #[test]
902 fn test_generate_session_id_uniqueness() {
903 let paths = [
904 "/home/user/project1",
905 "/home/user/project2",
906 "/tmp/worktree-a",
907 "/tmp/worktree-b",
908 ];
909
910 let ids: Vec<String> = paths
911 .iter()
912 .map(|p| generate_session_id(Path::new(p)))
913 .collect();
914
915 let unique_ids: std::collections::HashSet<_> = ids.iter().collect();
916 assert_eq!(ids.len(), unique_ids.len());
917 }
918
919 #[test]
920 fn test_main_session_id() {
921 assert_eq!(MAIN_SESSION_ID, "main");
922 assert_eq!(get_main_session_id(), "main");
923 }
924
925 #[test]
930 fn test_slugify_branch_name() {
931 assert_eq!(slugify_branch_name("feature/login"), "feature-login");
932 assert_eq!(
933 slugify_branch_name("feature/user/auth"),
934 "feature-user-auth"
935 );
936 assert_eq!(slugify_branch_name("main"), "main");
937 assert_eq!(slugify_branch_name("v1.0.0"), "v1.0.0");
938 assert_eq!(slugify_branch_name("feature//login"), "feature-login"); assert_eq!(slugify_branch_name("feature@login"), "feature-login"); }
941
942 #[test]
943 fn test_generate_worktree_name() {
944 assert_eq!(
945 generate_worktree_name("{repo}-wt-{branch}", "myproject", "feature/login"),
946 "myproject-wt-feature-login"
947 );
948 assert_eq!(
949 generate_worktree_name("{repo}_worktree_{branch}", "myproject", "main"),
950 "myproject_worktree_main"
951 );
952 }
953
954 #[test]
959 fn test_worktree_result() {
960 let path = PathBuf::from("/test/path");
961 let created = WorktreeResult::Created(path.clone());
962 let reused = WorktreeResult::Reused(path.clone());
963
964 assert_eq!(created.path(), &path);
965 assert_eq!(reused.path(), &path);
966 assert!(created.was_created());
967 assert!(!reused.was_created());
968 }
969
970 #[test]
975 fn test_format_worktree_error_messages() {
976 let msg = format_worktree_error(
978 "fatal: branch 'main' is already checked out",
979 "main",
980 Path::new("/new/worktree"),
981 );
982 assert!(msg.contains("already checked out"));
983 assert!(msg.contains("To resolve"));
984 assert!(msg.contains("git worktree"));
985
986 let msg = format_worktree_error(
988 "fatal: already exists",
989 "feature",
990 Path::new("/new/worktree"),
991 );
992 assert!(msg.contains("already exists"));
993 assert!(msg.contains("after removing existing"));
994
995 let msg = format_worktree_error(
997 "error: permission denied",
998 "feature",
999 Path::new("/restricted"),
1000 );
1001 assert!(msg.contains("permissions"));
1002
1003 let msg = format_worktree_error("unknown error", "feature/login", Path::new("/path/to/wt"));
1005 assert!(msg.contains("Manual worktree creation"));
1006 assert!(msg.contains("feature/login"));
1007 }
1008}