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
46impl Push<'_> {
47    /// Parse the output of `stdout` from the `git push` command to determine
48    /// which branches succeeded and which ones failed
49    ///
50    /// Example output:
51    /// ```txt
52    /// To http://127.0.0.1:9999/root/repo-a.git
53    /// =       refs/heads/a:refs/heads/a       [up to date]
54    /// =       refs/heads/asdf:refs/heads/asdf [up to date]
55    /// !       refs/heads/main:refs/heads/main [rejected] (non-fast-forward)
56    /// Done
57    /// ```
58    ///
59    /// `git push --porcelain <flag>` documentation (see `man git-push`)
60    ///
61    /// ```txt
62    /// A single character indicating the status of the ref:
63    ///       (space)
64    ///           for a successfully pushed fast-forward;
65    ///       +
66    ///           for a successful forced update;
67    ///       -
68    ///           for a successfully deleted ref;
69    ///       *
70    ///           for a successfully pushed new ref;
71    ///       !
72    ///           for a ref that was rejected or failed to push; and
73    ///       =
74    ///           for a ref that was up to date and did not need pushing.
75    /// ```
76    #[tracing::instrument(name = "push::parse_output", skip_all)]
77    pub fn parse_output(output: &str) -> Result<PushRefs, String> {
78        tracing::debug!("Parsing push output: {}", output);
79        output
80            .split('\n')
81            .try_fold(PushRefs(Vec::default()), |mut accm, line| {
82                if line.starts_with("To") {
83                    return Ok(accm);
84                }
85                if line.starts_with("Done") {
86                    return Ok(accm);
87                }
88                if line.is_empty() {
89                    return Ok(accm);
90                }
91                let refname = match get_match_regex().captures(line).ok() {
92                    Some(Some(group)) => group.get(1).map(|matched| matched.as_str().to_string()),
93                    Some(None) => None,
94                    None => None,
95                };
96
97                if let Some(refname) =
98                    refname.and_then(|refname| refname.split(':').next().map(|s| s.to_string()))
99                {
100                    if line.starts_with('*') {
101                        // success (new ref)
102                        accm.0.push((refname.clone(), RefStatus::NewRef))
103                    } else if line.starts_with('=') {
104                        // success (no update)
105                        accm.0.push((refname.clone(), RefStatus::NoPush))
106                    } else if line.starts_with('!') {
107                        // failed
108                        accm.0.push((refname.clone(), RefStatus::Rejected))
109                    } else if line.starts_with(' ') {
110                        // fast-forward
111                        accm.0.push((refname.clone(), RefStatus::FastForward))
112                    } else if line.starts_with('-') {
113                        // deleted
114                        accm.0.push((refname.clone(), RefStatus::Deleted))
115                    } else if line.starts_with('+') {
116                        // force-push
117                        // TODO: This probably should panic as we forbid force push in 100%
118                        // of scenarios in Lorry.
119                        accm.0.push((refname.clone(), RefStatus::ForcedUpdate))
120                    } else {
121                        return Err(format!("Unknown line format: {line}"));
122                    };
123                    Ok(accm)
124                } else {
125                    Err(format!("Bad line: {line}"))
126                }
127            })
128    }
129}
130
131impl CommandBuilder for Push<'_> {
132    fn build(&self, current_dir: &Path) -> Command {
133        let mut args = vec!["--bare", "push", "--porcelain", self.url.as_str()];
134        args.extend(self.ref_names.clone());
135        tracing::debug!("Invoking git command with args: {:?}", args);
136        let mut cmd = Command::new("git");
137        cmd.current_dir(current_dir);
138        cmd.envs([(
139            crate::git_config::GIT_CONFIG_GLOBAL,
140            self.config_path.to_string_lossy().as_ref(),
141        )]);
142        cmd.args(args);
143        cmd
144    }
145}
146
147fn get_match_regex() -> &'static Regex {
148    static EXPRESSIONS: OnceLock<Regex> = OnceLock::new();
149    EXPRESSIONS.get_or_init(|| Regex::new(r##"(?<=\s)(\S+)"##).unwrap())
150}
151
152#[cfg(test)]
153mod test {
154    use super::*;
155
156    const STDOUT_TEST_CASE_1: &str = r#"
157To http://127.0.0.1:9999/root/repo-a.git
158=       refs/heads/a:refs/heads/a       [up to date]
159=       refs/heads/asdf:refs/heads/asdf [up to date]
160!       refs/heads/main:refs/heads/main [rejected] (non-fast-forward)
161Done
162"#;
163
164    const STDOUT_TEST_CASE_2: &str = r#"
165To http://127.0.0.1:9999/root/repo-a.git
166*       refs/heads/a:refs/heads/a       [new branch]
167*       refs/heads/asdf:refs/heads/asdf [new branch]
168!       refs/heads/main:refs/heads/main [rejected] (non-fast-forward)
169Done
170"#;
171
172    const STDOUT_TEST_CASE_3: &str = r#"
173To http://127.0.0.1:9999/root/repo-a.git
174*       refs/heads/asdf:refs/heads/asdf [new branch]
175haha yeah no
176!       refs/heads/main:refs/heads/main [rejected] (non-fast-forward)
177Done
178"#;
179
180    const STDOUT_TEST_CASE_4: &str = r#"
181To http://127.0.0.1:20000/repo-a
182        refs/heads/asdf:refs/heads/asdf 7f96764..953422f
183Done
184"#;
185
186    const STDOUT_TEST_CASE_5: &str = r#"
187To file:///home/user/local-repo/
188*       refs/heads/a    [new branch]
189*       refs/heads/asdf    [new branch]
190Done
191"#;
192
193    #[test]
194    fn test_push_stdout_parsing_case_1() {
195        let results = Push::parse_output(STDOUT_TEST_CASE_1).unwrap();
196        assert!(results.0.len() == 3);
197        assert!(
198            results
199                .0
200                .iter()
201                .filter(|r| matches!(r.1, RefStatus::Rejected))
202                .count()
203                == 1
204        );
205        assert!(results.0.first().unwrap().0 == "refs/heads/a");
206        assert!(results.0.get(1).unwrap().0 == "refs/heads/asdf");
207        assert!(
208            results
209                .0
210                .iter()
211                .find(|r| matches!(r.1, RefStatus::Rejected))
212                .unwrap()
213                .0
214                == "refs/heads/main"
215        );
216    }
217
218    #[test]
219    fn test_push_stdout_parsing_case_2() {
220        let results = Push::parse_output(STDOUT_TEST_CASE_2).unwrap();
221        assert!(results.0.len() == 3);
222        assert!(
223            results
224                .0
225                .iter()
226                .filter(|r| matches!(r.1, RefStatus::Rejected))
227                .count()
228                == 1
229        );
230        assert!(results.0.first().unwrap().0 == "refs/heads/a");
231        assert!(results.0.get(1).unwrap().0 == "refs/heads/asdf");
232        assert!(
233            results
234                .0
235                .iter()
236                .find(|r| matches!(r.1, RefStatus::Rejected))
237                .unwrap()
238                .0
239                == "refs/heads/main"
240        );
241    }
242
243    #[test]
244    fn test_push_stdout_parsing_case_3() {
245        let results = Push::parse_output(STDOUT_TEST_CASE_3);
246        assert!(results.is_err_and(|err| err == "Unknown line format: haha yeah no"))
247    }
248
249    #[test]
250    fn test_push_stdout_parsing_case_4() {
251        let results = Push::parse_output(STDOUT_TEST_CASE_4).unwrap();
252        assert!(results.0.len() == 1);
253        assert!(matches!(
254            results.0.first().unwrap().1,
255            RefStatus::FastForward
256        ));
257        assert!(results.0.first().unwrap().0 == "refs/heads/asdf");
258    }
259
260    #[test]
261    fn test_push_stdout_parsing_case_5() {
262        let results = Push::parse_output(STDOUT_TEST_CASE_5).unwrap();
263        assert!(results.0.len() == 2);
264        assert!(results.0.first().unwrap().0 == "refs/heads/a");
265        assert!(results.0.get(1).unwrap().0 == "refs/heads/asdf");
266    }
267}