git_policy_cli/forwarded_command.rs
1//! What:
2//! The Git command a forwarded invocation actually runs,
3//! after ordinary alias expansion.
4//! Why:
5//! Git expands an alias only when no built-in has that name,
6//! reads it from `alias.<name>`
7//! or `alias.<name>.command`,
8//! splits it with `split_cmdline` quoting,
9//! lets its leading words
10//! be global options,
11//! and expands again when the result is an alias.
12//! Index-writer
13//! coordination and the commit rules decide from the expanded command,
14//! so
15//! `git -c alias.c=commit c` is a commit (`src/forwarded-command.ts`).
16//!
17//! In TS you'd write (pseudocode):
18//! ```ts
19//! const command = await resolveForwardedCommand({ args, gitPath });
20//! ```
21
22/// Debug diagnostics.
23use super::diagnostic_log::debug;
24/// The built-in command names.
25use super::git_builtins::is_git_builtin;
26/// The global-option layout.
27use super::global_arguments::{GlobalLayout, GlobalOutcome, global_layout};
28/// Trimming with the incumbent's `.trim()` semantics.
29use super::js_text::trim_javascript;
30/// Running real Git.
31use super::transaction_git::{GitContext, GitRequest, run_git};
32/// `OsString` is owned operating-system text of raw bytes.
33use std::ffi::OsString;
34/// `Path` is a borrowed filesystem path.
35use std::path::Path;
36
37/// Alias expansions followed before giving up,
38/// well above any realistic chain.
39pub const MAX_ALIAS_EXPANSIONS: usize = 16;
40
41/// What:
42/// How the command was reached.
43/// Why:
44/// A shell alias or an unresolved name is never classified as a built-in.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// type ForwardedCommandRoute = 'direct' | 'alias' | 'shell-alias' | 'unresolved';
49/// ```
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum Route {
52 /// A built-in named directly.
53 Direct,
54 /// A built-in reached through one or more aliases.
55 Alias,
56 /// An alias running a shell command (`!...`).
57 ShellAlias,
58 /// An external command,
59 /// an unknown name,
60 /// an alias loop or a malformed alias value.
61 Unresolved,
62}
63
64/// What:
65/// The resolved command:
66/// the arguments after expansion,
67/// where the subcommand is,
68/// its
69/// name,
70/// and the route.
71/// Why:
72/// `ResolvedGitCommand`.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// type ResolvedGitCommand = { args: string[]; subcommandIndex: number; subcommand?: string; route: ForwardedCommandRoute };
77/// ```
78#[derive(Clone, Debug, Eq, PartialEq)]
79pub struct ResolvedCommand {
80 /// Arguments after expansion,
81 /// global options included.
82 pub arguments: Vec<OsString>,
83 /// Index of the subcommand in `arguments`.
84 pub subcommand_index: usize,
85 /// The subcommand,
86 /// or nothing for a bare `git` or a help,
87 /// version or error form.
88 pub subcommand: Option<OsString>,
89 /// How it was reached.
90 pub route: Route,
91}
92
93/// Methods over a resolved command.
94impl ResolvedCommand {
95 /// What:
96 /// The subcommand as bytes,
97 /// empty when there is none.
98 /// Why:
99 /// Classification compares names.
100 ///
101 /// In TS you'd write (pseudocode):
102 /// ```ts
103 /// command.subcommand ?? ''
104 /// ```
105 pub fn word(&self) -> &[u8] {
106 match &self.subcommand {
107 Some(name) => return name.as_encoded_bytes(),
108 None => return b"",
109 }
110 }
111
112 /// What:
113 /// The arguments after the subcommand.
114 /// Why:
115 /// Option classification reads them.
116 ///
117 /// In TS you'd write (pseudocode):
118 /// ```ts
119 /// command.args.slice(command.subcommandIndex + 1)
120 /// ```
121 pub fn region(&self) -> &[OsString] {
122 return self.arguments.get(self.subcommand_index + 1..).unwrap_or(&[]);
123 }
124
125 /// What:
126 /// Whether this is a built-in reached directly or through aliases.
127 /// Why:
128 /// Shell aliases and unresolved names are never index writers or commits.
129 ///
130 /// In TS you'd write (pseudocode):
131 /// ```ts
132 /// command.route !== 'shell-alias' && command.route !== 'unresolved' && command.subcommand !== undefined
133 /// ```
134 pub fn is_builtin(&self) -> bool {
135 return matches!(self.route, Route::Direct | Route::Alias) && self.subcommand.is_some();
136 }
137}
138
139/// What:
140/// Whether a byte is whitespace as C `isspace` sees it in the C locale.
141/// Why:
142/// `split_cmdline` separates words on exactly these.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// [' ', '\t', '\n', '\r', '\v', '\f'].includes(character)
147/// ```
148fn is_space(byte: u8) -> bool {
149 return matches!(byte, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c);
150}
151
152/// What:
153/// Split an alias value as Git's `split_cmdline` does:
154/// whitespace separates words outside
155/// quotes,
156/// single and double quotes group,
157/// and a backslash escapes the next byte except
158/// inside single quotes.
159/// `None` is an unclosed quote or a trailing backslash.
160/// Why:
161/// `splitAliasCommand`;
162/// bytes are kept as they are.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// function splitAliasCommand(value: string): string[] | typeof ALIAS_SPLIT_FAILED;
167/// ```
168pub fn split_alias_command(value: &[u8]) -> Option<Vec<Vec<u8>>> {
169 let mut words: Vec<Vec<u8>> = Vec::new();
170 let mut current: Vec<u8> = Vec::new();
171 // Starts true as the incumbent's `splitAliasCommand` does, so an empty value is one empty word.
172 let mut started: bool = true;
173 let mut quote: u8 = 0;
174 let mut escaped: bool = false;
175 for &byte in value {
176 if escaped {
177 current.push(byte);
178 escaped = false;
179 } else if quote == 0 && is_space(byte) {
180 if started {
181 words.push(std::mem::take(&mut current));
182 }
183 current.clear();
184 started = false;
185 } else if quote == 0 && (byte == b'\'' || byte == b'"') {
186 quote = byte;
187 started = true;
188 } else if quote != 0 && byte == quote {
189 quote = 0;
190 } else if byte == b'\\' && quote != b'\'' {
191 escaped = true;
192 started = true;
193 } else {
194 current.push(byte);
195 started = true;
196 }
197 }
198 if quote != 0 || escaped {
199 return None;
200 }
201 if started {
202 words.push(current);
203 }
204 return Some(words);
205}
206
207/// What:
208/// An alias value with surrounding whitespace removed,
209/// as JavaScript's `.trim()` does for
210/// text;
211/// a value that is not UTF-8 loses only ASCII whitespace.
212/// Why:
213/// The incumbent trims `git config --get` output,
214/// newline included,
215/// before splitting.
216///
217/// In TS you'd write (pseudocode):
218/// ```ts
219/// value.trim()
220/// ```
221pub fn trim_alias_value(value: &[u8]) -> Vec<u8> {
222 if let Ok(text) = std::str::from_utf8(value) {
223 return trim_javascript(text).as_bytes().to_vec();
224 }
225 let start: usize = value.iter().position(is_not_ascii_space).unwrap_or(value.len());
226 let end: usize = value.iter().rposition(is_not_ascii_space).map_or(start, next_index);
227 return value[start..end].to_vec();
228}
229
230/// What:
231/// Whether a byte is not ASCII whitespace.
232/// Why:
233/// A named predicate keeps the trimming free of closures.
234///
235/// In TS you'd write (pseudocode):
236/// ```ts
237/// (byte: number) => !isspace(byte)
238/// ```
239fn is_not_ascii_space(byte: &u8) -> bool {
240 return !is_space(*byte);
241}
242
243/// What:
244/// The index after `index`.
245/// Why:
246/// A named function keeps `map_or` free of closures.
247///
248/// In TS you'd write (pseudocode):
249/// ```ts
250/// (index: number) => index + 1
251/// ```
252fn next_index(index: usize) -> usize {
253 return index + 1;
254}
255
256/// What:
257/// The value of one config key through `git config --get`,
258/// or nothing when Git reports
259/// none or cannot be asked.
260/// Why:
261/// `readAliasKey`:
262/// every failure of the lookup is "no alias".
263///
264/// In TS you'd write (pseudocode):
265/// ```ts
266/// async function readAliasKey({ gitPath, globalArgs, key }): Promise<string | typeof ALIAS_UNSET>;
267/// ```
268fn read_config_value(context: &GitContext, global: &[OsString], key: &OsString) -> Option<Vec<u8>> {
269 let mut arguments: Vec<OsString> = global.to_vec();
270 arguments.push(OsString::from("config"));
271 arguments.push(OsString::from("--get"));
272 arguments.push(key.clone());
273 let mut request: GitRequest = GitRequest::new(Path::new("."), arguments.as_slice());
274 request.without_prefix = true;
275 match run_git(context, &request) {
276 Ok(output) if output.succeeded() => return Some(output.stdout),
277 Ok(_) | Err(_) => {
278 debug(
279 "readAliasKey",
280 format!("no {}", key.to_string_lossy()).as_str(),
281 );
282 return None;
283 }
284 }
285}
286
287/// What:
288/// An alias value in either of Git's spellings,
289/// `alias.<name>` first.
290/// Why:
291/// `lookupAlias`.
292///
293/// In TS you'd write (pseudocode):
294/// ```ts
295/// async function lookupAlias({ gitPath, globalArgs, name }): Promise<string | typeof ALIAS_UNSET>;
296/// ```
297pub fn lookup_alias(context: &GitContext, global: &[OsString], name: &OsString) -> Option<Vec<u8>> {
298 let mut plain: OsString = OsString::from("alias.");
299 plain.push(name);
300 if let Some(value) = read_config_value(context, global, &plain) {
301 return Some(value);
302 }
303 let mut spelled: OsString = plain;
304 spelled.push(".command");
305 return read_config_value(context, global, &spelled);
306}
307
308/// What:
309/// The resolved command for the current arguments with a route.
310/// Why:
311/// Every ending of the expansion builds one.
312///
313/// In TS you'd write (pseudocode):
314/// ```ts
315/// ({ args, subcommandIndex, subcommand, route })
316/// ```
317fn resolved(arguments: &[OsString], layout: &GlobalLayout, route: Route) -> ResolvedCommand {
318 let subcommand: Option<OsString> = if layout.outcome == GlobalOutcome::Command {
319 arguments.get(layout.prefix_len).cloned()
320 } else {
321 None
322 };
323 return ResolvedCommand {
324 arguments: arguments.to_vec(),
325 subcommand_index: layout.prefix_len,
326 subcommand,
327 route,
328 };
329}
330
331/// What:
332/// Words of an alias value as arguments.
333/// Why:
334/// The expansion replaces the alias name with them.
335///
336/// In TS you'd write (pseudocode):
337/// ```ts
338/// words
339/// ```
340fn os_words(words: Vec<Vec<u8>>) -> Vec<OsString> {
341 let mut owned: Vec<OsString> = Vec::with_capacity(words.len());
342 for word in words {
343 #[cfg(unix)]
344 {
345 /// The Unix byte-to-string conversion for command words.
346 use std::os::unix::ffi::OsStringExt;
347 owned.push(OsString::from_vec(word));
348 }
349 #[cfg(not(unix))]
350 {
351 owned.push(OsString::from(String::from_utf8_lossy(word.as_slice()).into_owned()));
352 }
353 }
354 return owned;
355}
356
357/// What:
358/// Resolve the command a forwarded invocation runs,
359/// expanding aliases up to
360/// `MAX_ALIAS_EXPANSIONS` times;
361/// a built-in name starts no Git process.
362/// Why:
363/// `resolveForwardedCommand`.
364///
365/// In TS you'd write (pseudocode):
366/// ```ts
367/// async function resolveForwardedCommand({ args, gitPath }): Promise<ResolvedGitCommand>;
368/// ```
369pub fn resolve_forwarded_command(context: &GitContext, arguments: &[OsString]) -> ResolvedCommand {
370 // `mut` allows replacing the arguments with each expansion.
371 let mut current: Vec<OsString> = arguments.to_vec();
372 let mut seen: Vec<OsString> = Vec::new();
373 for _ in 0..=MAX_ALIAS_EXPANSIONS {
374 let layout: GlobalLayout = global_layout(current.as_slice());
375 let route: Route = if seen.is_empty() { Route::Direct } else { Route::Alias };
376 if layout.outcome != GlobalOutcome::Command {
377 return resolved(current.as_slice(), &layout, route);
378 }
379 let name: OsString = current[layout.prefix_len].clone();
380 if is_git_builtin(name.as_encoded_bytes()) {
381 return resolved(current.as_slice(), &layout, route);
382 }
383 if seen.contains(&name) {
384 debug(
385 "resolveForwardedCommand",
386 format!("alias loop at {}", name.to_string_lossy()).as_str(),
387 );
388 return resolved(current.as_slice(), &layout, Route::Unresolved);
389 }
390 seen.push(name.clone());
391 let global: &[OsString] = ¤t[..layout.prefix_len];
392 let Some(value) = lookup_alias(context, global, &name) else {
393 return resolved(current.as_slice(), &layout, Route::Unresolved);
394 };
395 let trimmed: Vec<u8> = trim_alias_value(value.as_slice());
396 if trimmed.first() == Some(&b'!') {
397 return resolved(current.as_slice(), &layout, Route::ShellAlias);
398 }
399 let Some(words) = split_alias_command(trimmed.as_slice()) else {
400 return resolved(current.as_slice(), &layout, Route::Unresolved);
401 };
402 if words.is_empty() {
403 return resolved(current.as_slice(), &layout, Route::Unresolved);
404 }
405 let mut expanded: Vec<OsString> = global.to_vec();
406 expanded.extend(os_words(words));
407 expanded.extend(current[layout.prefix_len + 1..].iter().cloned());
408 debug(
409 "resolveForwardedCommand",
410 format!("expanded alias {}", name.to_string_lossy()).as_str(),
411 );
412 current = expanded;
413 }
414 let layout: GlobalLayout = global_layout(current.as_slice());
415 return resolved(current.as_slice(), &layout, Route::Unresolved);
416}
417
418/// What:
419/// Whether a resolved command creates or moves linked worktrees.
420/// Why:
421/// `createsOrMovesWorktrees`:
422/// `git worktree add` and `git worktree move`,
423/// decided by the
424/// first positional token after `worktree`.
425///
426/// In TS you'd write (pseudocode):
427/// ```ts
428/// function createsOrMovesWorktrees(command: ResolvedGitCommand): boolean;
429/// ```
430pub fn creates_or_moves_worktrees(command: &ResolvedCommand) -> bool {
431 if !command.is_builtin() || command.word() != b"worktree" {
432 return false;
433 }
434 for token in command.region() {
435 let bytes: &[u8] = token.as_encoded_bytes();
436 if !bytes.starts_with(b"-") {
437 return bytes == b"add" || bytes == b"move";
438 }
439 }
440 return false;
441}
442
443/// Resolution controls stay out of the release executable.
444#[cfg(test)]
445#[path = "forwarded_command_tests.rs"]
446mod tests;