Skip to content

Commit be168ec

Browse files
Takishimaclaude
andcommitted
reload: exclude devenv dotfile from eval inputs and watch set
`lib.fileset.fromSource ./.` (and any other `readDir` on a parent of `.devenv/`) was dragging devenv's own churn into the Nix tracked-input set: the eval-cache SQLite db + WAL/SHM are rewritten on every evaluation, the tasks db on every task run, and shell-env.sh / imports.txt / generated bootstrap files on every build. Tracked inputs that change every run mean cache validity is poisoned and the reload watcher self-triggers in an infinite loop after the first reload. Exclude the whole devenv dotfile from both the eval cache's tracked inputs (CachingConfig.excluded_paths) and the reload watcher (is_watchable_input in reload::owner). To preserve the documented $DEVENV_STATE contract, carve out `<dotfile>/state/` via CachingConfig.excluded_path_exceptions and the equivalent predicate in the watcher: files users persist there still trigger reload and cache invalidation. The carve-out would otherwise re-admit devenv-managed leaves under `state/` — the tasks-cache sqlite db (rewritten on every task run) and the git-hooks state dir (rewritten on every reload) — and reintroduce the same loop scoped to state/. Switch `ops_to_inputs` to longest-prefix-match between `excluded_paths` and `excluded_path_exceptions`: the most specific rule wins, ties favor the exception. Callers can then list `<dotfile>/state/tasks.db`, `tasks.db-wal`, `tasks.db-shm`, and `git-hooks` back in `excluded_paths` and have them override the broader state exception. `is_watchable_input` mirrors the same leaf list as defense in depth in case a stale eval-cache row predates the filter. `Path::starts_with` matches on components, so each sqlite sibling (`-wal`, `-shm`) is its own component and is listed explicitly — a `tasks.db` prefix would not cover them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2d2f89e commit be168ec

7 files changed

