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;