nexo_driver_loop/workspace/
git.rs1use 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)] pub(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 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
122fn 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}