git_branchless_invoke/
lib.rs

1//! This crate is used to invoke `git-branchless` either directly via a
2//! subcommand (such as `git-branchless foo`) or via an entirely separate
3//! executable (such as `git-branchless-foo`). The objective is to improve
4//! developer iteration times by allowing them to build and test a single
5//! subcommand in isolation.
6
7#![warn(missing_docs)]
8#![warn(
9    clippy::all,
10    clippy::as_conversions,
11    clippy::clone_on_ref_ptr,
12    clippy::dbg_macro
13)]
14#![allow(clippy::too_many_arguments, clippy::blocks_in_if_conditions)]
15
16use std::any::Any;
17use std::collections::HashMap;
18use std::ffi::OsString;
19use std::fmt::Write;
20use std::path::PathBuf;
21use std::time::SystemTime;
22
23use clap::{CommandFactory, FromArgMatches, Parser};
24use cursive_core::theme::BaseColor;
25use cursive_core::utils::markup::StyledString;
26use eyre::Context;
27use git_branchless_opts::{ColorSetting, GlobalArgs};
28use lib::core::config::env_vars::{get_git_exec_path, get_path_to_git};
29use lib::core::effects::Effects;
30use lib::core::formatting::Glyphs;
31use lib::git::GitRunInfo;
32use lib::git::{Repo, RepoError};
33use lib::util::{ExitCode, EyreExitOr};
34use tracing::level_filters::LevelFilter;
35use tracing::{info, instrument, warn};
36use tracing_chrome::ChromeLayerBuilder;
37use tracing_error::ErrorLayer;
38use tracing_subscriber::fmt as tracing_fmt;
39use tracing_subscriber::prelude::*;
40use tracing_subscriber::EnvFilter;
41
42/// Shared context for all commands.
43#[derive(Clone, Debug)]
44pub struct CommandContext {
45    /// The `Effects` to use.
46    pub effects: Effects,
47
48    /// Information about the Git executable currently being used.
49    pub git_run_info: GitRunInfo,
50}
51
52#[must_use = "This function returns a guard object to flush traces. Dropping it immediately is probably incorrect. Make sure that the returned value lives until tracing has finished."]
53#[instrument]
54fn install_tracing(effects: Effects) -> eyre::Result<impl Drop> {
55    let env_filter = EnvFilter::builder()
56        .with_default_directive(LevelFilter::WARN.into())
57        .parse(std::env::var(EnvFilter::DEFAULT_ENV).unwrap_or_else(|_|
58                // Limit to first-party logs by default in case third-party
59                // packages log spuriously. See
60                // https://discord.com/channels/968932220549103686/968932220549103689/1077096194276339772
61                "git_branchless=warn".to_string()))?;
62    let fmt_layer = tracing_fmt::layer().with_writer(move || effects.clone().get_error_stream());
63
64    let (profile_layer, flush_guard): (_, Box<dyn Any>) = {
65        // We may invoke a hook that calls back into `git-branchless`. In that case,
66        // we have to be careful not to write to the same logging file.
67        const NESTING_LEVEL_KEY: &str = "RUST_LOGGING_NESTING_LEVEL";
68        let nesting_level = match std::env::var(NESTING_LEVEL_KEY) {
69            Ok(nesting_level) => nesting_level.parse::<usize>().unwrap_or_default(),
70            Err(_) => 0,
71        };
72        std::env::set_var(NESTING_LEVEL_KEY, (nesting_level + 1).to_string());
73
74        let should_include_function_args = match std::env::var("RUST_PROFILE_INCLUDE_ARGS") {
75            Ok(value) if !value.is_empty() => true,
76            Ok(_) | Err(_) => false,
77        };
78
79        let filename = match std::env::var("RUST_PROFILE") {
80            Ok(value) if value == "1" || value == "true" => {
81                let filename = format!(
82                    "trace-{}.json-{}",
83                    SystemTime::now()
84                        .duration_since(SystemTime::UNIX_EPOCH)?
85                        .as_secs(),
86                    nesting_level,
87                );
88                Some(filename)
89            }
90            Ok(value) if !value.is_empty() => Some(format!("{value}-{nesting_level}")),
91            Ok(_) | Err(_) => None,
92        };
93
94        match filename {
95            Some(filename) => {
96                let (layer, flush_guard) = ChromeLayerBuilder::new()
97                    .file(filename)
98                    .include_args(should_include_function_args)
99                    .build();
100                (Some(layer), Box::new(flush_guard))
101            }
102            None => {
103                struct TrivialDrop;
104                (None, Box::new(TrivialDrop))
105            }
106        }
107    };
108
109    tracing_subscriber::registry()
110        .with(ErrorLayer::default())
111        .with(fmt_layer.with_filter(env_filter))
112        .with(profile_layer)
113        .try_init()?;
114
115    Ok(flush_guard)
116}
117
118#[instrument]
119fn install_libgit2_tracing() {
120    fn git_trace(level: git2::TraceLevel, msg: &str) {
121        info!("[{:?}]: {}", level, msg);
122    }
123
124    if !git2::trace_set(git2::TraceLevel::Trace, git_trace) {
125        warn!("Failed to install libgit2 tracing");
126    }
127}
128
129#[instrument]
130fn check_unsupported_config_options(effects: &Effects) -> eyre::Result<Option<ExitCode>> {
131    let _repo = match Repo::from_current_dir() {
132        Ok(repo) => repo,
133        Err(RepoError::UnsupportedExtensionWorktreeConfig(_)) => {
134            writeln!(
135                effects.get_output_stream(),
136                "\
137{error}
138
139Usually, this configuration setting is enabled when initializing a sparse
140checkout. See https://github.com/arxanas/git-branchless/issues/278 for more
141information.
142
143Here are some options:
144
145- To unset the configuration option, run: git config --unset extensions.worktreeConfig
146  - This is safe unless you created another worktree also using a sparse checkout.
147- Try upgrading to Git v2.36+ and reinitializing your sparse checkout.",
148                error = effects.get_glyphs().render(StyledString::styled(
149                    "\
150Error: the Git configuration setting `extensions.worktreeConfig` is enabled in
151this repository. Due to upstream libgit2 limitations, git-branchless does not
152support repositories with this configuration option enabled.",
153                    BaseColor::Red.light()
154                ))?,
155            )?;
156            return Ok(Some(ExitCode(1)));
157        }
158        Err(_) => return Ok(None),
159    };
160
161    Ok(None)
162}
163
164/// Wrapper function for `main` to ensure that `Drop` is called for local
165/// variables, since `std::process::exit` will skip them. You probably want to
166/// call `invoke_subcommand_main` instead.
167#[instrument(skip(f))]
168pub fn do_main_and_drop_locals<T: Parser>(
169    f: impl Fn(CommandContext, T) -> EyreExitOr<()>,
170    args: Vec<OsString>,
171) -> eyre::Result<i32> {
172    let command = GlobalArgs::command();
173    let command_args = T::parse_from(&args);
174    let matches = command.ignore_errors(true).get_matches_from(&args);
175    let GlobalArgs {
176        working_directory,
177        color,
178    } = GlobalArgs::from_arg_matches(&matches)
179        .map_err(|err| eyre::eyre!("Could not parse global arguments: {err}"))?;
180
181    if let Some(working_directory) = working_directory {
182        std::env::set_current_dir(&working_directory).wrap_err_with(|| {
183            format!(
184                "Could not set working directory to: {:?}",
185                &working_directory
186            )
187        })?;
188    }
189
190    let path_to_git = get_path_to_git().unwrap_or_else(|_| PathBuf::from("git"));
191    let path_to_git = PathBuf::from(&path_to_git);
192    let git_run_info = GitRunInfo {
193        path_to_git,
194        working_directory: std::env::current_dir()?,
195        env: {
196            let mut env: HashMap<OsString, OsString> = std::env::vars_os().collect();
197            if let Ok(git_exec_path) = get_git_exec_path() {
198                env.entry("GIT_EXEC_PATH".into())
199                    .or_insert(git_exec_path.into());
200            }
201            env
202        },
203    };
204
205    let color = match color {
206        Some(ColorSetting::Always) => Glyphs::pretty(),
207        Some(ColorSetting::Never) => Glyphs::text(),
208        Some(ColorSetting::Auto) | None => Glyphs::detect(),
209    };
210    let effects = Effects::new(color);
211
212    let _tracing_guard = install_tracing(effects.clone());
213    install_libgit2_tracing();
214
215    if let Some(ExitCode(exit_code)) = check_unsupported_config_options(&effects)? {
216        let exit_code: i32 = exit_code.try_into()?;
217        return Ok(exit_code);
218    }
219
220    let ctx = CommandContext {
221        effects,
222        git_run_info,
223    };
224    let exit_code = match f(ctx, command_args)? {
225        Ok(()) => 0,
226        Err(ExitCode(exit_code)) => {
227            let exit_code: i32 = exit_code.try_into()?;
228            exit_code
229        }
230    };
231    Ok(exit_code)
232}
233
234/// Invoke the provided subcommand main function. This should be used in the
235/// `main.rs` file for the subcommand executable. For example:
236///
237/// ```ignore
238/// fn main() {
239///     git_branchless_invoke::invoke_subcommand_main(git_branchless_init::command_main)
240/// }
241/// ```
242#[instrument(skip(f))]
243pub fn invoke_subcommand_main<T: Parser>(f: impl Fn(CommandContext, T) -> EyreExitOr<()>) {
244    // Install panic handler.
245    color_eyre::install().expect("Could not install panic handler");
246    let args = std::env::args_os().collect();
247    let exit_code = do_main_and_drop_locals(f, args).expect("A fatal error occurred");
248    std::process::exit(exit_code);
249}