1use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6
7fn git(repo: &Path, args: &[&str]) -> Vec<u8> {
8 let output = Command::new("git")
9 .current_dir(repo)
10 .args(args)
11 .output()
12 .expect("run git for sibling identity");
13 assert!(
14 output.status.success(),
15 "git {args:?} failed in {}",
16 repo.display()
17 );
18 output.stdout
19}
20
21pub fn sibling_revision(repo: &Path) -> String {
25 let head = String::from_utf8(git(repo, &["rev-parse", "HEAD"])).unwrap();
26 let mut contents = git(repo, &["diff", "HEAD", "--binary", "--no-ext-diff"]);
27 for name in git(repo, &["ls-files", "--others", "--exclude-standard", "-z"])
28 .split(|byte| *byte == 0)
29 .filter(|name| !name.is_empty())
30 {
31 let name = std::str::from_utf8(name).expect("UTF-8 sibling file name");
32 let bytes = std::fs::read(repo.join(name)).expect("read untracked sibling file");
33 contents.extend_from_slice(&(name.len() as u64).to_le_bytes());
34 contents.extend_from_slice(name.as_bytes());
35 contents.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
36 contents.extend(bytes);
37 }
38 if contents.is_empty() {
39 return head.trim().to_owned();
40 }
41 format!("{}-dirty-{}", head.trim(), hash_contents(&contents))
42}
43
44fn hash_contents(contents: &[u8]) -> String {
45 let mut child = Command::new("git")
46 .args(["hash-object", "--stdin"])
47 .stdin(Stdio::piped())
48 .stdout(Stdio::piped())
49 .spawn()
50 .expect("hash sibling changes");
51 child.stdin.take().unwrap().write_all(contents).unwrap();
52 let output = child.wait_with_output().unwrap();
53 assert!(output.status.success());
54 String::from_utf8(output.stdout).unwrap().trim().to_owned()
55}
56
57pub fn sibling_cache_root() -> PathBuf {
61 PathBuf::from(
62 std::env::var_os("HOME")
63 .or_else(|| std::env::var_os("USERPROFILE"))
64 .expect("user home for sibling build cache"),
65 )
66 .join(".cache/cortexkit-tests/sibling-target")
67}
68
69pub fn cached_sibling_binary(
75 sibling: &str,
76 revision: &str,
77 bin: &str,
78 builder: impl FnOnce(&Path),
79) -> PathBuf {
80 cached_sibling_binary_with_target(sibling, revision, bin, builder).executable
81}
82
83pub struct CachedSiblingBinary {
87 pub executable: PathBuf,
88 pub target_dir: PathBuf,
89}
90
91pub fn cached_sibling_binary_with_target(
94 sibling: &str,
95 revision: &str,
96 bin: &str,
97 builder: impl FnOnce(&Path),
98) -> CachedSiblingBinary {
99 resolve_with_target(&sibling_cache_root(), sibling, revision, bin, builder)
100}
101
102#[cfg(test)]
103fn resolve(
104 root: &Path,
105 sibling: &str,
106 revision: &str,
107 bin: &str,
108 builder: impl FnOnce(&Path),
109) -> PathBuf {
110 resolve_with_target(root, sibling, revision, bin, builder).executable
111}
112
113fn resolve_with_target(
114 root: &Path,
115 sibling: &str,
116 revision: &str,
117 bin: &str,
118 builder: impl FnOnce(&Path),
119) -> CachedSiblingBinary {
120 for component in [sibling, revision, bin] {
121 assert!(
122 !component.is_empty()
123 && component
124 .bytes()
125 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
126 "invalid sibling cache component"
127 );
128 }
129 let root = root.join(sibling);
130 std::fs::create_dir_all(&root).unwrap();
131 let lock = std::fs::OpenOptions::new()
132 .create(true)
133 .truncate(false)
134 .read(true)
135 .write(true)
136 .open(root.join("build.lock"))
137 .unwrap();
138 lock.lock().expect("lock shared sibling build");
139 let destination = root.join(revision).join(bin);
140 let executable = destination
141 .join("debug")
142 .join(format!("{bin}{}", std::env::consts::EXE_SUFFIX));
143 if !executable.is_file() {
144 eprintln!("sibling cache: miss, building {sibling}/{revision}/{bin}");
145 let staging = root.join("building");
146 if staging.exists() {
147 std::fs::remove_dir_all(&staging).unwrap();
148 }
149 std::fs::create_dir_all(&staging).unwrap();
150 builder(&staging);
151 let built = staging
152 .join("debug")
153 .join(format!("{bin}{}", std::env::consts::EXE_SUFFIX));
154 assert!(
155 built.is_file(),
156 "builder did not produce {}",
157 built.display()
158 );
159 super::sign_test_binary(&built, None);
160 std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
161 std::fs::rename(&staging, &destination).expect("publish sibling executable atomically");
162 super::warm_exec_fenced(&executable);
163 } else {
164 eprintln!("sibling cache: hit {}", executable.display());
165 }
166 std::fs::write(destination.parent().unwrap().join("last-used"), []).unwrap();
168 let mut revisions: Vec<_> = std::fs::read_dir(&root)
169 .unwrap()
170 .filter_map(Result::ok)
171 .filter(|entry| {
172 entry.file_type().is_ok_and(|kind| kind.is_dir()) && entry.file_name() != "building"
173 })
174 .map(|entry| {
175 (
176 std::fs::metadata(entry.path().join("last-used"))
177 .and_then(|m| m.modified())
178 .ok(),
179 entry.path(),
180 )
181 })
182 .collect();
183 revisions.sort_by_key(|a| std::cmp::Reverse(a.0));
184 for (_, old) in revisions
185 .into_iter()
186 .filter(|(_, path)| path != destination.parent().unwrap())
187 .skip(3)
188 {
189 std::fs::remove_dir_all(old).unwrap();
190 }
191 CachedSiblingBinary {
194 target_dir: destination,
195 executable: super::stage_test_binary(&executable),
196 }
197}
198
199pub fn pinned_sibling_build_revision(revision: &str, dependencies: &[(&str, &str)]) -> String {
203 let mut key = revision.to_owned();
204 for (name, sha) in dependencies {
205 assert_eq!(sha.len(), 40, "dependency pin must be a full commit SHA");
206 assert!(sha.bytes().all(|b| b.is_ascii_hexdigit()));
207 key.push_str(&format!("-{name}-{sha}"));
208 }
209 format!("{revision}-pins-{}", hash_contents(key.as_bytes()))
210}
211
212pub fn sibling_build_revision(
216 repo: &Path,
217 revision: &str,
218 dependency_checkouts: &[&str],
219) -> String {
220 let mut key = revision.to_owned();
221 for name in dependency_checkouts {
222 crate::fence::plain_name(name);
223 let dependency = repo.parent().unwrap().join(name);
224 if dependency.join(".git").exists()
225 && dependency.canonicalize().ok() != repo.canonicalize().ok()
226 {
227 key.push('-');
228 key.push_str(&sibling_revision(&dependency));
229 }
230 }
231 format!("{revision}-deps-{}", hash_contents(key.as_bytes()))
232}
233
234pub fn build_sibling_binary(
238 repo: &Path,
239 sibling: &str,
240 bin: &str,
241 dependency_checkouts: &[&str],
242) -> PathBuf {
243 let revision = sibling_build_revision(repo, &sibling_revision(repo), dependency_checkouts);
244 cached_sibling_binary(sibling, &revision, bin, |target| {
245 let status = super::sibling_cargo_build(repo, target, bin)
246 .status()
247 .expect("build sibling binary");
248 assert!(
249 status.success(),
250 "building {bin} failed: {}",
251 crate::describe_exit_status(status)
252 );
253 assert_eq!(
254 sibling_build_revision(repo, &sibling_revision(repo), dependency_checkouts),
255 revision,
256 "sibling changed during build; retry with the new revision"
257 );
258 })
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use std::sync::{
265 atomic::{AtomicUsize, Ordering},
266 Arc, Barrier,
267 };
268
269 fn fake_build(target: &Path) {
270 std::fs::create_dir_all(target.join("debug")).unwrap();
271 std::fs::write(
272 target.join(format!("debug/probe{}", std::env::consts::EXE_SUFFIX)),
273 b"fake executable",
274 )
275 .unwrap();
276 }
277
278 #[test]
279 fn cached_binary_keeps_build_target_separate_from_ckdev_execution() {
280 let scratch = crate::ScratchDir::new("sibling-cache-build-and-exec");
281 let cache = scratch.path().join("cache");
282 let built_name = format!("ck-probe{}", std::env::consts::EXE_SUFFIX);
283 let first = resolve_with_target(&cache, "example", "revision", "ck-probe", |target| {
284 std::fs::create_dir_all(target.join("debug")).unwrap();
285 std::fs::copy(
286 std::env::current_exe().unwrap(),
287 target.join("debug").join(&built_name),
288 )
289 .unwrap();
290 });
291 let published_target = cache.join("example/revision/ck-probe");
292 assert_eq!(first.target_dir, published_target);
293 assert!(first.target_dir.join("debug").join(built_name).is_file());
294 assert_eq!(
295 first.executable.file_name().unwrap(),
296 format!("ckdev-probe{}", std::env::consts::EXE_SUFFIX).as_str()
297 );
298 assert!(!first.executable.starts_with(&cache));
299 let reused = resolve_with_target(&cache, "example", "revision", "ck-probe", |_| {
300 panic!("cache hit must not build")
301 });
302 assert_eq!(reused.target_dir, first.target_dir);
303 assert_eq!(reused.executable, first.executable);
304 }
305
306 fn git_ok(repo: &Path, args: &[&str]) {
307 assert!(Command::new("git")
308 .current_dir(repo)
309 .args(args)
310 .env("GIT_AUTHOR_NAME", "Test")
311 .env("GIT_AUTHOR_EMAIL", "test@example.org")
312 .env("GIT_COMMITTER_NAME", "Test")
313 .env("GIT_COMMITTER_EMAIL", "test@example.org")
314 .status()
315 .unwrap()
316 .success());
317 }
318
319 #[test]
320 fn pinned_identity_includes_every_dependency() {
321 let pin = "a".repeat(40);
322 let next = "b".repeat(40);
323 let baseline =
324 pinned_sibling_build_revision(&pin, &[("library-a", &pin), ("library-b", &pin)]);
325 for dependencies in [
326 [("library-a", next.as_str()), ("library-b", pin.as_str())],
327 [("library-a", pin.as_str()), ("library-b", next.as_str())],
328 ] {
329 assert_ne!(baseline, pinned_sibling_build_revision(&pin, &dependencies));
330 }
331 assert_ne!(
332 baseline,
333 pinned_sibling_build_revision(&next, &[("library-a", &pin), ("library-b", &pin)])
334 );
335 assert_eq!(
336 baseline,
337 pinned_sibling_build_revision(&pin, &[("library-a", &pin), ("library-b", &pin)])
338 );
339 }
340
341 #[test]
342 fn live_build_identity_includes_only_caller_selected_dependencies() {
343 let scratch = crate::ScratchDir::new("sibling-dependency-selection");
344 let repo = scratch.join("application");
345 let selected = scratch.join("library-a");
346 let excluded = scratch.join("library-b");
347 for checkout in [&repo, &selected, &excluded] {
348 std::fs::create_dir(checkout).unwrap();
349 git_ok(checkout, &["init"]);
350 std::fs::write(checkout.join("source"), "initial").unwrap();
351 git_ok(checkout, &["add", "source"]);
352 git_ok(checkout, &["commit", "-m", "initial"]);
353 }
354 let revision = sibling_revision(&repo);
355 let empty = sibling_build_revision(&repo, &revision, &[]);
356 let baseline = sibling_build_revision(&repo, &revision, &["library-a"]);
357 assert_ne!(baseline, empty);
358 assert_eq!(
359 baseline,
360 sibling_build_revision(&repo, &revision, &["library-a", "missing", "application"])
361 );
362 std::fs::write(excluded.join("source"), "excluded edit").unwrap();
363 assert_eq!(
364 baseline,
365 sibling_build_revision(&repo, &revision, &["library-a"])
366 );
367 assert_ne!(
368 baseline,
369 sibling_build_revision(&repo, &revision, &["library-a", "library-b"])
370 );
371 std::fs::write(selected.join("source"), "selected edit").unwrap();
372 let edited = sibling_build_revision(&repo, &revision, &["library-a"]);
373 assert_ne!(baseline, edited);
374 assert_eq!(empty, sibling_build_revision(&repo, &revision, &[]));
375 std::fs::write(selected.join("untracked"), "new dependency source").unwrap();
376 assert_ne!(
377 edited,
378 sibling_build_revision(&repo, &revision, &["library-a"])
379 );
380 }
381
382 #[test]
383 fn same_revision_from_different_checkout_roots_reuses_path_and_changes_get_new_paths() {
384 let scratch = crate::ScratchDir::new("sibling-cache-identity");
385 let first = scratch.path().join("first");
386 let second = scratch.path().join("second");
387 std::fs::create_dir_all(&first).unwrap();
388 git_ok(&first, &["init"]);
389 std::fs::write(first.join("source"), "one").unwrap();
390 git_ok(&first, &["add", "source"]);
391 git_ok(&first, &["commit", "-m", "first"]);
392 git_ok(
393 scratch.path(),
394 &["clone", first.to_str().unwrap(), second.to_str().unwrap()],
395 );
396 let cache = scratch.path().join("cache");
397 let a = resolve(
398 &cache,
399 "example",
400 &sibling_revision(&first),
401 "probe",
402 fake_build,
403 );
404 let b = resolve(
405 &cache,
406 "example",
407 &sibling_revision(&second),
408 "probe",
409 |_| panic!("cache hit must not build"),
410 );
411 assert_eq!(a, b);
412 std::fs::write(second.join("source"), "two").unwrap();
413 let dirty = resolve(
414 &cache,
415 "example",
416 &sibling_revision(&second),
417 "probe",
418 fake_build,
419 );
420 assert_ne!(a, dirty);
421 std::fs::write(second.join("source"), "three").unwrap();
422 assert_ne!(
423 dirty,
424 resolve(
425 &cache,
426 "example",
427 &sibling_revision(&second),
428 "probe",
429 fake_build
430 )
431 );
432 git_ok(&second, &["add", "source"]);
433 git_ok(&second, &["commit", "-m", "second"]);
434 assert_ne!(
435 a,
436 resolve(
437 &cache,
438 "example",
439 &sibling_revision(&second),
440 "probe",
441 fake_build
442 )
443 );
444 let clean_revision = sibling_revision(&second);
445 std::fs::write(second.join("untracked"), "new source").unwrap();
446 assert_ne!(clean_revision, sibling_revision(&second));
447 let dirty_revision = sibling_revision(&second);
448 std::fs::write(second.join("untracked"), "edited source").unwrap();
449 assert_ne!(dirty_revision, sibling_revision(&second));
450 }
451
452 #[test]
453 fn concurrent_resolution_builds_once() {
454 let scratch = crate::ScratchDir::new("sibling-cache-concurrent");
455 let root = scratch.path().to_owned();
456 let count = Arc::new(AtomicUsize::new(0));
457 let barrier = Arc::new(Barrier::new(8));
458 let threads: Vec<_> = (0..8)
459 .map(|_| {
460 let root = root.clone();
461 let count = count.clone();
462 let barrier = barrier.clone();
463 std::thread::spawn(move || {
464 barrier.wait();
465 resolve(&root, "example", "revision", "probe", |target| {
466 count.fetch_add(1, Ordering::SeqCst);
467 std::thread::sleep(std::time::Duration::from_millis(30));
468 fake_build(target);
469 })
470 })
471 })
472 .collect();
473 let paths: Vec<_> = threads
474 .into_iter()
475 .map(|thread| thread.join().unwrap())
476 .collect();
477 assert_eq!(count.load(Ordering::SeqCst), 1);
478 assert!(paths.iter().all(|path| path == &paths[0]));
479 }
480
481 #[test]
482 fn cache_prunes_to_four_revisions() {
483 let scratch = crate::ScratchDir::new("sibling-cache-prune");
484 for revision in 0..6 {
485 resolve(
486 scratch.path(),
487 "example",
488 &format!("rev-{revision}"),
489 "probe",
490 fake_build,
491 );
492 }
493 let count = std::fs::read_dir(scratch.path().join("example"))
494 .unwrap()
495 .filter_map(Result::ok)
496 .filter(|entry| entry.file_type().unwrap().is_dir())
497 .count();
498 assert_eq!(count, 4);
499 }
500}