git_xcrypt/git/config.rs
1//! Reading and writing git configuration through a library.
2//!
3//! Spawning `git config` is not an option: git starts a filter process per
4//! operation, so it would be N process spawns on the hot path — most expensive
5//! on exactly the platform where it hurts most. Writing only ever touches the
6//! repository-local file, which is the only one this tool has business changing.
7
8use std::fmt;
9use std::path::{Path, PathBuf};
10
11use bstr::ByteSlice as _;
12use gix_config::File;
13use gix_config::file::Metadata;
14
15use crate::{Error, Result};
16
17/// The global attributes file, resolved the way git resolves it.
18///
19/// Reading `core.attributesFile` verbatim is not enough, and the gap is not
20/// cosmetic: it is a source in git's attribute stack, so a line in it can put
21/// `text` back on a path this tool encrypts — and git then converts the
22/// ciphertext. Measured on git 2.55, 2 MB, the line living in
23/// `~/.config/git/attributes` while the same line in the tree is refused: `git
24/// add` exited **0**, 27 `CR` bytes were eaten out of the blob, the commit
25/// succeeded and the checkout left **no file at all**. The refusal in
26/// [`crate::commands::filter`] and the gate in `status` both resolve the stack correctly;
27/// they simply were not being handed this file, so both reported a healthy
28/// repository over a destroyed one.
29///
30/// Git's rule, measured on 2.55 rather than read from the documentation — the
31/// five shapes are a table test in this module:
32///
33/// | `core.attributesFile` | what git reads |
34/// | --- | --- |
35/// | unset | `$XDG_CONFIG_HOME/git/attributes`, else `$HOME/.config/git/attributes` |
36/// | `~/name`, `~user/name` | expanded, exactly as `core.excludesFile` is |
37/// | an absolute path | that path |
38/// | empty | **nothing** — and no XDG fallback either |
39///
40/// Returns the path whether or not it exists; a missing file is an empty source
41/// to the resolver, which is what git does with one too.
42#[must_use]
43pub fn global_attributes_file(config: &File) -> Option<PathBuf> {
44 global_attributes_file_for(
45 config,
46 // `HOME` first, then the platform's own answer — git's order, and the
47 // reason a Windows user can keep a linux-style home somewhere else.
48 gix_path::env::home_dir().as_deref(),
49 std::env::var_os("XDG_CONFIG_HOME").as_deref(),
50 )
51}
52
53/// [`global_attributes_file`], with the two environment variables as arguments.
54///
55/// Split out so the table below can be a test rather than a hope. `HOME` and
56/// `XDG_CONFIG_HOME` belong to the *process*, so setting them to exercise a row
57/// would need `unsafe` — which `unsafe_code = "forbid"` refuses, and that
58/// refusal is worth more than the convenience. Passing the difference in as an
59/// argument is the same shape `eol::apply_where` and `repo::with_separator`
60/// already use for a platform the test is not running on.
61#[must_use]
62fn global_attributes_file_for(
63 config: &File,
64 home: Option<&Path>,
65 xdg_config_home: Option<&std::ffi::OsStr>,
66) -> Option<PathBuf> {
67 let Ok(value) = config.raw_value("core.attributesFile") else {
68 // Git treats an empty `XDG_CONFIG_HOME` as unset, so `is_empty` is part
69 // of the rule and not a defensive extra.
70 if let Some(xdg) = xdg_config_home
71 && !xdg.is_empty()
72 {
73 return Some(PathBuf::from(xdg).join("git").join("attributes"));
74 }
75 return Some(home?.join(".config").join("git").join("attributes"));
76 };
77 // Set but empty turns the file off; it does **not** fall back to XDG.
78 if value.is_empty() {
79 return None;
80 }
81 // `~/`, `~user/` and `%(prefix)/`, through the same crate that parsed the
82 // value. Hand-rolling the expansion would be a second spelling of a rule
83 // git already has one of.
84 gix_config::Path::from(value)
85 .interpolate(gix_config::path::interpolate::Context {
86 home_dir: home,
87 ..Default::default()
88 })
89 .ok()
90}
91
92/// The repository-local configuration, loaded for editing.
93///
94/// Includes are deliberately not followed: we are about to write this file back,
95/// and following includes would fold someone else's file into ours.
96///
97/// # Errors
98///
99/// [`Error::Config`] when the file exists but cannot be parsed.
100pub fn open_local(path: &Path) -> Result<File> {
101 read_optional(path, gix_config::Source::Local)
102}
103
104/// One configuration file, where a missing one is empty and a broken one is not.
105///
106/// The asymmetry is the point: git creates these files lazily, so absence is an
107/// ordinary state and saying so costs nothing. A file that is *there* and does
108/// not parse is a different answer, and swallowing it would let a repository
109/// whose `filter.git-xcrypt.required` cannot be read pass for one that has it.
110fn read_optional(path: &Path, source: gix_config::Source) -> Result<File> {
111 if !path.exists() {
112 return Ok(File::new(Metadata::from(source)));
113 }
114 File::from_path_no_includes(path.to_path_buf(), source)
115 .map_err(|err| Error::Config(format!("could not read {}: {err}", path.display())))
116}
117
118/// The configuration git itself would see, for reading only.
119///
120/// Full precedence: git installation, system, global, repository-local,
121/// worktree and `GIT_CONFIG_*` overrides, with `include`/`includeIf` followed.
122/// The smudge path needs this rather than `.git/config` alone, because
123/// `core.autocrlf` and `core.eol` are almost always set globally — on Windows
124/// the installer does it — and reading only the local file would leave the
125/// measured line-ending table unreachable on exactly the platform it exists for.
126///
127/// This assembles the cascade the way `gix_config::File::from_git_dir` does,
128/// with **one** difference, and it is the reason it is spelled out here rather
129/// than called: that function reads `config.worktree` whenever
130/// `extensions.worktreeConfig` is true, and treats the file's absence as an
131/// error. Git treats it as empty — the extension is permission to look, not a
132/// promise the file exists, and git creates it lazily on the first
133/// `git config --worktree`.
134///
135/// Measured on git 2.55, 2026-08-05: in a repository where
136/// `extensions.worktreeConfig` was set and no `config.worktree` had been written
137/// yet, git ran `add`, `commit` and `status` at exit 0 while this build could
138/// not start its own filter — so with `required = true`, **every git operation
139/// in the repository failed**, `git add` exiting 128 with
140/// `could not read git configuration`. The trigger is the command git's own
141/// documentation gives for enabling per-worktree configuration, and the window
142/// is however long it takes to run the next one.
143///
144/// Fail-closed, so nothing was ever stored in the clear over it — but an outage
145/// is exactly what `required = true` turns a configuration read into, and the
146/// rest of this crate is careful about that.
147///
148/// **The two directories are different on purpose, and that is the second
149/// difference.** `config` is shared, so it comes from `common_dir`;
150/// `config.worktree` is per-checkout and comes from `git_dir`, which for a
151/// linked worktree is `…/.git/worktrees/<name>`. Reading both from the common
152/// directory — which is what this did, and what `gix-config` does when handed
153/// one path — gives a linked worktree the *main* checkout's per-worktree
154/// configuration, which is nobody's configuration.
155///
156/// Measured on git 2.55, 2026-08-05, 2 MB in a linked worktree whose
157/// `config.worktree` set `core.attributesFile` to a file declaring `vault/**
158/// text`: `git check-attr text` answered `set` there and `unspecified` in the
159/// main checkout, `git add` exited **0** because the refusal never saw the file,
160/// 40 bytes were eaten out of the blob, and the checkout left **no file at
161/// all**. Same shape as an unresolved `~/` in [`global_attributes_file`], one
162/// directory further out.
163///
164/// # Errors
165///
166/// [`Error::Config`] when a file in the cascade exists and cannot be parsed.
167pub fn open_full(git_dir: &Path, common_dir: &Path) -> Result<File> {
168 let broken =
169 |err: &dyn fmt::Display| Error::Config(format!("could not read git configuration: {err}"));
170
171 let mut local = read_optional(&common_dir.join("config"), gix_config::Source::Local)?;
172 // The one file git looks for only conditionally, and the one whose absence
173 // must not be an error. `Source::Worktree` rather than `Local`, so it keeps
174 // the precedence git gives it: above the local file, below the environment.
175 let worktree = get(&local, "extensions.worktreeConfig")
176 .is_some_and(|value| is_true(&value))
177 .then(|| {
178 read_optional(
179 &git_dir.join("config.worktree"),
180 gix_config::Source::Worktree,
181 )
182 })
183 .transpose()?;
184
185 let home = gix_path::env::home_dir();
186 let options = gix_config::file::init::Options {
187 includes: gix_config::file::includes::Options::follow(
188 gix_config::path::interpolate::Context {
189 home_dir: home.as_deref(),
190 ..Default::default()
191 },
192 gix_config::file::includes::conditional::Context {
193 git_dir: Some(git_dir),
194 branch_name: None,
195 },
196 ),
197 ..Default::default()
198 };
199
200 let mut config = File::from_globals().map_err(|err| broken(&err))?;
201 config
202 .resolve_includes(options)
203 .map_err(|err| broken(&err))?;
204 local
205 .resolve_includes(options)
206 .map_err(|err| broken(&err))?;
207 config.append(local).map_err(|err| broken(&err))?;
208 if let Some(mut worktree) = worktree {
209 worktree
210 .resolve_includes(options)
211 .map_err(|err| broken(&err))?;
212 config.append(worktree).map_err(|err| broken(&err))?;
213 }
214 config
215 .append(File::from_environment_overrides().map_err(|err| broken(&err))?)
216 .map_err(|err| broken(&err))?;
217 // Last, so `-c` outranks everything — including the `GIT_CONFIG_COUNT` set
218 // just above, which is the order git applies them in.
219 if let Some(overrides) = cli_overrides(std::env::var_os(CLI_OVERRIDE_ENV).as_deref()) {
220 config.append(overrides).map_err(|err| broken(&err))?;
221 }
222 Ok(config)
223}
224
225/// Where git puts the overrides given as `git -c key=value`.
226const CLI_OVERRIDE_ENV: &str = "GIT_CONFIG_PARAMETERS";
227
228/// The `git -c key=value` overrides, as a configuration source.
229///
230/// **Why this is hand-rolled rather than left to `gix-config`.** That crate
231/// reads the *other* mechanism — `GIT_CONFIG_COUNT` with `GIT_CONFIG_KEY_n` and
232/// `GIT_CONFIG_VALUE_n` — and git 2.55 does not populate it for `-c`; measured,
233/// by asking an alias to print its own environment. Without this, `git -c
234/// core.autocrlf=true checkout` converted the paths git owns and left ours
235/// alone: one command, two answers, on paths sitting next to each other.
236///
237/// **The format is measured, not assumed.** git 2.55 writes shell-quoted words,
238/// key and value quoted separately, and escapes `!` outside the quotes for csh:
239///
240/// ```text
241/// 'core.autocrlf'='true' 'core.eol'='lf' 'user.name'='a b'\''c'
242/// 'alias.x'=''\!'printenv FOO'
243/// ```
244///
245/// So a word is a concatenation of quoted runs, backslash escapes and bare
246/// characters, and it is only after unquoting that `key=value` can be split —
247/// a value may legally contain spaces, quotes and `=`. Older git quoted the
248/// whole pair as `'key=value'`, which this reads identically.
249///
250/// **Fail-open throughout, and that direction is deliberate.** Everything here
251/// runs on the clean path, where `required = true` turns a wrong answer into a
252/// blocked repository rather than a diagnostic. A value nobody typed is worse
253/// than a value we did not notice: a bogus `core.attributesFile` invented by a
254/// misparse would refuse `git add` across the whole repository. So an unterminated
255/// quote drops the entire variable, a word that does not name a dotted key is
256/// skipped, and a value `gix-config` will not accept is skipped — in every case
257/// leaving the configuration files to speak for themselves, exactly as before
258/// this function existed.
259fn cli_overrides(raw: Option<&std::ffi::OsStr>) -> Option<File> {
260 let words = split_quoted(raw?.to_str()?)?;
261 let mut file = File::new(Metadata::from(gix_config::Source::Cli));
262 let mut any = false;
263
264 for word in words {
265 // No `=` at all is git's boolean shorthand: `git -c core.autocrlf` means
266 // true. `get` already spells a value-less key that way, so this stays
267 // the one spelling every caller tests for.
268 let (key, value) = match word.split_once('=') {
269 Some((key, value)) => (key.to_string(), value.to_string()),
270 None => (word, "true".to_string()),
271 };
272 // Asked with `gix-config`'s own parser rather than by looking for a dot,
273 // so this and `get` cannot drift apart — and so `set_raw_value`, which
274 // panics on a key it will not parse, is never handed one.
275 if gix_config::AsKey::try_as_key(&key.as_str()).is_none() {
276 continue;
277 }
278 if file.set_raw_value(key.as_str(), value.as_str()).is_ok() {
279 any = true;
280 }
281 }
282
283 any.then_some(file)
284}
285
286/// Splits one shell-quoted line into words, or gives up on the whole line.
287///
288/// Only the three constructs git's own quoting produces: a single-quoted run is
289/// literal, a backslash outside quotes takes the next character literally, and
290/// unquoted whitespace ends a word. Returning `None` for an unterminated quote
291/// is the fail-open half of [`cli_overrides`] — half a word could name a key
292/// nobody asked for.
293fn split_quoted(line: &str) -> Option<Vec<String>> {
294 let mut words = Vec::new();
295 let mut current = String::new();
296 let mut started = false;
297 let mut quoted = false;
298 let mut chars = line.chars();
299
300 while let Some(character) = chars.next() {
301 match character {
302 '\'' => {
303 quoted = !quoted;
304 started = true;
305 }
306 '\\' if !quoted => {
307 current.push(chars.next()?);
308 started = true;
309 }
310 character if character.is_whitespace() && !quoted => {
311 if started {
312 words.push(std::mem::take(&mut current));
313 started = false;
314 }
315 }
316 character => {
317 current.push(character);
318 started = true;
319 }
320 }
321 }
322
323 if quoted {
324 return None;
325 }
326 if started {
327 words.push(current);
328 }
329 Some(words)
330}
331
332/// Writes a configuration file back to disk, replacing it in one step.
333///
334/// This file carries the driver registration, so a half-written one leaves git
335/// with no filter and the next `git add` storing plaintext with exit code 0.
336///
337/// # Errors
338///
339/// [`Error::Io`] when the file cannot be written.
340pub fn save_local(path: &Path, config: &File) -> Result<()> {
341 crate::util::atomic::write(path, &config.to_bstring())
342}
343
344/// Sets a dotted key such as `filter.git-xcrypt.required`, creating what is missing.
345///
346/// # Errors
347///
348/// [`Error::Config`] when the key cannot be set.
349pub fn set(config: &mut File, key: &str, value: &str) -> Result<()> {
350 config
351 .set_raw_value(key, value)
352 .map(|_| ())
353 .map_err(|err| Error::Config(format!("could not set {key}: {err}")))
354}
355
356/// Removes a dotted key, if it is there at all.
357///
358/// # Errors
359///
360/// [`Error::Config`] when the key names a section that cannot be addressed.
361pub fn unset(config: &mut File, key: &str) -> Result<()> {
362 let (section_key, name) = key
363 .rsplit_once('.')
364 .ok_or_else(|| Error::Config(format!("`{key}` is not a dotted configuration key")))?;
365
366 if let Ok(mut section) = config.section_mut_by_key(section_key) {
367 while section.remove(name).is_some() {}
368 }
369 Ok(())
370}
371
372/// Reads a dotted key, if present.
373///
374/// A key written with no value at all — `[core]\n\tautocrlf` — is `true` to git,
375/// but has no raw value to return, so it comes back as `Some("true")`: git's own
376/// reading of that line, spelled the way every caller already tests for.
377///
378/// **`Some("true")` rather than `Some("")`, and the difference is a security
379/// one.** `gix-config` reports `key` (no `=`) and `key =` (an empty value)
380/// identically — the first as `Err(KeyMissing)` from `raw_value`, the second as
381/// `Ok("")` — and git does not: measured on git 2.55, `git config --type=bool`
382/// reads the first as `true` and the second as **`false`**. Flattening both to
383/// the empty string and calling that true made `filter.git-xcrypt.required = `
384/// read as enabled, while git ignored the failing filter and stored the
385/// plaintext with `git add` exiting 0 — and `status`, the gate that exists to
386/// catch exactly that, reported no gap.
387///
388/// **A key `gix-config` will not parse gives `None` rather than a panic.** Its
389/// `raw_value` takes the key through `AsKey::as_key`, which panics on anything
390/// it cannot split — `notdotted` is enough. No caller passes such a key today,
391/// and the `-c` reader filters them out before they get here, so this is not a
392/// fix for a live bug; it is a fix for the *shape* of one. `get` is on the
393/// filter path, and with `required = true` a panic there does not fail one
394/// command, it aborts every git operation in the repository until someone
395/// unregisters the driver by hand. A lookup that cannot name a section has no
396/// answer, and saying so is the same thing this function already does two lines
397/// further down for the same key.
398#[must_use]
399pub fn get(config: &File, key: &str) -> Option<String> {
400 // `gix-config`'s own rule, not a second spelling of it: a guard that drifted
401 // narrower than the one that panics would leave the panic reachable.
402 gix_config::AsKey::try_as_key(&key)?;
403
404 if let Ok(value) = config.raw_value(key) {
405 // An explicit value, the empty string included. Git reads `key =` as
406 // false, so it must not be turned into a spelling of true below.
407 return Some(value.to_string());
408 }
409
410 let (section_key, name) = key.rsplit_once('.')?;
411 let (section, subsection) = match section_key.split_once('.') {
412 Some((section, subsection)) => (section, Some(subsection.as_bytes().as_bstr())),
413 None => (section_key, None),
414 };
415
416 let present = config
417 .sections_by_name(section)?
418 .filter(|section| section.header().subsection_name() == subsection)
419 .any(|section| section.value_names().any(|value_name| value_name == name));
420
421 present.then(|| "true".to_string())
422}
423
424/// Whether a value is one of git's spellings of true.
425///
426/// Git accepts `1`, `yes` and `on` beside `true`, case insensitively. Every
427/// caller that branches on a git boolean has to accept the same set, or a
428/// perfectly ordinary `required = 1` reads as "off".
429///
430/// The empty string is **not** in the set. Git reads `key =` as `false`
431/// (measured with `git config --type=bool` on 2.55), and the value-less
432/// `key` that git does read as true never arrives here as empty — [`get`]
433/// returns it as `"true"`.
434#[must_use]
435pub fn is_true(value: &str) -> bool {
436 matches!(
437 value.to_ascii_lowercase().as_str(),
438 "true" | "yes" | "on" | "1"
439 )
440}
441
442#[cfg(test)]
443mod tests {
444 use std::ffi::OsStr;
445
446 use super::*;
447 use tempfile::TempDir;
448
449 /// The five shapes of `core.attributesFile`, as measured on git 2.55.
450 ///
451 /// The scenario in `tests/attributes.rs` proves the two that cost a file;
452 /// this proves the whole table, including the two that must resolve to
453 /// **nothing**. Over-eager resolution is its own failure mode: with
454 /// `required = true`, a global file we invent and the user does not have
455 /// would refuse operations in every repository on the machine.
456 #[test]
457 fn the_global_attributes_file_resolves_where_git_resolves_it() {
458 let home = Path::new("/home/user");
459 let resolve = |contents: &str, xdg: Option<&str>| {
460 let file = File::try_from(contents).expect("the fixture is valid configuration");
461 global_attributes_file_for(&file, Some(home), xdg.map(std::ffi::OsStr::new))
462 };
463
464 assert_eq!(
465 resolve("[core]\n", None),
466 Some(home.join(".config").join("git").join("attributes")),
467 "unset must fall back to the XDG default, which is where git looks"
468 );
469 assert_eq!(
470 resolve("[core]\n", Some("/xdg")),
471 Some(Path::new("/xdg").join("git").join("attributes")),
472 "XDG_CONFIG_HOME must win over the $HOME/.config default"
473 );
474 assert_eq!(
475 resolve("[core]\n", Some("")),
476 Some(home.join(".config").join("git").join("attributes")),
477 "git treats an empty XDG_CONFIG_HOME as unset, so this must too"
478 );
479 assert_eq!(
480 resolve("[core]\n\tattributesFile = ~/attrs\n", None),
481 Some(home.join("attrs")),
482 "`~/` must be expanded, exactly as git expands `core.excludesFile`"
483 );
484 assert_eq!(
485 resolve(
486 "[core]\n\tattributesFile = /elsewhere/attrs\n",
487 Some("/xdg")
488 ),
489 Some(PathBuf::from("/elsewhere/attrs")),
490 "an absolute path must be taken as written, XDG or no XDG"
491 );
492 assert_eq!(
493 resolve("[core]\n\tattributesFile = \n", Some("/xdg")),
494 None,
495 "an empty value turns the file off — and does **not** fall back to XDG"
496 );
497 }
498
499 #[test]
500 fn an_empty_value_is_false_to_git_and_must_be_false_here() {
501 // Measured on git 2.55: `git config --type=bool` reads `key` (no `=`) as
502 // `true` and `key = ` as `false`. `gix-config` reports the two
503 // identically once they are flattened to a string, so this is the one
504 // place the difference can be kept. Getting it wrong let
505 // `filter.git-xcrypt.required = ` read as enabled while git ignored the
506 // failing filter and stored the plaintext, and `status` saw no gap.
507 let dir = TempDir::new().expect("temporary directory");
508 let path = dir.path().join("config");
509 std::fs::write(
510 &path,
511 "[filter \"git-xcrypt\"]\n\trequired = \n[core]\n\tautocrlf = \n",
512 )
513 .expect("writing must succeed");
514
515 let config = open_local(&path).expect("valid config");
516 for key in ["filter.git-xcrypt.required", "core.autocrlf"] {
517 let value = get(&config, key).unwrap_or_else(|| panic!("{key} must read as present"));
518 assert!(
519 !is_true(&value),
520 "{key} = `{value}` was taken for true, which git does not"
521 );
522 }
523 }
524
525 /// `git -c` overrides, in the shape git 2.55 actually hands them over.
526 ///
527 /// The line below is not invented: it is what
528 /// `git -c alias.showenv='!printenv GIT_CONFIG_PARAMETERS' -c core.autocrlf=true
529 /// -c core.eol=lf -c "user.name=a b'c" showenv` printed on 2.55, copied
530 /// verbatim. Three constructs have to survive it — key and value quoted
531 /// separately, a `!` escaped outside the quotes for csh, and a value holding
532 /// both a space and a quote.
533 #[test]
534 fn overrides_from_the_command_line_are_read_the_way_git_writes_them() {
535 let measured = r"'alias.showenv'=''\!'printenv GIT_CONFIG_PARAMETERS' 'core.autocrlf'='true' 'core.eol'='lf' 'user.name'='a b'\''c'";
536 let config = cli_overrides(Some(OsStr::new(measured))).expect("the line names four keys");
537
538 assert_eq!(get(&config, "core.autocrlf").as_deref(), Some("true"));
539 assert_eq!(get(&config, "core.eol").as_deref(), Some("lf"));
540 assert_eq!(get(&config, "user.name").as_deref(), Some("a b'c"));
541 assert_eq!(
542 get(&config, "alias.showenv").as_deref(),
543 Some("!printenv GIT_CONFIG_PARAMETERS"),
544 "a value may hold spaces and an escaped bang; splitting on either \
545 would invent a key nobody typed"
546 );
547
548 // Older git quoted the whole pair instead. Same words, same answer.
549 let old = cli_overrides(Some(OsStr::new("'core.autocrlf=input'"))).expect("one key");
550 assert_eq!(get(&old, "core.autocrlf").as_deref(), Some("input"));
551
552 // `git -c core.autocrlf` with no `=` is git's boolean shorthand, and it
553 // has to arrive spelled the way a value-less line in a file arrives.
554 let bare = cli_overrides(Some(OsStr::new("'core.autocrlf'"))).expect("one key");
555 assert_eq!(get(&bare, "core.autocrlf").as_deref(), Some("true"));
556
557 // An explicitly empty value is false to git, and must not be promoted.
558 let empty = cli_overrides(Some(OsStr::new("'core.autocrlf'=''"))).expect("one key");
559 let value = get(&empty, "core.autocrlf").expect("present");
560 assert!(!is_true(&value), "`-c core.autocrlf=` is false to git");
561 }
562
563 /// Everything this parser cannot read has to leave the files in charge.
564 ///
565 /// This is the half that decides whether the feature is safe to have at all.
566 /// It runs on the clean path, where `required = true` turns a wrong answer
567 /// into a repository that refuses every git operation — so a value nobody
568 /// typed is strictly worse than a value we failed to notice. Each row below
569 /// is a shape that must produce *no* override rather than a guessed one.
570 #[test]
571 fn anything_unreadable_leaves_the_configuration_files_to_speak() {
572 assert!(cli_overrides(None).is_none(), "unset means no overrides");
573 assert!(cli_overrides(Some(OsStr::new(""))).is_none());
574 assert!(cli_overrides(Some(OsStr::new(" "))).is_none());
575 assert!(
576 cli_overrides(Some(OsStr::new("'core.autocrlf'='true"))).is_none(),
577 "an unterminated quote drops the whole variable: half a word could \
578 name a key nobody asked for"
579 );
580 assert!(
581 cli_overrides(Some(OsStr::new("notdotted=1"))).is_none(),
582 "a word that is not a dotted key is skipped, not guessed at"
583 );
584
585 // A skipped word must not take its neighbours with it.
586 let mixed =
587 cli_overrides(Some(OsStr::new("notdotted=1 'core.eol'='crlf'"))).expect("one key");
588 assert_eq!(get(&mixed, "core.eol").as_deref(), Some("crlf"));
589 // Asked of the rendered file rather than through `get`, which asserts on
590 // a key with no dot in it — the very shape being skipped here.
591 assert!(
592 !mixed.to_bstring().to_str_lossy().contains("notdotted"),
593 "a word that names no section must not reach the configuration"
594 );
595 }
596
597 /// A key `gix-config` will not parse must answer `None`, not abort the process.
598 ///
599 /// `raw_value` panics on one — `'notdotted' is not a valid configuration key`
600 /// — and this function is on the filter path, where with `required = true` a
601 /// panic does not fail one command: it aborts every git operation in the
602 /// repository until the driver is unregistered by hand. No caller passes such
603 /// a key today, which is exactly why this is worth pinning; nothing else
604 /// would notice if one started to.
605 #[test]
606 fn a_key_that_names_no_section_has_no_value_and_does_not_panic() {
607 let config = File::try_from("[core]\n\tautocrlf = true\n")
608 .expect("the fixture is valid configuration");
609
610 for key in ["notdotted", "", "."] {
611 assert_eq!(
612 get(&config, key),
613 None,
614 "`{key}` cannot name a value, and answering that must not cost a panic"
615 );
616 }
617
618 // The rule is borrowed from `gix-config` rather than restated, so the
619 // ordinary key beside it still has to work.
620 assert_eq!(get(&config, "core.autocrlf").as_deref(), Some("true"));
621 }
622}