Skip to main content

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(&region),
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;