Skip to main content

git_policy_cli/
lfs_upload.rs

1//! What:
2//!  The parent's run of the Git LFS upload before a push: `git-lfs push <remote> <refs>` as one child it owns.
3//! Why:
4//!  Without an upload step a push publishes pointer files whose objects the LFS server never received.
5//!  The wrapper runs `git-lfs push` itself, so a failure is explained by the process that saw it and the push, which
6//!  then carries no hook, is never started. Nothing here starts the wrapper, writes shell text or injects Git
7//!  configuration (`doc/planning/cli-git-no-self-invocation.md`). What to upload is decided in `lfs_upload_plan`.
8//!  The child sees what the caller gave Git: the global `-c` options travel as `GIT_CONFIG_COUNT` pairs, `--git-dir`,
9//!  `--work-tree` and `--namespace` as their variables, `-C` as the working directory, and the real Git's directory
10//!  leads its `PATH` so a `git` it starts is never the wrapper while `git-lfs` itself is the one the caller's `PATH`
11//!  finds first.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const code = await spawn(findProgram(env.PATH), plan.arguments(), { env: withPrefix(env, globalPrefix), stdio: 'inherit' });
16//! ```
17
18/// The attempt record, the coordinator's Git and its error type.
19use super::auto_push_single_flight::{PushAttempt, PushGit, SingleFlightError};
20/// The deadline, the grace and the push runner.
21use super::bounded_push::{STOPPED_EXIT_CODE, STOP_GRACE, budget_spent_message, push_deadline, run_bounded_push};
22/// The invocation's environment values.
23use super::child_environment::environment_value;
24/// How a child ended, as a status.
25use super::forwarding::{ChildOutcome, exit_code, outcome_of};
26/// The texts, the program lookup and the plan.
27use super::lfs_pre_push::{failed_message, find_program, missing_program_message};
28use super::lfs_upload_plan::UploadPlan;
29/// Relays signals to the child.
30use super::signal_relay::SignalGuard;
31/// Starts that ride out a short shortage of processes.
32use super::start_retry::RetryStart;
33/// Owned OS text for arguments.
34use std::ffi::OsString;
35/// The real Git whose directory leads `PATH`.
36use std::path::Path;
37/// The child.
38use std::process::Command;
39
40/// `PATH` with the directory of the real Git first, so a `git` that `git-lfs` starts is the real Git and never the
41///  wrapper (Git does the same for the hooks it starts).
42fn path_with_real_git_first(environment: &[(OsString, OsString)], real_git: &Path) -> OsString {
43    let existing: OsString = environment_value(environment, "PATH").unwrap_or_default();
44    let mut directories: Vec<std::path::PathBuf> = Vec::new();
45    if let Some(directory) = real_git.parent() {
46        directories.push(directory.to_path_buf());
47    }
48    directories.extend(std::env::split_paths(existing.as_os_str()));
49    return std::env::join_paths(directories).unwrap_or(existing);
50}
51
52/// What:
53///  The variables that carry the global options of a push into a child that reads Git's configuration.
54/// Why:
55///   `-c name=value` becomes `GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` after any pairs the environment already holds
56///  (a `-c` without `=` is the boolean true), `--git-dir`, `--work-tree` and `--namespace` become `GIT_DIR`,
57///  `GIT_WORK_TREE` and `GIT_NAMESPACE`; `-C` is the working directory and is applied by the caller.
58///  No shell is involved, and an `-c http.extraHeader=...` or `-c lfs.url=...` reaches `git-lfs` as it reached the
59///  Git that used to run the hook.
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// function prefixVariables(globalPrefix: string[], environment): [string, string][];
64/// ```
65pub fn prefix_variables(global_prefix: &[OsString], environment: &[(OsString, OsString)]) -> Vec<(OsString, OsString)> {
66    let mut pairs: Vec<(OsString, OsString)> = Vec::new();
67    let mut variables: Vec<(OsString, OsString)> = Vec::new();
68    let mut tokens = global_prefix.iter();
69    while let Some(token) = tokens.next() {
70        let text: String = token.to_string_lossy().into_owned();
71        if text == "-c" {
72            if let Some(setting) = tokens.next() {
73                let setting_text: String = setting.to_string_lossy().into_owned();
74                let (name, value) = setting_text.split_once('=').unwrap_or((setting_text.as_str(), "true"));
75                pairs.push((OsString::from(name), OsString::from(value)));
76            }
77            continue;
78        }
79        for (option, variable) in [("--git-dir", "GIT_DIR"), ("--work-tree", "GIT_WORK_TREE"), ("--namespace", "GIT_NAMESPACE")] {
80            if text == option {
81                if let Some(value) = tokens.next() {
82                    variables.push((OsString::from(variable), value.clone()));
83                }
84            } else if let Some(value) = text.strip_prefix(format!("{option}=").as_str()) {
85                variables.push((OsString::from(variable), OsString::from(value)));
86            }
87        }
88    }
89    let existing: usize = environment_value(environment, "GIT_CONFIG_COUNT")
90        .and_then(|value| {
91            return value.to_str().and_then(|count| {
92                return count.parse::<usize>().ok();
93            });
94        })
95        .unwrap_or(0);
96    if !pairs.is_empty() {
97        variables.push((OsString::from("GIT_CONFIG_COUNT"), OsString::from((existing + pairs.len()).to_string())));
98    }
99    for (offset, (name, value)) in pairs.into_iter().enumerate() {
100        variables.push((OsString::from(format!("GIT_CONFIG_KEY_{}", existing + offset)), name));
101        variables.push((OsString::from(format!("GIT_CONFIG_VALUE_{}", existing + offset)), value));
102    }
103    return variables;
104}
105
106/// What:
107///  Run the upload as a child with the caller's streams and return the status to stop with, or nothing when it succeeded.
108/// Why:
109///   The parent saw the child end, so it prints the explanation after the program's own output; the push is not started.
110///  A signal sent to the wrapper is relayed to the child, so stopping the wrapper stops the upload. A push that also
111///  pushes submodules gets a notice, because their objects are not part of this upload.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// const code = await spawn('git-lfs', plan.arguments(), { stdio: 'inherit' }); if (code) { print(failedMessage(...)); return code; }
116/// ```
117pub fn run_upload_interactive(
118    plan: &UploadPlan,
119    global_prefix: &[OsString],
120    directory: &Path,
121    real_git: &Path,
122    environment: &[(OsString, OsString)],
123    overlay: &[(OsString, OsString)],
124    diagnostics: &mut dyn std::io::Write,
125) -> Option<i32> {
126    let Some(program) = find_program(environment_value(environment, "PATH").unwrap_or_default().as_os_str()) else {
127        // Terminal output error: the exit status already carries the refusal.
128        let _written = diagnostics.write_all(missing_program_message().as_bytes());
129        return Some(1);
130    };
131    if plan.submodules {
132        // Terminal output error: the notice is advisory.
133        let _written = diagnostics.write_all(submodule_notice().as_bytes());
134    }
135    let mut command: Command = Command::new(program);
136    command
137        .current_dir(directory)
138        .args(plan.arguments())
139        .env("PATH", path_with_real_git_first(environment, real_git));
140    for (name, value) in prefix_variables(global_prefix, environment).into_iter().chain(overlay.iter().cloned()) {
141        command.env(name, value);
142    }
143    let outcome: std::io::Result<ChildOutcome> = match SignalGuard::install() {
144        Ok(guard) => guard.run_child(&mut command),
145        Err(_) => command.status_retrying().map(outcome_of),
146    };
147    let Ok(ended) = outcome else {
148        // Terminal output error: see above.
149        let _written = diagnostics.write_all(missing_program_message().as_bytes());
150        return Some(1);
151    };
152    let code: i32 = exit_code(ended);
153    if code == 0 {
154        return None;
155    }
156    let description: String = match ended {
157        ChildOutcome::Exited(status) => format!("exited with status {status}"),
158        ChildOutcome::Signaled(signal) => format!("was ended by signal {signal}"),
159    };
160    // Terminal output error: see above.
161    let _written = diagnostics.write_all(failed_message(description.as_str()).as_bytes());
162    return Some(code);
163}
164
165/// What:
166///  The notice for a push that also pushes submodules.
167/// Why:
168///   Git pushes a submodule's commits itself; their LFS objects are not uploaded by the wrapper, and the pointer files
169///  would be published without them. Every way forward is named.
170///
171/// In TS you'd write (pseudocode):
172/// ```ts
173/// 'cli-git: this push also pushes submodules, ...'
174/// ```
175pub fn submodule_notice() -> String {
176    return String::from(
177        "cli-git: this push also pushes submodules (`--recurse-submodules`), and the wrapper uploads Git LFS objects only \\
178         for this repository's own refs. If a submodule uses Git LFS, upload its objects first by running \\
179         `git lfs push --all <remote>` inside it, or push the submodule on its own; otherwise its pointer files are \\
180         published without their objects.\\n",
181    );
182}
183
184/// What:
185///  Run the upload as a bounded child whose output is captured, and return the failed attempt to stop with, or nothing
186///  when it succeeded.
187/// Why:
188///   The auto-push records and shows a failed attempt's output; the explanation is part of it. A stop by deadline says
189///  so, and a step the holder's time budget left no time for is not started and says that instead.
190///
191/// In TS you'd write (pseudocode):
192/// ```ts
193/// const attempt = await runBoundedPush({ command: gitLfs(plan), deadlineMs }); return attempt.exitCode ? withExplanation(attempt) : undefined;
194/// ```
195pub fn run_upload_bounded(git: &PushGit, plan: &UploadPlan) -> Result<Option<PushAttempt>, SingleFlightError> {
196    if super::holder_budget::exhausted() {
197        return Ok(Some(PushAttempt { exit_code: STOPPED_EXIT_CODE, output: budget_spent_message() }));
198    }
199    let Some(program) = find_program(environment_value(git.environment.as_slice(), "PATH").unwrap_or_default().as_os_str())
200    else {
201        return Ok(Some(PushAttempt { exit_code: 1, output: missing_program_message().trim_end().to_owned() }));
202    };
203    let mut command: Command = Command::new(program);
204    command
205        .current_dir(&git.cwd)
206        .env_clear()
207        .envs(git.environment.iter().map(|(name, value)| {
208            return (name, value);
209        }))
210        .env("PATH", path_with_real_git_first(git.environment.as_slice(), git.real_git.as_path()))
211        .args(plan.arguments());
212    let attempt: PushAttempt =
213        match run_bounded_push(command, push_deadline(git.environment.as_slice()), STOP_GRACE, git.environment.as_slice()) {
214            Ok(finished) => finished,
215            Err(SingleFlightError::Start(_)) => {
216                return Ok(Some(PushAttempt { exit_code: 1, output: missing_program_message().trim_end().to_owned() }));
217            }
218            Err(other) => return Err(other),
219        };
220    if attempt.exit_code == 0 {
221        return Ok(None);
222    }
223    let description: String = if attempt.exit_code == STOPPED_EXIT_CODE {
224        String::from("did not finish and was stopped")
225    } else {
226        format!("exited with status {}", attempt.exit_code)
227    };
228    let explanation: String = failed_message(description.as_str());
229    let shown: &str = explanation.trim_end();
230    let output: String = if attempt.output.is_empty() { shown.to_owned() } else { format!("{}\n{shown}", attempt.output) };
231    return Ok(Some(PushAttempt { exit_code: attempt.exit_code, output }));
232}
233
234/// The runs are controlled in the test module.
235#[cfg(test)]
236#[path = "lfs_upload_tests.rs"]
237mod tests;