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