Skip to main content

gix_testtools/repository/
mod.rs

1//! Stable snapshots of the Git and filesystem state of a test repository.
2
3use std::{
4    borrow::Cow,
5    collections::BTreeMap,
6    ffi::OsStr,
7    fmt, fs,
8    path::{Path, PathBuf},
9};
10
11use bstr::{BStr, BString, ByteSlice};
12use gix_hash::ObjectId;
13
14use crate::Result;
15
16#[cfg(not(feature = "repo-snapshot"))]
17mod git;
18#[cfg(feature = "repo-snapshot")]
19mod gix;
20
21/// All relevant observable state of a repository and its worktree.
22#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct State {
24    /// The current HEAD, including whether it is attached, detached, or unborn.
25    pub head: Head,
26    /// The contents of the repository's common `config` file, with locations normalized and generated keys removed.
27    pub config: BString,
28    /// Every reference below `refs/`, sorted by name.
29    pub references: Vec<Reference>,
30    /// Raw commit objects reachable from HEAD or any reference, sorted by object ID.
31    ///
32    /// The raw data retains the tree, parents, identities, dates, headers, and message.
33    pub commits: Vec<Commit>,
34    /// Every index entry and stage, sorted in index order.
35    pub index: Vec<IndexEntry>,
36    /// The tree represented by a conflict-free index, computed without writing objects.
37    pub index_tree: Option<ObjectId>,
38    /// Exact filesystem entries below the worktree, excluding `.git` administration entries.
39    pub worktree: Vec<WorktreeEntry>,
40    normalization_root: PathBuf,
41    show_object_ids: bool,
42}
43
44/// The state of `HEAD`.
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub enum Head {
47    /// HEAD names a branch which does not exist yet.
48    Unborn(BString),
49    /// HEAD names a branch and resolves to the given object.
50    Symbolic {
51        /// The full branch name.
52        name: BString,
53        /// The fully peeled commit currently reached through the branch.
54        id: ObjectId,
55    },
56    /// HEAD directly names the given object.
57    Detached(ObjectId),
58}
59
60/// A reference and its immediate target.
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct Reference {
63    /// Full reference name.
64    pub name: BString,
65    /// Direct or symbolic target.
66    pub target: ReferenceTarget,
67}
68
69/// A reference target.
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub enum ReferenceTarget {
72    /// Another reference name.
73    Symbolic(BString),
74    /// An object ID.
75    Object(ObjectId),
76}
77
78/// A raw commit object.
79#[derive(Clone, Debug, Eq, PartialEq)]
80pub struct Commit {
81    /// Commit object ID.
82    pub id: ObjectId,
83    /// Complete decoded object bytes, excluding the loose-object header.
84    pub data: Vec<u8>,
85}
86
87/// One index entry.
88#[derive(Clone, Debug, Eq, PartialEq)]
89pub struct IndexEntry {
90    /// Git tree mode as stored in the index.
91    pub mode: u32,
92    /// Blob or submodule object ID.
93    pub id: ObjectId,
94    /// Conflict stage, with zero denoting an ordinary entry.
95    pub stage: u8,
96    /// Repository-relative byte path.
97    pub path: BString,
98}
99
100/// One filesystem entry in the worktree.
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct WorktreeEntry {
103    /// Worktree-relative path.
104    pub path: PathBuf,
105    /// Entry kind and contents.
106    pub kind: WorktreeEntryKind,
107    /// Unix permission bits when available.
108    pub unix_mode: Option<u32>,
109}
110
111/// The kind and exact contents of a worktree entry.
112#[derive(Clone, Debug, Eq, PartialEq)]
113pub enum WorktreeEntryKind {
114    /// A directory.
115    Directory,
116    /// A regular file.
117    File(Vec<u8>),
118    /// A symbolic link with its target.
119    Symlink(PathBuf),
120}
121
122/// Capture repository state at `path` without modifying references, index, objects, or worktree.
123///
124/// Location-bearing config values are normalized relative to the common Git directory, using `<normalized>` in place
125/// of the directory itself. Locations outside the repository which cannot be made relative become `<normalized>`.
126/// Git- and platform-generated config keys are omitted.
127pub fn snapshot(path: impl AsRef<Path>) -> Result<State> {
128    #[cfg(feature = "repo-snapshot")]
129    let mut state = gix::snapshot(path.as_ref())?;
130    #[cfg(not(feature = "repo-snapshot"))]
131    let mut state = git::snapshot(path.as_ref())?;
132    state.config = normalize_config_paths(state.config.as_bstr(), &state.normalization_root)?;
133    state.config = remove_generated_config(state.config.as_bstr())?;
134    state.show_object_ids = true;
135    Ok(state)
136}
137
138/// Capture portable repository state at `path` without modifying references, index, objects, or worktree.
139///
140/// Unlike [`snapshot()`], this omits filesystem metadata and object IDs which aren't stable across all supported
141/// platforms and object formats. In particular, [`WorktreeEntry::unix_mode`] is always `None`, and its display
142/// representation omits the object-ID mapping. Git index modes remain available as they are part of the repository
143/// itself and have platform-independent meaning. Config locations use the same normalization as [`snapshot()`].
144pub fn snapshot_portable(path: impl AsRef<Path>) -> Result<State> {
145    let mut state = snapshot(path)?;
146    for entry in &mut state.worktree {
147        entry.unix_mode = None;
148    }
149    state.config = normalize_config_indentation(state.config);
150    state.show_object_ids = false;
151    Ok(state)
152}
153
154#[cfg(feature = "repo-snapshot")]
155fn normalize_config_paths(input: &BStr, root: &Path) -> Result<BString> {
156    let mut config = gix_config::File::try_from(input)?;
157    for (section_name, value_name) in [
158        ("core", "worktree"),
159        ("remote", "url"),
160        ("remote", "pushurl"),
161        ("submodule", "url"),
162        ("include", "path"),
163        ("includeIf", "path"),
164    ] {
165        let subsections: std::collections::BTreeSet<_> = config
166            .sections_and_ids_by_name(section_name)
167            .into_iter()
168            .flatten()
169            .map(|(section, _)| section.header().subsection_name().map(ToOwned::to_owned))
170            .collect();
171        for subsection in subsections {
172            if let Ok(mut values) =
173                config.raw_values_mut_by(section_name, subsection.as_ref().map(|name| name.as_bstr()), value_name)
174            {
175                let normalized: Vec<_> = values
176                    .get()?
177                    .into_iter()
178                    .map(|value| normalize_config_path(value.as_bstr(), root))
179                    .collect();
180                for (index, value) in normalized.into_iter().enumerate() {
181                    values.set_at(index, value)?;
182                }
183            }
184        }
185    }
186    Ok(config.into())
187}
188
189fn normalize_config_path(value: &BStr, root: &Path) -> BString {
190    let path = gix_path::from_bstr(value).into_owned();
191    let relative = if path.is_absolute() {
192        path.strip_prefix(root)
193            .map(Path::to_owned)
194            .ok()
195            .or_else(|| {
196                let root = root.canonicalize().ok()?;
197                path.canonicalize().ok()?.strip_prefix(root).map(Path::to_owned).ok()
198            })
199            .or_else(|| relative_to_repository_sibling(&path, root))
200    } else {
201        if value.contains_str("://")
202            || value
203                .find_byte(b':')
204                .is_some_and(|colon| !value[..colon].contains(&b'/'))
205        {
206            return "<normalized>".into();
207        }
208        Some(path)
209    };
210    let Some(relative) = relative else {
211        return "<normalized>".into();
212    };
213    let relative = portable_path(&relative);
214    if relative.is_empty() {
215        return "<normalized>".into();
216    }
217    let mut out = b"<normalized>/".to_vec();
218    out.extend_from_slice(&relative);
219    out.into()
220}
221
222fn relative_to_repository_sibling(path: &Path, git_dir: &Path) -> Option<PathBuf> {
223    let repository = if git_dir.file_name() == Some(OsStr::new(".git")) {
224        git_dir.parent()?
225    } else {
226        git_dir
227    };
228    relative_to_repository_sibling_inner(path, repository).or_else(|| {
229        let path = comparable_realpath(path)?;
230        let repository = comparable_realpath(repository)?;
231        relative_to_repository_sibling_inner(&path, &repository)
232    })
233}
234
235fn relative_to_repository_sibling_inner(path: &Path, repository: &Path) -> Option<PathBuf> {
236    path.strip_prefix(repository.parent()?).ok()?;
237    relative_path(repository, path)
238}
239
240fn comparable_realpath(path: &Path) -> Option<PathBuf> {
241    let realpath = gix_path::realpath(path).ok()?;
242    #[cfg(windows)]
243    {
244        // Make equivalent existing paths such as `D:\a\gitoxide\source` and
245        // `\\?\D:\a\gitoxide\source` comparable. Keep `realpath` for missing components.
246        Some(realpath.canonicalize().unwrap_or(realpath))
247    }
248    #[cfg(not(windows))]
249    {
250        Some(realpath)
251    }
252}
253
254fn relative_path(from: &Path, to: &Path) -> Option<PathBuf> {
255    let from: Vec<_> = from.components().collect();
256    let to: Vec<_> = to.components().collect();
257    let common = from.iter().zip(&to).take_while(|(left, right)| left == right).count();
258    if common == 0 {
259        return None;
260    }
261    let mut out = PathBuf::new();
262    for _ in &from[common..] {
263        out.push("..");
264    }
265    for component in &to[common..] {
266        out.push(component.as_os_str());
267    }
268    Some(out)
269}
270
271#[cfg(not(feature = "repo-snapshot"))]
272fn normalize_config_paths(input: &BStr, root: &Path) -> Result<BString> {
273    let mut out = Vec::with_capacity(input.len());
274    let mut section = b"".as_slice();
275    for line in input.lines_with_terminator() {
276        if let Some(name) = config_section_name(line) {
277            section = name;
278            out.extend_from_slice(line);
279            continue;
280        }
281        let Some((key, value_start, value_end)) = config_key_and_value(line) else {
282            out.extend_from_slice(line);
283            continue;
284        };
285        if is_location_key(section, key) {
286            out.extend_from_slice(&line[..value_start]);
287            out.extend_from_slice(&normalize_config_path(line[value_start..value_end].as_bstr(), root));
288            out.extend_from_slice(&line[value_end..]);
289        } else {
290            out.extend_from_slice(line);
291        }
292    }
293    Ok(out.into())
294}
295
296#[cfg(not(feature = "repo-snapshot"))]
297fn remove_generated_config(input: &BStr) -> Result<BString> {
298    let mut out = Vec::with_capacity(input.len());
299    let mut header = None;
300    let mut body = Vec::new();
301    for line in input.lines_with_terminator() {
302        if config_section_name(line).is_some() {
303            if let Some(previous) = header.replace(line) {
304                write_portable_config_section(previous, &body, &mut out);
305                body.clear();
306            }
307        } else if header.is_some() {
308            body.push(line);
309        } else {
310            out.extend_from_slice(line);
311        }
312    }
313    if let Some(header) = header {
314        write_portable_config_section(header, &body, &mut out);
315    }
316    Ok(out.into())
317}
318
319#[cfg(not(feature = "repo-snapshot"))]
320fn write_portable_config_section(header: &[u8], body: &[&[u8]], out: &mut Vec<u8>) {
321    let section = config_section_name(header).expect("caller provides a section header");
322    let retained: Vec<_> = body
323        .iter()
324        .copied()
325        .filter(|line| match config_key_and_value(line) {
326            Some((key, _, _)) => !is_generated_config_key(section, key),
327            None => true,
328        })
329        .collect();
330    let has_values = retained.iter().any(|line| config_key_and_value(line).is_some());
331    if !has_values && (section.eq_ignore_ascii_case(b"core") || section.eq_ignore_ascii_case(b"extensions")) {
332        return;
333    }
334    out.extend_from_slice(header);
335    for line in retained {
336        out.extend_from_slice(line);
337    }
338}
339
340#[cfg(not(feature = "repo-snapshot"))]
341fn config_section_name(line: &[u8]) -> Option<&[u8]> {
342    let start = line.iter().take_while(|byte| byte.is_ascii_whitespace()).count();
343    let body = line[start..].strip_prefix(b"[")?.split(|byte| *byte == b']').next()?;
344    body.split(|byte| byte.is_ascii_whitespace() || *byte == b'\"').next()
345}
346
347#[cfg(not(feature = "repo-snapshot"))]
348fn config_key_and_value(line: &[u8]) -> Option<(&[u8], usize, usize)> {
349    let mut start = 0;
350    while line.get(start).is_some_and(u8::is_ascii_whitespace) {
351        start += 1;
352    }
353    if matches!(line.get(start), None | Some(b'#' | b';' | b'[')) {
354        return None;
355    }
356    let key_end = line[start..]
357        .iter()
358        .position(|byte| byte.is_ascii_whitespace() || *byte == b'=')?
359        + start;
360    let mut value_start = key_end;
361    while line.get(value_start).is_some_and(u8::is_ascii_whitespace) {
362        value_start += 1;
363    }
364    if line.get(value_start) == Some(&b'=') {
365        value_start += 1;
366        while line.get(value_start).is_some_and(u8::is_ascii_whitespace) {
367            value_start += 1;
368        }
369    }
370    let mut value_end = line.len();
371    while matches!(line.get(value_end.wrapping_sub(1)), Some(b'\n' | b'\r')) {
372        value_end -= 1;
373    }
374    Some((&line[start..key_end], value_start, value_end))
375}
376
377#[cfg(not(feature = "repo-snapshot"))]
378fn is_location_key(section: &[u8], key: &[u8]) -> bool {
379    matches_location(section, key, b"core", b"worktree")
380        || matches_location(section, key, b"remote", b"url")
381        || matches_location(section, key, b"remote", b"pushurl")
382        || matches_location(section, key, b"submodule", b"url")
383        || matches_location(section, key, b"include", b"path")
384        || matches_location(section, key, b"includeIf", b"path")
385}
386
387#[cfg(not(feature = "repo-snapshot"))]
388fn matches_location(section: &[u8], key: &[u8], expected_section: &[u8], expected_key: &[u8]) -> bool {
389    section.eq_ignore_ascii_case(expected_section) && key.eq_ignore_ascii_case(expected_key)
390}
391
392#[cfg(not(feature = "repo-snapshot"))]
393fn is_generated_config_key(section: &[u8], key: &[u8]) -> bool {
394    if section.eq_ignore_ascii_case(b"core") {
395        [
396            b"repositoryformatversion".as_slice(),
397            b"filemode",
398            b"logallrefupdates",
399            b"ignorecase",
400            b"precomposeunicode",
401            b"symlinks",
402        ]
403        .iter()
404        .any(|name| key.eq_ignore_ascii_case(name))
405    } else if section.eq_ignore_ascii_case(b"extensions") {
406        [b"objectformat".as_slice(), b"compatobjectformat"]
407            .iter()
408            .any(|name| key.eq_ignore_ascii_case(name))
409    } else {
410        false
411    }
412}
413
414#[cfg(feature = "repo-snapshot")]
415fn remove_generated_config(input: &BStr) -> Result<BString> {
416    let mut config = gix_config::File::try_from(input)?;
417    for (section_name, value_names) in [
418        (
419            "core",
420            &[
421                "repositoryformatversion",
422                "filemode",
423                "logallrefupdates",
424                "ignorecase",
425                "precomposeunicode",
426                "symlinks",
427            ][..],
428        ),
429        ("extensions", &["objectformat", "compatobjectformat"][..]),
430    ] {
431        let section_ids: Vec<_> = config
432            .sections_and_ids_by_name(section_name)
433            .into_iter()
434            .flatten()
435            .map(|(_, id)| id)
436            .collect();
437        for id in section_ids {
438            let mut section = config.section_mut_by_id(id).expect("ID came from this config");
439            for value_name in value_names {
440                while section.remove(value_name).is_some() {}
441            }
442            if section.num_values() == 0 {
443                config.remove_section_by_id(id);
444            }
445        }
446    }
447
448    Ok(config.into())
449}
450
451fn normalize_config_indentation(config: BString) -> BString {
452    let mut out = Vec::with_capacity(config.len());
453    let mut in_indentation = true;
454    for byte in config.iter().copied() {
455        match byte {
456            b'\t' if in_indentation => out.extend_from_slice(b"    "),
457            b'\n' => {
458                out.push(byte);
459                in_indentation = true;
460            }
461            b' ' if in_indentation => out.push(byte),
462            _ => {
463                out.push(byte);
464                in_indentation = false;
465            }
466        }
467    }
468    out.into()
469}
470
471/// Stable, human-readable names for objects referenced by a repository snapshot.
472///
473/// Commit IDs become `C…`, tree IDs `T…`, blobs `B…`, and gitlinks `S…`. Any other object visible in the snapshot,
474/// such as an annotated tag or a parent beyond a shallow boundary, becomes `O…`. This makes the rendered state readable
475/// and largely independent of the selected object-hash format. `commits` records their deterministic parent-before-child
476/// display order; object IDs break ties between unrelated commits.
477struct Aliases {
478    by_id: BTreeMap<ObjectId, String>,
479    commits: Vec<ObjectId>,
480}
481
482impl Aliases {
483    fn new(state: &State) -> Self {
484        let parents: BTreeMap<_, Vec<_>> = state
485            .commits
486            .iter()
487            .map(|commit| {
488                let parents = commit
489                    .data
490                    .lines()
491                    .filter_map(|line| line.strip_prefix(b"parent "))
492                    .filter_map(|hex| ObjectId::from_hex(hex).ok())
493                    .collect();
494                (commit.id, parents)
495            })
496            .collect();
497        let mut commits: Vec<_> = parents.keys().copied().collect();
498        let mut depths = BTreeMap::new();
499        commits.sort_by_key(|id| (commit_depth(*id, &parents, &mut depths), *id));
500
501        let mut by_id = BTreeMap::new();
502        for (index, id) in commits.iter().enumerate() {
503            by_id.insert(*id, format!("C{index}"));
504        }
505        let trees = state
506            .commits
507            .iter()
508            .flat_map(|commit| commit.data.lines())
509            .filter_map(|line| line.strip_prefix(b"tree "))
510            .filter_map(|hex| ObjectId::from_hex(hex).ok())
511            .chain(state.index_tree);
512        let mut tree_index = 0;
513        for id in trees {
514            by_id.entry(id).or_insert_with(|| {
515                let alias = format!("T{tree_index}");
516                tree_index += 1;
517                alias
518            });
519        }
520        let mut blob_index = 0;
521        let mut gitlink_index = 0;
522        for entry in &state.index {
523            by_id.entry(entry.id).or_insert_with(|| {
524                if entry.mode == 0o160000 {
525                    let alias = format!("S{gitlink_index}");
526                    gitlink_index += 1;
527                    alias
528                } else {
529                    let alias = format!("B{blob_index}");
530                    blob_index += 1;
531                    alias
532                }
533            });
534        }
535
536        let mut other_index = 0;
537        let mut insert_other = |id| {
538            by_id.entry(id).or_insert_with(|| {
539                let alias = format!("O{other_index}");
540                other_index += 1;
541                alias
542            });
543        };
544        match &state.head {
545            Head::Symbolic { id, .. } | Head::Detached(id) => insert_other(*id),
546            Head::Unborn(_) => {}
547        }
548        for reference in &state.references {
549            if let ReferenceTarget::Object(id) = &reference.target {
550                insert_other(*id);
551            }
552        }
553        for commit in &state.commits {
554            for id in commit
555                .data
556                .lines()
557                .filter_map(|line| line.strip_prefix(b"parent "))
558                .filter_map(|hex| ObjectId::from_hex(hex).ok())
559            {
560                insert_other(id);
561            }
562        }
563        Self { by_id, commits }
564    }
565
566    fn id(&self, id: ObjectId) -> String {
567        self.by_id.get(&id).cloned().unwrap_or_else(|| id.to_string())
568    }
569
570    fn head(&self, head: &Head) -> String {
571        match head {
572            Head::Unborn(name) => format!("unborn {}", name.as_bstr()),
573            Head::Symbolic { name, id } => format!("{} -> {}", name.as_bstr(), self.id(*id)),
574            Head::Detached(id) => format!("detached {}", self.id(*id)),
575        }
576    }
577}
578
579impl fmt::Display for State {
580    fn fmt(&self, out: &mut fmt::Formatter<'_>) -> fmt::Result {
581        let aliases = Aliases::new(self);
582        let State {
583            head,
584            config,
585            references,
586            commits,
587            index,
588            index_tree,
589            worktree,
590            normalization_root: _,
591            show_object_ids,
592        } = self;
593        writeln!(out, "HEAD {}", aliases.head(head))?;
594        write!(out, "\n[config]\n{}", config.as_bstr())?;
595        if !config.ends_with_str("\n") {
596            writeln!(out)?;
597        }
598        writeln!(out, "\n[refs]")?;
599        for reference in references {
600            let target = match &reference.target {
601                ReferenceTarget::Symbolic(name) => format!("-> {}", name.as_bstr()),
602                ReferenceTarget::Object(id) => aliases.id(*id),
603            };
604            writeln!(out, "{} = {target}", reference.name.as_bstr())?;
605        }
606
607        writeln!(out, "\n[commits]")?;
608        for commit in aliases.commits.iter().map(|id| {
609            commits
610                .iter()
611                .find(|commit| commit.id == *id)
612                .expect("aliases only contain captured commits")
613        }) {
614            writeln!(out, "{}", aliases.id(commit.id))?;
615            for line in commit.data.lines() {
616                if line.is_empty() {
617                    writeln!(out)?;
618                } else if let Some((name, value)) = line.split_once_str(b" ")
619                    && matches!(name, b"tree" | b"parent")
620                    && let Ok(id) = ObjectId::from_hex(value)
621                {
622                    writeln!(out, "  {} {}", name.as_bstr(), aliases.id(id))?;
623                } else {
624                    writeln!(out, "  {}", line.as_bstr())?;
625                }
626            }
627            writeln!(out)?;
628        }
629
630        writeln!(out, "[index]")?;
631        match index_tree {
632            Some(id) => writeln!(out, "tree = {}", aliases.id(*id))?,
633            None => writeln!(out, "tree = conflicted")?,
634        }
635        for entry in index {
636            writeln!(
637                out,
638                "{:06o} {} stage={} {:?}",
639                entry.mode,
640                aliases.id(entry.id),
641                entry.stage,
642                entry.path.as_bstr()
643            )?;
644        }
645
646        writeln!(out, "\n[worktree]")?;
647        for entry in worktree {
648            let mode = entry.unix_mode.map_or_else(|| "-".into(), |mode| format!("{mode:06o}"));
649            match &entry.kind {
650                WorktreeEntryKind::Directory => writeln!(out, "{mode} dir  {:?}", portable_path(&entry.path))?,
651                WorktreeEntryKind::File(data) => writeln!(
652                    out,
653                    "{mode} file {:?} = {:?}",
654                    portable_path(&entry.path),
655                    data.as_bstr()
656                )?,
657                WorktreeEntryKind::Symlink(target) => writeln!(
658                    out,
659                    "{mode} link {:?} -> {:?}",
660                    portable_path(&entry.path),
661                    portable_path(target)
662                )?,
663            }
664        }
665
666        if *show_object_ids {
667            writeln!(out, "\n[objects]")?;
668            for (id, alias) in &aliases.by_id {
669                writeln!(out, "{alias} = {id}")?;
670            }
671        }
672        Ok(())
673    }
674}
675
676/// Return a commit's zero-based generation number: roots have generation zero and every other commit has one more
677/// than its highest-generation parent. This is analogous to Git's v1 commit-graph generation numbers, except for the
678/// zero-based root, and is used only to assign deterministic parent-before-child snapshot aliases. Parents absent from
679/// the captured graph, such as those beyond a shallow boundary, act as roots; `cache` avoids traversing shared history
680/// repeatedly.
681fn commit_depth(
682    id: ObjectId,
683    parents: &BTreeMap<ObjectId, Vec<ObjectId>>,
684    cache: &mut BTreeMap<ObjectId, usize>,
685) -> usize {
686    if let Some(depth) = cache.get(&id) {
687        return *depth;
688    }
689    let depth = parents
690        .get(&id)
691        .into_iter()
692        .flatten()
693        .map(|parent| commit_depth(*parent, parents, cache) + 1)
694        .max()
695        .unwrap_or_default();
696    cache.insert(id, depth);
697    depth
698}
699
700fn portable_path(path: &Path) -> Cow<'_, BStr> {
701    gix_path::to_unix_separators_on_windows(gix_path::into_bstr(path))
702}
703
704fn worktree(root: Option<&Path>) -> Result<Vec<WorktreeEntry>> {
705    let Some(root) = root else {
706        return Ok(Vec::new());
707    };
708    let mut out = Vec::new();
709    visit_worktree(root, root, &mut out)?;
710    out.sort_by(|a, b| a.path.cmp(&b.path));
711    Ok(out)
712}
713
714fn visit_worktree(root: &Path, directory: &Path, out: &mut Vec<WorktreeEntry>) -> Result<()> {
715    let mut entries: Vec<_> = fs::read_dir(directory)?.collect::<std::io::Result<_>>()?;
716    entries.sort_by_key(fs::DirEntry::file_name);
717    for entry in entries {
718        if entry.file_name() == OsStr::new(".git") {
719            continue;
720        }
721        let path = entry.path();
722        let metadata = fs::symlink_metadata(&path)?;
723        let relative = path.strip_prefix(root)?.to_owned();
724        let kind = if metadata.file_type().is_symlink() {
725            WorktreeEntryKind::Symlink(fs::read_link(&path)?)
726        } else if metadata.is_dir() {
727            WorktreeEntryKind::Directory
728        } else {
729            WorktreeEntryKind::File(fs::read(&path)?)
730        };
731        out.push(WorktreeEntry {
732            path: relative,
733            kind,
734            unix_mode: unix_mode(&metadata),
735        });
736        if metadata.is_dir() {
737            visit_worktree(root, &path, out)?;
738        }
739    }
740    Ok(())
741}
742
743#[cfg(unix)]
744fn unix_mode(metadata: &fs::Metadata) -> Option<u32> {
745    use std::os::unix::fs::PermissionsExt;
746    Some(metadata.permissions().mode())
747}
748
749#[cfg(not(unix))]
750fn unix_mode(_metadata: &fs::Metadata) -> Option<u32> {
751    None
752}