Lines changed: 336 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
- Fixed long lines in `devenv shell` getting a hard newline inserted at the wrap point when copying to clipboard. The shell now preserves the soft-wrap when flushing wrapped output into the terminal's scrollback, so clipboard copy keeps the original single line ([#2865](https://github.com/cachix/devenv/issues/2865)).
1818
- Fixed files declared with the `files` option not being regenerated when an auto-loaded (`devenv allow`) shell reloaded after `devenv update`. enterShell tasks (including `devenv:files`) now re-run on hot-reload, matching a fresh shell entry, instead of only updating environment variables ([#2864](https://github.com/cachix/devenv/issues/2864)).
1919
- Fixed `devenv test --no-tui` (and any other non-TUI invocation) silently discarding all output from the `enterTest` script, so the test runner's output, traces, and failure messages never reached the terminal or CI logs. Output from commands run in the shell is now printed in non-TUI mode.
20+
- Fixed `devenv shell` self-triggering hot-reload in an infinite loop after the first reload, and `lib.fileset.fromSource ./.` (and similar `readDir` calls on a parent of `.devenv/`) dragging devenv's own churn (eval cache WAL/SHM, tasks DB, generated shell scripts, `imports.txt`, …) into the Nix tracked-input set and causing spurious rebuilds. The devenv dotfile dir is now excluded from both the reload watch set and the eval cache's tracked inputs, with a carve-out for `.devenv/state/` (`$DEVENV_STATE`) so files users persist there still trigger reload.
2021

2122
### Improvements
2223

devenv-eval-cache/src/db.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ pub(crate) fn empty_to_none(s: String) -> Option<String> {
1515
// Create a constant for embedded migrations
1616
pub const MIGRATIONS: sqlx::migrate::Migrator = sqlx::migrate!();
1717

18+
/// Filename of the SQLite eval cache database under the devenv dotfile dir.
19+
pub const DB_FILENAME: &str = "nix-eval-cache.db";
20+
1821
/// The row type for the `file_input` table.
1922
#[derive(Clone, Debug, PartialEq)]
2023
pub struct FileInputRow {

devenv-eval-cache/src/ffi_cache.rs

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,19 @@ pub struct CachingConfig {
6767
/// Additional paths to watch for changes beyond those detected during eval.
6868
pub extra_watch_paths: Vec<PathBuf>,
6969
/// Paths to exclude from cache invalidation (e.g., generated files).
70+
/// Prefix-matched: any source whose path starts with one of these is
71+
/// dropped, unless `excluded_path_exceptions` carves it back in by a
72+
/// longer (more specific) prefix.
7073
pub excluded_paths: Vec<PathBuf>,
74+
/// Carve-outs that override `excluded_paths`. Combined with
75+
/// `excluded_paths` under longest-prefix-match: for each source, the
76+
/// most specific matching entry wins. Ties favor the exception so a
77+
/// broad exclude with an equal-depth exception is still tracked. This
78+
/// lets callers exclude a parent broadly, carve out a subdirectory,
79+
/// and then re-exclude a leaf inside it by adding a longer entry to
80+
/// `excluded_paths` (e.g. exclude `.devenv/`, keep `.devenv/state/`,
81+
/// re-exclude `.devenv/state/tasks.db`).
82+
pub excluded_path_exceptions: Vec<PathBuf>,
7183
/// Environment variable names to exclude from cache invalidation
7284
/// (e.g., vars already tracked via NixArgs).
7385
pub excluded_envs: Vec<String>,
@@ -156,12 +168,30 @@ pub fn ops_to_inputs(ops: impl IntoIterator<Item = EvalOp>, config: &CachingConf
156168
continue;
157169
}
158170

159-
// Skip excluded paths
160-
if config
171+
// Longest-prefix-match between `excluded_paths` and
172+
// `excluded_path_exceptions`. The most specific matching
173+
// rule wins; ties favor the exception. This lets callers
174+
// re-exclude a leaf inside an otherwise-allowed carve-out
175+
// (e.g. exclude `.devenv/`, allow `.devenv/state/`,
176+
// re-exclude `.devenv/state/tasks.db`).
177+
let best_excluded = config
161178
.excluded_paths
162179
.iter()
163-
.any(|excluded| source.starts_with(excluded))
164-
{
180+
.filter(|p| source.starts_with(p))
181+
.map(|p| p.components().count())
182+
.max();
183+
let best_allowed = config
184+
.excluded_path_exceptions
185+
.iter()
186+
.filter(|p| source.starts_with(p))
187+
.map(|p| p.components().count())
188+
.max();
189+
let drop = match (best_excluded, best_allowed) {
190+
(None, _) => false,
191+
(Some(_), None) => true,
192+
(Some(e), Some(a)) => e > a,
193+
};
194+
if drop {
165195
continue;
166196
}
167197

@@ -282,6 +312,90 @@ mod tests {
282312
assert!(inputs.is_empty());
283313
}
284314

315+
#[test]
316+
fn test_ops_to_inputs_excluded_path_exceptions_kept() {
317+
// Broad exclude with a narrow carve-out: anything under /excluded is
318+
// dropped, except /excluded/keep — used to ignore devenv's own state
319+
// dir while still tracking user files under $DEVENV_STATE.
320+
let config = CachingConfig {
321+
excluded_paths: vec![PathBuf::from("/excluded")],
322+
excluded_path_exceptions: vec![PathBuf::from("/excluded/keep")],
323+
..Default::default()
324+
};
325+
let ops = vec![
326+
EvalOp::ReadFile {
327+
source: PathBuf::from("/excluded/internal.db"),
328+
},
329+
EvalOp::ReadFile {
330+
source: PathBuf::from("/excluded/keep/user-file.txt"),
331+
},
332+
];
333+
let inputs = ops_to_inputs(ops, &config);
334+
assert_eq!(inputs.len(), 1);
335+
match &inputs[0] {
336+
Input::File(desc) => {
337+
assert_eq!(desc.path, PathBuf::from("/excluded/keep/user-file.txt"))
338+
}
339+
_ => panic!("expected file input"),
340+
}
341+
}
342+
343+
#[test]
344+
fn test_ops_to_inputs_longest_prefix_re_excludes_leaf() {
345+
// Re-exclude a leaf inside an exception: `excluded_paths` covers a
346+
// broad parent, `excluded_path_exceptions` carves out a subdir,
347+
// and a longer entry in `excluded_paths` re-excludes a leaf inside
348+
// that subdir. Models the devenv layout: exclude `.devenv/`, keep
349+
// `.devenv/state/`, but drop devenv-managed `state/tasks.db*`.
350+
// `Path::starts_with` matches at component boundaries, so each
351+
// sqlite sibling (`-wal`, `-shm`) is its own component and must be
352+
// listed explicitly — they are *not* covered by a `tasks.db`
353+
// prefix.
354+
let config = CachingConfig {
355+
excluded_paths: vec![
356+
PathBuf::from("/d"),
357+
PathBuf::from("/d/state/tasks.db"),
358+
PathBuf::from("/d/state/tasks.db-wal"),
359+
PathBuf::from("/d/state/tasks.db-shm"),
360+
PathBuf::from("/d/state/git-hooks"),
361+
],
362+
excluded_path_exceptions: vec![PathBuf::from("/d/state")],
363+
..Default::default()
364+
};
365+
let ops = vec![
366+
// Dropped by `/d`.
367+
EvalOp::ReadFile {
368+
source: PathBuf::from("/d/shell-env.sh"),
369+
},
370+
// Kept by `/d/state` carve-out.
371+
EvalOp::ReadFile {
372+
source: PathBuf::from("/d/state/postgres/data"),
373+
},
374+
// Dropped: leaf exclusions are deeper than the carve-out.
375+
EvalOp::ReadFile {
376+
source: PathBuf::from("/d/state/tasks.db"),
377+
},
378+
EvalOp::ReadFile {
379+
source: PathBuf::from("/d/state/tasks.db-wal"),
380+
},
381+
EvalOp::ReadFile {
382+
source: PathBuf::from("/d/state/tasks.db-shm"),
383+
},
384+
EvalOp::ReadFile {
385+
source: PathBuf::from("/d/state/git-hooks/config.json"),
386+
},
387+
];
388+
let inputs = ops_to_inputs(ops, &config);
389+
let kept: Vec<_> = inputs
390+
.iter()
391+
.map(|i| match i {
392+
Input::File(d) => d.path.clone(),
393+
_ => panic!(),
394+
})
395+
.collect();
396+
assert_eq!(kept, vec![PathBuf::from("/d/state/postgres/data")]);
397+
}
398+
285399
#[test]
286400
fn test_ops_to_inputs_filters_excluded_envs() {
287401
let config = CachingConfig {

devenv-eval-cache/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ pub use ffi_cache::{CachingConfig, EvalCacheKey, InputTracker, ops_to_inputs};
2020
pub use resource_manager::{ResourceManager, ResourceSpec};
2121

2222
// Re-export database query functions for file tracking
23-
pub use db::{get_all_tracked_file_paths, get_file_inputs_by_key_hash};
23+
pub use db::{DB_FILENAME, get_all_tracked_file_paths, get_file_inputs_by_key_hash};

devenv-nix-backend/src/backend.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,39 @@ impl NixCBackend {
325325
force_refresh: self.cache_settings.refresh_eval_cache,
326326
extra_watch_paths: core_config_watch_paths(&self.paths.root),
327327
excluded_envs: vec!["NIXPKGS_CONFIG".to_string()],
328-
excluded_paths: vec![self.nixpkgs_config_path.clone()],
328+
// Exclude devenv's own dotfile dir from the tracked input
329+
// set. Files inside (eval cache + WAL/SHM, tasks DB,
330+
// generated shell scripts, imports.txt, etc.) are
331+
// rewritten on every evaluation or build; tracking them
332+
// would let `lib.fileset.fromSource ./.` (or any other
333+
// `readDir` on a parent) drag devenv's own churn into
334+
// the cache key and trigger spurious rebuilds.
335+
excluded_paths: {
336+
let state = self.paths.dotfile.join("state");
337+
vec![
338+
self.nixpkgs_config_path.clone(),
339+
self.paths.dotfile.clone(),
340+
// Re-exclude devenv-managed leaves that the
341+
// broader `state/` carve-out below would
342+
// otherwise re-admit. The tasks DB is rewritten
343+
// on every task run and the git-hooks state on
344+
// every reload; tracking either would invalidate
345+
// the eval cache and self-trigger reload loops.
346+
// `tasks.db-wal`/`tasks.db-shm` are listed
347+
// separately because path prefix matching is
348+
// component-wise, not byte-wise.
349+
state.join("tasks.db"),
350+
state.join("tasks.db-wal"),
351+
state.join("tasks.db-shm"),
352+
state.join("git-hooks"),
353+
]
354+
},
355+
// Carve-out: keep `.devenv/state/` tracked. That's the
356+
// documented `$DEVENV_STATE` area where users persist
357+
// files they want reload/eval to react to. The
358+
// devenv-internal leaves above re-exclude themselves by
359+
// longest-prefix-match.
360+
excluded_path_exceptions: vec![self.paths.dotfile.join("state")],
329361
};
330362
let service = CachingEvalService::with_config(pool.clone(), config.clone());
331363
let invalidation_flag = self.devenv_value_invalidated.clone();

devenv/src/devenv/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ impl Devenv {
385385
if cache_settings.eval_cache {
386386
eval_cache_pool
387387
.get_or_try_init(|| async {
388-
let db_path = devenv_dotfile.join("nix-eval-cache.db");
388+
let db_path = devenv_dotfile.join(devenv_eval_cache::DB_FILENAME);
389389
let db = devenv_cache_core::db::Database::new(
390390
db_path,
391391
&devenv_eval_cache::db::MIGRATIONS,

0 commit comments

Comments
 (0)