use assert_cmd::assert::OutputAssertExt;
use assert_fs::assert::PathAssert;
use assert_fs::fixture::{ChildPath, PathChild, PathCreateDir};
use assert_fs::prelude::FileWriteStr;
use prek_consts::{PRE_COMMIT_CONFIG_YAML, PRE_COMMIT_HOOKS_YAML};
use serde_json::json;
use std::time::{Duration, SystemTime};
use crate::common::{TestEnv, cmd_snapshot};
mod common;
#[test]
fn cache_dir() {
let context = TestEnv::new_without_git();
let home = context.work_dir().child("home");
cmd_snapshot!(context, context.command().arg("cache").arg("dir").env("PREK_HOME", &*home), @r"
success: true
exit_code: 0
----- stdout -----
[TEMP_DIR]/home
----- stderr -----
");
}
#[test]
fn cache_gc_verbose_shows_removed_entries() {
let context = TestEnv::new_without_git().with_config("repos: []\n");
let home = context.home_dir();
home.child("repos/deadbeef")
.create_dir_all()
.expect("create repo dir");
home.child("repos/deadbeef/.prek-repo.json")
.write_str(
&serde_json::to_string_pretty(&json!({
"repo": "https://github.com/pre-commit/pre-commit-hooks",
"rev": "v1.0.0",
}))
.expect("serialize repo marker"),
)
.expect("write repo marker");
home.child("hooks/hook-env-dead")
.create_dir_all()
.expect("create hook env dir");
home.child("hooks/hook-env-dead/.prek-hook.json")
.write_str(
&serde_json::to_string_pretty(&json!({
"schema_version": 1,
"language": "python",
"language_version": "3.12.0",
"repo": {
"url": "https://example.com/repo",
"rev": "v1.0.0",
},
"dependencies": [
"dep1",
"dep2",
"dep3",
"dep4",
"dep5",
"dep6",
"dep7",
],
"env_path": home.child("hooks/hook-env-dead").path(),
"toolchain": "/usr/bin/python3",
"extra": {},
}))
.expect("serialize hook marker"),
)
.expect("write hook marker");
home.child("cache/go")
.create_dir_all()
.expect("create cache dir");
let config_path = context.work_dir().child(PRE_COMMIT_CONFIG_YAML);
write_config_tracking_file(home, &[config_path.path()]).expect("write tracking file");
cmd_snapshot!(context, context.command().args(["cache", "gc", "-v"]),@r"
success: true
exit_code: 0
----- stdout -----
Removed 1 repo, 1 hook env, 1 cache entry ([SIZE])
Removed 1 repo:
- https://github.com/pre-commit/pre-commit-hooks@v1.0.0
path: [HOME]/repos/deadbeef
Removed 1 hook env:
- python env
path: [HOME]/hooks/hook-env-dead
language: python (3.12.0)
repo: https://example.com/repo@v1.0.0
deps: dep1, dep2, dep3, dep4, dep5, dep6, … (+1 more)
Removed 1 cache entry:
- go
path: [HOME]/cache/go
----- stderr -----
");
}
#[test]
fn cache_clean() -> anyhow::Result<()> {
let context = TestEnv::new_without_git().with_filter(
r"(?m)^Removed \d+ files? \([^)]+\)\n",
"Removed [N] file(s) ([SIZE])\n",
);
let home = context.work_dir().child("home");
home.create_dir_all()?;
home.child("cache/nested").create_dir_all()?;
home.child("cache/data.bin").write_str("hello")?;
home.child("cache/nested/data.bin").write_str("world!")?;
cmd_snapshot!(context, context.command().arg("cache").arg("clean").env("PREK_HOME", &*home), @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Removed [N] file(s) ([SIZE])
");
home.assert(predicates::path::missing());
home.create_dir_all()?;
home.child("cache").create_dir_all()?;
home.child("cache/one.txt").write_str("abc")?;
cmd_snapshot!(context, context.command().arg("clean").env("PREK_HOME", &*home), @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Removed [N] file(s) ([SIZE])
");
home.assert(predicates::path::missing());
Ok(())
}
#[test]
fn cache_size_output_formats() {
let context = TestEnv::new_without_git().with_filter(r"(?m)^\d+\n", "[BYTES]\n");
cmd_snapshot!(context, context.command().args(["cache", "size", "--no-log-file", "--output-format", "auto"]), @r"
success: true
exit_code: 0
----- stdout -----
[BYTES]
----- stderr -----
");
cmd_snapshot!(context, context.command().args(["cache", "size", "--no-log-file", "--output-format", "human"]), @r"
success: true
exit_code: 0
----- stdout -----
[SIZE]
----- stderr -----
");
cmd_snapshot!(context, context.command().args(["cache", "size", "--no-log-file", "--output-format", "machine"]), @r"
success: true
exit_code: 0
----- stdout -----
[BYTES]
----- stderr -----
");
}
#[test]
fn cache_size_with_populated_cache() -> anyhow::Result<()> {
let context = TestEnv::new()
.with_filter(r"(?m)^\d+\n", "[BYTES]\n")
.with_config(indoc::indoc! {r"
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
"});
let cwd = context.work_dir();
cwd.child("file.txt").write_str("Hello, world!\n")?;
context.git_add_all();
context.run();
cmd_snapshot!(context, context.command().arg("cache").arg("size"), @r"
success: true
exit_code: 0
----- stdout -----
[BYTES]
----- stderr -----
");
cmd_snapshot!(context, context.command().arg("cache").arg("size").arg("-H"), @r"
success: true
exit_code: 0
----- stdout -----
[SIZE]
----- stderr -----
");
Ok(())
}
#[test]
fn cache_gc_removes_unreferenced_entries() -> anyhow::Result<()> {
let context = TestEnv::new().with_config(indoc::indoc! {r#"
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-yaml
- repo: local
hooks:
- id: python-hook
name: Python Hook
entry: python -c "print('Hello from Python')"
language: python
"#});
let cwd = context.work_dir();
cwd.child("valid.yaml").write_str("a: 1\n")?;
context.git_add_all();
let home = context.home_dir();
cmd_snapshot!(context, context.run(), @r"
success: true
exit_code: 0
----- stdout -----
check yaml...............................................................Passed
Python Hook..............................................................Passed
----- stderr -----
");
home.child("repos/unused-repo").create_dir_all()?;
home.child("hooks/unused-hook-env").create_dir_all()?;
home.child("tools/node").create_dir_all()?;
home.child("cache/go").create_dir_all()?;
context.write_config(indoc::indoc! {r"
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-yaml
"});
cmd_snapshot!(context, context.command().arg("cache").arg("gc"), @r#"
success: true
exit_code: 0
----- stdout -----
Removed 1 repo, 2 hook envs, 1 tool, 1 cache entry ([SIZE])
----- stderr -----
"#);
home.child("repos/unused-repo")
.assert(predicates::path::missing());
home.child("hooks/unused-hook-env")
.assert(predicates::path::missing());
home.child("tools/node").assert(predicates::path::missing());
home.child("cache/go").assert(predicates::path::missing());
Ok(())
}
#[test]
fn cache_gc_keeps_relative_remote_repo() -> anyhow::Result<()> {
let context = TestEnv::new();
let hook_repo = context.work_dir().child("hook-repo");
hook_repo.create_dir_all()?;
context.git_at(&hook_repo).args(["init"]).assert().success();
hook_repo
.child(PRE_COMMIT_HOOKS_YAML)
.write_str(indoc::indoc! {r"
- id: test-hook
name: Test Hook
entry: echo test
language: system
always_run: true
"})?;
context
.git_at(&hook_repo)
.args(["add", "."])
.assert()
.success();
context
.git_at(&hook_repo)
.args(["commit", "-m", "Initial commit"])
.assert()
.success();
let output = context
.git_at(&hook_repo)
.args(["rev-parse", "HEAD"])
.output()?;
let revision = String::from_utf8(output.stdout)?.trim().to_string();
let subproject = context.work_dir().child("subproject");
subproject.create_dir_all()?;
subproject
.child(PRE_COMMIT_CONFIG_YAML)
.write_str(&indoc::formatdoc! {r"
repos:
- repo: ../hook-repo
rev: {revision}
hooks:
- id: test-hook
"})?;
context.git_add_all();
cmd_snapshot!(context, context.run()
.arg("--config")
.arg("subproject/.pre-commit-config.yaml"), @r"
success: true
exit_code: 0
----- stdout -----
Test Hook................................................................Passed
----- stderr -----
");
let repos_dir = context.home_dir().child("repos");
let cached_repo = fs_err::read_dir(repos_dir.path())?
.next()
.transpose()?
.expect("expected the relative remote repo to be cached")
.path();
repos_dir.child("unused-repo").create_dir_all()?;
cmd_snapshot!(context, context.command().args(["cache", "gc"]), @r"
success: true
exit_code: 0
----- stdout -----
Removed 1 repo ([SIZE])
----- stderr -----
");
assert!(cached_repo.is_dir(), "cache GC removed the configured repo");
repos_dir
.child("unused-repo")
.assert(predicates::path::missing());
Ok(())
}
#[test]
fn cache_gc_prunes_unused_tool_versions() -> anyhow::Result<()> {
let context = TestEnv::new_without_git().with_config(indoc::indoc! {r#"
repos:
- repo: local
hooks:
- id: local-python
name: Local Python Hook
entry: "python -c \"print(1)\""
language: python
- id: local-pygrep
name: Local Pygrep Hook
entry: "python -c \"print(1)\""
language: pygrep
- id: local-node
name: Local Node Hook
entry: "node -e \"console.log(1)\""
language: node
- id: local-go
name: Local Go Hook
entry: "go version"
language: golang
- id: local-ruby
name: Local Ruby Hook
entry: "ruby -e 'puts 1'"
language: ruby
- id: local-rust
name: Local Rust Hook
entry: "rustc --version"
language: rust
"#});
let home = context.home_dir();
let config_path = context.work_dir().child(PRE_COMMIT_CONFIG_YAML);
write_config_tracking_file(home, &[config_path.path()])?;
let env_py = home.child("hooks/python-keep");
let env_node = home.child("hooks/node-keep");
let env_go = home.child("hooks/go-keep");
let env_ruby = home.child("hooks/ruby-remove");
let env_rust = home.child("hooks/rust-remove");
env_py.create_dir_all()?;
env_node.create_dir_all()?;
env_go.create_dir_all()?;
env_ruby.create_dir_all()?;
env_rust.create_dir_all()?;
let py_keep = home.child("tools/python/3.12.0");
let py_remove = home.child("tools/python/3.11.0");
py_keep.create_dir_all()?;
py_remove.create_dir_all()?;
let node_keep = home.child("tools/node/22.0.0");
let node_remove = home.child("tools/node/21.0.0");
node_keep.create_dir_all()?;
node_remove.create_dir_all()?;
let go_keep = home.child("tools/go/1.24.0");
let go_remove = home.child("tools/go/1.23.0");
go_keep.create_dir_all()?;
go_remove.create_dir_all()?;
let marker_py = json!({
"schema_version": 1,
"language": "python",
"language_version": "3.12.0",
"dependencies": [],
"env_path": env_py.path(),
"toolchain": py_keep.child("bin/python").path(),
"extra": {},
});
env_py
.child(".prek-hook.json")
.write_str(&serde_json::to_string_pretty(&marker_py)?)?;
let marker_node = json!({
"schema_version": 1,
"language": "node",
"language_version": "22.0.0",
"dependencies": [],
"env_path": env_node.path(),
"toolchain": node_keep.child("bin/node").path(),
"extra": {},
});
env_node
.child(".prek-hook.json")
.write_str(&serde_json::to_string_pretty(&marker_node)?)?;
let marker_go = json!({
"schema_version": 1,
"language": "golang",
"language_version": "1.24.0",
"dependencies": [],
"env_path": env_go.path(),
"toolchain": go_keep.child("bin/go").path(),
"extra": {},
});
env_go
.child(".prek-hook.json")
.write_str(&serde_json::to_string_pretty(&marker_go)?)?;
cmd_snapshot!(context, context.command().args(["cache", "gc", "--dry-run", "-v"]), @r#"
success: true
exit_code: 0
----- stdout -----
Would remove 2 hook envs, 3 tools ([SIZE])
Would remove 2 hook envs:
- ruby-remove
path: [HOME]/hooks/ruby-remove
- rust-remove
path: [HOME]/hooks/rust-remove
Would remove 3 tools:
- go/1.23.0
path: [HOME]/tools/go/1.23.0
- node/21.0.0
path: [HOME]/tools/node/21.0.0
- python/3.11.0
path: [HOME]/tools/python/3.11.0
----- stderr -----
"#);
cmd_snapshot!(context, context.command().args(["cache", "gc", "-v"]), @r#"
success: true
exit_code: 0
----- stdout -----
Removed 2 hook envs, 3 tools ([SIZE])
Removed 2 hook envs:
- ruby-remove
path: [HOME]/hooks/ruby-remove
- rust-remove
path: [HOME]/hooks/rust-remove
Removed 3 tools:
- go/1.23.0
path: [HOME]/tools/go/1.23.0
- node/21.0.0
path: [HOME]/tools/node/21.0.0
- python/3.11.0
path: [HOME]/tools/python/3.11.0
----- stderr -----
"#);
Ok(())
}
#[test]
fn cache_gc_prunes_tool_versions_without_positive_identification() -> anyhow::Result<()> {
let context = TestEnv::new_without_git().with_config(indoc::indoc! {r#"
repos:
- repo: local
hooks:
- id: local-python
name: Local Python Hook
entry: "python -c \"print(1)\""
language: python
"#});
let home = context.home_dir();
let config_path = context.work_dir().child(PRE_COMMIT_CONFIG_YAML);
write_config_tracking_file(home, &[config_path.path()])?;
let env_py = home.child("hooks/python-keep");
env_py.create_dir_all()?;
let marker_py = json!({
"schema_version": 1,
"language": "python",
"language_version": "3.12.0",
"dependencies": [],
"env_path": env_py.path(),
"toolchain": "/usr/bin/python3",
"extra": {},
});
env_py
.child(".prek-hook.json")
.write_str(&serde_json::to_string_pretty(&marker_py)?)?;
let py_312 = home.child("tools/python/3.12.0");
let py_311 = home.child("tools/python/3.11.0");
py_312.create_dir_all()?;
py_311.create_dir_all()?;
home.child("repos/.temp").create_dir_all()?;
home.child("tools/.temp").create_dir_all()?;
cmd_snapshot!(context,
context.command().args(["cache", "gc", "--dry-run", "-v"]),
@r"
success: true
exit_code: 0
----- stdout -----
Would remove 2 tools ([SIZE])
Would remove 2 tools:
- python/3.11.0
path: [HOME]/tools/python/3.11.0
- python/3.12.0
path: [HOME]/tools/python/3.12.0
----- stderr -----
"
);
cmd_snapshot!(context, context.command().args(["cache", "gc"]), @r"
success: true
exit_code: 0
----- stdout -----
Removed 2 tools ([SIZE])
----- stderr -----
");
py_312.assert(predicates::path::missing());
py_311.assert(predicates::path::missing());
home.child("tools/python")
.assert(predicates::path::is_dir());
Ok(())
}
#[test]
fn cache_gc_keeps_local_hook_env() -> anyhow::Result<()> {
let context = TestEnv::new().with_config(indoc::indoc! {r#"
repos:
- repo: local
hooks:
- id: local-python
name: Local Python Hook
entry: python -c "print('hello')"
language: python
"#});
let cwd = context.work_dir();
cwd.child("file.txt").write_str("Hello\n")?;
context.git_add_all();
cmd_snapshot!(context, context.run(), @r"
success: true
exit_code: 0
----- stdout -----
Local Python Hook........................................................Passed
----- stderr -----
");
let home = context.home_dir();
let hooks_dir = home.child("hooks");
let mut local_envs = Vec::new();
for entry in fs_err::read_dir(hooks_dir.path())? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with("python-") {
local_envs.push(name);
}
}
assert!(
!local_envs.is_empty(),
"expected at least one local hook env"
);
home.child("hooks/unused-hook-env").create_dir_all()?;
cmd_snapshot!(context, context.command().args(["cache", "gc"]), @r#"
success: true
exit_code: 0
----- stdout -----
Removed 1 hook env ([SIZE])
----- stderr -----
"#);
for env in local_envs {
home.child(format!("hooks/{env}"))
.assert(predicates::path::is_dir());
}
home.child("hooks/unused-hook-env")
.assert(predicates::path::missing());
Ok(())
}
#[test]
fn cache_gc_removes_stale_patch_files() -> anyhow::Result<()> {
let context = TestEnv::new_without_git().with_config("repos: []\n");
let home = context.home_dir();
let config_path = context.work_dir().child(PRE_COMMIT_CONFIG_YAML);
write_config_tracking_file(home, &[config_path.path()])?;
let old_patch = home.child("patches/old.patch");
let recent_patch = home.child("patches/recent.patch");
write_patch_file(
&old_patch,
"old patch\n",
SystemTime::now() - Duration::from_hours(60 * 24),
)?;
write_patch_file(
&recent_patch,
"recent patch\n",
SystemTime::now() - Duration::from_hours(24),
)?;
cmd_snapshot!(context, context.command().args(["cache", "gc", "-v", "--dry-run"]), @"
success: true
exit_code: 0
----- stdout -----
Would remove 1 patch file ([SIZE])
Would remove 1 patch file:
- old.patch
path: [HOME]/patches/old.patch
----- stderr -----
");
old_patch.assert(predicates::path::is_file());
recent_patch.assert(predicates::path::is_file());
cmd_snapshot!(context, context.command().args(["cache", "gc", "-v"]), @r"
success: true
exit_code: 0
----- stdout -----
Removed 1 patch file ([SIZE])
Removed 1 patch file:
- old.patch
path: [HOME]/patches/old.patch
----- stderr -----
");
old_patch.assert(predicates::path::missing());
recent_patch.assert(predicates::path::is_file());
Ok(())
}
fn write_config_tracking_file(
home: &ChildPath,
configs: &[&std::path::Path],
) -> anyhow::Result<()> {
let configs: Vec<String> = configs
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
let content = serde_json::to_string_pretty(&configs)?;
home.child("config-tracking.json").write_str(&content)?;
Ok(())
}
fn write_patch_file(path: &ChildPath, content: &str, modified: SystemTime) -> anyhow::Result<()> {
let parent = path.path().parent().expect("patch file has parent");
fs_err::create_dir_all(parent)?;
fs_err::write(path.path(), content)?;
fs_err::OpenOptions::new()
.write(true)
.open(path.path())?
.set_modified(modified)?;
Ok(())
}
#[test]
fn cache_gc_drops_missing_tracked_config() -> anyhow::Result<()> {
let context = TestEnv::new().with_config("repos: []\n");
let cwd = context.work_dir();
context.git_add_all();
let home = context.home_dir();
let config_path = cwd.child(PRE_COMMIT_CONFIG_YAML);
write_config_tracking_file(home, &[config_path.path()])?;
fs_err::remove_file(config_path.path())?;
home.child("repos/unused-repo").create_dir_all()?;
home.child("hooks/unused-hook-env").create_dir_all()?;
home.child("tools/node").create_dir_all()?;
home.child("cache/go").create_dir_all()?;
home.child("scratch/some-temp").create_dir_all()?;
home.child("patches/some-patch").create_dir_all()?;
cmd_snapshot!(context, context.command().arg("cache").arg("gc"), @r#"
success: true
exit_code: 0
----- stdout -----
Removed 1 repo, 1 hook env, 1 tool, 1 cache entry ([SIZE])
----- stderr -----
"#);
let content = fs_err::read_to_string(home.child("config-tracking.json").path())?;
let tracked: Vec<String> = serde_json::from_str(&content)?;
assert!(tracked.is_empty());
home.child("scratch").assert(predicates::path::missing());
home.child("patches").assert(predicates::path::is_dir());
Ok(())
}
#[test]
fn cache_gc_keeps_tracked_config_on_parse_error() -> anyhow::Result<()> {
let context = TestEnv::new();
let cwd = context.work_dir();
cwd.child(PRE_COMMIT_CONFIG_YAML).write_str("repos: [\n")?;
context.git_add_all();
let home = context.home_dir();
let config_path = cwd.child(PRE_COMMIT_CONFIG_YAML);
write_config_tracking_file(home, &[config_path.path()])?;
home.child("repos/unused-repo").create_dir_all()?;
home.child("hooks/unused-hook-env").create_dir_all()?;
home.child("tools/node").create_dir_all()?;
home.child("cache/go").create_dir_all()?;
cmd_snapshot!(context, context.command().arg("cache").arg("gc"), @r#"
success: true
exit_code: 0
----- stdout -----
Removed 1 repo, 1 hook env, 1 tool, 1 cache entry ([SIZE])
----- stderr -----
"#);
let content = fs_err::read_to_string(home.child("config-tracking.json").path())?;
let tracked: Vec<String> = serde_json::from_str(&content)?;
assert_eq!(tracked.len(), 1);
Ok(())
}
#[test]
fn cache_gc_dry_run_does_not_remove_entries() -> anyhow::Result<()> {
let context = TestEnv::new().with_config("repos: []\n");
let cwd = context.work_dir();
context.git_add_all();
let home = context.home_dir();
let missing_config_path = cwd.child("missing-config.yaml");
write_config_tracking_file(home, &[missing_config_path.path()])?;
home.child("repos/unused-repo").create_dir_all()?;
home.child("hooks/unused-hook-env").create_dir_all()?;
home.child("tools/node").create_dir_all()?;
home.child("cache/go").create_dir_all()?;
home.child("scratch/some-temp").create_dir_all()?;
cmd_snapshot!(context, context.command().arg("cache").arg("gc").arg("--dry-run"), @r#"
success: true
exit_code: 0
----- stdout -----
Would remove 1 repo, 1 hook env, 1 tool, 1 cache entry ([SIZE])
----- stderr -----
"#);
home.child("repos/unused-repo")
.assert(predicates::path::is_dir());
home.child("hooks/unused-hook-env")
.assert(predicates::path::is_dir());
home.child("tools/node").assert(predicates::path::is_dir());
home.child("cache/go").assert(predicates::path::is_dir());
home.child("scratch/some-temp")
.assert(predicates::path::is_dir());
Ok(())
}