Skip to main content

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;