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;