1use std::collections::HashMap;
10use std::future::Future;
11use std::pin::Pin;
12
13use crate::error::SolariError;
14use crate::types::{CommandResult, GitBranch, GitCommit, GitStatus};
15
16pub trait GitRunner: Send + Sync {
19 fn run_git<'a>(
20 &'a self,
21 args: Vec<String>,
22 cwd: Option<String>,
23 ) -> Pin<Box<dyn Future<Output = Result<CommandResult, SolariError>> + Send + 'a>>;
24
25 fn run_git_env<'a>(
29 &'a self,
30 args: Vec<String>,
31 cwd: Option<String>,
32 env: Option<HashMap<String, String>>,
33 ) -> Pin<Box<dyn Future<Output = Result<CommandResult, SolariError>> + Send + 'a>> {
34 match env {
35 None => self.run_git(args, cwd),
36 Some(_) => Box::pin(async {
37 Err(SolariError::Git {
38 message: "this git runner cannot pass environment variables".to_string(),
39 })
40 }),
41 }
42 }
43}
44
45#[derive(Default, Clone)]
47pub struct GitCloneOptions {
48 pub path: Option<String>,
49 pub branch: Option<String>,
50 pub depth: Option<i64>,
51 pub username: Option<String>,
52 pub password: Option<String>,
53 pub cwd: Option<String>,
54}
55
56#[derive(Default, Clone)]
58pub struct GitCommitOptions {
59 pub cwd: Option<String>,
60 pub author: Option<String>,
61 pub email: Option<String>,
62 pub all: bool,
63}
64
65#[derive(Default, Clone)]
67pub struct GitRemoteOptions {
68 pub cwd: Option<String>,
69 pub remote: Option<String>,
70 pub branch: Option<String>,
71 pub username: Option<String>,
72 pub password: Option<String>,
73}
74
75#[derive(Default, Clone)]
77pub struct GitLogOptions {
78 pub cwd: Option<String>,
79 pub max_count: Option<i64>,
80}
81
82pub struct Git<'a> {
84 runner: &'a dyn GitRunner,
85}
86
87impl<'a> Git<'a> {
88 pub fn new(runner: &'a dyn GitRunner) -> Self {
89 Git { runner }
90 }
91
92 async fn must_git(&self, args: Vec<String>, cwd: Option<String>) -> Result<String, SolariError> {
94 self.must_git_env(args, cwd, None, None).await
95 }
96
97 async fn must_git_env(
99 &self,
100 args: Vec<String>,
101 cwd: Option<String>,
102 env: Option<HashMap<String, String>>,
103 secret: Option<&str>,
104 ) -> Result<String, SolariError> {
105 let sub = git_subcommand(&args);
106 let r = self.runner.run_git_env(args, cwd, env).await?;
107 if r.exit_code != 0 {
108 let mut detail = {
109 let s = r.stderr.trim();
110 if !s.is_empty() {
111 s.to_string()
112 } else {
113 r.stdout.trim().to_string()
114 }
115 };
116 if let Some(sec) = secret.filter(|s| !s.is_empty()) {
117 detail = detail.replace(sec, "***");
118 }
119 return Err(SolariError::Git {
120 message: format!("git {} failed (exit {}): {}", sub, r.exit_code, detail),
121 });
122 }
123 Ok(r.stdout)
124 }
125
126 pub async fn clone(&self, url: &str, opts: GitCloneOptions) -> Result<(), SolariError> {
128 let auth = git_auth(url, opts.username.as_deref(), opts.password.as_deref())?;
129 let mut args: Vec<String> = auth.as_ref().map(|a| a.0.clone()).unwrap_or_default();
130 args.push("clone".into());
131 if let Some(d) = opts.depth {
132 if d > 0 {
133 args.push("--depth".into());
134 args.push(d.to_string());
135 }
136 }
137 if let Some(b) = &opts.branch {
138 args.push("--branch".into());
139 args.push(b.clone());
140 }
141 args.push("--".into());
143 args.push(url.to_string());
144 if let Some(p) = &opts.path {
145 args.push(p.clone());
146 }
147 self.must_git_env(args, opts.cwd, auth.map(|a| a.1), opts.password.as_deref())
148 .await
149 .map(|_| ())
150 }
151
152 pub async fn status(&self, cwd: Option<&str>) -> Result<GitStatus, SolariError> {
154 let out = self
155 .must_git(
156 vec![
157 "status".into(),
158 "--porcelain=v1".into(),
159 "--branch".into(),
160 ],
161 cwd.map(String::from),
162 )
163 .await?;
164 Ok(parse_status(&out))
165 }
166
167 pub async fn add(&self, paths: &[String], cwd: Option<&str>) -> Result<(), SolariError> {
169 if paths.is_empty() {
170 return Ok(());
171 }
172 let mut args = vec!["add".to_string(), "--".to_string()];
173 args.extend(paths.iter().cloned());
174 self.must_git(args, cwd.map(String::from)).await.map(|_| ())
175 }
176
177 pub async fn commit(
179 &self,
180 message: &str,
181 opts: GitCommitOptions,
182 ) -> Result<String, SolariError> {
183 let mut args: Vec<String> = Vec::new();
184 if let Some(a) = &opts.author {
185 args.push("-c".into());
186 args.push(format!("user.name={a}"));
187 }
188 if let Some(e) = &opts.email {
189 args.push("-c".into());
190 args.push(format!("user.email={e}"));
191 }
192 args.push("commit".into());
193 args.push("-m".into());
194 args.push(message.to_string());
195 if opts.all {
196 args.push("-a".into());
197 }
198 self.must_git(args, opts.cwd.clone()).await?;
199 let hash = self
200 .must_git(vec!["rev-parse".into(), "HEAD".into()], opts.cwd)
201 .await?
202 .trim()
203 .to_string();
204 Ok(hash)
205 }
206
207 pub async fn push(&self, opts: GitRemoteOptions) -> Result<(), SolariError> {
209 self.push_pull("push", opts).await
210 }
211
212 pub async fn pull(&self, opts: GitRemoteOptions) -> Result<(), SolariError> {
214 self.push_pull("pull", opts).await
215 }
216
217 async fn push_pull(&self, op: &str, opts: GitRemoteOptions) -> Result<(), SolariError> {
218 let remote = opts.remote.clone().unwrap_or_else(|| "origin".to_string());
219 reject_option_like("remote", &remote)?;
220 if let Some(b) = &opts.branch {
221 reject_option_like("branch", b)?;
222 }
223 let mut auth = None;
224 if opts.username.is_some() || opts.password.is_some() {
225 let plain = self
227 .must_git(
228 vec!["remote".into(), "get-url".into(), "--".into(), remote.clone()],
229 opts.cwd.clone(),
230 )
231 .await?;
232 auth = git_auth(plain.trim(), opts.username.as_deref(), opts.password.as_deref())?;
233 }
234 let mut args: Vec<String> = auth.as_ref().map(|a| a.0.clone()).unwrap_or_default();
235 args.push(op.to_string());
236 args.push("--".into());
237 args.push(remote);
238 if let Some(b) = &opts.branch {
239 args.push(b.clone());
240 }
241 self.must_git_env(args, opts.cwd, auth.map(|a| a.1), opts.password.as_deref())
242 .await
243 .map(|_| ())
244 }
245
246 pub async fn checkout(
248 &self,
249 r#ref: &str,
250 cwd: Option<&str>,
251 create: bool,
252 ) -> Result<(), SolariError> {
253 reject_option_like("ref", r#ref)?;
256 let mut args = vec!["checkout".to_string()];
257 if create {
258 args.push("-b".into());
259 }
260 args.push(r#ref.to_string());
261 args.push("--".into());
262 self.must_git(args, cwd.map(String::from)).await.map(|_| ())
263 }
264
265 pub async fn branches(&self, cwd: Option<&str>) -> Result<Vec<GitBranch>, SolariError> {
267 let out = self
268 .must_git(
269 vec![
270 "branch".into(),
271 "--format=%(HEAD)%1f%(refname:short)%1f%(objectname:short)".into(),
272 ],
273 cwd.map(String::from),
274 )
275 .await?;
276 Ok(parse_branches(&out))
277 }
278
279 pub async fn log(&self, opts: GitLogOptions) -> Result<Vec<GitCommit>, SolariError> {
281 let mut args = vec![
282 "log".to_string(),
283 "--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s".to_string(),
284 ];
285 if let Some(n) = opts.max_count {
286 if n > 0 {
287 args.push(format!("--max-count={n}"));
288 }
289 }
290 let out = self.must_git(args, opts.cwd).await?;
291 Ok(parse_log(&out))
292 }
293}
294
295const GIT_CREDENTIAL_HELPER: &str = r#"!f() { test "$1" = get && printf "username=%s\npassword=%s\n" "$SOLARI_GIT_USERNAME" "$SOLARI_GIT_PASSWORD"; }; f"#;
300
301pub(crate) fn git_auth(
306 remote_url: &str,
307 username: Option<&str>,
308 password: Option<&str>,
309) -> Result<Option<(Vec<String>, HashMap<String, String>)>, SolariError> {
310 let u = username.unwrap_or("");
311 let p = password.unwrap_or("");
312 if u.is_empty() && p.is_empty() {
313 return Ok(None);
314 }
315 if u.contains(['\r', '\n', '\0']) || p.contains(['\r', '\n', '\0']) {
316 return Err(SolariError::Git {
317 message: "git credentials must not contain line breaks or NUL".to_string(),
318 });
319 }
320 let parsed = match url::Url::parse(remote_url) {
323 Ok(x) if x.scheme() == "http" || x.scheme() == "https" => x,
324 _ => return Ok(None),
325 };
326 let Some(host) = parsed.host_str() else {
327 return Ok(None);
328 };
329 let host = if host.contains(':') { format!("[{host}]") } else { host.to_string() };
330 let origin = match parsed.port() {
331 Some(port) => format!("{}://{}:{}", parsed.scheme(), host, port),
332 None => format!("{}://{}", parsed.scheme(), host),
333 };
334 if origin.contains(['=', ' ', '\t', '\r', '\n']) {
335 return Ok(None);
336 }
337 let cfg = vec![
338 "-c".to_string(),
340 "credential.helper=".to_string(),
341 "-c".to_string(),
343 format!("credential.{origin}.helper={GIT_CREDENTIAL_HELPER}"),
344 ];
345 let mut env = HashMap::new();
346 env.insert("GIT_TERMINAL_PROMPT".to_string(), "0".to_string());
347 env.insert("SOLARI_GIT_USERNAME".to_string(), u.to_string());
348 env.insert("SOLARI_GIT_PASSWORD".to_string(), p.to_string());
349 Ok(Some((cfg, env)))
350}
351
352fn git_subcommand(args: &[String]) -> String {
354 let mut i = 0;
355 while i + 1 < args.len() && args[i] == "-c" {
356 i += 2;
357 }
358 args.get(i).cloned().unwrap_or_default()
359}
360
361fn reject_option_like(what: &str, v: &str) -> Result<(), SolariError> {
363 if v.starts_with('-') {
364 return Err(SolariError::Git {
365 message: format!("git {what} must not start with \"-\": {v:?}"),
366 });
367 }
368 Ok(())
369}
370
371pub(crate) fn parse_status(out: &str) -> GitStatus {
373 let mut st = GitStatus {
374 branch: String::new(),
375 detached: false,
376 ahead: 0,
377 behind: 0,
378 staged: Vec::new(),
379 modified: Vec::new(),
380 untracked: Vec::new(),
381 clean: true,
382 };
383 for line in out.split('\n').filter(|l| !l.is_empty()) {
384 if let Some(head) = line.strip_prefix("## ") {
385 if head.starts_with("HEAD (no branch)") {
386 st.detached = true;
387 } else {
388 let body = head
390 .strip_prefix("No commits yet on ")
391 .unwrap_or(head)
392 .to_string();
393 let before_dots = body.split("...").next().unwrap_or("");
394 st.branch = before_dots.split(' ').next().unwrap_or("").to_string();
395 if let (Some(open), Some(close)) = (head.find('['), head.find(']')) {
396 if close > open {
397 let inner = &head[open + 1..close];
398 st.ahead = extract_after(inner, "ahead ").unwrap_or(0);
399 st.behind = extract_after(inner, "behind ").unwrap_or(0);
400 }
401 }
402 }
403 continue;
404 }
405 if line.len() < 3 {
406 continue;
407 }
408 let xy: Vec<char> = line.chars().take(2).collect();
409 let path = &line[3..];
410 if xy[0] == '?' && xy[1] == '?' {
411 st.untracked.push(path.to_string());
412 } else {
413 let index = xy[0];
414 let work = xy[1];
415 if index != ' ' && index != '?' {
416 st.staged.push(path.to_string());
417 }
418 if work != ' ' && work != '?' {
419 st.modified.push(path.to_string());
420 }
421 }
422 }
423 st.clean = st.staged.is_empty() && st.modified.is_empty() && st.untracked.is_empty();
424 st
425}
426
427fn extract_after(s: &str, marker: &str) -> Option<i64> {
429 let idx = s.find(marker)? + marker.len();
430 let digits: String = s[idx..].chars().take_while(|c| c.is_ascii_digit()).collect();
431 digits.parse().ok()
432}
433
434pub(crate) fn parse_branches(out: &str) -> Vec<GitBranch> {
436 let mut branches = Vec::new();
437 for line in out.split('\n') {
438 if line.trim().is_empty() {
439 continue;
440 }
441 let fields: Vec<&str> = line.split('\x1f').collect();
442 let head = fields.first().copied().unwrap_or("");
443 let name = fields.get(1).copied().unwrap_or("");
444 let commit = fields.get(2).copied().unwrap_or("");
445 branches.push(GitBranch {
446 name: name.to_string(),
447 commit: commit.to_string(),
448 current: head == "*",
449 });
450 }
451 branches
452}
453
454pub(crate) fn parse_log(out: &str) -> Vec<GitCommit> {
456 let mut commits = Vec::new();
457 for line in out.split('\n') {
458 if line.trim().is_empty() {
459 continue;
460 }
461 let f: Vec<&str> = line.split('\x1f').collect();
462 commits.push(GitCommit {
463 hash: f.first().copied().unwrap_or("").to_string(),
464 author: f.get(1).copied().unwrap_or("").to_string(),
465 email: f.get(2).copied().unwrap_or("").to_string(),
466 date: f.get(3).copied().unwrap_or("").to_string(),
467 message: f.get(4).copied().unwrap_or("").to_string(),
468 });
469 }
470 commits
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use std::sync::Mutex;
477
478 struct RecordedCall {
479 args: Vec<String>,
480 cwd: Option<String>,
481 env: Option<HashMap<String, String>>,
482 }
483
484 struct StubGit {
487 calls: Mutex<Vec<RecordedCall>>,
488 responder: Box<dyn Fn(&[String]) -> CommandResult + Send + Sync>,
489 }
490
491 impl StubGit {
492 fn new<F>(f: F) -> Self
493 where
494 F: Fn(&[String]) -> CommandResult + Send + Sync + 'static,
495 {
496 StubGit {
497 calls: Mutex::new(Vec::new()),
498 responder: Box::new(f),
499 }
500 }
501 fn ok(stdout: &str) -> CommandResult {
502 CommandResult {
503 exit_code: 0,
504 stdout: stdout.to_string(),
505 stderr: String::new(),
506 timed_out: false,
507 }
508 }
509 }
510
511 impl GitRunner for StubGit {
512 fn run_git<'a>(
513 &'a self,
514 args: Vec<String>,
515 cwd: Option<String>,
516 ) -> Pin<Box<dyn Future<Output = Result<CommandResult, SolariError>> + Send + 'a>> {
517 self.run_git_env(args, cwd, None)
518 }
519
520 fn run_git_env<'a>(
521 &'a self,
522 args: Vec<String>,
523 cwd: Option<String>,
524 env: Option<HashMap<String, String>>,
525 ) -> Pin<Box<dyn Future<Output = Result<CommandResult, SolariError>> + Send + 'a>> {
526 self.calls.lock().unwrap().push(RecordedCall {
527 args: args.clone(),
528 cwd,
529 env,
530 });
531 let r = (self.responder)(&args);
532 Box::pin(async move { Ok(r) })
533 }
534 }
535
536 #[tokio::test]
537 async fn status_parses_branch_ahead_behind_staged_modified_untracked() {
538 let fixture = [
541 "## main...origin/main [ahead 2, behind 1]",
542 "M staged.ts",
543 " M modified.ts",
544 "MM both.ts",
545 "?? new.txt",
546 "",
547 ]
548 .join("\n");
549 let stub = StubGit::new(move |args| {
550 if args[0] == "status" {
551 return StubGit::ok(&fixture);
552 }
553 StubGit::ok("")
554 });
555 let git = Git::new(&stub);
556 let st = git.status(Some("/repo")).await.unwrap();
557 assert_eq!(st.branch, "main");
558 assert_eq!(st.ahead, 2);
559 assert_eq!(st.behind, 1);
560 assert!(!st.detached);
561 assert_eq!(st.staged, vec!["staged.ts", "both.ts"]);
562 assert_eq!(st.modified, vec!["modified.ts", "both.ts"]);
563 assert_eq!(st.untracked, vec!["new.txt"]);
564 assert!(!st.clean);
565 }
566
567 #[tokio::test]
568 async fn status_clean_detached_head() {
569 let stub = StubGit::new(|args| {
570 if args[0] == "status" {
571 return StubGit::ok("## HEAD (no branch)\n");
572 }
573 StubGit::ok("")
574 });
575 let git = Git::new(&stub);
576 let st = git.status(None).await.unwrap();
577 assert!(st.detached);
578 assert!(st.clean);
579 assert_eq!(st.branch, "");
580 }
581
582 #[tokio::test]
583 async fn status_fresh_repo_no_commits() {
584 let stub = StubGit::new(|args| {
585 if args[0] == "status" {
586 return StubGit::ok("## No commits yet on trunk\n?? a.txt\n");
587 }
588 StubGit::ok("")
589 });
590 let git = Git::new(&stub);
591 let st = git.status(None).await.unwrap();
592 assert_eq!(st.branch, "trunk");
593 assert_eq!(st.untracked, vec!["a.txt"]);
594 }
595
596 #[tokio::test]
597 async fn branches_parses_name_commit_current() {
598 let stub = StubGit::new(|args| {
599 if args[0] == "branch" {
600 return StubGit::ok("*\x1fmain\x1fabc1234\n \x1fdev\x1fdef5678\n");
601 }
602 StubGit::ok("")
603 });
604 let git = Git::new(&stub);
605 let branches = git.branches(Some("/repo")).await.unwrap();
606 assert_eq!(
607 branches,
608 vec![
609 GitBranch { name: "main".into(), commit: "abc1234".into(), current: true },
610 GitBranch { name: "dev".into(), commit: "def5678".into(), current: false },
611 ]
612 );
613 let calls = stub.calls.lock().unwrap();
614 assert!(calls[0].args.iter().any(|a| a.contains("%(refname:short)")));
615 }
616
617 #[tokio::test]
618 async fn log_parses_unit_separated_records() {
619 let stub = StubGit::new(|args| {
620 if args[0] == "log" {
621 return StubGit::ok(
622 "h1\x1fAda\x1fada@x\x1f2026-01-01T00:00:00+00:00\x1ffirst\n\
623 h2\x1fLin\x1flin@x\x1f2026-01-02T00:00:00+00:00\x1fsecond\n",
624 );
625 }
626 StubGit::ok("")
627 });
628 let git = Git::new(&stub);
629 let log = git.log(GitLogOptions { cwd: Some("/repo".into()), max_count: Some(2) }).await.unwrap();
630 assert_eq!(log.len(), 2);
631 assert_eq!(
632 log[0],
633 GitCommit {
634 hash: "h1".into(),
635 author: "Ada".into(),
636 email: "ada@x".into(),
637 date: "2026-01-01T00:00:00+00:00".into(),
638 message: "first".into(),
639 }
640 );
641 }
642
643 #[tokio::test]
644 async fn commit_sets_identity_then_resolves_hash() {
645 let stub = StubGit::new(|args| {
646 if args.iter().any(|a| a == "rev-parse") {
647 return StubGit::ok("deadbeef\n");
648 }
649 StubGit::ok("")
650 });
651 let git = Git::new(&stub);
652 let hash = git
653 .commit(
654 "msg",
655 GitCommitOptions {
656 cwd: Some("/repo".into()),
657 author: Some("Ada".into()),
658 email: Some("ada@x".into()),
659 all: true,
660 },
661 )
662 .await
663 .unwrap();
664 assert_eq!(hash, "deadbeef");
665 let calls = stub.calls.lock().unwrap();
666 let commit = calls.iter().find(|c| c.args.iter().any(|a| a == "commit")).unwrap();
667 assert_eq!(
668 commit.args,
669 vec![
670 "-c", "user.name=Ada", "-c", "user.email=ada@x", "commit", "-m", "msg", "-a"
671 ]
672 );
673 }
674
675 #[tokio::test]
676 async fn clone_sends_credentials_by_env_and_origin_bound_helper_never_in_url_or_argv() {
677 let stub = StubGit::new(|_| StubGit::ok(""));
678 let git = Git::new(&stub);
679 git.clone(
680 "https://github.com/acme/repo.git",
681 GitCloneOptions {
682 branch: Some("main".into()),
683 depth: Some(1),
684 username: Some("u".into()),
685 password: Some("p@ss word".into()),
686 path: Some("dest".into()),
687 cwd: Some("/work".into()),
688 },
689 )
690 .await
691 .unwrap();
692 let calls = stub.calls.lock().unwrap();
693 let c = &calls[0];
694 assert_eq!(c.cwd.as_deref(), Some("/work"));
695 assert_eq!(&c.args[0..3], &["-c", "credential.helper=", "-c"]);
696 assert!(c.args[3].starts_with("credential.https://github.com.helper=!f()"), "got {}", c.args[3]);
697 assert_eq!(
698 &c.args[4..],
699 &["clone", "--depth", "1", "--branch", "main", "--", "https://github.com/acme/repo.git", "dest"]
700 );
701 let env = c.env.as_ref().unwrap();
702 assert_eq!(env["SOLARI_GIT_USERNAME"], "u");
703 assert_eq!(env["SOLARI_GIT_PASSWORD"], "p@ss word");
704 assert_eq!(env["GIT_TERMINAL_PROMPT"], "0");
705 let argv = c.args.join(" ");
706 assert!(!argv.contains("p@ss") && !argv.contains("p%40ss") && !argv.contains("u:"), "credential in argv: {argv}");
707 }
708
709 #[tokio::test]
710 async fn clone_puts_dashdash_before_a_user_url_and_sends_no_helper_without_creds() {
711 let stub = StubGit::new(|_| StubGit::ok(""));
712 let git = Git::new(&stub);
713 git.clone(
714 "--upload-pack=touch /tmp/pwned",
715 GitCloneOptions { path: Some("dest".into()), ..Default::default() },
716 )
717 .await
718 .unwrap();
719 let calls = stub.calls.lock().unwrap();
720 assert_eq!(calls[0].args, vec!["clone", "--", "--upload-pack=touch /tmp/pwned", "dest"]);
721 assert!(calls[0].env.is_none());
722 }
723
724 #[tokio::test]
725 async fn credentials_with_line_breaks_are_refused_and_the_password_is_masked_in_errors() {
726 let stub = StubGit::new(|_| StubGit::ok(""));
727 let git = Git::new(&stub);
728 let err = git
729 .clone(
730 "https://h/r.git",
731 GitCloneOptions { username: Some("u".into()), password: Some("a\nurl=https://evil".into()), ..Default::default() },
732 )
733 .await
734 .unwrap_err();
735 assert!(err.to_string().contains("line breaks"), "got {err}");
736 let stub2 = StubGit::new(|_| CommandResult {
737 exit_code: 128,
738 stdout: String::new(),
739 stderr: "fatal: unable to access: hunter2 rejected".into(),
740 timed_out: false,
741 });
742 let err = Git::new(&stub2)
743 .clone(
744 "https://h/r.git",
745 GitCloneOptions { username: Some("u".into()), password: Some("hunter2".into()), ..Default::default() },
746 )
747 .await
748 .unwrap_err()
749 .to_string();
750 assert!(err.contains("git clone failed") && !err.contains("hunter2"), "got {err}");
751 }
752
753 #[tokio::test]
754 async fn push_resolves_the_remote_url_binds_the_helper_to_its_origin_and_keeps_the_secret_out_of_argv() {
755 let stub = StubGit::new(|args| {
756 if args[0] == "remote" {
757 return StubGit::ok("https://git.example.com:8443/acme/repo.git\n");
758 }
759 StubGit::ok("")
760 });
761 let git = Git::new(&stub);
762 git.push(GitRemoteOptions {
763 cwd: Some("/repo".into()),
764 username: Some("u".into()),
765 password: Some("p@ss".into()),
766 branch: Some("main".into()),
767 ..Default::default()
768 })
769 .await
770 .unwrap();
771 let calls = stub.calls.lock().unwrap();
772 assert_eq!(calls[0].args, vec!["remote", "get-url", "--", "origin"]);
773 assert!(calls[0].env.is_none());
774 let push = &calls[1].args;
775 assert_eq!(&push[0..2], &["-c", "credential.helper="]);
776 assert!(push[3].starts_with("credential.https://git.example.com:8443.helper=!f()"), "got {}", push[3]);
777 assert_eq!(&push[4..], &["push", "--", "origin", "main"]);
778 assert_eq!(calls[1].env.as_ref().unwrap()["SOLARI_GIT_PASSWORD"], "p@ss");
779 assert!(!push.join(" ").contains("p@ss"));
780 }
781
782 #[tokio::test]
783 async fn credentials_are_not_used_for_an_ssh_remote_and_a_plain_push_is_plain() {
784 let stub = StubGit::new(|args| {
785 if args[0] == "remote" {
786 return StubGit::ok("git@github.com:acme/repo.git\n");
787 }
788 StubGit::ok("")
789 });
790 let git = Git::new(&stub);
791 git.pull(GitRemoteOptions {
792 username: Some("u".into()),
793 password: Some("p".into()),
794 remote: Some("upstream".into()),
795 ..Default::default()
796 })
797 .await
798 .unwrap();
799 git.push(GitRemoteOptions::default()).await.unwrap();
800 let calls = stub.calls.lock().unwrap();
801 assert_eq!(calls[0].args, vec!["remote", "get-url", "--", "upstream"]);
802 assert_eq!(calls[1].args, vec!["pull", "--", "upstream"]);
803 assert!(calls[1].env.is_none());
804 assert_eq!(calls[2].args, vec!["push", "--", "origin"]);
805 }
806
807 #[tokio::test]
808 async fn option_like_remotes_branches_and_refs_are_refused_before_anything_runs() {
809 let stub = StubGit::new(|_| StubGit::ok(""));
810 let git = Git::new(&stub);
811 assert!(git.push(GitRemoteOptions { remote: Some("--receive-pack=x".into()), ..Default::default() }).await.is_err());
812 assert!(git.pull(GitRemoteOptions { branch: Some("--rebase".into()), ..Default::default() }).await.is_err());
813 assert!(git.checkout("--orphan=x", None, false).await.is_err());
814 git.checkout("dev", Some("/repo"), true).await.unwrap();
815 let calls = stub.calls.lock().unwrap();
816 assert_eq!(calls.len(), 1, "only the valid checkout reached the runner");
817 assert_eq!(calls[0].args, vec!["checkout", "-b", "dev", "--"]);
818 }
819
820 #[tokio::test]
821 async fn add_noop_on_empty_and_dashdash_separator() {
822 let stub = StubGit::new(|_| StubGit::ok(""));
823 let git = Git::new(&stub);
824 git.add(&[], None).await.unwrap();
825 assert_eq!(stub.calls.lock().unwrap().len(), 0);
826 git.add(&["a.ts".into(), "-weird.ts".into()], Some("/repo")).await.unwrap();
827 let calls = stub.calls.lock().unwrap();
828 assert_eq!(calls[0].args, vec!["add", "--", "a.ts", "-weird.ts"]);
829 }
830
831 #[tokio::test]
832 async fn nonzero_exit_surfaces_error_with_stderr() {
833 let stub = StubGit::new(|_| CommandResult {
834 exit_code: 128,
835 stdout: String::new(),
836 stderr: "fatal: not a git repository".into(),
837 timed_out: false,
838 });
839 let git = Git::new(&stub);
840 let err = git.status(Some("/nope")).await.unwrap_err();
841 let msg = err.to_string();
842 assert!(msg.contains("not a git repository"), "got {msg}");
843 assert!(msg.contains("git status failed (exit 128)"), "got {msg}");
844 }
845}