diff --git a/Cargo.lock b/Cargo.lock index 1b0a12a..92342fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,6 +150,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "current_dir" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eef2919c1b81568a02425279ba21d61a450e76848eb1b50dff756b739529b70a" + [[package]] name = "deranged" version = "0.3.11" @@ -224,6 +230,7 @@ dependencies = [ "clap", "clap_complete", "clap_complete_nushell", + "current_dir", "git2", "memchr", "slog", diff --git a/Cargo.toml b/Cargo.toml index f850b8d..01bb234 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,4 +35,5 @@ memchr = "2.3" anyhow = "1.0" [dev-dependencies] +current_dir = "0.1.0" tempfile = "3.1" diff --git a/Documentation/git-absorb.adoc b/Documentation/git-absorb.adoc index 632f9cf..b11258c 100644 --- a/Documentation/git-absorb.adoc +++ b/Documentation/git-absorb.adoc @@ -44,7 +44,8 @@ FLAGS -r:: --and-rebase:: - Run rebase if successful + Run rebase if successful. + See also the REBASE_OPTIONS below. -n:: --dry-run:: @@ -62,7 +63,7 @@ FLAGS -f:: --force:: - Skip all safety checks as if all --force-* flags were givenj + Skip all safety checks as if all --force-* flags were given. See those flags to understand the full effect of supplying --force. -w:: @@ -93,6 +94,11 @@ OPTIONS Generate completions [possible values: bash, fish, nushell, zsh, powershell, elvish] +-- :: + Options to pass to git rebase after generating commits. + Must be the last arguments and the `--` must be present. + Only valid when `--and-rebase` is used. + USAGE ----- diff --git a/src/lib.rs b/src/lib.rs index b1127b7..dd04c2f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub struct Config<'a> { pub force_detach: bool, pub base: Option<&'a str>, pub and_rebase: bool, + pub rebase_options: &'a Vec<&'a str>, pub whole_file: bool, pub one_fixup_per_commit: bool, } @@ -27,6 +28,12 @@ pub fn run(logger: &slog::Logger, config: &Config) -> Result<()> { } fn run_with_repo(logger: &slog::Logger, config: &Config, repo: &git2::Repository) -> Result<()> { + if !config.rebase_options.is_empty() && !config.and_rebase { + return Err(anyhow!( + "REBASE_OPTIONS were specified without --and-rebase flag" + )); + } + let config = config::unify(&config, repo); let stack = stack::working_stack( repo, @@ -367,6 +374,10 @@ fn run_with_repo(logger: &slog::Logger, config: &Config, repo: &git2::Repository let mut command = Command::new("git"); command.args(["rebase", "--interactive", "--autosquash", "--autostash"]); + for arg in config.rebase_options { + command.arg(arg); + } + if number_of_parents == 0 { command.arg("--root"); } else { @@ -480,6 +491,7 @@ fn index_stats(repo: &git2::Repository) -> Result { #[cfg(test)] mod tests { + use git2::message_trailers_strs; use std::path::PathBuf; use super::*; @@ -525,7 +537,7 @@ mod tests { fn foreign_author() { let ctx = repo_utils::prepare_and_stage(); - repo_utils::become_new_author(&ctx.repo); + repo_utils::become_author(&ctx.repo, "nobody2", "nobody2@example.com"); // run 'git-absorb' let drain = slog::Discard; @@ -543,7 +555,7 @@ mod tests { fn foreign_author_with_force_author_flag() { let ctx = repo_utils::prepare_and_stage(); - repo_utils::become_new_author(&ctx.repo); + repo_utils::become_author(&ctx.repo, "nobody2", "nobody2@example.com"); // run 'git-absorb' let drain = slog::Discard; @@ -565,7 +577,7 @@ mod tests { fn foreign_author_with_force_author_config() { let ctx = repo_utils::prepare_and_stage(); - repo_utils::become_new_author(&ctx.repo); + repo_utils::become_author(&ctx.repo, "nobody2", "nobody2@example.com"); repo_utils::set_config_flag(&ctx.repo, "absorb.forceAuthor"); @@ -663,6 +675,92 @@ mod tests { assert!(nothing_left_in_index(&ctx.repo).unwrap()); } + #[test] + fn and_rebase_flag() { + let ctx = repo_utils::prepare_and_stage(); + repo_utils::set_config_option(&ctx.repo, "core.editor", "true"); + + // run 'git-absorb' + let drain = slog::Discard; + let logger = slog::Logger::root(drain, o!()); + let config = Config { + and_rebase: true, + ..DEFAULT_CONFIG + }; + repo_utils::run_in_repo(&ctx, || run_with_repo(&logger, &config, &ctx.repo)).unwrap(); + + let mut revwalk = ctx.repo.revwalk().unwrap(); + revwalk.push_head().unwrap(); + + assert_eq!(revwalk.count(), 1); + assert!(nothing_left_in_index(&ctx.repo).unwrap()); + } + + #[test] + fn and_rebase_flag_with_rebase_options() { + let ctx = repo_utils::prepare_and_stage(); + repo_utils::set_config_option(&ctx.repo, "core.editor", "true"); + + // run 'git-absorb' + let drain = slog::Discard; + let logger = slog::Logger::root(drain, o!()); + let config = Config { + and_rebase: true, + rebase_options: &vec!["--signoff"], + ..DEFAULT_CONFIG + }; + repo_utils::run_in_repo(&ctx, || run_with_repo(&logger, &config, &ctx.repo)).unwrap(); + + let mut revwalk = ctx.repo.revwalk().unwrap(); + revwalk.push_head().unwrap(); + assert_eq!(revwalk.count(), 1); + + let trailers = message_trailers_strs( + ctx.repo + .head() + .unwrap() + .peel_to_commit() + .unwrap() + .message() + .unwrap(), + ) + .unwrap(); + assert_eq!( + trailers + .iter() + .filter(|trailer| trailer.0 == "Signed-off-by") + .count(), + 1 + ); + + assert!(nothing_left_in_index(&ctx.repo).unwrap()); + } + + #[test] + fn rebase_options_without_and_rebase_flag() { + let ctx = repo_utils::prepare_and_stage(); + + // run 'git-absorb' + let drain = slog::Discard; + let logger = slog::Logger::root(drain, o!()); + let config = Config { + rebase_options: &vec!["--some-option"], + ..DEFAULT_CONFIG + }; + let result = run_with_repo(&logger, &config, &ctx.repo); + + assert_eq!( + result.err().unwrap().to_string(), + "REBASE_OPTIONS were specified without --and-rebase flag" + ); + + let mut revwalk = ctx.repo.revwalk().unwrap(); + revwalk.push_head().unwrap(); + assert_eq!(revwalk.count(), 1); + let is_something_in_index = !nothing_left_in_index(&ctx.repo).unwrap(); + assert!(is_something_in_index); + } + fn autostage_common(ctx: &repo_utils::Context, file_path: &PathBuf) -> (PathBuf, PathBuf) { // 1 modification w/o staging let path = ctx.join(&file_path); @@ -788,6 +886,7 @@ mod tests { force_detach: false, base: None, and_rebase: false, + rebase_options: &Vec::new(), whole_file: false, one_fixup_per_commit: false, }; diff --git a/src/main.rs b/src/main.rs index 5f0778f..c0d70a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,9 @@ struct Cli { /// Run rebase if successful #[clap(long, short = 'r')] and_rebase: bool, + /// Extra arguments to pass to git rebase. Only valid if --and-rebase is set + #[clap(last = true)] + rebase_options: Vec, /// Generate completions #[clap(long, value_name = "SHELL", value_parser = ["bash", "fish", "nushell", "zsh", "powershell", "elvish"])] gen_completions: Option, @@ -52,6 +55,7 @@ fn main() { force, verbose, and_rebase, + rebase_options, gen_completions, whole_file, one_fixup_per_commit, @@ -93,6 +97,7 @@ fn main() { )); } + let rebase_options: Vec<&str> = rebase_options.iter().map(AsRef::as_ref).collect(); if let Err(e) = git_absorb::run( &logger, &git_absorb::Config { @@ -101,6 +106,7 @@ fn main() { force_detach: force_detach || force, base: base.as_deref(), and_rebase, + rebase_options: &rebase_options, whole_file, one_fixup_per_commit, }, diff --git a/src/tests/repo_utils.rs b/src/tests/repo_utils.rs index 5cac36a..f9660e3 100644 --- a/src/tests/repo_utils.rs +++ b/src/tests/repo_utils.rs @@ -1,4 +1,6 @@ #[cfg(test)] +use anyhow::Result; +use current_dir::Cwd; use std::path::{Path, PathBuf}; pub struct Context { pub repo: git2::Repository, @@ -15,6 +17,7 @@ impl Context { pub fn prepare_repo() -> (Context, PathBuf) { let dir = tempfile::tempdir().unwrap(); let repo = git2::Repository::init(dir.path()).unwrap(); + become_author(&repo, "nobody", "nobody@example.com"); let path = PathBuf::from("test-file.txt"); std::fs::write( @@ -32,10 +35,7 @@ lines // make the borrow-checker happy by introducing a new scope { let tree = add(&repo, &path); - let signature = repo - .signature() - .or_else(|_| git2::Signature::now("nobody", "nobody@example.com")) - .unwrap(); + let signature = repo.signature().unwrap(); repo.commit( Some("HEAD"), &signature, @@ -76,10 +76,29 @@ pub fn prepare_and_stage() -> Context { ctx } -pub fn become_new_author(repo: &git2::Repository) { +/// Set the named repository config option to value. +pub fn set_config_option(repo: &git2::Repository, name: &str, value: &str) { + repo.config().unwrap().set_str(name, value).unwrap(); +} + +/// Run a function while in the working directory of the repository. +/// +/// Can be used to ensure that at most one test changes the working +/// directory at a time, preventing clashes. +pub fn run_in_repo(ctx: &Context, f: F) -> Result<()> +where + F: FnOnce() -> Result<()>, +{ + let mut locked_cwd = Cwd::mutex().lock().unwrap(); + locked_cwd.set(ctx.dir.path()).unwrap(); + f() +} + +/// Become a new author - set the user.name and user.email config options. +pub fn become_author(repo: &git2::Repository, name: &str, email: &str) { let mut config = repo.config().unwrap(); - config.set_str("user.name", "nobody2").unwrap(); - config.set_str("user.email", "nobody2@example.com").unwrap(); + config.set_str("user.name", name).unwrap(); + config.set_str("user.email", email).unwrap(); } /// Detach HEAD from the current branch.