1use crate::error::{Error, Result};
53use async_trait::async_trait;
54use std::collections::HashMap;
55use std::ffi::{OsStr, OsString};
56use std::path::PathBuf;
57use std::process::Stdio;
58use std::time::Duration;
59use tokio::process::Command as TokioCommand;
60use tracing::{debug, error, instrument, trace, warn};
61
62pub mod add;
63pub mod bisect;
64pub mod branch;
65pub mod cat_file;
66pub mod checkout;
67pub mod cherry_pick;
68pub mod clone;
69pub mod commit;
70pub mod config;
71pub mod describe;
72pub mod diff;
73pub mod fetch;
74pub mod for_each_ref;
75pub mod grep;
76pub mod hash_object;
77pub mod init;
78pub mod log;
79pub mod ls_files;
80pub mod ls_tree;
81pub mod merge;
82pub mod mv;
83pub mod pull;
84pub mod push;
85pub mod rebase;
86pub mod reflog;
87pub mod remote;
88pub mod reset;
89pub mod restore;
90pub mod rev_parse;
91pub mod rm;
92pub mod show;
93pub mod show_ref;
94pub mod stash;
95pub mod status;
96pub mod submodule;
97pub mod switch;
98pub mod symbolic_ref;
99pub mod tag;
100pub mod update_ref;
101pub mod worktree;
102
103pub const DEFAULT_COMMAND_TIMEOUT: Option<Duration> = None;
107
108#[async_trait]
110pub trait GitCommand {
111 type Output;
113
114 fn get_executor(&self) -> &CommandExecutor;
116
117 fn get_executor_mut(&mut self) -> &mut CommandExecutor;
119
120 fn build_command_args(&self) -> Vec<String>;
123
124 async fn execute(&self) -> Result<Self::Output>;
126
127 async fn execute_raw(&self) -> Result<CommandOutput> {
132 let args = self.build_command_args();
133 self.get_executor().execute_command(args).await
134 }
135
136 fn arg<S: AsRef<OsStr>>(&mut self, arg: S) -> &mut Self {
138 self.get_executor_mut().add_arg(arg);
139 self
140 }
141
142 fn args<I, S>(&mut self, args: I) -> &mut Self
144 where
145 I: IntoIterator<Item = S>,
146 S: AsRef<OsStr>,
147 {
148 self.get_executor_mut().add_args(args);
149 self
150 }
151
152 fn flag(&mut self, flag: &str) -> &mut Self {
154 self.get_executor_mut().add_flag(flag);
155 self
156 }
157
158 fn option(&mut self, key: &str, value: &str) -> &mut Self {
160 self.get_executor_mut().add_option(key, value);
161 self
162 }
163
164 fn current_dir<P: Into<PathBuf>>(&mut self, dir: P) -> &mut Self {
166 self.get_executor_mut().cwd = Some(dir.into());
167 self
168 }
169
170 fn env<K: Into<OsString>, V: Into<OsString>>(&mut self, key: K, value: V) -> &mut Self {
172 self.get_executor_mut().env.insert(key.into(), value.into());
173 self
174 }
175
176 fn with_timeout(&mut self, timeout: Duration) -> &mut Self {
179 self.get_executor_mut().timeout = Some(timeout);
180 self
181 }
182
183 fn with_timeout_secs(&mut self, seconds: u64) -> &mut Self {
185 self.get_executor_mut().timeout = Some(Duration::from_secs(seconds));
186 self
187 }
188}
189
190#[derive(Debug, Clone, Default)]
192pub struct CommandExecutor {
193 pub raw_args: Vec<String>,
195 pub cwd: Option<PathBuf>,
197 pub env: HashMap<OsString, OsString>,
199 pub timeout: Option<Duration>,
201}
202
203impl CommandExecutor {
204 #[must_use]
206 pub fn new() -> Self {
207 Self::default()
208 }
209
210 #[must_use]
212 pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
213 self.cwd = Some(path.into());
214 self
215 }
216
217 #[must_use]
219 pub fn with_env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
220 self.env.insert(key.into(), value.into());
221 self
222 }
223
224 #[must_use]
226 pub fn timeout(mut self, timeout: Duration) -> Self {
227 self.timeout = Some(timeout);
228 self
229 }
230
231 pub fn add_arg<S: AsRef<OsStr>>(&mut self, arg: S) {
233 self.raw_args
234 .push(arg.as_ref().to_string_lossy().into_owned());
235 }
236
237 pub fn add_args<I, S>(&mut self, args: I)
239 where
240 I: IntoIterator<Item = S>,
241 S: AsRef<OsStr>,
242 {
243 for a in args {
244 self.add_arg(a);
245 }
246 }
247
248 pub fn add_flag(&mut self, flag: &str) {
250 let normalized = if flag.starts_with('-') {
251 flag.to_string()
252 } else if flag.len() == 1 {
253 format!("-{flag}")
254 } else {
255 format!("--{flag}")
256 };
257 self.raw_args.push(normalized);
258 }
259
260 pub fn add_option(&mut self, key: &str, value: &str) {
262 let normalized = if key.starts_with('-') {
263 key.to_string()
264 } else if key.len() == 1 {
265 format!("-{key}")
266 } else {
267 format!("--{key}")
268 };
269 self.raw_args.push(normalized);
270 self.raw_args.push(value.to_string());
271 }
272
273 #[instrument(
277 name = "git.command",
278 skip(self, args),
279 fields(
280 cwd = self.cwd.as_ref().map(|p| p.display().to_string()),
281 timeout_secs = self.timeout.map(|t| t.as_secs()),
282 )
283 )]
284 pub async fn execute_command(&self, args: Vec<String>) -> Result<CommandOutput> {
285 let mut all_args = args;
286 all_args.extend(self.raw_args.iter().cloned());
287
288 trace!(args = ?all_args, "executing git command");
289
290 let result = if let Some(t) = self.timeout {
291 self.execute_with_timeout(&all_args, t).await
292 } else {
293 self.execute_internal(&all_args).await
294 };
295
296 match &result {
297 Ok(output) => debug!(
298 exit_code = output.exit_code,
299 stdout_len = output.stdout.len(),
300 stderr_len = output.stderr.len(),
301 "command completed"
302 ),
303 Err(e) => error!(error = %e, "command failed"),
304 }
305
306 result
307 }
308
309 async fn execute_internal(&self, all_args: &[String]) -> Result<CommandOutput> {
310 let mut cmd = TokioCommand::new("git");
311 cmd.args(all_args)
312 .stdout(Stdio::piped())
313 .stderr(Stdio::piped());
314
315 if let Some(dir) = &self.cwd {
316 cmd.current_dir(dir);
317 }
318 for (k, v) in &self.env {
319 cmd.env(k, v);
320 }
321
322 let output = cmd.output().await.map_err(|e| {
323 if e.kind() == std::io::ErrorKind::NotFound {
324 Error::GitNotFound
325 } else {
326 Error::Io {
327 message: format!("failed to spawn git: {e}"),
328 source: e,
329 }
330 }
331 })?;
332
333 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
334 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
335 let exit_code = output.status.code().unwrap_or(-1);
336 let success = output.status.success();
337
338 if !success {
339 return Err(Error::command_failed(
340 format!("git {}", all_args.join(" ")),
341 exit_code,
342 stdout,
343 stderr,
344 ));
345 }
346
347 Ok(CommandOutput {
348 stdout,
349 stderr,
350 exit_code,
351 success,
352 })
353 }
354
355 async fn execute_with_timeout(
356 &self,
357 all_args: &[String],
358 timeout_duration: Duration,
359 ) -> Result<CommandOutput> {
360 match tokio::time::timeout(timeout_duration, self.execute_internal(all_args)).await {
361 Ok(r) => r,
362 Err(_) => {
363 warn!(
364 timeout_secs = timeout_duration.as_secs(),
365 "command timed out"
366 );
367 Err(Error::timeout(timeout_duration.as_secs()))
368 }
369 }
370 }
371}
372
373#[derive(Debug, Clone)]
375pub struct CommandOutput {
376 pub stdout: String,
378 pub stderr: String,
380 pub exit_code: i32,
382 pub success: bool,
384}
385
386impl CommandOutput {
387 #[must_use]
389 pub fn stdout_lines(&self) -> Vec<&str> {
390 self.stdout.lines().collect()
391 }
392
393 #[must_use]
395 pub fn stderr_lines(&self) -> Vec<&str> {
396 self.stderr.lines().collect()
397 }
398
399 #[must_use]
401 pub fn stdout_trimmed(&self) -> &str {
402 self.stdout.trim_end()
403 }
404}
405
406pub fn find_git() -> Result<PathBuf> {
412 which::which("git").map_err(|_| Error::GitNotFound)
413}
414
415pub async fn git_version() -> Result<String> {
417 let output = CommandExecutor::new()
418 .execute_command(vec!["--version".into()])
419 .await?;
420 Ok(output.stdout_trimmed().to_string())
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 #[test]
428 fn executor_args() {
429 let mut e = CommandExecutor::new();
430 e.add_arg("foo");
431 e.add_args(["a", "b"]);
432 e.add_flag("verbose");
433 e.add_flag("v");
434 e.add_option("name", "bar");
435 assert_eq!(
436 e.raw_args,
437 vec!["foo", "a", "b", "--verbose", "-v", "--name", "bar"]
438 );
439 }
440
441 #[test]
442 fn executor_timeout_builder() {
443 let e = CommandExecutor::new().timeout(Duration::from_secs(5));
444 assert_eq!(e.timeout, Some(Duration::from_secs(5)));
445 }
446
447 #[test]
448 fn command_output_helpers() {
449 let o = CommandOutput {
450 stdout: "a\nb\n".into(),
451 stderr: String::new(),
452 exit_code: 0,
453 success: true,
454 };
455 assert_eq!(o.stdout_lines(), vec!["a", "b"]);
456 assert_eq!(o.stdout_trimmed(), "a\nb");
457 }
458}