1use std::future::Future;
10use std::pin::Pin;
11
12use crate::error::SolariError;
13use crate::http::encode_uri_component;
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
26#[derive(Default, Clone)]
28pub struct GitCloneOptions {
29 pub path: Option<String>,
30 pub branch: Option<String>,
31 pub depth: Option<i64>,
32 pub username: Option<String>,
33 pub password: Option<String>,
34 pub cwd: Option<String>,
35}
36
37#[derive(Default, Clone)]
39pub struct GitCommitOptions {
40 pub cwd: Option<String>,
41 pub author: Option<String>,
42 pub email: Option<String>,
43 pub all: bool,
44}
45
46#[derive(Default, Clone)]
48pub struct GitRemoteOptions {
49 pub cwd: Option<String>,
50 pub remote: Option<String>,
51 pub branch: Option<String>,
52 pub username: Option<String>,
53 pub password: Option<String>,
54}
55
56#[derive(Default, Clone)]
58pub struct GitLogOptions {
59 pub cwd: Option<String>,
60 pub max_count: Option<i64>,
61}
62
63pub struct Git<'a> {
65 runner: &'a dyn GitRunner,
66}
67
68impl<'a> Git<'a> {
69 pub fn new(runner: &'a dyn GitRunner) -> Self {
70 Git { runner }
71 }
72
73 async fn must_git(&self, args: Vec<String>, cwd: Option<String>) -> Result<String, SolariError> {
75 let sub = args.first().cloned().unwrap_or_default();
76 let r = self.runner.run_git(args, cwd).await?;
77 if r.exit_code != 0 {
78 let detail = {
79 let s = r.stderr.trim();
80 if !s.is_empty() {
81 s.to_string()
82 } else {
83 r.stdout.trim().to_string()
84 }
85 };
86 return Err(SolariError::Git {
87 message: format!("git {} failed (exit {}): {}", sub, r.exit_code, detail),
88 });
89 }
90 Ok(r.stdout)
91 }
92
93 pub async fn clone(&self, url: &str, opts: GitCloneOptions) -> Result<(), SolariError> {
95 let mut args = vec!["clone".to_string()];
96 if let Some(d) = opts.depth {
97 if d > 0 {
98 args.push("--depth".into());
99 args.push(d.to_string());
100 }
101 }
102 if let Some(b) = &opts.branch {
103 args.push("--branch".into());
104 args.push(b.clone());
105 }
106 args.push(auth_url(url, opts.username.as_deref(), opts.password.as_deref()));
107 if let Some(p) = &opts.path {
108 args.push(p.clone());
109 }
110 self.must_git(args, opts.cwd).await.map(|_| ())
111 }
112
113 pub async fn status(&self, cwd: Option<&str>) -> Result<GitStatus, SolariError> {
115 let out = self
116 .must_git(
117 vec![
118 "status".into(),
119 "--porcelain=v1".into(),
120 "--branch".into(),
121 ],
122 cwd.map(String::from),
123 )
124 .await?;
125 Ok(parse_status(&out))
126 }
127
128 pub async fn add(&self, paths: &[String], cwd: Option<&str>) -> Result<(), SolariError> {
130 if paths.is_empty() {
131 return Ok(());
132 }
133 let mut args = vec!["add".to_string(), "--".to_string()];
134 args.extend(paths.iter().cloned());
135 self.must_git(args, cwd.map(String::from)).await.map(|_| ())
136 }
137
138 pub async fn commit(
140 &self,
141 message: &str,
142 opts: GitCommitOptions,
143 ) -> Result<String, SolariError> {
144 let mut args: Vec<String> = Vec::new();
145 if let Some(a) = &opts.author {
146 args.push("-c".into());
147 args.push(format!("user.name={a}"));
148 }
149 if let Some(e) = &opts.email {
150 args.push("-c".into());
151 args.push(format!("user.email={e}"));
152 }
153 args.push("commit".into());
154 args.push("-m".into());
155 args.push(message.to_string());
156 if opts.all {
157 args.push("-a".into());
158 }
159 self.must_git(args, opts.cwd.clone()).await?;
160 let hash = self
161 .must_git(vec!["rev-parse".into(), "HEAD".into()], opts.cwd)
162 .await?
163 .trim()
164 .to_string();
165 Ok(hash)
166 }
167
168 pub async fn push(&self, opts: GitRemoteOptions) -> Result<(), SolariError> {
170 self.push_pull("push", opts).await
171 }
172
173 pub async fn pull(&self, opts: GitRemoteOptions) -> Result<(), SolariError> {
175 self.push_pull("pull", opts).await
176 }
177
178 async fn push_pull(&self, op: &str, opts: GitRemoteOptions) -> Result<(), SolariError> {
179 let remote = opts.remote.clone().unwrap_or_else(|| "origin".to_string());
180 let mut cfg: Vec<String> = Vec::new();
181 if opts.username.is_some() || opts.password.is_some() {
182 let plain = self
184 .runner
185 .run_git(
186 vec!["remote".into(), "get-url".into(), remote.clone()],
187 opts.cwd.clone(),
188 )
189 .await?
190 .stdout
191 .trim()
192 .to_string();
193 if !plain.is_empty() {
194 let authed = auth_url(&plain, opts.username.as_deref(), opts.password.as_deref());
195 if authed != plain {
196 cfg.push("-c".into());
197 cfg.push(format!("url.{authed}.insteadOf={plain}"));
198 }
199 }
200 }
201 let mut args = cfg;
202 args.push(op.to_string());
203 args.push(remote);
204 if let Some(b) = &opts.branch {
205 args.push(b.clone());
206 }
207 self.must_git(args, opts.cwd).await.map(|_| ())
208 }
209
210 pub async fn checkout(
212 &self,
213 r#ref: &str,
214 cwd: Option<&str>,
215 create: bool,
216 ) -> Result<(), SolariError> {
217 let mut args = vec!["checkout".to_string()];
218 if create {
219 args.push("-b".into());
220 }
221 args.push(r#ref.to_string());
222 self.must_git(args, cwd.map(String::from)).await.map(|_| ())
223 }
224
225 pub async fn branches(&self, cwd: Option<&str>) -> Result<Vec<GitBranch>, SolariError> {
227 let out = self
228 .must_git(
229 vec![
230 "branch".into(),
231 "--format=%(HEAD)%1f%(refname:short)%1f%(objectname:short)".into(),
232 ],
233 cwd.map(String::from),
234 )
235 .await?;
236 Ok(parse_branches(&out))
237 }
238
239 pub async fn log(&self, opts: GitLogOptions) -> Result<Vec<GitCommit>, SolariError> {
241 let mut args = vec![
242 "log".to_string(),
243 "--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s".to_string(),
244 ];
245 if let Some(n) = opts.max_count {
246 if n > 0 {
247 args.push(format!("--max-count={n}"));
248 }
249 }
250 let out = self.must_git(args, opts.cwd).await?;
251 Ok(parse_log(&out))
252 }
253}
254
255pub(crate) fn auth_url(url: &str, username: Option<&str>, password: Option<&str>) -> String {
260 let has_user = username.map(|u| !u.is_empty()).unwrap_or(false);
261 let has_pass = password.map(|p| !p.is_empty()).unwrap_or(false);
262 if !has_user && !has_pass {
263 return url.to_string();
264 }
265 let mut parsed = match url::Url::parse(url) {
266 Ok(u) => u,
267 Err(_) => return url.to_string(),
268 };
269 if let Some(u) = username {
270 if parsed.set_username(&encode_uri_component(u)).is_err() {
272 return url.to_string();
273 }
274 }
275 if let Some(p) = password {
276 if parsed.set_password(Some(&encode_uri_component(p))).is_err() {
277 return url.to_string();
278 }
279 }
280 parsed.to_string()
281}
282
283pub(crate) fn parse_status(out: &str) -> GitStatus {
285 let mut st = GitStatus {
286 branch: String::new(),
287 detached: false,
288 ahead: 0,
289 behind: 0,
290 staged: Vec::new(),
291 modified: Vec::new(),
292 untracked: Vec::new(),
293 clean: true,
294 };
295 for line in out.split('\n').filter(|l| !l.is_empty()) {
296 if let Some(head) = line.strip_prefix("## ") {
297 if head.starts_with("HEAD (no branch)") {
298 st.detached = true;
299 } else {
300 let body = head
302 .strip_prefix("No commits yet on ")
303 .unwrap_or(head)
304 .to_string();
305 let before_dots = body.split("...").next().unwrap_or("");
306 st.branch = before_dots.split(' ').next().unwrap_or("").to_string();
307 if let (Some(open), Some(close)) = (head.find('['), head.find(']')) {
308 if close > open {
309 let inner = &head[open + 1..close];
310 st.ahead = extract_after(inner, "ahead ").unwrap_or(0);
311 st.behind = extract_after(inner, "behind ").unwrap_or(0);
312 }
313 }
314 }
315 continue;
316 }
317 if line.len() < 3 {
318 continue;
319 }
320 let xy: Vec<char> = line.chars().take(2).collect();
321 let path = &line[3..];
322 if xy[0] == '?' && xy[1] == '?' {
323 st.untracked.push(path.to_string());
324 } else {
325 let index = xy[0];
326 let work = xy[1];
327 if index != ' ' && index != '?' {
328 st.staged.push(path.to_string());
329 }
330 if work != ' ' && work != '?' {
331 st.modified.push(path.to_string());
332 }
333 }
334 }
335 st.clean = st.staged.is_empty() && st.modified.is_empty() && st.untracked.is_empty();
336 st
337}
338
339fn extract_after(s: &str, marker: &str) -> Option<i64> {
341 let idx = s.find(marker)? + marker.len();
342 let digits: String = s[idx..].chars().take_while(|c| c.is_ascii_digit()).collect();
343 digits.parse().ok()
344}
345
346pub(crate) fn parse_branches(out: &str) -> Vec<GitBranch> {
348 let mut branches = Vec::new();
349 for line in out.split('\n') {
350 if line.trim().is_empty() {
351 continue;
352 }
353 let fields: Vec<&str> = line.split('\x1f').collect();
354 let head = fields.first().copied().unwrap_or("");
355 let name = fields.get(1).copied().unwrap_or("");
356 let commit = fields.get(2).copied().unwrap_or("");
357 branches.push(GitBranch {
358 name: name.to_string(),
359 commit: commit.to_string(),
360 current: head == "*",
361 });
362 }
363 branches
364}
365
366pub(crate) fn parse_log(out: &str) -> Vec<GitCommit> {
368 let mut commits = Vec::new();
369 for line in out.split('\n') {
370 if line.trim().is_empty() {
371 continue;
372 }
373 let f: Vec<&str> = line.split('\x1f').collect();
374 commits.push(GitCommit {
375 hash: f.first().copied().unwrap_or("").to_string(),
376 author: f.get(1).copied().unwrap_or("").to_string(),
377 email: f.get(2).copied().unwrap_or("").to_string(),
378 date: f.get(3).copied().unwrap_or("").to_string(),
379 message: f.get(4).copied().unwrap_or("").to_string(),
380 });
381 }
382 commits
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use std::sync::Mutex;
389
390 struct RecordedCall {
391 args: Vec<String>,
392 cwd: Option<String>,
393 }
394
395 struct StubGit {
398 calls: Mutex<Vec<RecordedCall>>,
399 responder: Box<dyn Fn(&[String]) -> CommandResult + Send + Sync>,
400 }
401
402 impl StubGit {
403 fn new<F>(f: F) -> Self
404 where
405 F: Fn(&[String]) -> CommandResult + Send + Sync + 'static,
406 {
407 StubGit {
408 calls: Mutex::new(Vec::new()),
409 responder: Box::new(f),
410 }
411 }
412 fn ok(stdout: &str) -> CommandResult {
413 CommandResult {
414 exit_code: 0,
415 stdout: stdout.to_string(),
416 stderr: String::new(),
417 }
418 }
419 }
420
421 impl GitRunner for StubGit {
422 fn run_git<'a>(
423 &'a self,
424 args: Vec<String>,
425 cwd: Option<String>,
426 ) -> Pin<Box<dyn Future<Output = Result<CommandResult, SolariError>> + Send + 'a>> {
427 self.calls.lock().unwrap().push(RecordedCall {
428 args: args.clone(),
429 cwd,
430 });
431 let r = (self.responder)(&args);
432 Box::pin(async move { Ok(r) })
433 }
434 }
435
436 #[tokio::test]
437 async fn status_parses_branch_ahead_behind_staged_modified_untracked() {
438 let fixture = [
441 "## main...origin/main [ahead 2, behind 1]",
442 "M staged.ts",
443 " M modified.ts",
444 "MM both.ts",
445 "?? new.txt",
446 "",
447 ]
448 .join("\n");
449 let stub = StubGit::new(move |args| {
450 if args[0] == "status" {
451 return StubGit::ok(&fixture);
452 }
453 StubGit::ok("")
454 });
455 let git = Git::new(&stub);
456 let st = git.status(Some("/repo")).await.unwrap();
457 assert_eq!(st.branch, "main");
458 assert_eq!(st.ahead, 2);
459 assert_eq!(st.behind, 1);
460 assert!(!st.detached);
461 assert_eq!(st.staged, vec!["staged.ts", "both.ts"]);
462 assert_eq!(st.modified, vec!["modified.ts", "both.ts"]);
463 assert_eq!(st.untracked, vec!["new.txt"]);
464 assert!(!st.clean);
465 }
466
467 #[tokio::test]
468 async fn status_clean_detached_head() {
469 let stub = StubGit::new(|args| {
470 if args[0] == "status" {
471 return StubGit::ok("## HEAD (no branch)\n");
472 }
473 StubGit::ok("")
474 });
475 let git = Git::new(&stub);
476 let st = git.status(None).await.unwrap();
477 assert!(st.detached);
478 assert!(st.clean);
479 assert_eq!(st.branch, "");
480 }
481
482 #[tokio::test]
483 async fn status_fresh_repo_no_commits() {
484 let stub = StubGit::new(|args| {
485 if args[0] == "status" {
486 return StubGit::ok("## No commits yet on trunk\n?? a.txt\n");
487 }
488 StubGit::ok("")
489 });
490 let git = Git::new(&stub);
491 let st = git.status(None).await.unwrap();
492 assert_eq!(st.branch, "trunk");
493 assert_eq!(st.untracked, vec!["a.txt"]);
494 }
495
496 #[tokio::test]
497 async fn branches_parses_name_commit_current() {
498 let stub = StubGit::new(|args| {
499 if args[0] == "branch" {
500 return StubGit::ok("*\x1fmain\x1fabc1234\n \x1fdev\x1fdef5678\n");
501 }
502 StubGit::ok("")
503 });
504 let git = Git::new(&stub);
505 let branches = git.branches(Some("/repo")).await.unwrap();
506 assert_eq!(
507 branches,
508 vec![
509 GitBranch { name: "main".into(), commit: "abc1234".into(), current: true },
510 GitBranch { name: "dev".into(), commit: "def5678".into(), current: false },
511 ]
512 );
513 let calls = stub.calls.lock().unwrap();
514 assert!(calls[0].args.iter().any(|a| a.contains("%(refname:short)")));
515 }
516
517 #[tokio::test]
518 async fn log_parses_unit_separated_records() {
519 let stub = StubGit::new(|args| {
520 if args[0] == "log" {
521 return StubGit::ok(
522 "h1\x1fAda\x1fada@x\x1f2026-01-01T00:00:00+00:00\x1ffirst\n\
523 h2\x1fLin\x1flin@x\x1f2026-01-02T00:00:00+00:00\x1fsecond\n",
524 );
525 }
526 StubGit::ok("")
527 });
528 let git = Git::new(&stub);
529 let log = git.log(GitLogOptions { cwd: Some("/repo".into()), max_count: Some(2) }).await.unwrap();
530 assert_eq!(log.len(), 2);
531 assert_eq!(
532 log[0],
533 GitCommit {
534 hash: "h1".into(),
535 author: "Ada".into(),
536 email: "ada@x".into(),
537 date: "2026-01-01T00:00:00+00:00".into(),
538 message: "first".into(),
539 }
540 );
541 }
542
543 #[tokio::test]
544 async fn commit_sets_identity_then_resolves_hash() {
545 let stub = StubGit::new(|args| {
546 if args.iter().any(|a| a == "rev-parse") {
547 return StubGit::ok("deadbeef\n");
548 }
549 StubGit::ok("")
550 });
551 let git = Git::new(&stub);
552 let hash = git
553 .commit(
554 "msg",
555 GitCommitOptions {
556 cwd: Some("/repo".into()),
557 author: Some("Ada".into()),
558 email: Some("ada@x".into()),
559 all: true,
560 },
561 )
562 .await
563 .unwrap();
564 assert_eq!(hash, "deadbeef");
565 let calls = stub.calls.lock().unwrap();
566 let commit = calls.iter().find(|c| c.args.iter().any(|a| a == "commit")).unwrap();
567 assert_eq!(
568 commit.args,
569 vec![
570 "-c", "user.name=Ada", "-c", "user.email=ada@x", "commit", "-m", "msg", "-a"
571 ]
572 );
573 }
574
575 #[tokio::test]
576 async fn clone_injects_basic_auth_into_https_remote() {
577 let stub = StubGit::new(|_| StubGit::ok(""));
578 let git = Git::new(&stub);
579 git.clone(
580 "https://github.com/acme/repo.git",
581 GitCloneOptions {
582 branch: Some("main".into()),
583 depth: Some(1),
584 username: Some("u".into()),
585 password: Some("p@ss word".into()),
586 path: Some("dest".into()),
587 cwd: Some("/work".into()),
588 },
589 )
590 .await
591 .unwrap();
592 let calls = stub.calls.lock().unwrap();
593 let c = &calls[0];
594 assert_eq!(c.cwd.as_deref(), Some("/work"));
595 assert_eq!(&c.args[0..5], &["clone", "--depth", "1", "--branch", "main"]);
596 let url = &c.args[5];
597 assert!(
598 url.starts_with("https://u:p%40ss%20word@github.com/"),
599 "got {url}"
600 );
601 assert_eq!(c.args[6], "dest");
602 }
603
604 #[tokio::test]
605 async fn add_noop_on_empty_and_dashdash_separator() {
606 let stub = StubGit::new(|_| StubGit::ok(""));
607 let git = Git::new(&stub);
608 git.add(&[], None).await.unwrap();
609 assert_eq!(stub.calls.lock().unwrap().len(), 0);
610 git.add(&["a.ts".into(), "-weird.ts".into()], Some("/repo")).await.unwrap();
611 let calls = stub.calls.lock().unwrap();
612 assert_eq!(calls[0].args, vec!["add", "--", "a.ts", "-weird.ts"]);
613 }
614
615 #[tokio::test]
616 async fn nonzero_exit_surfaces_error_with_stderr() {
617 let stub = StubGit::new(|_| CommandResult {
618 exit_code: 128,
619 stdout: String::new(),
620 stderr: "fatal: not a git repository".into(),
621 });
622 let git = Git::new(&stub);
623 let err = git.status(Some("/nope")).await.unwrap_err();
624 let msg = err.to_string();
625 assert!(msg.contains("not a git repository"), "got {msg}");
626 assert!(msg.contains("git status failed (exit 128)"), "got {msg}");
627 }
628
629 #[test]
630 fn auth_url_unchanged_without_creds_or_on_parse_failure() {
631 assert_eq!(auth_url("https://x/y.git", None, None), "https://x/y.git");
632 assert_eq!(
634 auth_url("git@github.com:acme/repo.git", Some("u"), Some("p")),
635 "git@github.com:acme/repo.git"
636 );
637 }
638}