Skip to content
Merged
90 changes: 90 additions & 0 deletions e2e/cli/test_hook_env_fast_path
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env bash

# Test that hook-env fast-path correctly detects when nothing has changed
# and skips expensive initialization
#
# Key insight: When fast-path triggers, hook-env returns immediately with NO output.
# When fast-path is bypassed, hook-env outputs shell commands including __MISE_SESSION.
#
# Note: eval "$(mise activate bash)" already runs hook-env and sets __MISE_SESSION,
# so subsequent calls should take the fast-path if nothing changed.

# Activate mise - this runs hook-env internally and establishes a session
eval "$(mise activate bash)"

# Verify session was established by activate
if [[ -n $__MISE_SESSION ]]; then
ok "activate established __MISE_SESSION"
else
fail "activate should establish __MISE_SESSION"
fi

# Fast-path should work - nothing has changed since activate
output=$(mise hook-env -s bash)
if [[ -z $output ]]; then
ok "fast-path works when nothing changed (no output)"
else
fail "fast-path should produce no output but got: '$output'"
fi

# Test 1: Creating a new config file should bypass fast-path
sleep 0.1 # ensure mtime difference
echo '[tools]' >mise.toml
output=$(mise hook-env -s bash)
if [[ $output == *"__MISE_SESSION"* ]]; then
ok "new config file bypasses fast-path"
else
fail "new config file should bypass fast-path but got: '$output'"
fi
rm mise.toml
eval "$output"

# Removing the config file also changes directory mtime, so the first
# hook-env after removal will bypass fast-path. Run it to re-establish session.
output=$(mise hook-env -s bash)
eval "$output"

# Now fast-path should work
output=$(mise hook-env -s bash)
if [[ -z $output ]]; then
ok "fast-path works after session re-established"
else
fail "fast-path should work after session re-established but got: '$output'"
fi

# Test 2: Changing directories should bypass fast-path
# First ensure we have a clean session in the current directory
eval "$(mise activate bash)"
# Verify fast-path works here
output=$(mise hook-env -s bash)
if [[ -n $output ]]; then
fail "expected fast-path before dir change but got output"
fi

# Now change directory and verify fast-path is bypassed
# Use builtin cd to avoid the _mise_hook wrapper that activate installs
mkdir -p subdir
builtin cd subdir
output=$(mise hook-env -s bash)
if [[ $output == *"__MISE_SESSION"* ]]; then
ok "directory change bypasses fast-path"
else
fail "directory change should bypass fast-path but got: '$output'"
fi
builtin cd ..
rmdir subdir

# Test 3: Creating config in parent's .config/mise should be detected
# Re-establish session first
eval "$(mise activate bash)"

mkdir -p ../.config/mise
sleep 0.1 # ensure mtime difference
echo '[tools]' >../.config/mise/config.toml
output=$(mise hook-env -s bash)
if [[ $output == *"__MISE_SESSION"* ]]; then
ok "parent .config/mise config file bypasses fast-path"
else
fail "parent .config/mise should bypass fast-path but got: '$output'"
fi
rm -rf ../.config
7 changes: 6 additions & 1 deletion src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::task::TaskOutput;
use crate::ui::{self, ctrlc};
use crate::{Result, backend};
use crate::{cli::args::ToolArg, path::PathExt};
use crate::{logger, migrate, shims};
use crate::{hook_env as hook_env_module, logger, migrate, shims};
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Importing hook_env as hook_env_module is unconventional and can be confusing. Prefer a direct use crate::hook_env; and call hook_env::should_exit_early_fast() for clarity.

Suggested change
use crate::{hook_env as hook_env_module, logger, migrate, shims};
use crate::{hook_env, logger, migrate, shims};

