git_policy_cli/rule_commit_only.rs
1//! What:
2//! The pure decision of the commit-only transform:
3//! insert `-o` after `git commit`,
4//! or
5//! say why not.
6//! Why:
7//! Every commit must name the paths it includes.
8//! Two decisions need a repository fact
9//! (sequencer state,
10//! index against `HEAD`);
11//! this module asks for the fact by returning
12//! a `Needs...` variant and the caller answers through a `resolve_...` function,
13//! so no
14//! process or file is touched here.
15//!
16//! In TS you'd write (pseudocode):
17//! ```ts
18//! // const rule = makeCommitOnly({ checkIndexDiffersFromHead, checkSequencerInProgress });
19//! ```
20
21/// What:
22/// Bring the commit facts,
23/// the global-option boundary,
24/// the tokenizer helpers and the
25/// repository-fact types into this file.
26/// Why:
27/// The decision combines them;
28/// it owns no parsing of its own.
29///
30/// In TS you'd write (pseudocode):
31/// ```ts
32/// import { parseCommitRegion } from '../parser/commit.ts';
33/// import { parseGlobalOptions } from '../parse-global-options.ts';
34/// ```
35use super::command_commit::{CommitRegion, FixupKind, parse_commit_region};
36use super::command_options::OptionError;
37use super::command_options_query::without_tokens;
38use super::global_arguments::{GlobalLayout, GlobalOutcome, global_layout};
39use super::rule_argument_rewrite::insert_tokens;
40use super::rule_commit_index::IndexVsHead;
41use super::rule_commit_only_message::{
42 ALL_FLAG_MESSAGE, NO_PATHSPEC_MESSAGE, ignored_index_message,
43};
44use super::rule_commit_sequencer::SequencerState;
45/// `OsString` is owned operating-system text of raw bytes (sibling `String` must be UTF-8).
46use std::ffi::OsString;
47
48/// What:
49/// Stable rejection codes.
50/// An `enum` is a closed set of named alternatives.
51/// Why:
52/// Events carry `commit-only/<code>`;
53/// consumers match the code,
54/// not the message.
55///
56/// In TS you'd write (pseudocode):
57/// ```ts
58/// type CommitOnlyViolationCode = 'all-flag' | 'pathspec-required' | 'staged-changes-ignored';
59/// ```
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum CommitOnlyViolationCode {
62 /// `-a`/`--all` stages every tracked modification implicitly.
63 AllFlag,
64 /// No pathspec source and no mode that permits a pathless only-commit.
65 PathspecRequired,
66 /// A pathless `--amend`/`--allow-empty` commit would ignore a dirty index.
67 StagedChangesIgnored,
68}
69
70/// What:
71/// The kebab-case spelling of a code.
72/// `&'static str` is text baked into the program.
73/// Why:
74/// The event format predates this module and its spellings must not drift.
75///
76/// In TS you'd write (pseudocode):
77/// ```ts
78/// const text = code; // the union member already is the string
79/// ```
80pub fn violation_code_text(code: CommitOnlyViolationCode) -> &'static str {
81 // `match` picks one arm per variant; the compiler refuses a forgotten variant.
82 return match code {
83 CommitOnlyViolationCode::AllFlag => "all-flag",
84 CommitOnlyViolationCode::PathspecRequired => "pathspec-required",
85 CommitOnlyViolationCode::StagedChangesIgnored => "staged-changes-ignored",
86 };
87}
88
89/// What:
90/// One expected rejection.
91/// `String` is owned UTF-8 text (sibling `&str` borrows).
92/// Why:
93/// The message is built per invocation,
94/// so the violation owns it.
95///
96/// In TS you'd write (pseudocode):
97/// ```ts
98/// class CommitOnlyViolationError extends Error { readonly code: CommitOnlyViolationCode }
99/// ```
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub struct CommitOnlyViolation {
102 /// Stable code.
103 pub code: CommitOnlyViolationCode,
104 /// Complete user-facing explanation naming every way forward.
105 pub message: String,
106}
107
108/// What:
109/// What `resolve_index_state` needs to finish the decision.
110/// Why:
111/// The first call already located `commit` and the pathless-allowed flags.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// type PendingInjection = { commandIndex: number; flagText: string };
116/// ```
117#[derive(Clone, Debug, Eq, PartialEq)]
118pub struct PendingInjection {
119 /// Index of the `commit` token in the full argument list.
120 pub command_index: usize,
121 /// The pathless-allowed flags present,
122 /// echoed in the diagnostic.
123 pub flag_text: String,
124}
125
126/// What:
127/// The transform's answer,
128/// or the repository fact it needs first.
129/// Why:
130/// The facts are fetched lazily:
131/// most commands are decided from arguments alone.
132///
133/// In TS you'd write (pseudocode):
134/// ```ts
135/// type CommitOnlyDecision = { kind: 'unchanged' } | { kind: 'rewritten'; args: string[] }
136/// | { kind: 'rejected'; violation } | { kind: 'needs-sequencer-state' } | { kind: 'needs-index-state'; pending };
137/// ```
138#[derive(Clone, Debug, Eq, PartialEq)]
139pub enum CommitOnlyDecision {
140 /// Forward the caller's arguments as they are.
141 Unchanged,
142 /// Forward this list:
143 /// `-o` inserted,
144 /// or the escape hatch removed.
145 Rewritten(Vec<OsString>),
146 /// Block the command with this finding.
147 Rejected(CommitOnlyViolation),
148 /// Ask whether a merge,
149 /// cherry-pick or revert awaits its commit,
150 /// then call
151 /// `resolve_sequencer_state`.
152 NeedsSequencerState,
153 /// Ask whether the index differs from `HEAD`,
154 /// then call `resolve_index_state`.
155 NeedsIndexState(PendingInjection),
156}
157
158/// What:
159/// Build a rejection from a code and its message text.
160/// Why:
161/// `String::from` copies the borrowed text into storage the violation owns.
162///
163/// In TS you'd write (pseudocode):
164/// ```ts
165/// new CommitOnlyViolationError(code, message)
166/// ```
167fn rejected(code: CommitOnlyViolationCode, message: &str) -> CommitOnlyDecision {
168 return CommitOnlyDecision::Rejected(CommitOnlyViolation {
169 code,
170 message: String::from(message),
171 });
172}
173
174/// What:
175/// The pathless-allowed flags of a region as diagnostic text,
176/// such as `--amend`.
177/// Why:
178/// The rejection names the exact form that was refused.
179///
180/// In TS you'd write (pseudocode):
181/// ```ts
182/// const flagText = [amend && '--amend', allowEmpty && '--allow-empty'].filter(Boolean).join(' ');
183/// ```
184fn pathless_flag_text(region: &CommitRegion) -> String {
185 let mut parts: Vec<&str> = Vec::<&str>::new();
186 if region.amend {
187 parts.push("--amend");
188 }
189 if region.allow_empty {
190 parts.push("--allow-empty");
191 }
192 if region.fixup == Some(FixupKind::Amend) {
193 parts.push("--fixup=amend:<commit>");
194 }
195 // `.join(" ")` builds one owned `String` with spaces between the parts.
196 return parts.join(" ");
197}
198
199/// What:
200/// Decide from arguments alone,
201/// or name the repository fact needed.
202/// `Result<A, B>` is "either success `A` or failure `B`".
203/// Why:
204/// The order mirrors the TypeScript rule:
205/// escape hatch,
206/// opt-out,
207/// `-a`,
208/// pathspec
209/// source,
210/// explicit only,
211/// modes Git owns,
212/// then the dirty-index guard.
213/// Gotcha:
214/// `Err` means Git 2.56.0 itself refuses the commit options;
215/// the caller forwards
216/// such a command unchanged so Git prints its own error.
217///
218/// In TS you'd write (pseudocode):
219/// ```ts
220/// async function commitOnly(args: readonly string[]): Promise<readonly string[]>; // throws
221/// ```
222pub fn decide_commit_only(
223 arguments: &[OsString],
224 wrapper_flags: &[&[u8]],
225) -> Result<CommitOnlyDecision, OptionError> {
226 let layout: GlobalLayout = global_layout(arguments);
227 if layout.outcome != GlobalOutcome::Command
228 || arguments[layout.prefix_len].as_encoded_bytes() != b"commit"
229 {
230 // `Ok(x)` is the success case of `Result`.
231 return Ok(CommitOnlyDecision::Unchanged);
232 }
233 let region_start: usize = layout.prefix_len + 1;
234 // `?` returns Git's refusal to our caller, or unwraps the facts.
235 let region: CommitRegion = parse_commit_region(&arguments[region_start..], wrapper_flags)?;
236 if !region.wrapper.escape.is_empty() {
237 // Only option-position hatch tokens go; an equal message or path stays.
238 return Ok(CommitOnlyDecision::Rewritten(without_tokens(
239 arguments,
240 region_start,
241 region.wrapper.escape.as_slice(),
242 )));
243 }
244 let fixup_owns_only: bool = region.fixup == Some(FixupKind::Reword)
245 || region.fixup == Some(FixupKind::UnknownSuboption);
246 let pathless_allowed: bool =
247 region.amend || region.allow_empty || region.fixup == Some(FixupKind::Amend);
248 let has_paths: bool = !region.pathspecs.is_empty() || region.pathspec_from_file.is_some();
249 // `--no-only` is the explicit choice to commit the whole index.
250 if region.only != Some(false) {
251 if region.all {
252 return Ok(rejected(CommitOnlyViolationCode::AllFlag, ALL_FLAG_MESSAGE));
253 }
254 let has_source: bool =
255 has_paths || pathless_allowed || fixup_owns_only || region.interactive || region.patch;
256 if !has_source {
257 return Ok(CommitOnlyDecision::NeedsSequencerState);
258 }
259 }
260 if region.only.is_some() {
261 return Ok(CommitOnlyDecision::Unchanged);
262 }
263 // Git forbids `--only` with these, or turns it on itself for `--fixup=reword:`.
264 if region.include || region.interactive || region.patch || fixup_owns_only {
265 return Ok(CommitOnlyDecision::Unchanged);
266 }
267 if pathless_allowed && !has_paths {
268 return Ok(CommitOnlyDecision::NeedsIndexState(PendingInjection {
269 command_index: layout.prefix_len,
270 flag_text: pathless_flag_text(®ion),
271 }));
272 }
273 return Ok(CommitOnlyDecision::Rewritten(insert_tokens(
274 arguments,
275 region_start,
276 &["-o"],
277 )));
278}
279
280/// What:
281/// Finish a `NeedsSequencerState` decision.
282/// Why:
283/// A pathless commit concludes a merge,
284/// cherry-pick or revert;
285/// otherwise it is refused.
286///
287/// In TS you'd write (pseudocode):
288/// ```ts
289/// if (sequencerState === 'in-progress') return args; throw new CommitOnlyViolationError('pathspec-required', ...);
290/// ```
291pub fn resolve_sequencer_state(state: SequencerState) -> CommitOnlyDecision {
292 if state == SequencerState::InProgress {
293 return CommitOnlyDecision::Unchanged;
294 }
295 return rejected(
296 CommitOnlyViolationCode::PathspecRequired,
297 NO_PATHSPEC_MESSAGE,
298 );
299}
300
301/// What:
302/// Finish a `NeedsIndexState` decision.
303/// `&PendingInjection` borrows the pending data.
304/// Why:
305/// An injected `--only` on a pathless amend or empty commit reuses `HEAD`'s tree,
306/// so a
307/// dirty index would be ignored silently;
308/// an equal or unknown index is safe to inject.
309///
310/// In TS you'd write (pseudocode):
311/// ```ts
312/// if (indexState === 'differs') throw new CommitOnlyViolationError('staged-changes-ignored', ...);
313/// return [...args.slice(0, i + 1), '-o', ...args.slice(i + 1)];
314/// ```
315pub fn resolve_index_state(
316 arguments: &[OsString],
317 pending: &PendingInjection,
318 state: IndexVsHead,
319) -> CommitOnlyDecision {
320 if state == IndexVsHead::Differs {
321 // `.as_str()` lends the owned text as a borrowed view.
322 return rejected(
323 CommitOnlyViolationCode::StagedChangesIgnored,
324 ignored_index_message(pending.flag_text.as_str()).as_str(),
325 );
326 }
327 return CommitOnlyDecision::Rewritten(insert_tokens(
328 arguments,
329 pending.command_index + 1,
330 &["-o"],
331 ));
332}
333
334/// Every `commit-only.unit.test.ts` case.
335#[cfg(test)]
336#[path = "rule_commit_only_tests.rs"]
337mod tests;
338
339/// Cases beyond the incumbent test,
340/// including readings where Git differs from it.
341#[cfg(test)]
342#[path = "rule_commit_only_divergence_tests.rs"]
343mod divergence_tests;
344
345/// Real Git 2.56.0 controls for the Git behavior the decision relies on.
346#[cfg(test)]
347#[path = "rule_commit_only_git_tests.rs"]
348mod git_tests;