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