git_policy_cli/command/command_push.rs
1//! What:
2//! The complete `git push` option table of Git 2.56.0 and the facts read from it.
3//! Why:
4//! The atomic-push transform must know whether the caller already chose atomicity,
5//! and the manual-push gate whether the push is a dry run;
6//! both in every spelling Git
7//! accepts (`--at`,
8//! `--no-at`,
9//! `-nf`),
10//! which a list of exact tokens misses.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! // parsePushRegion(postSubcommandArgs): PushRegion (throws OptionError)
15//! ```
16
17/// What:
18/// Bring the tokenizer,
19/// its table builder and its questions into this file.
20/// Why:
21/// This module only declares Git's table and interprets what the tokenizer found.
22///
23/// In TS you'd write (pseudocode):
24/// ```ts
25/// import { parseOptions, row } from './command_options.ts';
26/// ```
27use crate::command_options::{
28 Arity, DEFAULT_MODE, OptionError, OptionSpec, ParsedOptions, UNREAD, WrapperOccurrence,
29 parse_options, row,
30};
31use crate::command_options_query::{is_enabled, is_stated, last_occurrence, positional_tokens, value_bytes};
32/// `OsString` is owned operating-system text of raw bytes (sibling `String` must be UTF-8).
33use std::ffi::OsString;
34
35/// `--atomic`:
36/// request an atomic transaction on the remote side.
37pub const ATOMIC: u16 = 1;
38/// `-n`,
39/// `--dry-run`:
40/// do everything except send the updates.
41pub const DRY_RUN: u16 = 2;
42/// `-f`,
43/// `--force`:
44/// update a ref that is not a descendant.
45pub const FORCE: u16 = 4;
46/// `--force-with-lease`:
47/// force when the remote ref is still what the caller expected.
48pub const FORCE_WITH_LEASE: u16 = 8;
49/// `--force-if-includes`:
50/// a safety check that only means something with a forced push.
51pub const FORCE_IF_INCLUDES: u16 = 16;
52/// `--mirror`:
53/// make the remote's refs equal the local ones.
54pub const MIRROR: u16 = 32;
55/// `-d`,
56/// `--delete`:
57/// remove remote refs.
58pub const DELETE: u16 = 64;
59/// `--all` and its alias `--branches`:
60/// every local branch.
61pub const ALL: u16 = 128;
62/// `--tags`:
63/// every local tag.
64pub const TAGS: u16 = 256;
65/// `--prune`:
66/// remove remote refs that have no local counterpart.
67pub const PRUNE: u16 = 512;
68/// `--follow-tags`:
69/// add the annotated tags that point into the pushed history.
70pub const FOLLOW_TAGS: u16 = 1024;
71/// `--repo`:
72/// the destination is an option and not the first positional argument.
73pub const REPO: u16 = 2048;
74/// `--porcelain`:
75/// machine-readable output that a script parses.
76pub const PORCELAIN: u16 = 4096;
77/// `-u`,
78/// `--set-upstream`:
79/// record the pushed branch as the local branch's upstream.
80pub const SET_UPSTREAM: u16 = 8192;
81/// `--no-verify`:
82/// bypass the `pre-push` hooks, the Git LFS upload included.
83pub const NO_VERIFY: u16 = 16384;
84/// `--recurse-submodules`:
85/// also push the submodules' commits.
86pub const RECURSE_SUBMODULES: u16 = 32768;
87
88/// What:
89/// Rows of `options[]` in `cmd_push` (builtin/push.c:707-743),
90/// in source order.
91/// `&[OptionSpec]` is a borrowed table baked into the program.
92/// Why:
93/// `--branches` is an `OPT_ALIAS` of `--all`;
94/// the two names share no prefix,
95/// so the
96/// alias needs no special ambiguity handling and is a row of its own.
97///
98/// In TS you'd write (pseudocode):
99/// ```ts
100/// export const PUSH_TABLE = [row(UNREAD, 'v', 'verbose', 'none', true), /* ... */] as const;
101/// ```
102pub const PUSH_TABLE: &[OptionSpec] = &[
103 row(UNREAD, Some(b'v'), Some("verbose"), Arity::None, true),
104 row(UNREAD, Some(b'q'), Some("quiet"), Arity::None, true),
105 row(REPO, None, Some("repo"), Arity::Required, true),
106 row(ALL, None, Some("all"), Arity::None, true),
107 row(ALL, None, Some("branches"), Arity::None, true),
108 row(MIRROR, None, Some("mirror"), Arity::None, true),
109 row(DELETE, Some(b'd'), Some("delete"), Arity::None, true),
110 row(TAGS, None, Some("tags"), Arity::None, true),
111 row(DRY_RUN, Some(b'n'), Some("dry-run"), Arity::None, true),
112 row(PORCELAIN, None, Some("porcelain"), Arity::None, true),
113 row(FORCE, Some(b'f'), Some("force"), Arity::None, true),
114 // `PARSE_OPT_OPTARG`: only `--force-with-lease=<refname>:<expect>` carries a value.
115 row(
116 FORCE_WITH_LEASE,
117 None,
118 Some("force-with-lease"),
119 Arity::Optional,
120 true,
121 ),
122 row(FORCE_IF_INCLUDES, None, Some("force-if-includes"), Arity::None, true),
123 row(
124 RECURSE_SUBMODULES,
125 None,
126 Some("recurse-submodules"),
127 Arity::Required,
128 true,
129 ),
130 row(UNREAD, None, Some("thin"), Arity::None, true),
131 row(UNREAD, None, Some("receive-pack"), Arity::Required, true),
132 row(UNREAD, None, Some("exec"), Arity::Required, true),
133 row(SET_UPSTREAM, Some(b'u'), Some("set-upstream"), Arity::None, true),
134 row(UNREAD, None, Some("progress"), Arity::None, true),
135 row(PRUNE, None, Some("prune"), Arity::None, true),
136 row(NO_VERIFY, None, Some("no-verify"), Arity::None, true),
137 row(FOLLOW_TAGS, None, Some("follow-tags"), Arity::None, true),
138 // `PARSE_OPT_OPTARG`: only `--signed=<mode>` carries a value.
139 row(UNREAD, None, Some("signed"), Arity::Optional, true),
140 row(ATOMIC, None, Some("atomic"), Arity::None, true),
141 row(
142 UNREAD,
143 Some(b'o'),
144 Some("push-option"),
145 Arity::Required,
146 true,
147 ),
148 // `OPT_IPVERSION` rows are `PARSE_OPT_NONEG` (parse-options.h:636-640).
149 row(UNREAD, Some(b'4'), Some("ipv4"), Arity::None, false),
150 row(UNREAD, Some(b'6'), Some("ipv6"), Arity::None, false),
151];
152
153/// What:
154/// Final-state facts of one `git push` region.
155/// `bool` is true or false;
156/// `Vec<WrapperOccurrence>` is an owned list of wrapper-flag positions.
157/// Why:
158/// `atomic_stated` is "the caller chose",
159/// in either direction;
160/// `dry_run` is the state
161/// after the last `-n`,
162/// `--dry-run` or `--no-dry-run`.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// type PushRegion = { hasAtomicChoice: boolean; isDryRun: boolean };
167/// ```
168#[derive(Clone, Debug, Eq, PartialEq)]
169pub struct PushRegion {
170 /// `--atomic` or `--no-atomic` was written,
171 /// in any accepted abbreviation.
172 pub atomic_stated: bool,
173 /// The push sends no updates.
174 pub dry_run: bool,
175 /// The `pre-push` hooks are bypassed (`--no-verify`, in any accepted abbreviation).
176 pub no_verify: bool,
177 /// Wrapper-only flags in option position;
178 /// `flag` indexes the caller's list.
179 pub wrapper: Vec<WrapperOccurrence>,
180}
181
182/// What:
183/// Parse the region after `push`.
184/// `Result<A, B>` is "either success `A` or failure `B`".
185/// Why:
186/// A region Git itself would refuse yields the refusal,
187/// never a guessed fact set.
188///
189/// In TS you'd write (pseudocode):
190/// ```ts
191/// function parsePushRegion(region: string[], wrapperFlags: string[]): PushRegion; // throws
192/// ```
193pub fn parse_push_region(
194 region: &[OsString],
195 wrapper_flags: &[&[u8]],
196) -> Result<PushRegion, OptionError> {
197 // `?` returns Git's refusal to our caller, or unwraps the tokenized region.
198 let parsed: ParsedOptions = parse_options(region, PUSH_TABLE, DEFAULT_MODE, wrapper_flags)?;
199 // `Ok(x)` is the success case of `Result`.
200 return Ok(PushRegion {
201 atomic_stated: is_stated(&parsed, ATOMIC),
202 dry_run: is_enabled(&parsed, DRY_RUN),
203 no_verify: is_enabled(&parsed, NO_VERIFY),
204 wrapper: parsed.wrapper,
205 });
206}
207
208/// What:
209/// Where a push goes and what it names, read from its option region.
210/// Why:
211/// The Git LFS upload that runs before a push needs the destination remote and the refs the push will send; an
212/// explicit repository and explicit refspecs name them, and the options that send more than the named refs
213/// (`--all`, `--tags`, `--mirror`) say which kinds of ref are sent.
214///
215/// In TS you'd write (pseudocode):
216/// ```ts
217/// type PushShape = { repository?: string; refspecs: string[]; everything: boolean; deleteOnly: boolean };
218/// ```
219#[derive(Clone, Debug, Eq, PartialEq)]
220pub struct PushShape {
221 /// The repository the caller named (the first positional, or the value of `--repo`), if any.
222 pub repository: Option<OsString>,
223 /// The refspecs the caller named, in order, with `tag <name>` already joined into `refs/tags/<name>`.
224 pub refspecs: Vec<OsString>,
225 /// `--all` or `--branches`: every local branch.
226 pub branches: bool,
227 /// `--tags`: every local tag.
228 pub tags: bool,
229 /// `--mirror`: every ref, and the remote is made to match.
230 pub mirror: bool,
231 /// The push only deletes remote refs, so it sends no objects.
232 pub deletes: bool,
233 /// The value of `--recurse-submodules`, when one was written.
234 pub recurse_submodules: Option<OsString>,
235}
236
237/// What:
238/// Read the destination and the named refs of a push.
239/// `None` when the region is one Git itself would refuse.
240/// Why:
241/// One reading of the arguments serves the upload plan, so the remote and the refs it names are the ones Git reads.
242///
243/// In TS you'd write (pseudocode):
244/// ```ts
245/// function pushShape(region: string[]): PushShape | undefined;
246/// ```
247pub fn push_shape(region: &[OsString]) -> Option<PushShape> {
248 let parsed: ParsedOptions = parse_options(region, PUSH_TABLE, DEFAULT_MODE, &[]).ok()?;
249 let mut positionals: Vec<OsString> = positional_tokens(&parsed, region.len())
250 .into_iter()
251 .map(|index| {
252 return region[index].clone();
253 })
254 .collect();
255 let value_of = |id: u16| -> Option<OsString> {
256 let value = last_occurrence(&parsed, id).and_then(|occurrence| {
257 return occurrence.value;
258 })?;
259 return Some(<OsString as std::os::unix::ffi::OsStringExt>::from_vec(value_bytes(region, value).to_vec()));
260 };
261 // Git takes the first positional as the repository whatever `--repo` says; the option only fills in when none is typed.
262 let repository: Option<OsString> =
263 if positionals.is_empty() { value_of(REPO) } else { Some(positionals.remove(0)) };
264 let mut refspecs: Vec<OsString> = Vec::new();
265 let mut words = positionals.into_iter();
266 while let Some(word) = words.next() {
267 if word.as_encoded_bytes() == b"tag"
268 && let Some(name) = words.next()
269 {
270 let mut joined: OsString = OsString::from("refs/tags/");
271 joined.push(name);
272 refspecs.push(joined);
273 } else {
274 refspecs.push(word);
275 }
276 }
277 return Some(PushShape {
278 repository,
279 refspecs,
280 branches: is_enabled(&parsed, ALL),
281 tags: is_enabled(&parsed, TAGS),
282 mirror: is_enabled(&parsed, MIRROR),
283 deletes: is_enabled(&parsed, DELETE),
284 recurse_submodules: value_of(RECURSE_SUBMODULES),
285 });
286}
287
288/// Options after which a rejected push is never integrated:
289/// each either rewrites or removes remote history, sends more than one kind of ref, changes what the caller
290/// reads back, or names its destination in a way the stacked push does not reproduce.
291const NOT_STACKABLE: &[u16] = &[
292 DRY_RUN,
293 FORCE,
294 FORCE_WITH_LEASE,
295 FORCE_IF_INCLUDES,
296 MIRROR,
297 DELETE,
298 ALL,
299 TAGS,
300 PRUNE,
301 FOLLOW_TAGS,
302 REPO,
303 PORCELAIN,
304 SET_UPSTREAM,
305];
306
307/// What:
308/// The option tokens of a push that may be integrated after a rejection, or nothing when it may not.
309/// `Option<Vec<OsString>>` is "the tokens, or no answer".
310/// Why:
311/// Only a plain update of branches keeps its meaning when the wrapper merges what landed on the remote and pushes the
312/// merge; the stacked push repeats these tokens and replaces the remote and refspec arguments,
313/// so the caller's `--no-verify`, `--push-option` and the like still apply.
314/// A region the table refuses, a forced refspec (`+src:dst`) and every option in `NOT_STACKABLE` give nothing.
315///
316/// In TS you'd write (pseudocode):
317/// ```ts
318/// function stackableOptionTokens(region: string[]): string[] | undefined;
319/// ```
320pub fn stackable_option_tokens(region: &[OsString]) -> Option<Vec<OsString>> {
321 let parsed: ParsedOptions = parse_options(region, PUSH_TABLE, DEFAULT_MODE, &[]).ok()?;
322 if NOT_STACKABLE.iter().any(|id| {
323 return is_enabled(&parsed, *id);
324 }) {
325 return None;
326 }
327 let positionals: Vec<usize> = positional_tokens(&parsed, region.len());
328 if positionals.iter().any(|index| {
329 return region[*index].as_encoded_bytes().first() == Some(&b'+');
330 }) {
331 return None;
332 }
333 return Some(
334 region
335 .iter()
336 .enumerate()
337 .filter(|(index, _)| {
338 return !positionals.contains(index);
339 })
340 .map(|(_, token)| {
341 return token.clone();
342 })
343 .collect(),
344 );
345}
346
347/// Facts in every accepted spelling,
348/// and real-Git controls of the table and its abbreviations.
349#[cfg(test)]
350#[path = "command_push_tests.rs"]
351mod tests;