1use std::collections::HashMap;
2use std::ffi::{OsStr, OsString};
3use std::fmt::Write;
4use std::io::{BufRead, BufReader, Read, Write as WriteIo};
5use std::path::PathBuf;
6use std::process::{Command, ExitStatus, Stdio};
7use std::sync::Arc;
8use std::thread::{self, JoinHandle};
9
10use bstr::BString;
11use eyre::{eyre, Context};
12use itertools::Itertools;
13use tracing::{instrument, warn};
14
15use crate::core::config::get_main_worktree_hooks_dir;
16use crate::core::effects::{Effects, OperationType};
17use crate::core::eventlog::{EventTransactionId, BRANCHLESS_TRANSACTION_ID_ENV_VAR};
18use crate::git::repo::Repo;
19use crate::util::{get_sh, ExitCode, EyreExitOr};
20
21#[derive(Clone)]
23pub struct GitRunInfo {
24 pub path_to_git: PathBuf,
26
27 pub working_directory: PathBuf,
29
30 pub env: HashMap<OsString, OsString>,
32}
33
34impl std::fmt::Debug for GitRunInfo {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 write!(
37 f,
38 "<GitRunInfo path_to_git={:?} working_directory={:?} env=not shown>",
39 self.path_to_git, self.working_directory
40 )
41 }
42}
43
44pub struct GitRunOpts {
46 pub treat_git_failure_as_error: bool,
48
49 pub stdin: Option<Vec<u8>>,
52}
53
54impl Default for GitRunOpts {
55 fn default() -> Self {
56 Self {
57 treat_git_failure_as_error: true,
58 stdin: None,
59 }
60 }
61}
62
63#[must_use]
65pub struct GitRunResult {
66 pub exit_code: ExitCode,
68
69 pub stdout: Vec<u8>,
71
72 pub stderr: Vec<u8>,
74}
75
76impl std::fmt::Debug for GitRunResult {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 write!(
79 f,
80 "<GitRunResult exit_code={:?} stdout={:?} stderr={:?}>",
81 self.exit_code,
82 String::from_utf8_lossy(&self.stdout),
83 String::from_utf8_lossy(&self.stderr),
84 )
85 }
86}
87
88impl GitRunInfo {
89 fn spawn_writer_thread<
90 InputStream: Read + Send + 'static,
91 OutputStream: Write + Send + 'static,
92 >(
93 &self,
94 stream: Option<InputStream>,
95 mut output: OutputStream,
96 ) -> JoinHandle<()> {
97 thread::spawn(move || {
98 let stream = match stream {
99 Some(stream) => stream,
100 None => return,
101 };
102 let reader = BufReader::new(stream);
103 for line in reader.lines() {
104 let line = line.expect("Reading line from subprocess");
105 writeln!(output, "{line}").expect("Writing line from subprocess");
106 }
107 })
108 }
109
110 fn run_inner(
111 &self,
112 effects: &Effects,
113 event_tx_id: Option<EventTransactionId>,
114 args: &[&OsStr],
115 ) -> EyreExitOr<()> {
116 let GitRunInfo {
117 path_to_git,
118 working_directory,
119 env,
120 } = self;
121
122 let args_string = args
123 .iter()
124 .map(|arg| arg.to_string_lossy().to_string())
125 .collect_vec()
126 .join(" ");
127 let command_string = format!("git {args_string}");
128 let (effects, _progress) =
129 effects.start_operation(OperationType::RunGitCommand(Arc::new(command_string)));
130 writeln!(
131 effects.get_output_stream(),
132 "branchless: running command: {} {}",
133 &path_to_git.to_string_lossy(),
134 &args_string
135 )?;
136
137 let mut command = Command::new(path_to_git);
138 command.current_dir(working_directory);
139 command.args(args);
140 command.env_clear();
141 command.envs(env.iter());
142 if let Some(event_tx_id) = event_tx_id {
143 command.env(BRANCHLESS_TRANSACTION_ID_ENV_VAR, event_tx_id.to_string());
144 }
145 command.stdout(Stdio::piped());
146 command.stderr(Stdio::piped());
147
148 let mut child = command.spawn().wrap_err("Spawning Git subprocess")?;
149
150 let stdout = child.stdout.take();
151 let stdout_thread = self.spawn_writer_thread(stdout, effects.get_output_stream());
152 let stderr = child.stderr.take();
153 let stderr_thread = self.spawn_writer_thread(stderr, effects.get_error_stream());
154
155 let exit_status = child
156 .wait()
157 .wrap_err("Waiting for Git subprocess to complete")?;
158 stdout_thread.join().unwrap();
159 stderr_thread.join().unwrap();
160
161 let exit_code: i32 = exit_status.code().unwrap_or(1);
165 let exit_code: isize = exit_code
166 .try_into()
167 .wrap_err("Converting exit code from i32 to isize")?;
168 let exit_code = ExitCode(exit_code);
169 if exit_code.is_success() {
170 Ok(Ok(()))
171 } else {
172 Ok(Err(exit_code))
173 }
174 }
175
176 #[instrument]
186 #[must_use = "The return code for `GitRunInfo::run` must be checked"]
187 pub fn run<S: AsRef<OsStr> + std::fmt::Debug>(
188 &self,
189 effects: &Effects,
190 event_tx_id: Option<EventTransactionId>,
191 args: &[S],
192 ) -> EyreExitOr<()> {
193 self.run_inner(
194 effects,
195 event_tx_id,
196 args.iter().map(AsRef::as_ref).collect_vec().as_slice(),
197 )
198 }
199
200 #[instrument]
204 #[must_use = "The return code for `GitRunInfo::run_direct_no_wrapping` must be checked"]
205 pub fn run_direct_no_wrapping(
206 &self,
207 event_tx_id: Option<EventTransactionId>,
208 args: &[impl AsRef<OsStr> + std::fmt::Debug],
209 ) -> EyreExitOr<()> {
210 let GitRunInfo {
211 path_to_git,
212 working_directory,
213 env,
214 } = self;
215
216 let mut command = Command::new(path_to_git);
217 command.current_dir(working_directory);
218 command.args(args);
219 command.env_clear();
220 command.envs(env.iter());
221 if let Some(event_tx_id) = event_tx_id {
222 command.env(BRANCHLESS_TRANSACTION_ID_ENV_VAR, event_tx_id.to_string());
223 }
224
225 let mut child = command.spawn().wrap_err("Spawning Git subprocess")?;
226 let exit_status = child
227 .wait()
228 .wrap_err("Waiting for Git subprocess to complete")?;
229
230 let exit_code: i32 = exit_status.code().unwrap_or(1);
234 let exit_code: isize = exit_code
235 .try_into()
236 .wrap_err("Converting exit code from i32 to isize")?;
237 let exit_code = ExitCode(exit_code);
238 if exit_code.is_success() {
239 Ok(Ok(()))
240 } else {
241 Ok(Err(exit_code))
242 }
243 }
244
245 fn working_directory<'a>(&'a self, repo: &'a Repo) -> PathBuf {
259 repo.get_working_copy_path()
260 .map(|working_copy| {
275 if !self.working_directory.starts_with(&working_copy) {
277 self.working_directory.clone()
278 } else {
279 working_copy
280 }
281 })
282 .unwrap_or_else(|| repo.get_path().to_owned())
283 }
284
285 fn run_silent_inner(
286 &self,
287 repo: &Repo,
288 event_tx_id: Option<EventTransactionId>,
289 args: &[&str],
290 opts: GitRunOpts,
291 ) -> eyre::Result<GitRunResult> {
292 let GitRunInfo {
293 path_to_git,
294 working_directory,
295 env,
296 } = self;
297 let GitRunOpts {
298 treat_git_failure_as_error,
299 stdin,
300 } = opts;
301
302 let repo_path = self.working_directory(repo);
303 let repo_path = repo_path.to_str().ok_or_else(|| {
306 eyre::eyre!(
307 "Path to Git repo could not be converted to UTF-8 string: {:?}",
308 repo.get_path()
309 )
310 })?;
311
312 let args = {
313 let mut result = vec!["-C", repo_path];
314 result.extend(args);
315 result
316 };
317 let mut command = Command::new(path_to_git);
318 command.args(&args);
319 command.current_dir(working_directory);
320 command.env_clear();
321 command.envs(env.iter());
322 if let Some(event_tx_id) = event_tx_id {
323 command.env(BRANCHLESS_TRANSACTION_ID_ENV_VAR, event_tx_id.to_string());
324 }
325
326 command.stdin(match stdin {
327 Some(_) => Stdio::piped(),
328 None => Stdio::null(),
329 });
330 command.stdout(Stdio::piped());
331 command.stderr(Stdio::piped());
332
333 let mut child = command.spawn().wrap_err("Spawning Git subprocess")?;
334
335 if let Some(stdin) = stdin {
336 child
337 .stdin
338 .as_mut()
339 .unwrap()
340 .write_all(&stdin)
341 .wrap_err("Writing process stdin")?;
342 }
343
344 let output = child
345 .wait_with_output()
346 .wrap_err("Spawning Git subprocess")?;
347 let exit_code = ExitCode(output.status.code().unwrap_or(1).try_into()?);
348 let result = GitRunResult {
349 exit_code,
353 stdout: output.stdout,
354 stderr: output.stderr,
355 };
356 if treat_git_failure_as_error && !exit_code.is_success() {
357 eyre::bail!(
358 "Git subprocess failed:\nArgs: {:?}\nResult: {:?}",
359 &args,
360 result
361 );
362 }
363 Ok(result)
364 }
365
366 pub fn run_silent<S: AsRef<str> + std::fmt::Debug>(
373 &self,
374 repo: &Repo,
375 event_tx_id: Option<EventTransactionId>,
376 args: &[S],
377 opts: GitRunOpts,
378 ) -> eyre::Result<GitRunResult> {
379 self.run_silent_inner(
380 repo,
381 event_tx_id,
382 args.iter().map(AsRef::as_ref).collect_vec().as_slice(),
383 opts,
384 )
385 }
386
387 fn run_hook_inner(
388 &self,
389 effects: &Effects,
390 repo: &Repo,
391 hook_name: &str,
392 event_tx_id: EventTransactionId,
393 args: &[&str],
394 stdin: Option<BString>,
395 ) -> eyre::Result<()> {
396 let hook_dir = get_main_worktree_hooks_dir(self, repo, Some(event_tx_id))?;
397 if !hook_dir.exists() {
398 warn!(
399 ?hook_dir,
400 ?hook_name,
401 "Git hooks dir did not exist, so could not invoke hook"
402 );
403 return Ok(());
404 }
405
406 let GitRunInfo {
407 path_to_git: _,
409 working_directory: _,
412 env,
413 } = self;
414 let path = {
415 let mut path_components: Vec<PathBuf> =
416 vec![std::fs::canonicalize(&hook_dir).wrap_err("Canonicalizing hook dir")?];
417 if let Some(path) = env.get(OsStr::new("PATH")) {
418 path_components.extend(std::env::split_paths(path));
419 }
420 #[cfg(target_os = "windows")]
422 if let Some(path) = env.get(OsStr::new("Path")) {
423 path_components.extend(std::env::split_paths(path));
424 }
425 std::env::join_paths(path_components).wrap_err("Joining path components")?
426 };
427
428 if hook_dir.join(hook_name).exists() {
429 let mut child = Command::new(get_sh().ok_or_else(|| eyre!("could not get sh"))?)
430 .current_dir(self.working_directory(repo))
431 .arg("-c")
432 .arg(format!("{hook_name} \"$@\""))
433 .arg(hook_name) .args(args)
435 .env_clear()
436 .envs(env.iter())
437 .env(BRANCHLESS_TRANSACTION_ID_ENV_VAR, event_tx_id.to_string())
438 .env("PATH", &path)
439 .stdin(Stdio::piped())
440 .stdout(Stdio::piped())
441 .stderr(Stdio::piped())
442 .spawn()
443 .wrap_err_with(|| format!("Invoking {} hook with PATH: {:?}", &hook_name, &path))?;
444
445 if let Some(stdin) = stdin {
446 child
447 .stdin
448 .as_mut()
449 .unwrap()
450 .write_all(&stdin)
451 .wrap_err("Writing hook process stdin")?;
452 }
453
454 let stdout = child.stdout.take();
455 let stdout_thread = self.spawn_writer_thread(stdout, effects.get_output_stream());
456 let stderr = child.stderr.take();
457 let stderr_thread = self.spawn_writer_thread(stderr, effects.get_error_stream());
458
459 let _ignored: ExitStatus =
460 child.wait().wrap_err("Waiting for child process to exit")?;
461 stdout_thread.join().unwrap();
462 stderr_thread.join().unwrap();
463 }
464 Ok(())
465 }
466
467 #[instrument]
471 pub fn run_hook<S: AsRef<str> + std::fmt::Debug>(
472 &self,
473 effects: &Effects,
474 repo: &Repo,
475 hook_name: &str,
476 event_tx_id: EventTransactionId,
477 args: &[S],
478 stdin: Option<BString>,
479 ) -> eyre::Result<()> {
480 self.run_hook_inner(
481 effects,
482 repo,
483 hook_name,
484 event_tx_id,
485 args.iter().map(AsRef::as_ref).collect_vec().as_slice(),
486 stdin,
487 )
488 }
489}