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