Skip to content
Merged
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
6 changes: 4 additions & 2 deletions e2e/cli/test_prepare
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/usr/bin/env bash

# Test mise prepare (mise prep) command
Expand Down Expand Up @@ -256,9 +256,10 @@
requests==2.31.0
EOF

# Test 1: No output directory exists → should be stale
# Test 1: No output directory exists → should be stale with reason
rm -rf .venv
assert_contains "mise prepare --dry-run" "custom_venv"
assert_contains "mise prepare --dry-run" "does not exist"

# Test 2: Output exists and is newer than sources → should be fresh
# Use explicit timestamps: source=2023, output=2024
Expand All @@ -269,10 +270,11 @@
# In dry-run mode, fresh providers produce no output, so check provider name is absent
assert_not_contains "mise prepare --dry-run" "custom_venv"

# Test 3: Sources updated after output → should be stale
# Test 3: Sources updated after output → should be stale with reason
# source=2025 (newer than output=2024)
touch -t 202501010000 requirements.txt # Jan 1, 2025
assert_contains "mise prepare --dry-run" "custom_venv"
assert_contains "mise prepare --dry-run" "is newer than outputs"

# Clean up
rm -rf .venv requirements.txt
Expand Down
4 changes: 2 additions & 2 deletions src/cli/prepare.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ impl Prepare {
PrepareStepResult::Ran(id) => {
miseprintln!("Prepared: {}", id);
}
PrepareStepResult::WouldRun(id) => {
miseprintln!("[dry-run] Would prepare: {}", id);
PrepareStepResult::WouldRun(id, reason) => {
miseprintln!("[dry-run] Would prepare: {} ({})", id, reason);
}
PrepareStepResult::Fresh(id) => {
debug!("Fresh: {}", id);
Expand Down
114 changes: 95 additions & 19 deletions src/prepare/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,50 @@ pub struct PrepareOptions {
pub auto_only: bool,
}

/// Result of a freshness check with human-readable reason
#[derive(Debug, Clone)]
pub enum FreshnessResult {
/// Outputs are up to date
Fresh,
/// No outputs defined — always run
NoOutputs,
/// Output was created this session (e.g., auto-created venv)
SessionStale(String),
/// Some output files/dirs don't exist yet
OutputsMissing(String),
/// Sources are newer than outputs
Stale(String),
/// No sources exist — consider fresh
NoSources,
/// Forced by user request
Forced,
}

impl FreshnessResult {
pub fn is_fresh(&self) -> bool {
matches!(self, FreshnessResult::Fresh | FreshnessResult::NoSources)
}

pub fn reason(&self) -> &str {
match self {
FreshnessResult::Fresh => "up to date",
FreshnessResult::NoOutputs => "no outputs defined",
FreshnessResult::SessionStale(r) => r,
FreshnessResult::OutputsMissing(r) => r,
FreshnessResult::Stale(r) => r,
FreshnessResult::NoSources => "no sources",
FreshnessResult::Forced => "forced",
}
}
}

/// Result of a prepare step
#[derive(Debug)]
pub enum PrepareStepResult {
/// Step ran successfully
Ran(String),
/// Step would have run (dry-run mode)
WouldRun(String),
/// Step would have run (dry-run mode) — (id, reason)
WouldRun(String, String),
/// Step was skipped because outputs are fresh
Fresh(String),
/// Step was skipped by user request
Expand All @@ -70,7 +107,7 @@ impl PrepareResult {
self.steps.iter().any(|s| {
matches!(
s,
PrepareStepResult::Ran(_) | PrepareStepResult::WouldRun(_)
PrepareStepResult::Ran(_) | PrepareStepResult::WouldRun(_, _)
)
})
}
Expand Down Expand Up @@ -260,13 +297,18 @@ impl PrepareEngine {
}

/// Check if any auto-enabled provider has stale outputs (without running)
/// Returns the IDs of stale providers
pub fn check_staleness(&self) -> Vec<&str> {
/// Returns the IDs and reasons of stale providers
pub fn check_staleness(&self) -> Vec<(&str, String)> {
self.providers
.iter()
.filter(|p| p.is_auto())
.filter(|p| !self.check_freshness(p.as_ref()).unwrap_or(true))
.map(|p| p.id())
.filter_map(|p| {
let result = self.check_freshness(p.as_ref());
match result {
Ok(r) if !r.is_fresh() => Some((p.id(), r.reason().to_string())),
_ => None,
}
})
.collect()
}

Expand Down Expand Up @@ -301,20 +343,21 @@ impl PrepareEngine {
continue;
}

let is_fresh = if opts.force {
false
let freshness = if opts.force {
FreshnessResult::Forced
} else {
self.check_freshness(provider.as_ref())?
};

if !is_fresh {
if !freshness.is_fresh() {
let reason = freshness.reason().to_string();
let cmd = provider.prepare_command()?;
let outputs = provider.outputs();
let touch = provider.touch_outputs();

if opts.dry_run {
// Just record that it would run, let CLI handle output
results.push(PrepareStepResult::WouldRun(id));
results.push(PrepareStepResult::WouldRun(id, reason));
} else {
to_run.push(PrepareJob {
id,
Expand Down Expand Up @@ -377,32 +420,65 @@ impl PrepareEngine {
}

/// Check if outputs are newer than sources (stateless mtime comparison)
fn check_freshness(&self, provider: &dyn PrepareProvider) -> Result<bool> {
/// Returns a FreshnessResult with a human-readable reason
fn check_freshness(&self, provider: &dyn PrepareProvider) -> Result<FreshnessResult> {
let sources = provider.sources();
let outputs = provider.outputs();

if outputs.is_empty() {
return Ok(false); // No outputs defined, always run to be safe
return Ok(FreshnessResult::NoOutputs);
}

// Check if any output was created this session (before prepare ran)
// This handles the case where venv is auto-created but packages aren't installed yet
for output in &outputs {
if super::is_output_stale(output) {
return Ok(false); // Created this session, needs prepare
return Ok(FreshnessResult::SessionStale(format!(
"{} created this session",
output.display()
)));
}
}

// Note: empty sources is handled below - last_modified([]) returns None,
// and if outputs don't exist either, (_, None) takes precedence → stale
// Check for missing outputs
for output in &outputs {
if !output.exists() {
return Ok(FreshnessResult::OutputsMissing(format!(
"{} does not exist",
output.display()
)));
}
}

let sources_mtime = Self::last_modified(&sources)?;
let outputs_mtime = Self::last_modified(&outputs)?;

match (sources_mtime, outputs_mtime) {
(Some(src), Some(out)) => Ok(src <= out), // Fresh if outputs newer or equal to sources
(_, None) => Ok(false), // No outputs exist, not fresh (takes precedence)
(None, _) => Ok(true), // No sources exist, consider fresh
(Some(src), Some(out)) if src > out => {
// Find which source is newest to provide a helpful reason
let newest_source = sources
.iter()
.filter(|p| p.exists())
.filter_map(|p| {
let mtime = if p.is_dir() {
Self::newest_file_in_dir(p, 3)
} else {
p.metadata().ok().and_then(|m| m.modified().ok())
};
mtime.map(|m| (p, m))
})
.max_by_key(|(_, m)| *m)
.map(|(p, _)| p.display().to_string())
.unwrap_or_else(|| "sources".to_string());
Ok(FreshnessResult::Stale(format!(
"{newest_source} is newer than outputs"
)))
}
(Some(_), Some(_)) => Ok(FreshnessResult::Fresh),
(_, None) => Ok(FreshnessResult::Stale(
"could not determine modification time of outputs".to_string(),
)),
(None, _) => Ok(FreshnessResult::NoSources),
}
}

Expand Down
8 changes: 6 additions & 2 deletions src/prepare/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,12 @@ pub fn notify_if_stale(config: &Arc<Config>) {

let stale = engine.check_staleness();
if !stale.is_empty() {
let providers = stale.join(", ");
warn!("prepare: {providers} may need update, run `mise prep`");
let providers: Vec<String> = stale
.iter()
.map(|(id, reason)| format!("{id} ({reason})"))
.collect();
let summary = providers.join(", ");
warn!("prepare: {summary} — run `mise prep`");
}
}

Expand Down
Loading