From 5fee7a39220451a77d32d57eecda3494002b7c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KARASZI=20Istv=C3=A1n?= Date: Fri, 13 Nov 2026 18:23:52 +0100 Subject: [PATCH 1/2] Run the tests against the binary Each scenario uses its own folder and git config, so the scenarios can run in parallel. --- Cargo.lock | 39 +---------- Cargo.toml | 9 +-- src/cli.rs | 9 --- src/main.rs | 7 -- tests/example.rs | 117 ------------------------------- tests/features.rs | 134 ++++++++++++++++++++++++++++++++++++ tests/features/help.feature | 2 +- 7 files changed, 137 insertions(+), 180 deletions(-) delete mode 100644 tests/example.rs create mode 100644 tests/features.rs diff --git a/Cargo.lock b/Cargo.lock index 80aa13c..3345d1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aho-corasick" @@ -418,17 +418,6 @@ dependencies = [ "slab", ] -[[package]] -name = "getrandom" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "gherkin" version = "0.13.0" @@ -457,9 +446,7 @@ dependencies = [ "git2", "glob", "shlex", - "stdio-override", "tempfile", - "uuid", ] [[package]] @@ -1003,15 +990,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043" -[[package]] -name = "stdio-override" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9c22512d2e95aa9a717da4c955c0085078a1f28b988e0736fc7066fdcc030c8" -dependencies = [ - "libc", -] - [[package]] name = "strsim" version = "0.10.0" @@ -1219,15 +1197,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" -[[package]] -name = "uuid" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d" -dependencies = [ - "getrandom", -] - [[package]] name = "vcpkg" version = "0.2.15" @@ -1244,12 +1213,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 4153286..32cdde1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,21 +11,14 @@ clap = "4.0.18" git2 = "0.15" glob = "0.3.0" -[dev-dependencies.uuid] -version = "1.3.0" -features = [ - "v4", -] - [dev-dependencies] cucumber = "0.19" futures = "0.3" -stdio-override = "0.1.3" tempfile = "3.5.0" shlex = "1.1.0" # fake = "2.5.0" # rand = "0.8.5" [[test]] -name = "example" +name = "features" harness = false diff --git a/src/cli.rs b/src/cli.rs index a917b97..ce8515b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -96,15 +96,6 @@ fn matches_to_options(matches: ArgMatches) -> Options { } } -#[cfg(test)] -pub fn get_options_from(cmd: &str) -> Result { - let args = cmd.split(' '); - - let matches = command().try_get_matches_from(args)?; - - Ok(matches_to_options(matches)) -} - pub fn get_options() -> Result { let matches = command().try_get_matches()?; diff --git a/src/main.rs b/src/main.rs index 5919bfc..7f7089a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,13 +30,6 @@ fn execute(options: Options) -> Result<()> { Ok(()) } -#[cfg(test)] -pub fn from(cmd: &str) -> Result<()> { - let options = cli::get_options_from(cmd)?; - - execute(options) -} - fn main() -> Result<()> { let options = cli::get_options()?; diff --git a/tests/example.rs b/tests/example.rs deleted file mode 100644 index adfe2c1..0000000 --- a/tests/example.rs +++ /dev/null @@ -1,117 +0,0 @@ -use cucumber::gherkin::Step; -use cucumber::given; -use cucumber::then; -use cucumber::when; -use cucumber::World; -use shlex::split; -use std::env; -use std::fs; -use std::path::PathBuf; -use std::process::Command; -use stdio_override::StdoutOverride; -use tempfile::NamedTempFile; -use uuid::Uuid; - -// The tests use only some functions of the binary. Do not report the other functions as dead code. -#[allow(dead_code)] -#[path = "../src/main.rs"] -mod main; - -fn capture_stdout(f: F) -> String { - let temp_file = NamedTempFile::new().expect("Could not create temporary file"); - let captured_output = temp_file.into_temp_path().to_path_buf(); - - let guard = StdoutOverride::override_file(captured_output.clone()); - - f(); - - drop(guard); - - fs::read_to_string(captured_output).expect("Could not read captured output") -} - -fn get_doc_string(step: &Step) -> String { - step.docstring - .as_ref() - .expect("Doc string needed!") - .trim() - .to_string() -} - -#[derive(Debug, Default, World)] -pub struct GitWorld { - directory: PathBuf, - command: String, -} - -#[given("an empty directory")] -fn init_directory(world: &mut GitWorld) { - let dir = env::temp_dir(); - let uuid = Uuid::new_v4().to_string(); - - let name = format!("git-changed-{uuid}"); - let directory = dir.join(name); - - fs::create_dir_all(directory.clone()).expect("Could not create directory!"); - - world.directory = directory; -} - -#[when(expr = "the user executes the command: {string}")] -fn execute_command(world: &mut GitWorld, command: String) { - let command_parts = split(command.as_str()).expect("Could not parse command"); - - let (program, args) = command_parts.split_at(1); - - let output = Command::new(program.first().expect("Could not get the command")) - .current_dir(world.directory.clone()) - .args(args) - .output() - .expect("Failed to create the repository"); - - assert!(output.status.success()); -} - -#[when(expr = "the user executes the tool like: {string}")] -fn execute(world: &mut GitWorld, command: String) { - world.command = command; -} - -#[then("it fails with the following error")] -fn assert_failure(world: &mut GitWorld, step: &Step) { - let expected_error = get_doc_string(step); - - let result = main::from(&world.command); - - assert!(result.is_err()); - assert_eq!(result.unwrap_err().to_string().trim(), expected_error); -} - -#[then("it succeeds with the following output")] -fn assert_success(world: &mut GitWorld, step: &Step) { - let expected_output = get_doc_string(step); - - let actual_output = capture_stdout(|| { - env::set_current_dir(&world.directory).expect("Could not change to directory"); - - main::from(&world.command).expect("Could not execute"); - }); - - assert_eq!(expected_output, actual_output.trim()); -} - -fn cleanup(world: &mut GitWorld) { - fs::remove_dir_all(world.directory.clone()).expect("Could not remove directory"); -} - -fn main() { - let world = GitWorld::cucumber() - .after(|_feature, _rule, _scenario, _ev, world| { - Box::pin(async { - cleanup(world.expect("World is missing")); - }) - }) - .run_and_exit("tests/features"); - - futures::executor::block_on(world); -} diff --git a/tests/features.rs b/tests/features.rs new file mode 100644 index 0000000..6999701 --- /dev/null +++ b/tests/features.rs @@ -0,0 +1,134 @@ +use cucumber::gherkin::Step; +use cucumber::given; +use cucumber::then; +use cucumber::when; +use cucumber::World; +use std::fs; +use std::path::Path; +use std::process::Command; +use std::process::Output; +use tempfile::TempDir; + +fn main() { + futures::executor::block_on(GitWorld::cucumber().run_and_exit("tests/features")); +} + +#[derive(Debug, World)] +#[world(init = Self::new)] +pub struct GitWorld { + home: TempDir, + directory: TempDir, + output: Option, +} + +#[given("an empty directory")] +fn empty_directory(_world: &mut GitWorld) { + // GitWorld::new creates the directory before the first step. +} + +#[when(expr = "the user executes the command: {string}")] +fn run_command(world: &mut GitWorld, command: String) { + let (program, args) = split_command(&command); + + let output = world.run(&program, &args); + + assert!( + output.status.success(), + "The command failed: {command}\n{}", + text(&output.stderr) + ); +} + +#[when(expr = "the user executes the tool like: {string}")] +fn run_tool(world: &mut GitWorld, command: String) { + let (program, args) = split_command(&command); + assert_eq!(program, "git-changed", "This step runs only git-changed"); + + let output = world.run(env!("CARGO_BIN_EXE_git-changed"), &args); + + world.output = Some(output); +} + +#[then("it fails with the following error")] +fn assert_failure(world: &mut GitWorld, step: &Step) { + let output = world.tool_output(); + + assert!(!output.status.success(), "The tool did not fail"); + assert_eq!(text(&output.stderr), expected_text(step)); +} + +#[then("it succeeds with the following output")] +fn assert_success(world: &mut GitWorld, step: &Step) { + let output = world.tool_output(); + + assert!( + output.status.success(), + "The tool failed: {}", + text(&output.stderr) + ); + assert_eq!(text(&output.stdout), expected_text(step)); +} + +impl GitWorld { + fn new() -> Self { + let home = TempDir::new().expect("Cannot create the home directory"); + write_global_config(home.path()); + + Self { + home, + directory: TempDir::new().expect("Cannot create the work directory"), + output: None, + } + } + + fn run(&self, program: &str, args: &[String]) -> Output { + command_without_user_config(program, self.home.path(), self.directory.path()) + .args(args) + .output() + .unwrap_or_else(|error| panic!("Cannot run {program}: {error}")) + } + + fn tool_output(&self) -> &Output { + self.output.as_ref().expect("The tool did not run") + } +} + +fn command_without_user_config(program: &str, home: &Path, directory: &Path) -> Command { + let mut command = Command::new(program); + + // git and libgit2 read the global config from HOME and XDG_CONFIG_HOME. + command + .current_dir(directory) + .env("HOME", home) + .env("XDG_CONFIG_HOME", home) + .env("GIT_CONFIG_NOSYSTEM", "1"); + + command +} + +fn write_global_config(home: &Path) { + // libgit2 ignores GIT_CONFIG_NOSYSTEM. This value overrides the one in /etc/gitconfig. + let config = "[init]\n\tdefaultBranch = main\n"; + + fs::write(home.join(".gitconfig"), config).expect("Cannot write the global git config"); +} + +fn split_command(command: &str) -> (String, Vec) { + let mut parts = + shlex::split(command).unwrap_or_else(|| panic!("Cannot parse the command: {command}")); + assert!(!parts.is_empty(), "The command is empty"); + + let program = parts.remove(0); + + (program, parts) +} + +fn expected_text(step: &Step) -> String { + let doc_string = step.docstring.as_ref().expect("The step has no doc string"); + + doc_string.trim().to_string() +} + +fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).trim().to_string() +} diff --git a/tests/features/help.feature b/tests/features/help.feature index 640f26d..56e4fa3 100644 --- a/tests/features/help.feature +++ b/tests/features/help.feature @@ -5,7 +5,7 @@ Feature: Help When the user executes the tool like: "git-changed -h" Then it fails with the following error """ - Lists files modified since branching off + Error: Lists files modified since branching off Usage: git-changed [OPTIONS] [glob] From 51dc14b5edf710480ca3bdb78541d805bdba2c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?KARASZI=20Istv=C3=A1n?= Date: Fri, 13 Nov 2026 18:23:53 +0100 Subject: [PATCH 2/2] Fix the nested rules in the feature file Gherkin does not allow nested rules, so the committed-file scenarios did not commit anything. --- tests/features/non-empty.feature | 173 +++++++++++++++++-------------- 1 file changed, 97 insertions(+), 76 deletions(-) diff --git a/tests/features/non-empty.feature b/tests/features/non-empty.feature index 5e24479..abc978c 100644 --- a/tests/features/non-empty.feature +++ b/tests/features/non-empty.feature @@ -1,6 +1,6 @@ Feature: Non-empty git repository - Background: An empty git repository + Background: A git repository with a new file Given an empty directory When the user executes the command: "git init --initial-branch=main" And the user executes the command: "git config user.name 'John Doe'" @@ -9,78 +9,99 @@ Feature: Non-empty git repository And the user executes the command: "touch README.md" And the user executes the command: "git add README.md" - Rule: With uncommitted changes - - Scenario: It displays the changes - When the user executes the tool like: "git-changed" - Then it succeeds with the following output - """ - README.md - """ - - Scenario: It does not display non-matching changes - When the user executes the tool like: "git-changed *.txt" - Then it succeeds with the following output - """ - """ - - Scenario: It display matching changes - When the user executes the tool like: "git-changed *.md" - Then it succeeds with the following output - """ - README.md - """ - - Rule: With committed changes - - Background: With committed changes - When the user executes the command: "git commit --message 'Adding README'" - - Scenario: It displays nothing if there are no changes - When the user executes the tool like: "git-changed" - Then it succeeds with the following output - """ - """ - - Rule: When the changed files does not exist anymore - - Background: With deleted files - When the user executes the command: "rm README.md" - - Scenario: It displays the removed files - When the user executes the tool like: "git-changed" - Then it succeeds with the following output - """ - README.md - """ - - Scenario: It filters for existing files when requested - When the user executes the tool like: "git-changed -e" - Then it succeeds with the following output - """ - """ - - Rule: With uncommitted changes - - Background: With uncommitted changes - When the user executes the command: "dd if=/dev/random of=README.md count=1" - - Scenario: It displays the uncommitted changes - When the user executes the tool like: "git-changed" - Then it succeeds with the following output - """ - README.md - """ - - Scenario: It does not display non-matching changes - When the user executes the tool like: "git-changed *.txt" - Then it succeeds with the following output - """ - """ - - Scenario: It display matching changes - When the user executes the tool like: "git-changed *.md" - Then it succeeds with the following output - """ - README.md - """ + Rule: With uncommitted changes + + Scenario: It displays the changes + When the user executes the tool like: "git-changed" + Then it succeeds with the following output + """ + README.md + """ + + Scenario: It does not display non-matching changes + When the user executes the tool like: "git-changed *.txt" + Then it succeeds with the following output + """ + """ + + Scenario: It displays matching changes + When the user executes the tool like: "git-changed *.md" + Then it succeeds with the following output + """ + README.md + """ + + Rule: With changes committed on the base branch + + Background: With committed changes + When the user executes the command: "git commit --message 'Adding README'" + + Scenario: It displays nothing if there are no changes + When the user executes the tool like: "git-changed" + Then it succeeds with the following output + """ + """ + + Rule: With changes committed on a feature branch + + Background: With committed changes on a feature branch + When the user executes the command: "git switch --create feature" + And the user executes the command: "git commit --message 'Adding README'" + + Scenario: It displays the committed changes + When the user executes the tool like: "git-changed" + Then it succeeds with the following output + """ + README.md + """ + + Scenario: It does not display non-matching committed changes + When the user executes the tool like: "git-changed *.txt" + Then it succeeds with the following output + """ + """ + + Rule: When the committed files do not exist anymore + + Background: With deleted files + When the user executes the command: "git commit --message 'Adding README'" + And the user executes the command: "rm README.md" + + Scenario: It displays the removed files + When the user executes the tool like: "git-changed" + Then it succeeds with the following output + """ + README.md + """ + + Scenario: It filters for existing files when requested + When the user executes the tool like: "git-changed -e" + Then it succeeds with the following output + """ + """ + + Rule: With uncommitted changes to the committed files + + Background: With uncommitted changes + When the user executes the command: "git commit --message 'Adding README'" + And the user executes the command: "dd if=/dev/random of=README.md count=1" + + Scenario: It displays the uncommitted changes + When the user executes the tool like: "git-changed" + Then it succeeds with the following output + """ + README.md + """ + + Scenario: It does not display non-matching changes + When the user executes the tool like: "git-changed *.txt" + Then it succeeds with the following output + """ + """ + + Scenario: It displays matching changes + When the user executes the tool like: "git-changed *.md" + Then it succeeds with the following output + """ + README.md + """