branchless/git/
run.rs

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/// Path to the `git` executable on disk to be executed.
22#[derive(Clone)]
23pub struct GitRunInfo {
24    /// The path to the Git executable on disk.
25    pub path_to_git: PathBuf,
26
27    /// The working directory that the Git executable should be run in.
28    pub working_directory: PathBuf,
29
30    /// The environment variables that should be passed to the Git process.
31    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
44/// Options for invoking Git.
45pub struct GitRunOpts {
46    /// If set, a non-zero exit code will be treated as an error.
47    pub treat_git_failure_as_error: bool,
48
49    /// A vector of bytes to write to the Git process's stdin. If `None`,
50    /// nothing is written to stdin.
51    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/// The result of invoking Git.
64#[must_use]
65pub struct GitRunResult {
66    /// The exit code of the process.
67    pub exit_code: ExitCode,
68
69    /// The stdout contents written by the invocation.
70    pub stdout: Vec<u8>,
71
72    /// The stderr contents written by the invocation.
73    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        // On Unix, if the child process was terminated by a signal, we need to call
162        // some Unix-specific functions to access the signal that terminated it. For
163        // simplicity, just return `1` in those cases.
164        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    /// Run Git in a subprocess, and inform the user.
177    ///
178    /// This is suitable for commands which affect the working copy or should run
179    /// hooks. We don't want our process to be responsible for that.
180    ///
181    /// `args` contains the list of arguments to pass to Git, not including the Git
182    /// executable itself.
183    ///
184    /// Returns the exit code of Git (non-zero signifies error).
185    #[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    /// Run the provided command without wrapping it in an `Effects` operation.
201    /// This may clobber progress reporting, and is usually not what you want;
202    /// see [`GitRunInfo::run`] instead.
203    #[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        // On Unix, if the child process was terminated by a signal, we need to call
231        // some Unix-specific functions to access the signal that terminated it. For
232        // simplicity, just return `1` in those cases.
233        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    /// Returns the working directory for commands run on a given `Repo`.
246    ///
247    /// This is typically the working copy path for the repo.
248    ///
249    /// Some commands (notably `git status`) do not function correctly when run
250    /// from the git repo (i.e. `.git`) path.
251    /// Hooks should also be run from the working copy path - from
252    /// `githooks(5)`: Before Git invokes a hook, it changes its working
253    /// directory to either $GIT_DIR in a bare repository or the root of the
254    /// working tree in a non-bare repository.
255    ///
256    /// This contains additional logic for symlinked `.git` directories to work
257    /// around an upstream `libgit2` issue.
258    fn working_directory<'a>(&'a self, repo: &'a Repo) -> PathBuf {
259        repo.get_working_copy_path()
260            // `libgit2` returns the working copy path as the parent of the
261            // `.git` directory. However, if the `.git` directory is a symlink,
262            // `libgit2` *resolves the symlink*, returning an incorrect working
263            // directory: https://github.com/libgit2/libgit2/issues/6401
264            //
265            // Detect this case by checking if the current working directory is
266            // not a subdirectory of the returned working copy. While this does
267            // not work if the symlinked `.git` directory points to a child of
268            // an ancestor of the directory, this should handle most cases,
269            // including working copies managed by the `repo` tool.
270            //
271            // This workaround may result in slightly incorrect hook behavior,
272            // as the current working directory may be a subdirectory of the
273            // root of the working tree.
274            .map(|working_copy| {
275                // Both paths are already "canonicalized".
276                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        // Technically speaking, we should be able to work with non-UTF-8 repository
304        // paths. Need to make the typechecker accept it.
305        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            // On Unix, if the child process was terminated by a signal, we need to call
350            // some Unix-specific functions to access the signal that terminated it. For
351            // simplicity, just return `1` in those cases.
352            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    /// Run Git silently (don't display output to the user).
367    ///
368    /// Whenever possible, use `git2`'s bindings to Git instead, as they're
369    /// considerably more lightweight and reliable.
370    ///
371    /// Returns the stdout of the Git invocation.
372    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            // We're calling a Git hook, but not Git itself.
408            path_to_git: _,
409            // We always want to call the hook in the Git working copy,
410            // regardless of where the Git executable was invoked.
411            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            // On windows, PATH's name defaults to "Path".
421            #[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) // "$@" expands "$1" "$2" "$3" ... but we also must specify $0.
434                .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    /// Run a provided Git hook if it exists for the repository.
468    ///
469    /// See the man page for `githooks(5)` for more detail on Git hooks.
470    #[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}