1
0
mirror of https://github.com/j178/prek.git synced 2026-04-03 17:34:03 +02:00
Files
prek/tests/auto_update.rs
Benjamin Bannier ee7110b49b Fix autoupdate rev rendering for "float-like" version numbers (#867)
A version number could look like a float, e.g., 0.49 is a valid version
number. Serde YAML serialization already renders such version numbers in
quotes so types match the expectation. When doing an autoupdate we were
explicitly adding more quotes if they were present in the input. This
could have caused incorrect quoting if existing quoting clashed with
serde's quoting style, e.g., if `"` where used but serde defaulted to
quoting with `'`.

This patch removes any extra quoting here and fully defers to serde.

Closes #866.
2025-10-11 18:27:48 +08:00

939 lines
24 KiB
Rust

use std::process::Command;
use anyhow::Result;
use assert_cmd::assert::OutputAssertExt;
use assert_fs::fixture::ChildPath;
use assert_fs::prelude::*;
use constants::CONFIG_FILE;
use insta::assert_snapshot;
use crate::common::{TestContext, cmd_snapshot};
mod common;
/// Helper function to create a local git repository with hooks
fn create_local_git_repo(context: &TestContext, repo_name: &str, tags: &[&str]) -> Result<String> {
let repo_dir = context.home_dir().child(format!("test-repos/{repo_name}"));
repo_dir.create_dir_all()?;
Command::new("git")
.arg("-c")
.arg("init.defaultBranch=master")
.arg("init")
.current_dir(&repo_dir)
.assert()
.success();
Command::new("git")
.arg("config")
.arg("user.name")
.arg("Prek Test")
.current_dir(&repo_dir)
.assert()
.success();
Command::new("git")
.arg("config")
.arg("user.email")
.arg("test@prek.dev")
.current_dir(&repo_dir)
.assert()
.success();
Command::new("git")
.arg("config")
.arg("core.autocrlf")
.arg("false")
.current_dir(&repo_dir)
.assert()
.success();
// Create .pre-commit-hooks.yaml
repo_dir
.child(".pre-commit-hooks.yaml")
.write_str(indoc::indoc! {r#"
- id: test-hook
name: Test Hook
entry: echo
language: system
- id: another-hook
name: Another Hook
entry: python3 -c 'print("hello")'
language: python
"#})?;
Command::new("git")
.arg("add")
.arg(".")
.current_dir(&repo_dir)
.assert()
.success();
Command::new("git")
.arg("commit")
.arg("-m")
.arg("Initial commit")
.current_dir(&repo_dir)
.assert()
.success();
// Create tags
for tag in tags {
Command::new("git")
.arg("commit")
.arg("-m")
.arg(format!("Release {tag}"))
.arg("--allow-empty")
.current_dir(&repo_dir)
.assert()
.success();
Command::new("git")
.arg("tag")
.arg(tag)
.arg("-m")
.arg(tag)
.current_dir(&repo_dir)
.assert()
.success();
}
// Add an extra commit to the tip
Command::new("git")
.arg("commit")
.arg("-m")
.arg("tip")
.arg("--allow-empty")
.current_dir(&repo_dir)
.assert()
.success();
Ok(repo_dir.to_string_lossy().to_string())
}
#[test]
fn auto_update_basic() -> Result<()> {
let context = TestContext::new();
context.init_project();
let repo_path = create_local_git_repo(&context, "test-repo", &["v1.0.0", "v1.1.0", "v2.0.0"])?;
context.write_pre_commit_config(&indoc::formatdoc! {r"
repos:
- repo: {}
rev: v1.0.0
hooks:
- id: test-hook
", repo_path});
context.git_add(".");
let filters = context.filters();
cmd_snapshot!(filters.clone(), context.auto_update(), @r#"
success: true
exit_code: 0
----- stdout -----
[[HOME]/test-repos/test-repo] updating v1.0.0 -> v2.0.0
----- stderr -----
"#);
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read(CONFIG_FILE), @r#"
repos:
- repo: [HOME]/test-repos/test-repo
rev: v2.0.0
hooks:
- id: test-hook
"#);
}
);
Ok(())
}
#[test]
fn auto_update_already_up_to_date() -> Result<()> {
let context = TestContext::new();
context.init_project();
let repo_path = create_local_git_repo(&context, "up-to-date-repo", &["v1.0.0"])?;
context.write_pre_commit_config(&indoc::formatdoc! {r"
repos:
- repo: {}
rev: v1.0.0
hooks:
- id: test-hook
", repo_path});
context.git_add(".");
let filters = context.filters();
cmd_snapshot!(filters.clone(), context.auto_update(), @r#"
success: true
exit_code: 0
----- stdout -----
[[HOME]/test-repos/up-to-date-repo] already up to date
----- stderr -----
"#);
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read(CONFIG_FILE), @r#"
repos:
- repo: [HOME]/test-repos/up-to-date-repo
rev: v1.0.0
hooks:
- id: test-hook
"#);
}
);
Ok(())
}
#[test]
fn auto_update_multiple_repos_mixed() -> Result<()> {
let context = TestContext::new();
context.init_project();
let repo1_path = create_local_git_repo(&context, "repo1", &["v1.0.0", "v1.1.0"])?;
let repo2_path = create_local_git_repo(&context, "repo2", &["v2.0.0"])?;
context.write_pre_commit_config(&indoc::formatdoc! {r"
repos:
- repo: {}
rev: v1.0.0
hooks:
- id: test-hook
- repo: {}
rev: v1.0.0
hooks:
- id: same-hook
- repo: {}
rev: v2.0.0
hooks:
- id: another-hook
", repo1_path, repo1_path, repo2_path});
context.git_add(".");
let filters = context.filters();
cmd_snapshot!(filters.clone(), context.auto_update(), @r#"
success: true
exit_code: 0
----- stdout -----
[[HOME]/test-repos/repo1] updating v1.0.0 -> v1.1.0
[[HOME]/test-repos/repo2] already up to date
----- stderr -----
"#);
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read(CONFIG_FILE), @r"
repos:
- repo: [HOME]/test-repos/repo1
rev: v1.1.0
hooks:
- id: test-hook
- repo: [HOME]/test-repos/repo1
rev: v1.1.0
hooks:
- id: same-hook
- repo: [HOME]/test-repos/repo2
rev: v2.0.0
hooks:
- id: another-hook
");
}
);
Ok(())
}
#[test]
fn auto_update_specific_repos() -> Result<()> {
let context = TestContext::new();
context.init_project();
let repo1_path = create_local_git_repo(&context, "repo1", &["v1.0.0", "v1.1.0"])?;
let repo2_path = create_local_git_repo(&context, "repo2", &["v2.0.0", "v2.1.0"])?;
context.write_pre_commit_config(&indoc::formatdoc! {r"
repos:
- repo: {}
rev: v1.0.0
hooks:
- id: test-hook
- repo: {}
rev: v2.0.0
hooks:
- id: another-hook
", repo1_path, repo2_path});
context.git_add(".");
let filters = context.filters();
// Update only repo1
cmd_snapshot!(filters.clone(), context.auto_update().arg("--repo").arg(&repo1_path), @r#"
success: true
exit_code: 0
----- stdout -----
[[HOME]/test-repos/repo1] updating v1.0.0 -> v1.1.0
----- stderr -----
"#);
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read(CONFIG_FILE), @r#"
repos:
- repo: [HOME]/test-repos/repo1
rev: v1.1.0
hooks:
- id: test-hook
- repo: [HOME]/test-repos/repo2
rev: v2.0.0
hooks:
- id: another-hook
"#);
}
);
// Update both repo1 and repo2
cmd_snapshot!(filters.clone(), context.auto_update().arg("--repo").arg(&repo1_path).arg("--repo").arg(&repo2_path), @r#"
success: true
exit_code: 0
----- stdout -----
[[HOME]/test-repos/repo1] already up to date
[[HOME]/test-repos/repo2] updating v2.0.0 -> v2.1.0
----- stderr -----
"#);
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read(CONFIG_FILE), @r#"
repos:
- repo: [HOME]/test-repos/repo1
rev: v1.1.0
hooks:
- id: test-hook
- repo: [HOME]/test-repos/repo2
rev: v2.1.0
hooks:
- id: another-hook
"#);
}
);
Ok(())
}
#[test]
fn auto_update_bleeding_edge() -> Result<()> {
let context = TestContext::new();
context.init_project();
let repo_path = create_local_git_repo(&context, "bleeding-repo", &["v1.0.0"])?;
context.write_pre_commit_config(&indoc::formatdoc! {r"
repos:
- repo: {}
rev: v1.0.0
hooks:
- id: test-hook
", repo_path});
context.git_add(".");
let filters = context
.filters()
.into_iter()
.chain([("[a-f0-9]{40}", "[COMMIT_SHA]")])
.collect::<Vec<_>>();
cmd_snapshot!(filters.clone(), context.auto_update().arg("--bleeding-edge"), @r#"
success: true
exit_code: 0
----- stdout -----
[[HOME]/test-repos/bleeding-repo] updating v1.0.0 -> [COMMIT_SHA]
----- stderr -----
"#);
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read(CONFIG_FILE), @r#"
repos:
- repo: [HOME]/test-repos/bleeding-repo
rev: [COMMIT_SHA]
hooks:
- id: test-hook
"#);
}
);
Ok(())
}
#[test]
fn auto_update_freeze() -> Result<()> {
let context = TestContext::new();
context.init_project();
let repo_path = create_local_git_repo(&context, "freeze-repo", &["v1.0.0", "v1.1.0"])?;
context.write_pre_commit_config(&indoc::formatdoc! {r"
repos:
- repo: {}
rev: v1.0.0
hooks:
- id: test-hook
", repo_path});
context.git_add(".");
let filters = context
.filters()
.into_iter()
.chain([(r" [a-f0-9]{40}", r" [COMMIT_SHA]")])
.collect::<Vec<_>>();
cmd_snapshot!(filters.clone(), context.auto_update().arg("--freeze"), @r#"
success: true
exit_code: 0
----- stdout -----
[[HOME]/test-repos/freeze-repo] updating v1.0.0 -> [COMMIT_SHA]
----- stderr -----
"#);
// Should contain frozen comment
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read(CONFIG_FILE), @r##"
repos:
- repo: [HOME]/test-repos/freeze-repo
rev: [COMMIT_SHA] # frozen: v1.1.0
hooks:
- id: test-hook
"##);
}
);
Ok(())
}
#[test]
fn auto_update_preserve_formatting() -> Result<()> {
let context = TestContext::new();
context.init_project();
let repo1_path = create_local_git_repo(&context, "repo1", &["v1.0.0", "v1.1.0"])?;
let repo2_path = create_local_git_repo(&context, "repo2", &["v1.0.0", "v1.1.0"])?;
// Use specific formatting with comments
context.write_pre_commit_config(&indoc::formatdoc! {r#"
# Pre-commit configuration
repos:
- repo: {} # Test repository
rev: 'v1.0.0' # Current version
hooks:
- id: test-hook
# Hook configuration
name: Test Hook
- repo: {}
rev: "v1.0.0" # Current version
hooks:
- id: test-hook
# Hook configuration
name: Test Hook
"#, repo1_path, repo2_path });
context.git_add(".");
let filters = context.filters();
cmd_snapshot!(filters.clone(), context.auto_update(), @r#"
success: true
exit_code: 0
----- stdout -----
[[HOME]/test-repos/repo1] updating v1.0.0 -> v1.1.0
[[HOME]/test-repos/repo2] updating v1.0.0 -> v1.1.0
----- stderr -----
"#);
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read(CONFIG_FILE), @r"
# Pre-commit configuration
repos:
- repo: [HOME]/test-repos/repo1 # Test repository
rev: v1.1.0 # Current version
hooks:
- id: test-hook
# Hook configuration
name: Test Hook
- repo: [HOME]/test-repos/repo2
rev: v1.1.0 # Current version
hooks:
- id: test-hook
# Hook configuration
name: Test Hook
");
}
);
Ok(())
}
#[test]
fn auto_update_with_existing_frozen_comment() -> Result<()> {
let context = TestContext::new();
context.init_project();
let repo_path =
create_local_git_repo(&context, "frozen-repo", &["v1.0.0", "v1.1.0", "v1.2.0"])?;
let commit_sha = "1234567890abcdef1234567890abcdef12345678";
context.write_pre_commit_config(&indoc::formatdoc! {r"
repos:
- repo: {}
rev: {} # frozen: v1.0.0
hooks:
- id: test-hook
", repo_path, commit_sha});
context.git_add(".");
let filters = context
.filters()
.into_iter()
.chain([(commit_sha, "[COMMIT_SHA]")])
.collect::<Vec<_>>();
cmd_snapshot!(filters.clone(), context.auto_update(), @r#"
success: true
exit_code: 0
----- stdout -----
[[HOME]/test-repos/frozen-repo] updating [COMMIT_SHA] -> v1.2.0
----- stderr -----
"#);
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read(CONFIG_FILE), @r#"
repos:
- repo: [HOME]/test-repos/frozen-repo
rev: v1.2.0
hooks:
- id: test-hook
"#);
}
);
Ok(())
}
#[test]
fn auto_update_local_repo_ignored() -> Result<()> {
let context = TestContext::new();
context.init_project();
let repo_path = create_local_git_repo(&context, "remote-repo", &["v1.0.0", "v1.1.0"])?;
context.write_pre_commit_config(&indoc::formatdoc! {r"
repos:
- repo: local
hooks:
- id: local-hook
name: Local Hook
language: system
entry: echo
- repo: {}
rev: v1.0.0
hooks:
- id: test-hook
", repo_path});
context.git_add(".");
let filters = context.filters();
cmd_snapshot!(filters.clone(), context.auto_update(), @r#"
success: true
exit_code: 0
----- stdout -----
[[HOME]/test-repos/remote-repo] updating v1.0.0 -> v1.1.0
----- stderr -----
"#);
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read(CONFIG_FILE), @r#"
repos:
- repo: local
hooks:
- id: local-hook
name: Local Hook
language: system
entry: echo
- repo: [HOME]/test-repos/remote-repo
rev: v1.1.0
hooks:
- id: test-hook
"#);
}
);
Ok(())
}
#[test]
fn missing_hook_ids() -> Result<()> {
let context = TestContext::new();
context.init_project();
let repo_path = create_local_git_repo(&context, "missing-hook-repo", &["v1.0.0"])?;
// Remove the 'test-hook' from the hooks file
ChildPath::new(&repo_path)
.child(".pre-commit-hooks.yaml")
.write_str(indoc::indoc! {r#"
- id: another-hook
name: Another Hook
entry: python3 -c 'print("hello")'
language: python
"#})?;
Command::new("git")
.arg("add")
.arg(".")
.current_dir(&repo_path)
.assert()
.success();
Command::new("git")
.arg("commit")
.arg("-m")
.arg("Remove test-hook")
.current_dir(&repo_path)
.assert()
.success();
Command::new("git")
.arg("tag")
.arg("v2.0.0")
.arg("-m")
.arg("v2.0.0")
.current_dir(&repo_path)
.assert()
.success();
context.write_pre_commit_config(&indoc::formatdoc! {r"
repos:
- repo: {}
rev: v1.0.0
hooks:
- id: test-hook
", repo_path});
context.git_add(".");
let filters = context.filters();
cmd_snapshot!(filters.clone(), context.auto_update(), @r#"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
[[HOME]/test-repos/missing-hook-repo] update failed: Cannot update to rev `v2.0.0`, hook is missing: test-hook
"#);
Ok(())
}
#[test]
fn auto_update_workspace() -> Result<()> {
let context = TestContext::new();
context.init_project();
let repo1_path =
create_local_git_repo(&context, "workspace-repo1", &["v1.0.0", "v1.1.0", "v2.0.0"])?;
let repo2_path = create_local_git_repo(&context, "workspace-repo2", &["v1.0.0", "v1.5.0"])?;
let repo3_path = create_local_git_repo(&context, "workspace-repo3", &["v2.0.0"])?;
context.setup_workspace(
&["project-a", "project-b"],
"repos: []", // Minimal valid config for root
)?;
context
.work_dir()
.child("project-a/.pre-commit-config.yaml")
.write_str(&indoc::formatdoc! {r"
repos:
- repo: {}
rev: v1.0.0
hooks:
- id: test-hook
- repo: {}
rev: v1.0.0
hooks:
- id: another-hook
", repo1_path, repo2_path})?;
context
.work_dir()
.child("project-b/.pre-commit-config.yaml")
.write_str(&indoc::formatdoc! {r"
repos:
- repo: {}
rev: v1.0.0
hooks:
- id: another-hook
- repo: {}
rev: v2.0.0
hooks:
- id: test-hook
", repo2_path, repo3_path})?;
context.git_add(".");
let filters = context.filters();
cmd_snapshot!(filters.clone(), context.auto_update(), @r"
success: true
exit_code: 0
----- stdout -----
[[HOME]/test-repos/workspace-repo1] updating v1.0.0 -> v2.0.0
[[HOME]/test-repos/workspace-repo2] updating v1.0.0 -> v1.5.0
[[HOME]/test-repos/workspace-repo3] already up to date
----- stderr -----
");
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read("project-a/.pre-commit-config.yaml"), @r#"
repos:
- repo: [HOME]/test-repos/workspace-repo1
rev: v2.0.0
hooks:
- id: test-hook
- repo: [HOME]/test-repos/workspace-repo2
rev: v1.5.0
hooks:
- id: another-hook
"#);
}
);
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read("project-b/.pre-commit-config.yaml"), @r#"
repos:
- repo: [HOME]/test-repos/workspace-repo2
rev: v1.5.0
hooks:
- id: another-hook
- repo: [HOME]/test-repos/workspace-repo3
rev: v2.0.0
hooks:
- id: test-hook
"#);
}
);
Ok(())
}
// When there are multiple tags pointing to the same object,
// prek prefer picking a tag with a dot and is closest to the current rev according
// to Levenshtein distance.
#[test]
fn prefer_similar_tags() -> Result<()> {
let context = TestContext::new();
context.init_project();
let repo_path = create_local_git_repo(&context, "remote-repo", &["v1.0.0", "v1.1.0"])?;
// Add tag foo-v1.0.0 pointing to the same commit as v1.1.0
// v1.0.0 distance to v1.1.0 is 1
// v1.0.0 distance to foo-v1.0.0 is 4
// So we choose v1.1.0 as the update target
// But if the newest tag is v1.1.1111 (distance is 5), then we would choose foo-v1.0.0 instead
Command::new("git")
.arg("tag")
.arg("foo-v1.0.0")
.arg("-m")
.arg("foo-v1.0.0")
.arg("v1.1.0^{}")
.current_dir(&repo_path)
.assert()
.success();
// Add tag v1 pointing to the same commit as v1.1.0
Command::new("git")
.arg("tag")
.arg("v1")
.arg("-m")
.arg("v1")
.arg("v1.1.0^{}")
.current_dir(&repo_path)
.assert()
.success();
context.write_pre_commit_config(&indoc::formatdoc! {r"
repos:
- repo: local
hooks:
- id: local-hook
name: Local Hook
language: system
entry: echo
- repo: {}
rev: v1.0.0
hooks:
- id: test-hook
", repo_path});
context.git_add(".");
let filters = context.filters();
cmd_snapshot!(filters.clone(), context.auto_update(), @r"
success: true
exit_code: 0
----- stdout -----
[[HOME]/test-repos/remote-repo] updating v1.0.0 -> v1.1.0
----- stderr -----
");
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read(CONFIG_FILE), @r"
repos:
- repo: local
hooks:
- id: local-hook
name: Local Hook
language: system
entry: echo
- repo: [HOME]/test-repos/remote-repo
rev: v1.1.0
hooks:
- id: test-hook
");
}
);
Ok(())
}
#[test]
fn auto_update_dry_run() -> Result<()> {
let context = TestContext::new();
context.init_project();
let repo_path = create_local_git_repo(&context, "test-repo", &["v1.0.0", "v1.1.0", "v2.0.0"])?;
context.write_pre_commit_config(&indoc::formatdoc! {r"
repos:
- repo: {}
rev: v1.0.0
hooks:
- id: test-hook
", repo_path});
context.git_add(".");
let filters = context.filters();
cmd_snapshot!(filters.clone(), context.auto_update().arg("--dry-run"), @r#"
success: true
exit_code: 0
----- stdout -----
[[HOME]/test-repos/test-repo] updating v1.0.0 -> v2.0.0
----- stderr -----
"#);
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read(CONFIG_FILE), @r"
repos:
- repo: [HOME]/test-repos/test-repo
rev: v1.0.0
hooks:
- id: test-hook
");
}
);
Ok(())
}
#[test]
fn quoting_float_like_version_number() -> Result<()> {
let context = TestContext::new();
context.init_project();
let repo_path = create_local_git_repo(&context, "test-repo", &["0.49", "0.50"])?;
// Our serialize by default quotes this floats with single quotes, e.g., '0.49'. Use
// a different quotaing style here to validate that this does not create conflicts.
context.write_pre_commit_config(&indoc::formatdoc! {r#"
repos:
- repo: {}
rev: "0.49"
hooks:
- id: test-hook
"#, repo_path});
context.git_add(".");
let filters = context.filters();
cmd_snapshot!(filters.clone(), context.auto_update(), @r#"
success: true
exit_code: 0
----- stdout -----
[[HOME]/test-repos/test-repo] updating 0.49 -> 0.50
----- stderr -----
"#);
insta::with_settings!(
{ filters => filters.clone() },
{
assert_snapshot!(context.read(CONFIG_FILE), @r#"
repos:
- repo: [HOME]/test-repos/test-repo
rev: '0.50'
hooks:
- id: test-hook
"#);
}
);
Ok(())
}