git_policy_cli/maintenance_hk_config.rs
1//! What:
2//! Exact removal of retired hk hook registrations from one explicit Git configuration scope:
3//! list every scoped key with `git config --list --show-scope`, keep the sorted unique keys
4//! of that scope that start with `hook.hk-`, and unset each with `--unset-all`.
5//! Why:
6//! The retired hk installer wrote its hooks into Git configuration;
7//! this repository's migration removes only that exact namespace and leaves every other key alone.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! await cleanupHkGitConfig({ gitPath, scope: 'local', cwd });
12//! ```
13
14/// `Display` gives the failure text the caller prints.
15use std::fmt;
16/// `Path` is a borrowed filesystem path.
17use std::path::Path;
18/// Real Git's captured output and the context that starts it.
19use super::transaction_git::{GitContext, GitOutput, GitRequest, run_git};
20
21/// The exact key prefix the retired hk installer owns.
22const HK_HOOK_PREFIX: &str = "hook.hk-";
23
24/// What:
25/// The Git configuration scope eligible for cleanup.
26/// Why:
27/// Only the explicitly chosen scope is read and written; the other scope is never touched.
28///
29/// In TS you'd write (pseudocode):
30/// ```ts
31/// type HkConfigScope = 'global' | 'local';
32/// ```
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
34pub enum HkConfigScope {
35 /// The user's global configuration file.
36 Global,
37 /// The repository's configuration file.
38 Local,
39}
40
41/// Methods over the scope.
42impl HkConfigScope {
43 /// What:
44 /// The name Git prints for this scope in `--show-scope` output.
45 /// Why:
46 /// The listing is matched against that name.
47 ///
48 /// In TS you'd write (pseudocode):
49 /// ```ts
50 /// scope // 'global' or 'local'
51 /// ```
52 fn listing_name(self) -> &'static str {
53 match self {
54 Self::Global => return "global",
55 Self::Local => return "local",
56 }
57 }
58
59 /// What:
60 /// The `git config` flag that selects this scope when writing.
61 /// Why:
62 /// Writes must name the same scope the listing read.
63 ///
64 /// In TS you'd write (pseudocode):
65 /// ```ts
66 /// `--${scope}`
67 /// ```
68 fn flag(self) -> &'static str {
69 match self {
70 Self::Global => return "--global",
71 Self::Local => return "--local",
72 }
73 }
74}
75
76/// What:
77/// Why the cleanup could not finish.
78/// Why:
79/// Each variant names the step that failed, so the caller can report it without re-deriving the cause.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// class HkConfigCleanupError extends Error {}
84/// ```
85#[derive(Clone, Debug, Eq, PartialEq)]
86pub enum HkConfigCleanupError {
87 /// Real Git could not be started; the text names the command and the operating-system reason.
88 Start(String),
89 /// The listing ended a scope without a key, so its fields cannot be paired.
90 MalformedListing,
91 /// The listing exited with a status other than zero.
92 ListingFailed(i32),
93 /// Removing one key exited with a status other than zero.
94 Failed {
95 /// The key whose removal failed.
96 key: String,
97 /// Git's exit status; a signal counts as `1`.
98 code: i32,
99 },
100}
101
102/// Display text for a failed cleanup.
103impl fmt::Display for HkConfigCleanupError {
104 /// What:
105 /// Human-readable text for the variant.
106 /// Why:
107 /// The caller prints the error directly.
108 ///
109 /// In TS you'd write (pseudocode):
110 /// ```ts
111 /// toString(): string
112 /// ```
113 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
114 match self {
115 Self::Start(reason) => return formatter.write_str(reason.as_str()),
116 Self::MalformedListing => {
117 return formatter.write_str("git config --show-scope listing has a scope without a key");
118 }
119 Self::ListingFailed(code) => {
120 return write!(formatter, "git config --list --show-scope exited {code}");
121 }
122 Self::Failed { key, code } => {
123 return write!(formatter, "git config --unset-all {key} exited {code}");
124 }
125 }
126 }
127}
128
129/// The error trait lets callers box this error.
130impl std::error::Error for HkConfigCleanupError {}
131
132/// What:
133/// The keys removed from one scope, in sorted order.
134/// Why:
135/// An empty list proves that a repeated cleanup changed nothing.
136///
137/// In TS you'd write (pseudocode):
138/// ```ts
139/// type HkConfigCleanupResult = { scope: HkConfigScope; removedKeys: string[] };
140/// ```
141#[derive(Clone, Debug, Eq, PartialEq)]
142pub struct HkConfigCleanupResult {
143 /// The scope inspected.
144 pub scope: HkConfigScope,
145 /// Sorted unique hk-owned keys removed from that scope.
146 pub removed_keys: Vec<String>,
147}
148
149/// What:
150/// The hk-owned keys of one scope from `git config --null --show-scope --name-only --list` output.
151/// Why:
152/// Git prints alternating scope and key fields; a trailing empty field is the terminator and is dropped,
153/// while an empty field elsewhere stays a field.
154/// Keys sort by UTF-16 code units, as JavaScript's default sort does.
155///
156/// In TS you'd write (pseudocode):
157/// ```ts
158/// function hkKeysInScope(listing: string, scope: string): string[];
159/// ```
160pub fn hk_keys_in_scope(
161 listing: &[u8],
162 scope: HkConfigScope,
163) -> Result<Vec<String>, HkConfigCleanupError> {
164 let text: std::borrow::Cow<'_, str> = String::from_utf8_lossy(listing);
165 // `mut` lets the terminator be dropped.
166 let mut fields: Vec<&str> = text.split('\0').collect();
167 if fields.last() == Some(&"") {
168 fields.pop();
169 }
170 if fields.len() % 2 != 0 {
171 return Err(HkConfigCleanupError::MalformedListing);
172 }
173 // `mut` lets the keys be sorted and deduplicated in place.
174 let mut keys: Vec<String> = Vec::new();
175 for pair in fields.chunks(2) {
176 match pair {
177 [scope_field, key] if *scope_field == scope.listing_name() && key.starts_with(HK_HOOK_PREFIX) => {
178 keys.push(String::from(*key));
179 }
180 _ => {}
181 }
182 }
183 keys.sort_by(|left, right| return left.encode_utf16().cmp(right.encode_utf16()));
184 keys.dedup();
185 return Ok(keys);
186}
187
188/// What:
189/// Remove every hk-owned key from one scope, leaving every other key in place.
190/// Why:
191/// The migration runs once per machine or repository; a repeated run finds nothing and changes nothing.
192///
193/// In TS you'd write (pseudocode):
194/// ```ts
195/// await cleanupHkGitConfig({ gitPath, scope, cwd });
196/// ```
197pub fn cleanup_hk_git_config(
198 context: &GitContext,
199 scope: HkConfigScope,
200 cwd: &Path,
201) -> Result<HkConfigCleanupResult, HkConfigCleanupError> {
202 let listing: GitOutput = run_git(
203 context,
204 &GitRequest::new(cwd, &["config", "--null", "--show-scope", "--name-only", "--list"]),
205 )
206 .map_err(|error| return HkConfigCleanupError::Start(format!("git config --list could not start: {error}")))?;
207 let listing_code: i32 = listing.code.unwrap_or(1);
208 if listing_code != 0 {
209 return Err(HkConfigCleanupError::ListingFailed(listing_code));
210 }
211 let keys: Vec<String> = hk_keys_in_scope(listing.stdout.as_slice(), scope)?;
212 for key in &keys {
213 let removal: GitOutput = run_git(
214 context,
215 &GitRequest::new(cwd, &["config", scope.flag(), "--unset-all", key.as_str()]),
216 )
217 .map_err(|error| return HkConfigCleanupError::Start(format!("git config --unset-all could not start: {error}")))?;
218 let removal_code: i32 = removal.code.unwrap_or(1);
219 if removal_code != 0 {
220 return Err(HkConfigCleanupError::Failed {
221 key: key.clone(),
222 code: removal_code,
223 });
224 }
225 }
226 return Ok(HkConfigCleanupResult {
227 scope,
228 removed_keys: keys,
229 });
230}
231
232/// Cleanup controls live beside the cleanup.
233#[cfg(test)]
234#[path = "maintenance_hk_config_tests.rs"]
235mod tests;