1#![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
35pub 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#[derive(Debug)]
109pub enum Hook {
110 RegularHook {
112 path: PathBuf,
114 },
115
116 MultiHook {
118 path: PathBuf,
120 },
121}
122
123#[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 #[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 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
301fn 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 "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#[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 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#[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#[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 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#[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#[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#[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}