Skip to main content

git_policy_cli/
shadow_config.rs

1//! What:
2//!  The shadow repository's `config` file, written by cli-git itself,
3//!       and the repository format facts it copies from the real common config.
4//! Why:
5//!  Git does not apply `core.repositoryformatversion` and `extensions.*` from an included file,
6//!  so they are copied explicitly.
7//!  The shadow names no hooks directory (traditional hooks are unsupported, and every shadow command passes its own
8//!  `core.hooksPath`);
9//!  `core.worktree`, `gc.auto` and `maintenance.auto` follow the include,
10//!  so the real config cannot redirect the worktree or start maintenance in the shadow;
11//!  `hook.<event>.enabled = false` for every hook event follows it too, because the wrapper supports no external hook
12//!  (`src/shadow-repository/shadow-config.ts`).
13//!
14//! In TS you'd write (pseudocode):
15//! ```ts
16//! formatShadowConfig({ format, commonDir, worktreeRoot });
17//! ```
18
19/// Every hook event, switched off in the shadow.
20use super::hooks_off::HOOK_EVENTS;
21/// The one configuration read the commit shares.
22use super::config_snapshot::ConfigSnapshot;
23/// One required Git start, and its failure text.
24use super::transaction_git::{GitContext, GitFailure, GitRequest, run_git_checked};
25/// `Path` is a borrowed filesystem path.
26use std::path::Path;
27
28/// What:
29///  Repository format facts the shadow copies from the real common config.
30/// Why:
31///   A shadow with a different object format or ref backend would misread the real store.
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// type RepositoryFormat = { version: string; extensions: [string, string][] };
36/// ```
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub struct RepositoryFormat {
39    /// `core.repositoryformatversion`, `0` when unset.
40    pub version: String,
41    /// Every `extensions.*` key with its value, in file order, the prefix removed.
42    pub extensions: Vec<(String, String)>,
43}
44
45/// What:
46///  Quotes one value under Git's config syntax.
47/// Why:
48///   The value is always double-quoted,
49///  so `;`, `#` and surrounding whitespace stay literal;
50///  backslash, double quote, newline and tab use Git's escapes.
51///
52/// In TS you'd write (pseudocode):
53/// ```ts
54/// quoteGitConfigValue('/a "b"'); // '"/a \\"b\\""'
55/// ```
56pub fn quote_git_config_value(value: &str) -> String {
57    let mut body: String = String::with_capacity(value.len());
58    for character in value.chars() {
59        match character {
60            '\\' => body.push_str(r"\\"),
61            '"' => body.push_str(r#"\""#),
62            '\n' => body.push_str(r"\n"),
63            '\t' => body.push_str(r"\t"),
64            other => body.push(other),
65        }
66    }
67    return format!("\"{body}\"");
68}
69
70/// What:
71///  Reads the format facts from the real common config file alone.
72/// Why:
73///   `git config --file config --list` reads only that file,
74///  so an included or worktree config never leaks into the copied format.
75///
76/// In TS you'd write (pseudocode):
77/// ```ts
78/// await readRepositoryFormat({ gitPath, commonDir });
79/// ```
80pub fn read_repository_format(
81    context: &GitContext,
82    common_dir: &Path,
83) -> Result<RepositoryFormat, GitFailure> {
84    let mut request: GitRequest = GitRequest::new(
85        common_dir,
86        &["config", "--file", "config", "--null", "--list"],
87    );
88    request.without_prefix = true;
89    let output = run_git_checked(context, &request)?;
90    let mut version: Option<String> = None;
91    let mut extensions: Vec<(String, String)> = Vec::new();
92    for entry in output.stdout.split(|byte| { return *byte == 0; }).filter(|entry| { return !entry.is_empty(); }) {
93        // A key without a value line is a valueless boolean, which reads as `true`.
94        let (key, value): (&[u8], &[u8]) = match entry.iter().position(|byte| { return *byte == b'\n'; }) {
95            Some(newline) => (&entry[..newline], &entry[newline + 1..]),
96            None => (entry, &b"true"[..]),
97        };
98        note_format_entry(
99            String::from_utf8_lossy(key).as_ref(),
100            String::from_utf8_lossy(value).into_owned(),
101            &mut version,
102            &mut extensions,
103        );
104    }
105    return Ok(RepositoryFormat {
106        version: version.unwrap_or_else(|| { return String::from("0"); }),
107        extensions,
108    });
109}
110
111/// What:
112///  Record one config entry in the format facts when it is the version or an extension.
113/// Why:
114///   Both readers of the common config (Git's `--file` listing and the snapshot) keep the last version, and every
115///  extension in file order with the `extensions.` prefix removed.
116///
117/// In TS you'd write (pseudocode):
118/// ```ts
119/// if (key.toLowerCase() === 'core.repositoryformatversion') version = value;
120/// if (key.slice(0, 11).toLowerCase() === 'extensions.') extensions.push([key.slice(11), value]);
121/// ```
122fn note_format_entry(
123    key_text: &str,
124    value_text: String,
125    version: &mut Option<String>,
126    extensions: &mut Vec<(String, String)>,
127) {
128    if key_text.to_lowercase() == "core.repositoryformatversion" {
129        *version = Some(value_text.clone());
130    }
131    if key_text.is_char_boundary(11) && key_text[..11].to_lowercase() == "extensions." {
132        extensions.push((key_text[11..].to_string(), value_text));
133    }
134}
135
136/// What:
137///  The format facts from the configuration snapshot, when its local entries are exactly the common config file.
138/// Why:
139///   `git config --file config --list` reads that one file without includes, and the snapshot's `local` scope is
140///  that file plus whatever it includes; with no include key anywhere, and a read made without global options, the
141///  two lists are the same entries in the same order, so the facts are the same.
142///  Any include key makes the answer `None`, and the caller starts Git.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// snapshot.hasNoIncludes() ? formatFrom(snapshot.entries.filter((entry) => entry.scope === 'local')) : undefined;
147/// ```
148pub fn repository_format_from_snapshot(snapshot: &ConfigSnapshot) -> Option<RepositoryFormat> {
149    if !snapshot.without_global_options() {
150        return None;
151    }
152    let includes: bool = snapshot.entries().iter().any(|entry| {
153        return entry.key == "include.path" || (entry.key.starts_with("includeif.") && entry.key.ends_with(".path"));
154    });
155    if includes {
156        return None;
157    }
158    let mut version: Option<String> = None;
159    let mut extensions: Vec<(String, String)> = Vec::new();
160    for entry in snapshot.entries().iter().filter(|candidate| { return candidate.scope == "local"; }) {
161        // A key without a value is a valueless boolean, which reads as `true`.
162        let value_text: String = match &entry.value {
163            Some(bytes) => String::from_utf8_lossy(bytes).into_owned(),
164            None => String::from("true"),
165        };
166        note_format_entry(entry.key.as_str(), value_text, &mut version, &mut extensions);
167    }
168    return Some(RepositoryFormat {
169        version: version.unwrap_or_else(|| { return String::from("0"); }),
170        extensions,
171    });
172}
173
174/// What:
175///  Formats the shadow `config` file.
176/// Why:
177///   The line order is the incumbent's, minus its hooks path:
178///  format facts, the include, then the worktree, gc and maintenance overrides.
179///
180/// In TS you'd write (pseudocode):
181/// ```ts
182/// formatShadowConfig({ format, commonDir, worktreeRoot });
183/// ```
184pub fn format_shadow_config(format: &RepositoryFormat, common_dir: &Path, worktree_root: &Path) -> String {
185    let common: String = common_dir.display().to_string();
186    let mut lines: Vec<String> = vec![
187        String::from("[core]"),
188        format!("\trepositoryformatversion = {}", quote_git_config_value(format.version.as_str())),
189    ];
190    if !format.extensions.is_empty() {
191        lines.push(String::from("[extensions]"));
192        for (name, value) in &format.extensions {
193            lines.push(format!("\t{name} = {}", quote_git_config_value(value.as_str())));
194        }
195    }
196    lines.push(String::from("[include]"));
197    lines.push(format!(
198        "\tpath = {}",
199        quote_git_config_value(format!("{common}/config").as_str())
200    ));
201    lines.push(String::from("[core]"));
202    lines.push(format!(
203        "\tworktree = {}",
204        quote_git_config_value(worktree_root.display().to_string().as_str())
205    ));
206    lines.push(String::from("[gc]"));
207    lines.push(String::from("\tauto = 0"));
208    lines.push(String::from("[maintenance]"));
209    lines.push(String::from("\tauto = false"));
210    // After the include, so it beats the real config: no hook of any event runs in the shadow, whatever the real
211    // config defines (the wrapper supports no external hook).
212    for event in HOOK_EVENTS {
213        lines.push(format!("[hook \"{event}\"]"));
214        lines.push(String::from("\tenabled = false"));
215    }
216    lines.push(String::new());
217    return lines.join("\n");
218}
219
220/// Shadow config controls stay out of the release executable.
221#[cfg(test)]
222#[path = "shadow_config_tests.rs"]
223mod tests;