Copilot uses AI. Check for mistakes.
use clap::{ArgAction, CommandFactory, Parser, Subcommand};
use eyre::bail;
use std::path::PathBuf;
Expand Down Expand Up @@ -433,6 +433,11 @@ impl Cli {
if *crate::env::MISE_TOOL_STUB && args.len() >= 2 {
tool_stub::short_circuit_stub(&args[2..]).await?;
}
// Fast-path for hook-env: exit early if nothing has changed
// This avoids expensive backend::load_tools() and config loading
if hook_env_module::should_exit_early_fast() {
return Ok(());
}
measure!("logger", { logger::init() });
check_working_directory();
Comment on lines +436 to 442
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The fast-path returns before check_working_directory(), suppressing the warning when the current directory is invalid. If the warning is important regardless of initialization, consider moving check_working_directory() above the fast-path or explicitly running it in the fast path.

Suggested change
// Fast-path for hook-env: exit early if nothing has changed
// This avoids expensive backend::load_tools() and config loading
if hook_env_module::should_exit_early_fast() {
return Ok(());
}
measure!("logger", { logger::init() });
check_working_directory();
check_working_directory();
// Fast-path for hook-env: exit early if nothing has changed
// This avoids expensive backend::load_tools() and config loading
if hook_env_module::should_exit_early_fast() {
return Ok(());
}
measure!("logger", { logger::init() });

Copilot uses AI. Check for mistakes.
measure!("handle_shim", { shims::handle_shim().await })?;
Expand Down
118 changes: 101 additions & 17 deletions src/hook_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,15 @@ use crate::env::PATH_KEY;
use crate::env_diff::{EnvDiffOperation, EnvDiffPatches, EnvMap};
use crate::hash::hash_to_str;
use crate::shell::Shell;
use crate::{dirs, env, hooks, watch_files};
use crate::{dirs, env, file, hooks, watch_files};

/// Convert a SystemTime to milliseconds since Unix epoch
fn mtime_to_millis(mtime: SystemTime) -> u128 {
mtime
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}

pub static PREV_SESSION: Lazy<HookEnvSession> = Lazy::new(|| {
env::var("__MISE_SESSION")
Expand Down Expand Up @@ -59,28 +67,111 @@ impl From<PathBuf> for WatchFilePattern {
}
}

/// this function will early-exit the application if hook-env is being
/// called and it does not need to be
pub fn should_exit_early(
watch_files: impl IntoIterator<Item = WatchFilePattern>,
reason: Option<HookReason>,
) -> bool {
/// Fast-path early exit check that can be called BEFORE loading config/tools.
/// This checks basic conditions using only the previous session data.
/// Returns true if we can definitely skip hook-env, false if we need to continue.
pub fn should_exit_early_fast() -> bool {
let args = env::ARGS.read().unwrap();
if args.len() < 2 || args[1] != "hook-env" {
return false;
}
// Can't exit early if no previous session
// Check for dir being set as a proxy for "has valid session"
// (loaded_configs can be empty if there are no config files)
if PREV_SESSION.dir.is_none() {
return false;
}
// Can't exit early if --force flag is present
if args.iter().any(|a| a == "--force" || a == "-f") {
return false;
}
// Check if running from precmd for the first time
// Handle both "--reason=precmd" and "--reason precmd" forms
let is_precmd = args.iter().any(|a| a == "--reason=precmd")
|| args
.windows(2)
.any(|w| w[0] == "--reason" && w[1] == "precmd");
if is_precmd && !*env::__MISE_ZSH_PRECMD_RUN {
return false;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Fast-path fails to detect space-separated reason argument

The check for --reason=precmd only matches the equals-sign format, but zsh activation scripts use --reason precmd with a space. When called with space-separated arguments, the check fails to detect the first precmd run, potentially causing the fast-path to exit early when it should run the full initialization to catch PATH modifications from shell initialization.

Fix in Cursor Fix in Web

// Can't exit early if directory changed
if dir_change().is_some() {
return false;
}
// Can't exit early if MISE_ env vars changed
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Please document what specific MISE_ variables are hashed and compared by have_mise_env_vars_been_modified(), and whether non-MISE_ variables that affect resolution are intentionally excluded. This clarifies the assumptions of the fast path.

Suggested change
// Can't exit early if MISE_ env vars changed
// Can't exit early if MISE_ env vars changed
// Only environment variables with the `MISE_` prefix are hashed and compared by
// `have_mise_env_vars_been_modified()`. Non-`MISE_` variables that might affect
// resolution are intentionally excluded from this check. This assumes that only
// `MISE_` variables influence the fast path's correctness.

Copilot uses AI. Check for mistakes.
if have_mise_env_vars_been_modified() {
return false;
}
// Check if any loaded config files have been modified
for config_path in &PREV_SESSION.loaded_configs {
if let Ok(metadata) = config_path.metadata() {
if let Ok(modified) = metadata.modified()
&& mtime_to_millis(modified) > PREV_SESSION.latest_update
{
return false;
}
} else if !config_path.exists() {
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If metadata() fails for reasons other than non-existence (e.g., permissions), this logic continues without invalidating the fast path, potentially skipping necessary re-initialization. To be safe, any failure to read metadata should cause a full run, not an early exit.

Suggested change
} else if !config_path.exists() {
} else {

Copilot uses AI. Check for mistakes.
return false;
}
}
// Check if data dir has been modified (new tools installed, etc.)
// Also check if it's been deleted - this requires a full update
if !dirs::DATA.exists() {
return false;
}
if let Ok(metadata) = dirs::DATA.metadata()
&& let Ok(modified) = metadata.modified()
&& mtime_to_millis(modified) > PREV_SESSION.latest_update
{
return false;
}
// Check if any directory in the config search path has been modified
// This catches new config files created anywhere in the hierarchy
if let Some(cwd) = &*dirs::CWD
&& let Ok(ancestor_dirs) = file::all_dirs(cwd, &env::MISE_CEILING_PATHS)
{
// Config subdirectories that might contain config files
let config_subdirs = ["", ".config/mise", ".mise", "mise", ".config"];
for dir in ancestor_dirs {
for subdir in &config_subdirs {
let check_dir = if subdir.is_empty() {
dir.clone()
} else {
dir.join(subdir)
};
if let Ok(metadata) = check_dir.metadata()
&& let Ok(modified) = metadata.modified()
&& mtime_to_millis(modified) > PREV_SESSION.latest_update
{
return false;
}
}
}
}
true
}

/// Check if hook-env can exit early after config is loaded.
/// This is called after the fast-path check and handles cases that need
/// the full config (watch_files, hook scheduling).
pub fn should_exit_early(
watch_files: impl IntoIterator<Item = WatchFilePattern>,
reason: Option<HookReason>,
) -> bool {
// Force hook-env to run at least once from precmd after activation
// This catches PATH modifications from shell initialization (e.g., path_helper in zsh)
if reason == Some(HookReason::Precmd) && !*env::__MISE_ZSH_PRECMD_RUN {
trace!("__MISE_ZSH_PRECMD_RUN=0 and reason=precmd, forcing hook-env to run");
return false;
}
// Schedule hooks on directory change (can't do this in fast-path)
if dir_change().is_some() {
hooks::schedule_hook(hooks::Hooks::Cd);
hooks::schedule_hook(hooks::Hooks::Enter);
hooks::schedule_hook(hooks::Hooks::Leave);
return false;
}
// Check full watch_files list from config (may include more than config files)
let watch_files = match get_watch_files(watch_files) {
Ok(w) => w,
Err(e) => {
Expand Down Expand Up @@ -120,12 +211,8 @@ fn have_files_been_modified(watch_files: BTreeSet<PathBuf>) -> bool {
// check the files to see if they've been altered
let mut modified = false;
for fp in &watch_files {
if let Ok(modtime) = fp.metadata().and_then(|m| m.modified()) {
let modtime = modtime
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis();
if modtime > PREV_SESSION.latest_update {
if let Ok(mtime) = fp.metadata().and_then(|m| m.modified()) {
if mtime_to_millis(mtime) > PREV_SESSION.latest_update {
trace!("file modified: {:?}", fp);
modified = true;
watch_files::add_modified_file(fp.clone());
Expand Down Expand Up @@ -198,10 +285,7 @@ pub async fn build_session(
loaded_configs: config.config_files.keys().cloned().collect(),
loaded_tools,
config_paths,
latest_update: max_modtime
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis(),
latest_update: mtime_to_millis(max_modtime),
})
}

Expand Down
Loading