Skip to main content

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