use std::env::consts::EXE_EXTENSION;
#[cfg(all(feature = "ci", not(target_os = "windows")))]
use assert_fs::fixture::PathChild;
use crate::common::{TestEnv, cmd_snapshot};
fn ruby_context() -> TestEnv {
TestEnv::new()
.with_filter(
r"ruby (\d+\.\d+)\.\d+(?:p\d+)? \(\d{4}-\d{2}-\d{2} revision [0-9a-f]{0,10}\).*?\[.+\]",
"ruby $1.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]",
)
.init_git()
}
#[test]
fn system_ruby() {
let context = ruby_context();
context.write_config(indoc::indoc! {r"
repos:
- repo: local
hooks:
- id: ruby-version
name: ruby-version
language: ruby
entry: ruby --version
language_version: system
pass_filenames: false
always_run: true
- id: ruby-version-unspecified
name: ruby-version-unspecified
language: ruby
entry: ruby --version
pass_filenames: false
always_run: true
"});
context.git().add(".");
cmd_snapshot!(context, context.run().arg("-v"), @r"
success: true
exit_code: 0
----- stdout -----
ruby-version.............................................................Passed
- hook id: ruby-version
- duration: [TIME]
ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]
ruby-version-unspecified.................................................Passed
- hook id: ruby-version-unspecified
- duration: [TIME]
ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]
----- stderr -----
");
}
#[test]
fn language_version_default() {
let context = ruby_context();
context.write_config(indoc::indoc! {r"
repos:
- repo: local
hooks:
- id: ruby-default
name: ruby-default
language: ruby
entry: ruby --version
language_version: default
pass_filenames: false
always_run: true
"});
context.git().add(".");
cmd_snapshot!(context, context.run().arg("-v"), @r"
success: true
exit_code: 0
----- stdout -----
ruby-default.............................................................Passed
- hook id: ruby-default
- duration: [TIME]
ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]
----- stderr -----
");
}
#[test]
fn specific_ruby_available() {
let context = ruby_context();
context.write_config(indoc::indoc! {r"
repos:
- repo: local
hooks:
- id: ruby-version-prefixed
name: ruby-version-prefixed
language: ruby
entry: ruby --version
language_version: ruby3.4
pass_filenames: false
always_run: true
- id: ruby-version
name: ruby-version
language: ruby
entry: ruby --version
language_version: '3.4'
pass_filenames: false
always_run: true
- id: ruby-version-range-min
name: ruby-version-range-min
language: ruby
entry: ruby --version
language_version: '>=3.2'
pass_filenames: false
always_run: true
- id: ruby-version-range-max
name: ruby-version-range-max
language: ruby
entry: ruby --version
language_version: '<4.0'
pass_filenames: false
always_run: true
- id: ruby-version-constrained-range
name: ruby-version-constrained-range
language: ruby
entry: ruby --version
language_version: '>=3.2, <4'
pass_filenames: false
always_run: true
"});
context.git().add(".");
cmd_snapshot!(context, context.run().arg("-v"), @r"
success: true
exit_code: 0
----- stdout -----
ruby-version-prefixed....................................................Passed
- hook id: ruby-version-prefixed
- duration: [TIME]
ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]
ruby-version.............................................................Passed
- hook id: ruby-version
- duration: [TIME]
ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]
ruby-version-range-min...................................................Passed
- hook id: ruby-version-range-min
- duration: [TIME]
ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]
ruby-version-range-max...................................................Passed
- hook id: ruby-version-range-max
- duration: [TIME]
ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]
ruby-version-constrained-range...........................................Passed
- hook id: ruby-version-constrained-range
- duration: [TIME]
ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]
----- stderr -----
");
}
#[test]
fn specific_ruby_unavailable() {
let context = ruby_context();
context.write_config(indoc::indoc! {r"
repos:
- repo: local
hooks:
- id: ruby-version
name: ruby-version
language: ruby
entry: ruby --version
language_version: 3.1.3
pass_filenames: false
always_run: true
"});
context.git().add(".");
#[cfg(target_os = "windows")]
cmd_snapshot!(context, context.run().arg("-v"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Failed to install hook `ruby-version`
caused by: Failed to install Ruby
caused by: No suitable Ruby found for request: 3.1.3
Automatic installation is not supported on this platform.
Please install Ruby manually.
");
#[cfg(not(target_os = "windows"))]
cmd_snapshot!(context, context.run().arg("-v"), @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Failed to install hook `ruby-version`
caused by: Failed to install Ruby
caused by: No suitable Ruby found for request: 3.1.3
No rv-ruby release found matching: 3.1.3
Please install Ruby manually.
");
}
#[test]
fn additional_gem_dependencies() {
let context = TestEnv::new()
.with_file(
"test_script.rb",
indoc::indoc! {r"
require 'rspec'
puts RSpec::Version::STRING
"},
)
.init_git();
context.write_config(indoc::indoc! {r#"
repos:
- repo: local
hooks:
- id: test-gem-require
name: test-gem-require
language: ruby
entry: ruby test_script.rb
language_version: system
additional_dependencies: ["rspec"]
pass_filenames: false
always_run: true
- id: test-gem-require-versioned
name: test-gem-require-versioned
language: ruby
entry: ruby test_script.rb
language_version: system
additional_dependencies: ["rspec:3.12.0"]
pass_filenames: false
always_run: true
- id: test-gem-require-missing
name: test-gem-require-missing
language: ruby
entry: ruby test_script.rb
language_version: system
pass_filenames: false
always_run: true
"#});
context.git().add(".");
let context = context.with_filters([
(
r"(- hook id: test-gem-require\n- duration: .*?\n\n) \d+\.\d+\.\d+",
"$1 X.Y.Z",
),
(
r"([ \t]*(?:from )?)[^:\n]+/ruby/\d+\.\d+\.\d+/rubygems/core_ext/kernel_require\.rb:\d+:in `require'",
"$1<internal:[RUBY_LIB]>:[X]:in 'Kernel#require'",
),
(r":in `([^']+)'", ":in '$1'"),
(r"<internal:[^>]+>:\d+:in", "<internal:[RUBY_LIB]>:[X]:in"),
]);
cmd_snapshot!(context, context.run().arg("-v"), @r"
success: false
exit_code: 1
----- stdout -----
test-gem-require.........................................................Passed
- hook id: test-gem-require
- duration: [TIME]
X.Y.Z
test-gem-require-versioned...............................................Passed
- hook id: test-gem-require-versioned
- duration: [TIME]
3.12.0
test-gem-require-missing.................................................Failed
- hook id: test-gem-require-missing
- duration: [TIME]
- exit code: 1
<internal:[RUBY_LIB]>:[X]:in 'Kernel#require': cannot load such file -- rspec (LoadError)
from <internal:[RUBY_LIB]>:[X]:in 'Kernel#require'
from test_script.rb:1:in '<main>'
----- stderr -----
");
}
#[test]
fn gemspec_workflow() -> anyhow::Result<()> {
let context = TestEnv::new()
.with_file(
"test_gem.gemspec",
indoc::indoc! {r#"
Gem::Specification.new do |spec|
spec.name = "test_gem"
spec.version = "0.1.0"
spec.authors = ["Test"]
spec.email = ["test@example.com"]
spec.summary = "Test gem"
spec.files = ["lib/test_gem.rb"]
spec.require_paths = ["lib"]
end
"#},
)
.with_file(
"lib/test_gem.rb",
indoc::indoc! {r#"
module TestGem
def self.hello
"Hello from TestGem"
end
end
"#},
)
.with_file(
"test_script.rb",
indoc::indoc! {r"
require 'test_gem'
puts TestGem.hello
"},
)
.init_git();
context.write_config(indoc::indoc! {r"
repos:
- repo: local
hooks:
- id: test-gemspec
name: test-gemspec
language: ruby
entry: ruby -I lib test_script.rb
language_version: system
pass_filenames: false
always_run: true
"});
context.git().add(".");
cmd_snapshot!(context, context.run().arg("-v"), @r"
success: true
exit_code: 0
----- stdout -----
test-gemspec.............................................................Passed
- hook id: test-gemspec
- duration: [TIME]
Hello from TestGem
----- stderr -----
");
let gem_bin_ruby = fs_err::read_dir(context.home_dir().join("hooks"))?
.filter_map(Result::ok)
.find(|e| e.file_name().to_string_lossy().starts_with("ruby-"))
.map(|e| e.path().join("gems/bin/ruby").with_extension(EXE_EXTENSION))
.expect("No ruby hook directory found");
assert!(gem_bin_ruby.exists());
#[cfg(unix)]
assert!(
fs_err::symlink_metadata(gem_bin_ruby)?
.file_type()
.is_symlink()
);
Ok(())
}
#[test]
fn environment_isolation() -> anyhow::Result<()> {
let context = TestEnv::new()
.with_config(indoc::indoc! {r#"
repos:
- repo: local
hooks:
- id: hook1
name: hook1
language: ruby
entry: ruby -e "puts 'hook1=' + ENV['GEM_HOME']"
language_version: system
pass_filenames: false
always_run: true
verbose: true
- id: hook2
name: hook2
language: ruby
entry: ruby -e "puts 'hook2=' + ENV['GEM_HOME']"
language_version: system
pass_filenames: false
always_run: true
verbose: true
- id: hook3
name: hook3
language: ruby
entry: ruby -e "puts 'hook3=' + ENV['GEM_HOME']"
language_version: system
additional_dependencies: ["rspec"]
pass_filenames: false
always_run: true
verbose: true
- id: hook4
name: hook4
language: ruby
entry: ruby -e "puts 'hook4=' + ENV['GEM_HOME']"
language_version: system
additional_dependencies: ["webrick"]
pass_filenames: false
always_run: true
verbose: true
- id: hook5
name: hook5
language: ruby
entry: ruby -e "puts 'hook5=' + ENV['GEM_HOME']"
language_version: system
additional_dependencies: ["rspec"]
pass_filenames: false
always_run: true
verbose: true
"#})
.init_git();
let output = context.run().output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"Command failed\nEXIT CODE: {:?}\nSTDOUT:\n{}\nSTDERR:\n{}",
output.status.code(),
stdout,
stderr
);
let extract_gem_home = |hook_id: &str| -> String {
let prefix = format!("{hook_id}=");
stdout
.lines()
.find_map(|line| line.trim().strip_prefix(&prefix))
.unwrap_or_else(|| panic!("Failed to extract GEM_HOME for {hook_id}"))
.to_string()
};
let hook1_gem_home = extract_gem_home("hook1");
let hook2_gem_home = extract_gem_home("hook2");
let hook3_gem_home = extract_gem_home("hook3");
let hook4_gem_home = extract_gem_home("hook4");
let hook5_gem_home = extract_gem_home("hook5");
assert_eq!(
hook1_gem_home, hook2_gem_home,
"hook1 and hook2 should share the same environment (both have no additional_dependencies)"
);
assert_eq!(
hook3_gem_home, hook5_gem_home,
"hook3 and hook5 should share the same environment (both have the same additional_dependencies)"
);
assert_ne!(
hook1_gem_home, hook3_gem_home,
"hook1 and hook3 should have different environments (hook3 has rspec)"
);
assert_ne!(
hook1_gem_home, hook4_gem_home,
"hook1 and hook4 should have different environments (hook4 has webrick)"
);
assert_ne!(
hook3_gem_home, hook4_gem_home,
"hook3 and hook4 should have different environments (different gems)"
);
let output = context.run().output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"Command failed\nEXIT CODE: {:?}\nSTDOUT:\n{}\nSTDERR:\n{}",
output.status.code(),
stdout,
stderr
);
let hook1_gem_home_v2 = extract_gem_home("hook1");
let hook2_gem_home_v2 = extract_gem_home("hook2");
let hook3_gem_home_v2 = extract_gem_home("hook3");
let hook4_gem_home_v2 = extract_gem_home("hook4");
let hook5_gem_home_v2 = extract_gem_home("hook5");
assert_eq!(
hook1_gem_home, hook1_gem_home_v2,
"hook1 should reuse the same environment on a second run"
);
assert_eq!(
hook2_gem_home, hook2_gem_home_v2,
"hook2 should reuse the same environment on a second run"
);
assert_eq!(
hook3_gem_home, hook3_gem_home_v2,
"hook3 should reuse the same environment on a second run"
);
assert_eq!(
hook4_gem_home, hook4_gem_home_v2,
"hook4 should reuse the same environment on a second run"
);
assert_eq!(
hook5_gem_home, hook5_gem_home_v2,
"hook5 should reuse the same environment on a second run"
);
Ok(())
}
#[test]
fn local_hook_with_gemspec() {
let context = TestEnv::new().init_git();
let hook_repo = context
.create_hook_repo(
"ruby-hook",
indoc::indoc! {r"
- id: my-hook
name: My Hook
entry: my-hook
language: ruby
pass_filenames: false
"},
)
.with_file(
"my_hook.gemspec",
indoc::indoc! {r#"
Gem::Specification.new do |spec|
spec.name = "my_hook"
spec.version = "0.1.0"
spec.authors = ["Test"]
spec.email = ["test@example.com"]
spec.summary = "Test hook gem"
spec.files = ["bin/my-hook"]
spec.executables = ["my-hook"]
spec.bindir = "bin"
end
"#},
)
.with_file(
"bin/my-hook",
indoc::indoc! {r#"
#!/usr/bin/env ruby
puts "Hook executed from gem!"
"#},
)
.build();
context.write_config(indoc::formatdoc! {r"
repos:
- repo: {}
rev: v1.0.0
hooks:
- id: my-hook
name: my-hook
entry: my-hook
language: ruby
pass_filenames: false
always_run: true
",
hook_repo
});
context.git().add(".pre-commit-config.yaml");
cmd_snapshot!(context, context.run().arg("-v"), @r"
success: true
exit_code: 0
----- stdout -----
my-hook..................................................................Passed
- hook id: my-hook
- duration: [TIME]
Hook executed from gem!
----- stderr -----
");
}
#[test]
fn native_gem_dependency() {
let context = TestEnv::new()
.with_file(
"check_msgpack.rb",
indoc::indoc! {r#"
#!/usr/bin/env ruby
require 'msgpack'
# Test that the native extension works
data = { "hello" => "world", "number" => 42 }
packed = MessagePack.pack(data)
unpacked = MessagePack.unpack(packed)
puts "MessagePack native extension working!"
puts "Packed size: #{packed.bytesize} bytes"
"#},
)
.init_git();
context.write_config(indoc::indoc! {r"
repos:
- repo: local
hooks:
- id: test-native-gem
name: test-native-gem
language: ruby
entry: ruby check_msgpack.rb
additional_dependencies: ['msgpack']
pass_filenames: false
always_run: true
"});
context.git().add(".");
cmd_snapshot!(context, context.run().arg("-v"), @r"
success: true
exit_code: 0
----- stdout -----
test-native-gem..........................................................Passed
- hook id: test-native-gem
- duration: [TIME]
MessagePack native extension working!
Packed size: 21 bytes
----- stderr -----
");
}
#[test]
fn process_files() {
let context = TestEnv::new()
.with_file(
"check_ruby.rb",
indoc::indoc! {r#"
ARGV.sort.each do |file|
unless file.end_with?('.rb')
puts "Error: #{file} is not a Ruby file"
exit 1
end
puts "OK: #{file}"
end
"#},
)
.init_git();
let context = context
.with_config(indoc::indoc! {r"
repos:
- repo: local
hooks:
- id: check-ruby-files
name: check-ruby-files
language: ruby
entry: ruby check_ruby.rb
language_version: system
files: \.rb$
verbose: true
"})
.with_file("test.rb", "puts 'hello'")
.with_file("test.txt", "hello");
context.git().add(".");
cmd_snapshot!(context, context.run(), @r"
success: true
exit_code: 0
----- stdout -----
check-ruby-files.........................................................Passed
- hook id: check-ruby-files
- duration: [TIME]
OK: check_ruby.rb
OK: test.rb
----- stderr -----
");
}
#[cfg(feature = "ci")]
#[test]
#[cfg(not(target_os = "windows"))]
fn auto_download() -> anyhow::Result<()> {
use assert_fs::assert::PathAssert;
let context = ruby_context();
context.write_config(indoc::indoc! {r"
repos:
- repo: local
hooks:
- id: ruby-downloaded
name: ruby-downloaded
language: ruby
entry: ruby --version
language_version: '3.2.9'
pass_filenames: false
always_run: true
- id: ruby-system
name: ruby-system
language: ruby
entry: ruby --version
language_version: '3.4'
pass_filenames: false
always_run: true
"});
context.git().add(".");
let ruby_dir = context.home_dir().child("tools").child("ruby");
ruby_dir.assert(predicates::path::missing());
cmd_snapshot!(context, context.run().arg("-v"), @r"
success: true
exit_code: 0
----- stdout -----
ruby-downloaded..........................................................Passed
- hook id: ruby-downloaded
- duration: [TIME]
ruby 3.2.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]
ruby-system..............................................................Passed
- hook id: ruby-system
- duration: [TIME]
ruby 3.4.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]
----- stderr -----
");
let installed_versions = ruby_dir
.read_dir()?
.flatten()
.filter_map(|d| {
let filename = d.file_name().to_string_lossy().into_owned();
if filename.starts_with('.') {
None
} else {
Some(filename)
}
})
.collect::<Vec<_>>();
assert_eq!(
installed_versions.len(),
1,
"Expected only one Ruby version to be downloaded, but found: {installed_versions:?}"
);
assert!(
installed_versions.iter().any(|v| v.starts_with("3.2.9")),
"Expected Ruby 3.2.9 to be downloaded, but found: {installed_versions:?}"
);
let ruby_version_dir = ruby_dir
.read_dir()?
.flatten()
.find(|d| d.file_name().to_string_lossy().starts_with("3.2.9"))
.expect("Expected a 3.2.9 directory");
let mtime_before = ruby_version_dir.metadata()?.modified()?;
context.write_config(indoc::indoc! {r"
repos:
- repo: local
hooks:
- id: ruby-reused
name: ruby-reused
language: ruby
entry: ruby --version
language_version: '3.2.9'
pass_filenames: false
always_run: true
"});
context.git().add(".");
cmd_snapshot!(context, context.run().arg("-v"), @r"
success: true
exit_code: 0
----- stdout -----
ruby-reused..............................................................Passed
- hook id: ruby-reused
- duration: [TIME]
ruby 3.2.X ([DATE] revision [HASH]) [FLAGS] [PLATFORM]
----- stderr -----
");
let mtime_after = ruby_version_dir.metadata()?.modified()?;
assert_eq!(
mtime_before, mtime_after,
"Ruby directory was modified during reuse, suggests it was re-downloaded"
);
Ok(())
}