Skip to main content

solari/
git.rs

1//! `git` namespace: safe, non-shell `git` invocations composed over the command
2//! runner, with client-side parsers. Ported EXACTLY from the `git` namespace in
3//! `handle.ts` (arg construction + status/branches/log parsers + auth-URL).
4//!
5//! The runner is abstracted behind [`GitRunner`] so the parsers/args can be
6//! unit-tested with a stub that returns canned [`CommandResult`]s (no live WS),
7//! exactly like the TS `git.test.mjs` stubs `runCommand`.
8
9use 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
16/// The seam the git namespace runs commands through. `Sandbox` implements it by
17/// delegating to `commands.run("git", …)`; tests implement a recording stub.
18pub 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/// Options for `git.clone`.
27#[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/// Options for `git.commit`.
38#[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/// Options for `git.push` / `git.pull`.
47#[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/// Options for `git.log`.
57#[derive(Default, Clone)]
58pub struct GitLogOptions {
59    pub cwd: Option<String>,
60    pub max_count: Option<i64>,
61}
62
63/// The ergonomic `git` accessor returned by `Sandbox::git()`.
64pub 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    /// Run `git <args>`; return stdout, or an error with stderr on non-zero exit.
74    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    /// Clone `url` into `opts.path` under `opts.cwd`.
94    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    /// Parsed working-tree status.
114    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    /// Stage `paths` (no-op on empty; uses `--` to guard flag-like paths).
129    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    /// Commit staged changes; returns the new commit hash.
139    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    /// Push to a remote (default `origin`).
169    pub async fn push(&self, opts: GitRemoteOptions) -> Result<(), SolariError> {
170        self.push_pull("push", opts).await
171    }
172
173    /// Pull from a remote (default `origin`).
174    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            // One-off HTTPS auth without persisting a credential.
183            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    /// Check out an existing ref, or create a branch with `create: true`.
211    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    /// List local branches.
226    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    /// Recent commits, newest first.
240    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
255// --- pure parsers + arg helpers (directly unit-testable) --------------------
256
257/// Splice basic-auth creds into an https remote URL. On any parse failure,
258/// returns the URL unchanged. `p@ss word` → `p%40ss%20word`.
259pub(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        // encodeURIComponent, then set — mirrors the TS `u.username = encodeURIComponent(...)`.
271        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
283/// Parse `git status --porcelain=v1 --branch` output.
284pub(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                // "main...origin/main [ahead 1, behind 2]" | "main" | "No commits yet on main"
301                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
339/// Read the integer immediately following `marker` inside `s` (e.g. "ahead 2").
340fn 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
346/// Parse the `%(HEAD)%1f%(refname:short)%1f%(objectname:short)` branch format.
347pub(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
366/// Parse the unit-separated `git log` format.
367pub(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    /// Stub runner mirroring `stubGit` in git.test.mjs: records calls and maps
396    /// `git <args>` → a canned CommandResult via a responder closure.
397    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        // NOTE: leading spaces in porcelain lines are significant, so this is
439        // built by concatenation (a `\`-newline continuation would eat them).
440        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        // scp-style / non-URL: left untouched.
633        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}