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

# Test that env plugin watch_files are tracked in the session and env cache,
# so that modifying a watched file triggers hook-env to re-evaluate.
#
# See: https://github.com/jdx/mise/discussions/8603
#
# There are four meaningfully different code paths depending on:
# - tools=false: plugin runs in config.load_env() via NonToolsOnly;
# watch_files flow through config.watch_files() into the slow-path check.
# - tools=true: plugin runs in toolset.load_post_env() via ToolsOnly;
# watch_files come back as tool_watch_files from env_with_path_and_split()
# and the slow-path relies on PREV_SESSION.watch_files to detect changes.
# - cache=off: watch_files computed fresh each time.
# - cache=on: watch_files stored in CachedEnv; cache must invalidate on change.
#
# We test all four combinations to ensure full coverage.

export __MISE_ENV_CACHE_KEY="dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q="

setup_plugin() {
local plugin_name=$1
local data_file=$2
local env_var=$3

local plugin_dir="$MISE_DATA_DIR/plugins/$plugin_name"
mkdir -p "$plugin_dir/hooks"

cat >"$plugin_dir/metadata.lua" <<-EOFMETA
PLUGIN = {}
PLUGIN.name = "$plugin_name"
PLUGIN.version = "1.0.0"
PLUGIN.homepage = "https://example.com"
PLUGIN.license = "MIT"
PLUGIN.description = "Test plugin for watch_files tracking"
PLUGIN.minRuntimeVersion = "0.3.0"
EOFMETA

cat >"$plugin_dir/hooks/mise_env.lua" <<-EOFHOOK
function PLUGIN:MiseEnv(ctx)
local f = io.open("$data_file", "r")
local value = f:read("*all"):gsub("%s+$", "")
f:close()
return {
env = {{key = "$env_var", value = value}},
cacheable = true,
watch_files = {"$data_file"}
}
end
EOFHOOK
}

# Reset shell and mise state between test scenarios.
reset_state() {
unset TEST_WATCH_NONTOOL TEST_WATCH_TOOL
unset __MISE_SESSION __MISE_DIFF
}

# Runs a single test scenario: activate, verify initial value, modify watched
# file, verify hook-env detects the change, verify updated value.
run_watch_files_test() {
local label=$1
local env_var=$2
local data_file=$3

reset_state

# Reset data file
echo "initial_value" >"$data_file"

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

# Verify initial value
if [[ ${!env_var} == "initial_value" ]]; then
ok "$label: initial value set"
else
fail "$label: expected $env_var=initial_value, got '${!env_var}'"
fi

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

# Modify watched file
sleep 1 # 1s for mtime resolution on all filesystems
echo "updated_value" >"$data_file"

# hook-env should detect the change
output=$(mise hook-env -s bash)
if [[ $output == *"__MISE_SESSION"* ]]; then
ok "$label: watch_files change bypasses fast-path"
else
fail "$label: watch_files change should bypass fast-path but got: '$output'"
fi

# Eval and verify updated value
eval "$output"
if [[ ${!env_var} == "updated_value" ]]; then
ok "$label: updated value picked up"
else
fail "$label: expected $env_var=updated_value, got '${!env_var}'"
fi

# Fast-path should work again
output=$(mise hook-env -s bash)
if [[ -z $output ]]; then
ok "$label: fast-path works after update"
else
fail "$label: fast-path should work after update but got: '$output'"
fi
}

# --- Setup plugins and data files ---

DATA_FILE_A="$MISE_DATA_DIR/watch_test_data_a"
DATA_FILE_B="$MISE_DATA_DIR/watch_test_data_b"
setup_plugin "test-watch-nontool" "$DATA_FILE_A" "TEST_WATCH_NONTOOL"
setup_plugin "test-watch-tool" "$DATA_FILE_B" "TEST_WATCH_TOOL"

# --- Test 1: tools=false, cache=off ---

export MISE_ENV_CACHE=0
cat >"$MISE_CONFIG_DIR/config.toml" <<'EOF'
[env]
_.test-watch-nontool = { tools = false }
EOF
run_watch_files_test "tools=false cache=off" "TEST_WATCH_NONTOOL" "$DATA_FILE_A"

# --- Test 2: tools=false, cache=on ---

export MISE_ENV_CACHE=1
cat >"$MISE_CONFIG_DIR/config.toml" <<'EOF'
[env]
_.test-watch-nontool = { tools = false }
EOF
run_watch_files_test "tools=false cache=on" "TEST_WATCH_NONTOOL" "$DATA_FILE_A"

# --- Test 3: tools=true, cache=off ---

export MISE_ENV_CACHE=0
cat >"$MISE_CONFIG_DIR/config.toml" <<'EOF'
[env]
_.test-watch-tool = { tools = true }
EOF
run_watch_files_test "tools=true cache=off" "TEST_WATCH_TOOL" "$DATA_FILE_B"

# --- Test 4: tools=true, cache=on ---

