diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bc951dc..a5843e6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -86,25 +86,15 @@ jobs: target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - name: Run cargo test - run: cargo test --locked - env: - CARGO_INCREMENTAL: 0 - RUSTFLAGS: "-Cinstrument-coverage" - LLVM_PROFILE_FILE: "git_mob_tool-%p-%m.profraw" - - - name: Download grcov - run: | - mkdir -p "${HOME}/.local/bin" - curl -sL https://github.com/mozilla/grcov/releases/download/v0.8.19/grcov-x86_64-unknown-linux-gnu.tar.bz2 | tar jxf - -C "${HOME}/.local/bin" - echo "$HOME/.local/bin" >> $GITHUB_PATH - - - name: Generate coverage report - run: grcov . -s . --binary-path ./target/debug/ -t lcov --branch --ignore-not-existing --ignore "/*" --ignore "tests/**/*" --ignore src/lib.rs --excl-br-start "mod tests \{" --excl-start "mod tests \{" -o target/tests.lcov + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Generate code coverage + run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info - name: Upload coverage report to Codecov uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./target/tests.lcov + files: lcov.info fail_ci_if_error: true \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d4cfa9d..332dcaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,6 +337,7 @@ version = "1.5.4" dependencies = [ "assert_cmd", "clap", + "home", "inquire", "mockall", "once_cell", @@ -352,6 +353,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "inquire" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index f8c25cc..d0a4073 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ path = "src/main.rs" [dependencies] clap = { version = "4.5.4", features = ["derive"] } +home = "0.5.9" inquire = "0.6.2" [dev-dependencies] diff --git a/README.md b/README.md index 2fd698b..ae24aee 100644 --- a/README.md +++ b/README.md @@ -44,34 +44,26 @@ This CLI app will help you add them automatically and also help you store and ma $ cargo install git-mob-tool ``` -## Configuration +## Setup & Configuration -- Store your team members' details with keys +- Set up a global [`prepare-commit-msg`](https://git-scm.com/docs/githooks#_prepare_commit_msg) githook which appends the `Co-authored-by` trailers to the commit message. ```console - $ git mob coauthor --add lm "Leo Messi" leo.messi@example.com - $ git mob coauthor --add em "Emi Martinez" emi.martinez@example.com - $ git mob coauthor --add sa "Sergio Aguero" sergio.aguero@example.com + $ git mob setup --global ``` -- Set a global [`githooks`](https://git-scm.com/docs/githooks) directory + If a repository overrides `core.hooksPath` git configuration variable (e.g when using husky), then you will additionally need to run `git mob setup --local` for each such repository. This will set up a local (repository-specific) `prepare-commit-msg` githook which invokes the global one. - ```console - $ mkdir ~/git - $ git config --global core.hooksPath "~/git" - ``` + _If you prefer to set this up manually or encounter any issues with the automated setup process, you can follow steps outlined [here.](./docs/manual_setup.md)_ -- Download the [`prepare-commit-msg`](./prepare-commit-msg) file into the directory -- Ensure it is set as executable (Linux and macOS) +- Store your team members' details with keys - ```console - $ chmod +x ./prepare-commit-msg + ```console + $ git mob coauthor --add lm "Leo Messi" leo.messi@example.com + $ git mob coauthor --add em "Emi Martinez" emi.martinez@example.com + $ git mob coauthor --add sa "Sergio Aguero" sergio.aguero@example.com ``` - This `githook` will be used to append the `Co-authored-by` trailers to the commit's message. - - _This githook also adds a Jira Issue ID as a prefix to the commit message if the branch name starts with a string resembling one. If you don't want want this, comment out [line 12 which calls the function `add_jira_issue_id_prefix`](./prepare-commit-msg#LL12)._ - ## Usage - To mob with some team member(s): diff --git a/docs/manual_setup.md b/docs/manual_setup.md new file mode 100644 index 0000000..c319e63 --- /dev/null +++ b/docs/manual_setup.md @@ -0,0 +1,36 @@ +# Manual Setup + +- Set a global [githooks](https://git-scm.com/docs/githooks) directory + + ```console + $ mkdir -p ~/.git/hooks + $ git config --global core.hooksPath "~/.git/hooks" + ``` + +- Download the [`prepare-commit-msg`](../src/commands/prepare-commit-msg) file into the directory +- Ensure it is set as executable (Linux and macOS) + + ```console + $ chmod +x ./prepare-commit-msg + ``` + + This githook will append the `Co-authored-by` trailers to the commit message. + + _If you want this githook to add a Jira Issue ID as a prefix to the commit message when the git branch name begins with a string resembling one, uncomment [line 12 to call the function `add_jira_issue_id_prefix`](../src/commands/prepare-commit-msg#LL12)._ + +## If a repository overrides `core.hooksPath` git configuration variable (e.g when using husky), then you will need to do additional steps for each such repository + +- Retrieve the local (repository-specific) hooks directory + + ```console + $ git config --local core.hooksPath + ``` + +- Download the [`prepare-commit-msg.local`](../src/commands/prepare-commit-msg.local) as `prepare-commit-msg` file into the directory +- Ensure it is set as executable (Linux and macOS) + + ```console + $ chmod +x ./prepare-commit-msg + ``` + + This githook will invoke the global `prepare-commit-msg` githook that you originally set up. diff --git a/src/cli.rs b/src/cli.rs index effcfa5..c2e2a03 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,5 +1,5 @@ use crate::coauthor_repo::CoauthorRepo; -use crate::commands::{coauthor::Coauthor, mob::Mob}; +use crate::commands::{coauthor::Coauthor, mob::Mob, setup::Setup}; use clap::{Parser, Subcommand}; use std::error::Error; use std::io::Write; @@ -26,9 +26,11 @@ use std::str; /// /// Usage example: /// -/// git mob co-author --add lm "Leo Messi" leo.messi@example.com +/// git mob setup --global /// -/// git pair with +/// git mob coauthor --add lm "Leo Messi" leo.messi@example.com +/// +/// git mob --with lm struct Cli { #[command(subcommand)] command: Option, @@ -38,6 +40,8 @@ struct Cli { #[derive(Subcommand)] enum Commands { + /// Create prepare-commit-msg githook which append Co-authored-by trailers to commit message + Setup(Setup), /// Add/delete/list co-author(s) from co-author repository /// /// User must store co-author(s) to co-author repository by using keys @@ -57,6 +61,7 @@ fn run_inner( ) -> Result<(), Box> { match &cli.command { None => cli.mob.handle(coauthor_repo, out)?, + Some(Commands::Setup(setup)) => setup.handle(out)?, Some(Commands::Coauthor(coauthor)) => coauthor.handle(coauthor_repo, out)?, } Ok(()) diff --git a/src/coauthor_repo.rs b/src/coauthor_repo.rs index d71b63d..2c86e49 100644 --- a/src/coauthor_repo.rs +++ b/src/coauthor_repo.rs @@ -181,7 +181,7 @@ mod tests { Ok(CmdOutput { stdout: stdout.clone(), stderr: stderr.clone(), - status_code: status_code.clone(), + status_code, }) }); mock_cmd_runner @@ -632,7 +632,7 @@ mod tests { command_runner .expect_execute() .once() - .withf(|program, args| program == "git" && args == &["config", "--global", "--get-all", "coauthors-mob.entry"]) + .withf(|program, args| program == "git" && args == ["config", "--global", "--get-all", "coauthors-mob.entry"]) .returning(|_, _| { Ok(CmdOutput { stdout: b"Leo Messi \nEmi Martinez \n".into(), @@ -645,7 +645,7 @@ mod tests { .once() .withf(|program, args| { program == "git" - && args == &["config", "--global", "--remove-section", "coauthors-mob"] + && args == ["config", "--global", "--remove-section", "coauthors-mob"] }) .returning(|_, _| { Ok(CmdOutput { @@ -681,7 +681,7 @@ mod tests { command_runner .expect_execute() .once() - .withf( |program, args| program == "git" && args == &["config", "--global", "--get-all", "coauthors-mob.entry"]) + .withf( |program, args| program == "git" && args == ["config", "--global", "--get-all", "coauthors-mob.entry"]) .returning( |_, _| { Ok(CmdOutput { stdout: b"Leo Messi \nEmi Martinez \n".into(), @@ -694,7 +694,7 @@ mod tests { .once() .withf(|program, args| { program == "git" - && args == &["config", "--global", "--remove-section", "coauthors-mob"] + && args == ["config", "--global", "--remove-section", "coauthors-mob"] }) .returning(|_, _| { Ok(CmdOutput { @@ -719,7 +719,7 @@ mod tests { command_runner .expect_execute() .once() - .withf( |program, args| program == "git" && args == &["config", "--global", "--get-all", "coauthors-mob.entry"]) + .withf( |program, args| program == "git" && args == ["config", "--global", "--get-all", "coauthors-mob.entry"]) .returning( |_, _| { Ok(CmdOutput { stdout: b"Leo Messi \nEmi Martinez \n".into(), @@ -732,7 +732,7 @@ mod tests { .once() .withf(|program, args| { program == "git" - && args == &["config", "--global", "--remove-section", "coauthors-mob"] + && args == ["config", "--global", "--remove-section", "coauthors-mob"] }) .returning(|_, _| { Ok(CmdOutput { diff --git a/src/commands/coauthor.rs b/src/commands/coauthor.rs index 39acfcb..a629bb3 100644 --- a/src/commands/coauthor.rs +++ b/src/commands/coauthor.rs @@ -7,17 +7,17 @@ use std::io::Write; pub(crate) struct Coauthor { /// Adds co-author to co-author repository /// - /// Usage example: git mob co-author --add lm "Leo Messi" leo.messi@example.com + /// Usage example: git mob coauthor --add lm "Leo Messi" leo.messi@example.com #[arg(short = 'a', long = "add", num_args=3, value_names=["COAUTHOR_KEY", "COAUTHOR_NAME", "COAUTHOR_EMAIL"])] pub(crate) add: Option>, /// Remove co-author from co-author repository /// - /// Usage example: git mob co-author --delete lm + /// Usage example: git mob coauthor --delete lm #[arg(short = 'd', long = "delete", value_name = "COAUTHOR_KEY")] pub(crate) delete: Option, /// Lists co-author(s) with keys(s) from co-author repository /// - /// Usage example: git mob co-author --list + /// Usage example: git mob coauthor --list #[arg(short = 'l', long = "list")] pub(crate) list: bool, } diff --git a/src/commands/mob.rs b/src/commands/mob.rs index 03b5a91..0e43928 100644 --- a/src/commands/mob.rs +++ b/src/commands/mob.rs @@ -257,8 +257,9 @@ mod tests { let mut out = Vec::new(); let result = mob_cmd.handle(&mock_coauthor_repo, &mut out); - assert!(result.is_err_and(|err| err.to_string() - == "No co-author(s) found. At least one co-author must be added".to_string())); + assert!(result + .is_err_and(|err| err.to_string() + == *"No co-author(s) found. At least one co-author must be added")); Ok(()) } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c81f301..f668653 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod coauthor; pub(crate) mod mob; +pub(crate) mod setup; diff --git a/src/commands/prepare-commit-msg b/src/commands/prepare-commit-msg new file mode 100755 index 0000000..f131deb --- /dev/null +++ b/src/commands/prepare-commit-msg @@ -0,0 +1,42 @@ +#!/bin/sh + +set -e + +main() { + # Do nothing during rebase + [ -z "$(git branch --show-current)" ] && exit 0 + + # Do nothing during amend (without message) or reuse of a commit message + [ "$2" = 'commit' ] && exit 0 + + #add_jira_issue_id_prefix "$@" + add_co_authored_by_trailers "$@" +} + +add_co_authored_by_trailers() { + # Uses https://github.com/Mubashwer/git-mob + trailers=$(git mob --list | sed "s/^/Co-authored-by: /") + [ -n "$trailers" ] || return 0 + + printf "%s\n" "$trailers" | + sed "s/^/--trailer\n/" | + tr '\n' '\0' | + xargs -0 git interpret-trailers --if-exists addIfDifferent --in-place "$1" + + printf "%s\n\n" "$trailers" +} + +add_jira_issue_id_prefix() { + # If the branch name starts with string resembling a Jira Issue ID, fetch it + jira_issue_id=$(git branch --show-current | grep -o -E "^[a-zA-Z]+-[0-9]+" | tr '[:lower:]' '[:upper:]') + commit_msg_file_text=$(cat "$1") + commit_msg=$(echo "$commit_msg_file_text" | grep -v "^[[:space:]]*#" || true) + + # If the Jira Issue ID is identified and the commit message does not already start with it + # then prepend the commit message with it + if [ -n "$jira_issue_id" ] && echo "$commit_msg" | grep -q -i -v "^\[\?$jira_issue_id\]\?"; then + printf "[%s] %s\n" "$jira_issue_id" "$commit_msg_file_text" > "$1" + fi +} + +main "$@" diff --git a/src/commands/prepare-commit-msg.local b/src/commands/prepare-commit-msg.local new file mode 100755 index 0000000..88a1e79 --- /dev/null +++ b/src/commands/prepare-commit-msg.local @@ -0,0 +1,21 @@ +#!/bin/sh + +set -e + +# Get the global hooks directory +hooks_dir=$(git config --global core.hooksPath) + +# Check if the global hooks directory is set +if [ -z "$hooks_dir" ]; then + printf "Error: Global hooks directory is not set" >&2 + exit 1 +fi + +# Invoke the prepare-commit-msg hook from the global hooks directory +hook_path="$hooks_dir/prepare-commit-msg" +if [ -x "$hook_path" ]; then + exec "$hook_path" "$@" +else + printf "Error: prepare-commit-msg hook not found in the global hooks directory" >&2 + exit 1 +fi \ No newline at end of file diff --git a/src/commands/setup.rs b/src/commands/setup.rs new file mode 100644 index 0000000..e432e50 --- /dev/null +++ b/src/commands/setup.rs @@ -0,0 +1,163 @@ +use std::{ + error::Error, + fs, + io::Write, + path::{Path, PathBuf}, + process::Command, +}; + +use clap::Parser; +use home::home_dir; + +#[derive(Parser)] +#[command(arg_required_else_help = true)] +pub(crate) struct Setup { + /// Set up global prepare-commit-msg githook + /// + /// Usage example: git mob setup --global + #[arg(short = 'g', long = "global")] + pub(crate) global: bool, + /// Set up local prepare-commit-msg githook which invokes the global one + /// + /// Only need to be run for repo which overrides local hooks directory + /// + /// Usage example: git mob setup --local + #[arg(long = "local")] + pub(crate) local: bool, +} + +impl Setup { + pub(crate) fn handle(&self, out: &mut impl Write) -> Result<(), Box> { + if self.global { + self.handle_global(out)?; + } + + if self.local { + self.handle_local(out)?; + } + + Ok(()) + } + + fn handle_global(&self, out: &mut impl Write) -> Result<(), Box> { + let hooks_dir = match Self::get_hooks_dir("--global")? { + Some(hooks_dir) => hooks_dir, + None => { + let new_hooks_dir = home_dir() + .ok_or("Failed to get home directory")? + .join(".git") + .join("hooks"); + + Self::set_hooks_dir(out, &new_hooks_dir, "--global")?; + + new_hooks_dir + } + }; + + let prepare_commit_msg_path = hooks_dir.join("prepare-commit-msg"); + + if !hooks_dir.exists() { + fs::create_dir_all(&hooks_dir)?; + } else if prepare_commit_msg_path.exists() { + Self::backup_prepare_commit_msg_hook(out, &prepare_commit_msg_path)?; + } + + Self::create_prepare_commit_msg_hook( + out, + &prepare_commit_msg_path, + include_str!("prepare-commit-msg"), + )?; + + writeln!(out, "Setup complete")?; + Ok(()) + } + + fn handle_local(&self, out: &mut impl Write) -> Result<(), Box> { + let hooks_dir = match Self::get_hooks_dir("--local")? { + Some(hooks_dir) => hooks_dir, + None => return Err("Local githooks directory is not set".into()), + }; + + let prepare_commit_msg_path = hooks_dir.join("prepare-commit-msg"); + + if !hooks_dir.exists() { + fs::create_dir_all(&hooks_dir)?; + } else if prepare_commit_msg_path.exists() { + Self::backup_prepare_commit_msg_hook(out, &prepare_commit_msg_path)?; + } + + Self::create_prepare_commit_msg_hook( + out, + &prepare_commit_msg_path, + include_str!("prepare-commit-msg.local"), + )?; + + writeln!(out, "Setup complete")?; + Ok(()) + } + + fn get_hooks_dir(scope: &str) -> Result, Box> { + let output = Command::new("git") + .args(["config", scope, "core.hooksPath"]) + .output()?; + + match output.status.success() { + true => Ok(Some( + Path::new(String::from_utf8(output.stdout)?.trim()).to_path_buf(), + )), + false => Ok(None), + } + } + + fn set_hooks_dir(out: &mut impl Write, path: &Path, scope: &str) -> Result<(), Box> { + let path_str = &path.to_string_lossy(); + let status = Command::new("git") + .args(["config", scope, "core.hooksPath", path_str]) + .status()?; + + assert!(status.success()); + + writeln!(out, "Set global githooks directory: {}", path_str)?; + + Ok(()) + } + + fn create_prepare_commit_msg_hook( + out: &mut impl Write, + path: &Path, + contents: &str, + ) -> Result<(), Box> { + fs::write(path, contents)?; + + #[cfg(unix)] + { + use std::fs::Permissions; + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, Permissions::from_mode(0o755))?; // Sets rwxr-xr-x permissions + } + + writeln!( + out, + "Created new prepare-commit-msg githook: {}", + &path.to_string_lossy() + )?; + + Ok(()) + } + + fn backup_prepare_commit_msg_hook( + out: &mut impl Write, + path: &Path, + ) -> Result<(), Box> { + let backup_path = path.with_extension("bak"); + fs::rename(path, &backup_path)?; + + writeln!( + out, + "Backed up existing prepare-commit-msg githook: {}", + &backup_path.to_string_lossy() + )?; + + Ok(()) + } +} diff --git a/tests/help.rs b/tests/help.rs index 989a7f3..d208afc 100644 --- a/tests/help.rs +++ b/tests/help.rs @@ -20,13 +20,16 @@ A user can attribute a git commit to more than one author by adding one or more Usage example: -git mob co-author --add lm "Leo Messi" leo.messi@example.com +git mob setup --global -git pair with +git mob coauthor --add lm "Leo Messi" leo.messi@example.com + +git mob --with lm Usage: git mob [COMMAND] [OPTIONS] Commands: + setup Create prepare-commit-msg githook which append Co-authored-by trailers to commit message coauthor Add/delete/list co-author(s) from co-author repository help Print this message or the help of the given subcommand(s) @@ -75,6 +78,7 @@ r#"A CLI app which can help users automatically add co-author(s) to git commits Usage: git mob [COMMAND] [OPTIONS] Commands: + setup Create prepare-commit-msg githook which append Co-authored-by trailers to commit message coauthor Add/delete/list co-author(s) from co-author repository help Print this message or the help of the given subcommand(s) @@ -109,17 +113,17 @@ Options: -a, --add Adds co-author to co-author repository - Usage example: git mob co-author --add lm "Leo Messi" leo.messi@example.com + Usage example: git mob coauthor --add lm "Leo Messi" leo.messi@example.com -d, --delete Remove co-author from co-author repository - Usage example: git mob co-author --delete lm + Usage example: git mob coauthor --delete lm -l, --list Lists co-author(s) with keys(s) from co-author repository - Usage example: git mob co-author --list + Usage example: git mob coauthor --list -h, --help Print help (see a summary with '-h') @@ -160,3 +164,62 @@ Options: Ok(()) } + +#[test_context(TestContextCli, skip_teardown)] +#[test] +fn test_setup_help(ctx: TestContextCli) -> Result<(), Box> { + ctx.git() + .args(["mob", "help", "setup"]) + .assert() + .success() + .stdout(predicate::str::diff( +r#"Create prepare-commit-msg githook which append Co-authored-by trailers to commit message + +Usage: git mob setup [OPTIONS] + +Options: + -g, --global + Set up global prepare-commit-msg githook + + Usage example: git mob setup --global + + --local + Set up local prepare-commit-msg githook which invokes the global one + + Only need to be run for repo which overrides local hooks directory + + Usage example: git mob setup --local + + -h, --help + Print help (see a summary with '-h') + + -V, --version + Print version +"# + )); + + Ok(()) +} + +#[test_context(TestContextCli, skip_teardown)] +#[test] +fn test_setup_help_summary(ctx: TestContextCli) -> Result<(), Box> { + ctx.git() + .args(["mob", "setup", "-h"]) + .assert() + .success() + .stdout(predicate::str::diff( + r#"Create prepare-commit-msg githook which append Co-authored-by trailers to commit message + +Usage: git mob setup [OPTIONS] + +Options: + -g, --global Set up global prepare-commit-msg githook + --local Set up local prepare-commit-msg githook which invokes the global one + -h, --help Print help (see more with '--help') + -V, --version Print version +"#, + )); + + Ok(()) +} diff --git a/tests/helpers/test_contexts.rs b/tests/helpers/test_contexts.rs index 43b1dcc..49c6b5f 100644 --- a/tests/helpers/test_contexts.rs +++ b/tests/helpers/test_contexts.rs @@ -15,8 +15,7 @@ static PATH_ENV_VAR: Lazy = Lazy::new(|| { let mut split_paths: Vec = env::split_paths(path).collect(); split_paths.push(PathBuf::from(exe_dir)); - let new_path = env::join_paths(split_paths).unwrap(); - new_path + env::join_paths(split_paths).unwrap() }); pub(crate) struct TestContextCli { @@ -44,6 +43,7 @@ impl TestContext for TestContextCli { pub(crate) struct TestContextRepo { git_config_global: TempPath, dir: TempDir, + pub home_dir: TempDir, } impl TestContextRepo { @@ -52,6 +52,16 @@ impl TestContextRepo { command .current_dir(self.dir.path()) .env("GIT_CONFIG_GLOBAL", &self.git_config_global); + + #[cfg(unix)] + { + command.env("HOME", self.home_dir.path()); + } + #[cfg(windows)] + { + command.env("USERPROFILE", &self.home_dir.path()); + } + command } } @@ -63,6 +73,7 @@ impl TestContext for TestContextRepo { let ctx = TestContextRepo { git_config_global: NamedTempFile::new().unwrap().into_temp_path(), dir: tempdir().unwrap(), + home_dir: tempdir().unwrap(), }; ctx.git().arg("init").assert().success(); diff --git a/tests/prepare_commit_msg.rs b/tests/prepare_commit_msg.rs index 1c30720..206258e 100644 --- a/tests/prepare_commit_msg.rs +++ b/tests/prepare_commit_msg.rs @@ -3,14 +3,99 @@ mod helpers; use assert_cmd::prelude::*; use helpers::test_contexts::TestContextRepo; use predicates::prelude::*; -use std::{env, error::Error}; +use std::error::Error; +use tempfile::TempDir; use test_context::test_context; #[test_context(TestContextRepo, skip_teardown)] #[test] fn test_prepare_commit_msg(ctx: TestContextRepo) -> Result<(), Box> { ctx.git() - .args(["config", "core.hooksPath", env!("CARGO_MANIFEST_DIR")]) + .args(["mob", "setup", "--global"]) + .assert() + .success(); + + // adding 2 co-authors + ctx.git() + .args([ + "mob", + "coauthor", + "--add", + "lm", + "Leo Messi", + "leo.messi@example.com", + ]) + .assert() + .success(); + + ctx.git() + .args([ + "mob", + "coauthor", + "--add", + "em", + "Emi Martinez", + "emi.martinez@example.com", + ]) + .assert() + .success(); + + // mobbing with both of the co-authors + ctx.git() + .args(["mob", "--with", "lm", "em"]) + .assert() + .success() + .stdout(predicate::str::diff( + "Leo Messi \nEmi Martinez \n", + )); + + // git commit prints Co-authored-by trailers and an empty line to stderr + ctx.git() + .args(["commit", "--allow-empty", "--message", "test: hello world!"]) + .assert() + .success() + .stderr(predicate::str::diff( + "Co-authored-by: Leo Messi \nCo-authored-by: Emi Martinez \n\n", + )); + + // the commit body has message with Co-authored-by trailers + ctx.git() + .args(["show", "--no-patch", "--format=%B"]) + .assert() + .success() + .stdout(predicate::str::diff( + "test: hello world!\n\nCo-authored-by: Leo Messi \nCo-authored-by: Emi Martinez \n\n" + )); + + Ok(()) +} + +#[test_context(TestContextRepo, skip_teardown)] +#[test] +fn test_prepare_commit_msg_given_local_hooks_directory( + ctx: TestContextRepo, +) -> Result<(), Box> { + let hooks_dir = TempDir::new()?; + + // setting local hooks directory + ctx.git() + .args([ + "config", + "--local", + "core.hooksPath", + &hooks_dir.path().to_string_lossy(), + ]) + .assert() + .success(); + + ctx.git() + .args(["mob", "setup", "--global"]) + .assert() + .success(); + + // setup git mob locally - adds a local prepare-commit-msg hook which invokes the global one + ctx.git() + .args(["mob", "setup", "--local"]) .assert() .success(); diff --git a/tests/setup.rs b/tests/setup.rs new file mode 100644 index 0000000..213706f --- /dev/null +++ b/tests/setup.rs @@ -0,0 +1,349 @@ +mod helpers; + +use assert_cmd::prelude::*; +use helpers::test_contexts::TestContextRepo; +use predicates::prelude::*; +use std::{error::Error, fs, path::Path}; +use tempfile::TempDir; +use test_context::test_context; + +#[test_context(TestContextRepo, skip_teardown)] +#[test] +fn test_setup_global_given_hooks_dir_not_set(ctx: TestContextRepo) -> Result<(), Box> { + let hooks_dir = ctx.home_dir.path().join(".git").join("hooks"); + + ctx.git() + .args(["mob", "setup", "--global"]) + .assert() + .success() + .stdout(predicate::str::diff(format!( + r#"Set global githooks directory: {} +Created new prepare-commit-msg githook: {} +Setup complete +"#, + hooks_dir.to_string_lossy(), + hooks_dir.join("prepare-commit-msg").to_string_lossy() + ))); + + verify_prepare_commit_msg_global_hook(&ctx, &hooks_dir) +} + +#[test_context(TestContextRepo, skip_teardown)] +#[test] +fn test_setup_global_given_hooks_dir_set_and_exists( + ctx: TestContextRepo, +) -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let hooks_dir = temp_dir.path().to_path_buf(); + + // setting global hooks directory + ctx.git() + .args([ + "config", + "--global", + "core.hooksPath", + &hooks_dir.to_string_lossy(), + ]) + .assert() + .success(); + + ctx.git() + .args(["mob", "setup", "--global"]) + .assert() + .success() + .stdout(predicate::str::diff(format!( + r#"Created new prepare-commit-msg githook: {} +Setup complete +"#, + hooks_dir.join("prepare-commit-msg").to_string_lossy() + ))); + + verify_prepare_commit_msg_global_hook(&ctx, &hooks_dir) +} + +#[test_context(TestContextRepo, skip_teardown)] +#[test] +fn test_setup_global_given_hooks_dir_set_but_does_not_exist( + ctx: TestContextRepo, +) -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let hooks_dir = temp_dir.path().to_path_buf(); + + // setting global hooks directory + ctx.git() + .args([ + "config", + "--global", + "core.hooksPath", + &hooks_dir.to_string_lossy(), + ]) + .assert() + .success(); + + // removing the global hooks directory + temp_dir.close()?; + assert!(!hooks_dir.exists()); + + ctx.git() + .args(["mob", "setup", "--global"]) + .assert() + .success() + .stdout(predicate::str::diff(format!( + r#"Created new prepare-commit-msg githook: {} +Setup complete +"#, + hooks_dir.join("prepare-commit-msg").to_string_lossy() + ))); + + verify_prepare_commit_msg_global_hook(&ctx, &hooks_dir) +} + +#[test_context(TestContextRepo, skip_teardown)] +#[test] +fn test_setup_global_given_prepare_commit_msg_hook_already_exists( + ctx: TestContextRepo, +) -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let hooks_dir = temp_dir.path().to_path_buf(); + + let hook_path = hooks_dir.join("prepare-commit-msg"); + let backup_path = hooks_dir.join("prepare-commit-msg.bak"); + + let existing_hook_contents = "#Lorem ipsum"; + fs::write(&hook_path, existing_hook_contents.as_bytes())?; + + // setting global hooks directory + ctx.git() + .args([ + "config", + "--global", + "core.hooksPath", + &hooks_dir.to_string_lossy(), + ]) + .assert() + .success(); + + ctx.git() + .args(["mob", "setup", "--global"]) + .assert() + .success() + .stdout(predicate::str::diff(format!( + r#"Backed up existing prepare-commit-msg githook: {} +Created new prepare-commit-msg githook: {} +Setup complete +"#, + backup_path.to_string_lossy(), + hook_path.to_string_lossy() + ))); + + // verifying existing prepare-commit-msg is backed up as prepare-commit-msg.bak + assert!(backup_path.exists()); + assert!(fs::metadata(&backup_path)?.is_file()); + + let backup_contents = fs::read_to_string(&backup_path)?; + assert_eq!(backup_contents, existing_hook_contents); + + verify_prepare_commit_msg_global_hook(&ctx, &hooks_dir) +} + +#[test_context(TestContextRepo, skip_teardown)] +#[test] +fn test_setup_local_given_hooks_dir_not_set(ctx: TestContextRepo) -> Result<(), Box> { + ctx.git() + .args(["mob", "setup", "--local"]) + .assert() + .failure() + .stderr("Error: \"Local githooks directory is not set\"\n"); + + Ok(()) +} + +#[test_context(TestContextRepo, skip_teardown)] +#[test] +fn test_setup_local_given_hooks_dir_set_and_exists( + ctx: TestContextRepo, +) -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let hooks_dir = temp_dir.path().to_path_buf(); + + // setting local hooks directory + ctx.git() + .args([ + "config", + "--local", + "core.hooksPath", + &hooks_dir.to_string_lossy(), + ]) + .assert() + .success(); + + ctx.git() + .args(["mob", "setup", "--local"]) + .assert() + .success() + .stdout(predicate::str::diff(format!( + r#"Created new prepare-commit-msg githook: {} +Setup complete +"#, + hooks_dir.join("prepare-commit-msg").to_string_lossy() + ))); + + verify_prepare_commit_msg_local_hook(&ctx, &hooks_dir) +} + +#[test_context(TestContextRepo, skip_teardown)] +#[test] +fn test_setup_local_given_hooks_dir_set_but_does_not_exist( + ctx: TestContextRepo, +) -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let hooks_dir = temp_dir.path().to_path_buf(); + + // setting global hooks directory + ctx.git() + .args([ + "config", + "--local", + "core.hooksPath", + &hooks_dir.to_string_lossy(), + ]) + .assert() + .success(); + + // removing the local hooks directory + temp_dir.close()?; + assert!(!hooks_dir.exists()); + + ctx.git() + .args(["mob", "setup", "--local"]) + .assert() + .success() + .stdout(predicate::str::diff(format!( + r#"Created new prepare-commit-msg githook: {} +Setup complete +"#, + hooks_dir.join("prepare-commit-msg").to_string_lossy() + ))); + + verify_prepare_commit_msg_local_hook(&ctx, &hooks_dir) +} + +#[test_context(TestContextRepo, skip_teardown)] +#[test] +fn test_setup_local_given_prepare_commit_msg_hook_already_exists( + ctx: TestContextRepo, +) -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let hooks_dir = temp_dir.path().to_path_buf(); + + let hook_path = hooks_dir.join("prepare-commit-msg"); + let backup_path = hooks_dir.join("prepare-commit-msg.bak"); + + let existing_hook_contents = "#Lorem ipsum"; + fs::write(&hook_path, existing_hook_contents.as_bytes())?; + + // setting local hooks directory + ctx.git() + .args([ + "config", + "--local", + "core.hooksPath", + &hooks_dir.to_string_lossy(), + ]) + .assert() + .success(); + + ctx.git() + .args(["mob", "setup", "--local"]) + .assert() + .success() + .stdout(predicate::str::diff(format!( + r#"Backed up existing prepare-commit-msg githook: {} +Created new prepare-commit-msg githook: {} +Setup complete +"#, + backup_path.to_string_lossy(), + hook_path.to_string_lossy() + ))); + + // verifying existing prepare-commit-msg is backed up as prepare-commit-msg.bak + assert!(backup_path.exists()); + assert!(fs::metadata(&backup_path)?.is_file()); + + let backup_contents = fs::read_to_string(&backup_path)?; + assert_eq!(backup_contents, existing_hook_contents); + + verify_prepare_commit_msg_local_hook(&ctx, &hooks_dir) +} + +fn verify_prepare_commit_msg_global_hook( + ctx: &TestContextRepo, + hooks_dir: &Path, +) -> Result<(), Box> { + // verifying global hooks directory + ctx.git() + .args(["config", "--global", "core.hooksPath"]) + .assert() + .success() + .stdout(predicate::str::diff(format!( + "{}\n", + hooks_dir.to_string_lossy() + ))); + + // verifying prepare-commit-msg githook exists + let hook_path = hooks_dir.join("prepare-commit-msg"); + + assert!(hook_path.exists()); + + let metadata = fs::metadata(&hook_path)?; + assert!(metadata.is_file()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(metadata.permissions().mode() & 0o777, 0o755); + } + + // verifying prepare-commit-msg githook contents + assert_eq!( + fs::read_to_string(&hook_path)?, + include_str!("../src/commands/prepare-commit-msg") + ); + + Ok(()) +} + +fn verify_prepare_commit_msg_local_hook( + ctx: &TestContextRepo, + hooks_dir: &Path, +) -> Result<(), Box> { + // verifying local hooks directory + ctx.git() + .args(["config", "--local", "core.hooksPath"]) + .assert() + .success() + .stdout(predicate::str::diff(format!( + "{}\n", + hooks_dir.to_string_lossy() + ))); + + // verifying prepare-commit-msg githook exists + let hook_path = hooks_dir.join("prepare-commit-msg"); + + assert!(hook_path.exists()); + + let metadata = fs::metadata(&hook_path)?; + assert!(metadata.is_file()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(metadata.permissions().mode() & 0o777, 0o755); + } + + // verifying prepare-commit-msg githook contents + assert_eq!( + fs::read_to_string(&hook_path)?, + include_str!("../src/commands/prepare-commit-msg.local") + ); + + Ok(()) +}