Skip to main content

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_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::EnvFilter;
39use tracing_subscriber::fmt as tracing_fmt;
40use tracing_subscriber::prelude::*;
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        // SAFETY: We're setting an environment variable that we control and read immediately
73        // after. This is done at the start of execution before any threading occurs.
74        unsafe {
75            std::env::set_var(NESTING_LEVEL_KEY, (nesting_level + 1).to_string());
76        }
77
78        let should_include_function_args = match std::env::var("RUST_PROFILE_INCLUDE_ARGS") {
79            Ok(value) if !value.is_empty() => true,
80            Ok(_) | Err(_) => false,
81        };
82
83        let filename = match std::env::var("RUST_PROFILE") {
84            Ok(value) if value == "1" || value == "true" => {
85                let filename = format!(
86                    "trace-{}.json-{}",
87                    SystemTime::now()
88                        .duration_since(SystemTime::UNIX_EPOCH)?
89                        .as_secs(),
90                    nesting_level,
91                );
92                Some(filename)
93            }
94            Ok(value) if !value.is_empty() => Some(format!("{value}-{nesting_level}")),
95            Ok(_) | Err(_) => None,
96        };
97
98        match filename {
99            Some(filename) => {
100                let (layer, flush_guard) = ChromeLayerBuilder::new()
101                    .file(filename)
102                    .include_args(should_include_function_args)
103                    .build();
104                (Some(layer), Box::new(flush_guard))
105            }
106            None => {
107                struct TrivialDrop;
108                (None, Box::new(TrivialDrop))
109            }
110        }
111    };
112
113    tracing_subscriber::registry()
114        .with(ErrorLayer::default())
115        .with(fmt_layer.with_filter(env_filter))
116        .with(profile_layer)
117        .try_init()?;
118
119    Ok(flush_guard)
120}
121
122#[instrument]
123fn install_libgit2_tracing() {
124    fn git_trace(level: git2::TraceLevel, msg: &[u8]) {
125        info!("[{:?}]: {}", level, String::from_utf8_lossy(msg));
126    }
127
128    if let Err(err) = git2::trace_set(git2::TraceLevel::Trace, git_trace) {
129        warn!("Failed to install libgit2 tracing: {err}");
130    }
131}
132
133#[instrument]
134fn check_unsupported_config_options(effects: &Effects) -> eyre::Result<Option<ExitCode>> {
135    let _repo = match Repo::from_current_dir() {
136        Ok(repo) => repo,
137        Err(RepoError::UnsupportedExtensionWorktreeConfig(_)) => {
138            writeln!(
139                effects.get_output_stream(),
140                "\
141{error}
142
143Usually, this configuration setting is enabled when initializing a sparse
144checkout. See https://github.com/arxanas/git-branchless/issues/278 for more
145information.
146
147Here are some options:
148
149- To unset the configuration option, run: git config --unset extensions.worktreeConfig
150  - This is safe unless you created another worktree also using a sparse checkout.
151- Try upgrading to Git v2.36+ and reinitializing your sparse checkout.",
152                error = effects.get_glyphs().render(StyledString::styled(
153                    "\
154Error: the Git configuration setting `extensions.worktreeConfig` is enabled in
155this repository. Due to upstream libgit2 limitations, git-branchless does not
156support repositories with this configuration option enabled.",
157                    BaseColor::Red.light()
158                ))?,
159            )?;
160            return Ok(Some(ExitCode(1)));
161        }
162        Err(_) => return Ok(None),
163    };
164
165    Ok(None)
166}
167
168/// Wrapper function for `main` to ensure that `Drop` is called for local
169/// variables, since `std::process::exit` will skip them. You probably want to
170/// call `invoke_subcommand_main` instead.
171#[instrument(skip(f))]
172pub fn do_main_and_drop_locals<T: Parser>(
173    f: impl Fn(CommandContext, T) -> EyreExitOr<()>,
174    args: Vec<OsString>,
175) -> eyre::Result<i32> {
176    let command = GlobalArgs::command();
177    let command_args = T::parse_from(&args);
178    let matches = command.ignore_errors(true).get_matches_from(&args);
179    let GlobalArgs {
180        working_directory,
181        color,
182    } = GlobalArgs::from_arg_matches(&matches)
183        .map_err(|err| eyre::eyre!("Could not parse global arguments: {err}"))?;
184
185    if let Some(working_directory) = working_directory {
186        std::env::set_current_dir(&working_directory).wrap_err_with(|| {
187            format!(
188                "Could not set working directory to: {:?}",
189                &working_directory
190            )
191        })?;
192    }
193
194    let path_to_git = get_path_to_git().unwrap_or_else(|_| PathBuf::from("git"));
195    let path_to_git = PathBuf::from(&path_to_git);
196    let git_run_info = GitRunInfo {
197        path_to_git,
198        working_directory: std::env::current_dir()?,
199        env: {
200            let mut env: HashMap<OsString, OsString> = std::env::vars_os().collect();
201            if let Ok(git_exec_path) = get_git_exec_path() {
202                env.entry("GIT_EXEC_PATH".into())
203                    .or_insert(git_exec_path.into());
204            }
205            env
206        },
207    };
208
209    let color = match color {
210        Some(ColorSetting::Always) => Glyphs::pretty(),
211        Some(ColorSetting::Never) => Glyphs::text(),
212        Some(ColorSetting::Auto) | None => Glyphs::detect(),
213    };
214    let effects = Effects::new(color);
215
216    let _tracing_guard = install_tracing(effects.clone());
217    install_libgit2_tracing();
218
219    if let Some(ExitCode(exit_code)) = check_unsupported_config_options(&effects)? {
220        let exit_code: i32 = exit_code.try_into()?;
221        return Ok(exit_code);
222    }
223
224    let ctx = CommandContext {
225        effects,
226        git_run_info,
227    };
228    let exit_code = match f(ctx, command_args)? {
229        Ok(()) => 0,
230        Err(ExitCode(exit_code)) => {
231            let exit_code: i32 = exit_code.try_into()?;
232            exit_code
233        }
234    };
235    Ok(exit_code)
236}
237
238/// Invoke the provided subcommand main function. This should be used in the
239/// `main.rs` file for the subcommand executable. For example:
240///
241/// ```ignore
242/// fn main() {
243///     git_branchless_invoke::invoke_subcommand_main(git_branchless_init::command_main)
244/// }
245/// ```
246#[instrument(skip(f))]
247pub fn invoke_subcommand_main<T: Parser>(f: impl Fn(CommandContext, T) -> EyreExitOr<()>) {
248    // Install panic handler.
249    color_eyre::install().expect("Could not install panic handler");
250    let args = std::env::args_os().collect();
251    let exit_code = do_main_and_drop_locals(f, args).expect("A fatal error occurred");
252    std::process::exit(exit_code);
253}