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
16 changes: 16 additions & 0 deletions schema/mise.json
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,22 @@
"description": "Number of retries for HTTP requests in mise.",
"type": "number"
},
"hook_env": {
"type": "object",
"additionalProperties": false,
"properties": {
"cache_ttl": {
"default": "0s",
"description": "Cache hook-env directory checks for this duration. Useful for slow filesystems like NFS.",
"type": "string"
},
"chpwd_only": {
"default": false,
"description": "Only run hook-env checks on directory change, not on every prompt.",
"type": "boolean"
}
}
},
"idiomatic_version_file": {
"description": "Set to false to disable the idiomatic version files such as .node-version, .ruby-version, etc.",
"type": "boolean",
Expand Down
36 changes: 36 additions & 0 deletions settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,42 @@ Uses an exponential backoff strategy. The duration is calculated by taking the b
env = "MISE_HTTP_RETRIES"
type = "Integer"

[hook_env.cache_ttl]
default = "0s"
description = "Cache hook-env directory checks for this duration. Useful for slow filesystems like NFS."
docs = """
On slow filesystems (like NFS with cold cache), mise's hook-env can be slow due to
multiple filesystem stat operations. Setting this to a positive value (e.g., "5s")
will cache the results of directory traversal and only re-check after the TTL expires.

When set to "0s" (default), no caching is performed and every hook-env call will
check the filesystem for changes. This is the safest option but slowest on NFS.

Note: When caching is enabled, newly created config files may not be detected until
the TTL expires. Use `mise hook-env --force` to bypass the cache.
"""
env = "MISE_HOOK_ENV_CACHE_TTL"
type = "Duration"

[hook_env.chpwd_only]
default = false
description = "Only run hook-env checks on directory change, not on every prompt."
docs = """
When enabled, mise will only perform full config file checks when the directory changes
(chpwd), not on every shell prompt (precmd). This significantly reduces filesystem
operations on slow filesystems like NFS.

With this enabled, changes to config files will not be detected until you change
directories. Use `mise hook-env --force` to manually trigger a full update.

This setting is useful when:
- You're working on an NFS filesystem with slow stat operations
- Config files rarely change during a session
- You want the fastest possible shell prompt response time
"""
env = "MISE_HOOK_ENV_CHPWD_ONLY"
type = "Bool"

[idiomatic_version_file]
deprecated = "This has been replaced with the idiomatic_version_file_enable_tools setting."
description = "Set to false to disable the idiomatic version files such as .node-version, .ruby-version, etc."
Expand Down
102 changes: 97 additions & 5 deletions src/hook_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,48 @@ use serde_derive::{Deserialize, Serialize};
use std::sync::LazyLock as Lazy;

use crate::cli::HookReason;
use crate::config::{Config, DEFAULT_CONFIG_FILENAMES};
use crate::config::{Config, DEFAULT_CONFIG_FILENAMES, Settings};
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, file, hooks, watch_files};
use crate::{dirs, duration, env, file, hooks, watch_files};

/// Directory to store per-directory last check timestamps.
/// Timestamps are stored per-directory (using a hash of CWD) so that
/// multiple shells in different directories don't interfere with each other.
static LAST_CHECK_DIR: Lazy<PathBuf> = Lazy::new(|| dirs::STATE.join("hook-env-checks"));

/// Get the path to the last check file for a specific directory.
fn last_check_file_for_dir(dir: &Path) -> PathBuf {
let hash = hash_to_str(&dir.to_string_lossy());
LAST_CHECK_DIR.join(hash)
}

/// Read the last full check timestamp from the state file for the current directory.
fn read_last_full_check() -> u128 {
let Some(cwd) = &*dirs::CWD else {
return 0;
};
std::fs::read_to_string(last_check_file_for_dir(cwd))
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0)
}

/// Write the last full check timestamp to the state file for the current directory.
fn write_last_full_check(timestamp: u128) {
let Some(cwd) = &*dirs::CWD else {
return;
};
if let Err(e) = file::create_dir_all(&*LAST_CHECK_DIR) {
trace!("failed to create last check dir: {e}");
return;
}
if let Err(e) = std::fs::write(last_check_file_for_dir(cwd), timestamp.to_string()) {
trace!("failed to write last check file: {e}");
}
}

