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