1use std::path::Path;
2use std::process::Command;
3
4use crate::error::WorktreeError;
5use crate::types::{GitCapabilities, GitVersion, WorktreeHandle, WorktreeState};
6
7pub fn parse_git_version(output: &str) -> Result<GitVersion, WorktreeError> {
14 let version_str = output
16 .trim()
17 .strip_prefix("git version ")
18 .ok_or_else(|| WorktreeError::GitCommandFailed {
19 command: "git --version".to_string(),
20 stderr: format!("unexpected output format: {output}"),
21 exit_code: 0,
22 })?;
23
24 let version_token = version_str.split_whitespace().next().unwrap_or(version_str);
26
27 let parts: Vec<&str> = version_token.split('.').collect();
29 if parts.len() < 3 {
30 return Err(WorktreeError::GitCommandFailed {
31 command: "git --version".to_string(),
32 stderr: format!("cannot parse version: {version_token}"),
33 exit_code: 0,
34 });
35 }
36
37 let major = parts[0].parse::<u32>().map_err(|_| WorktreeError::GitCommandFailed {
38 command: "git --version".to_string(),
39 stderr: format!("cannot parse major version: {}", parts[0]),
40 exit_code: 0,
41 })?;
42 let minor = parts[1].parse::<u32>().map_err(|_| WorktreeError::GitCommandFailed {
43 command: "git --version".to_string(),
44 stderr: format!("cannot parse minor version: {}", parts[1]),
45 exit_code: 0,
46 })?;
47 let patch = parts[2].parse::<u32>().map_err(|_| WorktreeError::GitCommandFailed {
48 command: "git --version".to_string(),
49 stderr: format!("cannot parse patch version: {}", parts[2]),
50 exit_code: 0,
51 })?;
52
53 Ok(GitVersion { major, minor, patch })
54}
55
56pub fn detect_capabilities(version: &GitVersion) -> GitCapabilities {
58 let has_repair = *version >= GitVersion::HAS_REPAIR; let has_list_nul = *version >= GitVersion::HAS_LIST_NUL; let has_merge_tree_write = *version >= GitVersion::HAS_MERGE_TREE_WRITE; let has_orphan = *version >= GitVersion { major: 2, minor: 42, patch: 0 }; let has_relative_paths = *version >= GitVersion { major: 2, minor: 48, patch: 0 }; GitCapabilities::new(
65 version.clone(),
66 has_list_nul,
67 has_repair,
68 has_orphan,
69 has_relative_paths,
70 has_merge_tree_write,
71 )
72}
73
74pub fn detect_git_version() -> Result<GitCapabilities, WorktreeError> {
77 let output = Command::new("git")
78 .arg("--version")
79 .output()
80 .map_err(|_| WorktreeError::GitNotFound)?;
81
82 if !output.status.success() {
83 return Err(WorktreeError::GitNotFound);
84 }
85
86 let stdout = String::from_utf8_lossy(&output.stdout);
87 let version = parse_git_version(&stdout)?;
88
89 if version < GitVersion::MINIMUM {
90 return Err(WorktreeError::GitVersionTooOld {
91 required: format!(
92 "{}.{}.{}",
93 GitVersion::MINIMUM.major,
94 GitVersion::MINIMUM.minor,
95 GitVersion::MINIMUM.patch
96 ),
97 found: format!("{}.{}.{}", version.major, version.minor, version.patch),
98 });
99 }
100
101 Ok(detect_capabilities(&version))
102}
103
104pub fn parse_worktree_list_porcelain(
114 output: &[u8],
115 nul_delimited: bool,
116) -> Result<Vec<WorktreeHandle>, WorktreeError> {
117 let block_sep: &[u8] = if nul_delimited { b"\0\0" } else { b"\n\n" };
119 let field_sep: u8 = if nul_delimited { 0 } else { b'\n' };
121
122 let mut handles = Vec::new();
123 for block in split_bytes(output, block_sep) {
124 let block = trim_block(block);
126 if block.is_empty() {
127 continue;
128 }
129
130 let mut path: Option<std::path::PathBuf> = None;
131 let mut head_sha = String::new();
132 let mut branch = String::new();
133 let mut is_bare = false;
134 let mut is_detached = false;
135 let mut is_locked = false;
136 let mut is_prunable = false;
137
138 for field in block.split(|b| *b == field_sep) {
139 let field = trim_field(field);
140 if field.is_empty() {
141 continue;
142 }
143
144 if let Some(p) = strip_prefix_bytes(field, b"worktree ") {
145 if !nul_delimited && p.contains(&b'\n') {
146 eprintln!(
147 "WARNING: Worktree path may contain newlines — upgrade to git 2.36 for safe parsing"
148 );
149 }
150 path = Some(path_from_bytes(p));
151 } else if let Some(sha) = strip_prefix_bytes(field, b"HEAD ") {
152 head_sha = String::from_utf8_lossy(sha).into_owned();
153 } else if let Some(b) = strip_prefix_bytes(field, b"branch ") {
154 let s = String::from_utf8_lossy(b);
155 branch = s
156 .strip_prefix("refs/heads/")
157 .unwrap_or(&s)
158 .to_string();
159 } else if field == b"detached" {
160 is_detached = true;
161 } else if field == b"bare" {
162 is_bare = true;
163 } else if field == b"locked" || strip_prefix_bytes(field, b"locked ").is_some() {
164 is_locked = true;
165 } else if field == b"prunable" || strip_prefix_bytes(field, b"prunable ").is_some() {
166 is_prunable = true;
167 }
168 }
169
170 let Some(wt_path) = path else {
171 continue;
172 };
173
174 let state = if is_locked {
176 WorktreeState::Locked
177 } else if is_prunable {
178 WorktreeState::Orphaned
179 } else {
180 WorktreeState::Active
181 };
182
183 if is_bare || is_detached {
184 branch = String::new();
185 }
186
187 handles.push(WorktreeHandle::new(
188 wt_path,
189 branch,
190 head_sha,
191 state,
192 String::new(), 0, String::new(), None, false, None, String::new(), ));
200 }
201
202 Ok(handles)
203}
204
205fn path_from_bytes(b: &[u8]) -> std::path::PathBuf {
210 #[cfg(unix)]
211 {
212 use std::os::unix::ffi::OsStrExt;
213 std::path::PathBuf::from(std::ffi::OsStr::from_bytes(b))
214 }
215 #[cfg(not(unix))]
216 {
217 std::path::PathBuf::from(String::from_utf8_lossy(b).into_owned())
218 }
219}
220
221fn strip_prefix_bytes<'a>(s: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
222 if s.len() >= prefix.len() && &s[..prefix.len()] == prefix {
223 Some(&s[prefix.len()..])
224 } else {
225 None
226 }
227}
228
229fn trim_field(b: &[u8]) -> &[u8] {
230 let mut start = 0;
231 while start < b.len() && (b[start] == b' ' || b[start] == b'\t' || b[start] == b'\r') {
232 start += 1;
233 }
234 let mut end = b.len();
235 while end > start && (b[end - 1] == b' ' || b[end - 1] == b'\t' || b[end - 1] == b'\r') {
236 end -= 1;
237 }
238 &b[start..end]
239}
240
241fn trim_block(b: &[u8]) -> &[u8] {
242 let mut start = 0;
243 while start < b.len() && matches!(b[start], 0 | b'\n' | b'\r' | b' ' | b'\t') {
244 start += 1;
245 }
246 let mut end = b.len();
247 while end > start && matches!(b[end - 1], 0 | b'\n' | b'\r' | b' ' | b'\t') {
248 end -= 1;
249 }
250 &b[start..end]
251}
252
253fn split_bytes<'a>(haystack: &'a [u8], needle: &[u8]) -> Vec<&'a [u8]> {
255 if needle.is_empty() || haystack.is_empty() {
256 return vec![haystack];
257 }
258 let mut out = Vec::new();
259 let mut i = 0;
260 let mut start = 0;
261 while i + needle.len() <= haystack.len() {
262 if &haystack[i..i + needle.len()] == needle {
263 out.push(&haystack[start..i]);
264 i += needle.len();
265 start = i;
266 } else {
267 i += 1;
268 }
269 }
270 out.push(&haystack[start..]);
271 out
272}
273
274pub fn run_worktree_list(
276 repo: &Path,
277 caps: &GitCapabilities,
278) -> Result<Vec<WorktreeHandle>, WorktreeError> {
279 let mut cmd = Command::new("git");
280 cmd.arg("worktree").arg("list").arg("--porcelain");
281 cmd.current_dir(repo);
282
283 if caps.has_list_nul {
284 cmd.arg("-z");
285 }
286
287 let output = cmd.output().map_err(|_| WorktreeError::GitNotFound)?;
288
289 if !output.status.success() {
290 return Err(WorktreeError::GitCommandFailed {
291 command: format!("git worktree list --porcelain{}", if caps.has_list_nul { " -z" } else { "" }),
292 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
293 exit_code: output.status.code().unwrap_or(-1),
294 });
295 }
296
297 parse_worktree_list_porcelain(&output.stdout, caps.has_list_nul)
298}
299
300pub fn resolve_ref(repo: &Path, refspec: &str) -> Result<String, WorktreeError> {
302 let output = Command::new("git")
303 .args(["rev-parse", refspec])
304 .current_dir(repo)
305 .output()
306 .map_err(|_| WorktreeError::GitNotFound)?;
307
308 if !output.status.success() {
309 return Err(WorktreeError::GitCommandFailed {
310 command: format!("git rev-parse {refspec}"),
311 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
312 exit_code: output.status.code().unwrap_or(-1),
313 });
314 }
315
316 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
317}
318
319pub fn branch_exists(repo: &Path, branch: &str) -> Result<bool, WorktreeError> {
321 let output = Command::new("git")
322 .args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
323 .current_dir(repo)
324 .output()
325 .map_err(|_| WorktreeError::GitNotFound)?;
326
327 Ok(output.status.success())
328}
329
330pub fn worktree_add(
333 repo: &Path,
334 path: &Path,
335 branch: &str,
336 base: Option<&str>,
337 new_branch: bool,
338 lock: bool,
339 lock_reason: Option<&str>,
340) -> Result<(), WorktreeError> {
341 let mut cmd = Command::new("git");
342 cmd.arg("worktree").arg("add");
343 cmd.current_dir(repo);
344
345 if lock {
346 cmd.arg("--lock");
347 if let Some(reason) = lock_reason {
348 cmd.arg("--reason").arg(reason);
349 }
350 }
351
352 cmd.arg(path);
353
354 if new_branch {
355 cmd.arg("-b").arg(branch);
356 if let Some(base_ref) = base {
357 cmd.arg(base_ref);
358 }
359 } else {
360 cmd.arg(branch);
361 }
362
363 let output = cmd.output().map_err(|_| WorktreeError::GitNotFound)?;
364
365 if !output.status.success() {
366 return Err(WorktreeError::GitCommandFailed {
367 command: format!("git worktree add {}", path.display()),
368 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
369 exit_code: output.status.code().unwrap_or(-1),
370 });
371 }
372
373 Ok(())
374}
375
376pub fn worktree_remove_force(repo: &Path, path: &Path) -> Result<(), WorktreeError> {
378 let output = Command::new("git")
379 .args(["worktree", "remove", "--force"])
380 .arg(path)
381 .current_dir(repo)
382 .output()
383 .map_err(|_| WorktreeError::GitNotFound)?;
384
385 if !output.status.success() {
386 return Err(WorktreeError::GitCommandFailed {
387 command: format!("git worktree remove --force {}", path.display()),
388 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
389 exit_code: output.status.code().unwrap_or(-1),
390 });
391 }
392
393 Ok(())
394}
395
396pub fn worktree_remove(repo: &Path, path: &Path) -> Result<(), WorktreeError> {
398 let output = Command::new("git")
399 .args(["worktree", "remove"])
400 .arg(path)
401 .current_dir(repo)
402 .output()
403 .map_err(|_| WorktreeError::GitNotFound)?;
404
405 if !output.status.success() {
406 return Err(WorktreeError::GitCommandFailed {
407 command: format!("git worktree remove {}", path.display()),
408 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
409 exit_code: output.status.code().unwrap_or(-1),
410 });
411 }
412
413 Ok(())
414}
415
416pub fn post_create_git_crypt_check(worktree_path: &Path) -> Result<(), WorktreeError> {
419 let gitattributes = worktree_path.join(".gitattributes");
420 if !gitattributes.exists() {
421 return Ok(());
422 }
423
424 let content = match std::fs::read_to_string(&gitattributes) {
425 Ok(c) => c,
426 Err(_) => return Ok(()),
427 };
428
429 let has_git_crypt = content.lines().any(|l| l.contains("filter=git-crypt"));
430 if !has_git_crypt {
431 return Ok(());
432 }
433
434 const GIT_CRYPT_MAGIC: &[u8; 10] = b"\x00GITCRYPT\x00";
435
436 for line in content.lines() {
437 if !line.contains("filter=git-crypt") {
438 continue;
439 }
440 let pattern = line.split_whitespace().next().unwrap_or("");
441 if pattern.is_empty() {
442 continue;
443 }
444
445 let ls_output = Command::new("git")
446 .args(["ls-files", "--", pattern])
447 .current_dir(worktree_path)
448 .output();
449
450 if let Ok(ls) = ls_output {
451 for file_path in String::from_utf8_lossy(&ls.stdout).lines() {
452 let full_path = worktree_path.join(file_path);
453 if full_path.exists() {
454 if let Ok(true) = is_encrypted(&full_path, GIT_CRYPT_MAGIC) {
455 return Err(WorktreeError::GitCryptLocked);
456 }
457 }
458 }
459 }
460 }
461
462 Ok(())
463}
464
465pub(crate) fn is_encrypted(path: &Path, magic: &[u8; 10]) -> std::io::Result<bool> {
467 use std::io::Read;
468 let mut file = std::fs::File::open(path)?;
469 let mut header = [0u8; 10];
470 match file.read_exact(&mut header) {
471 Ok(_) => Ok(&header == magic),
472 Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
473 Err(e) => Err(e),
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480
481 #[test]
484 fn parse_standard_version() {
485 let v = parse_git_version("git version 2.43.0").unwrap();
486 assert_eq!(v, GitVersion { major: 2, minor: 43, patch: 0 });
487 }
488
489 #[test]
490 fn parse_apple_version() {
491 let v = parse_git_version("git version 2.39.3 (Apple Git-146)").unwrap();
492 assert_eq!(v, GitVersion { major: 2, minor: 39, patch: 3 });
493 }
494
495 #[test]
496 fn parse_windows_version() {
497 let v = parse_git_version("git version 2.43.0.windows.1").unwrap();
498 assert_eq!(v, GitVersion { major: 2, minor: 43, patch: 0 });
499 }
500
501 #[test]
502 fn parse_with_trailing_newline() {
503 let v = parse_git_version("git version 2.20.0\n").unwrap();
504 assert_eq!(v, GitVersion { major: 2, minor: 20, patch: 0 });
505 }
506
507 #[test]
508 fn parse_garbage_input() {
509 assert!(parse_git_version("not git output").is_err());
510 }
511
512 #[test]
515 fn version_2_19_is_too_old() {
516 let v = GitVersion { major: 2, minor: 19, patch: 9 };
517 assert!(v < GitVersion::MINIMUM);
518 }
519
520 #[test]
521 fn version_2_20_is_ok() {
522 let v = GitVersion { major: 2, minor: 20, patch: 0 };
523 assert!(v >= GitVersion::MINIMUM);
524 }
525
526 #[test]
529 fn capabilities_at_2_20() {
530 let caps = detect_capabilities(&GitVersion { major: 2, minor: 20, patch: 0 });
531 assert!(!caps.has_repair);
532 assert!(!caps.has_list_nul);
533 assert!(!caps.has_merge_tree_write);
534 assert!(!caps.has_orphan);
535 assert!(!caps.has_relative_paths);
536 }
537
538 #[test]
539 fn capabilities_at_2_29_no_repair() {
540 let caps = detect_capabilities(&GitVersion { major: 2, minor: 29, patch: 9 });
541 assert!(!caps.has_repair);
542 }
543
544 #[test]
545 fn capabilities_at_2_30_has_repair() {
546 let caps = detect_capabilities(&GitVersion { major: 2, minor: 30, patch: 0 });
547 assert!(caps.has_repair);
548 assert!(!caps.has_list_nul);
549 }
550
551 #[test]
552 fn capabilities_at_2_35_no_list_nul() {
553 let caps = detect_capabilities(&GitVersion { major: 2, minor: 35, patch: 9 });
554 assert!(caps.has_repair);
555 assert!(!caps.has_list_nul);
556 }
557
558 #[test]
559 fn capabilities_at_2_36_has_list_nul() {
560 let caps = detect_capabilities(&GitVersion { major: 2, minor: 36, patch: 0 });
561 assert!(caps.has_repair);
562 assert!(caps.has_list_nul);
563 assert!(!caps.has_merge_tree_write);
564 }
565
566 #[test]
567 fn capabilities_at_2_38_has_merge_tree() {
568 let caps = detect_capabilities(&GitVersion { major: 2, minor: 38, patch: 0 });
569 assert!(caps.has_merge_tree_write);
570 assert!(!caps.has_orphan);
571 }
572
573 #[test]
574 fn capabilities_at_2_42_has_orphan() {
575 let caps = detect_capabilities(&GitVersion { major: 2, minor: 42, patch: 0 });
576 assert!(caps.has_orphan);
577 assert!(!caps.has_relative_paths);
578 }
579
580 #[test]
581 fn capabilities_at_2_48_has_relative_paths() {
582 let caps = detect_capabilities(&GitVersion { major: 2, minor: 48, patch: 0 });
583 assert!(caps.has_relative_paths);
584 assert!(caps.has_repair);
586 assert!(caps.has_list_nul);
587 assert!(caps.has_merge_tree_write);
588 assert!(caps.has_orphan);
589 }
590
591 #[test]
594 fn detect_real_git_version() {
595 let caps = detect_git_version().expect("git should be installed on CI");
596 assert!(caps.version >= GitVersion::MINIMUM);
597 }
598
599 #[test]
602 fn parse_empty_output() {
603 let result = parse_worktree_list_porcelain(b"", false).unwrap();
604 assert!(result.is_empty());
605 }
606
607 #[test]
608 fn parse_single_worktree_newline_mode() {
609 let output = b"worktree /home/user/project\nHEAD abc1234abc1234abc1234abc1234abc1234abc1234\nbranch refs/heads/main\n\n";
610 let result = parse_worktree_list_porcelain(output, false).unwrap();
611 assert_eq!(result.len(), 1);
612 assert_eq!(result[0].path, std::path::PathBuf::from("/home/user/project"));
613 assert_eq!(result[0].branch, "main");
614 assert_eq!(result[0].base_commit, "abc1234abc1234abc1234abc1234abc1234abc1234");
615 assert_eq!(result[0].state, WorktreeState::Active);
616 }
617
618 #[test]
619 fn parse_multi_block_newline_mode() {
620 let output = b"worktree /home/user/project\nHEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nbranch refs/heads/main\n\nworktree /home/user/project-feature\nHEAD bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\nbranch refs/heads/feature/test\n\nworktree /home/user/project-detached\nHEAD cccccccccccccccccccccccccccccccccccccccc\ndetached\n\n";
621 let result = parse_worktree_list_porcelain(output, false).unwrap();
622 assert_eq!(result.len(), 3);
623 assert_eq!(result[0].branch, "main");
624 assert_eq!(result[1].branch, "feature/test");
625 assert_eq!(result[2].branch, ""); assert_eq!(result[2].state, WorktreeState::Active);
627 }
628
629 #[test]
630 fn parse_locked_worktree_no_reason() {
631 let output = b"worktree /tmp/wt\nHEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nbranch refs/heads/test\nlocked\n\n";
632 let result = parse_worktree_list_porcelain(output, false).unwrap();
633 assert_eq!(result.len(), 1);
634 assert_eq!(result[0].state, WorktreeState::Locked);
635 }
636
637 #[test]
638 fn parse_locked_worktree_with_reason() {
639 let output = b"worktree /tmp/wt\nHEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nbranch refs/heads/test\nlocked important work in progress\n\n";
640 let result = parse_worktree_list_porcelain(output, false).unwrap();
641 assert_eq!(result.len(), 1);
642 assert_eq!(result[0].state, WorktreeState::Locked);
643 }
644
645 #[test]
646 fn parse_prunable_worktree() {
647 let output = b"worktree /tmp/wt\nHEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nbranch refs/heads/test\nprunable gitdir file points to non-existent location\n\n";
648 let result = parse_worktree_list_porcelain(output, false).unwrap();
649 assert_eq!(result.len(), 1);
650 assert_eq!(result[0].state, WorktreeState::Orphaned);
651 }
652
653 #[test]
654 fn parse_bare_worktree() {
655 let output = b"worktree /tmp/bare.git\nbare\n\n";
656 let result = parse_worktree_list_porcelain(output, false).unwrap();
657 assert_eq!(result.len(), 1);
658 assert_eq!(result[0].branch, "");
659 }
660
661 #[test]
662 fn parse_nul_delimited_mode() {
663 let output = b"worktree /home/user/project\0HEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\0branch refs/heads/main\0\0worktree /home/user/project-feature\0HEAD bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\0branch refs/heads/feature\0\0";
666 let result = parse_worktree_list_porcelain(output, true).unwrap();
667 assert_eq!(result.len(), 2);
668 assert_eq!(result[0].branch, "main");
669 assert_eq!(result[1].branch, "feature");
670 }
671
672 #[test]
673 fn parse_nul_delimited_path_with_spaces() {
674 let output = b"worktree /home/user/my project\0HEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\0branch refs/heads/main\0\0";
675 let result = parse_worktree_list_porcelain(output, true).unwrap();
676 assert_eq!(result.len(), 1);
677 assert_eq!(result[0].path, std::path::PathBuf::from("/home/user/my project"));
678 }
679
680 #[cfg(unix)]
681 #[test]
682 fn parse_nul_delimited_preserves_non_utf8_path_bytes() {
683 let mut output: Vec<u8> = Vec::new();
685 output.extend_from_slice(b"worktree /tmp/wt-");
686 output.push(0xff);
687 output.extend_from_slice(b"-end\0HEAD aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\0branch refs/heads/x\0\0");
688
689 let result = parse_worktree_list_porcelain(&output, true).unwrap();
690 assert_eq!(result.len(), 1);
691
692 use std::os::unix::ffi::OsStrExt;
693 let bytes = result[0].path.as_os_str().as_bytes();
694 assert!(bytes.contains(&0xff), "non-UTF8 byte should survive");
695 }
696
697 #[test]
698 fn parse_integration_real_repo() {
699 let caps = detect_git_version().expect("git should be installed");
701 let result = run_worktree_list(std::path::Path::new("."), &caps);
702 assert!(result.is_ok());
704 let handles = result.unwrap();
705 assert!(!handles.is_empty());
707 }
708}