Skip to main content

nexo_driver_loop/workspace/
git.rs

1//! Thin shell-out helpers around `git`. Each helper wraps a single
2//! command, sets `GIT_AUTHOR/COMMITTER` env so commits don't fail
3//! when the operator's repo lacks `user.email`, and maps non-zero
4//! exits + timeouts to `DriverError::Workspace`.
5
6use std::path::Path;
7use std::time::Duration;
8
9use crate::acceptance::ShellRunner;
10use crate::error::DriverError;
11
12const GIT_AUTHOR_ENV: &str = "GIT_AUTHOR_NAME=nexo-driver \
13                              GIT_AUTHOR_EMAIL=nexo-driver@localhost \
14                              GIT_COMMITTER_NAME=nexo-driver \
15                              GIT_COMMITTER_EMAIL=nexo-driver@localhost";
16
17const DEFAULT_GIT_TIMEOUT: Duration = Duration::from_secs(30);
18
19#[allow(dead_code)] // 67.x will use this; tests already exercise it.
20pub(crate) async fn is_repo(shell: &ShellRunner, path: &Path) -> bool {
21    let res = shell
22        .run(
23            "git rev-parse --is-inside-work-tree 2>/dev/null",
24            path,
25            DEFAULT_GIT_TIMEOUT,
26        )
27        .await;
28    matches!(res, Ok(r) if r.exit_code == Some(0) && r.stdout.trim() == "true")
29}
30
31pub(crate) async fn worktree_add(
32    shell: &ShellRunner,
33    source_repo: &Path,
34    branch: &str,
35    target: &Path,
36    base_ref: &str,
37) -> Result<(), DriverError> {
38    let cmd = format!(
39        "git -C {src} worktree add --quiet -B {branch} {target} {base}",
40        src = quote(&source_repo.display().to_string()),
41        branch = quote(branch),
42        target = quote(&target.display().to_string()),
43        base = quote(base_ref),
44    );
45    let res = shell.run(&cmd, source_repo, DEFAULT_GIT_TIMEOUT).await?;
46    if res.timed_out || res.exit_code != Some(0) {
47        return Err(DriverError::Workspace(format!(
48            "git worktree add failed (exit {:?}): {}",
49            res.exit_code,
50            res.stderr.trim()
51        )));
52    }
53    Ok(())
54}
55
56pub(crate) async fn worktree_remove(
57    shell: &ShellRunner,
58    source_repo: &Path,
59    target: &Path,
60) -> Result<(), DriverError> {
61    let cmd = format!(
62        "git -C {src} worktree remove --force {target} 2>&1 || true",
63        src = quote(&source_repo.display().to_string()),
64        target = quote(&target.display().to_string()),
65    );
66    // Best-effort: never fail.
67    let _ = shell.run(&cmd, source_repo, DEFAULT_GIT_TIMEOUT).await;
68    Ok(())
69}
70
71pub(crate) async fn commit_all_with_label(
72    shell: &ShellRunner,
73    workspace: &Path,
74    label: &str,
75) -> Result<String, DriverError> {
76    let cmd = format!(
77        "{env} git add -A && {env} git commit -q --allow-empty -m {msg} && git rev-parse HEAD",
78        env = GIT_AUTHOR_ENV,
79        msg = quote(label),
80    );
81    let res = shell.run(&cmd, workspace, DEFAULT_GIT_TIMEOUT).await?;
82    if res.timed_out || res.exit_code != Some(0) {
83        return Err(DriverError::Workspace(format!(
84            "git commit failed (exit {:?}): {}",
85            res.exit_code,
86            res.stderr.trim()
87        )));
88    }
89    Ok(res.stdout.trim().to_string())
90}
91
92pub(crate) async fn reset_hard(
93    shell: &ShellRunner,
94    workspace: &Path,
95    sha: &str,
96) -> Result<(), DriverError> {
97    let cmd = format!("git reset --hard {sha} 2>&1", sha = quote(sha));
98    let res = shell.run(&cmd, workspace, DEFAULT_GIT_TIMEOUT).await?;
99    if res.timed_out || res.exit_code != Some(0) {
100        return Err(DriverError::Workspace(format!(
101            "git reset failed (exit {:?}): {}",
102            res.exit_code,
103            res.stdout.trim()
104        )));
105    }
106    Ok(())
107}
108
109pub(crate) async fn diff_stat(
110    shell: &ShellRunner,
111    workspace: &Path,
112    since_sha: &str,
113) -> Result<String, DriverError> {
114    let cmd = format!("git diff --stat {sha}..HEAD", sha = quote(since_sha));
115    let res = shell.run(&cmd, workspace, DEFAULT_GIT_TIMEOUT).await?;
116    if res.timed_out || res.exit_code != Some(0) {
117        return Ok(String::new());
118    }
119    Ok(res.stdout)
120}
121
122/// POSIX shell single-quoting. Embedded quotes get the standard
123/// `'\''` dance.
124fn quote(s: &str) -> String {
125    let mut out = String::with_capacity(s.len() + 2);
126    out.push('\'');
127    for ch in s.chars() {
128        if ch == '\'' {
129            out.push_str("'\\''");
130        } else {
131            out.push(ch);
132        }
133    }
134    out.push('\'');
135    out
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn git_available() -> bool {
143        which::which("git").is_ok()
144    }
145
146    #[tokio::test]
147    async fn quote_escapes_single_quotes() {
148        assert_eq!(quote("hi"), "'hi'");
149        assert_eq!(quote("it's"), "'it'\\''s'");
150    }
151
152    #[tokio::test]
153    async fn is_repo_false_outside_git() {
154        let dir = tempfile::tempdir().unwrap();
155        let shell = ShellRunner::default();
156        assert!(!is_repo(&shell, dir.path()).await);
157    }
158
159    #[tokio::test]
160    async fn is_repo_true_inside_git() {
161        if !git_available() {
162            return;
163        }
164        let dir = tempfile::tempdir().unwrap();
165        let shell = ShellRunner::default();
166        let res = shell
167            .run("git init -q", dir.path(), DEFAULT_GIT_TIMEOUT)
168            .await
169            .unwrap();
170        assert_eq!(res.exit_code, Some(0));
171        assert!(is_repo(&shell, dir.path()).await);
172    }
173
174    #[tokio::test]
175    async fn commit_all_returns_40_hex_sha() {
176        if !git_available() {
177            return;
178        }
179        let dir = tempfile::tempdir().unwrap();
180        let shell = ShellRunner::default();
181        shell
182            .run("git init -q", dir.path(), DEFAULT_GIT_TIMEOUT)
183            .await
184            .unwrap();
185        let sha = commit_all_with_label(&shell, dir.path(), "first")
186            .await
187            .unwrap();
188        assert_eq!(sha.len(), 40);
189        assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
190    }
191}