Skip to main content

iso_code/
git.rs

1use std::path::Path;
2use std::process::Command;
3
4use crate::error::WorktreeError;
5use crate::types::{GitCapabilities, GitVersion, WorktreeHandle, WorktreeState};
6
7/// Parse the output of `git --version` into a `GitVersion`.
8///
9/// Handles format variations:
10/// - "git version 2.43.0"
11/// - "git version 2.39.3 (Apple Git-146)"
12/// - "git version 2.43.0.windows.1"
13pub fn parse_git_version(output: &str) -> Result<GitVersion, WorktreeError> {
14    // Extract the version string after "git version "
15    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    // Take the first space-delimited token (drops "(Apple Git-146)" suffix)
25    let version_token = version_str.split_whitespace().next().unwrap_or(version_str);
26
27    // Split by '.' and parse first three components (drops ".windows.1" suffix)
28    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
56/// Build a `GitCapabilities` struct from a detected `GitVersion`.
57pub fn detect_capabilities(version: &GitVersion) -> GitCapabilities {
58    let has_repair = *version >= GitVersion::HAS_REPAIR;               // 2.30+
59    let has_list_nul = *version >= GitVersion::HAS_LIST_NUL;           // 2.36+
60    let has_merge_tree_write = *version >= GitVersion::HAS_MERGE_TREE_WRITE; // 2.38+
61    let has_orphan = *version >= GitVersion { major: 2, minor: 42, patch: 0 }; // 2.42+
62    let has_relative_paths = *version >= GitVersion { major: 2, minor: 48, patch: 0 }; // 2.48+
63
64    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
74/// Run `git --version`, parse the result, and validate against minimum version.
75/// Returns `GitCapabilities` on success.
76pub 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
104/// Parse the porcelain output of `git worktree list` into [`WorktreeHandle`]s.
105///
106/// Handles both NUL-delimited (`git -z`, available on Git >= 2.36) and
107/// newline-delimited layouts: each worktree is one block of key-value lines,
108/// and blocks are separated by double-NUL (or a blank line).
109///
110/// Path bytes are preserved end-to-end on Unix via `OsStr::from_bytes` so
111/// non-UTF8 paths survive through to [`std::path::PathBuf`]. Branch names,
112/// HEAD SHAs, and keyword markers are ASCII in practice and are decoded lossily.
113pub fn parse_worktree_list_porcelain(
114    output: &[u8],
115    nul_delimited: bool,
116) -> Result<Vec<WorktreeHandle>, WorktreeError> {
117    // Block separator: double-NUL in -z mode, blank line (double-LF) otherwise.
118    let block_sep: &[u8] = if nul_delimited { b"\0\0" } else { b"\n\n" };
119    // Field separator within a block: NUL in -z mode, LF otherwise.
120    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        // Trim leading/trailing separator bytes + whitespace from the block.
125        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        // locked > prunable(orphaned) > active
175        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(), // created_at — populated from state.json
193            0,             // creator_pid
194            String::new(), // creator_name
195            None,          // adapter
196            false,         // setup_complete
197            None,          // port
198            String::new(), // session_uuid
199        ));
200    }
201
202    Ok(handles)
203}
204
205/// Build a `PathBuf` from raw bytes. On Unix, preserves non-UTF8 bytes via
206/// `OsStr::from_bytes`. On other targets, falls back to lossy UTF-8 decoding
207/// (Windows paths are UTF-16 anyway, so non-UTF8 bytes from git there would
208/// already be broken).
209fn 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
253/// Split `haystack` on every occurrence of `needle`, returning the between-slices.
254fn 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
274/// Run `git worktree list --porcelain [-z]` and parse the output.
275pub 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
300/// Resolve a ref to its 40-char SHA.
301pub 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
319/// Check if a branch already exists.
320pub 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
330/// Run `git worktree add` with the appropriate flags.
331/// Returns Ok(()) on success.
332pub 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
376/// Run `git worktree remove --force <path>`.
377pub 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
396/// Run `git worktree remove <path>` (non-force).
397pub 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
416/// Post-create git-crypt check on the new worktree path.
417/// Returns Ok(()) if the worktree is safe, Err(GitCryptLocked) if encrypted files detected.
418pub 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
465/// Check if a file starts with the git-crypt magic header bytes.
466pub(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    // ── Version parsing ─────────────────────────────────────────────
482
483    #[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    // ── Minimum version check ───────────────────────────────────────
513
514    #[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    // ── Capability thresholds ───────────────────────────────────────
527
528    #[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        // All other caps should be true too
585        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    // ── Integration: actual git on this machine ─────────────────────
592
593    #[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    // ── Worktree list parser tests ──────────────────────────────────
600
601    #[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, ""); // detached
626        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        // Real git -z format: fields separated by NUL within a block,
664        // blocks separated by double NUL.
665        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        // A path byte that's valid on Unix but not valid UTF-8 (0xff).
684        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        // Run against the actual ISO repo
700        let caps = detect_git_version().expect("git should be installed");
701        let result = run_worktree_list(std::path::Path::new("."), &caps);
702        // Should succeed — we're in a git repo
703        assert!(result.is_ok());
704        let handles = result.unwrap();
705        // At minimum, the main worktree should be present
706        assert!(!handles.is_empty());
707    }
708}