Skip to main content

grit_lib/
environment.rs

1//! Explicit discovery and configuration environment for embedding callers.
2//!
3//! [`Environment`] holds the variables Git reads during repository discovery and config
4//! cascade loading. Library code must not read the process environment for these values;
5//! embedders (including the `grit` CLI) construct an [`Environment`] and pass it into
6//! [`crate::repo::Repository::discover`](crate::repo::Repository::discover) and
7//! [`crate::repo::Repository::open`](crate::repo::Repository::open), and
8//! [`ConfigSet::load`](crate::config::ConfigSet::load).
9
10use std::collections::HashMap;
11use std::ffi::OsString;
12use std::path::{Path, PathBuf};
13use std::str::FromStr;
14use std::sync::Arc;
15
16use time::{OffsetDateTime, UtcOffset};
17
18use crate::change_id::{ChangeIdGenerator, OsChangeIdGenerator};
19use crate::command_runner::{system_command_runner, CommandRunner};
20use crate::diagnostics::{DiagnosticsHandle, NullDiagnostics};
21use crate::git_date::tm::{local_tzoffset_with_tz, TzHhmm};
22use crate::ident_resolve::IdentityEnv;
23
24/// Discovery and config variables that affect repository open/discover and `ConfigSet` loading.
25#[derive(Debug, Clone)]
26pub struct Environment {
27    /// Process working directory used for relative path resolution during discovery.
28    pub cwd: PathBuf,
29    /// Override for `GIT_DIR` when set (see Git environment variable of the same name).
30    pub git_dir: Option<String>,
31    /// Override for `GIT_WORK_TREE` when set (see Git environment variable of the same name).
32    pub git_work_tree: Option<String>,
33    /// Override for `GIT_CEILING_DIRECTORIES` when set (see Git environment variable of the same name).
34    pub git_ceiling_directories: Option<String>,
35    /// Override for `GIT_INDEX_FILE` when set (see Git environment variable of the same name).
36    pub git_index_file: Option<String>,
37    /// Override for `GIT_NAMESPACE` when set (see Git environment variable of the same name).
38    pub git_namespace: Option<String>,
39    /// Override for `GIT_REPLACE_REF_BASE` when set (see Git environment variable of the same name).
40    pub git_replace_ref_base: Option<String>,
41    /// When true, replacement objects from `GIT_REPLACE_OBJECT` are ignored.
42    pub git_no_replace_objects: bool,
43    /// Override for `GIT_ALTERNATE_OBJECT_DIRECTORIES` when set (see Git environment variable of the same name).
44    pub git_alternate_object_directories: Option<String>,
45    /// Override for `GIT_OBJECT_DIRECTORY` when set (see Git environment variable of the same name).
46    ///
47    /// When set, a [`Repository`](crate::repo::Repository) opened with this environment reads
48    /// and writes objects in this directory instead of `<git-dir>/objects` (a relative path
49    /// resolves against [`Self::cwd`]). The directory must exist, or opening fails with
50    /// [`Error::NotARepository`](crate::error::Error::NotARepository); an empty value fails
51    /// the same way, as in Git. `<git-dir>/objects` is not consulted unless it is listed in
52    /// [`Self::git_alternate_object_directories`] or in this directory's `info/alternates`.
53    pub git_object_directory: Option<String>,
54    /// When set, overrides the repository-relative cwd prefix derived from `cwd`.
55    pub git_prefix: Option<String>,
56    /// Override for `GIT_CONFIG_NOSYSTEM` when set (see Git environment variable of the same name).
57    pub git_config_nosystem: Option<String>,
58    /// Override for `GIT_CONFIG_SYSTEM` when set (see Git environment variable of the same name).
59    pub git_config_system: Option<String>,
60    /// Override for `GIT_CONFIG_GLOBAL` when set (see Git environment variable of the same name).
61    pub git_config_global: Option<String>,
62    /// Override for `GIT_CONFIG` when set (see Git environment variable of the same name).
63    pub git_config: Option<String>,
64    /// Override for `GIT_CONFIG_PARAMETERS` when set (see Git environment variable of the same name).
65    pub git_config_parameters: Option<String>,
66    /// Override for `GIT_CONFIG_COUNT` when set (see Git environment variable of the same name).
67    pub git_config_count: Option<String>,
68    /// Pairs from `GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n` when `GIT_CONFIG_COUNT` is set.
69    pub git_config_pairs: Vec<(String, String)>,
70    /// Override for `HOME` when set (see Git environment variable of the same name).
71    pub home: Option<OsString>,
72    /// Override for `XDG_CONFIG_HOME` when set (see Git environment variable of the same name).
73    pub xdg_config_home: Option<String>,
74    /// Override for `USERPROFILE` when set (see Git environment variable of the same name).
75    pub userprofile: Option<OsString>,
76    /// Override for `HOMEDRIVE` when set (see Git environment variable of the same name).
77    pub homedrive: Option<OsString>,
78    /// Override for `HOMEPATH` when set (see Git environment variable of the same name).
79    pub homepath: Option<OsString>,
80    /// Override for `GIT_INSTALL_ROOT` when set (see Git environment variable of the same name).
81    pub git_install_root: Option<OsString>,
82    /// Override for `GIT_EXEC_PATH` when set (see Git environment variable of the same name).
83    pub git_exec_path: Option<OsString>,
84    /// `$PWD` when set (symlink-preserving absolute path for include matching).
85    pub pwd: Option<String>,
86    /// Override for `GIT_TEST_ASSUME_DIFFERENT_OWNER` when set (see Git environment variable of the same name).
87    pub git_test_assume_different_owner: Option<String>,
88    /// When true, relaxes safe-directory checks for debugging.
89    pub grit_debug_safe_dir: bool,
90    /// Override for `GIT_TRACE_SETUP` when set (see Git environment variable of the same name).
91    pub git_trace_setup: Option<String>,
92    /// Override for `GIT_TRACE2_PERF` when set (see Git environment variable of the same name).
93    pub git_trace2_perf: Option<String>,
94    /// Original working directory of the `grit` invocation (for messages).
95    pub grit_invocation_cwd: Option<String>,
96    /// Override for `SUDO_UID` when set (see Git environment variable of the same name).
97    pub sudo_uid: Option<String>,
98    /// Override for `GIT_TEST_UTF8_NFD_TO_NFC` when set (see Git environment variable of the same name).
99    pub git_test_utf8_nfd_to_nfc: Option<String>,
100    /// Override for `GIT_TEST_NO_WRITE_REV_INDEX` when set (see Git environment variable of the same name).
101    pub git_test_no_write_rev_index: Option<String>,
102    /// Test knob: when `"false"`, reftable auto-compaction is disabled (CLI captures at open).
103    pub git_test_reftable_autocompaction: Option<String>,
104    /// Override for `GIT_AUTHOR_NAME` when set (see Git environment variable of the same name).
105    pub git_author_name: Option<String>,
106    /// Override for `GIT_AUTHOR_EMAIL` when set (see Git environment variable of the same name).
107    pub git_author_email: Option<String>,
108    /// Override for `GIT_AUTHOR_DATE` when set (see Git environment variable of the same name).
109    pub git_author_date: Option<String>,
110    /// Override for `GIT_COMMITTER_NAME` when set (see Git environment variable of the same name).
111    pub git_committer_name: Option<String>,
112    /// Override for `GIT_COMMITTER_EMAIL` when set (see Git environment variable of the same name).
113    pub git_committer_email: Option<String>,
114    /// Override for `GIT_COMMITTER_DATE` when set (see Git environment variable of the same name).
115    pub git_committer_date: Option<String>,
116    /// Timezone for local date formatting (`TZ`).
117    pub tz: Option<String>,
118    /// Override for `USER` when set (see Git environment variable of the same name).
119    pub user: Option<String>,
120    /// Override for `USERNAME` when set (see Git environment variable of the same name).
121    pub username: Option<String>,
122    /// Windows `%ProgramFiles%` (for Git for Windows system config discovery).
123    pub program_files: Option<String>,
124    /// Windows `%ProgramFiles(x86)%`.
125    pub program_files_x86: Option<String>,
126    /// Full variable snapshot from [`Self::from_vars`] / [`Self::capture_process`].
127    pub(crate) captured_vars: HashMap<String, OsString>,
128}
129
130impl Environment {
131    /// No overrides; [`Self::cwd`] is `"."` (canonicalized by callers when needed).
132    #[must_use]
133    pub fn empty() -> Self {
134        Self {
135            cwd: PathBuf::from("."),
136            git_dir: None,
137            git_work_tree: None,
138            git_ceiling_directories: None,
139            git_index_file: None,
140            git_namespace: None,
141            git_replace_ref_base: None,
142            git_no_replace_objects: false,
143            git_alternate_object_directories: None,
144            git_object_directory: None,
145            git_prefix: None,
146            git_config_nosystem: None,
147            git_config_system: None,
148            git_config_global: None,
149            git_config: None,
150            git_config_parameters: None,
151            git_config_count: None,
152            git_config_pairs: Vec::new(),
153            home: None,
154            xdg_config_home: None,
155            userprofile: None,
156            homedrive: None,
157            homepath: None,
158            git_install_root: None,
159            git_exec_path: None,
160            pwd: None,
161            git_test_assume_different_owner: None,
162            grit_debug_safe_dir: false,
163            git_trace_setup: None,
164            git_trace2_perf: None,
165            grit_invocation_cwd: None,
166            sudo_uid: None,
167            git_test_utf8_nfd_to_nfc: None,
168            git_test_no_write_rev_index: None,
169            git_test_reftable_autocompaction: None,
170            git_author_name: None,
171            git_author_email: None,
172            git_author_date: None,
173            git_committer_name: None,
174            git_committer_email: None,
175            git_committer_date: None,
176            tz: None,
177            user: None,
178            username: None,
179            program_files: None,
180            program_files_x86: None,
181            captured_vars: HashMap::new(),
182        }
183    }
184
185    /// Whether this snapshot matches [`Self::empty`], the default
186    /// [`crate::transfer::FetchOptions::environment`] value.
187    ///
188    /// SSH transport uses the repository [`Environment`] when fetch options still
189    /// carry this sentinel so `FetchOptions::default()` does not mask `GIT_SSH_*`
190    /// stored on the repository handle.
191    #[must_use]
192    pub fn is_fetch_options_default(&self) -> bool {
193        let e = Self::empty();
194        self.cwd == e.cwd
195            && self.git_dir == e.git_dir
196            && self.git_work_tree == e.git_work_tree
197            && self.git_ceiling_directories == e.git_ceiling_directories
198            && self.git_index_file == e.git_index_file
199            && self.git_namespace == e.git_namespace
200            && self.git_replace_ref_base == e.git_replace_ref_base
201            && self.git_no_replace_objects == e.git_no_replace_objects
202            && self.git_alternate_object_directories == e.git_alternate_object_directories
203            && self.git_object_directory == e.git_object_directory
204            && self.git_prefix == e.git_prefix
205            && self.git_config_nosystem == e.git_config_nosystem
206            && self.git_config_system == e.git_config_system
207            && self.git_config_global == e.git_config_global
208            && self.git_config == e.git_config
209            && self.git_config_parameters == e.git_config_parameters
210            && self.git_config_count == e.git_config_count
211            && self.git_config_pairs == e.git_config_pairs
212            && self.home == e.home
213            && self.xdg_config_home == e.xdg_config_home
214            && self.userprofile == e.userprofile
215            && self.homedrive == e.homedrive
216            && self.homepath == e.homepath
217            && self.git_install_root == e.git_install_root
218            && self.git_exec_path == e.git_exec_path
219            && self.pwd == e.pwd
220            && self.git_test_assume_different_owner == e.git_test_assume_different_owner
221            && self.grit_debug_safe_dir == e.grit_debug_safe_dir
222            && self.git_trace_setup == e.git_trace_setup
223            && self.git_trace2_perf == e.git_trace2_perf
224            && self.grit_invocation_cwd == e.grit_invocation_cwd
225            && self.sudo_uid == e.sudo_uid
226            && self.git_test_utf8_nfd_to_nfc == e.git_test_utf8_nfd_to_nfc
227            && self.git_test_no_write_rev_index == e.git_test_no_write_rev_index
228            && self.git_test_reftable_autocompaction == e.git_test_reftable_autocompaction
229            && self.git_author_name == e.git_author_name
230            && self.git_author_email == e.git_author_email
231            && self.git_author_date == e.git_author_date
232            && self.git_committer_name == e.git_committer_name
233            && self.git_committer_email == e.git_committer_email
234            && self.git_committer_date == e.git_committer_date
235            && self.tz == e.tz
236            && self.user == e.user
237            && self.username == e.username
238            && self.program_files == e.program_files
239            && self.program_files_x86 == e.program_files_x86
240            && self.captured_vars == e.captured_vars
241    }
242
243    /// Parse known discovery/config variables from `vars` without reading the process environment.
244    ///
245    /// Unknown keys from `vars` are retained for later lookup via [`Self::var`] / [`Self::var_os`].
246    /// `GIT_CONFIG_COUNT` expands into [`Self::git_config_pairs`].
247    pub fn from_vars(vars: impl IntoIterator<Item = (OsString, OsString)>, cwd: PathBuf) -> Self {
248        let map: HashMap<String, OsString> = vars
249            .into_iter()
250            .map(|(k, v)| (k.to_string_lossy().into_owned(), v))
251            .collect();
252        let get = |key: &str| -> Option<String> {
253            map.get(key).and_then(|v| v.to_str().map(str::to_owned))
254        };
255        let get_os = |key: &str| -> Option<OsString> { map.get(key).cloned() };
256
257        let mut git_config_pairs = Vec::new();
258        if let Some(count_str) = get("GIT_CONFIG_COUNT") {
259            if let Ok(count) = count_str.parse::<usize>() {
260                for i in 0..count {
261                    let key_var = format!("GIT_CONFIG_KEY_{i}");
262                    let value_var = format!("GIT_CONFIG_VALUE_{i}");
263                    if let (Some(k), Some(v)) = (get(&key_var), get(&value_var)) {
264                        git_config_pairs.push((k, v));
265                    }
266                }
267            }
268        }
269
270        Self {
271            cwd,
272            git_dir: get("GIT_DIR"),
273            git_work_tree: get("GIT_WORK_TREE"),
274            git_ceiling_directories: get("GIT_CEILING_DIRECTORIES"),
275            git_index_file: get("GIT_INDEX_FILE"),
276            git_namespace: get("GIT_NAMESPACE"),
277            git_replace_ref_base: get("GIT_REPLACE_REF_BASE"),
278            git_no_replace_objects: map.contains_key("GIT_NO_REPLACE_OBJECTS"),
279            git_alternate_object_directories: get("GIT_ALTERNATE_OBJECT_DIRECTORIES"),
280            git_object_directory: get("GIT_OBJECT_DIRECTORY"),
281            git_prefix: get("GIT_PREFIX"),
282            git_config_nosystem: get("GIT_CONFIG_NOSYSTEM"),
283            git_config_system: get("GIT_CONFIG_SYSTEM"),
284            git_config_global: get("GIT_CONFIG_GLOBAL"),
285            git_config: get("GIT_CONFIG"),
286            git_config_parameters: get("GIT_CONFIG_PARAMETERS"),
287            git_config_count: get("GIT_CONFIG_COUNT"),
288            git_config_pairs,
289            home: get_os("HOME"),
290            xdg_config_home: get("XDG_CONFIG_HOME"),
291            userprofile: get_os("USERPROFILE"),
292            homedrive: get_os("HOMEDRIVE"),
293            homepath: get_os("HOMEPATH"),
294            git_install_root: get_os("GIT_INSTALL_ROOT"),
295            git_exec_path: get_os("GIT_EXEC_PATH"),
296            pwd: get("PWD"),
297            git_test_assume_different_owner: get("GIT_TEST_ASSUME_DIFFERENT_OWNER"),
298            grit_debug_safe_dir: map.contains_key("GRIT_DEBUG_SAFE_DIR"),
299            git_trace_setup: get("GIT_TRACE_SETUP"),
300            git_trace2_perf: get("GIT_TRACE2_PERF"),
301            grit_invocation_cwd: get("GRIT_INVOCATION_CWD"),
302            sudo_uid: get("SUDO_UID"),
303            git_test_utf8_nfd_to_nfc: get("GIT_TEST_UTF8_NFD_TO_NFC"),
304            git_test_no_write_rev_index: get("GIT_TEST_NO_WRITE_REV_INDEX"),
305            git_test_reftable_autocompaction: get("GIT_TEST_REFTABLE_AUTOCOMPACTION"),
306            git_author_name: get("GIT_AUTHOR_NAME"),
307            git_author_email: get("GIT_AUTHOR_EMAIL"),
308            git_author_date: get("GIT_AUTHOR_DATE"),
309            git_committer_name: get("GIT_COMMITTER_NAME"),
310            git_committer_email: get("GIT_COMMITTER_EMAIL"),
311            git_committer_date: get("GIT_COMMITTER_DATE"),
312            tz: get("TZ"),
313            user: get("USER"),
314            username: get("USERNAME"),
315            program_files: get("ProgramFiles"),
316            program_files_x86: get("ProgramFiles(x86)"),
317            captured_vars: map,
318        }
319    }
320
321    /// Parsed epoch from `GIT_AUTHOR_DATE` / `GIT_COMMITTER_DATE` when set.
322    #[must_use]
323    pub fn git_now_date_override(&self) -> Option<i64> {
324        let raw = self
325            .git_committer_date
326            .as_deref()
327            .or(self.git_author_date.as_deref())?;
328        raw.split_whitespace()
329            .next()
330            .and_then(|p| p.parse::<i64>().ok())
331    }
332
333    /// Whether reftable auto-compaction is enabled for this process snapshot.
334    ///
335    /// Defaults to `true`. Set `GIT_TEST_REFTABLE_AUTOCOMPACTION=false` at the CLI
336    /// boundary to disable (see site global-options docs).
337    #[must_use]
338    pub fn reftable_autocompaction_enabled(&self) -> bool {
339        self.git_test_reftable_autocompaction
340            .as_deref()
341            .map(|value| value != "false")
342            .unwrap_or(true)
343    }
344
345    /// Whether `GIT_TEST_ASSUME_DIFFERENT_OWNER` is enabled.
346    #[must_use]
347    pub fn test_assume_different_owner(&self) -> bool {
348        self.git_test_assume_different_owner
349            .as_deref()
350            .map(|v| {
351                let lower = v.to_ascii_lowercase();
352                v == "1" || lower == "true" || lower == "yes" || lower == "on"
353            })
354            .unwrap_or(false)
355    }
356
357    /// Use `path` as the repository directory, like setting `GIT_DIR`.
358    ///
359    /// A relative `path` is resolved against [`Self::cwd`] now, so the stored value stays
360    /// correct when it is exported to subprocesses that run in another directory.
361    ///
362    /// # Parameters
363    ///
364    /// - `path`: the `.git` directory (or gitfile) to open instead of discovering one.
365    ///
366    /// # Returns
367    ///
368    /// This environment with [`Self::git_dir`] set.
369    #[must_use]
370    pub fn with_git_dir(mut self, path: impl AsRef<Path>) -> Self {
371        self.git_dir = Some(self.absolute_from_cwd(path.as_ref()));
372        self
373    }
374
375    /// Use `path` as the work tree root, like setting `GIT_WORK_TREE`.
376    ///
377    /// A relative `path` is resolved against [`Self::cwd`] now, as in [`Self::with_git_dir`].
378    ///
379    /// # Parameters
380    ///
381    /// - `path`: the directory to treat as the top of the work tree.
382    ///
383    /// # Returns
384    ///
385    /// This environment with [`Self::git_work_tree`] set.
386    #[must_use]
387    pub fn with_work_tree(mut self, path: impl AsRef<Path>) -> Self {
388        self.git_work_tree = Some(self.absolute_from_cwd(path.as_ref()));
389        self
390    }
391
392    /// Append a command-line configuration override (`git -c key=value`).
393    ///
394    /// The override is added to [`Self::git_config_parameters`] in the quoted
395    /// `'key'='value'` form (`'key'=` for a bare key) that Git itself writes to
396    /// `GIT_CONFIG_PARAMETERS` for `git -c`, so it is read by
397    /// [`ConfigSet::load`](crate::config::ConfigSet::load) with command-line scope and is
398    /// inherited by subprocesses through [`Self::subprocess_environment`]. Later overrides win
399    /// over earlier ones, over `GIT_CONFIG_COUNT` pairs, and over every config file.
400    ///
401    /// # Parameters
402    ///
403    /// - `config_override`: the validated key and optional value to add.
404    pub fn push_config_override(&mut self, config_override: &ConfigOverride) {
405        use crate::transport::sq_quote_shell_arg;
406
407        let mut entry = sq_quote_shell_arg(&config_override.key);
408        entry.push('=');
409        if let Some(value) = &config_override.value {
410            entry.push_str(&sq_quote_shell_arg(value));
411        }
412        match &mut self.git_config_parameters {
413            Some(existing) if !existing.trim().is_empty() => {
414                existing.push(' ');
415                existing.push_str(&entry);
416            }
417            slot => *slot = Some(entry),
418        }
419    }
420
421    fn absolute_from_cwd(&self, path: &Path) -> String {
422        if path.is_absolute() {
423            path.to_string_lossy().into_owned()
424        } else {
425            self.cwd.join(path).to_string_lossy().into_owned()
426        }
427    }
428
429    /// Snapshot discovery/config variables from the current process (CLI and legacy call sites).
430    #[must_use]
431    pub fn capture_process() -> Self {
432        // hygiene: CLI boundary — snapshot process environment into [`Environment`].
433        Self::from_vars(std::env::vars_os(), process_cwd_fallback())
434    }
435
436    /// Fingerprint of config-related fields for cascade cache keys (mirrors prior env-var scan).
437    #[must_use]
438    pub fn config_fingerprint(&self) -> Option<Vec<(String, Option<String>)>> {
439        const VARS: [&str; 8] = [
440            "GIT_CONFIG_NOSYSTEM",
441            "GIT_CONFIG_SYSTEM",
442            "GIT_CONFIG_GLOBAL",
443            "XDG_CONFIG_HOME",
444            "HOME",
445            "GIT_CONFIG",
446            "GIT_CONFIG_PARAMETERS",
447            "GIT_CONFIG_COUNT",
448        ];
449        let mut fp: Vec<(String, Option<String>)> = VARS
450            .iter()
451            .map(|name| {
452                let val = match *name {
453                    "GIT_CONFIG_NOSYSTEM" => self.git_config_nosystem.clone(),
454                    "GIT_CONFIG_SYSTEM" => self.git_config_system.clone(),
455                    "GIT_CONFIG_GLOBAL" => self.git_config_global.clone(),
456                    "XDG_CONFIG_HOME" => self.xdg_config_home.clone(),
457                    "HOME" => self
458                        .home
459                        .as_ref()
460                        .and_then(|h| h.to_str().map(str::to_owned)),
461                    "GIT_CONFIG" => self.git_config.clone(),
462                    "GIT_CONFIG_PARAMETERS" => self.git_config_parameters.clone(),
463                    "GIT_CONFIG_COUNT" => self.git_config_count.clone(),
464                    _ => None,
465                };
466                ((*name).to_owned(), val)
467            })
468            .collect();
469        if let Some(count_str) = &self.git_config_count {
470            const MAX_TRACKED: usize = 256;
471            match count_str.parse::<usize>() {
472                Ok(n) if n <= MAX_TRACKED => {
473                    for i in 0..n {
474                        fp.push((
475                            format!("GIT_CONFIG_KEY_{i}"),
476                            self.git_config_pairs.get(i).map(|p| p.0.clone()),
477                        ));
478                        fp.push((
479                            format!("GIT_CONFIG_VALUE_{i}"),
480                            self.git_config_pairs.get(i).map(|p| p.1.clone()),
481                        ));
482                    }
483                }
484                Ok(_) => return None,
485                Err(_) => {}
486            }
487        }
488        fp.push((
489            "GRIT_ENV_CWD".to_owned(),
490            self.cwd.to_str().map(str::to_owned),
491        ));
492        fp.push(("GRIT_ENV_PWD".to_owned(), self.pwd.clone()));
493        Some(fp)
494    }
495
496    /// Return a string env var from this environment, if present and UTF-8.
497    #[must_use]
498    pub fn var(&self, key: &str) -> Option<String> {
499        match key {
500            "GIT_DIR" => self.git_dir.clone(),
501            "GIT_WORK_TREE" => self.git_work_tree.clone(),
502            "GIT_CEILING_DIRECTORIES" => self.git_ceiling_directories.clone(),
503            "GIT_INDEX_FILE" => self.git_index_file.clone(),
504            "GIT_NAMESPACE" => self.git_namespace.clone(),
505            "GIT_REPLACE_REF_BASE" => self.git_replace_ref_base.clone(),
506            "GIT_ALTERNATE_OBJECT_DIRECTORIES" => self.git_alternate_object_directories.clone(),
507            "GIT_OBJECT_DIRECTORY" => self.git_object_directory.clone(),
508            "GIT_CONFIG_NOSYSTEM" => self.git_config_nosystem.clone(),
509            "GIT_CONFIG_SYSTEM" => self.git_config_system.clone(),
510            "GIT_CONFIG_GLOBAL" => self.git_config_global.clone(),
511            "GIT_CONFIG" => self.git_config.clone(),
512            "GIT_CONFIG_PARAMETERS" => self.git_config_parameters.clone(),
513            "GIT_CONFIG_COUNT" => self.git_config_count.clone(),
514            "GIT_PREFIX" => self.git_prefix.clone(),
515            "GIT_TEST_UTF8_NFD_TO_NFC" => self.git_test_utf8_nfd_to_nfc.clone(),
516            "GIT_TEST_REFTABLE_AUTOCOMPACTION" => self.git_test_reftable_autocompaction.clone(),
517            "GIT_AUTHOR_NAME" => self.git_author_name.clone(),
518            "GIT_AUTHOR_EMAIL" => self.git_author_email.clone(),
519            "GIT_AUTHOR_DATE" => self.git_author_date.clone(),
520            "GIT_COMMITTER_NAME" => self.git_committer_name.clone(),
521            "GIT_COMMITTER_EMAIL" => self.git_committer_email.clone(),
522            "GIT_COMMITTER_DATE" => self.git_committer_date.clone(),
523            "TZ" => self.tz.clone(),
524            "USER" => self.user.clone(),
525            "USERNAME" => self.username.clone(),
526            "XDG_CONFIG_HOME" => self.xdg_config_home.clone(),
527            "HOME" => self
528                .home
529                .as_ref()
530                .and_then(|h| h.to_str().map(str::to_owned)),
531            "USERPROFILE" => self
532                .userprofile
533                .as_ref()
534                .and_then(|h| h.to_str().map(str::to_owned)),
535            "HOMEDRIVE" => self
536                .homedrive
537                .as_ref()
538                .and_then(|h| h.to_str().map(str::to_owned)),
539            "HOMEPATH" => self
540                .homepath
541                .as_ref()
542                .and_then(|h| h.to_str().map(str::to_owned)),
543            key if key.starts_with("GIT_CONFIG_KEY_") => {
544                let idx: usize = key["GIT_CONFIG_KEY_".len()..].parse().ok()?;
545                self.git_config_pairs.get(idx).map(|p| p.0.clone())
546            }
547            key if key.starts_with("GIT_CONFIG_VALUE_") => {
548                let idx: usize = key["GIT_CONFIG_VALUE_".len()..].parse().ok()?;
549                self.git_config_pairs.get(idx).map(|p| p.1.clone())
550            }
551            _ => self
552                .captured_vars
553                .get(key)
554                .and_then(|v| v.to_str().map(str::to_owned)),
555        }
556    }
557
558    #[must_use]
559    /// Var os.
560    pub fn var_os(&self, key: &str) -> Option<OsString> {
561        if key == "HOME" {
562            return self.home.clone();
563        }
564        if key == "GIT_NO_REPLACE_OBJECTS" && self.git_no_replace_objects {
565            return Some(OsString::new());
566        }
567        if key == "GIT_INSTALL_ROOT" {
568            return self.git_install_root.clone();
569        }
570        if key == "GIT_EXEC_PATH" {
571            return self.git_exec_path.clone();
572        }
573        if let Some(v) = self.var(key) {
574            return Some(OsString::from(v));
575        }
576        self.captured_vars.get(key).cloned()
577    }
578
579    /// Resolve `cwd` for discovery: canonicalize when possible without `env::current_dir`.
580    #[must_use]
581    pub fn discovery_cwd(&self) -> PathBuf {
582        self.cwd.canonicalize().unwrap_or_else(|_| self.cwd.clone())
583    }
584
585    /// Variables exported to hook/filter subprocesses (no process inheritance).
586    ///
587    /// Typed fields are authoritative for the variables they model (clearing
588    /// [`Self::git_dir`] removes `GIT_DIR` even when the original snapshot had it). Every other
589    /// variable captured by [`Self::from_vars`] (`PATH`, `SystemRoot`, `LANG`, …) is passed
590    /// through unchanged, as Git does for hooks, so child processes can find their tools.
591    #[must_use]
592    pub fn subprocess_environment(&self) -> Vec<(OsString, OsString)> {
593        let mut passthrough: Vec<(&String, &OsString)> = self
594            .captured_vars
595            .iter()
596            .filter(|(k, _)| !is_typed_environment_var(k))
597            .collect();
598        passthrough.sort_unstable_by(|a, b| a.0.cmp(b.0));
599        let mut set: Vec<(OsString, OsString)> = passthrough
600            .into_iter()
601            .map(|(k, v)| (OsString::from(k), v.clone()))
602            .collect();
603        subprocess_push_str(&mut set, "GIT_DIR", self.git_dir.as_deref());
604        subprocess_push_str(&mut set, "GIT_WORK_TREE", self.git_work_tree.as_deref());
605        subprocess_push_str(
606            &mut set,
607            "GIT_CEILING_DIRECTORIES",
608            self.git_ceiling_directories.as_deref(),
609        );
610        subprocess_push_str(&mut set, "GIT_INDEX_FILE", self.git_index_file.as_deref());
611        subprocess_push_str(&mut set, "GIT_NAMESPACE", self.git_namespace.as_deref());
612        subprocess_push_str(
613            &mut set,
614            "GIT_REPLACE_REF_BASE",
615            self.git_replace_ref_base.as_deref(),
616        );
617        subprocess_push_str(
618            &mut set,
619            "GIT_ALTERNATE_OBJECT_DIRECTORIES",
620            self.git_alternate_object_directories.as_deref(),
621        );
622        subprocess_push_str(
623            &mut set,
624            "GIT_OBJECT_DIRECTORY",
625            self.git_object_directory.as_deref(),
626        );
627        subprocess_push_str(&mut set, "GIT_PREFIX", self.git_prefix.as_deref());
628        subprocess_push_str(
629            &mut set,
630            "GIT_CONFIG_NOSYSTEM",
631            self.git_config_nosystem.as_deref(),
632        );
633        subprocess_push_str(
634            &mut set,
635            "GIT_CONFIG_SYSTEM",
636            self.git_config_system.as_deref(),
637        );
638        subprocess_push_str(
639            &mut set,
640            "GIT_CONFIG_GLOBAL",
641            self.git_config_global.as_deref(),
642        );
643        subprocess_push_str(&mut set, "GIT_CONFIG", self.git_config.as_deref());
644        subprocess_push_str(
645            &mut set,
646            "GIT_CONFIG_PARAMETERS",
647            self.git_config_parameters.as_deref(),
648        );
649        subprocess_push_str(
650            &mut set,
651            "GIT_CONFIG_COUNT",
652            self.git_config_count.as_deref(),
653        );
654        for (i, (k, v)) in self.git_config_pairs.iter().enumerate() {
655            set.push((
656                OsString::from(format!("GIT_CONFIG_KEY_{i}")),
657                OsString::from(k),
658            ));
659            set.push((
660                OsString::from(format!("GIT_CONFIG_VALUE_{i}")),
661                OsString::from(v),
662            ));
663        }
664        subprocess_push_os(&mut set, "HOME", self.home.as_ref());
665        subprocess_push_str(&mut set, "XDG_CONFIG_HOME", self.xdg_config_home.as_deref());
666        subprocess_push_os(&mut set, "USERPROFILE", self.userprofile.as_ref());
667        subprocess_push_os(&mut set, "HOMEDRIVE", self.homedrive.as_ref());
668        subprocess_push_os(&mut set, "HOMEPATH", self.homepath.as_ref());
669        subprocess_push_os(&mut set, "GIT_INSTALL_ROOT", self.git_install_root.as_ref());
670        subprocess_push_os(&mut set, "GIT_EXEC_PATH", self.git_exec_path.as_ref());
671        subprocess_push_str(&mut set, "PWD", self.pwd.as_deref());
672        subprocess_push_str(
673            &mut set,
674            "GIT_TEST_ASSUME_DIFFERENT_OWNER",
675            self.git_test_assume_different_owner.as_deref(),
676        );
677        subprocess_push_str(&mut set, "GIT_TRACE_SETUP", self.git_trace_setup.as_deref());
678        subprocess_push_str(&mut set, "GIT_TRACE2_PERF", self.git_trace2_perf.as_deref());
679        subprocess_push_str(
680            &mut set,
681            "GRIT_INVOCATION_CWD",
682            self.grit_invocation_cwd.as_deref(),
683        );
684        subprocess_push_str(&mut set, "SUDO_UID", self.sudo_uid.as_deref());
685        subprocess_push_str(
686            &mut set,
687            "GIT_TEST_UTF8_NFD_TO_NFC",
688            self.git_test_utf8_nfd_to_nfc.as_deref(),
689        );
690        subprocess_push_str(
691            &mut set,
692            "GIT_TEST_NO_WRITE_REV_INDEX",
693            self.git_test_no_write_rev_index.as_deref(),
694        );
695        subprocess_push_str(
696            &mut set,
697            "GIT_TEST_REFTABLE_AUTOCOMPACTION",
698            self.git_test_reftable_autocompaction.as_deref(),
699        );
700        subprocess_push_str(&mut set, "GIT_AUTHOR_NAME", self.git_author_name.as_deref());
701        subprocess_push_str(
702            &mut set,
703            "GIT_AUTHOR_EMAIL",
704            self.git_author_email.as_deref(),
705        );
706        subprocess_push_str(&mut set, "GIT_AUTHOR_DATE", self.git_author_date.as_deref());
707        subprocess_push_str(
708            &mut set,
709            "GIT_COMMITTER_NAME",
710            self.git_committer_name.as_deref(),
711        );
712        subprocess_push_str(
713            &mut set,
714            "GIT_COMMITTER_EMAIL",
715            self.git_committer_email.as_deref(),
716        );
717        subprocess_push_str(
718            &mut set,
719            "GIT_COMMITTER_DATE",
720            self.git_committer_date.as_deref(),
721        );
722        subprocess_push_str(&mut set, "TZ", self.tz.as_deref());
723        subprocess_push_str(&mut set, "USER", self.user.as_deref());
724        subprocess_push_str(&mut set, "USERNAME", self.username.as_deref());
725        subprocess_push_str(&mut set, "ProgramFiles", self.program_files.as_deref());
726        subprocess_push_str(
727            &mut set,
728            "ProgramFiles(x86)",
729            self.program_files_x86.as_deref(),
730        );
731        if self.git_no_replace_objects {
732            set.push((OsString::from("GIT_NO_REPLACE_OBJECTS"), OsString::new()));
733        }
734        if self.grit_debug_safe_dir {
735            set.push((OsString::from("GRIT_DEBUG_SAFE_DIR"), OsString::new()));
736        }
737        set
738    }
739
740    /// User home directory using the same resolution as Git config loading.
741    #[must_use]
742    pub fn home_dir(&self) -> Option<PathBuf> {
743        if let Some(home) = self.home.as_ref().filter(|h| !h.is_empty()) {
744            return Some(PathBuf::from(home));
745        }
746        #[cfg(windows)]
747        {
748            if let (Some(drive), Some(path)) = (
749                self.homedrive.as_ref().filter(|d| !d.is_empty()),
750                self.homepath.as_ref().filter(|p| !p.is_empty()),
751            ) {
752                let mut combined = drive.clone();
753                combined.push(path);
754                return Some(PathBuf::from(combined));
755            }
756            if let Some(profile) = self.userprofile.as_ref().filter(|p| !p.is_empty()) {
757                return Some(PathBuf::from(profile));
758            }
759        }
760        None
761    }
762
763    /// Default path for `$XDG_CONFIG_HOME/git/{name}` or `$HOME/.config/git/{name}`.
764    ///
765    /// Used for Git's implicit global excludes (`ignore`) and attributes (`attributes`) files
766    /// when `core.excludesfile` / `core.attributesfile` are unset.
767    #[must_use]
768    pub fn default_xdg_config_git_file(&self, name: &str) -> Option<PathBuf> {
769        if let Some(xdg) = self.xdg_config_home.as_deref().filter(|s| !s.is_empty()) {
770            return Some(PathBuf::from(xdg).join("git").join(name));
771        }
772        self.home_dir()
773            .map(|home| home.join(".config").join("git").join(name))
774    }
775}
776
777/// One command-line configuration override, the `key[=value]` argument of `git -c`.
778///
779/// Parse one with [`str::parse`] (or [`ConfigOverride::new`]) and apply it with
780/// [`Environment::push_config_override`]. The key is validated like any config key
781/// (`section[.subsection].name`); a bare `key` with no `=` sets the boolean value `true`,
782/// and `key=` sets the empty string.
783#[derive(Debug, Clone, PartialEq, Eq)]
784pub struct ConfigOverride {
785    key: String,
786    value: Option<String>,
787}
788
789impl ConfigOverride {
790    /// Build an override from a key and optional value.
791    ///
792    /// # Parameters
793    ///
794    /// - `key`: config key such as `user.name` or `remote.origin.url`.
795    /// - `value`: the value to set, or `None` for a boolean `true` (bare key).
796    ///
797    /// # Errors
798    ///
799    /// Returns [`crate::error::Error::Config`] when `key` is not a valid config key.
800    pub fn new(key: impl Into<String>, value: Option<String>) -> crate::error::Result<Self> {
801        let key = key.into();
802        crate::config::canonical_key(&key)?;
803        Ok(Self { key, value })
804    }
805
806    /// The config key, as given.
807    #[must_use]
808    pub fn key(&self) -> &str {
809        &self.key
810    }
811
812    /// The value, or `None` when the key was given without `=` (boolean `true`).
813    #[must_use]
814    pub fn value(&self) -> Option<&str> {
815        self.value.as_deref()
816    }
817}
818
819impl FromStr for ConfigOverride {
820    type Err = crate::error::Error;
821
822    /// Parse `key=value` (value may be empty or contain `=`) or a bare `key`.
823    fn from_str(spec: &str) -> Result<Self, Self::Err> {
824        match spec.split_once('=') {
825            Some((key, value)) => Self::new(key, Some(value.to_owned())),
826            None => Self::new(spec, None),
827        }
828    }
829}
830
831impl IdentityEnv for Environment {
832    fn var(&self, key: &str) -> Option<String> {
833        Environment::var(self, key)
834    }
835
836    fn var_os(&self, key: &str) -> Option<OsString> {
837        Environment::var_os(self, key)
838    }
839}
840
841/// Nanoseconds since the Unix epoch from the process wall clock.
842///
843/// Callers pass this into [`crate::fsmonitor::FsmonitorRefreshOptions`] so fsmonitor hook
844/// protocol v1 tokens are sampled before the hook subprocess runs.
845#[must_use]
846pub fn wall_clock_unix_timestamp_nanos() -> u64 {
847    u64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos()).unwrap_or(u64::MAX)
848}
849
850/// Whole seconds since the Unix epoch from the process wall clock.
851#[must_use]
852pub(crate) fn wall_clock_unix_timestamp_seconds() -> i64 {
853    OffsetDateTime::now_utc().unix_timestamp()
854}
855
856/// Map a Unix timestamp through an optional `TZ`-style string (or the process default offset).
857#[must_use]
858pub fn offset_from_epoch_and_tz(epoch: i64, tz: Option<&str>) -> OffsetDateTime {
859    let tz_hhmm = local_tzoffset_with_tz(epoch as u64, tz);
860    let offset = utc_offset_from_tz_hhmm(tz_hhmm).unwrap_or(UtcOffset::UTC);
861    OffsetDateTime::from_unix_timestamp(epoch)
862        .unwrap_or(OffsetDateTime::UNIX_EPOCH)
863        .to_offset(offset)
864}
865
866fn utc_offset_from_tz_hhmm(tz: TzHhmm) -> Option<UtcOffset> {
867    let sign: i32 = if tz < 0 { -1 } else { 1 };
868    let abs = tz.unsigned_abs();
869    let hours = (abs / 100) as i32;
870    let minutes = (abs % 100) as i32;
871    let seconds = sign * (hours * 3600 + minutes * 60);
872    UtcOffset::from_whole_seconds(seconds).ok()
873}
874
875/// Variables [`Environment::from_vars`] parses into typed fields; keep in sync with it.
876const TYPED_ENVIRONMENT_VARS: &[&str] = &[
877    "GIT_DIR",
878    "GIT_WORK_TREE",
879    "GIT_CEILING_DIRECTORIES",
880    "GIT_INDEX_FILE",
881    "GIT_NAMESPACE",
882    "GIT_REPLACE_REF_BASE",
883    "GIT_NO_REPLACE_OBJECTS",
884    "GIT_ALTERNATE_OBJECT_DIRECTORIES",
885    "GIT_OBJECT_DIRECTORY",
886    "GIT_PREFIX",
887    "GIT_CONFIG_NOSYSTEM",
888    "GIT_CONFIG_SYSTEM",
889    "GIT_CONFIG_GLOBAL",
890    "GIT_CONFIG",
891    "GIT_CONFIG_PARAMETERS",
892    "GIT_CONFIG_COUNT",
893    "HOME",
894    "XDG_CONFIG_HOME",
895    "USERPROFILE",
896    "HOMEDRIVE",
897    "HOMEPATH",
898    "GIT_INSTALL_ROOT",
899    "GIT_EXEC_PATH",
900    "PWD",
901    "GIT_TEST_ASSUME_DIFFERENT_OWNER",
902    "GRIT_DEBUG_SAFE_DIR",
903    "GIT_TRACE_SETUP",
904    "GIT_TRACE2_PERF",
905    "GRIT_INVOCATION_CWD",
906    "SUDO_UID",
907    "GIT_TEST_UTF8_NFD_TO_NFC",
908    "GIT_TEST_NO_WRITE_REV_INDEX",
909    "GIT_TEST_REFTABLE_AUTOCOMPACTION",
910    "GIT_AUTHOR_NAME",
911    "GIT_AUTHOR_EMAIL",
912    "GIT_AUTHOR_DATE",
913    "GIT_COMMITTER_NAME",
914    "GIT_COMMITTER_EMAIL",
915    "GIT_COMMITTER_DATE",
916    "TZ",
917    "USER",
918    "USERNAME",
919    "ProgramFiles",
920    "ProgramFiles(x86)",
921];
922
923fn is_typed_environment_var(key: &str) -> bool {
924    TYPED_ENVIRONMENT_VARS.contains(&key)
925        || key.starts_with("GIT_CONFIG_KEY_")
926        || key.starts_with("GIT_CONFIG_VALUE_")
927}
928
929fn subprocess_push_str(out: &mut Vec<(OsString, OsString)>, key: &str, val: Option<&str>) {
930    if let Some(v) = val {
931        out.push((OsString::from(key), OsString::from(v)));
932    }
933}
934
935fn subprocess_push_os(out: &mut Vec<(OsString, OsString)>, key: &str, val: Option<&OsString>) {
936    if let Some(v) = val {
937        out.push((OsString::from(key), v.clone()));
938    }
939}
940
941/// Options passed to repository open/discover with an explicit [`Environment`].
942#[derive(Clone)]
943#[non_exhaustive]
944pub struct RepositoryOptions {
945    /// Discovery and configuration environment.
946    pub environment: Environment,
947    /// Subprocess runner for hooks, filters, and helpers.
948    pub command_runner: Arc<dyn CommandRunner>,
949    /// Random bytes for new change ids (`grit.changeIds`).
950    pub change_id_generator: Arc<dyn ChangeIdGenerator>,
951    /// Where non-fatal warnings and trace events are delivered.
952    pub diagnostics: DiagnosticsHandle,
953    /// When true, network operations may emit [`crate::diagnostics::Trace::Network`] events.
954    pub network_trace: bool,
955    /// When true, treat repository ownership as a different user (`safe.directory` tests).
956    pub test_assume_different_owner: bool,
957    /// Wall-clock reference for rev-parse date selectors; embedders set explicitly.
958    pub reference_unix_time: Option<i64>,
959    /// Parallel worker overrides (`--threads` in the CLI).
960    pub thread_policy: crate::parallel::ThreadPolicy,
961    /// Work tree path for [`crate::repo::Repository::open`] (non-bare repos).
962    pub work_tree: Option<std::path::PathBuf>,
963    /// When false, skip [`crate::repo::validate_repo_format`] during open.
964    pub validate_repository_format: bool,
965    /// When true, [`crate::repo::Repository::open`] resolves `path` like wire-protocol serving.
966    pub open_for_serving: bool,
967    /// Optional on-demand fetcher for partial-clone promisor objects.
968    pub promisor_fetcher: Option<std::sync::Arc<dyn crate::promisor_fetch::PromisorFetcher>>,
969    /// Where hook subprocesses write their output (captured by default).
970    pub hook_stdio: crate::hooks::HookStdio,
971}
972
973impl std::fmt::Debug for RepositoryOptions {
974    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
975        f.debug_struct("RepositoryOptions")
976            .field("environment", &self.environment)
977            .field("command_runner", &"<CommandRunner>")
978            .field("network_trace", &self.network_trace)
979            .finish_non_exhaustive()
980    }
981}
982
983impl Default for RepositoryOptions {
984    fn default() -> Self {
985        Self {
986            environment: Environment::empty(),
987            command_runner: system_command_runner(),
988            change_id_generator: Arc::new(OsChangeIdGenerator),
989            diagnostics: Arc::new(NullDiagnostics),
990            network_trace: false,
991            test_assume_different_owner: false,
992            reference_unix_time: None,
993            thread_policy: crate::parallel::ThreadPolicy::default(),
994            work_tree: None,
995            validate_repository_format: true,
996            open_for_serving: false,
997            promisor_fetcher: None,
998            hook_stdio: crate::hooks::HookStdio::default(),
999        }
1000    }
1001}
1002
1003impl RepositoryOptions {
1004    /// Shorthand for [`Environment::empty`].
1005    #[must_use]
1006    pub fn empty() -> Self {
1007        Self::default()
1008    }
1009
1010    /// Build options around an explicit [`Environment`] snapshot.
1011    #[must_use]
1012    pub fn with_environment(environment: Environment) -> Self {
1013        Self {
1014            environment,
1015            ..Self::default()
1016        }
1017    }
1018
1019    /// Override the injectable subprocess runner used for hooks and filters.
1020    #[must_use]
1021    pub fn with_command_runner(mut self, runner: Arc<dyn CommandRunner>) -> Self {
1022        self.command_runner = runner;
1023        self
1024    }
1025
1026    /// Override the generator used for random change ids on new commits.
1027    #[must_use]
1028    pub fn with_change_id_generator(mut self, generator: Arc<dyn ChangeIdGenerator>) -> Self {
1029        self.change_id_generator = generator;
1030        self
1031    }
1032
1033    /// Attach an explicit work tree for [`crate::repo::Repository::open`].
1034    #[must_use]
1035    pub fn with_work_tree(mut self, work_tree: impl Into<std::path::PathBuf>) -> Self {
1036        self.work_tree = Some(work_tree.into());
1037        self
1038    }
1039
1040    /// Skip repository format validation when opening (discovery follow-up paths).
1041    #[must_use]
1042    pub fn without_format_validation(mut self) -> Self {
1043        self.validate_repository_format = false;
1044        self
1045    }
1046
1047    /// Resolve `path` for smart-HTTP / upload-pack serving instead of a bare git directory.
1048    #[must_use]
1049    pub fn for_serving(mut self) -> Self {
1050        self.open_for_serving = true;
1051        self
1052    }
1053
1054    /// Override the diagnostic sink for warnings and trace events.
1055    #[must_use]
1056    pub fn with_diagnostics(mut self, diagnostics: DiagnosticsHandle) -> Self {
1057        self.diagnostics = diagnostics;
1058        self
1059    }
1060
1061    /// Choose whether hooks run by repositories opened with these options have their output
1062    /// captured ([`HookStdio::Capture`](crate::command_runner::hooks::HookStdio::Capture), the default) or
1063    /// write straight to this process's stderr.
1064    #[must_use]
1065    pub fn with_hook_stdio(mut self, hook_stdio: crate::hooks::HookStdio) -> Self {
1066        self.hook_stdio = hook_stdio;
1067        self
1068    }
1069
1070    /// Enable or disable network trace events.
1071    #[must_use]
1072    pub fn with_network_trace(mut self, network_trace: bool) -> Self {
1073        self.network_trace = network_trace;
1074        self
1075    }
1076
1077    /// Install a promisor on-demand object fetcher for repositories opened with these options.
1078    #[must_use]
1079    pub fn with_promisor_fetcher(
1080        mut self,
1081        fetcher: Option<std::sync::Arc<dyn crate::promisor_fetch::PromisorFetcher>>,
1082    ) -> Self {
1083        self.promisor_fetcher = fetcher;
1084        self
1085    }
1086}
1087
1088fn process_cwd_fallback() -> PathBuf {
1089    // hygiene: used only from [`Environment::capture_process`].
1090    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::*;
1096
1097    #[test]
1098    fn is_fetch_options_default_matches_empty() {
1099        assert!(Environment::empty().is_fetch_options_default());
1100        let mut env = Environment::from_vars(
1101            [(
1102                OsString::from("GIT_SSH_COMMAND"),
1103                OsString::from("/bin/wrap"),
1104            )],
1105            PathBuf::from("."),
1106        );
1107        assert!(!env.is_fetch_options_default());
1108        env.captured_vars.clear();
1109        assert!(env.is_fetch_options_default());
1110    }
1111
1112    #[test]
1113    fn var_reads_typed_object_directory_fields() {
1114        let vars = [
1115            ("GIT_OBJECT_DIRECTORY", "/captured/objects"),
1116            ("GIT_ALTERNATE_OBJECT_DIRECTORIES", "/captured/alt"),
1117        ]
1118        .into_iter()
1119        .map(|(k, v)| (OsString::from(k), OsString::from(v)));
1120        let mut env = Environment::from_vars(vars, PathBuf::from("/tmp"));
1121        assert_eq!(
1122            env.var("GIT_OBJECT_DIRECTORY").as_deref(),
1123            Some("/captured/objects")
1124        );
1125        env.git_object_directory = Some("/typed/objects".to_owned());
1126        env.git_alternate_object_directories = None;
1127        assert_eq!(
1128            env.var("GIT_OBJECT_DIRECTORY").as_deref(),
1129            Some("/typed/objects")
1130        );
1131        assert_eq!(env.var("GIT_ALTERNATE_OBJECT_DIRECTORIES"), None);
1132    }
1133
1134    #[test]
1135    fn subprocess_environment_passes_through_untyped_vars() {
1136        let vars = [
1137            ("PATH", "/opt/tools/bin:/usr/bin"),
1138            ("SystemRoot", r"C:\Windows"),
1139            ("GIT_AUTHOR_NAME", "Ada"),
1140            ("GIT_DIR", "/stale/.git"),
1141            ("GIT_CONFIG_COUNT", "1"),
1142            ("GIT_CONFIG_KEY_0", "user.name"),
1143            ("GIT_CONFIG_VALUE_0", "Ada"),
1144        ]
1145        .into_iter()
1146        .map(|(k, v)| (OsString::from(k), OsString::from(v)));
1147        let mut env = Environment::from_vars(vars, PathBuf::from("/tmp"));
1148        env.git_dir = None;
1149        env.git_config_pairs[0].1 = "Grace".to_owned();
1150
1151        let set = env.subprocess_environment();
1152        let get = |key: &str| -> Vec<&OsString> {
1153            set.iter()
1154                .filter(|(k, _)| k == key)
1155                .map(|(_, v)| v)
1156                .collect()
1157        };
1158        assert_eq!(get("PATH"), vec!["/opt/tools/bin:/usr/bin"]);
1159        assert_eq!(get("SystemRoot"), vec![r"C:\Windows"]);
1160        assert_eq!(get("GIT_AUTHOR_NAME"), vec!["Ada"]);
1161        assert!(get("GIT_DIR").is_empty(), "cleared typed field must win");
1162        assert_eq!(get("GIT_CONFIG_VALUE_0"), vec!["Grace"]);
1163    }
1164
1165    #[test]
1166    fn subprocess_environment_of_empty_snapshot_is_empty() {
1167        assert!(Environment::empty().subprocess_environment().is_empty());
1168    }
1169
1170    #[test]
1171    fn from_vars_parses_config_count_pairs() {
1172        let vars = [
1173            ("GIT_CONFIG_COUNT", "1"),
1174            ("GIT_CONFIG_KEY_0", "user.name"),
1175            ("GIT_CONFIG_VALUE_0", "Ada"),
1176        ]
1177        .into_iter()
1178        .map(|(k, v)| (OsString::from(k), OsString::from(v)));
1179        let env = Environment::from_vars(vars, PathBuf::from("/tmp"));
1180        assert_eq!(env.git_config_count.as_deref(), Some("1"));
1181        assert_eq!(
1182            env.git_config_pairs,
1183            vec![("user.name".to_owned(), "Ada".to_owned())]
1184        );
1185    }
1186
1187    #[test]
1188    fn config_fingerprint_tracks_global_override() {
1189        let mut env = Environment::empty();
1190        env.git_config_global = Some("/tmp/global.cfg".to_owned());
1191        let fp = env.config_fingerprint().expect("fingerprint");
1192        assert!(fp
1193            .iter()
1194            .any(|(k, v)| k == "GIT_CONFIG_GLOBAL" && v.as_deref() == Some("/tmp/global.cfg")));
1195    }
1196
1197    #[test]
1198    fn default_xdg_config_git_file_prefers_xdg() {
1199        let mut env = Environment::empty();
1200        env.home = Some(OsString::from("/home/u"));
1201        env.xdg_config_home = Some("/xdg".to_owned());
1202        assert_eq!(
1203            env.default_xdg_config_git_file("ignore"),
1204            Some(PathBuf::from("/xdg/git/ignore"))
1205        );
1206    }
1207
1208    fn isolated_env(dir: &Path) -> Environment {
1209        let mut env = Environment::empty();
1210        env.cwd = dir.to_path_buf();
1211        env.home = Some(dir.as_os_str().to_owned());
1212        env.git_config_nosystem = Some("1".to_owned());
1213        env.git_config_global = Some(dir.join("no-global").to_string_lossy().into_owned());
1214        env
1215    }
1216
1217    fn override_of(spec: &str) -> ConfigOverride {
1218        spec.parse().expect("valid override")
1219    }
1220
1221    #[test]
1222    fn config_override_parses_key_value_bare_and_empty() {
1223        let o = override_of("user.name=Ada Lovelace");
1224        assert_eq!((o.key(), o.value()), ("user.name", Some("Ada Lovelace")));
1225        let o = override_of("http.extraHeader=Authorization: a=b");
1226        assert_eq!(o.value(), Some("Authorization: a=b"));
1227        let o = override_of("core.bare");
1228        assert_eq!((o.key(), o.value()), ("core.bare", None));
1229        let o = override_of("user.email=");
1230        assert_eq!(o.value(), Some(""));
1231        let o = override_of("remote.My Origin.url=x");
1232        assert_eq!(o.key(), "remote.My Origin.url");
1233    }
1234
1235    #[test]
1236    fn config_override_rejects_invalid_keys() {
1237        for spec in [
1238            "nosection",
1239            "=value",
1240            "user.=x",
1241            ".name=x",
1242            "us er.name=x",
1243            "",
1244        ] {
1245            assert!(
1246                spec.parse::<ConfigOverride>().is_err(),
1247                "{spec:?} should be rejected"
1248            );
1249        }
1250        assert!(ConfigOverride::new("a\nb.c", None).is_err());
1251    }
1252
1253    #[test]
1254    fn push_config_override_round_trips_through_parameter_parser() {
1255        let mut env = Environment::empty();
1256        let tricky = "it's a \"quote\" = !bang  spaced";
1257        env.push_config_override(
1258            &ConfigOverride::new("user.name", Some(tricky.to_owned())).unwrap(),
1259        );
1260        env.push_config_override(&override_of("core.bare"));
1261        env.push_config_override(&override_of("user.email="));
1262        let raw = env.git_config_parameters.clone().expect("parameters set");
1263        assert_eq!(
1264            crate::config::parse_config_parameters(&raw),
1265            vec![
1266                format!("user.name\u{1}{tricky}"),
1267                "core.bare\u{1}".to_owned(),
1268                "user.email\u{1}".to_owned(),
1269            ]
1270        );
1271    }
1272
1273    #[test]
1274    fn push_config_override_appends_to_inherited_parameters() {
1275        let mut env = Environment::empty();
1276        env.git_config_parameters = Some("'core.editor'='vi'".to_owned());
1277        env.push_config_override(&override_of("user.name=CI"));
1278        assert_eq!(
1279            env.git_config_parameters.as_deref(),
1280            Some("'core.editor'='vi' 'user.name'='CI'")
1281        );
1282        let mut blank = Environment::empty();
1283        blank.git_config_parameters = Some("  ".to_owned());
1284        blank.push_config_override(&override_of("user.name=CI"));
1285        assert_eq!(
1286            blank.git_config_parameters.as_deref(),
1287            Some("'user.name'='CI'")
1288        );
1289    }
1290
1291    #[test]
1292    fn later_config_override_wins_over_earlier_env_pairs_and_files() {
1293        let dir = tempfile::tempdir().unwrap();
1294        let git_dir = dir.path().join(".git");
1295        std::fs::create_dir_all(&git_dir).unwrap();
1296        std::fs::write(
1297            git_dir.join("config"),
1298            "[user]\n\tname = FromRepo\n\temail = repo@x.y\n[core]\n\tbare = false\n",
1299        )
1300        .unwrap();
1301        let mut env = isolated_env(dir.path());
1302        env.git_config_count = Some("1".to_owned());
1303        env.git_config_pairs = vec![("user.name".to_owned(), "FromCount".to_owned())];
1304        env.push_config_override(&override_of("user.name=First"));
1305        env.push_config_override(&override_of("user.name=Second"));
1306        env.push_config_override(&override_of("core.bare"));
1307
1308        let config = crate::config::ConfigSet::load(&env, Some(&git_dir), false).unwrap();
1309        assert_eq!(config.get("user.name").as_deref(), Some("Second"));
1310        assert_eq!(config.get("user.email").as_deref(), Some("repo@x.y"));
1311        assert_eq!(config.get_bool("core.bare"), Some(Ok(true)));
1312    }
1313
1314    #[test]
1315    fn config_override_is_exported_to_subprocesses() {
1316        let mut env = Environment::empty();
1317        env.push_config_override(&override_of("user.name=CI"));
1318        assert!(env.subprocess_environment().contains(&(
1319            OsString::from("GIT_CONFIG_PARAMETERS"),
1320            OsString::from("'user.name'='CI'")
1321        )));
1322    }
1323
1324    #[test]
1325    fn with_git_dir_and_work_tree_resolve_relative_paths_against_cwd() {
1326        let mut env = Environment::empty();
1327        env.cwd = PathBuf::from("/base/dir");
1328        let env = env.with_git_dir("repo/.git").with_work_tree("wt");
1329        assert_eq!(
1330            env.git_dir.as_deref().map(PathBuf::from),
1331            Some(PathBuf::from("/base/dir/repo/.git"))
1332        );
1333        assert_eq!(
1334            env.git_work_tree.as_deref().map(PathBuf::from),
1335            Some(PathBuf::from("/base/dir/wt"))
1336        );
1337        let abs = std::env::temp_dir().join("abs.git");
1338        let env = env.with_git_dir(&abs);
1339        assert_eq!(env.git_dir.as_deref().map(PathBuf::from), Some(abs));
1340    }
1341
1342    #[test]
1343    fn with_git_dir_opens_that_repository_from_elsewhere() {
1344        let dir = tempfile::tempdir().unwrap();
1345        let repo_dir = dir.path().join("r");
1346        crate::repo::init_repository(
1347            &repo_dir,
1348            false,
1349            "main",
1350            None,
1351            crate::ref_storage::RefStorageFormat::Files,
1352        )
1353        .unwrap();
1354        let elsewhere = dir.path().join("elsewhere");
1355        std::fs::create_dir_all(&elsewhere).unwrap();
1356        let mut env = isolated_env(&elsewhere);
1357        env.cwd = elsewhere.clone();
1358        let env = env.with_git_dir("../r/.git").with_work_tree("../r");
1359        let repo =
1360            crate::repo::Repository::discover(None, &RepositoryOptions::with_environment(env))
1361                .unwrap();
1362        assert_eq!(
1363            repo.git_dir().canonicalize().unwrap(),
1364            repo_dir.join(".git").canonicalize().unwrap()
1365        );
1366        assert_eq!(
1367            repo.work_tree().map(|p| p.canonicalize().unwrap()),
1368            Some(repo_dir.canonicalize().unwrap())
1369        );
1370    }
1371
1372    #[test]
1373    fn default_xdg_config_git_file_falls_back_to_home_config() {
1374        let mut env = Environment::empty();
1375        env.home = Some(OsString::from("/home/u"));
1376        assert_eq!(
1377            env.default_xdg_config_git_file("ignore"),
1378            Some(PathBuf::from("/home/u/.config/git/ignore"))
1379        );
1380    }
1381}