Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- 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)).
- 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)).
- 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.
- 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.

### Improvements

Expand Down
3 changes: 3 additions & 0 deletions devenv-eval-cache/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ pub(crate) fn empty_to_none(s: String) -> Option<String> {
// Create a constant for embedded migrations
pub const MIGRATIONS: sqlx::migrate::Migrator = sqlx::migrate!();

/// Filename of the SQLite eval cache database under the devenv dotfile dir.
pub const DB_FILENAME: &str = "nix-eval-cache.db";

/// The row type for the `file_input` table.
#[derive(Clone, Debug, PartialEq)]
pub struct FileInputRow {
Expand Down
122 changes: 118 additions & 4 deletions devenv-eval-cache/src/ffi_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,19 @@ pub struct CachingConfig {
/// Additional paths to watch for changes beyond those detected during eval.
pub extra_watch_paths: Vec<PathBuf>,
/// Paths to exclude from cache invalidation (e.g., generated files).
/// Prefix-matched: any source whose path starts with one of these is
/// dropped, unless `excluded_path_exceptions` carves it back in by a
/// longer (more specific) prefix.
pub excluded_paths: Vec<PathBuf>,
/// Carve-outs that override `excluded_paths`. Combined with
/// `excluded_paths` under longest-prefix-match: for each source, the
/// most specific matching entry wins. Ties favor the exception so a
/// broad exclude with an equal-depth exception is still tracked. This
/// lets callers exclude a parent broadly, carve out a subdirectory,
/// and then re-exclude a leaf inside it by adding a longer entry to
/// `excluded_paths` (e.g. exclude `.devenv/`, keep `.devenv/state/`,
/// re-exclude `.devenv/state/tasks.db`).
pub excluded_path_exceptions: Vec<PathBuf>,
/// Environment variable names to exclude from cache invalidation
/// (e.g., vars already tracked via NixArgs).
pub excluded_envs: Vec<String>,
Expand Down Expand Up @@ -156,12 +168,30 @@ pub fn ops_to_inputs(ops: impl IntoIterator<Item = EvalOp>, config: &CachingConf
continue;
}

// Skip excluded paths
if config
// Longest-prefix-match between `excluded_paths` and
// `excluded_path_exceptions`. The most specific matching
// rule wins; ties favor the exception. This lets callers
// re-exclude a leaf inside an otherwise-allowed carve-out
// (e.g. exclude `.devenv/`, allow `.devenv/state/`,
// re-exclude `.devenv/state/tasks.db`).
let best_excluded = config
.excluded_paths
.iter()
.any(|excluded| source.starts_with(excluded))
{
.filter(|p| source.starts_with(p))
.map(|p| p.components().count())
.max();
let best_allowed = config
.excluded_path_exceptions
.iter()
.filter(|p| source.starts_with(p))
.map(|p| p.components().count())
.max();
let drop = match (best_excluded, best_allowed) {
(None, _) => false,
(Some(_), None) => true,
(Some(e), Some(a)) => e > a,
};
if drop {
continue;
}

Expand Down Expand Up @@ -282,6 +312,90 @@ mod tests {
assert!(inputs.is_empty());
}

#[test]
fn test_ops_to_inputs_excluded_path_exceptions_kept() {
// Broad exclude with a narrow carve-out: anything under /excluded is
// dropped, except /excluded/keep — used to ignore devenv's own state
// dir while still tracking user files under $DEVENV_STATE.
let config = CachingConfig {
excluded_paths: vec![PathBuf::from("/excluded")],
excluded_path_exceptions: vec![PathBuf::from("/excluded/keep")],
..Default::default()
};
let ops = vec![
EvalOp::ReadFile {
source: PathBuf::from("/excluded/internal.db"),
},
EvalOp::ReadFile {
source: PathBuf::from("/excluded/keep/user-file.txt"),
},
];
let inputs = ops_to_inputs(ops, &config);
assert_eq!(inputs.len(), 1);
match &inputs[0] {
Input::File(desc) => {
assert_eq!(desc.path, PathBuf::from("/excluded/keep/user-file.txt"))
}
_ => panic!("expected file input"),
}
}

#[test]
fn test_ops_to_inputs_longest_prefix_re_excludes_leaf() {
// Re-exclude a leaf inside an exception: `excluded_paths` covers a
// broad parent, `excluded_path_exceptions` carves out a subdir,
// and a longer entry in `excluded_paths` re-excludes a leaf inside
// that subdir. Models the devenv layout: exclude `.devenv/`, keep
// `.devenv/state/`, but drop devenv-managed `state/tasks.db*`.
// `Path::starts_with` matches at component boundaries, so each
// sqlite sibling (`-wal`, `-shm`) is its own component and must be
// listed explicitly — they are *not* covered by a `tasks.db`
// prefix.
let config = CachingConfig {
excluded_paths: vec![
PathBuf::from("/d"),
PathBuf::from("/d/state/tasks.db"),
PathBuf::from("/d/state/tasks.db-wal"),
PathBuf::from("/d/state/tasks.db-shm"),
PathBuf::from("/d/state/git-hooks"),
],
excluded_path_exceptions: vec![PathBuf::from("/d/state")],
..Default::default()
};
let ops = vec![
// Dropped by `/d`.
EvalOp::ReadFile {
source: PathBuf::from("/d/shell-env.sh"),
},
// Kept by `/d/state` carve-out.
EvalOp::ReadFile {
source: PathBuf::from("/d/state/postgres/data"),
},
// Dropped: leaf exclusions are deeper than the carve-out.
EvalOp::ReadFile {
source: PathBuf::from("/d/state/tasks.db"),
},
EvalOp::ReadFile {
source: PathBuf::from("/d/state/tasks.db-wal"),
},
EvalOp::ReadFile {
source: PathBuf::from("/d/state/tasks.db-shm"),
},
EvalOp::ReadFile {
source: PathBuf::from("/d/state/git-hooks/config.json"),
},
];
let inputs = ops_to_inputs(ops, &config);
let kept: Vec<_> = inputs
.iter()
.map(|i| match i {
Input::File(d) => d.path.clone(),
_ => panic!(),
})
.collect();
assert_eq!(kept, vec![PathBuf::from("/d/state/postgres/data")]);
}

#[test]
fn test_ops_to_inputs_filters_excluded_envs() {
let config = CachingConfig {
Expand Down
2 changes: 1 addition & 1 deletion devenv-eval-cache/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ pub use ffi_cache::{CachingConfig, EvalCacheKey, InputTracker, ops_to_inputs};
pub use resource_manager::{ResourceManager, ResourceSpec};

// Re-export database query functions for file tracking
pub use db::{get_all_tracked_file_paths, get_file_inputs_by_key_hash};
pub use db::{DB_FILENAME, get_all_tracked_file_paths, get_file_inputs_by_key_hash};
34 changes: 33 additions & 1 deletion devenv-nix-backend/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,39 @@ impl NixCBackend {
force_refresh: self.cache_settings.refresh_eval_cache,
extra_watch_paths: core_config_watch_paths(&self.paths.root),
excluded_envs: vec!["NIXPKGS_CONFIG".to_string()],
excluded_paths: vec![self.nixpkgs_config_path.clone()],
// Exclude devenv's own dotfile dir from the tracked input
// set. Files inside (eval cache + WAL/SHM, tasks DB,
// generated shell scripts, imports.txt, etc.) are
// rewritten on every evaluation or build; tracking them
// would let `lib.fileset.fromSource ./.` (or any other
// `readDir` on a parent) drag devenv's own churn into
// the cache key and trigger spurious rebuilds.
excluded_paths: {
let state = self.paths.dotfile.join("state");
vec![
self.nixpkgs_config_path.clone(),
self.paths.dotfile.clone(),
// Re-exclude devenv-managed leaves that the
// broader `state/` carve-out below would
// otherwise re-admit. The tasks DB is rewritten
// on every task run and the git-hooks state on
// every reload; tracking either would invalidate
// the eval cache and self-trigger reload loops.
// `tasks.db-wal`/`tasks.db-shm` are listed
// separately because path prefix matching is
// component-wise, not byte-wise.
state.join("tasks.db"),
state.join("tasks.db-wal"),
state.join("tasks.db-shm"),
state.join("git-hooks"),
]
},
// Carve-out: keep `.devenv/state/` tracked. That's the
// documented `$DEVENV_STATE` area where users persist
// files they want reload/eval to react to. The
// devenv-internal leaves above re-exclude themselves by
// longest-prefix-match.
excluded_path_exceptions: vec![self.paths.dotfile.join("state")],
};
let service = CachingEvalService::with_config(pool.clone(), config.clone());
let invalidation_flag = self.devenv_value_invalidated.clone();
Expand Down
2 changes: 1 addition & 1 deletion devenv/src/devenv/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ impl Devenv {
if cache_settings.eval_cache {
eval_cache_pool
.get_or_try_init(|| async {
let db_path = devenv_dotfile.join("nix-eval-cache.db");
let db_path = devenv_dotfile.join(devenv_eval_cache::DB_FILENAME);
let db = devenv_cache_core::db::Database::new(
db_path,
&devenv_eval_cache::db::MIGRATIONS,
Expand Down
Loading