workerlib/
push.rs

1//!
2//! Helper command builder for pushing mirrors downstream
3//!
4
5use crate::execute::CommandBuilder;
6use crate::PushRefs;
7use fancy_regex::Regex;
8use std::{fmt::Display, path::Path, sync::OnceLock};
9use tokio::process::Command;
10
11/// The status as returned from the Git binary (or libgit2)
12#[derive(Clone, Debug)]
13pub enum RefStatus {
14    FastForward,
15    ForcedUpdate,
16    Deleted,
17    NewRef,
18    Rejected,
19    NoPush,
20}
21
22impl Display for RefStatus {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        match self {
25            RefStatus::FastForward => write!(f, "FastForward"),
26            RefStatus::ForcedUpdate => write!(f, "ForcedUpdate"),
27            RefStatus::Deleted => write!(f, "Deleted"),
28            RefStatus::NewRef => write!(f, "NewRef"),
29            RefStatus::Rejected => write!(f, "Rejected"),
30            RefStatus::NoPush => write!(f, "NoPush"),
31        }
32    }
33}
34
35/// Push the contents of a mirror directory into the downstream
36pub struct Push<'a> {
37    pub url: &'a url::Url,
38
39    // Git ref that should be pushed
40    pub ref_names: Vec<&'a str>,
41
42    // Path to the global git configuration file
43    pub config_path: &'a Path,
44
45    /// Optional LFS endpoint URL. When set, `-c lfs.url=<url>` is added to the
46    /// push command so that git-lfs pushes objects to the correct downstream.
47    pub lfs_url: Option<&'a url::Url>,
48}
49
50impl Push<'_> {
51    /// Parse the output of `stdout` from the `git push` command to determine
52    /// which branches succeeded and which ones failed
53    ///
54    /// Example output:
55    /// ```txt
56    /// To http://127.0.0.1:9999/root/repo-a.git
57    /// =       refs/heads/a:refs/heads/a       [up to date]
58    /// =       refs/heads/asdf:refs/heads/asdf [up to date]
59    /// !       refs/heads/main:refs/heads/main [rejected] (non-fast-forward)
60    /// Done
61    /// ```
62    ///
63    /// `git push --porcelain <flag>` documentation (see `man git-push`)
64    ///
65    /// ```txt
66    /// A single character indicating the status of the ref:
67    ///       (space)
68    ///           for a successfully pushed fast-forward;
69    ///       +
70    ///           for a successful forced update;
71    ///       -
72    ///           for a successfully deleted ref;
73    ///       *
74    ///           for a successfully pushed new ref;
75    ///       !
76    ///           for a ref that was rejected or failed to push; and
77    ///       =
78    ///           for a ref that was up to date and did not need pushing.
79    /// ```
80    #[tracing::instrument(name = "push::parse_output", skip_all)]
81    pub fn parse_output(output: &str) -> Result<PushRefs, String> {
82        tracing::debug!("Parsing push output: {}", output);
83        output
84            .split('\n')
85            .try_fold(PushRefs(Vec::default()), |mut accm, line| {
86                if line.starts_with("To") {
87                    return Ok(accm);
88                }
89                if line.starts_with("Done") {
90                    return Ok(accm);
91                }
92                if line.is_empty() {
93                    return Ok(accm);
94                }
95                // Skip lines that are not porcelain ref status lines (e.g. LFS
96                // progress output like "Uploading LFS objects: ...").
97                if !line.starts_with(['*', '=', '!', ' ', '-', '+']) {
98                    tracing::debug!("Skipping non-porcelain line: {line}");
99                    return Ok(accm);
100                }
101                let refname = match get_match_regex().captures(line).ok() {
102                    Some(Some(group)) => group.get(1).map(|matched| matched.as_str().to_string()),
103                    Some(None) => None,
104                    None => None,
105                };
106
107                if let Some(refname) =
108                    refname.and_then(|refname| refname.split(':').next().map(|s| s.to_string()))
109                {
110                    if line.starts_with('*') {
111                        // success (new ref)
112                        accm.0.push((refname.clone(), RefStatus::NewRef))
113                    } else if line.starts_with('=') {
114                        // success (no update)
115                        accm.0.push((refname.clone(), RefStatus::NoPush))
116                    } else if line.starts_with('!') {
117                        // failed
118                        accm.0.push((refname.clone(), RefStatus::Rejected))
119                    } else if line.starts_with(' ') {
120                        // fast-forward
121                        accm.0.push((refname.clone(), RefStatus::FastForward))
122                    } else if line.starts_with('-') {
123                        // deleted
124                        accm.0.push((refname.clone(), RefStatus::Deleted))
125                    } else if line.starts_with('+') {
126                        // force-push
127                        // TODO: This probably should panic as we forbid force push in 100%
128                        // of scenarios in Lorry.
129                        accm.0.push((refname.clone(), RefStatus::ForcedUpdate))
130                    } else {
131                        return Err(format!("Unknown line format: {line}"));
132                    };
133                    Ok(accm)
134                } else {
135                    Err(format!("Bad line: {line}"))
136                }
137            })
138    }
139}
140
141impl CommandBuilder for Push<'_> {
142    fn build(&self, current_dir: &Path) -> Command {
143        let mut cmd = Command::new("git");
144        cmd.current_dir(current_dir);
145        cmd.envs([(
146            crate::git_config::GIT_CONFIG_GLOBAL,
147            self.config_path.to_string_lossy().as_ref(),
148        )]);
149
150        if let Some(lfs_url) = self.lfs_url {
151            cmd.args(["--bare", "-c", &format!("lfs.url={lfs_url}")]);
152        } else {
153            cmd.arg("--bare");
154        }
155
156        cmd.args(["push", "--porcelain", self.url.as_str()]);
157        cmd.args(self.ref_names.clone());
158        tracing::debug!("Invoking git push command");
159        cmd
160    }
161}
162
163fn get_match_regex() -> &'static Regex {
164    static EXPRESSIONS: OnceLock<Regex> = OnceLock::new();
165    EXPRESSIONS.get_or_init(|| Regex::new(r##"(?<=\s)(\S+)"##).unwrap())
166}
167
168#[cfg(test)]
169mod test {
170    use super::*;
171
172    const STDOUT_TEST_CASE_1: &str = r#"
173To http://127.0.0.1:9999/root/repo-a.git
174=       refs/heads/a:refs/heads/a       [up to date]
175=       refs/heads/asdf:refs/heads/asdf [up to date]
176!       refs/heads/main:refs/heads/main [rejected] (non-fast-forward)
177Done
178"#;
179
180    const STDOUT_TEST_CASE_2: &str = r#"
181To http://127.0.0.1:9999/root/repo-a.git
182*       refs/heads/a:refs/heads/a       [new branch]
183*       refs/heads/asdf:refs/heads/asdf [new branch]
184!       refs/heads/main:refs/heads/main [rejected] (non-fast-forward)
185Done
186"#;
187
188    const STDOUT_TEST_CASE_3: &str = r#"
189To http://127.0.0.1:9999/root/repo-a.git
190*       refs/heads/asdf:refs/heads/asdf [new branch]
191Uploading LFS objects: 100% (1/1), 18 MB | 0 B/s, done.
192!       refs/heads/main:refs/heads/main [rejected] (non-fast-forward)
193Done
194"#;
195
196    const STDOUT_TEST_CASE_4: &str = r#"
197To http://127.0.0.1:20000/repo-a
198        refs/heads/asdf:refs/heads/asdf 7f96764..953422f
199Done
200"#;
201
202    const STDOUT_TEST_CASE_5: &str = r#"
203To file:///home/user/local-repo/
204*       refs/heads/a    [new branch]
205*       refs/heads/asdf    [new branch]
206Done
207"#;
208
209    #[test]
210    fn test_push_stdout_parsing_case_1() {
211        let results = Push::parse_output(STDOUT_TEST_CASE_1).unwrap();
212        assert!(results.0.len() == 3);
213        assert!(
214            results
215                .0
216                .iter()
217                .filter(|r| matches!(r.1, RefStatus::Rejected))
218                .count()
219                == 1
220        );
221        assert!(results.0.first().unwrap().0 == "refs/heads/a");
222        assert!(results.0.get(1).unwrap().0 == "refs/heads/asdf");
223        assert!(
224            results
225                .0
226                .iter()
227                .find(|r| matches!(r.1, RefStatus::Rejected))
228                .unwrap()
229                .0
230                == "refs/heads/main"
231        );
232    }
233
234    #[test]
235    fn test_push_stdout_parsing_case_2() {
236        let results = Push::parse_output(STDOUT_TEST_CASE_2).unwrap();
237        assert!(results.0.len() == 3);
238        assert!(
239            results
240                .0
241                .iter()
242                .filter(|r| matches!(r.1, RefStatus::Rejected))
243                .count()
244                == 1
245        );
246        assert!(results.0.first().unwrap().0 == "refs/heads/a");
247        assert!(results.0.get(1).unwrap().0 == "refs/heads/asdf");
248        assert!(
249            results
250                .0
251                .iter()
252                .find(|r| matches!(r.1, RefStatus::Rejected))
253                .unwrap()
254                .0
255                == "refs/heads/main"
256        );
257    }
258
259    #[test]
260    fn test_push_stdout_parsing_case_3() {
261        let results = Push::parse_output(STDOUT_TEST_CASE_3).unwrap();
262        assert!(results.0.len() == 2);
263        assert!(results.0.first().unwrap().0 == "refs/heads/asdf");
264        assert!(matches!(results.0.first().unwrap().1, RefStatus::NewRef));
265        assert!(
266            results
267                .0
268                .iter()
269                .filter(|r| matches!(r.1, RefStatus::Rejected))
270                .count()
271                == 1
272        );
273    }
274
275    #[test]
276    fn test_push_stdout_parsing_case_4() {
277        let results = Push::parse_output(STDOUT_TEST_CASE_4).unwrap();
278        assert!(results.0.len() == 1);
279        assert!(matches!(
280            results.0.first().unwrap().1,
281            RefStatus::FastForward
282        ));
283        assert!(results.0.first().unwrap().0 == "refs/heads/asdf");
284    }
285
286    #[test]
287    fn test_push_stdout_parsing_case_5() {
288        let results = Push::parse_output(STDOUT_TEST_CASE_5).unwrap();
289        assert!(results.0.len() == 2);
290        assert!(results.0.first().unwrap().0 == "refs/heads/a");
291        assert!(results.0.get(1).unwrap().0 == "refs/heads/asdf");
292    }
293}