Skip to main content

git_policy_cli/
doctor_config.rs

1//! What:
2//!  What `git cli-git doctor` reports from the configuration: the external hooks Git configuration defines, and the
3//!  retired `hooks.concurrentCommits`, `indexLock.unprovenOwnerTimeoutMs` and `landing.reserveAfterLostRaces` keys.
4//! Why:
5//!  The wrapper runs no external hook, so a configured hook is a finding the author must act on, and each retired key in
6//!  `cli-git.config.jsonc` is accepted for older files but means nothing; doctor says both plainly.
7//!  The commit path prints the first as a warning from the snapshot it already holds; doctor lists it on request.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const findings = await configFindings({ context, cwd }); // { hooks: [...], retiredHooksKey, retiredIndexLockKey, retiredLandingKey }
12//! ```
13
14use super::config_file::load_repository_config;
15use super::config_schema::{CliGitConfig, ConcurrencyConfig};
16use super::discarded_prune::Limits;
17use super::config_snapshot::read_config_snapshot;
18use super::external_hooks::{ConfiguredHook, configured_hooks, describe_hook, explain_external_hooks};
19use super::transaction_git::{GitContext, GitRequest, run_git};
20use std::path::{Path, PathBuf};
21
22/// What:
23///  What the configuration contributes to the doctor report.
24/// Why:
25///   Standard output lists the hooks one per line, and standard error carries the explanation and the notes.
26///
27/// In TS you'd write (pseudocode):
28/// ```ts
29/// type ConfigFindings = { hooks: ConfiguredHook[]; retiredHooksKey: boolean;
30///   retiredIndexLockKey: boolean; retiredLandingKey: boolean };
31/// ```
32#[derive(Clone, Debug, Eq, PartialEq)]
33pub struct ConfigFindings {
34    /// The configured external hooks, none of which runs.
35    pub hooks: Vec<ConfiguredHook>,
36    /// Whether `cli-git.config.jsonc` has the retired `hooks` key.
37    pub retired_hooks_key: bool,
38    /// Whether `cli-git.config.jsonc` sets the retired `indexLock.unprovenOwnerTimeoutMs`.
39    pub retired_index_lock_key: bool,
40    /// Whether `cli-git.config.jsonc` sets the retired `landing.reserveAfterLostRaces`.
41    pub retired_landing_key: bool,
42    /// The limits of the folder of saved copies in force: the file's `discardedCopies`, else the defaults.
43    pub discarded: Limits,
44}
45
46/// What:
47///  The worktree top level, when Git reports one.
48/// Why:
49///   The JSONC file sits at the top level; a bare repository has none and so no file.
50///
51/// In TS you'd write (pseudocode):
52/// ```ts
53/// await git(['rev-parse', '--show-toplevel']).catch(() => undefined)
54/// ```
55fn top_level(context: &GitContext, cwd: &Path) -> Option<PathBuf> {
56    let request: GitRequest = GitRequest::new(cwd, &["rev-parse", "--show-toplevel"]);
57    let output = run_git(context, &request).ok()?;
58    if !output.succeeded() {
59        return None;
60    }
61    let text: String = String::from_utf8(output.stdout).ok()?;
62    let trimmed: &str = text.trim_end_matches('\n');
63    if trimmed.is_empty() {
64        return None;
65    }
66    return Some(PathBuf::from(trimmed));
67}
68
69/// What:
70///  Read the configuration findings for `cwd`.
71/// Why:
72///   One snapshot read gives the hooks; one file read gives the retired key.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// configFindings({ context, cwd });
77/// ```
78pub fn config_findings(context: &GitContext, cwd: &Path) -> ConfigFindings {
79    let hooks: Vec<ConfiguredHook> = read_config_snapshot(context, cwd)
80        .map(|snapshot| { return configured_hooks(&snapshot); })
81        .unwrap_or_default();
82    // A configuration that cannot be loaded is not doctor's to report: the command works beside a broken file, and
83    // every other command names the problem.
84    let loaded: Option<CliGitConfig> = top_level(context, cwd).and_then(|root| { return load_repository_config(root.as_path()).ok(); }).map(|found| { return found.config; });
85    let concurrency: Option<ConcurrencyConfig> = loaded.as_ref().map(|found| { return found.concurrency; });
86    return ConfigFindings {
87        hooks,
88        retired_hooks_key: concurrency.is_some_and(|retired| { return retired.hooks.present; }),
89        retired_index_lock_key: concurrency.is_some_and(|retired| { return retired.index_lock.present; }),
90        retired_landing_key: concurrency.is_some_and(|retired| { return retired.landing.present; }),
91        discarded: loaded.map_or_else(Limits::default, |found| { return found.discarded; }),
92    };
93}
94
95/// What:
96///  The lines for standard output: one per configured hook.
97/// Why:
98///   The hooks sit beside the traditional hooks in the report a script can read.
99///
100/// In TS you'd write (pseudocode):
101/// ```ts
102/// findings.hooks.map(describeHook).join('\n')
103/// ```
104pub fn render_hook_lines(findings: &ConfigFindings) -> String {
105    let mut text: String = String::new();
106    for hook in &findings.hooks {
107        text.push_str(describe_hook(hook).as_str());
108        text.push('\n');
109    }
110    return text;
111}
112
113/// What:
114///  The explanation and notes for standard error.
115/// Why:
116///   The ways forward for hooks, and a note for each retired key that is accepted and ignored, with the remedy of
117///  removing it.
118///
119/// In TS you'd write (pseudocode):
120/// ```ts
121/// renderNotes(findings);
122/// ```
123pub fn render_notes(findings: &ConfigFindings) -> String {
124    let mut text: String = String::new();
125    if !findings.hooks.is_empty() {
126        text.push_str("git cli-git doctor: ");
127        text.push_str(explain_external_hooks(findings.hooks.as_slice()).as_str());
128        text.push('\n');
129    }
130    if findings.retired_hooks_key {
131        text.push_str(
132            "git cli-git doctor: cli-git.config.jsonc has a `hooks` key (`hooks.concurrentCommits`). It is accepted so \
133             older files still load, and ignored: the wrapper runs no external hook, so there is no hook lock to \
134             tune. Delete the key when convenient.\n",
135        );
136    }
137    if findings.retired_index_lock_key {
138        text.push_str(
139            "git cli-git doctor: cli-git.config.jsonc sets `indexLock.unprovenOwnerTimeoutMs`. The key does nothing: the \
140             commit service waits for another process's `index.lock` for as long as `CLI_GIT_LOCK_WAIT_SECONDS` allows \
141             and reads no timeout from this file. Remove the key from cli-git.config.jsonc.\n",
142        );
143    }
144    if findings.retired_landing_key {
145        text.push_str(
146            "git cli-git doctor: cli-git.config.jsonc sets `landing.reserveAfterLostRaces`. The key does nothing: the \
147             commit service lands commits in the order they arrive and no longer reserves a landing slot for a commit \
148             that lost races. Remove the key from cli-git.config.jsonc.\n",
149        );
150    }
151    return text;
152}
153
154/// Reading the findings is controlled in the test module.
155#[cfg(test)]
156#[path = "doctor_config_tests.rs"]
157mod tests;