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::collections::HashMap;
10use std::future::Future;
11use std::pin::Pin;
12
13use crate::error::SolariError;
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    /// Like [`run_git`](GitRunner::run_git) with environment variables for the command (used to hand credentials to
26    /// git). The default refuses rather than dropping them: a runner that cannot pass an environment must not run an
27    /// authenticated command as an unauthenticated one.
28    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/// Options for `git.clone`.
46#[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/// Options for `git.commit`.
57#[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/// Options for `git.push` / `git.pull`.
66#[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/// Options for `git.log`.
76#[derive(Default, Clone)]
77pub struct GitLogOptions {
78    pub cwd: Option<String>,
79    pub max_count: Option<i64>,
80}
81
82/// The ergonomic `git` accessor returned by `Sandbox::git()`.
83pub 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    /// Run `git <args>`; return stdout, or an error with stderr on non-zero exit.
93    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    /// As [`must_git`](Self::must_git) with an environment for the command; a `secret` is masked in the message.
98    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    /// Clone `url` into `opts.path` under `opts.cwd`.
127    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        // `--` ends options: a url such as "--upload-pack=cmd" is then a (bad) repository, not an option.
142        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    /// Parsed working-tree status.
153    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    /// Stage `paths` (no-op on empty; uses `--` to guard flag-like paths).
168    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    /// Commit staged changes; returns the new commit hash.
178    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    /// Push to a remote (default `origin`).
208    pub async fn push(&self, opts: GitRemoteOptions) -> Result<(), SolariError> {
209        self.push_pull("push", opts).await
210    }
211
212    /// Pull from a remote (default `origin`).
213    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            // The helper is bound to the remote's origin, so resolve the remote's URL first.
226            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    /// Check out an existing ref, or create a branch with `create: true`.
247    pub async fn checkout(
248        &self,
249        r#ref: &str,
250        cwd: Option<&str>,
251        create: bool,
252    ) -> Result<(), SolariError> {
253        // A ref that starts with "-" would be read as an option ("--orphan=x" created a branch). Git never allows one
254        // as a branch name, so refuse it rather than guess.
255        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    /// List local branches.
266    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    /// Recent commits, newest first.
280    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
295// --- pure parsers + arg helpers (directly unit-testable) --------------------
296
297/// Inline git credential helper: answers `get` from the command's environment, ignores `store` / `erase`, and holds no
298/// secret.
299const GIT_CREDENTIAL_HELPER: &str = r#"!f() { test "$1" = get && printf "username=%s\npassword=%s\n" "$SOLARI_GIT_USERNAME" "$SOLARI_GIT_PASSWORD"; }; f"#;
300
301/// The `-c` options and environment for ONE git command that must authenticate to a private https remote. The
302/// credentials travel as environment variables of that command and git reads them back through the inline helper, so
303/// they are never in the URL (and so not in `.git/config`, a snapshot or a template), never in argv
304/// (`/proc/<pid>/cmdline`) and never in a config file. `None` when there are no credentials.
305pub(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    // The helper is bound to the remote's origin, so git never offers the password to another host: a redirect to a
321    // different host, or a downgrade to http, gets nothing. Not http(s) (ssh, scp-style): no credentials apply.
322    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        // The empty value first clears any helper configured on the guest, so only ours answers...
339        "-c".to_string(),
340        "credential.helper=".to_string(),
341        // ...and only for this origin.
342        "-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
352/// The git subcommand in an argv that may start with `-c k=v` pairs.
353fn 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
361/// Refuse user text that git would parse as an option.
362fn 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
371/// Parse `git status --porcelain=v1 --branch` output.
372pub(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                // "main...origin/main [ahead 1, behind 2]" | "main" | "No commits yet on main"
389                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
427/// Read the integer immediately following `marker` inside `s` (e.g. "ahead 2").
428fn 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
434/// Parse the `%(HEAD)%1f%(refname:short)%1f%(objectname:short)` branch format.
435pub(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
454/// Parse the unit-separated `git log` format.
455pub(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    /// Stub runner mirroring `stubGit` in git.test.mjs: records calls and maps
485    /// `git <args>` → a canned CommandResult via a responder closure.
486    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        // NOTE: leading spaces in porcelain lines are significant, so this is
539        // built by concatenation (a `\`-newline continuation would eat them).
540        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}