diff --git a/host/tests/it/common.rs b/host/tests/it/common.rs index 77c57dc..c47c486 100644 --- a/host/tests/it/common.rs +++ b/host/tests/it/common.rs @@ -1,5 +1,7 @@ //! Shared fixture copies and builds. A mutex protects cwd-init and the brush checkout. use std::{ + hash::{Hash, Hasher}, + os::unix::{ffi::OsStrExt, fs::PermissionsExt}, path::{Path, PathBuf}, process::{Command, ExitStatus}, sync::{Mutex, OnceLock}, @@ -31,6 +33,79 @@ pub fn target_dir() -> PathBuf { }) } +/// Fingerprint the snapshot inputs and their current working-tree contents. +pub fn bundle_key(repo: &Path) -> anyhow::Result { + const INPUTS: &[&str] = &[ + "host", + "vfs", + "userland", + "agents", + "skills", + "extensions", + "Cargo.toml", + "Cargo.lock", + "flake.nix", + "flake.lock", + "README.md", + "AGENTS.md", + ".gitignore", + ]; + let tree = Command::new("git") + .current_dir(repo) + .args(["ls-tree", "HEAD", "--"]) + .args(INPUTS) + .output()?; + anyhow::ensure!( + tree.status.success(), + "git ls-tree: {}", + String::from_utf8_lossy(&tree.stderr) + ); + let files = Command::new("git") + .current_dir(repo) + .args([ + "ls-files", + "-z", + "--cached", + "--others", + "--exclude-standard", + "--", + ]) + .args(INPUTS) + .output()?; + anyhow::ensure!( + files.status.success(), + "git ls-files: {}", + String::from_utf8_lossy(&files.stderr) + ); + let mut hash = std::collections::hash_map::DefaultHasher::new(); + tree.stdout.hash(&mut hash); + let mut names: Vec<_> = files + .stdout + .split(|byte| *byte == 0) + .filter(|name| !name.is_empty()) + .collect(); + names.sort_unstable(); + for name in names { + name.hash(&mut hash); + let path = repo.join(std::ffi::OsStr::from_bytes(name)); + match std::fs::symlink_metadata(&path) { + Ok(metadata) => { + metadata.permissions().mode().hash(&mut hash); + if metadata.file_type().is_symlink() { + std::fs::read_link(path)?.hash(&mut hash); + } else { + std::fs::read(path)?.hash(&mut hash); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + "deleted".hash(&mut hash); + } + Err(error) => return Err(error.into()), + } + } + Ok(format!("{:016x}\n", hash.finish())) +} + pub fn cwd_init() { static INIT: OnceLock<()> = OnceLock::new(); INIT.get_or_init(|| { diff --git a/host/tests/it/p1.rs b/host/tests/it/p1.rs index 6c9c5d3..5e748c3 100644 --- a/host/tests/it/p1.rs +++ b/host/tests/it/p1.rs @@ -7,9 +7,15 @@ fn bundle() -> anyhow::Result { let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".."); let bundle = crate::common::target_dir().join("bundle"); let _guard = crate::common::BUILD.lock().unwrap(); + let key = crate::common::bundle_key(&repo)?; + let stamp = bundle.join(".source-key"); if !bundle.join("bin/sh.wasm").exists() || !bundle.join("src/sausage/agents/models.json").exists() + || std::fs::read_to_string(&stamp).ok().as_deref() != Some(key.as_str()) { + if stamp.exists() { + std::fs::remove_file(&stamp)?; + } assert!( std::process::Command::new("nu") .arg(repo.join("userland/bundle.nu")) @@ -17,6 +23,7 @@ fn bundle() -> anyhow::Result { .status()? .success() ); + std::fs::write(stamp, key)?; } Ok(bundle) }