git_policy_cli/invocation/entry.rs
1//! What:
2//! Decide what one `git` invocation of the native wrapper does,
3//! and perform it.
4//! Why:
5//! The executable stays thin:
6//! it gathers process facts,
7//! asks this module for an
8//! action,
9//! and performs it.
10//! The decision itself is testable without replacing
11//! the test process.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! // const action = planInvocation(args, env, inputs); perform(action);
16//! ```
17
18/// Import the sibling modules the decision combines.
19use crate::action::{Action, ENGINE_FAILURE_EXIT_CODE, PushForward, failure, with_leading_notice};
20#[cfg(unix)]
21use crate::auto_push_trigger::{TipWatch, hand_over_if_ahead, try_watch_tip};
22use crate::push_trigger_log::{Decision, Origin, Outcome, record_decision};
23use crate::announced_query::{WRAPPER_VERSION, announced_query, announcement, printable_after};
24use crate::command_push::stackable_option_tokens;
25use crate::external_hooks::external_hooks_warning;
26use crate::lfs_pre_push::uses_lfs;
27use crate::lfs_upload_plan::settings_from_snapshot;
28use crate::config_snapshot::{ConfigSnapshot, read_config_snapshot};
29use crate::push_stacking::{STACKED_PUSH_MARKER, run_stacked_push};
30use crate::command_push::parse_push_region;
31use crate::child_environment::{
32 FORWARD_TARGET_VARIABLE, child_environment_overlay, environment_value,
33};
34use crate::effective_target::default_allowed_worktree_dirs;
35use crate::forwarded_lock_notice::{forwarded_lock_notice, is_covered};
36use crate::forwarding::{exit_code, replace_process_with_real_git, run_real_git};
37use crate::stale_ref_lock_judge::Judge;
38use crate::index_writer_forward::{CoordinatedForward, CoordinatedRun, run_coordinated_forward};
39use crate::index_writer_commands::is_index_writer;
40use crate::forwarded_command::{ResolvedCommand, Route, creates_worktrees, resolve_forwarded_command};
41use crate::invocation_config::{config_invalid_event, load_identity_config};
42use crate::repository_location::RepositoryLocation;
43use crate::transaction_git::GitContext;
44use crate::worktree_identity::WorktreeIdentity;
45use crate::global_arguments::{GlobalLayout, GlobalOutcome, global_layout};
46use crate::management::{host_filesystem_section, plan_management_with_section};
47/// The linter the executable gives the Markdown policy.
48/// The shipped checks and their constructor.
49use crate::policy_checks::{ShippedChecks, shipped_checks};
50/// A forwarded command starts with no candidate content.
51use crate::policy_content::LifecycleContent;
52use crate::real_git::{ResolutionInputs, ResolvedGit, process_resolution_inputs, resolve_real_git};
53use crate::started_as_git::refusal_when_started_as_git;
54use crate::real_git_candidate::same_file;
55use crate::repository_facts::{GitFacts, RepositoryFacts, git_facts};
56use crate::rule_require_root::{RequireRootDecision, decide_require_root};
57/// The variable that names the forbidden-strings rules list.
58use crate::scanner_selection::RULES_VARIABLE;
59use crate::wrapped_command::{WrappedOutcome, run_wrapped_command};
60use crate::wrapper_controls::{Controls, no_controls, strip_global_controls};
61use crate::worktree_add_plan::{AddPlan, copy_start_directory, parse_add_plan, parse_copy_destination, start_directory};
62use crate::worktree_remove_plan::{RemovePlan, parse_remove_plan};
63use crate::worktree_copy_forward::{CopyFacts, ForwardedRun, copy_applies_with, run_forwarded_git};
64use crate::wrapper_invocation::{StrippedInvocation, strip_wrapper_controls};
65/// What:
66/// `OsString` is owned operating-system text of raw OS bytes (sibling `String`
67/// must be UTF-8).
68/// Why:
69/// Arguments and environment values are never decoded.
70///
71/// In TS you'd write (pseudocode):
72/// ```ts
73/// // process.argv and process.env, but byte-preserving.
74/// ```
75use std::ffi::OsString;
76/// The trait that gives output streams `.write_all(..)`.
77use std::io::Write;
78/// `Path`/`PathBuf` are borrowed/owned filesystem paths of raw OS bytes.
79use std::path::{Path, PathBuf};
80
81/// The management namespace:
82/// `git cli-git ...` is handled by the wrapper,
83/// never by Git.
84pub const MANAGEMENT_COMMAND: &str = "cli-git";
85
86/// What:
87/// Explain that another cli-git wrapper selected this executable as real Git.
88/// `&Path` borrows this executable's path for the message.
89/// Why:
90/// Forwarding again would bounce between the two wrappers forever;
91/// stopping
92/// names this executable so the user can fix PATH.
93///
94/// In TS you'd write (pseudocode):
95/// ```ts
96/// function forwardedToSelf(ownExecutable: string): Action;
97/// ```
98fn forwarded_to_self(own_executable: &Path) -> Action {
99 // `format!` builds owned text; `.as_str()` lends it to the helper.
100 return failure(
101 format!(
102 "another cli-git wrapper selected this cli-git executable ({}) as real Git. \
103 Forwarding again would never reach Git. Remove the extra cli-git from PATH or \
104 place the real Git executable on PATH.",
105 own_executable.display()
106 )
107 .as_str(),
108 );
109}
110
111/// What:
112/// The caller's home directory from the environment,
113/// if it names one.
114/// `Option<PathBuf>` is "an owned path or nothing".
115/// Why:
116/// The tool caches exempt from linked-worktree enforcement live under the home
117/// directory;
118/// it comes from the injected environment,
119/// never from a fixed path.
120///
121/// In TS you'd write (pseudocode):
122/// ```ts
123/// const home = environment.HOME === undefined || environment.HOME === '' ? undefined : environment.HOME;
124/// ```
125fn home_directory(environment: &[(OsString, OsString)]) -> Option<PathBuf> {
126 // `if let Some(home) = ... && cond` runs only when the variable is set and not empty.
127 if let Some(home) = environment_value(environment, "HOME")
128 && !home.is_empty()
129 {
130 // `Some(...)` is the "present" case; `PathBuf::from` turns the text into a path.
131 return Some(PathBuf::from(home));
132 }
133 // `None` is the "absent" case.
134 return None;
135}
136
137/// What:
138/// Decide the action for one invocation from injected process facts.
139/// `&[OsString]` borrows the arguments after the program name;
140/// `&[(OsString, OsString)]` borrows the environment as name/value pairs.
141/// Why:
142/// Order matters.
143/// The recursion check runs before anything else.
144/// Wrapper controls
145/// written before the subcommand are removed first,
146/// because Git's own reading of
147/// the arguments stops at them and would hide the subcommand from every later
148/// decision.
149/// The management namespace is answered by the wrapper.
150/// Every other
151/// command goes through the wrapped-command lifecycle with real Git behind it.
152///
153/// In TS you'd write (pseudocode):
154/// ```ts
155/// function planInvocation(args: string[], environment: [string, string][], inputs: ResolutionInputs): Action;
156/// ```
157pub fn plan_invocation(
158 arguments: &[OsString],
159 environment: &[(OsString, OsString)],
160 inputs: &ResolutionInputs,
161) -> Action {
162 // `if let Some(target) = ... && cond` runs only when a forwarding wrapper left its
163 // marker and that marker names this executable.
164 if let Some(target) = environment_value(environment, FORWARD_TARGET_VARIABLE)
165 && same_file(Path::new(&target), inputs.own_executable.as_path())
166 {
167 return forwarded_to_self(inputs.own_executable.as_path());
168 }
169 // `mut` allows the scan to record what the removed controls asked for.
170 let mut global_controls: Controls = no_controls();
171 // `&mut global_controls` lends the record for writing.
172 let global_clean: Vec<OsString> = strip_global_controls(arguments, &mut global_controls);
173 // `.as_slice()` lends the owned list as a borrowed view.
174 let layout: GlobalLayout = global_layout(global_clean.as_slice());
175 if layout.outcome == GlobalOutcome::Command
176 && global_clean[layout.prefix_len] == MANAGEMENT_COMMAND
177 {
178 // `&list[a..]` borrows from index `a` on; `&list[..a]` borrows up to it.
179 return plan_management_with_section(
180 &global_clean[layout.prefix_len + 1..],
181 &global_clean[..layout.prefix_len],
182 &global_controls,
183 environment,
184 inputs,
185 &host_filesystem_section,
186 );
187 }
188 // What: `match` on the resolver's `Result`: keep the selection or stop with its message.
189 // Why: Without real Git there is nothing to forward to.
190 //
191 // In TS you'd write (pseudocode):
192 // ```ts
193 // let resolved; try { resolved = resolveRealGit(inputs); } catch (e) { return failure(String(e)); }
194 // ```
195 let resolved: ResolvedGit = match resolve_real_git(inputs) {
196 Ok(found) => found,
197 Err(error) => return failure(error.to_string().as_str()),
198 };
199 // The merge push of an integrated race is planned again inside this process with the stacking marker set;
200 // the planning of the push it came from has already given the notice.
201 let notice: Option<String> = if environment_value(environment, STACKED_PUSH_MARKER).is_some() {
202 None
203 } else {
204 resolved.notice
205 };
206 let action: Action = plan_with_real_git(
207 arguments,
208 environment,
209 resolved.path,
210 global_clean,
211 &layout,
212 );
213 // `.as_deref()` lends the optional owned notice as an optional borrowed one.
214 return with_leading_notice(action, notice.as_deref());
215}
216
217/// What:
218/// Plan an invocation that is not a management command,
219/// once the real Git is known.
220/// `&GlobalLayout` borrows where the subcommand starts in the cleaned arguments.
221/// Why:
222/// `plan_invocation` adds the notice of a recorded fallback to whatever action this returns,
223/// so every ending of the planning (a forward,
224/// a stop,
225/// a refusal) carries it without repeating it.
226///
227/// In TS you'd write (pseudocode):
228/// ```ts
229/// function planWithRealGit(args: string[], environment: [string, string][], realGit: string, globalClean: string[], layout: GlobalLayout): Action;
230/// ```
231fn plan_with_real_git(
232 arguments: &[OsString],
233 environment: &[(OsString, OsString)],
234 real_git: PathBuf,
235 global_clean: Vec<OsString>,
236 layout: &GlobalLayout,
237) -> Action {
238 let overlay: Vec<(OsString, OsString)> =
239 child_environment_overlay(environment, real_git.as_path());
240 // What: Top-level `--version`, `-v`, `--help` and `-h` run Git as a child and add the announcement.
241 // Why: The layout is already known here, so every other invocation pays one enum comparison;
242 // the other queries keep the exact process replacement below.
243 //
244 // In TS you'd write (pseudocode):
245 // ```ts
246 // const query = announcedQuery(args, layout); if (query) return { kind: 'forward-announced', ... };
247 // ```
248 if let Some(query) = announced_query(global_clean.as_slice(), layout) {
249 return Action::ForwardAnnounced {
250 announcement: announcement(query, WRAPPER_VERSION, real_git.as_path()),
251 real_git,
252 arguments: global_clean,
253 overlay,
254 stderr: String::new(),
255 };
256 }
257 let stripped: StrippedInvocation = strip_wrapper_controls(arguments);
258 // The global options before the subcommand select the repository for every fact query.
259 let facts: GitFacts = git_facts(
260 real_git.as_path(),
261 &stripped.arguments[..stripped.layout.prefix_len],
262 overlay.as_slice(),
263 );
264 // `mut` lets the lifecycle cache facts and state what content policies can read.
265 let mut checks: ShippedChecks<GitFacts> = shipped_checks(
266 facts,
267 // `.clone()` copies the stripped arguments for the rule cores.
268 stripped.arguments.clone(),
269 LifecycleContent::None,
270 default_allowed_worktree_dirs(
271 environment,
272 // `.as_deref()` lends the optional owned path as an optional borrowed one.
273 home_directory(environment).as_deref(),
274 ),
275 );
276 // The forbidden-strings rules variable is read from this invocation's environment.
277 checks.scanner_settings.rules_variable = environment_value(environment, RULES_VARIABLE);
278 // `match` picks by variant and binds the fields each ending carries.
279 match run_wrapped_command(&stripped, environment, &mut checks) {
280 // `arguments: forwarded` binds the field under a new name.
281 WrappedOutcome::Forward {
282 arguments: forwarded,
283 stderr,
284 } => {
285 // The opt-out, Git's other query forms (`--exec-path` and so on), and the commands that need no repository
286 // (`version`, `help`, `init`, `clone`, user-level `config`) never settle a copy.
287 let copy_bypassed: bool = stripped.controls.skip_worktree_copy
288 || stripped.layout.outcome != GlobalOutcome::Command
289 || decide_require_root(forwarded.as_slice(), &[])
290 != RequireRootDecision::NeedsRepositoryRoot;
291 return stack_if_push(
292 forward_action(
293 real_git,
294 forwarded,
295 overlay,
296 stderr,
297 environment,
298 copy_bypassed,
299 &mut checks,
300 ),
301 environment,
302 );
303 }
304 WrappedOutcome::Exit { code, stderr } => {
305 return Action::Exit {
306 code,
307 // `String::new()` is empty owned text: wrapped commands report on standard error.
308 stdout: String::new(),
309 stderr,
310 };
311 }
312 }
313}
314
315/// What:
316/// Run one invocation with the real process facts and return its exit code.
317/// `i32` is a signed 32-bit integer,
318/// the type of process exit codes.
319/// Returns only when Git was not started;
320/// a forwarded command never returns.
321/// Why:
322/// This is everything the executable's `main` does;
323/// keeping it in the library
324/// lets the same code be measured by tests and mutation runs.
325///
326/// In TS you'd write (pseudocode):
327/// ```ts
328/// function runProcess(args: string[], environment: [string, string][]): number;
329/// ```
330pub fn run_process(arguments: &[OsString], environment: &[(OsString, OsString)]) -> i32 {
331 // From here on every real-Git start of this process runs with traditional hooks off.
332 crate::hooks_off::activate();
333 let action: Action = match process_resolution_inputs(environment) {
334 // A cli-git executable that a wrapper started as Git stops before it plans anything or starts anything.
335 Ok(inputs) => match refusal_when_started_as_git(environment, inputs.own_executable.as_path()) {
336 Some(refusal) => failure(refusal.as_str()),
337 None => plan_invocation(arguments, environment, &inputs),
338 },
339 Err(error) => failure(
340 format!(
341 "cannot determine this executable or the current directory, so real Git \
342 cannot be selected safely: {error}"
343 )
344 .as_str(),
345 ),
346 };
347 // `match` picks by variant and binds the fields each one carries.
348 match action {
349 Action::Forward {
350 real_git,
351 arguments: forwarded,
352 overlay,
353 stderr,
354 } => {
355 // Warning events reach the caller before Git's own output.
356 write_stream(&mut std::io::stderr(), stderr.as_str());
357 let error: std::io::Error = replace_process_with_real_git(
358 real_git.as_path(),
359 forwarded.as_slice(),
360 overlay.as_slice(),
361 );
362 write_stream(
363 &mut std::io::stderr(),
364 format!(
365 "cli-git: cannot start {}: {error}. Hint: if you moved or upgraded Git, check that the file exists and is executable with `ls -l {}`, \
366 then reinstall Git or the wrapper; no Git command ran.\n",
367 real_git.display(),
368 real_git.display()
369 )
370 .as_str(),
371 );
372 return ENGINE_FAILURE_EXIT_CODE;
373 }
374 Action::ForwardCoordinated { forward, stderr } => {
375 write_stream(&mut std::io::stderr(), stderr.as_str());
376 return run_coordinated_action(&forward);
377 }
378 Action::ForwardAnnounced {
379 real_git,
380 arguments: forwarded,
381 overlay,
382 announcement,
383 stderr,
384 } => {
385 write_stream(&mut std::io::stderr(), stderr.as_str());
386 return run_announced(
387 real_git.as_path(),
388 forwarded.as_slice(),
389 overlay.as_slice(),
390 announcement.as_str(),
391 );
392 }
393 Action::ForwardSettled {
394 real_git,
395 arguments: forwarded,
396 overlay,
397 copy,
398 stderr,
399 } => {
400 write_stream(&mut std::io::stderr(), stderr.as_str());
401 return finish_forwarded(run_forwarded_git(
402 real_git.as_path(),
403 forwarded.as_slice(),
404 overlay.as_slice(),
405 Some(&*copy),
406 ));
407 }
408 Action::ForwardPush { forward, stderr } => {
409 write_stream(&mut std::io::stderr(), stderr.as_str());
410 return run_stacked_push(&forward);
411 }
412 Action::Exit {
413 code,
414 stdout,
415 stderr,
416 } => {
417 // `&mut` lends each stream for writing.
418 write_stream(&mut std::io::stdout(), stdout.as_str());
419 write_stream(&mut std::io::stderr(), stderr.as_str());
420 return code;
421 }
422 }
423}
424
425/// What:
426/// Run Git as a child for an announced query,
427/// print the announcement when Git exited with status 0,
428/// and return Git's status.
429/// Why:
430/// The child shares the caller's streams, so Git's own answer is untouched and comes first;
431/// the announcement follows it only when Git answered.
432///
433/// In TS you'd write (pseudocode):
434/// ```ts
435/// const outcome = await runRealGit(realGit, args, overlay); if (outcome.exited === 0) print(text); return exitCode(outcome);
436/// ```
437fn run_announced(
438 real_git: &Path,
439 arguments: &[OsString],
440 overlay: &[(OsString, OsString)],
441 text: &str,
442) -> i32 {
443 match run_real_git(real_git, arguments, overlay) {
444 Ok(outcome) => {
445 write_stream(&mut std::io::stdout(), printable_after(outcome, text));
446 return exit_code(outcome);
447 }
448 Err(error) => {
449 write_stream(
450 &mut std::io::stderr(),
451 format!(
452 "cli-git: cannot start {}: {error}. Hint: if you moved or upgraded Git, check that the file exists and is executable with `ls -l {}`, \
453 then reinstall Git or the wrapper; no Git command ran.\n",
454 real_git.display(),
455 real_git.display()
456 )
457 .as_str(),
458 );
459 return ENGINE_FAILURE_EXIT_CODE;
460 }
461 }
462}
463
464/// What:
465/// The exit code of a forwarded run, after printing the text of a run that failed.
466/// Why:
467/// A settled run keeps the wrapper alive around Git, so the exit code returns here instead of replacing the process.
468///
469/// In TS you'd write (pseudocode):
470/// ```ts
471/// process.exitCode = outcome.exitCode;
472/// ```
473fn finish_forwarded(run: ForwardedRun) -> i32 {
474 // `match` on the ending: a status, or a failure with its own text.
475 match run {
476 ForwardedRun::Ran(code) => return code,
477 ForwardedRun::Failed { stderr, code } => {
478 write_stream(&mut std::io::stderr(), stderr.as_str());
479 return code;
480 }
481 }
482}
483
484/// What:
485/// Run a coordinated forward:
486/// print its failure,
487/// run Git under the landing lock,
488/// or replace the process when no coordination applies after all.
489/// Why:
490/// The lock must outlive Git,
491/// so only a run that takes no lock may replace the process.
492///
493/// In TS you'd write (pseudocode):
494/// ```ts
495/// const outcome = await forwardToRealGit({ ... }); process.exitCode = outcome.exitCode;
496/// ```
497fn run_coordinated_action(forward: &CoordinatedForward) -> i32 {
498 // A command that adds commits keeps the wrapper alive around Git, so the push can be handed over once it succeeded.
499 let watch: Option<TipWatch> = if adds_commits(&forward.command) { watch_forward(forward) } else { None };
500 let code: i32 = match run_coordinated_forward(forward) {
501 CoordinatedRun::Ran(code) => code,
502 CoordinatedRun::Failed { stderr, code } => {
503 write_stream(&mut std::io::stderr(), stderr.as_str());
504 code
505 }
506 CoordinatedRun::NotCoordinated if watch.is_some() => {
507 finish_forwarded(run_forwarded_git(forward.real_git.as_path(), forward.arguments.as_slice(), forward.overlay.as_slice(), None))
508 }
509 CoordinatedRun::NotCoordinated => {
510 let error: std::io::Error = replace_process_with_real_git(
511 forward.real_git.as_path(),
512 forward.arguments.as_slice(),
513 forward.overlay.as_slice(),
514 );
515 write_stream(
516 &mut std::io::stderr(),
517 format!("cli-git: cannot start {}: {error}\n", forward.real_git.display()).as_str(),
518 );
519 ENGINE_FAILURE_EXIT_CODE
520 }
521 };
522 if let Some(found) = watch.as_ref() {
523 if code == 0 {
524 hand_over_if_ahead(found, trigger_command(&forward.command).as_str(), &mut std::io::stderr());
525 } else {
526 // The watch holds the branch and its tip from before the command, which a reader needs to tell which merge failed.
527 record_decision(&Decision {
528 common_dir: Some(found.common_dir()),
529 origin: Some(found.origin()),
530 command: trigger_command(&forward.command).as_str(),
531 outcome: Outcome::Declined,
532 reason: "command-failed",
533 detail: format!("Git exited with status {code}; {} was at {} before the command", found.branch_ref(), found.tip_before()).as_str(),
534 branch: Some(found.branch_ref()),
535 });
536 }
537 }
538 return code;
539}
540
541/// Without file system readers for this platform, no push is handed over after a command that adds commits.
542#[cfg(not(unix))]
543struct TipWatch;
544
545/// Without file system readers for this platform, no branch is watched.
546#[cfg(not(unix))]
547fn watch_forward(_forward: &CoordinatedForward) -> Option<TipWatch> {
548 return None;
549}
550
551/// Without file system readers for this platform, nothing is handed over.
552#[cfg(not(unix))]
553fn hand_over_if_ahead(_watch: &TipWatch, _command: &str, _stderr: &mut dyn Write) {}
554
555/// What:
556/// Whether a resolved command adds commits to the branch that the owner wants pushed like a commit: a merge, a pull, a
557/// cherry-pick, a revert or an applied patch.
558/// Why:
559/// A rebase is left out, because a rebased branch would need a forced push; the tip check then also leaves out the
560/// rebase a pull runs.
561///
562/// In TS you'd write (pseudocode):
563/// ```ts
564/// function addsCommits(command: ResolvedGitCommand): boolean;
565/// ```
566fn adds_commits(command: &ResolvedCommand) -> bool {
567 return command.is_builtin() && matches!(command.word(), b"merge" | b"pull" | b"cherry-pick" | b"revert" | b"am");
568}
569
570/// The command word a trigger record names.
571///
572/// In TS you'd write (pseudocode):
573/// ```ts
574/// const word = command.word().toString();
575/// ```
576fn trigger_command(command: &ResolvedCommand) -> String {
577 return String::from_utf8_lossy(command.word()).into_owned();
578}
579
580/// The branch watch of a coordinated forward, started in the worktree the command runs in.
581/// A watch that cannot start leaves a record naming the step,
582/// in the common Git directory the wrapper already resolved.
583///
584/// In TS you'd write (pseudocode):
585/// ```ts
586/// function watchForward(forward: CoordinatedForward): TipWatch | undefined;
587/// ```
588#[cfg(unix)]
589fn watch_forward(forward: &CoordinatedForward) -> Option<TipWatch> {
590 let (root, common): (PathBuf, PathBuf) = match &forward.identity {
591 WorktreeIdentity::MainWorktree { worktree_root, common_dir, .. } | WorktreeIdentity::LinkedWorktree { worktree_root, common_dir, .. } => {
592 (worktree_root.clone(), common_dir.clone())
593 }
594 WorktreeIdentity::OutsideWorktree | WorktreeIdentity::BareRepository { .. } => return None,
595 };
596 return match try_watch_tip(root.as_path(), forward.real_git.as_path(), forward.overlay.as_slice(), forward.environment.as_slice()) {
597 Ok(watch) => Some(watch),
598 Err(declined) => {
599 // The watch could not start, so no branch is known; the worktree is.
600 let git_dir: PathBuf = match &forward.identity {
601 WorktreeIdentity::MainWorktree { git_dir, .. } | WorktreeIdentity::LinkedWorktree { git_dir, .. } => git_dir.clone(),
602 WorktreeIdentity::OutsideWorktree | WorktreeIdentity::BareRepository { .. } => common.clone(),
603 };
604 record_decision(&Decision {
605 common_dir: Some(common.as_path()),
606 origin: Some(Origin { worktree: root.as_path(), git_dir: git_dir.as_path() }),
607 command: trigger_command(&forward.command).as_str(),
608 outcome: Outcome::Declined,
609 reason: declined.reason,
610 detail: declined.detail.as_str(),
611 branch: None,
612 });
613 None
614 }
615 };
616}
617
618/// What:
619/// Choose how a forwarded command runs:
620/// plainly,
621/// under the landing lock when it writes the real index,
622/// and settled by the linked-worktree copy when it runs from a linked worktree or a bare repository
623/// (or creates a worktree from a main checkout whose configuration names the rules file).
624/// `copy_bypassed` is true for the wrapper's opt-out and for Git's query forms.
625/// Why:
626/// Only these commands need the wrapper to outlive Git or to hold a lock around it;
627/// everything else keeps the exact process replacement.
628///
629/// In TS you'd write (pseudocode):
630/// ```ts
631/// if (!isIndexWriter(command) && !copyApplies) forward(args); else await forwardToRealGit({ ..., unprovenOwnerTimeoutMs });
632/// ```
633fn forward_action(
634 real_git: PathBuf,
635 arguments: Vec<OsString>,
636 overlay: Vec<(OsString, OsString)>,
637 stderr: String,
638 environment: &[(OsString, OsString)],
639 copy_bypassed: bool,
640 checks: &mut ShippedChecks<GitFacts>,
641) -> Action {
642 let context: GitContext = GitContext {
643 real_git: real_git.clone(),
644 overlay: overlay.clone(),
645 global_prefix: Vec::new(),
646 // These queries send no input, so no scratch directory is needed.
647 common_dir: None,
648 };
649 let command: ResolvedCommand = resolve_forwarded_command(&context, arguments.as_slice());
650 // An alias value may carry its own `-c` options, and Git applies those after any option put before the alias word,
651 // so the real Git gets the expansion the wrapper already resolved: the hooks-off options then follow the alias's own.
652 let forwarded_arguments: Vec<OsString> = match command.route {
653 Route::Alias => command.arguments.clone(),
654 Route::Direct | Route::ShellAlias | Route::Unresolved => arguments,
655 };
656 let writes_index: bool = is_index_writer(&command);
657 if !writes_index && copy_bypassed {
658 return Action::Forward {
659 real_git,
660 arguments: forwarded_arguments,
661 overlay,
662 stderr,
663 };
664 }
665 let location: RepositoryLocation = match checks.facts.location() {
666 Ok(found) => found,
667 Err(message) => return failure(message.as_str()),
668 };
669 if location.identity == WorktreeIdentity::OutsideWorktree {
670 return Action::Forward {
671 real_git,
672 arguments: forwarded_arguments,
673 overlay,
674 stderr,
675 };
676 }
677 // An invalid configuration refuses an index writer; a main checkout's worktree copy reads none.
678 if configuration_needed(writes_index)
679 && let Err(error) = load_identity_config(&location.identity)
680 {
681 return Action::Exit {
682 code: ENGINE_FAILURE_EXIT_CODE,
683 stdout: String::new(),
684 stderr: config_invalid_event(&error),
685 };
686 }
687 let creates: bool = creates_worktrees(&command);
688 let add_plan: Option<AddPlan> = if creates { plan_worktree_add(&command) } else { None };
689 let destination: Option<PathBuf> = if creates { plan_copy_destination(&command) } else { None };
690 let remove_plan: Option<RemovePlan> = if command.word() == b"worktree" { plan_worktree_remove(&command) } else { None };
691 let serves_ladder: bool = add_plan.is_some() || remove_plan.is_some();
692 let copy: Option<CopyFacts> =
693 if !copy_bypassed && copy_applies_with(&location.identity, creates, serves_ladder) {
694 Some(CopyFacts {
695 identity: location.identity.clone(),
696 creates,
697 destination,
698 add_plan,
699 remove_plan,
700 })
701 } else {
702 None
703 };
704 if !writes_index {
705 let Some(facts) = copy else {
706 return Action::Forward {
707 real_git,
708 arguments: forwarded_arguments,
709 overlay,
710 stderr,
711 };
712 };
713 return Action::ForwardSettled {
714 real_git,
715 arguments: forwarded_arguments,
716 overlay,
717 copy: Box::new(facts),
718 stderr,
719 };
720 }
721 return Action::ForwardCoordinated {
722 forward: Box::new(CoordinatedForward {
723 real_git,
724 arguments: forwarded_arguments,
725 overlay,
726 identity: location.identity,
727 command,
728 environment: environment.to_vec(),
729 copy,
730 }),
731 stderr,
732 };
733}
734
735/// What:
736/// The plan of a `git worktree add` in a form the cheap-worktree ladder serves, read from the resolved command and the
737/// process's working directory.
738/// Why:
739/// The destination is relative to the directory Git starts in, which the global `-C` options move; an unreadable
740/// working directory or a form outside the table leaves the command to plain Git.
741///
742/// In TS you'd write (pseudocode):
743/// ```ts
744/// function planWorktreeAdd(command: ResolvedCommand): AddPlan | undefined;
745/// ```
746fn plan_worktree_add(command: &ResolvedCommand) -> Option<AddPlan> {
747 let current: PathBuf = std::env::current_dir().ok()?;
748 let start: PathBuf = start_directory(&command.arguments[..command.subcommand_index], current.as_path())?;
749 return parse_add_plan(command.arguments.as_slice(), command.subcommand_index, start.as_path());
750}
751
752/// What:
753/// The destination of a `git worktree add` in any form of Git's documented options, read from the resolved command and the
754/// process's working directory.
755/// Why:
756/// The ignored-state copy goes to the worktree this command created, which the command line names; a form the reading
757/// does not know leaves the destination unknown, and the copy then says it did not run.
758///
759/// In TS you'd write (pseudocode):
760/// ```ts
761/// function planCopyDestination(command: ResolvedCommand): string | undefined;
762/// ```
763fn plan_copy_destination(command: &ResolvedCommand) -> Option<PathBuf> {
764 let current: PathBuf = std::env::current_dir().ok()?;
765 let start: PathBuf = copy_start_directory(&command.arguments[..command.subcommand_index], current.as_path())?;
766 return parse_copy_destination(command.arguments.as_slice(), command.subcommand_index, start.as_path());
767}
768
769/// What:
770/// The plan of a plain `git worktree remove <directory>`, read from the resolved command and the process's working
771/// directory.
772/// Why:
773/// The directory is relative to where Git starts, which the global `-C` options move; anything else is Git's.
774///
775/// In TS you'd write (pseudocode):
776/// ```ts
777/// function planWorktreeRemove(command: ResolvedCommand): RemovePlan | undefined;
778/// ```
779fn plan_worktree_remove(command: &ResolvedCommand) -> Option<RemovePlan> {
780 let current: PathBuf = std::env::current_dir().ok()?;
781 let start: PathBuf = start_directory(&command.arguments[..command.subcommand_index], current.as_path())?;
782 return parse_remove_plan(command.arguments.as_slice(), command.subcommand_index, start.as_path());
783}
784
785/// What:
786/// Whether `command` is a push that must upload LFS objects first, and the configuration warning for configured
787/// external hooks, when the same read shows any, as empty text otherwise.
788/// A dry-run push reads the configuration too, for the warning, but uploads nothing.
789/// Why:
790/// A push that sends nothing (`--dry-run`) needs no upload, and a repository without LFS keys needs none either.
791/// One configuration read answers it, made with the invocation's own global options; when Git cannot list its
792/// configuration the hook is added anyway, because it then decides with Git's own answers.
793/// The warning costs no read of its own: a push is the one forwarded command that already holds the snapshot.
794///
795/// In TS you'd write (pseudocode):
796/// ```ts
797/// function needsLfsUpload(command, realGit, overlay): [boolean, string, settings];
798/// ```
799fn needs_lfs_upload(
800 command: &ResolvedCommand,
801 real_git: &Path,
802 overlay: &[(OsString, OsString)],
803) -> (bool, String, Option<Vec<(String, String)>>) {
804 if command.word() != b"push" {
805 return (false, String::new(), None);
806 }
807 let dry_run: bool = parse_push_region(command.region(), &[]).is_ok_and(|region| {
808 return region.dry_run;
809 });
810 let context: GitContext = GitContext {
811 real_git: real_git.to_path_buf(),
812 overlay: overlay.to_vec(),
813 global_prefix: command.arguments[..command.subcommand_index].to_vec(),
814 common_dir: None,
815 };
816 let snapshot: Option<ConfigSnapshot> = match std::env::current_dir() {
817 Ok(directory) => read_config_snapshot(&context, directory.as_path()),
818 Err(_) => None,
819 };
820 let warning: String = external_hooks_warning(snapshot.as_ref()).unwrap_or_default();
821 if dry_run {
822 return (false, warning, None);
823 }
824 return match snapshot {
825 Some(found) => {
826 let uses: bool = uses_lfs(&found);
827 let settings: Option<Vec<(String, String)>> = uses.then(|| {
828 return settings_from_snapshot(&found);
829 });
830 (uses, warning, settings)
831 }
832 None => (true, warning, None),
833 };
834}
835
836/// What:
837/// Turn the forward of a plain push that may be integrated into a run that can integrate it,
838/// and put the Git LFS upload in front of every push that publishes.
839/// Why:
840/// A push that lost a race to another session cannot succeed again, and the wrapper can only look at its status
841/// while it is alive;
842/// the stacked push itself carries `STACKED_PUSH_MARKER`, so it is forwarded as it is and never stacks again.
843/// A real push in a repository with LFS keys also runs as a child that the Git LFS upload (`lfs_upload`) precedes,
844/// the stacked push too, so the parent can explain a failed upload;
845/// the integration step's own dry-run queries never upload.
846/// Every other action is returned unchanged.
847///
848/// In TS you'd write (pseudocode):
849/// ```ts
850/// function stackIfPush(action: Action, environment): Action;
851/// ```
852fn stack_if_push(action: Action, environment: &[(OsString, OsString)]) -> Action {
853 let (real_git, arguments, overlay, copy, stderr) = match action {
854 Action::Forward {
855 real_git,
856 arguments,
857 overlay,
858 stderr,
859 } => (real_git, arguments, overlay, None, stderr),
860 Action::ForwardSettled {
861 real_git,
862 arguments,
863 overlay,
864 copy,
865 stderr,
866 } => (real_git, arguments, overlay, Some(copy), stderr),
867 other => return other,
868 };
869 let context: GitContext = GitContext {
870 real_git: real_git.clone(),
871 overlay: overlay.clone(),
872 global_prefix: Vec::new(),
873 // These queries send no input, so no scratch directory is needed.
874 common_dir: None,
875 };
876 let command: ResolvedCommand = resolve_forwarded_command(&context, arguments.as_slice());
877 let (lfs, hook_warning, lfs_settings): (bool, String, Option<Vec<(String, String)>>) =
878 needs_lfs_upload(&command, real_git.as_path(), overlay.as_slice());
879 let stderr_with_warning: String = format!("{stderr}{hook_warning}");
880 let stacked_already: bool = environment_value(environment, STACKED_PUSH_MARKER).is_some();
881 let stack: bool =
882 !stacked_already && command.word() == b"push" && stackable_option_tokens(command.region()).is_some();
883 // A push that uploads LFS objects runs as a child like a stackable one: the parent that owns the push is the one
884 // that explains a failed upload, and no wrapper is started for it.
885 if stack || lfs {
886 return Action::ForwardPush {
887 forward: Box::new(PushForward {
888 real_git,
889 subcommand_index: command.subcommand_index,
890 arguments: command.arguments,
891 overlay,
892 copy,
893 environment: environment.to_vec(),
894 lfs,
895 lfs_settings,
896 stack,
897 }),
898 stderr: stderr_with_warning,
899 };
900 }
901 // A plain forwarded fetch or push becomes Git itself, so the reference locks of a killed Git are judged here, before.
902 let lock_notice: String = if is_covered(command.word()) {
903 forwarded_lock_notice(command.word(), &command.arguments[..command.subcommand_index], environment, &Judge::here())
904 } else {
905 String::new()
906 };
907 let stderr_with_locks: String = format!("{stderr_with_warning}{lock_notice}");
908 let forwarded: Vec<OsString> = arguments;
909 return match copy {
910 Some(facts) => Action::ForwardSettled {
911 real_git,
912 arguments: forwarded,
913 overlay,
914 copy: facts,
915 stderr: stderr_with_locks,
916 },
917 None => Action::Forward {
918 real_git,
919 arguments: forwarded,
920 overlay,
921 stderr: stderr_with_locks,
922 },
923 };
924}
925
926/// What:
927/// Whether the forwarded command needs the repository configuration.
928/// Why:
929/// An index writer reads its patience from it; the copy of a main checkout's ignored state reads none,
930/// so a command that only creates a worktree loads nothing.
931///
932/// In TS you'd write (pseudocode):
933/// ```ts
934/// isIndexWriter(command)
935/// ```
936fn configuration_needed(writes_index: bool) -> bool {
937 return writes_index;
938}
939
940/// What:
941/// Write text to an output stream,
942/// ignoring a closed stream.
943/// `&mut dyn Write` lends "any writable stream" (`dyn` means the concrete type is
944/// chosen at run time,
945/// like a TS interface value).
946/// Why:
947/// The exit code already carries the result;
948/// a reader that went away must not
949/// turn a diagnostic into a panic.
950///
951/// In TS you'd write (pseudocode):
952/// ```ts
953/// function writeStream(stream: Writable, text: string): void { try { stream.write(text); } catch {} }
954/// ```
955fn write_stream(stream: &mut dyn Write, text: &str) {
956 // What: `let _ = ...` deliberately discards the write's `Result`.
957 // Why: There is nowhere left to report a failure to write a diagnostic.
958 //
959 // In TS you'd write (pseudocode):
960 // ```ts
961 // try { stream.write(text); } catch { /* reader closed */ }
962 // ```
963 let _ = stream.write_all(text.as_bytes());
964}
965
966/// Plans of the announced queries,
967/// `--version`, `-v`, `--help` and `-h`, against the queries that stay exact.
968#[cfg(test)]
969#[path = "entry_announced_tests.rs"]
970mod announced_tests;
971
972/// Plans whose real Git is the one the commit service's unit records because `PATH` offers none.
973#[cfg(test)]
974#[path = "entry_fallback_tests.rs"]
975mod fallback_tests;
976
977/// Stop controls for a looping,
978/// missing or uninterpretable real Git.
979#[cfg(test)]
980#[path = "entry_stop_tests.rs"]
981mod stop_tests;
982
983/// Decision controls and the shared fixtures stay out of the release executable.
984#[cfg(test)]
985#[path = "entry_tests.rs"]
986mod tests;