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_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
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 ("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#[derive(Debug)]
110pub enum Hook {
111 RegularHook {
113 path: PathBuf,
115 },
116
117 MultiHook {
119 path: PathBuf,
121 },
122}
123
124#[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 #[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 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
302fn 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 "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#[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 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#[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#[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 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#[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#[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#[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}