export MISE_ENV_CACHE=1
cat >"$MISE_CONFIG_DIR/config.toml" <<'EOF'
[env]
_.test-watch-tool = { tools = true }
EOF
run_watch_files_test "tools=true cache=on" "TEST_WATCH_TOOL" "$DATA_FILE_B"
20 changes: 18 additions & 2 deletions src/cli/hook_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,16 @@ impl HookEnv {
config.watch_files().await?
};

if !self.force && hook_env::should_exit_early(watch_files.clone(), self.reason) {
// For the slow-path check, include watch_files from the previous session to detect
// changes to files from tools=true plugins (not yet available via config.watch_files()).
// We use a separate variable to ensure deleted watch_files don't persist indefinitely.
let slow_path_watch_files: BTreeSet<WatchFilePattern> = watch_files
.iter()
.cloned()
.chain(PREV_SESSION.watch_files.iter().map(|p| p.as_path().into()))
.collect();

if !self.force && hook_env::should_exit_early(slow_path_watch_files, self.reason) {
trace!("should_exit_early true");
return Ok(());
}
Expand All @@ -81,7 +90,8 @@ impl HookEnv {
miseprint!("{}", hook_env::clear_old_env(&*shell))?;

// Use env_with_path_and_split which handles caching internally
let (mut mise_env, user_paths, tool_paths) = ts.env_with_path_and_split(&config).await?;
let (mut mise_env, user_paths, tool_paths, tool_watch_files) =
ts.env_with_path_and_split(&config).await?;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
mise_env.remove(&*PATH_KEY);

// Create config_paths from user_paths for display_status and build_session
Expand Down Expand Up @@ -117,6 +127,12 @@ impl HookEnv {
.map(|(k, (v, _))| (k.clone(), v.clone()))
.collect();

// Include tool plugin watch_files in the session for the next prompt's fast-path check.
let watch_files: BTreeSet<WatchFilePattern> = watch_files
.into_iter()
.chain(tool_watch_files.iter().map(|p| p.as_path().into()))
.collect();

patches.extend(self.build_path_operations(&user_paths, &tool_paths, &__MISE_DIFF.path)?);
patches.push(self.build_diff_operation(&diff)?);
patches.push(
Expand Down
1 change: 1 addition & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,7 @@ impl Config {
.flatten()
.chain(env_results.env_files.iter().map(|p| p.as_path().into()))
.chain(env_results.env_scripts.iter().map(|p| p.as_path().into()))
.chain(env_results.watch_files.iter().map(|p| p.as_path().into()))
.chain(
Settings::get()
.env_files()
Expand Down
4 changes: 2 additions & 2 deletions src/hook_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,10 +350,10 @@ pub struct HookEnvSession {
/// that should be watched for changes.
#[serde(default)]
pub tera_files: Vec<PathBuf>,
/// Resolved file paths from [[watch_files]] config patterns.
/// Resolved file paths from [[watch_files]] config patterns and env plugin watch_files.
/// Stored so the fast-path can detect changes without loading config.
#[serde(default)]
watch_files: Vec<PathBuf>,
pub watch_files: Vec<PathBuf>,
dir: Option<PathBuf>,
env_var_hash: String,
latest_update: u128,
Expand Down
21 changes: 18 additions & 3 deletions src/toolset/toolset_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ impl Toolset {
pub async fn env_with_path_and_split(
&self,
config: &Arc<Config>,
) -> Result<(EnvMap, Vec<PathBuf>, Vec<PathBuf>)> {
) -> Result<(EnvMap, Vec<PathBuf>, Vec<PathBuf>, Vec<PathBuf>)> {
// Try to load from cache if enabled
if CachedEnv::is_enabled()
&& let Some(cached) = self.try_load_env_cache_full(config)?
Expand All @@ -102,7 +102,12 @@ impl Toolset {
path_env.add(p.clone());
}
env.insert(PATH_KEY.to_string(), path_env.to_string());
return Ok((env, cached.user_paths, cached.tool_paths));
return Ok((
env,
cached.user_paths,
cached.tool_paths,
cached.watch_files,
));
}

// Compute fresh
Expand All @@ -127,7 +132,7 @@ impl Toolset {
debug!("env_cache: failed to save: {}", e);
}

Ok((env, user_paths, tool_paths))
Ok((env, user_paths, tool_paths, env_results.watch_files))
}

/// Try to load environment from cache (returns full CachedEnv)
Expand Down Expand Up @@ -330,6 +335,16 @@ impl Toolset {
ctx.insert("tools", &self.build_tools_tera_map(config));
let mut env_results = self.load_post_env(config, ctx, &tera_env).await?;

// Include watch_files from tools=false plugins so the env cache tracks all
// plugin watch_files, not just tools=true ones. env_results_cached()
// returns Some here because self.env(config) above always initialises
// config.env via config.env_results().
if let Some(non_tool_env) = config.env_results_cached() {
env_results
Comment thread
greptile-apps[bot] marked this conversation as resolved.
.watch_files
.extend(non_tool_env.watch_files.clone());
}

// Store add_paths separately to maintain consistent PATH ordering
env_results.tool_add_paths = add_paths;

Expand Down
Loading