Skip to main content

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