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

# Test that hook-env fast-path correctly detects when nothing has changed
# and skips expensive initialization

# Activate mise to set up the session
eval "$(mise activate bash)"

# Run hook-env once to establish a session
mise hook-env -s bash

# Verify fast-path works when nothing has changed
# The fast-path should produce no output (empty response)
output=$(mise hook-env -s bash 2>&1)
if [[ -z $output || $output == *"early-exit"* ]]; then
ok "fast-path works when nothing changed"
else
fail "fast-path did not work: got output '$output'"
fi

# Test 1: Creating a new config file should NOT trigger fast-path
echo '[tools]' >mise.toml
output=$(mise hook-env -s bash 2>&1)
# The output should contain environment variable exports (not be empty)
# because the new config file should be detected
rm mise.toml
ok "new config file detection test completed"

# Test 2: Test that changing directories prevents fast-path
# First reset the session
eval "$(mise activate bash)"
mise hook-env -s bash

# Create a subdirectory and cd into it
mkdir -p subdir
pushd subdir >/dev/null
output=$(mise hook-env -s bash 2>&1)
popd >/dev/null
rmdir subdir
ok "directory change detection test completed"

# Test 3: Creating config in parent's .config/mise should be detected
mkdir -p ../.config/mise
eval "$(mise activate bash)"
mise hook-env -s bash
# Wait a moment to ensure mtime difference
sleep 0.1
echo '[tools]' >../.config/mise/config.toml
output=$(mise hook-env -s bash 2>&1)
rm -rf ../.config
ok "parent .config/mise config file detection test completed"

# Test 4: Fast-path should work again after session is re-established
eval "$(mise activate bash)"
mise hook-env -s bash
output=$(mise hook-env -s bash 2>&1)
if [[ -z $output || $output == *"early-exit"* ]]; then
ok "fast-path works after session re-established"
else
fail "fast-path did not work after re-establishment: got output '$output'"
fi
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
106 changes: 99 additions & 7 deletions src/hook_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ 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};

pub static PREV_SESSION: Lazy<HookEnvSession> = Lazy::new(|| {
env::var("__MISE_SESSION")
Expand Down Expand Up @@ -59,28 +59,120 @@ 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
if PREV_SESSION.loaded_configs.is_empty() {
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
if args
.iter()
.any(|a| a == "--reason=precmd" || a.starts_with("--reason=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() {
let modtime = modified
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
if modtime > PREV_SESSION.latest_update {
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 continues when modification time unavailable

The fast-path check silently continues when metadata.modified() fails, potentially skipping necessary reloads. If modification time retrieval fails (due to filesystem limitations, permissions, or unsupported platforms), the code assumes no changes occurred and continues with the fast-path. This could cause stale environment state when config files or directories were actually modified but their mtimes couldn't be read. The safe behavior would be to bypass the fast-path when modification times are unavailable.

Additional Locations (2)

Fix in Cursor Fix in Web

} 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.)
if dirs::DATA.exists() {
if let Ok(metadata) = dirs::DATA.metadata() {
if let Ok(modified) = metadata.modified() {
let modtime = modified
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
if modtime > PREV_SESSION.latest_update {
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 misses deleted data directory

The fast-path only checks dirs::DATA modification time when it exists, but skips the check entirely if it doesn't exist. If dirs::DATA existed in the previous session but is now deleted, the fast-path won't detect this change and may incorrectly exit early, leaving stale tool paths in the environment. The check should return false when dirs::DATA is missing to trigger a full environment update.

Fix in Cursor Fix in Web

// 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 {
if 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() {
if let Ok(modified) = metadata.modified() {
let modtime = modified
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
if modtime > 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
Loading