Skip to main content

git_policy_cli/
event_hints.rs

1//! What:
2//!  The hint that follows a finding, an engine failure or a warn-unsafe notice: a guess at what the person was
3//!  trying to do, and the concrete next step.
4//! Why:
5//!  A block that only states a rule leaves the next move to guesswork. Every block and every divergence from real
6//!  Git's behavior names the likely intent and a way forward, so a rejected command is a step toward the goal.
7//!  Messages built with their own hint (the discard, branch, add, root and commit-only rejections) are listed as
8//!  self-hinted, and a guard test fails when a code has neither.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const hint = hints.get(`${policy}/${code}`); if (hint) event.hint = hint;
13//! ```
14
15use super::json_record::quote;
16
17/// The policy codes whose messages already carry their own intent guess and next step.
18pub const SELF_HINTED: &[(&str, &str)] = &[
19    ("require-root", "not-at-root"),
20    ("linked-worktree-only", "linked-worktree-required"),
21    ("branch-worktree-only", "branch-creation-requires-worktree"),
22];
23
24/// What:
25///  The hint for a policy finding, or none when the message carries its own.
26///  `&'static str` is text compiled into the program.
27/// Why:
28///   A finding's wording is the rule; the hint is the guess about the goal and the way to reach it.
29///
30/// In TS you'd write (pseudocode):
31/// ```ts
32/// function findingHint(policy: string, code: string, fixAvailable: boolean): string | undefined;
33/// ```
34pub fn finding_hint(policy: &str, code: &str, fix_available: bool) -> Option<&'static str> {
35    match (policy, code, fix_available) {
36        ("add-explicit", "bulk-add-rejected", _) => {
37            return Some(
38                "Hint: if you meant to stage everything you changed, run `git status --short` to list it and name \
39                 each path, as in `git add -- <path> <path>`; if you meant only files git already tracks, name those \
40                 paths the same way.",
41            );
42        }
43        ("final-newline", "noncanonical-final-newline", true) => {
44            return Some(
45                "Hint: if you are committing or checking this file, a commit corrects the ending for you; \
46                 to correct it now without committing, run `git cli-git fix -- <path>`.",
47            );
48        }
49        ("final-newline", "noncanonical-final-newline", false) => {
50            return Some(
51                "Hint: if you are committing or checking this file, edit it so it ends with exactly one newline, \
52                 then stage it again.",
53            );
54        }
55        ("markdown/autofix", "markdown-autofix", _) => {
56            return Some(
57                "Hint: if you are committing this Markdown, the commit applies the linter's corrections itself; \
58                 to apply them now, run `git cli-git fix -- <path>`.",
59            );
60        }
61        ("markdown/autofix", "markdown-violation", _) => {
62            return Some(
63                "Hint: if you are committing this Markdown, the linter cannot repair this rule by itself; \
64                 edit the file at the reported line and column, stage it again, and retry.",
65            );
66        }
67        ("mono/forbidden-root-context", "root-context-forbidden", _) => {
68            return Some(
69                "Hint: if you meant to keep project notes or terms for later readers, put them in a file under doc/; \
70                 to drop CONTEXT.md from this commit and keep the file on disk, run `git rm --cached CONTEXT.md`.",
71            );
72        }
73        ("mono/dependent-version-bump", "dependent-version-stale", _) => {
74            return Some(
75                "Hint: if you changed a package that others depend on, raise its dependents' version ranges to match \
76                 the new version in the same commit, then stage those files; `git cli-git fix -- <path>` applies the \
77                 correction when one is available.",
78            );
79        }
80        ("mono/dependent-version-bump", "dependent-version-unsupported", _) => {
81            return Some(
82                "Hint: if you edited a dependency declaration of a shape this check does not read, update the \
83                 dependents by hand in the same commit; the message names the file.",
84            );
85        }
86        ("mono/catalog-specifiers", "disallowed-specifier", _) => {
87            return Some(
88                "Hint: if you are adding or changing a dependency, move its version into the catalog section of \
89                 pnpm-workspace.yaml and write catalog: here, or write workspace:* for a package of this workspace; \
90                 peerDependencies take * or catalog:.",
91            );
92        }
93        ("mono/catalog-specifiers", "non-string-specifier", _) => {
94            return Some(
95                "Hint: if you are writing a dependency version, make it a string such as catalog: or workspace:*; \
96                 a number, list, object or null is not a specifier.",
97            );
98        }
99        ("mono/catalog-specifiers", "malformed-section", _) => {
100            return Some(
101                "Hint: if you are editing a dependency section, write it as an object that maps package names to \
102                 specifiers; the message names the section.",
103            );
104        }
105        ("mono/catalog-specifiers", "duplicate-key", _) => {
106            return Some(
107                "Hint: if you are editing this manifest by hand, keep one copy of the repeated section or \
108                 dependency name; pnpm keeps the last copy, which may not be the one you meant.",
109            );
110        }
111        ("mono/catalog-specifiers", "invalid-manifest", _) => {
112            return Some(
113                "Hint: if you are committing this package.json, make it a JSON object that parses and is UTF-8 \
114                 text (a stray character, an empty file or a link instead of a file shows here), then stage it again.",
115            );
116        }
117        ("mono/catalog-specifiers", "unsupported-manifest-format", _) => {
118            return Some(
119                "Hint: if you are adding a package.json5, package.yaml or package.yml, use package.json instead, \
120                 because the catalog rule reads only that format; rename or convert the file and stage it again.",
121            );
122        }
123        ("security/forbidden-strings", "forbidden-string", _) => {
124            return Some(
125                "Hint: if you are committing text or a path that contains a secret or a banned term, remove or \
126                 replace it where the message points and stage the file again; if you believe the rule is wrong, \
127                 review it in the rules file named by the configuration instead of splitting the commit.",
128            );
129        }
130        _ => return None,
131    }
132}
133
134/// What:
135///  The hint for an engine failure, by the code's wire spelling.
136/// Why:
137///   An engine failure stops the command before Git runs; the person needs to know whether to retry, fix a file, or
138///  report a defect.
139///
140/// In TS you'd write (pseudocode):
141/// ```ts
142/// function engineFailureHint(code: string): string | undefined;
143/// ```
144pub fn engine_failure_hint(code: &str) -> Option<&'static str> {
145    match code {
146        "config-invalid" => {
147            return Some(
148                "Hint: if you just edited cli-git.config.jsonc, fix the key the message names or revert the edit; \
149                 an unknown key, a wrong type or an unknown policy id fails before any policy runs.",
150            );
151        }
152        "core-incomplete" => {
153            return Some(
154                "Hint: if another session was changing the repository at the same moment (a commit, a checkout or a \
155                 rebase running elsewhere), run the command again; if it repeats, run `git status` to check that the \
156                 repository is healthy.",
157            );
158        }
159        "content-unavailable" => {
160            return Some(
161                "Hint: if you are committing or pushing content that is not in this clone (a missing object, a \
162                 shallow history, or a file removed during the command), fetch the history with `git fetch`, make \
163                 sure the files exist, and retry.",
164            );
165        }
166        "policy-incomplete" => {
167            return Some(
168                "Hint: if you are committing or pushing, a policy could not finish reading the content, so it \
169                 decided nothing; fix the cause the message names (an unreadable file, a path with a line break) \
170                 and retry.",
171            );
172        }
173        "patch-invalid" | "patch-conflict" => {
174            return Some(
175                "Hint: if you are committing files that changed while the command ran or that another policy also \
176                 rewrites, save your edits, run `git cli-git fix -- <path>` to apply the corrections, review the \
177                 result with `git diff`, and commit again.",
178            );
179        }
180        "fix-cycle" | "fix-pass-limit" => {
181            return Some(
182                "Hint: two corrections keep undoing each other on this file; apply one of them by hand, stage the \
183                 file, and commit again. Report the pair of policies if the file is ordinary text.",
184            );
185        }
186        "transaction-failed" => {
187            return Some(
188                "Hint: if you were committing, run `git status` and `git log --oneline -3` to see whether the \
189                 commit landed before you retry; staged files stay staged unless the message says otherwise, and the \
190                 message names the step that failed.",
191            );
192        }
193        "index-lock-unproven-owner" => {
194            return Some(
195                "Hint: if no other Git command is running in this repository, a crashed command left the lock \
196                 behind: check with `ps` that no Git process uses this repository, then remove the .git/index.lock \
197                 file the message names and retry.",
198            );
199        }
200        "legacy-config-ignored" => {
201            return Some(
202                "Hint: if you are still keeping the old configuration files, delete them once \
203                 cli-git.config.jsonc holds the settings; they are no longer read.",
204            );
205        }
206        "traditional-hooks-unsupported" => {
207            return Some(
208                "Hint: scripts in the hooks directory never run through cli-git. If one still checks something for you, \
209                 express it as a policy under policies in cli-git.config.jsonc; `git cli-git doctor` lists the \
210                 scripts it finds and exits 1 while any remain.",
211            );
212        }
213        _ => return None,
214    }
215}
216
217/// What:
218///  The hint for a warn-unsafe notice.
219/// Why:
220///   The person who set a policy to warn wanted commits to keep flowing; the notice says what that costs and what to
221///  choose instead.
222///
223/// In TS you'd write (pseudocode):
224/// ```ts
225/// const warnUnsafeHint: string;
226/// ```
227pub const WARN_UNSAFE_HINT: &str = "Hint: if you set it to warn to keep commits flowing, note that this policy \
228     changes or misses content when it only warns; set its severity to error to enforce it, or to off to disable it, \
229     in cli-git.config.jsonc.";
230
231/// The hint that follows a post-commit gate block.
232pub const POST_COMMIT_BLOCKED_HINT: &str = "Hint: if you wanted this commit backed up, fix what the gate reported \
233     and run `git push` yourself; the commit stays in your local history until then.";
234
235/// The hint that follows a replay that dropped signature headers.
236pub const REPLAY_HEADERS_DROPPED_HINT: &str = "Hint: if this commit must carry your original signature, run \
237     `git commit --amend --no-edit -S` after the replay to sign the replayed commit again.";
238
239/// What:
240///  Append the `hint` field to a JSON event line when a hint exists.
241///  `&mut String` lends the line for appending.
242/// Why:
243///   The `message` field stays byte-identical to the retired wrapper's; the hint is an additive field that readers of
244///  the line see without any change to the message.
245///
246/// In TS you'd write (pseudocode):
247/// ```ts
248/// if (hint !== undefined) event.hint = hint;
249/// ```
250pub fn push_hint_field(line: &mut String, hint: Option<&str>) {
251    if let Some(text) = hint {
252        line.push_str(",\"hint\":");
253        line.push_str(quote(text).as_str());
254    }
255}
256
257/// The guard that every code has a hint or its own.
258#[cfg(test)]
259#[path = "event_hints_tests.rs"]
260mod tests;