Skip to main content

git_branchless_init/
lib.rs

1//! Install any hooks, aliases, etc. to set up `git-branchless` in this repo.
2
3#![warn(missing_docs)]
4#![warn(
5    clippy::all,
6    clippy::as_conversions,
7    clippy::clone_on_ref_ptr,
8    clippy::dbg_macro
9)]
10#![allow(clippy::too_many_arguments, clippy::blocks_in_conditions)]
11
12use std::fmt::Write;
13use std::io::{BufRead, BufReader, Write as WriteIo, stdin, stdout};
14use std::path::{Path, PathBuf};
15
16use console::style;
17use eyre::Context;
18use git_branchless_invoke::CommandContext;
19use itertools::Itertools;
20use lib::core::config::env_vars::should_use_separate_command_binary;
21use lib::util::EyreExitOr;
22use path_slash::PathExt;
23use tracing::{instrument, warn};
24
25use git_branchless_opts::{InitArgs, InstallManPagesArgs, write_man_pages};
26use lib::core::config::{
27    get_default_branch_name, get_default_hooks_dir, get_main_worktree_hooks_dir,
28};
29use lib::core::dag::Dag;
30use lib::core::effects::Effects;
31use lib::core::eventlog::{EventLogDb, EventReplayer};
32use lib::core::repo_ext::RepoExt;
33use lib::git::{BranchType, Config, ConfigRead, ConfigWrite, GitRunInfo, GitVersion, Repo};
34
35/// The contents of all Git hooks to install.
36pub const ALL_HOOKS: &[(&str, &str)] = &[
37    (
38        "post-applypatch",
39        r#"
40git branchless hook post-applypatch "$@"
41"#,
42    ),
43    (
44        "post-checkout",
45        r#"
46git branchless hook post-checkout "$@"
47"#,
48    ),
49    (
50        "post-commit",
51        r#"
52git branchless hook post-commit "$@"
53"#,
54    ),
55    (
56        "post-merge",
57        r#"
58git branchless hook post-merge "$@"
59"#,
60    ),
61    (
62        "post-rewrite",
63        r#"
64git branchless hook post-rewrite "$@"
65"#,
66    ),
67    (
68        "pre-auto-gc",
69        r#"
70git branchless hook pre-auto-gc "$@"
71"#,
72    ),
73    (
74        "reference-transaction",
75        r#"
76# Avoid canceling the reference transaction in the case that `branchless` fails
77# for whatever reason.
78git branchless hook reference-transaction "$@" || (
79echo 'branchless: Failed to process reference transaction!'
80echo 'branchless: Some events (e.g. branch updates) may have been lost.'
81echo 'branchless: This is a bug. Please report it.'
82)
83"#,
84    ),
85];
86
87const ALL_ALIASES: &[(&str, &str)] = &[
88    ("amend", "amend"),
89    ("hide", "hide"),
90    ("move", "move"),
91    ("next", "next"),
92    ("prev", "prev"),
93    ("query", "query"),
94    ("record", "record"),
95    ("restack", "restack"),
96    ("reword", "reword"),
97    ("sl", "smartlog"),
98    ("split", "split"),
99    ("smartlog", "smartlog"),
100    ("submit", "submit"),
101    ("sw", "switch"),
102    ("sync", "sync"),
103    ("test", "test"),
104    ("undo", "undo"),
105    ("unhide", "unhide"),
106];
107
108/// A specification for installing a Git hook on disk.
109#[derive(Debug)]
110pub enum Hook {
111    /// Regular Git hook.
112    RegularHook {
113        /// The path to the hook script.
114        path: PathBuf,
115    },
116
117    /// For Twitter multihooks. (But does anyone even work at Twitter anymore?)
118    MultiHook {
119        /// The path to the hook script.
120        path: PathBuf,
121    },
122}
123
124/// Determine the path where all hooks are installed.
125#[instrument]
126pub fn determine_hook_path(repo: &Repo, hooks_dir: &Path, hook_type: &str) -> eyre::Result<Hook> {
127    let multi_hooks_path = repo.get_path().join("hooks_multi");
128    let hook = if multi_hooks_path.exists() {
129        let path = multi_hooks_path
130            .join(format!("{hook_type}.d"))
131            .join("00_local_branchless");
132        Hook::MultiHook { path }
133    } else {
134        let path = hooks_dir.join(hook_type);
135        Hook::RegularHook { path }
136    };
137    Ok(hook)
138}
139
140const SHEBANG: &str = "#!/bin/sh";
141const UPDATE_MARKER_START: &str = "## START BRANCHLESS CONFIG";
142const UPDATE_MARKER_END: &str = "## END BRANCHLESS CONFIG";
143
144fn append_hook(new_lines: &mut String, hook_contents: &str) {
145    new_lines.push_str(UPDATE_MARKER_START);
146    new_lines.push('\n');
147    new_lines.push_str(hook_contents);
148    new_lines.push_str(UPDATE_MARKER_END);
149    new_lines.push('\n');
150}
151
152fn update_between_lines(lines: &str, updated_lines: &str) -> String {
153    let mut new_lines = String::new();
154    let mut found_marker = false;
155    let mut is_ignoring_lines = false;
156    for line in lines.lines() {
157        if line == UPDATE_MARKER_START {
158            found_marker = true;
159            is_ignoring_lines = true;
160            append_hook(&mut new_lines, updated_lines);
161        } else if line == UPDATE_MARKER_END {
162            is_ignoring_lines = false;
163        } else if !is_ignoring_lines {
164            new_lines.push_str(line);
165            new_lines.push('\n');
166        }
167    }
168    if is_ignoring_lines {
169        warn!("Unterminated branchless config comment in hook");
170    } else if !found_marker {
171        append_hook(&mut new_lines, updated_lines);
172    }
173    new_lines
174}
175
176#[instrument]
177fn write_script(path: &Path, contents: &str) -> eyre::Result<()> {
178    let script_dir = path
179        .parent()
180        .ok_or_else(|| eyre::eyre!("No parent for dir {:?}", path))?;
181    std::fs::create_dir_all(script_dir).wrap_err("Creating script dir")?;
182
183    let contents = if should_use_separate_command_binary("hook") {
184        contents.replace("branchless hook", "branchless-hook")
185    } else {
186        contents.to_string()
187    };
188    std::fs::write(path, contents).wrap_err("Writing script contents")?;
189
190    // Setting hook file as executable only supported on Unix systems.
191    #[cfg(unix)]
192    {
193        use std::os::unix::fs::PermissionsExt;
194        let metadata = std::fs::metadata(path).wrap_err("Reading script permissions")?;
195        let mut permissions = metadata.permissions();
196        let mode = permissions.mode();
197        // Set execute bits.
198        let mode = mode | 0o111;
199        permissions.set_mode(mode);
200        std::fs::set_permissions(path, permissions)
201            .wrap_err_with(|| format!("Marking {path:?} as executable"))?;
202    }
203
204    Ok(())
205}
206
207#[instrument]
208fn update_hook_contents(hook: &Hook, hook_contents: &str) -> eyre::Result<()> {
209    let (hook_path, hook_contents) = match hook {
210        Hook::RegularHook { path } => match std::fs::read_to_string(path) {
211            Ok(lines) => {
212                let lines = update_between_lines(&lines, hook_contents);
213                (path, lines)
214            }
215            Err(ref err) if err.kind() == std::io::ErrorKind::NotFound => {
216                let hook_contents = format!(
217                    "{SHEBANG}\n{UPDATE_MARKER_START}\n{hook_contents}\n{UPDATE_MARKER_END}\n"
218                );
219                (path, hook_contents)
220            }
221            Err(other) => {
222                return Err(eyre::eyre!(other));
223            }
224        },
225        Hook::MultiHook { path } => (path, format!("{SHEBANG}\n{hook_contents}")),
226    };
227
228    write_script(hook_path, &hook_contents).wrap_err("Writing hook script")?;
229
230    Ok(())
231}
232
233#[instrument]
234fn install_hook(
235    repo: &Repo,
236    hooks_dir: &Path,
237    hook_type: &str,
238    hook_script: &str,
239) -> eyre::Result<()> {
240    let hook = determine_hook_path(repo, hooks_dir, hook_type)?;
241    update_hook_contents(&hook, hook_script)?;
242    Ok(())
243}
244
245#[instrument]
246fn install_hooks(effects: &Effects, git_run_info: &GitRunInfo, repo: &Repo) -> eyre::Result<()> {
247    writeln!(
248        effects.get_output_stream(),
249        "Installing hooks: {}",
250        ALL_HOOKS
251            .iter()
252            .map(|(hook_type, _hook_script)| hook_type)
253            .join(", ")
254    )?;
255    let hooks_dir = get_main_worktree_hooks_dir(git_run_info, repo, None)?;
256    for (hook_type, hook_script) in ALL_HOOKS {
257        install_hook(repo, &hooks_dir, hook_type, hook_script)?;
258    }
259
260    let default_hooks_dir = get_default_hooks_dir(repo)?;
261    if hooks_dir != default_hooks_dir {
262        writeln!(
263            effects.get_output_stream(),
264            "\
265{}: the configuration value core.hooksPath was set to: {},
266which is not the expected default value of: {}
267The Git hooks above may have been installed to an unexpected global location.",
268            style("Warning").yellow().bold(),
269            hooks_dir.to_string_lossy(),
270            default_hooks_dir.to_string_lossy()
271        )?;
272    }
273
274    Ok(())
275}
276
277#[instrument]
278fn uninstall_hooks(effects: &Effects, git_run_info: &GitRunInfo, repo: &Repo) -> eyre::Result<()> {
279    writeln!(
280        effects.get_output_stream(),
281        "Uninstalling hooks: {}",
282        ALL_HOOKS
283            .iter()
284            .map(|(hook_type, _hook_script)| hook_type)
285            .join(", ")
286    )?;
287    let hooks_dir = get_main_worktree_hooks_dir(git_run_info, repo, None)?;
288    for (hook_type, _hook_script) in ALL_HOOKS {
289        install_hook(
290            repo,
291            &hooks_dir,
292            hook_type,
293            r#"
294# This hook has been uninstalled.
295# Run `git branchless init` to reinstall.
296"#,
297        )?;
298    }
299    Ok(())
300}
301
302/// Determine if we should make an alias of the form `branchless smartlog` or
303/// `branchless-smartlog`.
304///
305/// The form of the alias is important because it determines what command Git
306/// tries to look up with `man` when you run e.g. `git smartlog --help`:
307///
308/// - `branchless smartlog`: invokes `man git-branchless`, which means that the
309///   subcommand is not included in the `man` invocation, so it can only show
310///   generic help.
311/// - `branchless-smartlog`: invokes `man git-branchless-smartlog, so the
312///   subcommand is included in the `man` invocation, so it can show more specific
313///   help.
314fn should_use_wrapped_command_alias() -> bool {
315    cfg!(feature = "man-pages")
316}
317
318#[instrument]
319fn install_alias(
320    effects: &Effects,
321    repo: &Repo,
322    config: &mut Config,
323    default_config: &Config,
324    from: &str,
325    to: &str,
326) -> eyre::Result<()> {
327    let alias_key = format!("alias.{from}");
328
329    let existing_alias: Option<String> = config.get(&alias_key)?;
330    if existing_alias.is_some() {
331        config.remove(&alias_key)?;
332    }
333
334    let default_alias: Option<String> = default_config.get(&alias_key)?;
335    if default_alias.is_some() {
336        writeln!(
337            effects.get_output_stream(),
338            "Alias {from} already installed, skipping"
339        )?;
340        return Ok(());
341    }
342
343    let alias = if should_use_wrapped_command_alias() {
344        format!("branchless-{to}")
345    } else {
346        format!("branchless {to}")
347    };
348    config.set(&alias_key, alias)?;
349    Ok(())
350}
351
352#[instrument]
353fn detect_main_branch_name(repo: &Repo) -> eyre::Result<Option<String>> {
354    if let Some(default_branch_name) = get_default_branch_name(repo)? {
355        if repo
356            .find_branch(&default_branch_name, BranchType::Local)?
357            .is_some()
358        {
359            return Ok(Some(default_branch_name));
360        }
361    }
362
363    for branch_name in [
364        "master",
365        "main",
366        "mainline",
367        "devel",
368        "develop",
369        "development",
370        "trunk",
371    ] {
372        if repo.find_branch(branch_name, BranchType::Local)?.is_some() {
373            return Ok(Some(branch_name.to_string()));
374        }
375    }
376    Ok(None)
377}
378
379#[instrument]
380fn install_aliases(
381    effects: &Effects,
382    repo: &mut Repo,
383    config: &mut Config,
384    default_config: &Config,
385    git_run_info: &GitRunInfo,
386) -> eyre::Result<()> {
387    for (from, to) in ALL_ALIASES {
388        install_alias(effects, repo, config, default_config, from, to)?;
389    }
390
391    let version_str = git_run_info
392        .run_silent(repo, None, &["version"], Default::default())
393        .wrap_err("Determining Git version")?
394        .stdout;
395    let version_str =
396        String::from_utf8(version_str).wrap_err("Decoding stdout from Git subprocess")?;
397    let version_str = version_str.trim();
398    let version: GitVersion = version_str
399        .parse()
400        .wrap_err_with(|| format!("Parsing Git version string: {version_str}"))?;
401    if version < GitVersion(2, 29, 0) {
402        write!(
403            effects.get_output_stream(),
404            "\
405{warning_str}: the branchless workflow's `git undo` command requires Git
406v2.29 or later, but your Git version is: {version_str}
407
408Some operations, such as branch updates, won't be correctly undone. Other
409operations may be undoable. Attempt at your own risk.
410
411Once you upgrade to Git v2.29, run `git branchless init` again. Any work you
412do from then on will be correctly undoable.
413
414This only applies to the `git undo` command. Other commands which are part of
415the branchless workflow will work properly.
416",
417            warning_str = style("Warning").yellow().bold(),
418            version_str = version_str,
419        )?;
420    }
421
422    Ok(())
423}
424
425#[instrument]
426fn install_man_pages(effects: &Effects, repo: &Repo, config: &mut Config) -> eyre::Result<()> {
427    let should_install = cfg!(feature = "man-pages");
428    if !should_install {
429        return Ok(());
430    }
431
432    let man_dir = repo.get_man_dir()?;
433    let man_dir_relative = {
434        let man_dir_relative = man_dir.strip_prefix(repo.get_path()).wrap_err_with(|| {
435            format!(
436                "Getting relative path for {:?} with respect to {:?}",
437                &man_dir,
438                repo.get_path()
439            )
440        })?;
441        &man_dir_relative.to_str().ok_or_else(|| {
442            eyre::eyre!(
443                "Could not convert man dir to UTF-8 string: {:?}",
444                &man_dir_relative
445            )
446        })?
447    };
448    config.set(
449        "man.branchless.cmd",
450        format!(
451            // FIXME: the path to the man directory is not shell-escaped.
452            //
453            // NB: the trailing `:` at the end of `MANPATH` indicates to `man`
454            // that it should try its normal lookup paths if the requested
455            // `man`-page cannot be found in the provided `MANPATH`.
456            "env MANPATH=.git/{man_dir_relative}: man"
457        ),
458    )?;
459    config.set("man.viewer", "branchless")?;
460
461    write_man_pages(&man_dir).wrap_err_with(|| format!("Writing man-pages to: {:?}", &man_dir))?;
462    Ok(())
463}
464
465#[instrument(skip(r#in))]
466fn set_configs(
467    r#in: &mut impl BufRead,
468    effects: &Effects,
469    repo: &Repo,
470    config: &mut Config,
471    main_branch_name: Option<&str>,
472) -> eyre::Result<()> {
473    let main_branch_name = match main_branch_name {
474        Some(main_branch_name) => main_branch_name.to_string(),
475
476        None => match detect_main_branch_name(repo)? {
477            Some(main_branch_name) => {
478                writeln!(
479                    effects.get_output_stream(),
480                    "Auto-detected your main branch as: {}",
481                    console::style(&main_branch_name).bold()
482                )?;
483                writeln!(
484                    effects.get_output_stream(),
485                    "If this is incorrect, run: git branchless init --main-branch <branch>"
486                )?;
487                main_branch_name
488            }
489
490            None => {
491                writeln!(
492                    effects.get_output_stream(),
493                    "{}",
494                    console::style("Your main branch name could not be auto-detected!")
495                        .yellow()
496                        .bold()
497                )?;
498                writeln!(
499                    effects.get_output_stream(),
500                    "Examples of a main branch: master, main, trunk, etc."
501                )?;
502                writeln!(
503                    effects.get_output_stream(),
504                    "See https://github.com/arxanas/git-branchless/wiki/Concepts#main-branch"
505                )?;
506                write!(
507                    effects.get_output_stream(),
508                    "Enter the name of your main branch: "
509                )?;
510                stdout().flush()?;
511                let mut input = String::new();
512                r#in.read_line(&mut input)?;
513                match input.trim() {
514                    "" => eyre::bail!("No main branch name provided"),
515                    main_branch_name => main_branch_name.to_string(),
516                }
517            }
518        },
519    };
520
521    config.set("branchless.core.mainBranch", main_branch_name)?;
522    config.set("advice.detachedHead", false)?;
523    config.set("log.excludeDecoration", "refs/branchless/*")?;
524
525    Ok(())
526}
527
528const INCLUDE_PATH_REGEX: &str = r"^branchless/";
529
530/// Create an isolated configuration file under `.git/branchless`, which is then
531/// included into the repository's main configuration file. This makes it easier
532/// to uninstall our settings (or for the user to override our settings) without
533/// needing to modify the user's configuration file.
534#[instrument]
535fn create_isolated_config(
536    effects: &Effects,
537    repo: &Repo,
538    mut parent_config: Config,
539) -> eyre::Result<Config> {
540    let config_path = repo.get_config_path()?;
541    let config_dir = config_path
542        .parent()
543        .ok_or_else(|| eyre::eyre!("Could not get parent config directory"))?;
544    std::fs::create_dir_all(config_dir).wrap_err("Creating config path parent")?;
545
546    let config = Config::open(&config_path)?;
547    let config_path_relative = config_path
548        .strip_prefix(repo.get_path())
549        .wrap_err("Getting relative config path")?;
550    // Be careful when setting paths on Windows. Since the path would have a
551    // backslash, naively using it produces
552    //
553    //    Git error GenericError: invalid escape at config
554    //
555    // We need to convert it to forward-slashes for Git. See also
556    // https://stackoverflow.com/a/28520596.
557    let config_path_relative = config_path_relative.to_slash().ok_or_else(|| {
558        eyre::eyre!(
559            "Could not convert config path to UTF-8 string: {:?}",
560            &config_path_relative
561        )
562    })?;
563    parent_config.set_multivar("include.path", INCLUDE_PATH_REGEX, config_path_relative)?;
564
565    writeln!(
566        effects.get_output_stream(),
567        "Created config file at {}",
568        config_path.to_string_lossy()
569    )?;
570    Ok(config)
571}
572
573/// Delete the configuration file created by `create_isolated_config` and remove
574/// its `include` directive from the repository's configuration file.
575#[instrument]
576fn delete_isolated_config(
577    effects: &Effects,
578    repo: &Repo,
579    mut parent_config: Config,
580) -> eyre::Result<()> {
581    let config_path = repo.get_config_path()?;
582    writeln!(
583        effects.get_output_stream(),
584        "Removing config file: {}",
585        config_path.to_string_lossy()
586    )?;
587    parent_config.remove_multivar("include.path", INCLUDE_PATH_REGEX)?;
588    let result = match std::fs::remove_file(config_path) {
589        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
590            writeln!(
591                effects.get_output_stream(),
592                "(The config file was not present, ignoring)"
593            )?;
594            Ok(())
595        }
596        result => result,
597    };
598    result.wrap_err("Deleting isolated config")?;
599    Ok(())
600}
601
602/// Initialize `git-branchless` in the current repo.
603#[instrument]
604fn command_init(
605    effects: &Effects,
606    git_run_info: &GitRunInfo,
607    main_branch_name: Option<&str>,
608) -> EyreExitOr<()> {
609    let mut in_ = BufReader::new(stdin());
610    let repo = Repo::from_current_dir()?;
611    let mut repo = repo.open_worktree_parent_repo()?.unwrap_or(repo);
612
613    let default_config = Config::open_default()?;
614    let readonly_config = repo.get_readonly_config()?;
615    let mut config = create_isolated_config(effects, &repo, readonly_config.into_config())?;
616
617    set_configs(&mut in_, effects, &repo, &mut config, main_branch_name)?;
618    install_hooks(effects, git_run_info, &repo)?;
619    install_aliases(
620        effects,
621        &mut repo,
622        &mut config,
623        &default_config,
624        git_run_info,
625    )?;
626    install_man_pages(effects, &repo, &mut config)?;
627
628    let conn = repo.get_db_conn()?;
629    let event_log_db = EventLogDb::new(&conn)?;
630    // If the main branch hasn't been born yet, then we may fail to generate a
631    // references snapshot. In that case, defer syncing of the DAG to a future
632    // invocation, when the main branch has been born.
633    if let Ok(references_snapshot) = repo.get_references_snapshot() {
634        let event_replayer = EventReplayer::from_event_log_db(effects, &repo, &event_log_db)?;
635        let event_cursor = event_replayer.make_default_cursor();
636        Dag::open_and_sync(
637            effects,
638            &repo,
639            &event_replayer,
640            event_cursor,
641            &references_snapshot,
642        )?;
643    }
644
645    writeln!(
646        effects.get_output_stream(),
647        "{}",
648        console::style("Successfully installed git-branchless.")
649            .green()
650            .bold()
651    )?;
652    writeln!(
653        effects.get_output_stream(),
654        "To uninstall, run: {}",
655        console::style("git branchless init --uninstall").bold()
656    )?;
657
658    Ok(Ok(()))
659}
660
661/// Uninstall `git-branchless` in the current repo.
662#[instrument]
663fn command_uninstall(effects: &Effects, git_run_info: &GitRunInfo) -> EyreExitOr<()> {
664    let repo = Repo::from_current_dir()?;
665    let readonly_config = repo.get_readonly_config().wrap_err("Getting repo config")?;
666    delete_isolated_config(effects, &repo, readonly_config.into_config())?;
667    uninstall_hooks(effects, git_run_info, &repo)?;
668    Ok(Ok(()))
669}
670
671/// Install `git-branchless` in the current repo.
672#[instrument]
673pub fn command_main(ctx: CommandContext, args: InitArgs) -> EyreExitOr<()> {
674    let CommandContext {
675        effects,
676        git_run_info,
677    } = ctx;
678    match args {
679        InitArgs {
680            uninstall: false,
681            main_branch_name,
682        } => command_init(&effects, &git_run_info, main_branch_name.as_deref()),
683
684        InitArgs {
685            uninstall: true,
686            main_branch_name: _,
687        } => command_uninstall(&effects, &git_run_info),
688    }
689}
690
691/// Install the man-pages for `git-branchless` to the provided path.
692#[instrument]
693pub fn command_install_man_pages(ctx: CommandContext, args: InstallManPagesArgs) -> EyreExitOr<()> {
694    let InstallManPagesArgs { path } = args;
695    write_man_pages(&path)?;
696    Ok(Ok(()))
697}
698
699#[cfg(test)]
700mod tests {
701    use super::{UPDATE_MARKER_END, UPDATE_MARKER_START, update_between_lines};
702
703    #[test]
704    fn test_update_between_lines() {
705        let input = format!(
706            "\
707hello, world
708{UPDATE_MARKER_START}
709contents 1
710{UPDATE_MARKER_END}
711goodbye, world
712"
713        );
714        let expected = format!(
715            "\
716hello, world
717{UPDATE_MARKER_START}
718contents 2
719contents 3
720{UPDATE_MARKER_END}
721goodbye, world
722"
723        );
724
725        assert_eq!(
726            update_between_lines(
727                &input,
728                "\
729contents 2
730contents 3
731"
732            ),
733            expected
734        )
735    }
736}