Skip to main content

git_policy_cli/invocation/
wrapped_command.rs

1//! What:
2//!  Decide what one wrapped Git command does once real Git is known:
3//!  forward it,
4//!       possibly rewritten,
5//!  or stop with policy events or a refusal.
6//! Why:
7//!  This is the whole lifecycle of a forwarded command in one place and in the
8//!      installed wrapper's order:
9//!  unported work is refused,
10//!  configuration is loaded only
11//!      for commands that need it,
12//!  the policy pass runs,
13//!  and a real push is stopped until
14//!      its own lifecycle exists.
15//!  A read-only command never reads configuration and asks
16//!      Git at most where it runs.
17//!
18//! In TS you'd write (pseudocode):
19//! ```ts
20//! // const outcome = await runWrappedCommand(stripped, environment, checks);
21//! ```
22
23/// What:
24///  `use` brings names from sibling files into this file;
25///  `super::` means "the parent
26///       module",
27///  where every sibling file of this crate is declared.
28/// Why:
29///   The lifecycle joins control stripping,
30///  the frontier,
31///  configuration and the pass.
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// import { runPolicyPass } from './policy_pass.ts';
36/// ```
37use crate::action::ENGINE_FAILURE_EXIT_CODE;
38use crate::auto_push_single_flight::PushGit;
39use crate::child_environment::environment_with_overlay;
40/// `git add` asks for what it would stage.
41use crate::candidate_prediction::CandidateRequest;
42use crate::command_options::OptionError;
43use crate::command_push::{PushRegion, parse_push_region};
44use crate::config_loading::{ConfigLoading, classify_config_loading};
45use crate::command_commit::{CommitRegion, parse_commit_region};
46use crate::commit_command::{CommitCommand, run_commit_command};
47use crate::config_schema::{CliGitConfig, PolicyConfig};
48use crate::diagnostics::EngineFailureCode;
49use crate::discard_audit::{AuditOutcome, record_audit};
50use crate::global_arguments::GlobalOutcome;
51use crate::invocation_config::{config_invalid_event, load_identity_config};
52use crate::manual_push_cause::ManualPushCause;
53use crate::manual_push_lifecycle::manual_push_scope;
54use crate::policy_checks::ShippedChecks;
55/// What the command offers its content policies.
56use crate::policy_content::LifecycleContent;
57use crate::policy_engine::{
58    StageEnd, StageRequest, StageResult, any_policy_applies, pass_exit_code, run_policy_stage,
59};
60use crate::event_hints::manual_push_failure_hint;
61use crate::policy_events::{EngineFailureHint, PolicyEvent, render_policy_events};
62use crate::policy_pass::{PassResult, policies_of_kind, run_policy_pass};
63use crate::policy_registry::PolicyId;
64use crate::landed_summary::short_name;
65use crate::push_failure_notice::{ORPHAN_NOTICE_DIRECTORY, is_machine_read, notice_directory, take_orphaned_failures, take_push_failures_for};
66use crate::worktree_removal_rescue::{settle_removals, snapshot_linked_worktrees};
67use crate::policy_trigger::Trigger;
68use crate::rule_linked_worktree::{LinkedWorktreeDecision, decide_linked_worktree};
69use crate::repository_facts::RepositoryFacts;
70use crate::repository_location::RepositoryLocation;
71/// Startup recovery as a command step.
72use crate::startup_gate::{Gate, recover_for_command};
73/// A valid preparation lease marks a command started by a hook of a live transaction.
74use crate::unported::{Unported, unported_from_unavailable, unported_notice};
75use crate::worktree_identity::worktree_root;
76use crate::wrapper_controls::is_escaped;
77use crate::wrapper_invocation::{StrippedInvocation, command_region, command_word};
78/// What:
79///  `OsString` is owned operating-system text of raw bytes.
80///  Sibling the reader might
81///       expect:
82///  `String`,
83///  which must be valid UTF-8.
84/// Why:
85///   Arguments and environment values are never decoded.
86///
87/// In TS you'd write (pseudocode):
88/// ```ts
89/// // string, but byte-preserving.
90/// ```
91use std::ffi::OsString;
92use std::io::Write;
93/// Paths of the probe's working directory.
94use std::path::{Path, PathBuf};
95
96/// What:
97///  How the lifecycle of one wrapped command ended.
98///  An `enum` is a closed set of named
99///       alternatives;
100///  each carries the values its ending needs.
101///  `#[derive(...)]` asks the
102///       compiler to generate copying,
103///  debug printing and `==`.
104/// Why:
105///   The caller either becomes Git with these arguments or exits with this code;
106///       both may first print event lines on standard error.
107///
108/// In TS you'd write (pseudocode):
109/// ```ts
110/// type WrappedOutcome = { kind: 'forward'; args: string[]; stderr: string } | { kind: 'exit'; code: number; stderr: string };
111/// ```
112#[derive(Clone, Debug, Eq, PartialEq)]
113pub enum WrappedOutcome {
114    /// Run real Git with these arguments.
115    Forward {
116        /// The arguments for Git:
117        ///  no wrapper control,
118        ///  fixed transforms applied.
119        arguments: Vec<OsString>,
120        /// Complete event lines for standard error,
121        ///  written before Git starts.
122        stderr: String,
123    },
124    /// Stop without running Git.
125    Exit {
126        /// Process exit code:
127        ///  1 for a policy rejection,
128        ///  2 for a failure or a refusal.
129        code: i32,
130        /// Complete text for standard error.
131        stderr: String,
132    },
133}
134
135/// What:
136///  The ending for a command that needs unported work:
137///  events so far,
138///  then the notice.
139///       `String` is the owned event text;
140///  `&Unported` borrows the reason;
141///  `&str` borrows
142///       the command word.
143/// Why:
144///   Every refusal exits 2,
145///  whether it was found before or inside the policy pass.
146///
147/// In TS you'd write (pseudocode):
148/// ```ts
149/// function refused(events: string, what: Unported, command: string): WrappedOutcome;
150/// ```
151fn refused(events: String, what: &Unported, command: &str) -> WrappedOutcome {
152    // `mut` allows appending the notice after the events.
153    let mut stderr: String = events;
154    // `.as_str()` lends the owned notice as borrowed text.
155    stderr.push_str(unported_notice(what, command).as_str());
156    return WrappedOutcome::Exit {
157        code: ENGINE_FAILURE_EXIT_CODE,
158        stderr,
159    };
160}
161
162/// What:
163///  The ending of a stage that did not let the command proceed,
164///  or nothing when it
165///       did.
166///  `&[PolicyEvent]` borrows every event so far;
167///  `Option<WrappedOutcome>` is "an
168///       ending or nothing".
169/// Why:
170///   The pre-forward pass and the push gate end the same way:
171///  an unavailable stage is
172///       a refusal,
173///  a failed one exits 2,
174///  and an error finding exits 1,
175///  each after the
176///       events gathered so far.
177///
178/// In TS you'd write (pseudocode):
179/// ```ts
180/// function stageEnding(events: PolicyEvent[], end: StageEnd, command: string): WrappedOutcome | undefined;
181/// ```
182fn stage_ending(events: &[PolicyEvent], end: StageEnd, command: &str) -> Option<WrappedOutcome> {
183    let rendered: String = render_policy_events(0, events);
184    // `if let StageEnd::Unavailable(x) = end` runs only for that ending and binds what it carries.
185    if let StageEnd::Unavailable(unavailable) = end {
186        // `Some(x)` is the "present" case of `Option`.
187        return Some(refused(
188            rendered,
189            &unported_from_unavailable(unavailable),
190            command,
191        ));
192    }
193    let code: i32 = pass_exit_code(events, end);
194    if code == 0 {
195        // `None` is the "absent" case: the command may proceed.
196        return None;
197    }
198    return Some(WrappedOutcome::Exit {
199        code,
200        stderr: rendered,
201    });
202}
203
204/// What:
205///  Whether Git's own option table reads the tokens after `push` as a dry run.
206///       `&[OsString]` borrows those tokens,
207///  already free of wrapper controls.
208/// Why:
209///   Only a dry run publishes nothing.
210///  A region the table refuses is not known to be
211///       a dry run,
212///  so it is treated as a real push.
213///
214/// In TS you'd write (pseudocode):
215/// ```ts
216/// function pushIsDryRun(region: string[]): boolean { try { return parsePushRegion(region).isDryRun; } catch { return false; } }
217/// ```
218fn push_is_dry_run(region: &[OsString]) -> bool {
219    // `&[]` is an empty list of wrapper flags: they were removed before this point.
220    let parsed: Result<PushRegion, OptionError> = parse_push_region(region, &[]);
221    // `match` unpacks the `Result`: `Ok` carries the facts, `Err(_)` ignores the refusal.
222    match parsed {
223        Ok(found) => return found.dry_run,
224        Err(_) => return false,
225    }
226}
227
228/// What:
229///  Every shipped policy in registry order:
230///  the built-in ones,
231///  then the optional ones.
232/// Why:
233///   The push gate asks all of them at once,
234///  as the installed wrapper does.
235///
236/// In TS you'd write (pseudocode):
237/// ```ts
238/// const everyPolicy = [...builtInPolicies, ...optionalPolicies];
239/// ```
240fn every_policy() -> Vec<PolicyId> {
241    // `mut` allows appending the second kind.
242    let mut policies: Vec<PolicyId> = policies_of_kind(false);
243    policies.extend(policies_of_kind(true));
244    return policies;
245}
246
247/// What:
248///  Prepare a command that loads configuration:
249///  recover dead transactions,
250///  read the
251///       configuration of the worktree Git reports,
252///  and say what a content policy may
253///       read.
254///  Returns the effective policy settings,
255///  or the ending that stops the command.
256///       `Result<PolicyConfig, WrappedOutcome>` is "settings,
257///  or an ending".
258/// Why:
259///   The order is the installed wrapper's:
260///  dead transactions are recovered before
261///       configuration,
262///  and a commit is refused only after its configuration was accepted,
263///       so an invalid file is always reported as such.
264///
265/// In TS you'd write (pseudocode):
266/// ```ts
267/// async function prepareGuardedCommand(stripped, environment, checks): Promise<PolicyConfig>; // throws the ending
268/// ```
269fn prepare_guarded_command<F: RepositoryFacts>(
270    stripped: &StrippedInvocation,
271    checks: &mut ShippedChecks<F>,
272) -> Result<CliGitConfig, WrappedOutcome> {
273    // `match` unpacks the location answer; a failure to ask Git stops the command.
274    let location: RepositoryLocation = match checks.facts.location() {
275        Ok(found) => found,
276        Err(message) => {
277            return Err(WrappedOutcome::Exit {
278                code: ENGINE_FAILURE_EXIT_CODE,
279                // `format!` builds the owned, line-terminated text.
280                stderr: format!("cli-git: {message}\n"),
281            });
282        }
283    };
284    // An isolated merge whose process died is settled before configuration loads, so the checks that follow never read
285    // its half-finished checkout.
286    match recover_for_command(&checks.facts.transaction_context(), &location.identity) {
287        Gate::Clear => {}
288        Gate::Stop(stderr) => {
289            return Err(WrappedOutcome::Exit {
290                code: ENGINE_FAILURE_EXIT_CODE,
291                stderr,
292            });
293        }
294    }
295    let config: CliGitConfig = match load_identity_config(&location.identity) {
296        Ok(loaded) => loaded.config,
297        Err(error) => {
298            return Err(WrappedOutcome::Exit {
299                code: ENGINE_FAILURE_EXIT_CODE,
300                stderr: config_invalid_event(&error),
301            });
302        }
303    };
304    // `.clone()` copies the scanner and Markdown options the configuration chose.
305    checks.scanner_settings.options = config.policies.forbidden_strings.clone();
306    checks.markdown.options = config.policies.markdown_autofix.clone();
307    checks.discarded_limits = config.discarded;
308    // Only `git add` inside a worktree has content for a policy to read before Git runs:
309    // what it would stage, predicted when a content policy first reads it.
310    if command_word(stripped) == b"add" && worktree_root(&location.identity).is_some() {
311        checks.candidates = LifecycleContent::Requested(CandidateRequest::Add(
312            // `.to_vec()` copies the arguments after `add` for the prediction.
313            command_region(stripped).to_vec(),
314        ));
315    }
316    // `Ok(x)` is the success case.
317    return Ok(config);
318}
319
320/// What:
321///  Run a real `git commit` through the commit transaction, or nothing when the command is anything else.
322///  `Option<WrappedOutcome>` is "the ending, or nothing to say".
323/// Why:
324///   Every non-dry-run commit goes through the transaction instead of being forwarded, because native `git commit`
325///   keeps `index.lock` on disk through hooks and the editor. A region Git's table refuses is not known to be a
326///   dry run, so it is treated as a real commit and Git itself rejects it.
327///
328/// In TS you'd write (pseudocode):
329/// ```ts
330/// const transaction = willShortCircuit ? NOT_APPLICABLE : await runCommitTransactionBoundary({ args, gitPath, policyOptions, concurrency });
331/// ```
332fn run_commit_if_real<F: RepositoryFacts>(
333    stripped: &StrippedInvocation,
334    environment: &[(OsString, OsString)],
335    checks: &mut ShippedChecks<F>,
336    config: &CliGitConfig,
337) -> Option<WrappedOutcome> {
338    if command_word(stripped) != b"commit" {
339        return None;
340    }
341    let region: CommitRegion = match parse_commit_region(command_region(stripped), &[]) {
342        Ok(found) => found,
343        Err(_) => return None,
344    };
345    if region.dry_run {
346        return None;
347    }
348    let context = checks.facts.transaction_context();
349    let ending = run_commit_command(
350        &CommitCommand {
351            context: &context,
352            environment,
353            arguments: stripped.arguments.as_slice(),
354            subcommand_index: stripped.layout.prefix_len,
355            region: &region,
356            config,
357            controls: &stripped.controls,
358        },
359        checks,
360    );
361    return Some(WrappedOutcome::Exit {
362        code: ending.code,
363        stderr: String::new(),
364    });
365}
366
367/// What:
368///  Record a guarded command form that runs because the invocation carries the escape for the linked-worktree-only
369///  policy.
370/// Why:
371///   The policy never runs under its escape, so nothing else would say that a discarding form was deliberately let
372///  through; a later loss must be attributable to the escape.
373///
374/// In TS you'd write (pseudocode):
375/// ```ts
376/// recordEscapeUse(stripped, checks);
377/// ```
378fn record_escape_use<F: RepositoryFacts>(stripped: &StrippedInvocation, checks: &mut ShippedChecks<F>) {
379    if !is_escaped(&stripped.controls, PolicyId::LinkedWorktreeOnly) {
380        return;
381    }
382    let command = match decide_linked_worktree(stripped.arguments.as_slice()) {
383        LinkedWorktreeDecision::Pass => return,
384        LinkedWorktreeDecision::NeedsEffectiveTarget(command) | LinkedWorktreeDecision::WhenDirty(command) => command,
385    };
386    if let Ok(location) = checks.facts.location() {
387        record_audit(&location.identity, command, AuditOutcome::Hatch, stripped.arguments.as_slice(), None);
388    }
389}
390
391/// What:
392///  Recover before a read-only command that already asked Git for the repository location;
393///       a live transaction does not stop it,
394///  a legacy directory or a recovery failure does.
395///       `Option<WrappedOutcome>` is "the ending,
396///  or nothing to say".
397/// Why:
398///   The incumbent recovers before read-only commands too,
399///  reusing the identity it already
400///       resolved;
401///  a command that never asked for the location starts no extra work.
402///
403/// In TS you'd write (pseudocode):
404/// ```ts
405/// if (configFreeIdentity !== undefined) await recoverCommitTransaction({ args, gitPath, identity: configFreeIdentity });
406/// ```
407fn recover_before_read_only<F: RepositoryFacts>(
408    checks: &ShippedChecks<F>,
409) -> Option<WrappedOutcome> {
410    let Some(Ok(location)) = checks.facts.asked_location() else {
411        return None;
412    };
413    match recover_for_command(&checks.facts.transaction_context(), &location.identity) {
414        Gate::Clear => return None,
415        Gate::Stop(stderr) => {
416            return Some(WrappedOutcome::Exit {
417                code: ENGINE_FAILURE_EXIT_CODE,
418                stderr,
419            });
420        }
421    }
422}
423
424/// What:
425///  Shows, on standard error, each backup push failure the service recorded since one was last shown and that belongs to this
426///  worktree, before the command runs.
427/// Why:
428///   The service pushes after a commit returned, so the command that made the commit cannot print the failure; the next
429///  command in the worktree whose commit it was does, because the records live under that worktree's own Git directory.
430///  A command whose output a program reads shows nothing and takes
431///  nothing, and neither does a command run where the repository is not found without starting Git. When nothing
432///  failed the check is one directory read.
433///
434/// In TS you'd write (pseudocode):
435/// ```ts
436/// if (!isMachineRead(command)) for (const text of takePushFailures(gitDir)) stderr.write(text + '\n');
437/// ```
438fn show_push_failures<F: RepositoryFacts>(stripped: &StrippedInvocation, checks: &mut ShippedChecks<F>) {
439    if is_machine_read(command_word(stripped), command_region(stripped)) {
440        return;
441    }
442    // The records are the worktree's own, so a notice shows in the worktree whose push failed.
443    let Some(git_dir) = checks.facts.known_git_dir() else { return };
444    let common_dir: PathBuf = checks.facts.known_common_dir().unwrap_or_else(|| { return git_dir.clone(); });
445    // A worktree Git removed took its Git directory along; what it held is shown to whoever asks next. What could not be kept
446    //  for the person is said here, each time, until it can be.
447    for problem in settle_removals(common_dir.as_path()) {
448        // A closed terminal is not a reason to stop the command the person asked for.
449        let _written = writeln!(std::io::stderr(), "{problem}");
450    }
451    // Nothing failed: two directory checks, and no question to Git.
452    let own: bool = notice_directory(git_dir.as_path()).is_dir();
453    let orphaned: bool = common_dir.join(ORPHAN_NOTICE_DIRECTORY).is_dir();
454    if !own && !orphaned {
455        return;
456    }
457    let context = checks.facts.transaction_context();
458    let cwd: PathBuf = std::env::current_dir().unwrap_or_else(|_| { return git_dir.clone(); });
459    let objects: PathBuf = common_dir.join("objects");
460    // The name Git gives the commit in this repository as it is now, asked of Git once per shown notice.
461    let abbreviate = |commit: &str| -> String { return short_name(&context, cwd.as_path(), objects.as_path(), None, commit); };
462    let mut texts: Vec<String> = take_push_failures_for(git_dir.as_path(), None, &abbreviate);
463    texts.extend(take_orphaned_failures(common_dir.as_path(), &abbreviate));
464    for text in texts {
465        // A closed terminal is not a reason to stop the command the person asked for.
466        let _written = writeln!(std::io::stderr(), "{text}");
467    }
468}
469
470/// What:
471///  Before `git worktree remove` or `git worktree prune`, copies the records of the linked worktrees into snapshots that
472///  survive the removal.
473/// Why:
474///   Git deletes a removed worktree's Git directory, and with it the failed pushes nobody was told about and the pushes the
475///  service had not run yet; the process is handed to Git, so only a copy made first can keep them. Which worktrees go is
476///  decided later, from whether each still exists (`worktree_removal_rescue.rs`).
477///
478/// In TS you'd write (pseudocode):
479/// ```ts
480/// if (command === 'worktree' && ['remove', 'prune'].includes(region[0])) await snapshotLinkedWorktrees(commonDir);
481/// ```
482fn snapshot_before_worktree_removal<F: RepositoryFacts>(stripped: &StrippedInvocation, checks: &mut ShippedChecks<F>) {
483    if command_word(stripped) != b"worktree" {
484        return;
485    }
486    let removes: bool = command_region(stripped).first().is_some_and(|word| { return matches!(word.as_encoded_bytes(), b"remove" | b"prune"); });
487    if !removes {
488        return;
489    }
490    if let Some(common_dir) = checks.facts.known_common_dir() {
491        // A record that could not be saved is lost with the worktree, so the person is told before Git runs.
492        for problem in snapshot_linked_worktrees(common_dir.as_path()) {
493            // A closed terminal is not a reason to stop the command the person asked for.
494            let _written = writeln!(std::io::stderr(), "{problem}");
495        }
496    }
497}
498
499/// What:
500///  Run the lifecycle of one wrapped command.
501///  `&StrippedInvocation` borrows the
502///       invocation without its wrapper controls;
503///  `&mut ShippedChecks<F>` lends the shipped
504///       policies and their facts provider `F` for writing.
505/// Why:
506///   A command Git does not run as a subcommand is forwarded as it is.
507///  A read-only
508///       command skips configuration and every refusal check,
509///  and uses the default
510///       settings.
511///  Every other command is prepared first.
512///  Then one pre-forward pass runs,
513///       and a real push must also clear the manual-push gate.
514///
515/// In TS you'd write (pseudocode):
516/// ```ts
517/// async function runWrappedCommand(stripped, environment, checks): Promise<WrappedOutcome>;
518/// ```
519pub fn run_wrapped_command<F: RepositoryFacts>(
520    stripped: &StrippedInvocation,
521    environment: &[(OsString, OsString)],
522    checks: &mut ShippedChecks<F>,
523) -> WrappedOutcome {
524    if stripped.layout.outcome != GlobalOutcome::Command {
525        return WrappedOutcome::Forward {
526            // `.clone()` copies the arguments for the caller.
527            arguments: stripped.arguments.clone(),
528            stderr: String::new(),
529        };
530    }
531    // `String::from_utf8_lossy` renders the command word for messages, replacing bytes that
532    // are not UTF-8; `.into_owned()` makes it owned text.
533    let command: String = String::from_utf8_lossy(command_word(stripped)).into_owned();
534    // `.as_slice()` lends an owned list as a borrowed view.
535    let skips_config: bool =
536        classify_config_loading(stripped.arguments.as_slice()) == ConfigLoading::Skip;
537    let loaded: Option<CliGitConfig> = if skips_config {
538        None
539    } else {
540        match prepare_guarded_command(stripped, checks) {
541            Ok(found) => Some(found),
542            Err(ending) => return ending,
543        }
544    };
545    // After the configuration step, so a command that had Git answer the repository location shows from that answer, and
546    //  before the commit, so a failure shows ahead of the commit's own output.
547    show_push_failures(stripped, checks);
548    snapshot_before_worktree_removal(stripped, checks);
549    if let Some(config) = &loaded
550        && let Some(ending) = run_commit_if_real(stripped, environment, checks, config)
551    {
552        return ending;
553    }
554    let policies: PolicyConfig = match loaded {
555        Some(config) => config.policies,
556        None => PolicyConfig::defaults(),
557    };
558    record_escape_use(stripped, checks);
559    let request: StageRequest = StageRequest {
560        trigger: Trigger::PreForward,
561        config: policies,
562        controls: stripped.controls.clone(),
563        // `Vec::<PolicyId>::new()` is an empty owned list: a wrapped command selects every policy.
564        selected: Vec::<PolicyId>::new(),
565    };
566    let pass: PassResult = run_policy_pass(&request, checks);
567    if let Some(ending) = stage_ending(pass.events.as_slice(), pass.end, command.as_str()) {
568        return ending;
569    }
570    if skips_config
571        && let Some(ending) = recover_before_read_only(checks)
572    {
573        return ending;
574    }
575    // `mut` allows the push gate to append its events.
576    let mut events: Vec<PolicyEvent> = pass.events;
577    if command_word(stripped) == b"push" && !push_is_dry_run(command_region(stripped)) {
578        let gate_request: StageRequest = StageRequest {
579            trigger: Trigger::ManualPush,
580            config: request.config,
581            controls: request.controls,
582            selected: request.selected,
583        };
584        let gated: Vec<PolicyId> = every_policy();
585        if any_policy_applies(&gate_request, gated.as_slice()) {
586            // The push's scope is discovered before any policy reads content; a discovery failure refuses the push.
587            if let Err(failure) = prepare_manual_push(checks, environment, command_region(stripped)) {
588                // Git refused the push itself: nothing could be published, and Git's own words and status are the answer.
589                if let Some(refusal) = failure.refusal {
590                    return WrappedOutcome::Exit {
591                        code: refusal.code,
592                        stderr: refusal.stderr,
593                    };
594                }
595                events.push(PolicyEvent::EngineFailure {
596                    code: EngineFailureCode::ContentUnavailable,
597                    hint: EngineFailureHint::Chosen(manual_push_failure_hint(failure.cause)),
598                    message: failure.message,
599                    trigger: Some(Trigger::ManualPush),
600                    policy: None,
601                    path: None,
602                });
603                return WrappedOutcome::Exit {
604                    code: ENGINE_FAILURE_EXIT_CODE,
605                    stderr: render_policy_events(0, events.as_slice()),
606                };
607            }
608            let gate: StageResult = run_policy_stage(&gate_request, gated.as_slice(), checks);
609            // `.extend(list)` moves every item of `list` onto the end.
610            events.extend(gate.events);
611            if let Some(ending) = stage_ending(events.as_slice(), gate.end, command.as_str()) {
612                return ending;
613            }
614        }
615    }
616    // The notices come first: they say what was saved before Git changes anything.
617    return WrappedOutcome::Forward {
618        arguments: pass.arguments,
619        stderr: format!("{}{}", checks.notices.concat(), render_policy_events(0, events.as_slice())),
620    };
621}
622
623/// Lifecycle order,
624///  fact use and every ending stay out of the release executable.
625/// What:
626///  Discover the scope of a manual push and hand it to the manual-push stage as its candidates.
627/// Why:
628///   The stage reads only what the push publishes, and the discovery runs against the repository the
629///  command targets, with the invocation's global options and environment.
630///
631/// In TS you'd write (pseudocode):
632/// ```ts
633/// const scope = await manualPushScope({ gitPath, cwd, args }); // then the manual-push stage reads it
634/// ```
635fn prepare_manual_push<F: RepositoryFacts>(
636    checks: &mut ShippedChecks<F>,
637    environment: &[(OsString, OsString)],
638    post_subcommand: &[OsString],
639) -> Result<(), crate::manual_push_lifecycle::ManualPushFailure> {
640    let worktree: Option<PathBuf> = match checks.facts.location() {
641        Ok(location) => worktree_root(&location.identity).map(Path::to_path_buf),
642        Err(message) => return Err(crate::manual_push_lifecycle::ManualPushFailure::step(ManualPushCause::SelfExplained, message)),
643    };
644    let context = checks.facts.transaction_context();
645    let cwd = match worktree {
646        Some(root) => root,
647        None => std::env::current_dir().map_err(|error| {
648            return crate::manual_push_lifecycle::ManualPushFailure::step(ManualPushCause::SelfExplained, format!(
649                "the working directory could not be read: {error}"
650            ));
651        })?,
652    };
653    let git = PushGit {
654        real_git: context.real_git,
655        cwd,
656        environment: environment_with_overlay(environment, context.overlay.as_slice()),
657        // The scope probe is a dry-run push; an upload during it would run before any policy has decided.
658        lfs: false,
659    };
660    let scope = manual_push_scope(&git, context.global_prefix.as_slice(), post_subcommand)?;
661    checks.candidates = LifecycleContent::Requested(CandidateRequest::Pushed(scope));
662    return Ok(());
663}
664
665#[cfg(test)]
666#[path = "wrapped_command_tests.rs"]
667mod tests;