1pub mod state;
9
10use std::path::Path;
11use std::sync::Arc;
12
13use xianyun_executor::exec::{CommandRunner, ExecError};
14
15#[derive(Debug, Default, Clone, PartialEq, Eq)]
17pub struct Status {
18 pub branch: String,
19 pub revision: String,
20 pub has_uncommitted_changes: bool,
21 pub remote_url: String,
22}
23
24#[derive(Debug, Default)]
26pub struct RepositoryState {
27 pub exists: bool,
28 pub is_git_repo: bool,
29 pub current_branch: String,
31 pub is_detached: bool,
32 pub has_uncommitted: bool,
33 pub has_unpushed: bool,
34 pub tracking_branch: String,
36 pub default_branch: String,
38 pub commits_behind: i64,
39 pub commits_ahead: i64,
40 pub remote_url: String,
41 pub remote_matches: bool,
42 pub last_error: Option<GitError>,
43}
44
45#[derive(Debug, thiserror::Error)]
50pub enum GitError {
51 #[error("invalid clone strategy: {0}")]
53 InvalidCloneStrategy(String),
54
55 #[error("directory exists but is not a git repository: {0}")]
57 DirectoryNotGitRepo(String),
58
59 #[error("no output from git command")]
61 NoGitOutput,
62
63 #[error("{0}: {1}")]
70 Exec(ExecError, String),
71
72 #[error("{0}")]
75 Other(String),
76}
77
78impl From<ExecError> for GitError {
84 fn from(err: ExecError) -> Self {
85 GitError::Exec(err, String::new())
86 }
87}
88
89pub trait Backend {
91 fn clone(&self, repo_url: &str, directory: &str) -> Result<(), GitError>;
94
95 fn pull(&self, directory: &str) -> Result<(), GitError>;
97
98 fn get_revision(&self, directory: &str) -> Result<String, GitError>;
100
101 fn get_status(&self, directory: &str) -> Result<Status, GitError>;
103
104 fn ensure_cloned(
107 &self,
108 repo_url: &str,
109 directory: &str,
110 clone_strategy: &str,
111 ) -> Result<(), GitError>;
112
113 fn is_git_repository(&self, directory: &str) -> Result<bool, GitError>;
115
116 fn get_current_branch(&self, directory: &str) -> Result<String, GitError>;
118
119 fn is_detached(&self, directory: &str) -> Result<bool, GitError>;
121
122 fn get_tracking_branch(&self, directory: &str) -> Result<String, GitError>;
125
126 fn get_default_branch(&self, directory: &str, repo_url: &str) -> Result<String, GitError>;
128
129 fn get_commit_counts(&self, directory: &str) -> Result<(i64, i64), GitError>;
131
132 fn detect_state(
135 &self,
136 directory: &str,
137 configured_url: &str,
138 ) -> Result<RepositoryState, GitError>;
139
140 fn merge_ff_only(&self, directory: &str) -> Result<(), GitError>;
143
144 fn fetch(&self, directory: &str, remote: &str, ref_: &str) -> Result<(), GitError>;
147
148 fn checkout(&self, directory: &str, ref_: &str) -> Result<(), GitError>;
150
151 fn stash_push(&self, directory: &str, message: &str) -> Result<(), GitError>;
153}
154
155pub struct Client {
157 pub(crate) executor: Arc<dyn CommandRunner + Send + Sync>,
158}
159
160impl Client {
161 pub fn new(executor: Arc<dyn CommandRunner + Send + Sync>) -> Self {
163 Self { executor }
164 }
165}
166
167fn run_result_to_git_error(result: xianyun_executor::exec::ExecResult) -> Result<(), GitError> {
171 let output = result.combined_output();
172 match result.error {
173 Some(err) => Err(GitError::Exec(err, output)),
174 None => Ok(()),
175 }
176}
177
178impl Backend for Client {
179 fn clone(&self, repo_url: &str, directory: &str) -> Result<(), GitError> {
180 if Path::new(directory).exists() {
181 if self.is_git_repository(directory)? {
182 return Ok(());
183 }
184
185 return Err(GitError::DirectoryNotGitRepo(directory.to_string()));
186 }
187
188 if let Some(parent) = Path::new(directory).parent() {
189 std::fs::create_dir_all(parent)
190 .map_err(|err| GitError::Other(format!("creating parent directory: {err}")))?;
191 }
192
193 let result = self.executor.run(
194 "git",
195 &[
196 "clone".to_string(),
197 repo_url.to_string(),
198 directory.to_string(),
199 ],
200 None,
201 );
202
203 run_result_to_git_error(result)
204 }
205
206 fn pull(&self, directory: &str) -> Result<(), GitError> {
207 let result = self.executor.run(
208 "git",
209 &["-C".to_string(), directory.to_string(), "pull".to_string()],
210 None,
211 );
212
213 run_result_to_git_error(result)
214 }
215
216 fn get_revision(&self, directory: &str) -> Result<String, GitError> {
217 let out = self.executor.read_stdout(
218 "git",
219 &[
220 "-C".to_string(),
221 directory.to_string(),
222 "rev-parse".to_string(),
223 "HEAD".to_string(),
224 ],
225 )?;
226
227 let trimmed = out.trim().to_string();
228 if trimmed.is_empty() {
229 return Err(GitError::NoGitOutput);
230 }
231
232 Ok(trimmed)
233 }
234
235 fn get_status(&self, directory: &str) -> Result<Status, GitError> {
236 let mut status = Status::default();
237
238 if let Ok(branch) = self.executor.read_stdout(
239 "git",
240 &[
241 "-C".to_string(),
242 directory.to_string(),
243 "rev-parse".to_string(),
244 "--abbrev-ref".to_string(),
245 "HEAD".to_string(),
246 ],
247 ) && !branch.is_empty()
248 {
249 status.branch = branch.trim().to_string();
250 }
251
252 if let Ok(revision) = self.get_revision(directory) {
253 status.revision = revision;
254 }
255
256 if let Ok(remote) = self.executor.read_stdout(
257 "git",
258 &[
259 "-C".to_string(),
260 directory.to_string(),
261 "remote".to_string(),
262 "get-url".to_string(),
263 "origin".to_string(),
264 ],
265 ) && !remote.is_empty()
266 {
267 status.remote_url = remote.trim().to_string();
268 }
269
270 if let Ok(porcelain) = self.executor.read_stdout(
271 "git",
272 &[
273 "-C".to_string(),
274 directory.to_string(),
275 "status".to_string(),
276 "--porcelain".to_string(),
277 ],
278 ) {
279 status.has_uncommitted_changes = !porcelain.is_empty();
280 }
281
282 Ok(status)
283 }
284
285 fn ensure_cloned(
286 &self,
287 repo_url: &str,
288 directory: &str,
289 clone_strategy: &str,
290 ) -> Result<(), GitError> {
291 match clone_strategy {
292 "always" => {
293 if Path::new(directory).exists() {
294 std::fs::remove_dir_all(directory).map_err(|err| {
295 GitError::Other(format!("removing existing directory: {err}"))
296 })?;
297 }
298
299 self.clone(repo_url, directory)
300 }
301 "on-demand" | "" => self.clone(repo_url, directory),
302 other => Err(GitError::InvalidCloneStrategy(other.to_string())),
303 }
304 }
305
306 fn is_git_repository(&self, directory: &str) -> Result<bool, GitError> {
307 Ok(self.executor.check(
308 "git",
309 &[
310 "-C".to_string(),
311 directory.to_string(),
312 "rev-parse".to_string(),
313 "--git-dir".to_string(),
314 ],
315 )?)
316 }
317
318 fn get_current_branch(&self, directory: &str) -> Result<String, GitError> {
319 Ok(self
322 .executor
323 .read_stdout(
324 "git",
325 &[
326 "-C".to_string(),
327 directory.to_string(),
328 "symbolic-ref".to_string(),
329 "--short".to_string(),
330 "HEAD".to_string(),
331 ],
332 )
333 .map(|out| out.trim().to_string())
334 .unwrap_or_default())
335 }
336
337 fn is_detached(&self, directory: &str) -> Result<bool, GitError> {
338 let on_branch = self
341 .executor
342 .check(
343 "git",
344 &[
345 "-C".to_string(),
346 directory.to_string(),
347 "symbolic-ref".to_string(),
348 "-q".to_string(),
349 "HEAD".to_string(),
350 ],
351 )
352 .unwrap_or(true);
353
354 Ok(!on_branch)
355 }
356
357 fn get_tracking_branch(&self, directory: &str) -> Result<String, GitError> {
358 Ok(self
360 .executor
361 .read_stdout(
362 "git",
363 &[
364 "-C".to_string(),
365 directory.to_string(),
366 "rev-parse".to_string(),
367 "--abbrev-ref".to_string(),
368 "--symbolic-full-name".to_string(),
369 "@{u}".to_string(),
370 ],
371 )
372 .map(|out| out.trim().to_string())
373 .unwrap_or_default())
374 }
375
376 fn get_default_branch(&self, directory: &str, repo_url: &str) -> Result<String, GitError> {
377 if let Some(branch) = self.default_branch_from_local(directory) {
378 return Ok(branch);
379 }
380
381 if !repo_url.is_empty()
382 && let Some(branch) = self.default_branch_from_remote(repo_url)
383 {
384 return Ok(branch);
385 }
386
387 Ok("main".to_string())
388 }
389
390 fn get_commit_counts(&self, directory: &str) -> Result<(i64, i64), GitError> {
391 let Ok(out) = self.executor.read_stdout(
392 "git",
393 &[
394 "-C".to_string(),
395 directory.to_string(),
396 "rev-list".to_string(),
397 "--count".to_string(),
398 "--left-right".to_string(),
399 "@{u}...HEAD".to_string(),
400 ],
401 ) else {
402 return Ok((0, 0));
404 };
405
406 let parts: Vec<&str> = out.split_whitespace().collect();
407 if parts.len() != 2 {
408 return Ok((0, 0));
409 }
410
411 let behind = parts[0]
412 .parse::<i64>()
413 .map_err(|err| GitError::Other(format!("parsing behind count: {err}")))?;
414 let ahead = parts[1]
415 .parse::<i64>()
416 .map_err(|err| GitError::Other(format!("parsing ahead count: {err}")))?;
417
418 Ok((behind, ahead))
419 }
420
421 fn detect_state(
422 &self,
423 directory: &str,
424 configured_url: &str,
425 ) -> Result<RepositoryState, GitError> {
426 let mut state = RepositoryState::default();
427
428 match std::fs::metadata(directory) {
429 Ok(_) => {}
430 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
431 state.exists = false;
432 return Ok(state);
433 }
434 Err(err) => {
435 let err = GitError::Other(format!("checking directory: {err}"));
436 state.last_error = Some(err);
437 return Ok(state);
439 }
440 }
441
442 state.exists = true;
443
444 let is_git_repo = match self.is_git_repository(directory) {
445 Ok(is_git_repo) => is_git_repo,
446 Err(err) => {
447 state.last_error = Some(err);
448 return Ok(state);
449 }
450 };
451
452 state.is_git_repo = is_git_repo;
453
454 if !is_git_repo {
455 return Ok(state);
456 }
457
458 let status = match self.get_status(directory) {
459 Ok(status) => status,
460 Err(err) => {
461 state.last_error = Some(err);
462 return Ok(state);
463 }
464 };
465
466 state.remote_url = status.remote_url.clone();
467 state.remote_matches =
468 state::normalize_git_url(&state.remote_url) == state::normalize_git_url(configured_url);
469
470 if let Ok(is_detached) = self.is_detached(directory) {
471 state.is_detached = is_detached;
472 }
473
474 state.current_branch = status.branch.clone();
475 if state.current_branch == "HEAD" || state.is_detached {
476 state.current_branch = String::new();
477 state.is_detached = true;
478 }
479
480 state.has_uncommitted = status.has_uncommitted_changes;
481
482 if let Ok(tracking_branch) = self.get_tracking_branch(directory) {
483 state.tracking_branch = tracking_branch;
484 }
485
486 if let Ok((behind, ahead)) = self.get_commit_counts(directory) {
487 state.commits_behind = behind;
488 state.commits_ahead = ahead;
489 state.has_unpushed = ahead > 0;
490 }
491
492 if let Ok(default_branch) = self.get_default_branch(directory, configured_url) {
493 state.default_branch = default_branch;
494 }
495
496 Ok(state)
497 }
498
499 fn merge_ff_only(&self, directory: &str) -> Result<(), GitError> {
500 let result = self.executor.run(
501 "git",
502 &[
503 "-C".to_string(),
504 directory.to_string(),
505 "merge".to_string(),
506 "--ff-only".to_string(),
507 "@{u}".to_string(),
508 ],
509 None,
510 );
511
512 run_result_to_git_error(result)
513 }
514
515 fn fetch(&self, directory: &str, remote: &str, ref_: &str) -> Result<(), GitError> {
516 let mut args = vec![
517 "-C".to_string(),
518 directory.to_string(),
519 "fetch".to_string(),
520 "--no-tags".to_string(),
521 remote.to_string(),
522 ];
523 if !ref_.is_empty() {
524 args.push(ref_.to_string());
525 }
526
527 let result = self.executor.run("git", &args, None);
528
529 run_result_to_git_error(result)
530 }
531
532 fn checkout(&self, directory: &str, ref_: &str) -> Result<(), GitError> {
533 let result = self.executor.run(
534 "git",
535 &[
536 "-C".to_string(),
537 directory.to_string(),
538 "checkout".to_string(),
539 ref_.to_string(),
540 ],
541 None,
542 );
543
544 run_result_to_git_error(result)
545 }
546
547 fn stash_push(&self, directory: &str, message: &str) -> Result<(), GitError> {
548 let mut args = vec![
549 "-C".to_string(),
550 directory.to_string(),
551 "stash".to_string(),
552 "push".to_string(),
553 ];
554 if !message.is_empty() {
555 args.push("-m".to_string());
556 args.push(message.to_string());
557 }
558
559 let result = self.executor.run("git", &args, None);
560
561 run_result_to_git_error(result)
562 }
563}
564
565impl Client {
566 fn default_branch_from_local(&self, directory: &str) -> Option<String> {
568 let out = self
569 .executor
570 .read_stdout(
571 "git",
572 &[
573 "-C".to_string(),
574 directory.to_string(),
575 "symbolic-ref".to_string(),
576 "refs/remotes/origin/HEAD".to_string(),
577 "--short".to_string(),
578 ],
579 )
580 .ok()?;
581
582 let full_ref = out.trim();
583 let parts: Vec<&str> = full_ref.split('/').collect();
584 if parts.len() >= 2 {
585 return Some((*parts.last().unwrap()).to_string());
586 }
587
588 None
589 }
590
591 fn default_branch_from_remote(&self, repo_url: &str) -> Option<String> {
593 let out = self
594 .executor
595 .read_stdout(
596 "git",
597 &[
598 "ls-remote".to_string(),
599 "--symref".to_string(),
600 repo_url.to_string(),
601 "HEAD".to_string(),
602 ],
603 )
604 .ok()?;
605
606 let first_line = out.lines().next()?.trim();
607 if !first_line.starts_with("ref:") {
608 return None;
609 }
610
611 let parts: Vec<&str> = first_line.split_whitespace().collect();
612 if parts.len() < 2 {
613 return None;
614 }
615
616 let ref_parts: Vec<&str> = parts[1].split('/').collect();
617 if ref_parts.len() >= 3 {
618 return Some((*ref_parts.last().unwrap()).to_string());
619 }
620
621 None
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use xianyun_executor::exec::ExecResult;
629 use xianyun_testutil::MockCommandRunner;
630
631 #[test]
634 fn is_git_repository_true() {
635 let mock = MockCommandRunner::new();
636 mock.expect_check(
637 "git",
638 &["-C", "/some/dir", "rev-parse", "--git-dir"],
639 Ok(true),
640 );
641
642 let client = Client::new(Arc::new(mock));
643
644 let is_repo = client.is_git_repository("/some/dir").unwrap();
645 assert!(is_repo);
646 }
647
648 #[test]
649 fn is_git_repository_false() {
650 let mock = MockCommandRunner::new();
651 mock.expect_check(
652 "git",
653 &["-C", "/some/dir", "rev-parse", "--git-dir"],
654 Ok(false),
655 );
656
657 let client = Client::new(Arc::new(mock));
658
659 let is_repo = client.is_git_repository("/some/dir").unwrap();
660 assert!(!is_repo);
661 }
662
663 #[test]
664 fn is_git_repository_exec_error() {
665 let mock = MockCommandRunner::new();
666 mock.expect_check(
667 "git",
668 &["-C", "/some/dir", "rev-parse", "--git-dir"],
669 Err(ExecError::RunFailed("binary missing".to_string())),
670 );
671
672 let client = Client::new(Arc::new(mock));
673
674 let err = client.is_git_repository("/some/dir").unwrap_err();
675 assert!(matches!(err, GitError::Exec(ExecError::RunFailed(_), _)));
676 }
677
678 #[test]
679 fn is_detached_on_branch() {
680 let mock = MockCommandRunner::new();
681 mock.expect_check(
682 "git",
683 &["-C", "/some/dir", "symbolic-ref", "-q", "HEAD"],
684 Ok(true),
685 );
686
687 let client = Client::new(Arc::new(mock));
688
689 let detached = client.is_detached("/some/dir").unwrap();
690 assert!(!detached);
691 }
692
693 #[test]
694 fn is_detached_detached() {
695 let mock = MockCommandRunner::new();
696 mock.expect_check(
697 "git",
698 &["-C", "/some/dir", "symbolic-ref", "-q", "HEAD"],
699 Ok(false),
700 );
701
702 let client = Client::new(Arc::new(mock));
703
704 let detached = client.is_detached("/some/dir").unwrap();
705 assert!(detached);
706 }
707
708 #[test]
709 fn is_detached_exec_error_is_fail_safe() {
710 let mock = MockCommandRunner::new();
711 mock.expect_check(
712 "git",
713 &["-C", "/some/dir", "symbolic-ref", "-q", "HEAD"],
714 Err(ExecError::RunFailed("binary missing".to_string())),
715 );
716
717 let client = Client::new(Arc::new(mock));
718
719 let detached = client.is_detached("/some/dir").unwrap();
721 assert!(!detached);
722 }
723
724 #[test]
725 fn checkout_success() {
726 let mock = MockCommandRunner::new();
727 mock.expect_run(
728 "git",
729 &["-C", "/some/dir", "checkout", "abc123"],
730 ExecResult::default(),
731 );
732
733 let client = Client::new(Arc::new(mock));
734
735 client.checkout("/some/dir", "abc123").unwrap();
736 }
737
738 #[test]
739 fn checkout_error() {
740 let mock = MockCommandRunner::new();
741 mock.expect_run(
742 "git",
743 &["-C", "/some/dir", "checkout", "abc123"],
744 ExecResult {
745 error: Some(ExecError::RunFailed("binary missing".to_string())),
746 ..Default::default()
747 },
748 );
749
750 let client = Client::new(Arc::new(mock));
751
752 let err = client.checkout("/some/dir", "abc123").unwrap_err();
753 assert!(matches!(err, GitError::Exec(ExecError::RunFailed(_), _)));
754 }
755
756 #[test]
765 fn checkout_error_includes_combined_output_in_the_message() {
766 let mock = MockCommandRunner::new();
767 mock.expect_run(
768 "git",
769 &["-C", "/some/dir", "checkout", "abc123"],
770 ExecResult {
771 error: Some(ExecError::RunFailed("exit status 1".to_string())),
772 stdout: vec!["Switched to branch 'abc123'".to_string()],
773 stderr: vec!["error: pathspec 'abc123' did not match".to_string()],
774 ..Default::default()
775 },
776 );
777
778 let client = Client::new(Arc::new(mock));
779
780 let err = client.checkout("/some/dir", "abc123").unwrap_err();
781 let message = err.to_string();
782 assert!(
783 message.contains("pathspec 'abc123' did not match"),
784 "expected the combined output in the error message, got: {message:?}"
785 );
786 assert!(
787 message.contains("Switched to branch"),
788 "expected stdout to also be included: {message:?}"
789 );
790 }
791
792 #[test]
797 fn checkout_error_with_no_output_omits_trailing_colon_text() {
798 let mock = MockCommandRunner::new();
799 mock.expect_run(
800 "git",
801 &["-C", "/some/dir", "checkout", "abc123"],
802 ExecResult {
803 error: Some(ExecError::RunFailed("binary missing".to_string())),
804 ..Default::default()
805 },
806 );
807
808 let client = Client::new(Arc::new(mock));
809
810 let err = client.checkout("/some/dir", "abc123").unwrap_err();
811 assert_eq!(err.to_string(), "running command: binary missing: ");
812 }
813
814 struct FetchCase {
815 name: &'static str,
816 remote: &'static str,
817 ref_: &'static str,
818 want_args: &'static [&'static str],
819 exec_err: bool,
820 }
821
822 #[test]
823 fn fetch_cases() {
824 let cases = [
825 FetchCase {
826 name: "scoped ref",
827 remote: "origin",
828 ref_: "main",
829 want_args: &["-C", "/some/dir", "fetch", "--no-tags", "origin", "main"],
830 exec_err: false,
831 },
832 FetchCase {
833 name: "empty ref fetches all branches",
834 remote: "origin",
835 ref_: "",
836 want_args: &["-C", "/some/dir", "fetch", "--no-tags", "origin"],
837 exec_err: false,
838 },
839 FetchCase {
840 name: "non-origin remote",
841 remote: "upstream",
842 ref_: "main",
843 want_args: &["-C", "/some/dir", "fetch", "--no-tags", "upstream", "main"],
844 exec_err: false,
845 },
846 FetchCase {
847 name: "error propagated",
848 remote: "origin",
849 ref_: "main",
850 want_args: &["-C", "/some/dir", "fetch", "--no-tags", "origin", "main"],
851 exec_err: true,
852 },
853 ];
854
855 for case in cases {
856 let mock = MockCommandRunner::new();
857 mock.expect_run(
858 "git",
859 case.want_args,
860 ExecResult {
861 error: case
862 .exec_err
863 .then(|| ExecError::RunFailed("binary missing".to_string())),
864 ..Default::default()
865 },
866 );
867
868 let client = Client::new(Arc::new(mock));
869
870 let result = client.fetch("/some/dir", case.remote, case.ref_);
871 if case.exec_err {
872 assert!(result.is_err(), "{}", case.name);
873 } else {
874 assert!(result.is_ok(), "{}", case.name);
875 }
876 }
877 }
878
879 #[test]
880 fn merge_ff_only_success() {
881 let mock = MockCommandRunner::new();
882 mock.expect_run(
883 "git",
884 &["-C", "/some/dir", "merge", "--ff-only", "@{u}"],
885 ExecResult::default(),
886 );
887
888 let client = Client::new(Arc::new(mock));
889
890 client.merge_ff_only("/some/dir").unwrap();
891 }
892
893 #[test]
894 fn merge_ff_only_error() {
895 let mock = MockCommandRunner::new();
896 mock.expect_run(
897 "git",
898 &["-C", "/some/dir", "merge", "--ff-only", "@{u}"],
899 ExecResult {
900 error: Some(ExecError::RunFailed("binary missing".to_string())),
901 ..Default::default()
902 },
903 );
904
905 let client = Client::new(Arc::new(mock));
906
907 let err = client.merge_ff_only("/some/dir").unwrap_err();
908 assert!(matches!(err, GitError::Exec(ExecError::RunFailed(_), _)));
909 }
910}