Skip to main content

branchless/core/
config.rs

1//! Accesses repo-specific configuration.
2
3use std::ffi::OsString;
4use std::fmt::Write;
5use std::path::PathBuf;
6
7use cursive::theme::{BaseColor, Effect, Style};
8use cursive::utils::markup::StyledString;
9use eyre::Context;
10use tracing::{instrument, warn};
11
12use crate::core::formatting::StyledStringBuilder;
13use crate::git::{ConfigRead, GitRunInfo, GitRunOpts, Repo};
14
15use super::effects::Effects;
16use super::eventlog::EventTransactionId;
17
18/// Get the expected hooks dir inside `.git`, assuming that the user has not
19/// overridden it.
20#[instrument]
21pub fn get_default_hooks_dir(repo: &Repo) -> eyre::Result<PathBuf> {
22    let parent_repo = repo.open_worktree_parent_repo()?;
23    let repo = parent_repo.as_ref().unwrap_or(repo);
24    Ok(repo.get_path().join("hooks"))
25}
26
27/// Get the path where the main worktree's Git hooks are stored on disk.
28///
29/// Git hooks live at `$GIT_DIR/hooks` by default, which means that they will be
30/// different per wortkree. Most people, when creating a new worktree, will not
31/// also reinstall hooks or reinitialize git-branchless in that worktree, so we
32/// instead look up hooks for the main worktree, which is most likely to have them
33/// installed.
34///
35/// This could in theory cause problems for users who have different
36/// per-worktree hooks.
37#[instrument]
38pub fn get_main_worktree_hooks_dir(
39    git_run_info: &GitRunInfo,
40    repo: &Repo,
41    event_tx_id: Option<EventTransactionId>,
42) -> eyre::Result<PathBuf> {
43    let result = git_run_info
44        .run_silent(
45            repo,
46            event_tx_id,
47            &["config", "--type", "path", "core.hooksPath"],
48            GitRunOpts {
49                treat_git_failure_as_error: false,
50                ..Default::default()
51            },
52        )
53        .context("Reading core.hooksPath")?;
54    let hooks_path = if result.exit_code.is_success() {
55        let path = String::from_utf8(result.stdout)
56            .context("Decoding git config output for hooks path")?;
57        PathBuf::from(path.strip_suffix('\n').unwrap_or(&path))
58    } else {
59        get_default_hooks_dir(repo)?
60    };
61    Ok(hooks_path)
62}
63
64/// Get the configured name of the main branch.
65///
66/// The following config values are resolved, in order. The first valid value is returned.
67/// - branchless.core.mainBranch
68/// - (deprecated) branchless.mainBranch
69/// - init.defaultBranch
70/// - finally, default to "master"
71#[instrument]
72pub fn get_main_branch_name(repo: &Repo) -> eyre::Result<String> {
73    let config = repo.get_readonly_config()?;
74
75    if let Some(branch_name) = config.get("branchless.core.mainBranch")? {
76        return Ok(branch_name);
77    }
78
79    if let Some(branch_name) = config.get("branchless.mainBranch")? {
80        return Ok(branch_name);
81    }
82
83    if let Some(branch_name) = get_default_branch_name(repo)? {
84        return Ok(branch_name);
85    }
86
87    Ok("master".to_string())
88}
89
90/// If `true`, switch to the branch associated with a target commit instead of
91/// the commit directly.
92///
93/// The switch will only occur if it is the only branch on the target commit.
94#[instrument]
95pub fn get_auto_switch_branches(repo: &Repo) -> eyre::Result<bool> {
96    repo.get_readonly_config()?
97        .get_or("branchless.navigation.autoSwitchBranches", true)
98}
99
100/// The default smartlog revset to render. This will be used when running `git
101/// smartlog` with no arguments, and also when the smartlog is rendered
102/// automatically as part of some commands like `git next`/`git prev`.
103#[instrument]
104pub fn get_smartlog_default_revset(repo: &Repo) -> eyre::Result<String> {
105    repo.get_readonly_config()?
106        .get_or_else("branchless.smartlog.defaultRevset", || {
107            "((draft() | branches() | @) % main()) | branches() | @".to_string()
108        })
109}
110
111/// Whether to reverse the smartlog direction by default
112#[instrument]
113pub fn get_smartlog_reverse(repo: &Repo) -> eyre::Result<bool> {
114    repo.get_readonly_config()?
115        .get_or("branchless.smartlog.reverse", false)
116}
117
118/// Get the default comment character.
119#[instrument]
120pub fn get_comment_char(repo: &Repo) -> eyre::Result<char> {
121    let from_config: Option<String> = repo.get_readonly_config()?.get("core.commentChar")?;
122    let comment_char = match from_config {
123        // Note that git also allows `core.commentChar="auto"`, which we do not currently support.
124        Some(comment_char) => comment_char.chars().next().unwrap(),
125        None => char::from(git2::DEFAULT_COMMENT_CHAR.unwrap()),
126    };
127    Ok(comment_char)
128}
129
130/// Get the commit template message, if any.
131#[instrument]
132pub fn get_commit_template(repo: &Repo) -> eyre::Result<Option<String>> {
133    let commit_template_path: Option<String> =
134        repo.get_readonly_config()?.get("commit.template")?;
135    let commit_template_path = match commit_template_path {
136        Some(commit_template_path) => PathBuf::from(commit_template_path),
137        None => return Ok(None),
138    };
139
140    let commit_template_path = if commit_template_path.is_relative() {
141        match repo.get_working_copy_path() {
142            Some(root) => root.join(commit_template_path),
143            None => {
144                warn!(
145                    ?commit_template_path,
146                    "Commit template path was relative, but this repository does not have a working copy"
147                );
148                return Ok(None);
149            }
150        }
151    } else {
152        commit_template_path
153    };
154
155    match std::fs::read_to_string(&commit_template_path) {
156        Ok(contents) => Ok(Some(contents)),
157        Err(e) => {
158            warn!(?e, ?commit_template_path, "Could not read commit template");
159            Ok(None)
160        }
161    }
162}
163
164/// Get the default init branch name.
165#[instrument]
166pub fn get_default_branch_name(repo: &Repo) -> eyre::Result<Option<String>> {
167    let config = repo.get_readonly_config()?;
168    let default_branch_name: Option<String> = config.get("init.defaultBranch")?;
169    Ok(default_branch_name)
170}
171
172/// Get the configured editor, if any.
173///
174/// Because this is primarily intended for use w/ dialoguer::Editor, and it already considers
175/// several environment variables, we only need to consider git-specific config options: the
176/// `$GIT_EDITOR` environment var and the `core.editor` config setting. We do so in that order to
177/// match how git resolves the editor to use.
178///
179/// FMI see <https://git-scm.com/docs/git-var#Documentation/git-var.txt-GITEDITOR>
180#[instrument]
181pub fn get_editor(git_run_info: &GitRunInfo, repo: &Repo) -> eyre::Result<Option<OsString>> {
182    if let Ok(result) =
183        git_run_info.run_silent(repo, None, &["var", "GIT_EDITOR"], GitRunOpts::default())
184    {
185        if result.exit_code.is_success() {
186            let editor =
187                std::str::from_utf8(&result.stdout).context("Decoding git var output as UTF-8")?;
188            let editor = editor.trim_end();
189            let editor = OsString::from(editor);
190            return Ok(Some(editor));
191        } else {
192            warn!(?result, "`git var` invocation failed");
193        }
194    }
195
196    let editor = std::env::var_os("GIT_EDITOR");
197    if editor.is_some() {
198        return Ok(editor);
199    }
200
201    let config = repo.get_readonly_config()?;
202    let editor: Option<String> = config.get("core.editor")?;
203    match editor {
204        Some(editor) => Ok(Some(editor.into())),
205        None => Ok(None),
206    }
207}
208
209/// If `true`, create working copy snapshots automatically after certain
210/// operations.
211#[instrument]
212pub fn get_undo_create_snapshots(repo: &Repo) -> eyre::Result<bool> {
213    repo.get_readonly_config()?
214        .get_or("branchless.undo.createSnapshots", true)
215}
216
217/// If `true`, when restacking a commit, do not update its timestamp to the
218/// current time.
219#[instrument]
220pub fn get_restack_preserve_timestamps(repo: &Repo) -> eyre::Result<bool> {
221    repo.get_readonly_config()?
222        .get_or("branchless.restack.preserveTimestamps", false)
223}
224
225/// If `true`, when advancing to a "next" commit, prompt interactively to
226/// if there is ambiguity in which commit to advance to.
227#[instrument]
228pub fn get_next_interactive(repo: &Repo) -> eyre::Result<bool> {
229    repo.get_readonly_config()?
230        .get_or("branchless.next.interactive", false)
231}
232
233/// If `true`, show branches pointing to each commit in the smartlog.
234#[instrument]
235pub fn get_commit_descriptors_branches(repo: &Repo) -> eyre::Result<bool> {
236    repo.get_readonly_config()?
237        .get_or("branchless.commitDescriptors.branches", true)
238}
239
240/// If `true`, show associated Phabricator commits in the smartlog.
241#[instrument]
242pub fn get_commit_descriptors_differential_revision(repo: &Repo) -> eyre::Result<bool> {
243    repo.get_readonly_config()?
244        .get_or("branchless.commitDescriptors.differentialRevision", true)
245}
246
247/// If `true`, show the age of each commit in the smartlog.
248#[instrument]
249pub fn get_commit_descriptors_relative_time(repo: &Repo) -> eyre::Result<bool> {
250    repo.get_readonly_config()?
251        .get_or("branchless.commitDescriptors.relativeTime", true)
252}
253
254/// Config key for `get_restack_warn_abandoned`.
255pub const RESTACK_WARN_ABANDONED_CONFIG_KEY: &str = "branchless.restack.warnAbandoned";
256
257/// Possible hint types.
258#[derive(Clone, Debug)]
259pub enum Hint {
260    /// Suggest running `git add` on skipped, untracked files, which are never
261    /// automatically reconsidered for tracking.
262    AddSkippedFiles,
263
264    /// Suggest running `git test clean` in order to clean cached test results.
265    CleanCachedTestResults,
266
267    /// Suggest omitting arguments when they would default to `HEAD`.
268    MoveImplicitHeadArgument,
269
270    /// Suggest running `git restack` when a commit is abandoned as part of a `rewrite` event.
271    RestackWarnAbandoned,
272
273    /// Suggest running `git restack` when the smartlog prints an abandoned commit.
274    SmartlogFixAbandoned,
275
276    /// Suggest showing more output with `git test show` using `--verbose`.
277    TestShowVerbose,
278}
279
280impl Hint {
281    fn get_config_key(&self) -> &'static str {
282        match self {
283            Hint::AddSkippedFiles => "branchless.hint.addSkippedFiles",
284            Hint::CleanCachedTestResults => "branchless.hint.cleanCachedTestResults",
285            Hint::MoveImplicitHeadArgument => "branchless.hint.moveImplicitHeadArgument",
286            Hint::RestackWarnAbandoned => "branchless.hint.restackWarnAbandoned",
287            Hint::SmartlogFixAbandoned => "branchless.hint.smartlogFixAbandoned",
288            Hint::TestShowVerbose => "branchless.hint.testShowVerbose",
289        }
290    }
291}
292
293/// Determine if a given hint is enabled.
294pub fn get_hint_enabled(repo: &Repo, hint: Hint) -> eyre::Result<bool> {
295    repo.get_readonly_config()?
296        .get_or(hint.get_config_key(), true)
297}
298
299/// Render the leading colored "hint" text for use in messaging.
300pub fn get_hint_string() -> StyledString {
301    StyledStringBuilder::new()
302        .append_styled(
303            "hint",
304            Style::merge(&[BaseColor::Blue.dark().into(), Effect::Bold.into()]),
305        )
306        .build()
307}
308
309/// Print instructions explaining how to disable a given hint.
310pub fn print_hint_suppression_notice(effects: &Effects, hint: Hint) -> eyre::Result<()> {
311    writeln!(
312        effects.get_output_stream(),
313        "{}: disable this hint by running: git config --global {} false",
314        effects.get_glyphs().render(get_hint_string())?,
315        hint.get_config_key(),
316    )?;
317    Ok(())
318}
319
320/// Environment variables which affect the functioning of `git-branchless`.
321pub mod env_vars {
322    use std::path::PathBuf;
323
324    use tracing::instrument;
325
326    /// Path to the Git executable to shell out to as a subprocess when
327    /// appropriate. This may be set during tests.
328    pub const TEST_GIT: &str = "TEST_GIT";
329
330    /// "Path to wherever your core Git programs are installed". You can find
331    /// the default value by running `git --exec-path`.
332    ///
333    /// See <https://git-scm.com/docs/git#Documentation/git.txt---exec-pathltpathgt>.
334    pub const TEST_GIT_EXEC_PATH: &str = "TEST_GIT_EXEC_PATH";
335
336    /// Specifies `git-branchless` subcommands to invoke directly.
337    ///
338    /// For example, `TEST_SEPARATE_COMMAND_BINARIES=init test`, this function
339    /// would try to run `git-branchless-init` instead of `git-branchless init`
340    /// and `git-branchless-test` instead of `git-branchless test`.
341    ///
342    /// Why? The `git test` command is implemented in its own
343    /// `git-branchless-test` binary. It's slow to include it in
344    /// `git-branchless` itself because we have to relink the entire
345    /// `git-branchless` binary whenever just a portion of `git-branchless-test`
346    /// changes, which leads to slow incremental builds and iteration times.
347    ///
348    /// Instead, we can assume that its dependency commands like `git branchless
349    /// init` don't change when we're incrementally rebuilding the tests, and we
350    /// can try to build and use the existing `git-branchless-init` binary on
351    /// disk, which shouldn't change when we make changes to
352    /// `git-branchless-test`.
353    ///
354    /// Common dependency binaries like `git-branchless-init` should be built
355    /// with the `cargo build -p git-branchless-init` before running incremental
356    /// tests. (Ideally, this would happen automatically by marking the binaries
357    /// as dependencies and having Cargo build them, but that's not implemented;
358    /// see <https://github.com/rust-lang/cargo/issues/4316> for more details).
359    ///
360    /// If there *is* a change to `git-branchless-init`, and it hasn't been
361    /// built, and the test is rerun, then it might fail in an unusual way
362    /// because it's invoking the wrong version of the `git-branchless-init`
363    /// code. This can be fixed by building the necessary dependency binaries
364    /// manually.
365    pub const TEST_SEPARATE_COMMAND_BINARIES: &str = "TEST_SEPARATE_COMMAND_BINARIES";
366
367    /// Get the path to the Git executable for testing.
368    #[instrument]
369    pub fn get_path_to_git() -> eyre::Result<PathBuf> {
370        let path_to_git = std::env::var_os(TEST_GIT).ok_or_else(|| {
371            eyre::eyre!(
372                "No path to Git executable was set. \
373Try running as: `{0}=$(which git) cargo test ...` \
374or set `env.{0}` in your `config.toml` \
375(see https://doc.rust-lang.org/cargo/reference/config.html)",
376                TEST_GIT,
377            )
378        })?;
379        let path_to_git = PathBuf::from(&path_to_git);
380        Ok(path_to_git)
381    }
382
383    /// Get the `GIT_EXEC_PATH` environment variable for testing.
384    #[instrument]
385    pub fn get_git_exec_path() -> eyre::Result<PathBuf> {
386        let git_exec_path = std::env::var_os(TEST_GIT_EXEC_PATH).ok_or_else(|| {
387            eyre::eyre!(
388                "No Git exec path was set. \
389Try running as: `{0}=$(git --exec-path) cargo test ...` \
390or set `env.{0}` in your `config.toml` \
391(see https://doc.rust-lang.org/cargo/reference/config.html)",
392                TEST_GIT_EXEC_PATH,
393            )
394        })?;
395        let git_exec_path = PathBuf::from(&git_exec_path);
396        Ok(git_exec_path)
397    }
398
399    /// Determine whether the specified binary should be run separately. See
400    /// [`TEST_SEPARATE_COMMAND_BINARIES`] for more details.
401    #[instrument]
402    pub fn should_use_separate_command_binary(program: &str) -> bool {
403        let values = match std::env::var("TEST_SEPARATE_COMMAND_BINARIES") {
404            Ok(value) => value,
405            Err(_) => return false,
406        };
407        let program = program.strip_prefix("git-branchless-").unwrap_or(program);
408        values
409            .split_ascii_whitespace()
410            .any(|value| value.strip_prefix("git-branchless-").unwrap_or(value) == program)
411    }
412}