use std::borrow::Cow;
use std::cmp::Ordering;
use std::path::Path;
use std::process::Stdio;
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use itertools::Itertools;
use prek_consts::PRE_COMMIT_HOOKS_YAML;
use prek_consts::env_vars::EnvVars;
use rustc_hash::FxHashSet;
use semver::Version;
use tracing::{debug, trace};
use crate::cli::update::{CommitPresence, RevisionSelection, SkippedDowngrade, TagTimestamp};
use crate::git::GitCommandExt;
use crate::{config, git};
pub(super) async fn setup_and_fetch_repo(repo_url: &str, repo_path: &Path) -> Result<()> {
git::init_repo(repo_url, repo_path).await?;
git::git_cmd()?
.arg("fetch")
.arg("origin")
.arg("HEAD")
.arg("--quiet")
.arg("--filter=blob:none")
.arg("--tags")
.current_dir(repo_path)
.sanitize_git_repo_env()
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await?;
Ok(())
}
pub(super) async fn resolve_revision_to_commit(repo_path: &Path, rev: &str) -> Result<String> {
let output = git::git_cmd()?
.arg("rev-parse")
.arg(format!("{rev}^{{}}"))
.check(true)
.current_dir(repo_path)
.sanitize_git_repo_env()
.output()
.await?;
Ok(str::from_utf8(&output.stdout)?.trim().to_string())
}
pub(super) async fn is_commit_present(repo_path: &Path, commit: &str) -> Result<CommitPresence> {
static GIT_SUPPORTS_NO_LAZY_FETCH: OnceLock<bool> = OnceLock::new();
if matches!(GIT_SUPPORTS_NO_LAZY_FETCH.get(), Some(false)) {
return Ok(CommitPresence::Unknown);
}
let output = git::git_cmd()?
.arg("--no-lazy-fetch")
.arg("cat-file")
.arg("-e")
.arg(format!("{commit}^{{commit}}"))
.env(EnvVars::LC_ALL, "C")
.check(false)
.current_dir(repo_path)
.sanitize_git_repo_env()
.stdout(Stdio::null())
.output()
.await?;
if output.status.success() {
let _ = GIT_SUPPORTS_NO_LAZY_FETCH.set(true);
return Ok(CommitPresence::Present);
}
if no_lazy_fetch_unsupported(&output.stderr) {
let _ = GIT_SUPPORTS_NO_LAZY_FETCH.set(false);
return Ok(CommitPresence::Unknown);
}
let _ = GIT_SUPPORTS_NO_LAZY_FETCH.set(true);
Ok(CommitPresence::Absent)
}
pub(super) fn no_lazy_fetch_unsupported(stderr: &[u8]) -> bool {
let stderr = String::from_utf8_lossy(stderr);
stderr.contains("--no-lazy-fetch") && stderr.contains("unknown option")
}
pub(super) fn get_tags_pointing_at_revision<'a>(
tag_timestamps: &'a [TagTimestamp],
rev: &str,
) -> Vec<&'a str> {
tag_timestamps
.iter()
.filter(|tag_timestamp| tag_timestamp.commit.eq_ignore_ascii_case(rev))
.map(|tag_timestamp| tag_timestamp.tag.as_str())
.collect()
}
pub(super) async fn resolve_bleeding_edge(repo_path: &Path) -> Result<Option<String>> {
let output = git::git_cmd()?
.arg("describe")
.arg("FETCH_HEAD")
.arg("--tags")
.arg("--exact-match")
.check(false)
.current_dir(repo_path)
.sanitize_git_repo_env()
.output()
.await?;
let rev = if output.status.success() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
debug!("No matching tag for `FETCH_HEAD`, using rev-parse instead");
let output = git::git_cmd()?
.arg("rev-parse")
.arg("FETCH_HEAD")
.check(true)
.current_dir(repo_path)
.sanitize_git_repo_env()
.output()
.await?;
str::from_utf8(&output.stdout)?.trim().to_string()
};
debug!("Resolved `FETCH_HEAD` to `{rev}`");
Ok(Some(rev))
}
pub(super) async fn list_tag_metadata(repo: &Path) -> Result<Vec<TagTimestamp>> {
let output = git::git_cmd()?
.arg("for-each-ref")
.arg("--sort=-creatordate")
.arg("--format=%(refname:lstrip=2)\t%(creatordate:unix)\t%(objectname)\t%(*objectname)")
.arg("refs/tags")
.check(true)
.current_dir(repo)
.sanitize_git_repo_env()
.output()
.await?;
let mut tags: Vec<TagTimestamp> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let mut parts = line.split('\t');
let tag = parts.next()?.trim_ascii();
let ts_str = parts.next()?.trim_ascii();
let object = parts.next()?.trim_ascii();
let peeled = parts.next().unwrap_or_default().trim_ascii();
let ts: u64 = ts_str.parse().ok()?;
let commit = if peeled.is_empty() { object } else { peeled };
Some(TagTimestamp::new(tag.to_string(), ts, commit.to_string()))
})
.collect();
tags.sort_by(compare_tag_metadata);
Ok(tags)
}
fn compare_tag_metadata(tag_a: &TagTimestamp, tag_b: &TagTimestamp) -> Ordering {
tag_b
.timestamp
.cmp(&tag_a.timestamp)
.then_with(|| match (&tag_a.version, &tag_b.version) {
(Some(a), Some(b)) => b.cmp(a).then_with(|| tag_a.tag.cmp(&tag_b.tag)),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => tag_a.tag.cmp(&tag_b.tag),
})
}
async fn current_tag_metadata<'a>(
repo_path: &Path,
current_rev: &str,
tag_timestamps: &'a [TagTimestamp],
) -> Option<&'a TagTimestamp> {
if let Some(tag) = tag_timestamps.iter().find(|tag| tag.tag == current_rev) {
return Some(tag);
}
let current_commit = if current_rev.len() == 40 && config::looks_like_sha(current_rev) {
Cow::Borrowed(current_rev)
} else {
Cow::Owned(
resolve_revision_to_commit(repo_path, current_rev)
.await
.ok()?,
)
};
tag_timestamps
.iter()
.filter(|tag| tag.commit.eq_ignore_ascii_case(current_commit.as_ref()))
.min_by(|tag_a, tag_b| compare_tag_metadata(tag_a, tag_b))
}
pub(super) async fn select_update_revision(
repo_path: &Path,
current_rev: &str,
bleeding_edge: bool,
cooldown_days: u8,
tag_timestamps: &[TagTimestamp],
update_tag_timestamps: &[TagTimestamp],
) -> Result<RevisionSelection> {
if bleeding_edge {
return Ok(match resolve_bleeding_edge(repo_path).await? {
Some(rev) => RevisionSelection::Update(rev),
None => RevisionSelection::Unchanged,
});
}
let cutoff_secs = u64::from(cooldown_days) * 86400;
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let cutoff = now.saturating_sub(cutoff_secs);
let left =
match update_tag_timestamps.binary_search_by(|tag| tag.timestamp.cmp(&cutoff).reverse()) {
Ok(i) | Err(i) => i,
};
let Some(target_tag) = update_tag_timestamps.get(left) else {
trace!("No tags meet cooldown cutoff {cutoff_secs}s");
return Ok(RevisionSelection::Unchanged);
};
debug!(
"Using tag `{}` cutoff timestamp {}",
target_tag.tag, target_tag.timestamp
);
if cooldown_days > 0
&& let Some(current_tag) =
current_tag_metadata(repo_path, current_rev, tag_timestamps).await
&& !current_tag.commit.eq_ignore_ascii_case(&target_tag.commit)
&& compare_tag_metadata(current_tag, target_tag).is_lt()
{
debug!(
"Skipping candidate tag `{}` because current tag `{}` sorts newer",
target_tag.tag, current_tag.tag
);
return Ok(RevisionSelection::SkippedDowngrade(SkippedDowngrade {
current: current_rev.to_string(),
candidate: target_tag.tag.clone(),
cooldown_days,
}));
}
let tags = get_tags_pointing_at_revision(update_tag_timestamps, &target_tag.commit);
let best = select_best_tag(&tags, current_rev, false)
.unwrap_or(target_tag.tag.as_str())
.to_string();
debug!(
"Using best candidate tag `{best}` for revision `{}`",
target_tag.tag
);
Ok(RevisionSelection::Update(best))
}
fn compare_tag_versions_desc(tag_a: &str, tag_b: &str) -> std::cmp::Ordering {
let version_a = Version::parse(tag_a.strip_prefix('v').unwrap_or(tag_a));
let version_b = Version::parse(tag_b.strip_prefix('v').unwrap_or(tag_b));
match (version_a, version_b) {
(Ok(a), Ok(b)) => b.cmp(&a),
(Ok(_), Err(_)) => std::cmp::Ordering::Less,
(Err(_), Ok(_)) => std::cmp::Ordering::Greater,
(Err(_), Err(_)) => std::cmp::Ordering::Equal,
}
}
pub(super) fn select_best_tag<'a>(
tags: &[&'a str],
current_ref: &str,
allow_non_version_like: bool,
) -> Option<&'a str> {
let has_version_like = tags.iter().any(|tag| tag.contains('.'));
let candidates = if has_version_like {
tags.iter()
.filter(|tag| tag.contains('.'))
.copied()
.collect::<Vec<_>>()
} else if allow_non_version_like {
tags.to_vec()
} else {
return None;
};
candidates
.into_iter()
.map(|tag| (levenshtein_distance(tag, current_ref), tag))
.min_by(|(distance_a, tag_a), (distance_b, tag_b)| {
distance_a
.cmp(distance_b)
.then_with(|| compare_tag_versions_desc(tag_a, tag_b))
.then_with(|| tag_a.cmp(tag_b))
})
.map(|(_, tag)| tag)
}
fn levenshtein_distance(a: &str, b: &str) -> usize {
if a == b {
return 0;
}
let a_len = a.chars().count();
let b_len = b.chars().count();
if a_len == 0 {
return b_len;
}
if b_len == 0 {
return a_len;
}
let mut row = (0..=a_len).collect::<Vec<_>>();
for (b_index, b_char) in b.chars().enumerate() {
let mut previous = row[0];
row[0] = b_index + 1;
for (a_index, a_char) in a.chars().enumerate() {
let deletion = row[a_index + 1] + 1;
let insertion = row[a_index] + 1;
let substitution = previous + usize::from(a_char != b_char);
previous = row[a_index + 1];
row[a_index + 1] = deletion.min(insertion).min(substitution);
}
}
row[a_len]
}
pub(super) async fn checkout_and_validate_manifest(
repo_path: &Path,
rev: &str,
required_hook_ids: &[&str],
) -> Result<()> {
if cfg!(windows) {
git::git_cmd()?
.arg("show")
.arg(format!("{rev}:{PRE_COMMIT_HOOKS_YAML}"))
.current_dir(repo_path)
.sanitize_git_repo_env()
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await?;
}
git::git_cmd()?
.arg("checkout")
.arg("--quiet")
.arg(rev)
.arg("--")
.arg(PRE_COMMIT_HOOKS_YAML)
.current_dir(repo_path)
.sanitize_git_repo_env()
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await?;
let manifest = config::read_manifest(&repo_path.join(PRE_COMMIT_HOOKS_YAML))?;
let new_hook_ids = manifest
.hooks
.into_iter()
.map(|h| h.id)
.collect::<FxHashSet<_>>();
let hooks_missing = required_hook_ids
.iter()
.filter(|hook_id| !new_hook_ids.contains(**hook_id))
.collect::<Vec<_>>();
if !hooks_missing.is_empty() {
anyhow::bail!(
"Cannot update to rev `{}`, hook{} {} missing: {}",
rev,
if hooks_missing.len() > 1 { "s" } else { "" },
if hooks_missing.len() > 1 { "are" } else { "is" },
hooks_missing.into_iter().join(", ")
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
levenshtein_distance, list_tag_metadata, no_lazy_fetch_unsupported, resolve_bleeding_edge,
select_best_tag, select_update_revision,
};
use crate::cli::update::{RevisionSelection, SkippedDowngrade};
use crate::git;
use crate::git::GitCommandExt;
use crate::process::Cmd;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
async fn setup_test_repo() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
git::git_cmd()
.unwrap()
.arg("init")
.current_dir(repo)
.sanitize_git_repo_env()
.output()
.await
.unwrap();
git::git_cmd()
.unwrap()
.args(["config", "user.email", "test@test.com"])
.current_dir(repo)
.sanitize_git_repo_env()
.output()
.await
.unwrap();
git::git_cmd()
.unwrap()
.args(["config", "user.name", "Test"])
.current_dir(repo)
.sanitize_git_repo_env()
.output()
.await
.unwrap();
git::git_cmd()
.unwrap()
.args([
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"initial",
])
.current_dir(repo)
.sanitize_git_repo_env()
.output()
.await
.unwrap();
git::git_cmd()
.unwrap()
.args(["branch", "-M", "trunk"])
.current_dir(repo)
.sanitize_git_repo_env()
.output()
.await
.unwrap();
tmp
}
fn git_cmd(dir: impl AsRef<Path>) -> Cmd {
let mut cmd = git::git_cmd().unwrap();
cmd.current_dir(dir)
.args(["-c", "commit.gpgsign=false"])
.args(["-c", "tag.gpgsign=false"]);
cmd
}
#[test]
fn test_levenshtein_distance() {
assert_eq!(levenshtein_distance("", ""), 0);
assert_eq!(levenshtein_distance("", "v1.0.0"), 6);
assert_eq!(levenshtein_distance("v1.0.0", "v1.1.0"), 1);
assert_eq!(levenshtein_distance("v1.0.0", "foo-v1.1.0"), 5);
assert_eq!(levenshtein_distance("v1.0.0", "v1.0.0"), 0);
assert_eq!(levenshtein_distance("mañana", "manana"), 1);
}
#[test]
fn test_select_best_tag_prefers_closest_tag() {
let tags = ["v1.1.0", "foo-v1.1.0", "v1"];
assert_eq!(select_best_tag(&tags, "v1.0.0", false), Some("v1.1.0"));
}
async fn create_commit(repo: &Path, message: &str) {
git_cmd(repo)
.args(["commit", "--allow-empty", "-m", message])
.sanitize_git_repo_env()
.output()
.await
.unwrap();
}
async fn create_backdated_commit(repo: &Path, message: &str, days_ago: u64) {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
- (days_ago * 86400);
let date_str = format!("{timestamp} +0000");
git_cmd(repo)
.args(["commit", "--allow-empty", "-m", message])
.env("GIT_AUTHOR_DATE", &date_str)
.env("GIT_COMMITTER_DATE", &date_str)
.sanitize_git_repo_env()
.output()
.await
.unwrap();
}
async fn create_lightweight_tag(repo: &Path, tag: &str) {
git_cmd(repo)
.arg("tag")
.arg(tag)
.sanitize_git_repo_env()
.output()
.await
.unwrap();
}
async fn create_annotated_tag(repo: &Path, tag: &str, days_ago: u64) {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
- (days_ago * 86400);
let date_str = format!("{timestamp} +0000");
git_cmd(repo)
.arg("tag")
.arg(tag)
.arg("-m")
.arg(tag)
.env("GIT_AUTHOR_DATE", &date_str)
.env("GIT_COMMITTER_DATE", &date_str)
.sanitize_git_repo_env()
.output()
.await
.unwrap();
}
#[tokio::test]
async fn test_list_tag_metadata() {
let tmp = setup_test_repo().await;
let repo = tmp.path();
create_backdated_commit(repo, "old", 5).await;
create_lightweight_tag(repo, "v0.1.0").await;
create_backdated_commit(repo, "new", 2).await;
create_lightweight_tag(repo, "v0.2.0").await;
create_annotated_tag(repo, "alias-v0.2.0", 0).await;
let timestamps = list_tag_metadata(repo).await.unwrap();
assert_eq!(timestamps.len(), 3);
assert_eq!(timestamps[0].tag, "alias-v0.2.0");
assert_eq!(timestamps[1].tag, "v0.2.0");
assert_eq!(timestamps[2].tag, "v0.1.0");
assert_eq!(timestamps[0].commit, timestamps[1].commit);
}
#[tokio::test]
async fn test_resolve_bleeding_edge_prefers_exact_tag() {
let tmp = setup_test_repo().await;
let repo = tmp.path();
create_commit(repo, "tagged").await;
create_lightweight_tag(repo, "v1.2.3").await;
git::git_cmd()
.unwrap()
.args(["fetch", ".", "HEAD"])
.current_dir(repo)
.sanitize_git_repo_env()
.output()
.await
.unwrap();
let rev = resolve_bleeding_edge(repo).await.unwrap();
assert_eq!(rev, Some("v1.2.3".to_string()));
}
#[tokio::test]
async fn test_resolve_bleeding_edge_falls_back_to_rev_parse() {
let tmp = setup_test_repo().await;
let repo = tmp.path();
create_commit(repo, "untagged").await;
git::git_cmd()
.unwrap()
.args(["fetch", ".", "HEAD"])
.current_dir(repo)
.sanitize_git_repo_env()
.output()
.await
.unwrap();
let rev = resolve_bleeding_edge(repo).await.unwrap();
let head = git::git_cmd()
.unwrap()
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.sanitize_git_repo_env()
.output()
.await
.unwrap()
.stdout;
let head = String::from_utf8_lossy(&head).trim().to_string();
assert_eq!(rev, Some(head));
}
#[tokio::test]
async fn test_select_update_revision_uses_cooldown_bucket() {
let tmp = setup_test_repo().await;
let repo = tmp.path();
create_backdated_commit(repo, "current", 10).await;
create_lightweight_tag(repo, "v1.0.0").await;
create_backdated_commit(repo, "candidate", 5).await;
create_lightweight_tag(repo, "v2.0.0-rc1").await;
create_lightweight_tag(repo, "totally-different").await;
create_backdated_commit(repo, "latest", 1).await;
create_lightweight_tag(repo, "v2.0.0").await;
let tag_timestamps = list_tag_metadata(repo).await.unwrap();
let rev =
select_update_revision(repo, "v1.0.0", false, 3, &tag_timestamps, &tag_timestamps)
.await
.unwrap();
assert_eq!(rev, RevisionSelection::Update("v2.0.0-rc1".to_string()));
}
#[tokio::test]
async fn test_select_update_revision_skips_cooldown_downgrade() {
let tmp = setup_test_repo().await;
let repo = tmp.path();
create_backdated_commit(repo, "candidate", 5).await;
create_lightweight_tag(repo, "v2.0.0-rc1").await;
create_backdated_commit(repo, "current", 1).await;
create_lightweight_tag(repo, "v2.0.0").await;
let tag_timestamps = list_tag_metadata(repo).await.unwrap();
let rev =
select_update_revision(repo, "v2.0.0", false, 3, &tag_timestamps, &tag_timestamps)
.await
.unwrap();
assert_eq!(
rev,
RevisionSelection::SkippedDowngrade(SkippedDowngrade {
current: "v2.0.0".to_string(),
candidate: "v2.0.0-rc1".to_string(),
cooldown_days: 3
})
);
}
#[tokio::test]
async fn test_select_update_revision_returns_none_when_all_tags_too_new() {
let tmp = setup_test_repo().await;
let repo = tmp.path();
create_backdated_commit(repo, "recent-1", 2).await;
create_lightweight_tag(repo, "v1.0.0").await;
create_backdated_commit(repo, "recent-2", 1).await;
create_lightweight_tag(repo, "v1.1.0").await;
let tag_timestamps = list_tag_metadata(repo).await.unwrap();
let rev =
select_update_revision(repo, "v1.1.0", false, 5, &tag_timestamps, &tag_timestamps)
.await
.unwrap();
assert_eq!(rev, RevisionSelection::Unchanged);
}
#[tokio::test]
async fn test_select_update_revision_skips_older_eligible_bucket() {
let tmp = setup_test_repo().await;
let repo = tmp.path();
create_backdated_commit(repo, "oldest", 10).await;
create_lightweight_tag(repo, "v1.0.0").await;
create_backdated_commit(repo, "mid", 4).await;
create_lightweight_tag(repo, "v1.1.0").await;
create_backdated_commit(repo, "newest", 1).await;
create_lightweight_tag(repo, "v1.2.0").await;
let tag_timestamps = list_tag_metadata(repo).await.unwrap();
let rev =
select_update_revision(repo, "v1.2.0", false, 5, &tag_timestamps, &tag_timestamps)
.await
.unwrap();
assert_eq!(
rev,
RevisionSelection::SkippedDowngrade(SkippedDowngrade {
current: "v1.2.0".to_string(),
candidate: "v1.0.0".to_string(),
cooldown_days: 5
})
);
}
#[tokio::test]
async fn test_select_update_revision_prefers_version_like_tags() {
let tmp = setup_test_repo().await;
let repo = tmp.path();
create_backdated_commit(repo, "eligible", 2).await;
create_lightweight_tag(repo, "moving-tag").await;
create_lightweight_tag(repo, "v1.0.0").await;
let tag_timestamps = list_tag_metadata(repo).await.unwrap();
let rev = select_update_revision(
repo,
"moving-tag",
false,
1,
&tag_timestamps,
&tag_timestamps,
)
.await
.unwrap();
assert_eq!(rev, RevisionSelection::Update("v1.0.0".to_string()));
}
#[tokio::test]
async fn test_select_update_revision_picks_closest_version_string() {
let tmp = setup_test_repo().await;
let repo = tmp.path();
create_backdated_commit(repo, "eligible", 3).await;
create_lightweight_tag(repo, "v1.2.0").await;
create_lightweight_tag(repo, "foo-1.2.0").await;
create_lightweight_tag(repo, "v2.0.0").await;
let tag_timestamps = list_tag_metadata(repo).await.unwrap();
let rev =
select_update_revision(repo, "v1.2.3", false, 1, &tag_timestamps, &tag_timestamps)
.await
.unwrap();
assert_eq!(rev, RevisionSelection::Update("v1.2.0".to_string()));
}
#[tokio::test]
async fn test_list_tag_metadata_stable_order_for_equal_timestamps() {
let tmp = setup_test_repo().await;
let repo = tmp.path();
create_backdated_commit(repo, "release", 5).await;
create_lightweight_tag(repo, "v1.0.0").await;
create_lightweight_tag(repo, "v1.0.3").await;
create_lightweight_tag(repo, "v1.0.5").await;
create_lightweight_tag(repo, "v1.0.2").await;
let timestamps = list_tag_metadata(repo).await.unwrap();
let tags: Vec<&str> = timestamps.iter().map(|tag| tag.tag.as_str()).collect();
assert_eq!(tags, vec!["v1.0.5", "v1.0.3", "v1.0.2", "v1.0.0"]);
}
#[tokio::test]
async fn test_list_tag_metadata_deterministic_order_for_equal_timestamp_non_semver() {
let tmp = setup_test_repo().await;
let repo = tmp.path();
create_backdated_commit(repo, "release", 5).await;
create_lightweight_tag(repo, "beta").await;
create_lightweight_tag(repo, "alpha").await;
create_lightweight_tag(repo, "gamma").await;
let timestamps = list_tag_metadata(repo).await.unwrap();
let tags: Vec<&str> = timestamps.iter().map(|tag| tag.tag.as_str()).collect();
assert_eq!(tags, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn test_no_lazy_fetch_unsupported() {
assert!(no_lazy_fetch_unsupported(
b"unknown option: --no-lazy-fetch\n"
));
assert!(!no_lazy_fetch_unsupported(
b"fatal: Not a valid object name 1234567890abcdef1234567890abcdef12345678^{commit}\n"
));
}
}