Skip to main content

git_policy_cli/
repo_read_layout.rs

1//! What:
2//!  The repository location that `git rev-parse --path-format=absolute --is-bare-repository --git-dir --git-common-dir
3//!  --show-toplevel --show-prefix` prints, found from the file system without a Git process.
4//! Why:
5//!  Every wrapped command asks for it, and it is the only extra Git start of most forwarded commands.
6//!  The reader walks up from the directory looking for `.git`, checks what Git checks for the plain layouts
7//!  (a valid `HEAD`, the objects and refs directories, files ref storage, a known object format, the owner of every
8//!  path, no `core.bare` that is true and no `core.worktree` in any scope), and returns `None` for everything else, which
9//!  sends the caller to Git: a bare repository, `GIT_*` variables, a symbolic link, another file system, reftable, an
10//!  unknown extension, a submodule that sets `core.worktree`.
11//!  With `extensions.worktreeConfig` on, the worktree's own `config.worktree` is read too, as Git reads it
12//!  (`Documentation/config/extensions.adoc`; `read_and_verify_repository_format` in `setup.c` of Git 2.56.0):
13//!  its `core.bare` overrides the common file's, its entries are checked like the common file's, and its includes are
14//!  scanned and never interpreted.
15//!
16//! In TS you'd write (pseudocode):
17//! ```ts
18//! const layout: RepoLayout | undefined = readLayout({ globalPrefix, overlay }); // undefined: ask Git
19//! ```
20
21use super::child_environment::{COUNT_VARIABLE, environment_value, parse_config_count};
22use super::private_storage::read_regular_file;
23use super::repo_read_hook::enabled;
24use super::repo_read_config_scan::{ScopeEnvironment, discovery_key, entries_avoid_keys, outer_scopes_avoid_keys, read_following};
25use super::repo_read_config_text::{CONFIG_TEXT_LIMIT, ConfigEntry, last_entry, parse_bool, parse_config_text};
26use super::repo_read_config_typed::{entries_are_accepted, environment_is_accepted, outer_scopes_are_accepted};
27use std::ffi::OsString;
28use std::path::{Path, PathBuf};
29
30/// The environment variables a plain read tolerates, by prefix; any other `GIT_` variable can move a path or a key.
31const PLAIN_ENVIRONMENT_PREFIXES: [&str; 19] = [
32    "GIT_AUTHOR_",
33    "GIT_COMMITTER_",
34    "GIT_TERMINAL_PROMPT",
35    "GIT_ASKPASS",
36    "GIT_PAGER",
37    "GIT_EDITOR",
38    "GIT_SEQUENCE_EDITOR",
39    "GIT_EXEC_PATH",
40    "GIT_SSH",
41    "GIT_ALLOW_PROTOCOL",
42    "GIT_LFS_",
43    "GIT_CONFIG_NOSYSTEM",
44    "GIT_CONFIG_GLOBAL",
45    "GIT_CONFIG_SYSTEM",
46    "GIT_CONFIG_COUNT",
47    "GIT_CONFIG_KEY_",
48    "GIT_CONFIG_VALUE_",
49    "GIT_MERGE_AUTOEDIT",
50    "GIT_OPTIONAL_LOCKS",
51];
52
53/// The prefix of the variables this package owns (`GIT_POLICY_NATIVE_IMAGE_TAG` keeps the images of parallel runs apart);
54///  Git defines none of them, so they cannot move a path or a key.
55pub const WRAPPER_ENVIRONMENT_PREFIX: &str = "GIT_POLICY_";
56
57/// The per-worktree configuration file in a worktree's own Git directory.
58const WORKTREE_CONFIG_NAME: &str = "config.worktree";
59
60/// The longest `HEAD` or `commondir` or gitfile this reader looks at.
61const SMALL_FILE_LIMIT: u64 = 4096;
62
63/// How many environment-supplied configuration entries the reader inspects before it declines.
64const ENV_CONFIG_ENTRY_LIMIT: u64 = 1024;
65
66/// The hash algorithms of object names.
67#[derive(Clone, Copy, Debug, Eq, PartialEq)]
68pub enum ObjectFormat {
69    /// 20-byte names, 40 hexadecimal digits.
70    Sha1,
71    /// 32-byte names, 64 hexadecimal digits.
72    Sha256,
73}
74
75impl ObjectFormat {
76    /// The name `rev-parse --show-object-format` prints.
77    pub fn name(self) -> &'static str {
78        return match self {
79            ObjectFormat::Sha1 => "sha1",
80            ObjectFormat::Sha256 => "sha256",
81        };
82    }
83
84    /// The number of hexadecimal digits of one object name.
85    pub fn hex_length(self) -> usize {
86        return match self {
87            ObjectFormat::Sha1 => 40,
88            ObjectFormat::Sha256 => 64,
89        };
90    }
91}
92
93/// What:
94///  Where one worktree and its repository are, as Git reports them with absolute paths.
95/// Why:
96///   One value answers the location, the Git paths and the object format queries of a command.
97///
98/// In TS you'd write (pseudocode):
99/// ```ts
100/// type RepoLayout = { gitDir: string; commonDir: string; worktreeRoot: string; prefix: Uint8Array; objectFormat: 'sha1' | 'sha256' };
101/// ```
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct RepoLayout {
104    /// The real path of the worktree's Git directory.
105    pub git_dir: PathBuf,
106    /// The real path of the directory that holds the objects and the shared refs.
107    pub common_dir: PathBuf,
108    /// The real path of the worktree's top level.
109    pub worktree_root: PathBuf,
110    /// The start directory below the top level, with a trailing `/`, or empty at the top level.
111    pub prefix: Vec<u8>,
112    /// The object hash of the repository.
113    pub object_format: ObjectFormat,
114}
115
116/// The keys an environment-supplied configuration entry may not set without the reader declining.
117fn env_key_moves_discovery(key: &str) -> bool {
118    let lowered: String = key.to_ascii_lowercase();
119    return lowered == "core.bare" || lowered == "core.worktree" || lowered.starts_with("include");
120}
121
122/// Whether the `GIT_CONFIG_COUNT` entries of `variables` (the wrapper injects some) all leave discovery alone.
123fn env_config_is_plain(variables: &[(OsString, OsString)]) -> bool {
124    return env_config_avoids(variables, &env_key_moves_discovery);
125}
126
127/// What:
128///  Whether every environment-supplied configuration entry has a key `declines` does not name;
129///  a malformed set of entries is not plain either.
130/// Why:
131///   The content queries use the same walk with their own key list, so an injected `diff.orderFile` is not missed.
132///
133/// In TS you'd write (pseudocode):
134/// ```ts
135/// envConfigKeys(variables).every((key) => !declines(key)); // false when malformed
136/// ```
137pub fn env_config_avoids(variables: &[(OsString, OsString)], declines: &dyn Fn(&str) -> bool) -> bool {
138    let Some(count_text) = environment_value(variables, COUNT_VARIABLE) else {
139        return true;
140    };
141    let Some(count) = parse_config_count(count_text.as_os_str()) else {
142        return false;
143    };
144    if count > ENV_CONFIG_ENTRY_LIMIT {
145        return false;
146    }
147    for index in 0..count {
148        let Some(key) = environment_value(variables, format!("GIT_CONFIG_KEY_{index}").as_str()) else {
149            return false;
150        };
151        match key.to_str() {
152            Some(text) if !declines(text) => {}
153            _ => return false,
154        }
155    }
156    return true;
157}
158
159/// Whether every `GIT_` variable among `variables` is one that cannot move discovery:
160///  a tolerated prefix, or one of this package's own.
161fn environment_is_plain(variables: &[(OsString, OsString)]) -> bool {
162    for (name, _) in variables {
163        let Some(text) = name.to_str() else {
164            continue;
165        };
166        let tolerated: bool = text.starts_with(WRAPPER_ENVIRONMENT_PREFIX) || PLAIN_ENVIRONMENT_PREFIXES.iter().any(|prefix| { return text.starts_with(prefix); });
167        if text.starts_with("GIT_") && !tolerated {
168            return false;
169        }
170    }
171    return env_config_is_plain(variables);
172}
173
174/// The environment Git would see: the process environment with the child overlay applied over it.
175fn effective_environment(overlay: &[(OsString, OsString)]) -> Vec<(OsString, OsString)> {
176    let mut variables: Vec<(OsString, OsString)> = std::env::vars_os()
177        .filter(|(name, _)| { return !overlay.iter().any(|(added, _)| { return added == name; }); })
178        .collect();
179    variables.extend(overlay.iter().cloned());
180    return variables;
181}
182
183/// The directory a command starts in: the process directory moved by leading `-C <path>` options.
184///  Any other global option returns `None`.
185fn start_directory(global_prefix: &[OsString]) -> Option<PathBuf> {
186    let mut directory: PathBuf = std::env::current_dir().ok()?;
187    let mut options = global_prefix.iter();
188    while let Some(option) = options.next() {
189        if option != "-C" {
190            return None;
191        }
192        let path: &OsString = options.next()?;
193        if !path.is_empty() {
194            directory = directory.join(path);
195        }
196        // Git changes directory at each `-C` in turn and fails at the first one that is not a directory.
197        if !std::fs::metadata(&directory).is_ok_and(|found| { return found.is_dir(); }) {
198            return None;
199        }
200    }
201    return std::fs::canonicalize(directory).ok();
202}
203
204/// A small regular file's text, refusing links.
205fn small_file(path: &Path) -> Option<String> {
206    let bytes: Vec<u8> = read_regular_file(path, SMALL_FILE_LIMIT).ok()?;
207    return String::from_utf8(bytes).ok();
208}
209
210/// Whether `directory` holds the three entries Git takes for a Git directory.
211fn has_git_directory_entries(directory: &Path) -> bool {
212    return directory.join("HEAD").exists() && directory.join("objects").is_dir() && directory.join("refs").is_dir();
213}
214
215/// Whether the `HEAD` text is one of the two spellings Git accepts that this reader knows.
216fn head_is_valid(text: &str, format: ObjectFormat) -> bool {
217    let Some(line) = text.strip_suffix('\n') else {
218        return false;
219    };
220    if let Some(name) = line.strip_prefix("ref: ") {
221        return name.starts_with("refs/") && !name.contains('\n');
222    }
223    return line.len() == format.hex_length() && line.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
224}
225
226/// Whether `path` is owned by the effective user, as Git's ownership check requires.
227#[cfg(unix)]
228fn owned_by_current_user(path: &Path) -> bool {
229    use std::os::unix::fs::MetadataExt;
230    let Ok(metadata) = std::fs::symlink_metadata(path) else {
231        return false;
232    };
233    // SAFETY: `geteuid` takes no argument, touches no memory and cannot fail.
234    return metadata.uid() == unsafe { libc::geteuid() };
235}
236
237/// Without user ids, every repository is left to Git.
238#[cfg(not(unix))]
239fn owned_by_current_user(_path: &Path) -> bool {
240    return false;
241}
242
243/// The device a directory lives on, so a walk can stop where Git stops.
244#[cfg(unix)]
245fn device_of(path: &Path) -> Option<u64> {
246    use std::os::unix::fs::MetadataExt;
247    return std::fs::metadata(path).ok().map(|metadata| { return metadata.dev(); });
248}
249
250/// Without device numbers, every walk is left to Git.
251#[cfg(not(unix))]
252fn device_of(_path: &Path) -> Option<u64> {
253    return None;
254}
255
256/// The Git directory a `.git` entry names: the entry itself when it is a directory,
257///  the `gitdir:` line of a plain file, `None` for a link or anything else.
258fn dot_git_target(worktree: &Path) -> Option<Option<(PathBuf, bool)>> {
259    let dot_git: PathBuf = worktree.join(".git");
260    let metadata = match std::fs::symlink_metadata(&dot_git) {
261        Ok(found) => found,
262        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(None),
263        Err(_) => return None,
264    };
265    if metadata.is_dir() {
266        return Some(Some((dot_git, false)));
267    }
268    if !metadata.is_file() {
269        return None;
270    }
271    let text: String = small_file(dot_git.as_path())?;
272    let target: &str = text.strip_prefix("gitdir: ")?.strip_suffix('\n')?;
273    if target.is_empty() || target.contains('\n') {
274        return None;
275    }
276    return Some(Some((worktree.join(target), true)));
277}
278
279/// What:
280///  The repository format keys the layout needs from the repository's own configuration file:
281///  the object format, and whether `extensions.worktreeConfig` turns the per-worktree file on.
282/// Why:
283///   The object format decides the length of a name; any other extension may change how the repository is read.
284///  Git respects `extensions.worktreeConfig` at every `core.repositoryFormatVersion`
285///  (`Documentation/config/extensions.adoc`, `handle_extension_v0` in `setup.c`); the last entry decides, as a boolean.
286///
287/// In TS you'd write (pseudocode):
288/// ```ts
289/// const format: { objectFormat: ObjectFormat; worktreeConfig: boolean } | undefined = readFormat(entries); // undefined: ask Git
290/// ```
291fn format_of(entries: &[ConfigEntry]) -> Option<(ObjectFormat, bool)> {
292    // A bare `core.repositoryformatversion` is an error in Git, not an absent key.
293    let version: Option<&[u8]> = match last_entry(entries, "core.repositoryformatversion") {
294        Some(entry) => Some(entry.value.as_deref()?),
295        None => None,
296    };
297    let version_one: bool = match version {
298        None | Some(b"0") => false,
299        Some(b"1") => true,
300        Some(_) => return None,
301    };
302    let mut format: ObjectFormat = ObjectFormat::Sha1;
303    let mut worktree_config: bool = false;
304    for entry in entries.iter().filter(|entry| { return entry.key.starts_with("extensions."); }) {
305        if entry.key == "extensions.worktreeconfig" {
306            worktree_config = parse_bool(entry.value.as_deref())?;
307            continue;
308        }
309        if !version_one {
310            return None;
311        }
312        let value: &[u8] = entry.value.as_deref().unwrap_or_default();
313        match (entry.key.as_str(), value) {
314            ("extensions.noop", _) => {}
315            ("extensions.preciousobjects", _) if parse_bool(entry.value.as_deref()).is_some() => {}
316            ("extensions.objectformat", b"sha1") => format = ObjectFormat::Sha1,
317            ("extensions.objectformat", b"sha256") => format = ObjectFormat::Sha256,
318            ("extensions.refstorage", b"files") => {}
319            _ => return None,
320        }
321    }
322    return Some((format, worktree_config));
323}
324
325/// What:
326///  The entries of the worktree's own `config.worktree`, or none when the extension is off or the file is absent;
327///  `None` when the file cannot be read or parsed.
328/// Why:
329///   Git reads `<git dir>/config.worktree` (the worktree's own Git directory, not the common one) after the common
330///  file, and only when `extensions.worktreeConfig` is on; a missing file is an empty file.
331///
332/// In TS you'd write (pseudocode):
333/// ```ts
334/// const entries = enabled ? parse(await readIfExists(join(gitDir, 'config.worktree'))) : []; // undefined: ask Git
335/// ```
336fn worktree_entries(git_dir: &Path, enabled: bool) -> Option<Vec<ConfigEntry>> {
337    if !enabled {
338        return Some(Vec::new());
339    }
340    return match read_following(git_dir.join(WORKTREE_CONFIG_NAME).as_path())? {
341        Some(bytes) => parse_config_text(bytes.as_slice()),
342        None => Some(Vec::new()),
343    };
344}
345
346/// What:
347///  The entries with every direct `core.bare` removed when the last one leaves the repository non-bare;
348///  `None` when it makes the repository bare or any of them is not a boolean.
349/// Why:
350///   Git takes the last `core.bare` of the common file then the per-worktree file (`read_worktree_config` in
351///  `setup.c`); a bare repository, an unreadable boolean and a `core.worktree` are left to Git by the key scan.
352///
353/// In TS you'd write (pseudocode):
354/// ```ts
355/// const kept = lastBare(files) === true ? undefined : files.map((entries) => entries.filter((e) => e.key !== 'core.bare'));
356/// ```
357fn without_false_bare(files: [&[ConfigEntry]; 2]) -> Option<[Vec<ConfigEntry>; 2]> {
358    let mut last: Option<bool> = None;
359    for entry in files.iter().flat_map(|entries| { return entries.iter(); }).filter(|entry| { return entry.key == "core.bare"; }) {
360        last = Some(parse_bool(entry.value.as_deref())?);
361    }
362    if last == Some(true) {
363        return None;
364    }
365    return Some(files.map(|entries| {
366        return entries.iter().filter(|entry| { return entry.key != "core.bare"; }).cloned().collect::<Vec<ConfigEntry>>();
367    }));
368}
369
370/// What:
371///  Check the discovered Git directory and build the layout.
372/// Why:
373///   Everything beyond the plain layout returns `None`.
374///
375/// In TS you'd write (pseudocode):
376/// ```ts
377/// function finish(start: string, worktree: string, gitDir: string, fromFile: boolean): RepoLayout | undefined;
378/// ```
379fn finish(start: &Path, worktree: &Path, entry: (PathBuf, bool), scope: &ScopeEnvironment) -> Option<RepoLayout> {
380    let (target, from_file) = entry;
381    let git_dir: PathBuf = std::fs::canonicalize(target).ok()?;
382    let dot_git: PathBuf = worktree.join(".git");
383    let owners_ok: bool = owned_by_current_user(worktree)
384        && owned_by_current_user(git_dir.as_path())
385        && (!from_file || owned_by_current_user(dot_git.as_path()));
386    if !owners_ok {
387        return None;
388    }
389    let common_dir: PathBuf = match small_file(git_dir.join("commondir").as_path()) {
390        Some(text) => std::fs::canonicalize(git_dir.join(text.strip_suffix('\n')?)).ok()?,
391        None if git_dir.join("commondir").exists() => return None,
392        None => git_dir.clone(),
393    };
394    if git_dir.join("reftable").exists() || common_dir.join("reftable").exists() || !has_git_directory_entries_split(&git_dir, &common_dir) {
395        return None;
396    }
397    let config_bytes: Vec<u8> = read_regular_file(common_dir.join("config").as_path(), CONFIG_TEXT_LIMIT as u64).ok()?;
398    let entries: Vec<ConfigEntry> = parse_config_text(config_bytes.as_slice())?;
399    let (object_format, worktree_config): (ObjectFormat, bool) = format_of(entries.as_slice())?;
400    let worktree_path: PathBuf = git_dir.join(WORKTREE_CONFIG_NAME);
401    let per_worktree: Vec<ConfigEntry> = worktree_entries(git_dir.as_path(), worktree_config)?;
402
403    // The repository's own file carries `bare = false` after `init`; that spelling is the plain layout. With the
404    // per-worktree file on, the last `core.bare` of the two files decides, and a `true` there is a bare repository.
405    let [discoverable, worktree_discoverable] = without_false_bare([entries.as_slice(), per_worktree.as_slice()])?;
406    let local_ok: bool = entries_avoid_keys(discoverable.as_slice(), common_dir.join("config").as_path(), scope.home.as_deref(), 0, &discovery_key)?
407        && entries_avoid_keys(worktree_discoverable.as_slice(), worktree_path.as_path(), scope.home.as_deref(), 0, &discovery_key)?;
408    if !local_ok || !outer_scopes_avoid_keys(scope, &discovery_key)? {
409        return None;
410    }
411    // Git types many keys for every command and fails, or warns, on a value that does not fit; the readers leave
412    // such a repository to Git, which words its own failure.
413    let typed_ok: bool = entries_are_accepted(entries.as_slice(), common_dir.join("config").as_path(), scope.home.as_deref(), 0)?
414        && entries_are_accepted(per_worktree.as_slice(), worktree_path.as_path(), scope.home.as_deref(), 0)?
415        && outer_scopes_are_accepted(scope)?;
416    if !typed_ok {
417        return None;
418    }
419    let head: String = small_file(git_dir.join("HEAD").as_path())?;
420    if !head_is_valid(head.as_str(), object_format) {
421        return None;
422    }
423    let relative: &Path = start.strip_prefix(worktree).ok()?;
424    let mut prefix: Vec<u8> = Vec::new();
425    for component in relative.components() {
426        prefix.extend_from_slice(component.as_os_str().as_encoded_bytes());
427        prefix.push(b'/');
428    }
429    return Some(RepoLayout { git_dir, common_dir, worktree_root: worktree.to_path_buf(), prefix, object_format });
430}
431
432/// The three-entry check, with `HEAD` in the worktree's directory and the rest in the common directory.
433fn has_git_directory_entries_split(git_dir: &Path, common_dir: &Path) -> bool {
434    return git_dir.join("HEAD").exists() && common_dir.join("objects").is_dir() && common_dir.join("refs").is_dir();
435}
436
437/// What:
438///  The layout of the repository a command run with `global_prefix` and `overlay` starts in,
439///  or `None` when Git must be asked.
440/// Why:
441///   This is the one entry point the callers use; it never errors, because "cannot tell" and "not a repository" both
442///  mean "ask Git", which words the failure itself.
443///
444/// In TS you'd write (pseudocode):
445/// ```ts
446/// const layout = readLayout(globalPrefix, overlay); // undefined: ask Git
447/// ```
448pub fn read_layout(global_prefix: &[OsString], overlay: &[(OsString, OsString)]) -> Option<RepoLayout> {
449    if !enabled(overlay) {
450        return None;
451    }
452    let variables: Vec<(OsString, OsString)> = effective_environment(overlay);
453    if !environment_is_plain(variables.as_slice()) || !environment_is_accepted(variables.as_slice()) {
454        return None;
455    }
456    let start: PathBuf = start_directory(global_prefix)?;
457    return walk_up(start.as_path(), &ScopeEnvironment::from_variables(variables.as_slice()));
458}
459
460/// What:
461///  The read with its environment injected: the variables Git would see and the configuration scope facts.
462/// Why:
463///   The controls run in a process whose own environment and home they must not depend on.
464///
465/// In TS you'd write (pseudocode):
466/// ```ts
467/// const layout = readLayoutWith(start, variables, scope); // undefined: ask Git
468/// ```
469pub fn read_layout_with(start: &Path, variables: &[(OsString, OsString)], scope: &ScopeEnvironment) -> Option<RepoLayout> {
470    if !environment_is_plain(variables) || !environment_is_accepted(variables) {
471        return None;
472    }
473    return walk_up(std::fs::canonicalize(start).ok()?.as_path(), scope);
474}
475
476/// What:
477///  The same read for a command run in `directory` without global options.
478/// Why:
479///   The transaction Git requests name their directory and leave the invocation's global options out.
480///
481/// In TS you'd write (pseudocode):
482/// ```ts
483/// const layout = readLayoutIn(directory, overlay); // undefined: ask Git
484/// ```
485pub fn read_layout_in(directory: &Path, overlay: &[(OsString, OsString)]) -> Option<RepoLayout> {
486    if !enabled(overlay) {
487        return None;
488    }
489    let variables: Vec<(OsString, OsString)> = effective_environment(overlay);
490    return read_layout_with(directory, variables.as_slice(), &ScopeEnvironment::from_variables(variables.as_slice()));
491}
492
493/// Walk from `start` to the root looking for the first `.git`, as Git does, and build the layout there.
494fn walk_up(start: &Path, scope: &ScopeEnvironment) -> Option<RepoLayout> {
495    let mut directory: PathBuf = start.to_path_buf();
496    loop {
497        if let Some(entry) = dot_git_target(directory.as_path())? {
498            return finish(start, directory.as_path(), entry, scope);
499        }
500        if has_git_directory_entries(directory.as_path()) {
501            return None;
502        }
503        let parent: PathBuf = directory.parent()?.to_path_buf();
504        if device_of(parent.as_path())? != device_of(directory.as_path())? {
505            return None;
506        }
507        directory = parent;
508    }
509}
510
511/// What:
512///  The absolute path `git rev-parse --path-format=absolute --git-path <name>` prints for the names this reader knows
513///  in a repository whose worktree owns its Git directory.
514/// Why:
515///   A linked worktree splits names between its own directory and the common one by a table in Git;
516///  those answers stay with Git.
517///
518/// In TS you'd write (pseudocode):
519/// ```ts
520/// const gitPath = (layout, name) => layout.gitDir === layout.commonDir && KNOWN.has(name) ? join(layout.gitDir, name) : undefined;
521/// ```
522pub fn git_path(layout: &RepoLayout, name: &str) -> Option<PathBuf> {
523    const KNOWN: [&str; 9] = [
524        "index",
525        "objects",
526        "HEAD",
527        "MERGE_HEAD",
528        "CHERRY_PICK_HEAD",
529        "REVERT_HEAD",
530        "cli-git-transactions",
531        "packed-refs",
532        "config",
533    ];
534    if layout.git_dir != layout.common_dir || !KNOWN.contains(&name) {
535        return None;
536    }
537    let path: PathBuf = layout.git_dir.join(name);
538    // With `--path-format=absolute` Git prints the target of a link, so a linked name is left to Git.
539    if std::fs::symlink_metadata(path.as_path()).is_ok_and(|metadata| { return metadata.file_type().is_symlink(); }) {
540        return None;
541    }
542    return Some(path);
543}
544
545#[cfg(test)]
546#[path = "repo_read_layout_tests.rs"]
547mod tests;
548
549#[cfg(test)]
550#[path = "repo_read_layout_worktree_tests.rs"]
551mod worktree_tests;