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 pub lfs_url: Option<&'a url::Url>,
48}
49
50impl Push<'_> {
51 #[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 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 accm.0.push((refname.clone(), RefStatus::NewRef))
113 } else if line.starts_with('=') {
114 accm.0.push((refname.clone(), RefStatus::NoPush))
116 } else if line.starts_with('!') {
117 accm.0.push((refname.clone(), RefStatus::Rejected))
119 } else if line.starts_with(' ') {
120 accm.0.push((refname.clone(), RefStatus::FastForward))
122 } else if line.starts_with('-') {
123 accm.0.push((refname.clone(), RefStatus::Deleted))
125 } else if line.starts_with('+') {
126 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}