Skip to main content

git_policy_cli/repo_read/
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 crate::child_environment::{COUNT_VARIABLE, environment_value, parse_config_count};
22use crate::private_storage::read_regular_file;
23use crate::repo_read_hook::enabled;
24use crate::repo_read_config_scan::{ScopeEnvironment, discovery_key, entries_avoid_keys, outer_scopes_avoid_keys, read_following};
25use crate::repo_read_config_text::{CONFIG_TEXT_LIMIT, ConfigEntry, last_entry, parse_bool, parse_config_text};
26use crate::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.
31///  The first group is the original list. The second group was added after reading what Git 2.56.0 does with each name
32///  (`environment.h`, and the file named for each); none can change which configuration entry, attribute tree, repository,
33///  index, object, ref or pathspec meaning an answer rests on, since each only traces, words output, reaches a network or a tool,
34///  or sets a limit or a write-time choice:
35///  Tracing and output: `GIT_TRACE` and every `GIT_TRACE2`, `GIT_TRACE_*` name (`trace.c` line 33, `trace2/tr2_sysenv.c`
36///   lines 32 and 34), `GIT_FLUSH` (`write-or-die.c` line 25), `GIT_PROGRESS_DELAY` (`progress.c` line 286),
37///   `GIT_PRINT_SHA1_ELLIPSIS` (`environment.c` line 242), `GIT_MERGE_VERBOSITY` (`merge-ort.c` line 5516), `GIT_ADVICE`
38///   (`advice.c` line 139), `GIT_REFLOG_ACTION` (`sequencer.c` line 53), `GIT_CHERRY_PICK_HELP` (`sequencer.c` line 527);
39///  Network and transport: `GIT_CURL_*` (`http.c` line 1494), `GIT_HTTP_*` (`http.c` lines 1475 to 1477), `GIT_SSL_*`
40///   (`http.c` line 1465), `GIT_PROXY_*` (`connect.c` line 1046), `GIT_TCP_KEEP*` (`http.c` line 1507), `GIT_USER_AGENT`
41///   (`version.c` line 33), `GIT_OVERRIDE_VIRTUAL_HOST` (`connect.c` line 1276), `GIT_PROTOCOL_FROM_USER` (`transport.c` line 1182);
42///  Tools: `GIT_EXTERNAL_DIFF` (`diff.c` line 565), `GIT_DIFF_*` (`diff.c` line 4045, `builtin/difftool.c` lines 792 and 799),
43///   `GIT_MERGETOOL_GUI` (`builtin/difftool.c` line 786), `GIT_MAN_VIEWER` (`builtin/help.c` line 521), `GIT_SHELL_PATH`
44///   (`builtin/var.c` line 149);
45///  Limits and write-time choices: `GIT_ALLOC_LIMIT` (`wrapper.c` line 25), `GIT_MMAP_LIMIT` (`wrapper.c` line 947),
46///   `GIT_INDEX_VERSION` (`read-cache.c` line 1646, the version Git writes; the index reader takes versions 2 to 4),
47///   `GIT_BASENAME_FACTOR` (`diffcore-rename.c` line 1497), `GIT_COMMIT_GRAPH_PARANOIA` (`commit-graph.c` line 1028),
48///   `GIT_DISABLE_UNTRACKED_CACHE` and `GIT_FORCE_UNTRACKED_CACHE` (`dir.c` lines 2988 and 3174),
49///   `GIT_AUTO_COMMENT_CHAR_CONFIG_WARNING_GIVEN` (`config.c` line 2260, only suppresses a warning).
50///  Left out because they can move an answer: `GIT_DIR`, `GIT_WORK_TREE`, `GIT_COMMON_DIR`, `GIT_PREFIX`, `GIT_IMPLICIT_WORK_TREE`,
51///  `GIT_INDEX_FILE`, `GIT_OBJECT_DIRECTORY`, `GIT_ALTERNATE_OBJECT_DIRECTORIES`, `GIT_CEILING_DIRECTORIES`,
52///  `GIT_DISCOVERY_ACROSS_FILESYSTEM`, `GIT_NAMESPACE`, `GIT_CONFIG`, `GIT_CONFIG_PARAMETERS`, `GIT_ATTR_*`, `GIT_NO_REPLACE_OBJECTS`,
53///  `GIT_REPLACE_REF_BASE`, `GIT_GRAFT_FILE`, `GIT_SHALLOW_FILE`, `GIT_QUARANTINE_PATH`, `GIT_REFERENCE_BACKEND`, `GIT_REF_PARANOIA`,
54///  the four `GIT_*_PATHSPECS`, `GIT_TEMPLATE_DIR`, `GIT_TEXTDOMAINDIR` (the wording the synthesized failures assume),
55///  `GIT_NOTES_*`, `GIT_NO_LAZY_FETCH`, `GIT_DEFAULT_*` and every `GIT_TEST_*`.
56const PLAIN_ENVIRONMENT_PREFIXES: &[&str] = &[
57    "GIT_AUTHOR_",
58    "GIT_COMMITTER_",
59    "GIT_TERMINAL_PROMPT",
60    "GIT_ASKPASS",
61    "GIT_PAGER",
62    "GIT_EDITOR",
63    "GIT_SEQUENCE_EDITOR",
64    "GIT_EXEC_PATH",
65    "GIT_SSH",
66    "GIT_ALLOW_PROTOCOL",
67    "GIT_LFS_",
68    "GIT_CONFIG_NOSYSTEM",
69    "GIT_CONFIG_GLOBAL",
70    "GIT_CONFIG_SYSTEM",
71    "GIT_CONFIG_COUNT",
72    "GIT_CONFIG_KEY_",
73    "GIT_CONFIG_VALUE_",
74    "GIT_MERGE_AUTOEDIT",
75    "GIT_OPTIONAL_LOCKS",
76    "GIT_TRACE",
77    "GIT_FLUSH",
78    "GIT_PROGRESS_DELAY",
79    "GIT_PRINT_SHA1_ELLIPSIS",
80    "GIT_MERGE_VERBOSITY",
81    "GIT_ADVICE",
82    "GIT_REFLOG_ACTION",
83    "GIT_CHERRY_PICK_HELP",
84    "GIT_CURL_",
85    "GIT_HTTP_",
86    "GIT_SSL_",
87    "GIT_PROXY_",
88    "GIT_TCP_KEEP",
89    "GIT_USER_AGENT",
90    "GIT_OVERRIDE_VIRTUAL_HOST",
91    "GIT_PROTOCOL_FROM_USER",
92    "GIT_EXTERNAL_DIFF",
93    "GIT_DIFF_",
94    "GIT_DIFFTOOL_",
95    "GIT_MERGETOOL_GUI",
96    "GIT_MAN_VIEWER",
97    "GIT_SHELL_PATH",
98    "GIT_ALLOC_LIMIT",
99    "GIT_MMAP_LIMIT",
100    "GIT_INDEX_VERSION",
101    "GIT_BASENAME_FACTOR",
102    "GIT_COMMIT_GRAPH_PARANOIA",
103    "GIT_DISABLE_UNTRACKED_CACHE",
104    "GIT_FORCE_UNTRACKED_CACHE",
105    "GIT_AUTO_COMMENT_CHAR_CONFIG_WARNING_GIVEN",
106];
107
108/// The prefix of the variables this package owns (`GIT_POLICY_NATIVE_IMAGE_TAG` keeps the images of parallel runs apart);
109///  Git defines none of them, so they cannot move a path or a key.
110pub const WRAPPER_ENVIRONMENT_PREFIX: &str = "GIT_POLICY_";
111
112/// The per-worktree configuration file in a worktree's own Git directory.
113const WORKTREE_CONFIG_NAME: &str = "config.worktree";
114
115/// The longest `HEAD` or `commondir` or gitfile this reader looks at.
116///  Exempt from a citation under `LQM` (class: reader fallback): a file, chain or count past this bound makes the reader
117///  decline and real Git answer, so a Git start is added and no result changes. Classed once in
118///  `doc/planning/cli-git-shipped-limits-audit.md`, "Exempt limits"; the coordinator's pick under the owner's delegation,
119///  open to the owner's veto.
120const SMALL_FILE_LIMIT: u64 = 4096;
121
122/// How many environment-supplied configuration entries the reader inspects before it declines.
123///  Exempt from a citation under `LQM` (class: reader fallback): a file, chain or count past this bound makes the reader
124///  decline and real Git answer, so a Git start is added and no result changes. Classed once in
125///  `doc/planning/cli-git-shipped-limits-audit.md`, "Exempt limits"; the coordinator's pick under the owner's delegation,
126///  open to the owner's veto.
127const ENV_CONFIG_ENTRY_LIMIT: u64 = 1024;
128
129/// The hash algorithms of object names.
130#[derive(Clone, Copy, Debug, Eq, PartialEq)]
131pub enum ObjectFormat {
132    /// 20-byte names, 40 hexadecimal digits.
133    Sha1,
134    /// 32-byte names, 64 hexadecimal digits.
135    Sha256,
136}
137
138impl ObjectFormat {
139    /// The name `rev-parse --show-object-format` prints.
140    pub fn name(self) -> &'static str {
141        return match self {
142            ObjectFormat::Sha1 => "sha1",
143            ObjectFormat::Sha256 => "sha256",
144        };
145    }
146
147    /// The number of hexadecimal digits of one object name.
148    pub fn hex_length(self) -> usize {
149        return match self {
150            ObjectFormat::Sha1 => 40,
151            ObjectFormat::Sha256 => 64,
152        };
153    }
154}
155
156/// What:
157///  Where one worktree and its repository are, as Git reports them with absolute paths.
158/// Why:
159///   One value answers the location, the Git paths and the object format queries of a command.
160///
161/// In TS you'd write (pseudocode):
162/// ```ts
163/// type RepoLayout = { gitDir: string; commonDir: string; worktreeRoot: string; prefix: Uint8Array; objectFormat: 'sha1' | 'sha256' };
164/// ```
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct RepoLayout {
167    /// The real path of the worktree's Git directory.
168    pub git_dir: PathBuf,
169    /// The real path of the directory that holds the objects and the shared refs.
170    pub common_dir: PathBuf,
171    /// The real path of the worktree's top level.
172    pub worktree_root: PathBuf,
173    /// The start directory below the top level, with a trailing `/`, or empty at the top level.
174    pub prefix: Vec<u8>,
175    /// The object hash of the repository.
176    pub object_format: ObjectFormat,
177}
178
179/// The keys an environment-supplied configuration entry may not set without the reader declining.
180fn env_key_moves_discovery(key: &str) -> bool {
181    let lowered: String = key.to_ascii_lowercase();
182    return lowered == "core.bare" || lowered == "core.worktree" || lowered.starts_with("include");
183}
184
185/// Whether the `GIT_CONFIG_COUNT` entries of `variables` (the wrapper injects some) all leave discovery alone.
186fn env_config_is_plain(variables: &[(OsString, OsString)]) -> bool {
187    return env_config_avoids(variables, &env_key_moves_discovery);
188}
189
190/// What:
191///  Whether every environment-supplied configuration entry has a key `declines` does not name;
192///  a malformed set of entries is not plain either.
193/// Why:
194///   The content queries use the same walk with their own key list, so an injected `diff.orderFile` is not missed.
195///
196/// In TS you'd write (pseudocode):
197/// ```ts
198/// envConfigKeys(variables).every((key) => !declines(key)); // false when malformed
199/// ```
200pub fn env_config_avoids(variables: &[(OsString, OsString)], declines: &dyn Fn(&str) -> bool) -> bool {
201    let Some(count_text) = environment_value(variables, COUNT_VARIABLE) else {
202        return true;
203    };
204    let Some(count) = parse_config_count(count_text.as_os_str()) else {
205        return false;
206    };
207    if count > ENV_CONFIG_ENTRY_LIMIT {
208        return false;
209    }
210    for index in 0..count {
211        let Some(key) = environment_value(variables, format!("GIT_CONFIG_KEY_{index}").as_str()) else {
212            return false;
213        };
214        match key.to_str() {
215            Some(text) if !declines(text) => {}
216            _ => return false,
217        }
218    }
219    return true;
220}
221
222/// Whether every `GIT_` variable among `variables` is one that cannot move discovery:
223///  a tolerated prefix, or one of this package's own.
224pub(crate) fn environment_is_plain(variables: &[(OsString, OsString)]) -> bool {
225    for (name, _) in variables {
226        let Some(text) = name.to_str() else {
227            continue;
228        };
229        let tolerated: bool = text.starts_with(WRAPPER_ENVIRONMENT_PREFIX) || PLAIN_ENVIRONMENT_PREFIXES.iter().any(|prefix| { return text.starts_with(prefix); });
230        if text.starts_with("GIT_") && !tolerated {
231            return false;
232        }
233    }
234    return env_config_is_plain(variables);
235}
236
237/// The environment Git would see: the process environment with the child overlay applied over it.
238pub(crate) fn effective_environment(overlay: &[(OsString, OsString)]) -> Vec<(OsString, OsString)> {
239    let mut variables: Vec<(OsString, OsString)> = std::env::vars_os()
240        .filter(|(name, _)| { return !overlay.iter().any(|(added, _)| { return added == name; }); })
241        .collect();
242    variables.extend(overlay.iter().cloned());
243    return variables;
244}
245
246/// The directory a command starts in: the process directory moved by leading `-C <path>` options.
247///  Any other global option returns `None`.
248pub(crate) fn start_directory(global_prefix: &[OsString]) -> Option<PathBuf> {
249    let mut directory: PathBuf = std::env::current_dir().ok()?;
250    let mut options = global_prefix.iter();
251    while let Some(option) = options.next() {
252        if option != "-C" {
253            return None;
254        }
255        let path: &OsString = options.next()?;
256        if !path.is_empty() {
257            directory = directory.join(path);
258        }
259        // Git changes directory at each `-C` in turn and fails at the first one that is not a directory.
260        if !std::fs::metadata(&directory).is_ok_and(|found| { return found.is_dir(); }) {
261            return None;
262        }
263    }
264    return std::fs::canonicalize(directory).ok();
265}
266
267/// A small regular file's text, refusing links.
268fn small_file(path: &Path) -> Option<String> {
269    let bytes: Vec<u8> = read_regular_file(path, SMALL_FILE_LIMIT).ok()?;
270    return String::from_utf8(bytes).ok();
271}
272
273/// Whether `directory` holds the three entries Git takes for a Git directory.
274fn has_git_directory_entries(directory: &Path) -> bool {
275    return directory.join("HEAD").exists() && directory.join("objects").is_dir() && directory.join("refs").is_dir();
276}
277
278/// Whether the `HEAD` text is one of the two spellings Git accepts that this reader knows.
279fn head_is_valid(text: &str, format: ObjectFormat) -> bool {
280    let Some(line) = text.strip_suffix('\n') else {
281        return false;
282    };
283    if let Some(name) = line.strip_prefix("ref: ") {
284        return name.starts_with("refs/") && !name.contains('\n');
285    }
286    return line.len() == format.hex_length() && line.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
287}
288
289/// Whether `path` is owned by the effective user, as Git's ownership check requires.
290#[cfg(unix)]
291fn owned_by_current_user(path: &Path) -> bool {
292    use std::os::unix::fs::MetadataExt;
293    let Ok(metadata) = std::fs::symlink_metadata(path) else {
294        return false;
295    };
296    // SAFETY: `geteuid` takes no argument, touches no memory and cannot fail.
297    return metadata.uid() == unsafe { libc::geteuid() };
298}
299
300/// Without user ids, every repository is left to Git.
301#[cfg(not(unix))]
302fn owned_by_current_user(_path: &Path) -> bool {
303    return false;
304}
305
306/// The device a directory lives on, so a walk can stop where Git stops.
307#[cfg(unix)]
308fn device_of(path: &Path) -> Option<u64> {
309    use std::os::unix::fs::MetadataExt;
310    return std::fs::metadata(path).ok().map(|metadata| { return metadata.dev(); });
311}
312
313/// Without device numbers, every walk is left to Git.
314#[cfg(not(unix))]
315fn device_of(_path: &Path) -> Option<u64> {
316    return None;
317}
318
319/// The Git directory a `.git` entry names: the entry itself when it is a directory,
320///  the `gitdir:` line of a plain file, `None` for a link or anything else.
321pub fn dot_git_target(worktree: &Path) -> Option<Option<(PathBuf, bool)>> {
322    let dot_git: PathBuf = worktree.join(".git");
323    let metadata = match std::fs::symlink_metadata(&dot_git) {
324        Ok(found) => found,
325        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(None),
326        Err(_) => return None,
327    };
328    if metadata.is_dir() {
329        return Some(Some((dot_git, false)));
330    }
331    if !metadata.is_file() {
332        return None;
333    }
334    let text: String = small_file(dot_git.as_path())?;
335    // Git strips every trailing carriage return and line feed (`read_gitfile_gently` in `setup.c`).
336    let target: &str = text.strip_prefix("gitdir: ")?.trim_end_matches(['\r', '\n']);
337    if target.is_empty() || target.contains('\n') {
338        return None;
339    }
340    return Some(Some((worktree.join(target), true)));
341}
342
343/// What:
344///  The repository format keys the layout needs from the repository's own configuration file:
345///  the object format, and whether `extensions.worktreeConfig` turns the per-worktree file on.
346/// Why:
347///   The object format decides the length of a name; any other extension may change how the repository is read.
348///  Git respects `extensions.worktreeConfig` at every `core.repositoryFormatVersion`
349///  (`Documentation/config/extensions.adoc`, `handle_extension_v0` in `setup.c`); the last entry decides, as a boolean.
350///  A repository whose configuration names no `core.repositoryformatversion` has no format at all to Git, so the
351///  extension is off there.
352///
353/// In TS you'd write (pseudocode):
354/// ```ts
355/// const format: { objectFormat: ObjectFormat; worktreeConfig: boolean } | undefined = readFormat(entries); // undefined: ask Git
356/// ```
357fn format_of(entries: &[ConfigEntry]) -> Option<(ObjectFormat, bool)> {
358    // A bare `core.repositoryformatversion` is an error in Git, not an absent key.
359    let version: Option<&[u8]> = match last_entry(entries, "core.repositoryformatversion") {
360        Some(entry) => Some(entry.value.as_deref()?),
361        None => None,
362    };
363    let version_one: bool = match version {
364        None | Some(b"0") => false,
365        Some(b"1") => true,
366        Some(_) => return None,
367    };
368    let mut format: ObjectFormat = ObjectFormat::Sha1;
369    let mut worktree_config: bool = false;
370    for entry in entries.iter().filter(|entry| { return entry.key.starts_with("extensions."); }) {
371        if entry.key == "extensions.worktreeconfig" {
372            worktree_config = parse_bool(entry.value.as_deref())?;
373            continue;
374        }
375        if !version_one {
376            return None;
377        }
378        let value: &[u8] = entry.value.as_deref().unwrap_or_default();
379        match (entry.key.as_str(), value) {
380            ("extensions.noop", _) => {}
381            ("extensions.preciousobjects", _) if parse_bool(entry.value.as_deref()).is_some() => {}
382            ("extensions.objectformat", b"sha1") => format = ObjectFormat::Sha1,
383            ("extensions.objectformat", b"sha256") => format = ObjectFormat::Sha256,
384            ("extensions.refstorage", b"files") => {}
385            _ => return None,
386        }
387    }
388    // Git clears the whole format when no version was found (`read_repository_format` in `setup.c`), the extension with
389    // it, so an absent version never turns the per-worktree file on.
390    return Some((format, worktree_config && version.is_some()));
391}
392
393/// What:
394///  The entries of the worktree's own `config.worktree`, or none when the extension is off or the file is absent;
395///  `None` when the file cannot be read or parsed.
396/// Why:
397///   Git reads `<git dir>/config.worktree` (the worktree's own Git directory, not the common one) after the common
398///  file, and only when `extensions.worktreeConfig` is on; a missing file is an empty file.
399///
400/// In TS you'd write (pseudocode):
401/// ```ts
402/// const entries = enabled ? parse(await readIfExists(join(gitDir, 'config.worktree'))) : []; // undefined: ask Git
403/// ```
404fn worktree_entries(git_dir: &Path, enabled: bool) -> Option<Vec<ConfigEntry>> {
405    if !enabled {
406        return Some(Vec::new());
407    }
408    return match read_following(git_dir.join(WORKTREE_CONFIG_NAME).as_path())? {
409        Some(bytes) => parse_config_text(bytes.as_slice()),
410        None => Some(Vec::new()),
411    };
412}
413
414/// What:
415///  The entries with every direct `core.bare` removed when the last one leaves the repository non-bare;
416///  `None` when it makes the repository bare or any of them is not a boolean.
417/// Why:
418///   Git takes the last `core.bare` of the common file then the per-worktree file (`read_worktree_config` in
419///  `setup.c`); a bare repository, an unreadable boolean and a `core.worktree` are left to Git by the key scan.
420///
421/// In TS you'd write (pseudocode):
422/// ```ts
423/// const kept = lastBare(files) === true ? undefined : files.map((entries) => entries.filter((e) => e.key !== 'core.bare'));
424/// ```
425fn without_false_bare(files: [&[ConfigEntry]; 2]) -> Option<[Vec<ConfigEntry>; 2]> {
426    let mut last: Option<bool> = None;
427    for entry in files.iter().flat_map(|entries| { return entries.iter(); }).filter(|entry| { return entry.key == "core.bare"; }) {
428        last = Some(parse_bool(entry.value.as_deref())?);
429    }
430    if last == Some(true) {
431        return None;
432    }
433    return Some(files.map(|entries| {
434        return entries.iter().filter(|entry| { return entry.key != "core.bare"; }).cloned().collect::<Vec<ConfigEntry>>();
435    }));
436}
437
438/// What:
439///  Check the discovered Git directory and build the layout.
440/// Why:
441///   Everything beyond the plain layout returns `None`.
442///
443/// In TS you'd write (pseudocode):
444/// ```ts
445/// function finish(start: string, worktree: string, gitDir: string, fromFile: boolean): RepoLayout | undefined;
446/// ```
447fn finish(start: &Path, worktree: &Path, entry: (PathBuf, bool), scope: &ScopeEnvironment, typed: bool) -> Option<RepoLayout> {
448    let (target, from_file) = entry;
449    let git_dir: PathBuf = std::fs::canonicalize(target).ok()?;
450    let dot_git: PathBuf = worktree.join(".git");
451    let owners_ok: bool = owned_by_current_user(worktree)
452        && owned_by_current_user(git_dir.as_path())
453        && (!from_file || owned_by_current_user(dot_git.as_path()));
454    if !owners_ok {
455        return None;
456    }
457    let common_dir: PathBuf = match small_file(git_dir.join("commondir").as_path()) {
458        // Git strips every trailing carriage return and line feed here too (`get_common_dir_noenv` in `path.c`).
459        Some(text) => std::fs::canonicalize(git_dir.join(text.trim_end_matches(['\r', '\n']))).ok()?,
460        None if git_dir.join("commondir").exists() => return None,
461        None => git_dir.clone(),
462    };
463    if git_dir.join("reftable").exists() || common_dir.join("reftable").exists() || !has_git_directory_entries_split(&git_dir, &common_dir) {
464        return None;
465    }
466    let config_bytes: Vec<u8> = read_regular_file(common_dir.join("config").as_path(), CONFIG_TEXT_LIMIT as u64).ok()?;
467    let entries: Vec<ConfigEntry> = parse_config_text(config_bytes.as_slice())?;
468    let (object_format, worktree_config): (ObjectFormat, bool) = format_of(entries.as_slice())?;
469    let worktree_path: PathBuf = git_dir.join(WORKTREE_CONFIG_NAME);
470    let per_worktree: Vec<ConfigEntry> = worktree_entries(git_dir.as_path(), worktree_config)?;
471
472    // The repository's own file carries `bare = false` after `init`; that spelling is the plain layout. With the
473    // per-worktree file on, the last `core.bare` of the two files decides, and a `true` there is a bare repository.
474    let [discoverable, worktree_discoverable] = without_false_bare([entries.as_slice(), per_worktree.as_slice()])?;
475    let local_ok: bool = entries_avoid_keys(discoverable.as_slice(), common_dir.join("config").as_path(), scope.home.as_deref(), 0, &discovery_key)?
476        && entries_avoid_keys(worktree_discoverable.as_slice(), worktree_path.as_path(), scope.home.as_deref(), 0, &discovery_key)?;
477    if !local_ok || !outer_scopes_avoid_keys(scope, &discovery_key)? {
478        return None;
479    }
480    // Git types many keys for every command and fails, or warns, on a value that does not fit; the readers leave
481    // such a repository to Git, which words its own failure.
482    // The callers that ask for no typed proof need the structure of the layout and no diagnostic about values:
483    //  they do not rely on an earlier command having validated every value (see `read_layout_after_success`).
484    let typed_ok: bool = !typed
485        || (entries_are_accepted(entries.as_slice(), common_dir.join("config").as_path(), scope.home.as_deref(), 0)?
486            && entries_are_accepted(per_worktree.as_slice(), worktree_path.as_path(), scope.home.as_deref(), 0)?
487            && outer_scopes_are_accepted(scope)?);
488    if !typed_ok {
489        return None;
490    }
491    let head: String = small_file(git_dir.join("HEAD").as_path())?;
492    if !head_is_valid(head.as_str(), object_format) {
493        return None;
494    }
495    let relative: &Path = start.strip_prefix(worktree).ok()?;
496    let mut prefix: Vec<u8> = Vec::new();
497    for component in relative.components() {
498        prefix.extend_from_slice(component.as_os_str().as_encoded_bytes());
499        prefix.push(b'/');
500    }
501    return Some(RepoLayout { git_dir, common_dir, worktree_root: worktree.to_path_buf(), prefix, object_format });
502}
503
504/// The three-entry check, with `HEAD` in the worktree's directory and the rest in the common directory.
505fn has_git_directory_entries_split(git_dir: &Path, common_dir: &Path) -> bool {
506    return git_dir.join("HEAD").exists() && common_dir.join("objects").is_dir() && common_dir.join("refs").is_dir();
507}
508
509/// What:
510///  The layout of the repository a command run with `global_prefix` and `overlay` starts in,
511///  or `None` when Git must be asked.
512/// Why:
513///   This is the one entry point the callers use; it never errors, because "cannot tell" and "not a repository" both
514///  mean "ask Git", which words the failure itself.
515///
516/// In TS you'd write (pseudocode):
517/// ```ts
518/// const layout = readLayout(globalPrefix, overlay); // undefined: ask Git
519/// ```
520pub fn read_layout(global_prefix: &[OsString], overlay: &[(OsString, OsString)]) -> Option<RepoLayout> {
521    if !enabled(overlay) {
522        return None;
523    }
524    let variables: Vec<(OsString, OsString)> = effective_environment(overlay);
525    if !environment_is_plain(variables.as_slice()) || !environment_is_accepted(variables.as_slice()) {
526        return None;
527    }
528    let start: PathBuf = start_directory(global_prefix)?;
529    return walk_up(start.as_path(), &ScopeEnvironment::from_variables(variables.as_slice()), true);
530}
531
532/// What:
533///  The read with its environment injected: the variables Git would see and the configuration scope facts.
534/// Why:
535///   The controls run in a process whose own environment and home they must not depend on.
536///
537/// In TS you'd write (pseudocode):
538/// ```ts
539/// const layout = readLayoutWith(start, variables, scope); // undefined: ask Git
540/// ```
541pub fn read_layout_with(start: &Path, variables: &[(OsString, OsString)], scope: &ScopeEnvironment) -> Option<RepoLayout> {
542    if !environment_is_plain(variables) || !environment_is_accepted(variables) {
543        return None;
544    }
545    return walk_up(std::fs::canonicalize(start).ok()?.as_path(), scope, true);
546}
547
548/// What:
549///  The same read for a command run in `directory` without global options.
550/// Why:
551///   The transaction Git requests name their directory and leave the invocation's global options out.
552///
553/// In TS you'd write (pseudocode):
554/// ```ts
555/// const layout = readLayoutIn(directory, overlay); // undefined: ask Git
556/// ```
557pub fn read_layout_in(directory: &Path, overlay: &[(OsString, OsString)]) -> Option<RepoLayout> {
558    if !enabled(overlay) {
559        return None;
560    }
561    let variables: Vec<(OsString, OsString)> = effective_environment(overlay);
562    return read_layout_with(directory, variables.as_slice(), &ScopeEnvironment::from_variables(variables.as_slice()));
563}
564
565/// What:
566///  The layout for a caller that follows a Git command that exited with status 0 and needs the structure of the repository
567///  but not a diagnostic about its configuration values.
568/// Why:
569///   `read_layout_in` leaves a repository to Git when any configuration value is one Git types for every command and the
570///  reader cannot prove it accepts (a key such as `diff.dirstat` is one).
571/// That proof guards the wording of a failure Git would give for a malformed value,
572/// and a caller that follows a command that succeeded,
573/// or that needs no valid configuration,
574/// has none to give.
575/// This is not a certificate that every value is valid:
576/// the files are read again,
577/// not the snapshot the earlier command saw,
578/// and a successful command types only the keys it consults.
579/// The structure checks (owner,
580/// object format,
581///  `core.bare`,
582/// `core.worktree`,
583/// reftable,
584/// `HEAD`) still apply,
585/// because they decide whether the files mean what the reader takes them to mean.
586///
587/// In TS you'd write (pseudocode):
588/// ```ts
589/// const layout = readLayoutAfterSuccess(directory, overlay); // undefined: the files are not a plain layout
590/// ```
591pub fn read_layout_after_success(directory: &Path, overlay: &[(OsString, OsString)]) -> Option<RepoLayout> {
592    if !enabled(overlay) {
593        return None;
594    }
595    let variables: Vec<(OsString, OsString)> = effective_environment(overlay);
596    if !environment_is_plain(variables.as_slice()) {
597        return None;
598    }
599    return walk_up(std::fs::canonicalize(directory).ok()?.as_path(), &ScopeEnvironment::from_variables(variables.as_slice()), false);
600}
601
602/// What:
603///  The layout for a caller that follows a successful command and holds the environment and the configuration scope itself.
604/// Why:
605///   The merges from objects read `attr.tree` with the variables their Git children get, which the process environment alone does
606///  not say (the service adds an overlay); the controls give a hermetic scope the same way `read_layout_with` takes it.
607///
608/// In TS you'd write (pseudocode):
609/// ```ts
610/// const layout = readLayoutAfterSuccessWith(start, variables, scope); // undefined: ask Git
611/// ```
612pub fn read_layout_after_success_with(start: &Path, variables: &[(OsString, OsString)], scope: &ScopeEnvironment) -> Option<RepoLayout> {
613    if !environment_is_plain(variables) {
614        return None;
615    }
616    return walk_up(std::fs::canonicalize(start).ok()?.as_path(), scope, false);
617}
618
619/// What:
620///  The layout for a caller that runs after a Git command succeeded that was given the same global options and environment,
621///  for a command that starts where `global_prefix` says.
622/// Why:
623///   The two query-driven LFS pushes plan their upload through Git queries first,
624/// and read the layout only once those queries exited with status 0
625/// (an interactive push may hold settings read earlier),
626/// so the diagnostic the typed proof guards has been Git's to give;
627/// the files are read again,
628/// and a plan that cannot be narrowed is still pushed by `git-lfs` and Git.
629/// They take `-C` options like `read_layout`.
630///
631/// In TS you'd write (pseudocode):
632/// ```ts
633/// const layout = readLayoutAfterSuccessFor(globalPrefix, overlay); // undefined: the files are not a plain layout
634/// ```
635pub fn read_layout_after_success_for(global_prefix: &[OsString], overlay: &[(OsString, OsString)]) -> Option<RepoLayout> {
636    if !enabled(overlay) {
637        return None;
638    }
639    let variables: Vec<(OsString, OsString)> = effective_environment(overlay);
640    if !environment_is_plain(variables.as_slice()) {
641        return None;
642    }
643    let start: PathBuf = start_directory(global_prefix)?;
644    return walk_up(start.as_path(), &ScopeEnvironment::from_variables(variables.as_slice()), false);
645}
646
647/// Walk from `start` to the root looking for the first `.git`, as Git does, and build the layout there.
648pub(crate) fn walk_up(start: &Path, scope: &ScopeEnvironment, typed: bool) -> Option<RepoLayout> {
649    let mut directory: PathBuf = start.to_path_buf();
650    loop {
651        if let Some(entry) = dot_git_target(directory.as_path())? {
652            return finish(start, directory.as_path(), entry, scope, typed);
653        }
654        if has_git_directory_entries(directory.as_path()) {
655            return None;
656        }
657        let parent: PathBuf = directory.parent()?.to_path_buf();
658        if device_of(parent.as_path())? != device_of(directory.as_path())? {
659            return None;
660        }
661        directory = parent;
662    }
663}
664
665/// What:
666///  The absolute path `git rev-parse --path-format=absolute --git-path <name>` prints for the names this reader knows
667///  in a repository whose worktree owns its Git directory.
668/// Why:
669///   A linked worktree splits names between its own directory and the common one by a table in Git;
670///  those answers stay with Git.
671///
672/// In TS you'd write (pseudocode):
673/// ```ts
674/// const gitPath = (layout, name) => layout.gitDir === layout.commonDir && KNOWN.has(name) ? join(layout.gitDir, name) : undefined;
675/// ```
676pub fn git_path(layout: &RepoLayout, name: &str) -> Option<PathBuf> {
677    const KNOWN: [&str; 9] = [
678        "index",
679        "objects",
680        "HEAD",
681        "MERGE_HEAD",
682        "CHERRY_PICK_HEAD",
683        "REVERT_HEAD",
684        "cli-git-transactions",
685        "packed-refs",
686        "config",
687    ];
688    if layout.git_dir != layout.common_dir || !KNOWN.contains(&name) {
689        return None;
690    }
691    let path: PathBuf = layout.git_dir.join(name);
692    // With `--path-format=absolute` Git prints the target of a link, so a linked name is left to Git.
693    if std::fs::symlink_metadata(path.as_path()).is_ok_and(|metadata| { return metadata.file_type().is_symlink(); }) {
694        return None;
695    }
696    return Some(path);
697}
698
699#[cfg(test)]
700#[path = "repo_read_layout_tests.rs"]
701mod tests;
702
703#[cfg(test)]
704#[path = "repo_read_layout_worktree_tests.rs"]
705mod worktree_tests;