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