git_policy_cli/shadow/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 crate::hooks_off::HOOK_EVENTS;
21/// The one configuration read the commit shares.
22use crate::config_snapshot::ConfigSnapshot;
23/// One required Git start, and its failure text.
24use crate::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;