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/// Get the default comment character.
112#[instrument]
113pub fn get_comment_char(repo: &Repo) -> eyre::Result<char> {
114    let from_config: Option<String> = repo.get_readonly_config()?.get("core.commentChar")?;
115    let comment_char = match from_config {
116        // Note that git also allows `core.commentChar="auto"`, which we do not currently support.
117        Some(comment_char) => comment_char.chars().next().unwrap(),
118        None => char::from(git2::DEFAULT_COMMENT_CHAR.unwrap()),
119    };
120    Ok(comment_char)
121}
122
123/// Get the commit template message, if any.
124#[instrument]
125pub fn get_commit_template(repo: &Repo) -> eyre::Result<Option<String>> {
126    let commit_template_path: Option<String> =
127        repo.get_readonly_config()?.get("commit.template")?;
128    let commit_template_path = match commit_template_path {
129        Some(commit_template_path) => PathBuf::from(commit_template_path),
130        None => return Ok(None),
131    };
132
133    let commit_template_path = if commit_template_path.is_relative() {
134        match repo.get_working_copy_path() {
135            Some(root) => root.join(commit_template_path),
136            None => {
137                warn!(?commit_template_path, "Commit template path was relative, but this repository does not have a working copy");
138                return Ok(None);
139            }
140        }
141    } else {
142        commit_template_path
143    };
144
145    match std::fs::read_to_string(&commit_template_path) {
146        Ok(contents) => Ok(Some(contents)),
147        Err(e) => {
148            warn!(?e, ?commit_template_path, "Could not read commit template");
149            Ok(None)
150        }
151    }
152}
153
154/// Get the default init branch name.
155#[instrument]
156pub fn get_default_branch_name(repo: &Repo) -> eyre::Result<Option<String>> {
157    let config = repo.get_readonly_config()?;
158    let default_branch_name: Option<String> = config.get("init.defaultBranch")?;
159    Ok(default_branch_name)
160}
161
162/// Get the configured editor, if any.
163///
164/// Because this is primarily intended for use w/ dialoguer::Editor, and it already considers
165/// several environment variables, we only need to consider git-specific config options: the
166/// `$GIT_EDITOR` environment var and the `core.editor` config setting. We do so in that order to
167/// match how git resolves the editor to use.
168///
169/// FMI see <https://git-scm.com/docs/git-var#Documentation/git-var.txt-GITEDITOR>
170#[instrument]
171pub fn get_editor(git_run_info: &GitRunInfo, repo: &Repo) -> eyre::Result<Option<OsString>> {
172    if let Ok(result) =
173        git_run_info.run_silent(repo, None, &["var", "GIT_EDITOR"], GitRunOpts::default())
174    {
175        if result.exit_code.is_success() {
176            let editor =
177                std::str::from_utf8(&result.stdout).context("Decoding git var output as UTF-8")?;
178            let editor = editor.trim_end();
179            let editor = OsString::from(editor);
180            return Ok(Some(editor));
181        } else {
182            warn!(?result, "`git var` invocation failed");
183        }
184    }
185
186    let editor = std::env::var_os("GIT_EDITOR");
187    if editor.is_some() {
188        return Ok(editor);
189    }
190
191    let config = repo.get_readonly_config()?;
192    let editor: Option<String> = config.get("core.editor")?;
193    match editor {
194        Some(editor) => Ok(Some(editor.into())),
195        None => Ok(None),
196    }
197}
198
199/// If `true`, create working copy snapshots automatically after certain
200/// operations.
201#[instrument]
202pub fn get_undo_create_snapshots(repo: &Repo) -> eyre::Result<bool> {
203    repo.get_readonly_config()?
204        .get_or("branchless.undo.createSnapshots", true)
205}
206
207/// If `true`, when restacking a commit, do not update its timestamp to the
208/// current time.
209#[instrument]
210pub fn get_restack_preserve_timestamps(repo: &Repo) -> eyre::Result<bool> {
211    repo.get_readonly_config()?
212        .get_or("branchless.restack.preserveTimestamps", false)
213}
214
215/// If `true`, when advancing to a "next" commit, prompt interactively to
216/// if there is ambiguity in which commit to advance to.
217#[instrument]
218pub fn get_next_interactive(repo: &Repo) -> eyre::Result<bool> {
219    repo.get_readonly_config()?
220        .get_or("branchless.next.interactive", false)
221}
222
223/// If `true`, show branches pointing to each commit in the smartlog.
224#[instrument]
225pub fn get_commit_descriptors_branches(repo: &Repo) -> eyre::Result<bool> {
226    repo.get_readonly_config()?
227        .get_or("branchless.commitDescriptors.branches", true)
228}
229
230/// If `true`, show associated Phabricator commits in the smartlog.
231#[instrument]
232pub fn get_commit_descriptors_differential_revision(repo: &Repo) -> eyre::Result<bool> {
233    repo.get_readonly_config()?
234        .get_or("branchless.commitDescriptors.differentialRevision", true)
235}
236
237/// If `true`, show the age of each commit in the smartlog.
238#[instrument]
239pub fn get_commit_descriptors_relative_time(repo: &Repo) -> eyre::Result<bool> {
240    repo.get_readonly_config()?
241        .get_or("branchless.commitDescriptors.relativeTime", true)
242}
243
244/// Config key for `get_restack_warn_abandoned`.
245pub const RESTACK_WARN_ABANDONED_CONFIG_KEY: &str = "branchless.restack.warnAbandoned";
246
247/// Possible hint types.
248#[derive(Clone, Debug)]
249pub enum Hint {
250    /// Suggest running `git test clean` in order to clean cached test results.
251    CleanCachedTestResults,
252
253    /// Suggest omitting arguments when they would default to `HEAD`.
254    MoveImplicitHeadArgument,
255
256    /// Suggest running `git restack` when a commit is abandoned as part of a `rewrite` event.
257    RestackWarnAbandoned,
258
259    /// Suggest running `git restack` when the smartlog prints an abandoned commit.
260    SmartlogFixAbandoned,
261
262    /// Suggest showing more output with `git test show` using `--verbose`.
263    TestShowVerbose,
264}
265
266impl Hint {
267    fn get_config_key(&self) -> &'static str {
268        match self {
269            Hint::CleanCachedTestResults => "branchless.hint.cleanCachedTestResults",
270            Hint::MoveImplicitHeadArgument => "branchless.hint.moveImplicitHeadArgument",
271            Hint::RestackWarnAbandoned => "branchless.hint.restackWarnAbandoned",
272            Hint::SmartlogFixAbandoned => "branchless.hint.smartlogFixAbandoned",
273            Hint::TestShowVerbose => "branchless.hint.testShowVerbose",
274        }
275    }
276}
277
278/// Determine if a given hint is enabled.
279pub fn get_hint_enabled(repo: &Repo, hint: Hint) -> eyre::Result<bool> {
280    repo.get_readonly_config()?
281        .get_or(hint.get_config_key(), true)
282}
283
284/// Render the leading colored "hint" text for use in messaging.
285pub fn get_hint_string() -> StyledString {
286    StyledStringBuilder::new()
287        .append_styled(
288            "hint",
289            Style::merge(&[BaseColor::Blue.dark().into(), Effect::Bold.into()]),
290        )
291        .build()
292}
293
294/// Print instructions explaining how to disable a given hint.
295pub fn print_hint_suppression_notice(effects: &Effects, hint: Hint) -> eyre::Result<()> {
296    writeln!(
297        effects.get_output_stream(),
298        "{}: disable this hint by running: git config --global {} false",
299        effects.get_glyphs().render(get_hint_string())?,
300        hint.get_config_key(),
301    )?;
302    Ok(())
303}
304
305/// Environment variables which affect the functioning of `git-branchless`.
306pub mod env_vars {
307    use std::path::PathBuf;
308
309    use tracing::instrument;
310
311    /// Path to the Git executable to shell out to as a subprocess when
312    /// appropriate. This may be set during tests.
313    pub const TEST_GIT: &str = "TEST_GIT";
314
315    /// "Path to wherever your core Git programs are installed". You can find
316    /// the default value by running `git --exec-path`.
317    ///
318    /// See <https://git-scm.com/docs/git#Documentation/git.txt---exec-pathltpathgt>.
319    pub const TEST_GIT_EXEC_PATH: &str = "TEST_GIT_EXEC_PATH";
320
321    /// Specifies `git-branchless` subcommands to invoke directly.
322    ///
323    /// For example, `TEST_SEPARATE_COMMAND_BINARIES=init test`, this function
324    /// would try to run `git-branchless-init` instead of `git-branchless init`
325    /// and `git-branchless-test` instead of `git-branchless test`.
326    ///
327    /// Why? The `git test` command is implemented in its own
328    /// `git-branchless-test` binary. It's slow to include it in
329    /// `git-branchless` itself because we have to relink the entire
330    /// `git-branchless` binary whenever just a portion of `git-branchless-test`
331    /// changes, which leads to slow incremental builds and iteration times.
332    ///
333    /// Instead, we can assume that its dependency commands like `git branchless
334    /// init` don't change when we're incrementally rebuilding the tests, and we
335    /// can try to build and use the existing `git-branchless-init` binary on
336    /// disk, which shouldn't change when we make changes to
337    /// `git-branchless-test`.
338    ///
339    /// Common dependency binaries like `git-branchless-init` should be built
340    /// with the `cargo build -p git-branchless-init` before running incremental
341    /// tests. (Ideally, this would happen automatically by marking the binaries
342    /// as dependencies and having Cargo build them, but that's not implemented;
343    /// see <https://github.com/rust-lang/cargo/issues/4316> for more details).
344    ///
345    /// If there *is* a change to `git-branchless-init`, and it hasn't been
346    /// built, and the test is rerun, then it might fail in an unusual way
347    /// because it's invoking the wrong version of the `git-branchless-init`
348    /// code. This can be fixed by building the necessary dependency binaries
349    /// manually.
350    pub const TEST_SEPARATE_COMMAND_BINARIES: &str = "TEST_SEPARATE_COMMAND_BINARIES";
351
352    /// Get the path to the Git executable for testing.
353    #[instrument]
354    pub fn get_path_to_git() -> eyre::Result<PathBuf> {
355        let path_to_git = std::env::var_os(TEST_GIT).ok_or_else(|| {
356            eyre::eyre!(
357                "No path to Git executable was set. \
358Try running as: `{0}=$(which git) cargo test ...` \
359or set `env.{0}` in your `config.toml` \
360(see https://doc.rust-lang.org/cargo/reference/config.html)",
361                TEST_GIT,
362            )
363        })?;
364        let path_to_git = PathBuf::from(&path_to_git);
365        Ok(path_to_git)
366    }
367
368    /// Get the `GIT_EXEC_PATH` environment variable for testing.
369    #[instrument]
370    pub fn get_git_exec_path() -> eyre::Result<PathBuf> {
371        let git_exec_path = std::env::var_os(TEST_GIT_EXEC_PATH).ok_or_else(|| {
372            eyre::eyre!(
373                "No Git exec path was set. \
374Try running as: `{0}=$(git --exec-path) cargo test ...` \
375or set `env.{0}` in your `config.toml` \
376(see https://doc.rust-lang.org/cargo/reference/config.html)",
377                TEST_GIT_EXEC_PATH,
378            )
379        })?;
380        let git_exec_path = PathBuf::from(&git_exec_path);
381        Ok(git_exec_path)
382    }
383
384    /// Determine whether the specified binary should be run separately. See
385    /// [`TEST_SEPARATE_COMMAND_BINARIES`] for more details.
386    #[instrument]
387    pub fn should_use_separate_command_binary(program: &str) -> bool {
388        let values = match std::env::var("TEST_SEPARATE_COMMAND_BINARIES") {
389            Ok(value) => value,
390            Err(_) => return false,
391        };
392        let program = program.strip_prefix("git-branchless-").unwrap_or(program);
393        values
394            .split_ascii_whitespace()
395            .any(|value| value.strip_prefix("git-branchless-").unwrap_or(value) == program)
396    }
397}