From 0423058f7f6395bc8d764c66df0308aa65b8775d Mon Sep 17 00:00:00 2001 From: Juliano Solanho Date: Tue, 28 Jul 2026 11:42:35 -0300 Subject: [PATCH 1/2] Checksum watched files by repo-relative path The cache signature (the hash in the cache. filename) and the freshness comparison both serialize each watched file's PathBuf. Since watch_files() absolutizes against the build directory, a cache built in one checkout could never be fresh in another: identical trees at different paths (e.g. linked git worktrees) produced different sums, so a cloned, byte-for-byte-current cache still reported "Nix environment is out of date" on every load. Store paths relative to the build directory instead. Content hashes are unchanged; the hook absolutizes the (possibly relative) paths against the build directory when emitting watch_file lines, so direnv still watches the right files wherever the cache came from. Paths outside the build directory are kept absolute. Caches written by older versions keep working through the fallback symlink: they compare as stale once (their sums carry absolute paths), and the next `firstaide build` writes a portable cache. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/cmds/build.rs | 2 +- src/cmds/hook.rs | 6 ++-- src/cmds/status.rs | 8 ++++-- src/config.rs | 2 +- src/sums.rs | 69 ++++++++++++++++++++++++++++++++++++++++++---- 7 files changed, 77 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a8fc8f..ced915c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "firstaide" -version = "0.1.6" +version = "0.1.7" dependencies = [ "anyhow", "atty", diff --git a/Cargo.toml b/Cargo.toml index 1aa563d..05d2d99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "firstaide" -version = "0.1.6" +version = "0.1.7" authors = ["Gavin Panella "] edition = "2018" description = "Bootstrap and cache Nix environments; works with direnv." diff --git a/src/cmds/build.rs b/src/cmds/build.rs index 7b54979..12efef2 100644 --- a/src/cmds/build.rs +++ b/src/cmds/build.rs @@ -92,7 +92,7 @@ fn build(config: config::Config) -> Result { // 5. Calculate checksums. log::info!("Calculate file checksums."); - let checksums = spin(|| sums::Checksums::from(&config.watch_files()?)) + let checksums = spin(|| sums::Checksums::from(&config.build_dir, &config.watch_files()?)) .context("could not calculate checksums")?; let cache_file = config.cache_file(&checksums); diff --git a/src/cmds/hook.rs b/src/cmds/hook.rs index 9a2977d..f49aaa0 100644 --- a/src/cmds/hook.rs +++ b/src/cmds/hook.rs @@ -75,7 +75,7 @@ impl Command { .write_all(&chunk("Helpers.", include_bytes!("hook/helpers.sh"))) .context("could not write helpers")?; - let sums_now = sums::Checksums::from(&config.watch_files()?)?; + let sums_now = sums::Checksums::from(&config.build_dir, &config.watch_files()?)?; let cache_file = config.cache_file(&sums_now); let cache_file_fallback = config.cache_file_most_recent(); @@ -116,8 +116,10 @@ impl Command { { let mut watches = Vec::with_capacity(8192); // 8kB enough? watches.extend(b"watch_file \\\n "); + // Checksum paths are relative to the build directory (or + // absolute, in caches written by older versions). for watch in cache.sums.into_iter() { - bash::escape_into(watch.path(), &mut watches); + bash::escape_into(&config.abspath(watch.path()), &mut watches); watches.extend(b" \\\n "); } // Also watch the cache file, the build log, the build diff --git a/src/cmds/status.rs b/src/cmds/status.rs index 79f3c42..e36c673 100644 --- a/src/cmds/status.rs +++ b/src/cmds/status.rs @@ -23,9 +23,11 @@ impl Command { let stdout = io::stdout(); let mut handle = stdout.lock(); - let sums_now = - sums::Checksums::from(&config.watch_files().context("could not get watch files")?) - .context("could not calculate checksums")?; + let sums_now = sums::Checksums::from( + &config.build_dir, + &config.watch_files().context("could not get watch files")?, + ) + .context("could not calculate checksums")?; let cache_file = config.cache_file(&sums_now); let cache_file_fallback = config.cache_file_most_recent(); diff --git a/src/config.rs b/src/config.rs index 23089cc..ac53aee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -181,7 +181,7 @@ impl Config { } /// Return an absolute path, resolved relative to `self.build_dir`. - fn abspath>(&self, path: T) -> PathBuf { + pub fn abspath>(&self, path: T) -> PathBuf { let p = path.as_ref(); if p.is_relative() { self.build_dir.join(p) diff --git a/src/sums.rs b/src/sums.rs index 97446fd..e17e5a9 100644 --- a/src/sums.rs +++ b/src/sums.rs @@ -1,4 +1,3 @@ - use crypto_hash::{hex_digest, Algorithm}; use serde::{Deserialize, Serialize}; use std::fs; @@ -9,13 +8,14 @@ use std::path::{Path, PathBuf}; pub struct Checksums(Vec); impl Checksums { - pub fn from(filenames: &[T]) -> io::Result + pub fn from(root: R, filenames: &[T]) -> io::Result where + R: AsRef, T: AsRef, { let mut sums = Vec::new(); for filename in filenames { - let sum = Checksum::from(filename)?; + let sum = Checksum::from(root.as_ref(), filename)?; sums.push(sum); } Ok(Self(sums)) @@ -44,11 +44,15 @@ pub enum Checksum { } impl Checksum { - pub fn from(filename: T) -> io::Result + pub fn from(root: &Path, filename: T) -> io::Result where T: AsRef, { - let path = filename.as_ref().to_path_buf(); + // Store the path relative to `root` so that checksums – and the cache + // signature derived from them – do not depend on where the working + // tree lives. Paths outside `root` are kept as they are. + let path = filename.as_ref(); + let path = path.strip_prefix(root).unwrap_or(path).to_path_buf(); match Sha1::from(&filename) { Ok(sha1) => Ok(Checksum::Found(path, sha1)), Err(ref err) if err.kind() == io::ErrorKind::NotFound => Ok(Checksum::NotFound(path)), @@ -79,3 +83,58 @@ impl Sha1 { pub fn equal(a: &Checksums, b: &Checksums) -> bool { a.0.iter().eq(b.0.iter()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn checksums_of_identical_trees_at_different_roots_are_equal() { + let dir_a = tempfile::tempdir().unwrap(); + let dir_b = tempfile::tempdir().unwrap(); + for dir in [dir_a.path(), dir_b.path()] { + fs::create_dir(dir.join("sub")).unwrap(); + fs::write(dir.join("shell.nix"), b"{ }: 12345").unwrap(); + fs::write(dir.join("sub").join("deps.nix"), b"{ }: 67890").unwrap(); + } + let files_a = [ + dir_a.path().join("shell.nix"), + dir_a.path().join("sub/deps.nix"), + ]; + let files_b = [ + dir_b.path().join("shell.nix"), + dir_b.path().join("sub/deps.nix"), + ]; + + let sums_a = Checksums::from(dir_a.path(), &files_a).unwrap(); + let sums_b = Checksums::from(dir_b.path(), &files_b).unwrap(); + + assert!(equal(&sums_a, &sums_b)); + assert_eq!(sums_a.sig(), sums_b.sig()); + } + + #[test] + fn checksums_of_missing_files_are_also_root_relative() { + let dir_a = tempfile::tempdir().unwrap(); + let dir_b = tempfile::tempdir().unwrap(); + + let sums_a = Checksums::from(dir_a.path(), &[dir_a.path().join("nope.nix")]).unwrap(); + let sums_b = Checksums::from(dir_b.path(), &[dir_b.path().join("nope.nix")]).unwrap(); + + assert!(equal(&sums_a, &sums_b)); + assert_eq!(sums_a.sig(), sums_b.sig()); + } + + #[test] + fn checksums_keep_paths_outside_the_root_absolute() { + let root = tempfile::tempdir().unwrap(); + let elsewhere = tempfile::tempdir().unwrap(); + fs::write(elsewhere.path().join("other.nix"), b"{ }: 1").unwrap(); + let outside = elsewhere.path().join("other.nix"); + + let sums = Checksums::from(root.path(), &[outside.clone()]).unwrap(); + + assert_eq!(sums.0[0].path(), outside.as_path()); + } +} From 24275831c66854abab355e7af6331cdc90f2188d Mon Sep 17 00:00:00 2001 From: Juliano Solanho Date: Tue, 28 Jul 2026 16:27:41 -0300 Subject: [PATCH 2/2] Update CI to supported runners and action versions macos-11 was retired from GitHub's hosted runner pool, so that leg of the matrix never got a runner and sat queued until the 24h timeout. Switch to macos-latest, and bump actions/checkout and install-nix-action off their Node 20 versions. Note: macos-latest is arm64, so nix-build now targets aarch64-darwin for the first time. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3edc5e5..81cb383 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,10 @@ jobs: matrix: # note: we're using ubuntu-latest as a stand-in for all Linux # distributions. If we find we need more, we should do Docker stuff. - os: [ubuntu-latest, macos-11] + os: [ubuntu-latest, macos-latest] runs-on: "${{ matrix.os }}" steps: - - uses: actions/checkout@v2 - - uses: cachix/install-nix-action@v16 + - uses: actions/checkout@v7 + - uses: cachix/install-nix-action@v31 - run: nix-build