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