/// Convert a SystemTime to milliseconds since Unix epoch
fn mtime_to_millis(mtime: SystemTime) -> u128 {
Expand Down Expand Up @@ -94,14 +130,50 @@ pub fn should_exit_early_fast() -> bool {
if is_precmd && !*env::__MISE_ZSH_PRECMD_RUN {
return false;
}

// Get settings for cache_ttl and chpwd_only
let settings = Settings::get();
let cache_ttl_ms = duration::parse_duration(&settings.hook_env.cache_ttl)
.map(|d| d.as_millis())
.inspect_err(|e| warn!("invalid hook_env.cache_ttl setting: {e}"))
.unwrap_or(0);
Comment on lines +136 to +139

Copilot AI Dec 15, 2025

Copy link

Choose a reason for hiding this comment

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

The unwrap_or(0) silently ignores parsing errors for cache_ttl. If a user provides an invalid duration format, they won't receive any feedback. Consider logging a warning when parsing fails so users can identify configuration issues.

Copilot uses AI. Check for mistakes.

// Compute TTL window check only if cache_ttl is enabled (avoid unnecessary file read)
let (now, within_ttl_window) = if cache_ttl_ms > 0 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let last_full_check = read_last_full_check();
(now, now.saturating_sub(last_full_check) < cache_ttl_ms)
} else {
(0, false)
};

// Can't exit early if directory changed
if dir_change().is_some() {
return false;
}
// Can't exit early if MISE_ env vars changed
// Can't exit early if MISE_ env vars changed (cheap in-memory hash comparison)
if have_mise_env_vars_been_modified() {
return false;
}

// chpwd_only mode: skip on precmd if directory hasn't changed
// This significantly reduces stat operations on slow filesystems like NFS
// Note: We check this AFTER env var check since that's cheap (no I/O)
if settings.hook_env.chpwd_only && is_precmd {
trace!("chpwd_only enabled, skipping precmd hook-env");
return true;
}

// Cache TTL check: if within the TTL window, skip all stat operations
// This is useful for slow filesystems like NFS where stat calls are expensive
if within_ttl_window {
trace!("within cache TTL, skipping filesystem checks");
return true;
}

// Check if any loaded config files have been modified
for config_path in &PREV_SESSION.loaded_configs {
if let Ok(metadata) = config_path.metadata() {
Expand Down Expand Up @@ -133,7 +205,7 @@ pub fn should_exit_early_fast() -> bool {
// Config subdirectories that might contain config files
let config_subdirs = DEFAULT_CONFIG_FILENAMES
.iter()
.map(|f| f.rsplit_once("/").map(|(dir, _)| dir).unwrap_or(""))
.map(|f| Path::new(f).parent().and_then(|p| p.to_str()).unwrap_or(""))
.unique()
.collect::<Vec<_>>();
for dir in ancestor_dirs {
Expand All @@ -152,6 +224,11 @@ pub fn should_exit_early_fast() -> bool {
}
}
}
// Filesystem checks passed - update the last check timestamp so subsequent
// prompts can benefit from the TTL cache without repeating these checks
if cache_ttl_ms > 0 {
write_last_full_check(now);
}
true
}

Expand Down Expand Up @@ -282,11 +359,26 @@ pub async fn build_session(
IndexSet::new()
};

let loaded_configs: IndexSet<PathBuf> = config.config_files.keys().cloned().collect();

// Update the last full check timestamp (only if cache_ttl feature is enabled)
let settings = Settings::get();
if duration::parse_duration(&settings.hook_env.cache_ttl)
.map(|d| d.as_millis() > 0)
.unwrap_or(false)
{
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
write_last_full_check(now);
}

Ok(HookEnvSession {
dir: dirs::CWD.clone(),
env_var_hash: get_mise_env_vars_hashed(),
env,
loaded_configs: config.config_files.keys().cloned().collect(),
loaded_configs,
loaded_tools,
config_paths,
latest_update: mtime_to_millis(max_modtime),
Expand Down
Loading