1use crate::execute::CommandBuilder;
6use crate::PushRefs;
7use fancy_regex::Regex;
8use std::{fmt::Display, path::Path, sync::OnceLock};
9use tokio::process::Command;
10
11#[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
35pub struct Push<'a> {
37 pub url: &'a url::Url,
38
39 pub ref_names: Vec<&'a str>,
41
42 pub config_path: &'a Path,
44}
45
46impl Push<'_> {
47 #[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 accm.0.push((refname.clone(), RefStatus::NewRef))
103 } else if line.starts_with('=') {
104 accm.0.push((refname.clone(), RefStatus::NoPush))
106 } else if line.starts_with('!') {
107 accm.0.push((refname.clone(), RefStatus::Rejected))
109 } else if line.starts_with(' ') {
110 accm.0.push((refname.clone(), RefStatus::FastForward))
112 } else if line.starts_with('-') {
113 accm.0.push((refname.clone(), RefStatus::Deleted))
115 } else if line.starts_with('+') {
116 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}