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 @@ -5,6 +5,7 @@
### Bug Fixes

- Fixed authenticated Cachix pulls failing with HTTP 401 even when a valid auth token was configured. The token was resolved correctly but applied to Nix too late, after the store had already opened and made its first request, so private cache lookups went out unauthenticated. The netrc credentials are now in place before the store opens.
- Fixed local files and directories pulled into the Nix store by path (e.g. `scripts.foo.exec = ./foo.sh;` or `languages.rust.import ./.`) not being tracked as eval-cache dependencies, so editing them returned stale `devenv build`/shell results until the cache was manually refreshed. Such sources are now tracked, with directories hashed recursively over their contents so nested edits are detected ([#2886](https://github.com/cachix/devenv/issues/2886), [#2893](https://github.com/cachix/devenv/issues/2893)).
- Fixed `devenv shell` lingering as a background process, often pinned at 100%+ CPU, after its terminal window or tab was closed. The shell now reacts to the SIGHUP/SIGINT/SIGTERM that already trigger devenv's graceful shutdown by killing the inner shell, instead of orphaning it ([#2845](https://github.com/cachix/devenv/issues/2845)).
- Fixed `devenv shell`/`devenv update` failing with `authentication required but no callback set` when a `url."ssh://git@github.com/".insteadOf` git config rewrites GitHub HTTPS URLs to SSH. GitHub flake inputs now resolve over SSH using your ssh-agent ([#2842](https://github.com/cachix/devenv/issues/2842)).
- Fixed the "N files" counter under "Evaluating shell" inflating from generic Nix log lines. Now only counts actual file read operations.
Expand Down
95 changes: 95 additions & 0 deletions devenv-cache-core/src/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,57 @@ pub fn compute_directory_hash<P: AsRef<Path>>(path: P) -> CacheResult<Option<Str
Ok(Some(compute_string_hash(&entries.join("\n"))))
}

/// Compute a content-only hash of a directory's contents, recursively.
///
/// Unlike [`compute_directory_hash`], this ignores modification times, so
/// touching a file without changing its contents does not change the hash. It
/// also keys entries by their path relative to `path`, so the same tree hashes
/// identically regardless of where it lives on disk. This mirrors how Nix
/// hashes a source tree copied into the store, and is what the eval cache needs
/// to detect edits to files nested inside a copied source directory.
///
/// Returns the hash of the empty string for an empty directory.
pub fn compute_directory_content_hash<P: AsRef<Path>>(path: P) -> CacheResult<String> {
let path = path.as_ref();
let mut entries = Vec::new();

// Skip the root directory itself, sort by file name for consistent ordering
for entry in WalkDir::new(path).min_depth(1).sort_by_file_name() {
match entry {
Ok(entry) => {
let rel = entry
.path()
.strip_prefix(path)
.unwrap_or_else(|_| entry.path())
.to_string_lossy()
.into_owned();
let file_type = entry.file_type();

if file_type.is_dir() {
entries.push(format!("dir {rel}"));
} else if file_type.is_symlink() {
// Record the link target, not its contents, matching Nix.
let target = std::fs::read_link(entry.path())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
entries.push(format!("symlink {rel} -> {target}"));
} else {
match compute_file_hash(entry.path()) {
Ok(hash) => entries.push(format!("file {rel} {hash}")),
Err(_) => entries.push(format!("file_error {rel}")),
}
}
}
Err(e) => {
// Include error entries as well to detect when errors change
entries.push(format!("error {e}"));
}
}
}

Ok(compute_string_hash(&entries.join("\n")))
}

/// Compute a hash of a string
pub fn compute_string_hash(content: &str) -> String {
let hash = blake3::hash(content.as_bytes());
Expand Down Expand Up @@ -233,6 +284,50 @@ mod tests {
assert_ne!(hash, hash3);
}

#[test]
fn test_directory_content_hash_detects_nested_changes() {
let temp_dir = TempDir::new().unwrap();
let nested = temp_dir.path().join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
let file_path = nested.join("c.txt");
std::fs::write(&file_path, b"hello").unwrap();

let hash = compute_directory_content_hash(temp_dir.path()).unwrap();
assert!(!hash.is_empty());

// Same contents hash identically.
assert_eq!(
hash,
compute_directory_content_hash(temp_dir.path()).unwrap()
);

// Editing a deeply nested file changes the hash, even though the
// directory listing is unchanged.
std::fs::write(&file_path, b"world").unwrap();
assert_ne!(
hash,
compute_directory_content_hash(temp_dir.path()).unwrap()
);
}

#[test]
fn test_directory_content_hash_is_location_independent() {
// The same tree at two different locations hashes identically, because
// entries are keyed by their path relative to the root.
let make_tree = || {
let dir = TempDir::new().unwrap();
std::fs::create_dir(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src").join("main.rs"), b"fn main() {}").unwrap();
dir
};
let a = make_tree();
let b = make_tree();
assert_eq!(
compute_directory_content_hash(a.path()).unwrap(),
compute_directory_content_hash(b.path()).unwrap(),
);
}

#[test]
fn test_tracked_file() {
let temp_dir = TempDir::new().unwrap();
Expand Down
4 changes: 3 additions & 1 deletion devenv-cache-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ pub mod time;
// Re-export common types for convenience
pub use db::Database;
pub use error::{CacheError, CacheResult};
pub use file::{TrackedFile, compute_file_hash, compute_string_hash};
pub use file::{
TrackedFile, compute_directory_content_hash, compute_file_hash, compute_string_hash,
};
60 changes: 59 additions & 1 deletion devenv-core/src/eval_op.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//!
//! Note: `EvalOp` is duplicated in `devenv_activity::EvalOp`.

use crate::internal_log::InternalLog;
use crate::internal_log::{ActivityType, Field, InternalLog};
use regex::Regex;
use std::path::PathBuf;
use std::sync::{Arc, LazyLock};
Expand Down Expand Up @@ -107,6 +107,31 @@ impl EvalOp {
_ => None,
}
}

/// Extract an `EvalOp` from a structured Nix activity.
///
/// Some operations are surfaced as structured activities rather than free-text
/// log messages. Unlike messages, activities are emitted regardless of the
/// configured verbosity, so this is the reliable way to observe them.
///
/// `ActivityType::EvalCopySource` carries the source path in field 0 and the
/// destination store path in field 1.
pub fn from_activity(typ: ActivityType, fields: &[Field]) -> Option<Self> {
match typ {
ActivityType::EvalCopySource => {
let source = match fields.first() {
Some(Field::String(s)) => PathBuf::from(s),
_ => return None,
};
let target = match fields.get(1) {
Some(Field::String(s)) => PathBuf::from(s),
_ => return None,
};
Some(EvalOp::CopiedSource { source, target })
}
_ => None,
}
}
}

/// Observer trait for receiving evaluation operations.
Expand Down Expand Up @@ -158,6 +183,39 @@ mod tests {
);
}

#[test]
fn test_copied_source_from_activity() {
// Nix emits source copies as a structured `EvalCopySource` activity:
// field 0 = source path, field 1 = destination store path.
let fields = vec![
Field::String("/path/to/source".to_string()),
Field::String("/nix/store/abc-source".to_string()),
];
let op = EvalOp::from_activity(ActivityType::EvalCopySource, &fields);
assert_eq!(
op,
Some(EvalOp::CopiedSource {
source: PathBuf::from("/path/to/source"),
target: PathBuf::from("/nix/store/abc-source"),
})
);
}

#[test]
fn test_from_activity_ignores_other_types() {
let fields = vec![Field::String("/some/path".to_string())];
assert_eq!(EvalOp::from_activity(ActivityType::Build, &fields), None);
}

#[test]
fn test_from_activity_requires_both_fields() {
let fields = vec![Field::String("/only/source".to_string())];
assert_eq!(
EvalOp::from_activity(ActivityType::EvalCopySource, &fields),
None
);
}

#[test]
fn test_evaluated_file() {
let log = create_log("evaluating file '/path/to/file'");
Expand Down
3 changes: 3 additions & 0 deletions devenv-core/src/internal_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ pub enum ActivityType {
PostBuildHook = 110,
BuildWaiting = 111,
FetchTree = 112,
/// A local source path was copied into the store during evaluation.
/// Fields: [0] = source path, [1] = destination store path.
EvalCopySource = 113,
}

#[derive(Debug, thiserror::Error)]
Expand Down
16 changes: 16 additions & 0 deletions devenv-core/src/nix_log_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,17 @@ impl NixLogBridge {
fields,
..
} => {
// Some eval operations (e.g. a source copied into the store) are
// surfaced as structured activities rather than log messages.
// Activities are delivered regardless of verbosity, so record any
// input dependency they carry for cache invalidation.
if let Some(op) = EvalOp::from_activity(typ, &fields) {
if let Ok(guard) = self.observers.lock() {
for observer in guard.iter() {
observer.record(op.clone());
}
}
}
self.handle_activity_start(id, typ, text, fields);
}
InternalLog::Stop { id } => {
Expand Down Expand Up @@ -674,6 +685,7 @@ pub fn activity_type_from_str(s: &str) -> ActivityType {
"post-build-hook" => ActivityType::PostBuildHook,
"build-waiting" => ActivityType::BuildWaiting,
"fetch-tree" => ActivityType::FetchTree,
"eval-copy-source" => ActivityType::EvalCopySource,
_ => ActivityType::Unknown,
}
}
Expand Down Expand Up @@ -821,6 +833,10 @@ mod tests {
ActivityType::Substitute
);
assert_eq!(activity_type_from_str("copy-path"), ActivityType::CopyPath);
assert_eq!(
activity_type_from_str("eval-copy-source"),
ActivityType::EvalCopySource
);
assert_eq!(
activity_type_from_str("unknown-type"),
ActivityType::Unknown
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Track whether a directory input must be hashed recursively over its contents.
--
-- Directories observed via `copied source` / tracked devenv paths end up in the
-- Nix store with all of their contents, so a change to any nested file must
-- invalidate the cache. Directories observed via `readDir` only depend on their
-- listing, so they keep the cheaper name-only hashing (recursive = 0).
ALTER TABLE file_input ADD COLUMN recursive BOOLEAN NOT NULL DEFAULT 0;
22 changes: 11 additions & 11 deletions devenv-eval-cache/src/caching_eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ impl CachingEvalService {
// Add extra watch paths
let fallback_time = SystemTime::now();
for path in &self.config.extra_watch_paths {
if let Ok(desc) = FileInputDesc::new(path.clone(), fallback_time) {
if let Ok(desc) = FileInputDesc::new(path.clone(), fallback_time, true) {
all_inputs.push(Input::File(desc));
}
}
Expand Down Expand Up @@ -884,7 +884,7 @@ mod tests {
// Store a result with the real file
let json_output = r#"{"shell":"/nix/store/abc-shell"}"#;
let inputs = vec![Input::File(
FileInputDesc::new(temp_file, SystemTime::now()).unwrap(),
FileInputDesc::new(temp_file, SystemTime::now(), false).unwrap(),
)];
service.store(&key, json_output, inputs).await.unwrap();

Expand Down Expand Up @@ -957,7 +957,7 @@ mod tests {
// Store with the file as input
let json_output = r#"{"result":"original"}"#;
let inputs = vec![Input::File(
FileInputDesc::new(temp_file.clone(), SystemTime::now()).unwrap(),
FileInputDesc::new(temp_file.clone(), SystemTime::now(), false).unwrap(),
)];
service.store(&key, json_output, inputs).await.unwrap();

Expand Down Expand Up @@ -986,7 +986,7 @@ mod tests {
// Store with the file as input
let json_output = r#"{"content":"original content"}"#;
let inputs = vec![Input::File(
FileInputDesc::new(temp_file.clone(), SystemTime::now()).unwrap(),
FileInputDesc::new(temp_file.clone(), SystemTime::now(), false).unwrap(),
)];
service.store(&key, json_output, inputs).await.unwrap();

Expand Down Expand Up @@ -1022,7 +1022,7 @@ mod tests {
// Store with the file as input
let json_output = r#"{"content":"stable content"}"#;
let inputs = vec![Input::File(
FileInputDesc::new(temp_file.clone(), SystemTime::now()).unwrap(),
FileInputDesc::new(temp_file.clone(), SystemTime::now(), false).unwrap(),
)];
service.store(&key, json_output, inputs).await.unwrap();

Expand Down Expand Up @@ -1057,8 +1057,8 @@ mod tests {
// Store with multiple file inputs
let json_output = r#"{"config":{"version":"1.0"},"data":"important data"}"#;
let inputs = vec![
Input::File(FileInputDesc::new(file1.clone(), SystemTime::now()).unwrap()),
Input::File(FileInputDesc::new(file2.clone(), SystemTime::now()).unwrap()),
Input::File(FileInputDesc::new(file1.clone(), SystemTime::now(), false).unwrap()),
Input::File(FileInputDesc::new(file2.clone(), SystemTime::now(), false).unwrap()),
];
service.store(&key, json_output, inputs).await.unwrap();

Expand Down Expand Up @@ -1168,7 +1168,7 @@ mod tests {
// Store with both file and env inputs
let json_output = r#"{"file":"file content","env":"env_value"}"#;
let inputs = vec![
Input::File(FileInputDesc::new(temp_file.clone(), SystemTime::now()).unwrap()),
Input::File(FileInputDesc::new(temp_file.clone(), SystemTime::now(), false).unwrap()),
Input::Env(EnvInputDesc::new(env_name.to_string()).unwrap()),
];
service.store(&key, json_output, inputs).await.unwrap();
Expand Down Expand Up @@ -1231,7 +1231,7 @@ mod tests {
let service = CachingEvalService::new(pool.clone());
let json_output = r#"{"persistent":true}"#;
let inputs = vec![Input::File(
FileInputDesc::new(temp_file.clone(), SystemTime::now()).unwrap(),
FileInputDesc::new(temp_file.clone(), SystemTime::now(), false).unwrap(),
)];
service.store(&key, json_output, inputs).await.unwrap();
}
Expand Down Expand Up @@ -1448,7 +1448,7 @@ mod tests {
.unwrap();

let inputs = vec![Input::File(
FileInputDesc::new(file_path, SystemTime::now()).unwrap(),
FileInputDesc::new(file_path, SystemTime::now(), false).unwrap(),
)];
assert!(any_input_modified_after(&inputs, threshold));
}
Expand All @@ -1468,7 +1468,7 @@ mod tests {
.unwrap();

let inputs = vec![Input::File(
FileInputDesc::new(file_path, SystemTime::now()).unwrap(),
FileInputDesc::new(file_path, SystemTime::now(), false).unwrap(),
)];
assert!(!any_input_modified_after(&inputs, SystemTime::now()));
}
Expand Down
Loading
Loading