Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 65 additions & 22 deletions crates/prek/src/cli/auto_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ async fn update_repo(
Ok(Revision { rev, frozen })
}

async fn setup_and_fetch_repo(repo_url: &str, repo_path: &Path) -> Result<()> {
pub(crate) async fn setup_and_fetch_repo(repo_url: &str, repo_path: &Path) -> Result<()> {
git::init_repo(repo_url, repo_path).await?;
git::git_cmd("git config")?
.arg("config")
Expand Down Expand Up @@ -288,7 +288,7 @@ async fn resolve_bleeding_edge(repo_path: &Path) -> Result<Option<String>> {
}

/// Returns all tags and their Unix timestamps (newest first).
async fn get_tag_timestamps(repo: &Path) -> Result<Vec<(String, u64)>> {
pub(crate) async fn get_tag_timestamps(repo: &Path) -> Result<Vec<(String, u64)>> {
let output = git::git_cmd("git for-each-ref")?
.arg("for-each-ref")
.arg("--sort=-creatordate")
Expand All @@ -314,18 +314,19 @@ async fn get_tag_timestamps(repo: &Path) -> Result<Vec<(String, u64)>> {
.collect())
}

async fn resolve_revision(
/// Find the best tag that meets the cooldown requirement.
///
/// Given a list of tags sorted newest-to-oldest with timestamps, finds the first tag
/// that is at least `cooldown_days` old, then picks the best version-like tag pointing
/// to the same commit.
///
/// Returns `None` if no tags meet the cooldown requirement.
pub(crate) async fn find_eligible_tag(
repo_path: &Path,
tags_with_ts: &[(String, u64)],
current_rev: &str,
bleeding_edge: bool,
cooldown_days: u8,
) -> Result<Option<String>> {
if bleeding_edge {
return resolve_bleeding_edge(repo_path).await;
}

let tags_with_ts = get_tag_timestamps(repo_path).await?;

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);
Expand All @@ -335,35 +336,73 @@ async fn resolve_revision(
Ok(i) | Err(i) => i,
};

let Some((target_tag, target_ts)) = tags_with_ts.get(left) else {
trace!("No tags meet cooldown cutoff {cutoff_secs}s");
let Some((target_tag, _)) = tags_with_ts.get(left) else {
return Ok(None);
};

debug!("Using tag `{target_tag}` cutoff timestamp {target_ts}");

let best = get_best_candidate_tag(repo_path, target_tag, current_rev)
.await
.unwrap_or_else(|_| target_tag.clone());
debug!("Using best candidate tag `{best}` for revision `{target_tag}`");

Ok(Some(best))
}

async fn freeze_revision(repo_path: &Path, rev: &str) -> Result<Option<String>> {
let exact = git::git_cmd("git rev-parse")?
async fn resolve_revision(
repo_path: &Path,
current_rev: &str,
bleeding_edge: bool,
cooldown_days: u8,
) -> Result<Option<String>> {
if bleeding_edge {
return resolve_bleeding_edge(repo_path).await;
}

let tags_with_ts = get_tag_timestamps(repo_path).await?;

let best = find_eligible_tag(repo_path, &tags_with_ts, current_rev, cooldown_days).await?;

if let Some(ref tag) = best {
debug!("Using best candidate tag `{tag}`");
} else {
trace!("No tags meet cooldown cutoff");
}

Ok(best)
}

/// Resolve a revision (tag, branch, etc.) to its dereferenced commit hash.
///
/// Returns `None` if the revision cannot be resolved.
pub(crate) async fn resolve_rev_to_commit_hash(
repo_path: &Path,
rev: &str,
) -> Result<Option<String>> {
let output = git::git_cmd("git rev-parse")?
.arg("rev-parse")
.arg(format!("{rev}^{{}}"))
.check(false)
.current_dir(repo_path)
.remove_git_envs()
.output()
.await?
.stdout;
let exact = str::from_utf8(&exact)?.trim();
.await?;

if output.status.success() {
Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_string(),
))
} else {
Ok(None)
}
}

async fn freeze_revision(repo_path: &Path, rev: &str) -> Result<Option<String>> {
let Some(exact) = resolve_rev_to_commit_hash(repo_path, rev).await? else {
return Ok(None);
};
if rev == exact {
Ok(None)
} else {
Ok(Some(exact.to_string()))
Ok(Some(exact))
}
}

Expand Down Expand Up @@ -427,7 +466,11 @@ async fn checkout_and_validate_manifest(
/// Multiple tags can exist on an SHA. Sometimes a moving tag is attached
/// to a version tag. Try to pick the tag that looks like a version and most similar
/// to the current revision.
async fn get_best_candidate_tag(repo: &Path, rev: &str, current_rev: &str) -> Result<String> {
pub(crate) async fn get_best_candidate_tag(
repo: &Path,
rev: &str,
current_rev: &str,
) -> Result<String> {
let stdout = git::git_cmd("git tag")?
.arg("tag")
.arg("--points-at")
Expand Down
2 changes: 1 addition & 1 deletion crates/prek/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use prek_consts::env_vars::EnvVars;

use crate::config::{HookType, Language, Stage};

mod auto_update;
pub(crate) mod auto_update;
mod cache_clean;
mod cache_gc;
mod cache_size;
Expand Down
199 changes: 199 additions & 0 deletions crates/prek/src/hooks/builtin_hooks/check_hook_updates.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
use std::io::Write;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::Result;
use clap::Parser;
use tracing::debug;

use crate::cli::auto_update::{
find_eligible_tag, get_tag_timestamps, resolve_rev_to_commit_hash, setup_and_fetch_repo,
};
use crate::config;
use crate::hook::Hook;
use crate::store::{CacheBucket, Store};

#[derive(Parser)]
#[command(disable_help_subcommand = true)]
#[command(disable_version_flag = true)]
#[command(disable_help_flag = true)]
struct Args {
/// Minimum release age (in days) required for a version to be eligible.
/// A value of `0` disables this check.
#[arg(long, value_name = "DAYS", default_value_t = 7)]
cooldown_days: u8,

/// Fail the hook if updates are available (default: warn only).
#[arg(long, default_value_t = false)]
fail_on_updates: bool,

/// Minimum hours between checks (default: 24). Set to 0 to check every time.
#[arg(long, value_name = "HOURS", default_value_t = 24)]
check_interval_hours: u64,
}

const LAST_CHECK_FILE: &str = "hook-updates-last-check";

/// Check if configured hooks have newer versions available.
pub(crate) async fn check_hook_updates(
hook: &Hook,
_filenames: &[&Path],
) -> Result<(i32, Vec<u8>)> {
let args = Args::try_parse_from(hook.entry.resolve(None)?.iter().chain(&hook.args))?;

// Check if we should skip based on check interval
if args.check_interval_hours > 0 {
if let Ok(store) = Store::from_settings() {
if should_skip_check(&store, args.check_interval_hours) {
debug!(
"Skipping hook update check (last check was within {} hours)",
args.check_interval_hours
);
return Ok((0, Vec::new()));
}
}
}

let project_config = hook.project().config();

let mut code = 0;
let mut output = Vec::new();

for repo in &project_config.repos {
if let config::Repo::Remote(remote_repo) = repo {
match check_repo_for_updates(remote_repo, args.cooldown_days).await {
Ok(Some(update_info)) => {
writeln!(
&mut output,
"{}: {} -> {} available",
remote_repo.repo, remote_repo.rev, update_info.new_rev
)?;
if args.fail_on_updates {
code = 1;
}
}
Ok(None) => {
// No update available or already up to date
}
Err(e) => {
writeln!(
&mut output,
"{}: failed to check for updates: {}",
remote_repo.repo, e
)?;
// Don't fail on network errors, just warn
}
}
}
}

// Mark check as complete (only if we actually ran the check)
if args.check_interval_hours > 0 {
if let Ok(store) = Store::from_settings() {
mark_check_complete(&store);
}
}

Ok((code, output))
}

/// Check if we should skip the update check based on the last check time.
fn should_skip_check(store: &Store, interval_hours: u64) -> bool {
let cache_file = store.cache_path(CacheBucket::Prek).join(LAST_CHECK_FILE);

let Ok(metadata) = std::fs::metadata(&cache_file) else {
return false;
};

let Ok(modified) = metadata.modified() else {
return false;
};

let Ok(age) = SystemTime::now().duration_since(modified) else {
return false;
};

let interval_secs = interval_hours * 3600;
age.as_secs() < interval_secs
}

/// Mark the check as complete by touching the cache file.
fn mark_check_complete(store: &Store) {
let cache_dir = store.cache_path(CacheBucket::Prek);
if std::fs::create_dir_all(&cache_dir).is_err() {
return;
}

let cache_file = cache_dir.join(LAST_CHECK_FILE);
// Write current timestamp to the file
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);

if let Err(e) = std::fs::write(&cache_file, now.to_string()) {
debug!("Failed to write last check timestamp: {}", e);
}
}

struct UpdateInfo {
new_rev: String,
}

async fn check_repo_for_updates(
repo: &config::RemoteRepo,
cooldown_days: u8,
) -> Result<Option<UpdateInfo>> {
let tmp_dir = tempfile::tempdir()?;
let repo_path = tmp_dir.path();

// Initialize and fetch the repo (lightweight fetch, tags only)
setup_and_fetch_repo(&repo.repo, repo_path).await?;

// Get the latest eligible revision
let latest_rev = resolve_latest_revision(repo_path, &repo.rev, cooldown_days).await?;

let Some(latest_rev) = latest_rev else {
return Ok(None);
};

// Check if the latest revision is different from the current one
if is_same_revision(repo_path, &repo.rev, &latest_rev).await? {
return Ok(None);
}

Ok(Some(UpdateInfo {
new_rev: latest_rev,
}))
}

async fn resolve_latest_revision(
repo_path: &Path,
current_rev: &str,
cooldown_days: u8,
) -> Result<Option<String>> {
let tags_with_ts = get_tag_timestamps(repo_path).await?;

if tags_with_ts.is_empty() {
// No tags, try to get the latest commit from HEAD
return resolve_head_revision(repo_path).await;
}

find_eligible_tag(repo_path, &tags_with_ts, current_rev, cooldown_days).await
}

async fn resolve_head_revision(repo_path: &Path) -> Result<Option<String>> {
resolve_rev_to_commit_hash(repo_path, "FETCH_HEAD").await
}

/// Check if two revisions point to the same commit.
async fn is_same_revision(repo_path: &Path, rev1: &str, rev2: &str) -> Result<bool> {
let hash1 = resolve_rev_to_commit_hash(repo_path, rev1).await?;
let hash2 = resolve_rev_to_commit_hash(repo_path, rev2).await?;

match (hash1, hash2) {
(Some(h1), Some(h2)) => Ok(h1 == h2),
// If we can't resolve one of them, assume they're different
_ => Ok(false),
}
}
Loading
Loading