Skip to content

Commit 9172278

Browse files
shaanmajidj178
andauthored
Use semver fallback sort when tag timestamps are equal (#1579)
Fixes #1533 --------- Co-authored-by: Jo <10510431+j178@users.noreply.github.com>
1 parent 294325c commit 9172278

File tree

2 files changed

+256
-24
lines changed

2 files changed

+256
-24
lines changed

crates/prek/src/cli/auto_update.rs

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use owo_colors::OwoColorize;
1111
use prek_consts::PRE_COMMIT_HOOKS_YAML;
1212
use rustc_hash::FxHashMap;
1313
use rustc_hash::FxHashSet;
14+
use semver::Version;
1415
use toml_edit::DocumentMut;
1516
use tracing::{debug, trace};
1617

@@ -289,6 +290,9 @@ async fn resolve_bleeding_edge(repo_path: &Path) -> Result<Option<String>> {
289290
}
290291

291292
/// Returns all tags and their Unix timestamps (newest first).
293+
///
294+
/// Within groups of tags sharing the same timestamp, semver-parseable tags
295+
/// are sorted highest version first; non-semver tags sort after them.
292296
async fn get_tag_timestamps(repo: &Path) -> Result<Vec<(String, u64)>> {
293297
let output = git::git_cmd("git for-each-ref")?
294298
.arg("for-each-ref")
@@ -303,7 +307,7 @@ async fn get_tag_timestamps(repo: &Path) -> Result<Vec<(String, u64)>> {
303307
.output()
304308
.await?;
305309

306-
Ok(String::from_utf8_lossy(&output.stdout)
310+
let mut tags: Vec<(String, u64)> = String::from_utf8_lossy(&output.stdout)
307311
.lines()
308312
.filter_map(|line| {
309313
let mut parts = line.split_whitespace();
@@ -312,7 +316,26 @@ async fn get_tag_timestamps(repo: &Path) -> Result<Vec<(String, u64)>> {
312316
let ts: u64 = ts_str.parse().ok()?;
313317
Some((tag.to_string(), ts))
314318
})
315-
.collect())
319+
.collect();
320+
321+
// Deterministic sort: primary key is timestamp (newest first).
322+
// Within equal timestamps, prefer higher semver versions; non-semver tags
323+
// sort after semver ones. As a final tie-breaker, compare the tag refname
324+
// so ordering is stable across platforms/filesystems.
325+
tags.sort_by(|(tag_a, ts_a), (tag_b, ts_b)| {
326+
ts_b.cmp(ts_a).then_with(|| {
327+
let ver_a = Version::parse(tag_a.strip_prefix('v').unwrap_or(tag_a));
328+
let ver_b = Version::parse(tag_b.strip_prefix('v').unwrap_or(tag_b));
329+
match (ver_a, ver_b) {
330+
(Ok(a), Ok(b)) => b.cmp(&a).then_with(|| tag_a.cmp(tag_b)),
331+
(Ok(_), Err(_)) => std::cmp::Ordering::Less,
332+
(Err(_), Ok(_)) => std::cmp::Ordering::Greater,
333+
(Err(_), Err(_)) => tag_a.cmp(tag_b),
334+
}
335+
})
336+
});
337+
338+
Ok(tags)
316339
}
317340

318341
async fn resolve_revision(
@@ -908,4 +931,40 @@ mod tests {
908931

909932
assert_eq!(rev, Some("v1.2.0".to_string()));
910933
}
934+
935+
#[tokio::test]
936+
async fn test_get_tag_timestamps_stable_order_for_equal_timestamps() {
937+
let tmp = setup_test_repo().await;
938+
let repo = tmp.path();
939+
940+
// Create multiple tags on the same commit (same timestamp)
941+
create_backdated_commit(repo, "release", 5).await;
942+
create_lightweight_tag(repo, "v1.0.0").await;
943+
create_lightweight_tag(repo, "v1.0.3").await;
944+
create_lightweight_tag(repo, "v1.0.5").await;
945+
create_lightweight_tag(repo, "v1.0.2").await;
946+
947+
let timestamps = get_tag_timestamps(repo).await.unwrap();
948+
949+
// All timestamps are equal (tags on same commit).
950+
// Within equal timestamps, semver tags should sort highest version first.
951+
let tags: Vec<&str> = timestamps.iter().map(|(t, _)| t.as_str()).collect();
952+
assert_eq!(tags, vec!["v1.0.5", "v1.0.3", "v1.0.2", "v1.0.0"]);
953+
}
954+
955+
#[tokio::test]
956+
async fn test_get_tag_timestamps_deterministic_order_for_equal_timestamp_non_semver() {
957+
let tmp = setup_test_repo().await;
958+
let repo = tmp.path();
959+
960+
// Lightweight tags on the same commit share a timestamp.
961+
create_backdated_commit(repo, "release", 5).await;
962+
create_lightweight_tag(repo, "beta").await;
963+
create_lightweight_tag(repo, "alpha").await;
964+
create_lightweight_tag(repo, "gamma").await;
965+
966+
let timestamps = get_tag_timestamps(repo).await.unwrap();
967+
let tags: Vec<&str> = timestamps.iter().map(|(t, _)| t.as_str()).collect();
968+
assert_eq!(tags, vec!["alpha", "beta", "gamma"]);
969+
}
911970
}

crates/prek/tests/auto_update.rs

Lines changed: 195 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,31 @@ use crate::common::{TestContext, cmd_snapshot, git_cmd};
99

1010
mod common;
1111

12-
/// Helper function to create a local git repository with hooks
12+
const BASE_TIMESTAMP: u64 = 1_000_000_000;
13+
const INCREMENTING_STEP_SECS: u64 = 100;
14+
const FIXED_STEP_SECS: u64 = 0;
15+
16+
/// Helper function to create a local git repository with hooks and incrementing timestamps.
1317
fn create_local_git_repo(context: &TestContext, repo_name: &str, tags: &[&str]) -> Result<String> {
18+
create_local_git_repo_with_timestamps(context, repo_name, tags, INCREMENTING_STEP_SECS)
19+
}
20+
21+
/// Like `create_local_git_repo`, but all commits and tags share a single fixed timestamp.
22+
/// Simulates mirror repos where all tags are imported simultaneously.
23+
fn create_local_git_repo_fixed_ts(
24+
context: &TestContext,
25+
repo_name: &str,
26+
tags: &[&str],
27+
) -> Result<String> {
28+
create_local_git_repo_with_timestamps(context, repo_name, tags, FIXED_STEP_SECS)
29+
}
30+
31+
fn create_local_git_repo_with_timestamps(
32+
context: &TestContext,
33+
repo_name: &str,
34+
tags: &[&str],
35+
timestamp_step_secs: u64,
36+
) -> Result<String> {
1437
let repo_dir = context.home_dir().child(format!("test-repos/{repo_name}"));
1538
repo_dir.create_dir_all()?;
1639

@@ -20,24 +43,6 @@ fn create_local_git_repo(context: &TestContext, repo_name: &str, tags: &[&str])
2043
.arg("init")
2144
.assert()
2245
.success();
23-
git_cmd(&repo_dir)
24-
.arg("config")
25-
.arg("user.name")
26-
.arg("Prek Test")
27-
.assert()
28-
.success();
29-
git_cmd(&repo_dir)
30-
.arg("config")
31-
.arg("user.email")
32-
.arg("test@prek.dev")
33-
.assert()
34-
.success();
35-
git_cmd(&repo_dir)
36-
.arg("config")
37-
.arg("core.autocrlf")
38-
.arg("false")
39-
.assert()
40-
.success();
4146

4247
// Create .pre-commit-hooks.yaml
4348
repo_dir
@@ -55,7 +60,7 @@ fn create_local_git_repo(context: &TestContext, repo_name: &str, tags: &[&str])
5560

5661
git_cmd(&repo_dir).arg("add").arg(".").assert().success();
5762

58-
let mut timestamp = 1_000_000_000;
63+
let mut timestamp = BASE_TIMESTAMP;
5964

6065
git_cmd(&repo_dir)
6166
.arg("commit")
@@ -68,7 +73,7 @@ fn create_local_git_repo(context: &TestContext, repo_name: &str, tags: &[&str])
6873

6974
// Create tags
7075
for tag in tags {
71-
timestamp += 100;
76+
timestamp += timestamp_step_secs;
7277
git_cmd(&repo_dir)
7378
.arg("commit")
7479
.arg("-m")
@@ -89,7 +94,7 @@ fn create_local_git_repo(context: &TestContext, repo_name: &str, tags: &[&str])
8994
.success();
9095
}
9196

92-
timestamp += 100;
97+
timestamp += timestamp_step_secs;
9398
// Add an extra commit to the tip
9499
git_cmd(&repo_dir)
95100
.arg("commit")
@@ -1330,6 +1335,174 @@ fn auto_update_freeze_toml() -> Result<()> {
13301335
Ok(())
13311336
}
13321337

1338+
#[test]
1339+
fn auto_update_equal_timestamp_tags_picks_highest_version() -> Result<()> {
1340+
let context = TestContext::new();
1341+
context.init_project();
1342+
1343+
let repo_path = create_local_git_repo_fixed_ts(
1344+
&context,
1345+
"mirror-repo",
1346+
&["v1.0.0", "v1.0.1", "v1.0.2", "v1.0.3", "v1.0.4", "v1.0.5"],
1347+
)?;
1348+
1349+
context.write_pre_commit_config(&indoc::formatdoc! {r"
1350+
repos:
1351+
- repo: {}
1352+
rev: v1.0.3
1353+
hooks:
1354+
- id: test-hook
1355+
", repo_path});
1356+
1357+
context.git_add(".");
1358+
1359+
let filters = context.filters();
1360+
cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#"
1361+
success: true
1362+
exit_code: 0
1363+
----- stdout -----
1364+
[[HOME]/test-repos/mirror-repo] updating v1.0.3 -> v1.0.5
1365+
1366+
----- stderr -----
1367+
"#);
1368+
1369+
insta::with_settings!(
1370+
{ filters => filters.clone() },
1371+
{
1372+
assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#"
1373+
repos:
1374+
- repo: [HOME]/test-repos/mirror-repo
1375+
rev: v1.0.5
1376+
hooks:
1377+
- id: test-hook
1378+
"#);
1379+
}
1380+
);
1381+
1382+
Ok(())
1383+
}
1384+
1385+
// When all tags share a timestamp and some are non-semver (e.g. "latest", "stable"),
1386+
// semver tags should be preferred and sorted highest-first.
1387+
#[test]
1388+
fn auto_update_equal_timestamp_prefers_semver_over_nonsemver() -> Result<()> {
1389+
let context = TestContext::new();
1390+
context.init_project();
1391+
1392+
let repo_path = create_local_git_repo_fixed_ts(
1393+
&context,
1394+
"mixed-tags-repo",
1395+
&["v1.0.0", "latest", "v2.0.0", "stable"],
1396+
)?;
1397+
1398+
context.write_pre_commit_config(&indoc::formatdoc! {r"
1399+
repos:
1400+
- repo: {}
1401+
rev: v1.0.0
1402+
hooks:
1403+
- id: test-hook
1404+
", repo_path});
1405+
1406+
context.git_add(".");
1407+
1408+
let filters = context.filters();
1409+
1410+
cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#"
1411+
success: true
1412+
exit_code: 0
1413+
----- stdout -----
1414+
[[HOME]/test-repos/mixed-tags-repo] updating v1.0.0 -> v2.0.0
1415+
1416+
----- stderr -----
1417+
"#);
1418+
1419+
insta::with_settings!(
1420+
{ filters => filters.clone() },
1421+
{
1422+
assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#"
1423+
repos:
1424+
- repo: [HOME]/test-repos/mixed-tags-repo
1425+
rev: v2.0.0
1426+
hooks:
1427+
- id: test-hook
1428+
"#);
1429+
}
1430+
);
1431+
1432+
Ok(())
1433+
}
1434+
1435+
// When tags span multiple timestamp groups, the newest group should be selected first.
1436+
// Within an equal-timestamp group, semver tiebreaker picks the highest version.
1437+
#[test]
1438+
fn auto_update_mixed_timestamps_with_equal_subgroups() -> Result<()> {
1439+
let context = TestContext::new();
1440+
context.init_project();
1441+
1442+
// Create base repo with v1.0.x tags at incrementing timestamps.
1443+
let repo_path = create_local_git_repo(&context, "mixed-ts-repo", &["v1.0.0", "v1.0.1"])?;
1444+
1445+
// Add a second group of tags sharing a single newer timestamp
1446+
// (must be in the past so the cooldown filter doesn't exclude them).
1447+
let newer_ts = "1500000000 +0000";
1448+
for tag in &["v2.0.1", "v2.0.0"] {
1449+
git_cmd(&repo_path)
1450+
.arg("commit")
1451+
.arg("-m")
1452+
.arg(format!("Release {tag}"))
1453+
.arg("--allow-empty")
1454+
.env("GIT_AUTHOR_DATE", newer_ts)
1455+
.env("GIT_COMMITTER_DATE", newer_ts)
1456+
.assert()
1457+
.success();
1458+
git_cmd(&repo_path)
1459+
.arg("tag")
1460+
.arg(tag)
1461+
.arg("-m")
1462+
.arg(tag)
1463+
.env("GIT_AUTHOR_DATE", newer_ts)
1464+
.env("GIT_COMMITTER_DATE", newer_ts)
1465+
.assert()
1466+
.success();
1467+
}
1468+
1469+
context.write_pre_commit_config(&indoc::formatdoc! {r"
1470+
repos:
1471+
- repo: {}
1472+
rev: v1.0.0
1473+
hooks:
1474+
- id: test-hook
1475+
", repo_path});
1476+
1477+
context.git_add(".");
1478+
1479+
let filters = context.filters();
1480+
1481+
cmd_snapshot!(filters.clone(), context.auto_update().arg("--cooldown-days").arg("0"), @r#"
1482+
success: true
1483+
exit_code: 0
1484+
----- stdout -----
1485+
[[HOME]/test-repos/mixed-ts-repo] updating v1.0.0 -> v2.0.1
1486+
1487+
----- stderr -----
1488+
"#);
1489+
1490+
insta::with_settings!(
1491+
{ filters => filters.clone() },
1492+
{
1493+
assert_snapshot!(context.read(PRE_COMMIT_CONFIG_YAML), @r#"
1494+
repos:
1495+
- repo: [HOME]/test-repos/mixed-ts-repo
1496+
rev: v2.0.1
1497+
hooks:
1498+
- id: test-hook
1499+
"#);
1500+
}
1501+
);
1502+
1503+
Ok(())
1504+
}
1505+
13331506
#[test]
13341507
fn auto_update_freeze_toml_with_comment() -> Result<()> {
13351508
let context = TestContext::new();

0 commit comments

Comments
 (0)