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