1use std::collections::BTreeSet;
6use std::ffi::{OsStr, OsString};
7use std::fs;
8use std::io::{self, Read};
9use std::path::{Component, Path, PathBuf};
10use std::time::Duration;
11
12use runner_manager_domain::model::ScaleTarget;
13use serde::{Deserialize, Serialize};
14
15use crate::lock::{HostLock, LockKind};
16use crate::process::{OutputMode, SpawnSpec};
17
18pub const ENVIRONMENT: &str = "RUNNER_MANAGER_GIT_CACHE_DIR";
19const PLAN: &str = "runner-manager-git-cache.json";
20const POINTER: &str = "current.json";
21const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
22const MAX_SHALLOW_BYTES: u64 = 8 * 1024 * 1024;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Outcome {
26 Cold,
27 Seeded,
28 Existing,
29 Updated,
30 Skipped,
31}
32
33#[derive(Debug, Serialize, Deserialize)]
34#[serde(deny_unknown_fields)]
35struct Snapshot {
36 schema: u8,
37 target: String,
38 origin: String,
39 workspace_name: String,
40 generation: uuid::Uuid,
41}
42
43fn failure(message: &str) -> io::Error {
44 io::Error::other(message)
45}
46
47fn plain(metadata: &fs::Metadata) -> bool {
48 #[cfg(windows)]
49 {
50 use std::os::windows::fs::MetadataExt as _;
51 if metadata.file_attributes() & 0x400 != 0 {
52 return false;
53 }
54 }
55 !metadata.file_type().is_symlink()
56}
57
58fn real_directory(path: &Path) -> io::Result<()> {
59 let metadata = fs::symlink_metadata(path)?;
60 if metadata.is_dir() && plain(&metadata) {
61 Ok(())
62 } else {
63 Err(failure("Git cache path is not a regular directory"))
64 }
65}
66
67fn directories(root: &Path, path: &Path) -> io::Result<()> {
70 real_directory(root)?;
71 let suffix = path
72 .strip_prefix(root)
73 .map_err(|_| failure("Git cache path escaped its root"))?;
74 let mut current = root.to_path_buf();
75 for component in suffix.components() {
76 let Component::Normal(name) = component else {
77 return Err(failure("invalid Git cache path component"));
78 };
79 current.push(name);
80 match fs::create_dir(¤t) {
81 Ok(()) => {}
82 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
83 Err(error) => return Err(error),
84 }
85 real_directory(¤t)?;
86 }
87 Ok(())
88}
89
90fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> io::Result<T> {
91 let metadata = fs::symlink_metadata(path)?;
92 if !metadata.is_file() || !plain(&metadata) || metadata.len() > 8192 {
93 return Err(failure("invalid Git cache metadata file"));
94 }
95 let mut bytes = Vec::new();
96 fs::File::open(path)?.take(8193).read_to_end(&mut bytes)?;
97 if bytes.len() > 8192 {
98 return Err(failure("Git cache metadata grew beyond its limit"));
99 }
100 serde_json::from_slice(&bytes).map_err(|_| failure("invalid Git cache metadata"))
101}
102
103fn write_json(path: &Path, value: &impl Serialize) -> io::Result<()> {
104 let bytes =
105 serde_json::to_vec(value).map_err(|_| failure("cannot encode Git cache metadata"))?;
106 crate::host_fitness::write_atomically(path, &bytes)
107}
108
109fn repository(target: &ScaleTarget) -> io::Result<&runner_manager_domain::model::OwnerRepo> {
110 match target {
111 ScaleTarget::Repository(repository) => Ok(repository),
112 ScaleTarget::Organization(_) => Err(failure("Git cache requires repository scope")),
113 }
114}
115
116#[cfg(test)]
117fn checkout_path(runtime: &Path, target: &ScaleTarget) -> io::Result<PathBuf> {
118 let name = repository(target)?.repo();
119 checkout_named(runtime, name)
120}
121
122fn checkout_named(runtime: &Path, name: &str) -> io::Result<PathBuf> {
123 if Path::new(name).components().count() != 1
124 || !matches!(
125 Path::new(name).components().next(),
126 Some(Component::Normal(_))
127 )
128 {
129 return Err(failure("invalid checkout directory name"));
130 }
131 Ok(runtime.join("_work").join(name).join(name))
132}
133
134fn existing_checkout(runtime: &Path, target: &ScaleTarget) -> io::Result<Option<PathBuf>> {
135 real_directory(runtime)?;
136 let work = runtime.join("_work");
137 match real_directory(&work) {
138 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
139 other => other?,
140 }
141 let expected = repository(target)?.repo();
142 let mut found = None;
143 for entry in fs::read_dir(&work)? {
144 let entry = entry?;
145 let name = entry.file_name();
146 let Some(name) = name
147 .to_str()
148 .filter(|name| name.eq_ignore_ascii_case(expected))
149 else {
150 continue;
151 };
152 real_directory(&entry.path())?;
153 let candidate = checkout_named(runtime, name)?;
154 match real_directory(&candidate) {
155 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
156 other => other?,
157 }
158 if found.replace(candidate).is_some() {
159 return Err(failure("ambiguous checkout directory casing"));
160 }
161 }
162 Ok(found)
163}
164
165fn cache_box(git_dir: &Path, target: &ScaleTarget) -> io::Result<PathBuf> {
166 use sha2::Digest as _;
167 repository(target)?;
168 let digest = hex::encode(sha2::Sha256::digest(target.slug().to_ascii_lowercase()));
174 Ok(git_dir.join(&digest[..16]))
175}
176
177fn origin_matches(origin: &str, target: &ScaleTarget) -> bool {
178 origin
179 .strip_prefix("https://github.com/")
180 .is_some_and(|path| {
181 path.eq_ignore_ascii_case(&target.slug())
182 || path
183 .strip_suffix(".git")
184 .unwrap_or(path)
185 .eq_ignore_ascii_case(&target.slug())
186 })
187}
188
189fn snapshot(box_dir: &Path, target: &ScaleTarget) -> io::Result<Option<Snapshot>> {
190 let value: Snapshot = match read_json(&box_dir.join(POINTER)) {
191 Ok(value) => value,
192 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
193 Err(error) => return Err(error),
194 };
195 if value.schema != 1
196 || value.target != target.slug().to_ascii_lowercase()
197 || !origin_matches(&value.origin, target)
198 || !value
199 .workspace_name
200 .eq_ignore_ascii_case(repository(target)?.repo())
201 || checkout_named(Path::new(""), &value.workspace_name).is_err()
202 {
203 return Err(failure("Git cache repository identity mismatch"));
204 }
205 real_directory(&box_dir.join(value.generation.to_string()))?;
206 Ok(Some(value))
207}
208
209fn lock(box_dir: &Path) -> io::Result<HostLock> {
210 HostLock::acquire_at(
214 &box_dir.join(LockKind::GitCache.file_name()),
215 LockKind::GitCache,
216 Duration::from_secs(2),
217 )
218 .map_err(|_| failure("Git cache is busy"))
219}
220
221fn git(arguments: &[OsString]) -> io::Result<Vec<u8>> {
222 let mut spec = SpawnSpec::new("git")
223 .args(arguments)
224 .output(OutputMode::Capture);
225 for (key, _) in std::env::vars_os() {
228 if key
229 .to_string_lossy()
230 .to_ascii_uppercase()
231 .starts_with("GIT_")
232 {
233 spec = spec.without_env(key);
234 }
235 }
236 let null = if cfg!(windows) { "NUL" } else { "/dev/null" };
237 spec = spec
238 .env("GIT_CONFIG_NOSYSTEM", "1")
239 .env("GIT_CONFIG_GLOBAL", null)
240 .env("GIT_TERMINAL_PROMPT", "0")
241 .env("GIT_NO_REPLACE_OBJECTS", "1")
242 .env("GIT_ALLOW_PROTOCOL", "file")
243 .env("GIT_NO_LAZY_FETCH", "1")
244 .env("GIT_CONFIG_COUNT", "3")
245 .env("GIT_CONFIG_KEY_0", "core.hooksPath")
246 .env("GIT_CONFIG_VALUE_0", null)
247 .env("GIT_CONFIG_KEY_1", "maintenance.auto")
248 .env("GIT_CONFIG_VALUE_1", "false")
249 .env("GIT_CONFIG_KEY_2", "core.longpaths")
250 .env("GIT_CONFIG_VALUE_2", "true");
251 let mut child = spec
252 .spawn()
253 .map_err(|_| failure("Git cache command could not start"))?;
254 let stdout = child
255 .take_stdout()
256 .ok_or_else(|| failure("Git cache stdout unavailable"))?;
257 let mut stderr = child
258 .take_stderr()
259 .ok_or_else(|| failure("Git cache stderr unavailable"))?;
260 let output = std::thread::spawn(move || {
261 let mut bytes = Vec::new();
262 stdout.take(8193).read_to_end(&mut bytes).map(|_| bytes)
263 });
264 let errors = std::thread::spawn(move || {
265 let _ = io::copy(&mut stderr, &mut io::sink());
266 });
267 let waited = child.wait_for(COMMAND_TIMEOUT);
268 let status = match waited {
269 Ok(Some(status)) => Ok(status),
270 _ => {
271 let _ = child.stop(Duration::ZERO);
272 Err(failure(
273 "Git cache command timed out or could not be observed",
274 ))
275 }
276 };
277 let bytes = output
278 .join()
279 .map_err(|_| failure("Git cache output unavailable"))??;
280 let _ = errors.join();
281 if !status?.success() || bytes.len() > 8192 {
282 return Err(failure("Git cache command failed"));
283 }
284 Ok(bytes)
285}
286
287fn command(path: &Path, arguments: &[&str]) -> io::Result<Vec<u8>> {
288 let mut args = vec![OsString::from("-C"), path.as_os_str().to_owned()];
289 args.extend(arguments.iter().map(OsString::from));
290 git(&args)
291}
292
293fn clone(source: &Path, destination: &Path, bare: bool, template: &Path) -> io::Result<()> {
294 let mut args: Vec<OsString> = ["clone", "--local", "--no-hardlinks", "--no-checkout"]
295 .into_iter()
296 .map(OsString::from)
297 .collect();
298 if bare {
299 args.push("--bare".into());
300 }
301 args.push(format!("--template={}", template.display()).into());
302 args.push("--".into());
303 args.push(source.as_os_str().to_owned());
304 args.push(destination.as_os_str().to_owned());
305 git(&args).map(|_| ())
306}
307
308fn independent(git_dir: &Path) -> io::Result<()> {
309 real_directory(git_dir)?;
310 real_directory(&git_dir.join("objects"))?;
311 for path in [
312 git_dir.join("commondir"),
313 git_dir.join("objects/info/alternates"),
314 git_dir.join("objects/info/http-alternates"),
315 ] {
316 match fs::symlink_metadata(path) {
317 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
318 _ => return Err(failure("Git repository uses an external object store")),
319 }
320 }
321 Ok(())
322}
323
324pub fn prepare(
328 root: &Path,
329 git_dir: &Path,
330 target: &ScaleTarget,
331 runtime: &Path,
332) -> io::Result<Outcome> {
333 repository(target)?;
334 directories(runtime, &runtime.join("_diag"))?;
335 write_json(&runtime.join("_diag").join(PLAN), target)?;
336 let box_dir = cache_box(git_dir, target)?;
337 directories(root, &box_dir)?;
338 let _lock = lock(&box_dir)?;
339 let Some(cached) = snapshot(&box_dir, target)? else {
340 return Ok(Outcome::Cold);
341 };
342 let checkout = checkout_named(runtime, &cached.workspace_name)?;
343 directories(runtime, checkout.parent().expect("checkout parent"))?;
345 match fs::symlink_metadata(&checkout) {
346 Ok(metadata) if !metadata.is_dir() || !plain(&metadata) => {
347 return Err(failure("checkout path is not a regular directory"));
348 }
349 Ok(_) if fs::read_dir(&checkout)?.next().is_some() => return Ok(Outcome::Existing),
350 Ok(_) => {}
351 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
352 Err(error) => return Err(error),
353 }
354 let source = box_dir.join(cached.generation.to_string());
355 independent(&source)?;
356 let stage = tempfile::Builder::new()
360 .prefix("git-seed-")
361 .tempdir_in(runtime)?;
362 let template = stage.path().join("template");
363 fs::create_dir(&template)?;
364 let format = command(&source, &["rev-parse", "--show-object-format"])?;
365 let format = std::str::from_utf8(&format)
366 .map_err(|_| failure("invalid Git object format"))?
367 .trim();
368 if !matches!(format, "sha1" | "sha256") {
369 return Err(failure("unsupported Git object format"));
370 }
371 let head = command(&source, &["rev-parse", "--verify", "HEAD^{commit}"])?;
372 let head = std::str::from_utf8(&head)
373 .map_err(|_| failure("invalid cached HEAD"))?
374 .trim();
375 if !hex_name(OsStr::new(head), &[40, 64]) {
376 return Err(failure("invalid cached HEAD"));
377 }
378 let fresh = stage.path().join("fresh");
381 git(&[
382 "init".into(),
383 format!("--object-format={format}").into(),
384 format!("--template={}", template.display()).into(),
385 fresh.as_os_str().to_owned(),
386 ])?;
387 let fresh_git = fresh.join(".git");
388 merge_objects(&source.join("objects"), &fresh_git.join("objects"))?;
392 merge_shallow(&source, &fresh_git)?;
393 command(&fresh, &["remote", "add", "origin", &cached.origin])?;
394 command(
395 &fresh,
396 &[
397 "-c",
398 "user.name=Runner Manager cache",
399 "-c",
400 "user.email=runner-manager@localhost",
401 "update-ref",
402 "--no-deref",
403 "HEAD",
404 head,
405 ],
406 )?;
407 command(&fresh, &["fsck", "--full", "--no-dangling"])?;
408 if !checkout.exists() {
411 fs::create_dir(&checkout)?;
412 }
413 fs::rename(fresh_git, checkout.join(".git"))?;
414 Ok(Outcome::Seeded)
415}
416
417pub fn target_for_runtime(runtime: &Path) -> io::Result<Option<ScaleTarget>> {
419 match fs::symlink_metadata(runtime.join("_diag")) {
420 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
421 Err(error) => return Err(error),
422 Ok(metadata) if !metadata.is_dir() || !plain(&metadata) => {
423 return Err(failure("invalid diagnostic directory"));
424 }
425 Ok(_) => {}
426 }
427 match read_json(&runtime.join("_diag").join(PLAN)) {
428 Ok(target) => Ok(Some(target)),
429 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
430 Err(error) => Err(error),
431 }
432}
433
434fn hex_name(value: &OsStr, lengths: &[usize]) -> bool {
435 value.to_str().is_some_and(|value| {
436 lengths.contains(&value.len())
437 && value
438 .bytes()
439 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
440 })
441}
442
443fn merge_objects(source: &Path, destination: &Path) -> io::Result<()> {
446 real_directory(source)?;
447 for directory in fs::read_dir(source)? {
448 let directory = directory?;
449 let name = directory.file_name();
450 if name != OsStr::new("pack") && !hex_name(&name, &[2]) {
451 continue;
452 }
453 real_directory(&directory.path())?;
454 let target = destination.join(&name);
455 fs::create_dir_all(&target)?;
456 real_directory(&target)?;
457 for file in fs::read_dir(directory.path())? {
458 let file = file?;
459 let filename = file.file_name();
460 let valid_pack = filename.to_str().is_some_and(|name| {
461 let Some((stem, extension)) = name.rsplit_once('.') else {
462 return false;
463 };
464 stem.strip_prefix("pack-")
465 .is_some_and(|hash| hex_name(OsStr::new(hash), &[40, 64]))
466 && matches!(extension, "pack" | "idx" | "rev")
467 });
468 if !(if name == OsStr::new("pack") {
469 valid_pack
470 } else {
471 hex_name(&filename, &[38, 62])
472 }) {
473 continue;
474 }
475 let metadata = fs::symlink_metadata(file.path())?;
476 if !metadata.is_file() || !plain(&metadata) {
477 return Err(failure("invalid Git object file"));
478 }
479 let target = target.join(filename);
480 if !target.exists() {
481 fs::copy(file.path(), target)?;
482 }
483 }
484 }
485 Ok(())
486}
487
488fn merge_shallow(source: &Path, destination: &Path) -> io::Result<()> {
489 let mut boundaries = BTreeSet::new();
490 for path in [source.join("shallow"), destination.join("shallow")] {
491 let metadata = match fs::symlink_metadata(&path) {
492 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
493 other => other?,
494 };
495 if !metadata.is_file() || !plain(&metadata) || metadata.len() > MAX_SHALLOW_BYTES {
496 return Err(failure("invalid shallow boundary file"));
497 }
498 let mut bytes = Vec::new();
499 fs::File::open(&path)?
500 .take(MAX_SHALLOW_BYTES + 1)
501 .read_to_end(&mut bytes)?;
502 if bytes.len() as u64 > MAX_SHALLOW_BYTES {
503 return Err(failure("shallow boundary file is too large"));
504 }
505 let text =
506 std::str::from_utf8(&bytes).map_err(|_| failure("invalid shallow boundaries"))?;
507 for boundary in text.lines() {
508 if !hex_name(OsStr::new(boundary), &[40, 64]) {
509 return Err(failure("invalid shallow boundary"));
510 }
511 boundaries.insert(boundary.to_owned());
512 }
513 }
514 if !boundaries.is_empty() {
515 let text = boundaries.into_iter().collect::<Vec<_>>().join("\n") + "\n";
516 if text.len() as u64 > MAX_SHALLOW_BYTES {
519 return Err(failure("merged shallow boundaries are too large"));
520 }
521 crate::host_fitness::write_atomically(&destination.join("shallow"), text.as_bytes())?;
522 }
523 Ok(())
524}
525
526pub fn capture(
531 root: &Path,
532 git_dir: &Path,
533 target: &ScaleTarget,
534 runtime: &Path,
535) -> io::Result<Outcome> {
536 let Some(source) = existing_checkout(runtime, target)? else {
537 return Ok(Outcome::Skipped);
538 };
539 if !source.join(".git").exists() {
540 return Ok(Outcome::Skipped);
541 }
542 independent(&source.join(".git"))?;
543 let origin = command(&source, &["remote", "get-url", "origin"])?;
544 let origin = std::str::from_utf8(&origin)
545 .map_err(|_| failure("invalid Git origin"))?
546 .trim();
547 if !origin_matches(origin, target) {
548 return Ok(Outcome::Skipped);
549 }
550 let origin = origin
553 .strip_suffix(".git")
554 .filter(|without_suffix| origin_matches(without_suffix, target))
555 .unwrap_or(origin);
556 let box_dir = cache_box(git_dir, target)?;
557 directories(root, &box_dir)?;
558 let _lock = lock(&box_dir)?;
559 let previous = snapshot(&box_dir, target).ok().flatten();
560 let stage = tempfile::Builder::new()
561 .prefix("git-stage-")
562 .tempdir_in(&box_dir)?;
563 let template = stage.path().join("template");
564 fs::create_dir(&template)?;
565 let copy = stage.path().join("copy");
566 clone(&source, ©, true, &template)?;
567 independent(©)?;
568 command(©, &["remote", "set-url", "origin", origin])?;
569 if let Some(previous) = &previous {
570 let objects = box_dir
571 .join(previous.generation.to_string())
572 .join("objects");
573 merge_objects(&objects, ©.join("objects"))?;
574 merge_shallow(&box_dir.join(previous.generation.to_string()), ©)?;
577 }
578 command(©, &["fsck", "--full", "--no-dangling"])?;
579 let generation = uuid::Uuid::new_v4();
580 let published = box_dir.join(generation.to_string());
581 fs::rename(©, &published)?;
582 let value = Snapshot {
583 schema: 1,
584 target: target.slug().to_ascii_lowercase(),
585 origin: origin.into(),
586 workspace_name: source
587 .file_name()
588 .and_then(OsStr::to_str)
589 .ok_or_else(|| failure("invalid workspace name"))?
590 .into(),
591 generation,
592 };
593 if let Err(error) = write_json(&box_dir.join(POINTER), &value) {
594 let _ = remove_dir_all::remove_dir_all(&published);
595 return Err(error);
596 }
597 if let Some(previous) = previous {
598 let old = box_dir.join(previous.generation.to_string());
601 if real_directory(&old).is_ok() {
602 let _ = remove_dir_all::remove_dir_all(old);
603 }
604 }
605 Ok(Outcome::Updated)
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 #[test]
613 fn shallow_union_limit_preserves_the_last_readable_destination() {
614 use std::fmt::Write as _;
615 let root = tempfile::tempdir().unwrap();
616 let source = root.path().join("source");
617 let destination = root.path().join("destination");
618 fs::create_dir(&source).unwrap();
619 fs::create_dir(&destination).unwrap();
620 let count = MAX_SHALLOW_BYTES as usize / 41;
621 let mut before = String::new();
622 for index in 0..count {
623 writeln!(before, "{index:040x}").unwrap();
624 }
625 fs::write(destination.join("shallow"), &before).unwrap();
626 fs::write(source.join("shallow"), format!("{:040x}\n", count - 1)).unwrap();
627 merge_shallow(&source, &destination).unwrap();
628 assert_eq!(
629 fs::read(destination.join("shallow")).unwrap(),
630 before.as_bytes()
631 );
632 fs::write(source.join("shallow"), format!("{count:040x}\n")).unwrap();
633 assert!(merge_shallow(&source, &destination).is_err());
634 assert_eq!(
635 fs::read(destination.join("shallow")).unwrap(),
636 before.as_bytes()
637 );
638 }
639
640 struct Fixture {
641 root: tempfile::TempDir,
642 cache: PathBuf,
643 git_dir: PathBuf,
644 target: ScaleTarget,
645 }
646
647 impl Fixture {
648 fn new() -> Self {
649 let root = tempfile::tempdir().unwrap();
650 let cache = root.path().join("cache");
651 fs::create_dir(&cache).unwrap();
652 Self {
653 git_dir: cache.join("octo/repo/git"),
654 cache,
655 root,
656 target: ScaleTarget::repository("Octo/Repo").unwrap(),
657 }
658 }
659
660 fn runtime(&self, name: &str) -> PathBuf {
661 let path = self.root.path().join(name);
662 fs::create_dir(&path).unwrap();
663 path
664 }
665
666 fn source(&self, runtime: &Path) -> PathBuf {
667 let checkout = checkout_path(runtime, &self.target).unwrap();
668 fs::create_dir_all(&checkout).unwrap();
669 command(&checkout, &["init", "--initial-branch=main"]).unwrap();
670 command(&checkout, &["config", "user.name", "Cache fixture"]).unwrap();
671 command(
672 &checkout,
673 &["config", "user.email", "cache@example.invalid"],
674 )
675 .unwrap();
676 command(&checkout, &["config", "commit.gpgsign", "false"]).unwrap();
677 command(
678 &checkout,
679 &[
680 "remote",
681 "add",
682 "origin",
683 &format!("https://github.com/{}", self.target.slug()),
684 ],
685 )
686 .unwrap();
687 checkout
688 }
689
690 fn prepare(&self, runtime: &Path) -> io::Result<Outcome> {
691 prepare(&self.cache, &self.git_dir, &self.target, runtime)
692 }
693
694 fn capture(&self, runtime: &Path) -> io::Result<Outcome> {
695 capture(&self.cache, &self.git_dir, &self.target, runtime)
696 }
697 }
698
699 fn commit(path: &Path, content: &str) -> String {
700 fs::write(path.join("tracked.txt"), content).unwrap();
701 command(path, &["add", "tracked.txt"]).unwrap();
702 command(path, &["commit", "--quiet", "-m", content]).unwrap();
703 String::from_utf8(command(path, &["rev-parse", "HEAD"]).unwrap())
704 .unwrap()
705 .trim()
706 .into()
707 }
708
709 #[test]
710 fn cold_to_warm_checkout_is_independent_and_copies_no_credentials_or_working_files() {
711 let fixture = Fixture::new();
712 let first = fixture.runtime("first");
713 assert_eq!(fixture.prepare(&first).unwrap(), Outcome::Cold);
714 assert_eq!(
715 target_for_runtime(&first).unwrap(),
716 Some(fixture.target.clone())
717 );
718 let source = fixture.source(&first);
719 let initial = commit(&source, "first version");
720 command(&source, &["tag", "old-cache-tag"]).unwrap();
721 command(&source, &["branch", "old-cache-branch"]).unwrap();
722 fs::write(source.join("untracked-secret"), b"secret").unwrap();
723 command(
724 &source,
725 &[
726 "config",
727 "http.https://github.com/.extraheader",
728 "AUTHORIZATION: secret-fixture-token",
729 ],
730 )
731 .unwrap();
732 fs::write(
733 source.join(".git/hooks/post-checkout"),
734 b"#!/bin/sh\nexit 97\n",
735 )
736 .unwrap();
737 assert_eq!(fixture.capture(&first).unwrap(), Outcome::Updated);
738
739 let second = fixture.runtime("second");
740 assert_eq!(fixture.prepare(&second).unwrap(), Outcome::Seeded);
741 let checkout = checkout_path(&second, &fixture.target).unwrap();
742 assert!(!checkout.join("tracked.txt").exists());
743 assert!(!checkout.join("untracked-secret").exists());
744 assert!(!checkout.join(".git/objects/info/alternates").exists());
745 assert!(!checkout.join(".git/hooks/post-checkout").exists());
746 let config = fs::read_to_string(checkout.join(".git/config")).unwrap();
747 assert!(!config.contains("secret-fixture-token"));
748 assert!(!config.contains("extraheader"));
749 assert!(!config.contains("branch."));
750 assert!(
751 command(&checkout, &["for-each-ref", "--format=%(refname)"])
752 .unwrap()
753 .is_empty(),
754 "old tags and branches must not affect the next job"
755 );
756 assert_eq!(
757 command(&checkout, &["remote", "get-url", "origin"]).unwrap(),
758 b"https://github.com/Octo/Repo\n"
759 );
760 remove_dir_all::remove_dir_all(&fixture.cache).unwrap();
762 command(&checkout, &["fsck", "--full", "--no-dangling"]).unwrap();
763 command(&checkout, &["checkout", "--detach", &initial]).unwrap();
764 assert_eq!(
765 fs::read_to_string(checkout.join("tracked.txt")).unwrap(),
766 "first version"
767 );
768
769 let next = commit(&source, "new remote version");
770 let source_path = source.to_str().unwrap();
771 command(&checkout, &["fetch", "--no-tags", source_path, &next]).unwrap();
772 command(&checkout, &["checkout", "--detach", "FETCH_HEAD"]).unwrap();
773 assert_eq!(
774 fs::read_to_string(checkout.join("tracked.txt")).unwrap(),
775 "new remote version"
776 );
777 }
778
779 #[test]
780 fn publications_from_two_cold_attempts_keep_both_sets_of_objects() {
781 let fixture = Fixture::new();
782 let first = fixture.runtime("first");
783 let second = fixture.runtime("second");
784 let source_a = fixture.source(&first);
785 let source_b = fixture.source(&second);
786 let a = commit(&source_a, "branch A");
787 let b = commit(&source_b, "branch B");
788 fixture.capture(&first).unwrap();
789 fixture.capture(&second).unwrap();
790 let third = fixture.runtime("third");
791 fixture.prepare(&third).unwrap();
792 let checkout = checkout_path(&third, &fixture.target).unwrap();
793 command(&checkout, &["cat-file", "-e", &a]).unwrap();
794 command(&checkout, &["cat-file", "-e", &b]).unwrap();
795 let box_dir = cache_box(&fixture.git_dir, &fixture.target).unwrap();
796 assert_eq!(
797 fs::read_dir(box_dir)
798 .unwrap()
799 .filter_map(Result::ok)
800 .filter(|entry| entry.file_type().unwrap().is_dir())
801 .count(),
802 1,
803 "a publication retires its previous generation"
804 );
805 }
806
807 #[test]
808 fn repositories_stay_separate_inside_a_shared_dependency_namespace() {
809 let mut fixture = Fixture::new();
810 let first = fixture.runtime("first");
811 let source = fixture.source(&first);
812 let a = commit(&source, "repository A");
813 fixture.capture(&first).unwrap();
814 fixture.target = ScaleTarget::repository("Octo/Other").unwrap();
815 let second = fixture.runtime("second");
816 let source = fixture.source(&second);
817 let b = commit(&source, "repository B");
818 fixture.capture(&second).unwrap();
819 fixture.target = ScaleTarget::repository("Octo/Repo").unwrap();
820 let third = fixture.runtime("third");
821 fixture.prepare(&third).unwrap();
822 let checkout = checkout_path(&third, &fixture.target).unwrap();
823 command(&checkout, &["cat-file", "-e", &a]).unwrap();
824 assert!(command(&checkout, &["cat-file", "-e", &b]).is_err());
825 }
826
827 #[test]
828 fn long_repository_names_use_a_bounded_cache_path() {
829 let mut fixture = Fixture::new();
830 fixture.target =
831 ScaleTarget::repository(format!("Octo/{}", "repository".repeat(6))).unwrap();
832 let first = fixture.runtime("first");
833 let source = fixture.source(&first);
834 let head = commit(&source, "long name");
835 fixture.capture(&first).unwrap();
836 assert_eq!(
837 cache_box(&fixture.git_dir, &fixture.target)
838 .unwrap()
839 .file_name()
840 .unwrap()
841 .len(),
842 16
843 );
844 let second = fixture.runtime("second");
845 fixture.prepare(&second).unwrap();
846 let checkout = checkout_path(&second, &fixture.target).unwrap();
847 assert_eq!(
848 command(&checkout, &["rev-parse", "HEAD"]).unwrap(),
849 format!("{head}\n").as_bytes()
850 );
851 }
852
853 #[test]
854 fn a_repository_copy_does_not_hold_the_global_dependency_cache_lock() {
855 let fixture = Fixture::new();
856 let box_dir = cache_box(&fixture.git_dir, &fixture.target).unwrap();
857 directories(&fixture.cache, &box_dir).unwrap();
858 let _git = lock(&box_dir).unwrap();
859 let _global = HostLock::acquire_at(
860 &fixture.cache.join(LockKind::DependencyCache.file_name()),
861 LockKind::DependencyCache,
862 Duration::ZERO,
863 )
864 .unwrap();
865 let other = cache_box(
866 &fixture.git_dir,
867 &ScaleTarget::repository("Octo/Other").unwrap(),
868 )
869 .unwrap();
870 directories(&fixture.cache, &other).unwrap();
871 let _other = lock(&other).unwrap();
872 assert!(
873 lock(&box_dir).is_err(),
874 "only copies of the same repository serialize"
875 );
876 }
877
878 #[test]
879 fn shallow_detached_pr_checkout_round_trips_and_can_fetch_the_next_commit() {
880 let fixture = Fixture::new();
881 let remote_runtime = fixture.runtime("remote");
882 let remote = fixture.source(&remote_runtime);
883 commit(&remote, "base");
884 let pr = commit(&remote, "PR merge commit");
885 let first = fixture.runtime("first");
886 let checkout = checkout_path(&first, &fixture.target).unwrap();
887 fs::create_dir_all(checkout.parent().unwrap()).unwrap();
888 let prefix = if cfg!(windows) { "file:///" } else { "file://" };
889 let url = format!(
890 "{prefix}{}",
891 remote.display().to_string().replace('\\', "/")
892 );
893 git(&[
894 "clone".into(),
895 "--depth=1".into(),
896 url.into(),
897 checkout.as_os_str().to_owned(),
898 ])
899 .unwrap();
900 command(&checkout, &["checkout", "--detach", &pr]).unwrap();
901 command(
902 &checkout,
903 &[
904 "remote",
905 "set-url",
906 "origin",
907 "https://github.com/Octo/Repo",
908 ],
909 )
910 .unwrap();
911 fixture.capture(&first).unwrap();
912 let second = fixture.runtime("second");
913 assert_eq!(fixture.prepare(&second).unwrap(), Outcome::Seeded);
914 let copy = checkout_path(&second, &fixture.target).unwrap();
915 assert_eq!(
916 command(©, &["rev-parse", "--is-shallow-repository"]).unwrap(),
917 b"true\n"
918 );
919 let next = commit(&remote, "next PR merge");
920 command(
921 ©,
922 &["fetch", "--depth=1", remote.to_str().unwrap(), &next],
923 )
924 .unwrap();
925 command(©, &["checkout", "--detach", "FETCH_HEAD"]).unwrap();
926 assert_eq!(
927 fs::read_to_string(copy.join("tracked.txt")).unwrap(),
928 "next PR merge"
929 );
930 }
931
932 #[test]
933 fn two_cold_shallow_publications_preserve_both_boundary_sets() {
934 let fixture = Fixture::new();
935 let remote_runtime = fixture.runtime("remote");
936 let remote = fixture.source(&remote_runtime);
937 let base = commit(&remote, "base");
938 let a = commit(&remote, "branch A");
939 command(&remote, &["branch", "branch-a"]).unwrap();
940 command(&remote, &["reset", "--hard", &base]).unwrap();
941 let b = commit(&remote, "branch B");
942 command(&remote, &["branch", "branch-b"]).unwrap();
943 let prefix = if cfg!(windows) { "file:///" } else { "file://" };
944 let url = format!(
945 "{prefix}{}",
946 remote.display().to_string().replace('\\', "/")
947 );
948 for (name, branch) in [("first", "branch-a"), ("second", "branch-b")] {
949 let runtime = fixture.runtime(name);
950 let checkout = checkout_path(&runtime, &fixture.target).unwrap();
951 fs::create_dir_all(checkout.parent().unwrap()).unwrap();
952 git(&[
953 "clone".into(),
954 "--depth=1".into(),
955 format!("--branch={branch}").into(),
956 url.clone().into(),
957 checkout.as_os_str().to_owned(),
958 ])
959 .unwrap();
960 command(
961 &checkout,
962 &[
963 "remote",
964 "set-url",
965 "origin",
966 "https://github.com/Octo/Repo",
967 ],
968 )
969 .unwrap();
970 fixture.capture(&runtime).unwrap();
971 }
972 let third = fixture.runtime("third");
973 fixture.prepare(&third).unwrap();
974 let checkout = checkout_path(&third, &fixture.target).unwrap();
975 assert_eq!(
976 command(&checkout, &["rev-parse", "HEAD"]).unwrap(),
977 format!("{b}\n").as_bytes()
978 );
979 command(&checkout, &["cat-file", "-e", &a]).unwrap();
980 command(&checkout, &["fsck", "--full", "--no-dangling"]).unwrap();
981 }
982
983 #[test]
984 fn nonempty_workspaces_wrong_origins_and_external_object_stores_are_preserved() {
985 let fixture = Fixture::new();
986 let first = fixture.runtime("first");
987 let source = fixture.source(&first);
988 commit(&source, "base");
989 command(
990 &source,
991 &[
992 "remote",
993 "set-url",
994 "origin",
995 "https://token@github.com/Octo/Repo",
996 ],
997 )
998 .unwrap();
999 assert_eq!(fixture.capture(&first).unwrap(), Outcome::Skipped);
1000 command(
1001 &source,
1002 &[
1003 "remote",
1004 "set-url",
1005 "origin",
1006 "https://github.com/Other/Repo",
1007 ],
1008 )
1009 .unwrap();
1010 assert_eq!(fixture.capture(&first).unwrap(), Outcome::Skipped);
1011 command(
1012 &source,
1013 &[
1014 "remote",
1015 "set-url",
1016 "origin",
1017 "https://github.com/Octo/Repo",
1018 ],
1019 )
1020 .unwrap();
1021 fixture.capture(&first).unwrap();
1022 fs::write(source.join("sentinel"), b"preserve").unwrap();
1023 assert_eq!(fixture.prepare(&first).unwrap(), Outcome::Existing);
1024 assert_eq!(fs::read(source.join("sentinel")).unwrap(), b"preserve");
1025 fs::write(
1026 source.join(".git/objects/info/alternates"),
1027 b"/another/store\n",
1028 )
1029 .unwrap();
1030 assert!(fixture.capture(&first).is_err());
1031 let second = fixture.runtime("second");
1032 assert_eq!(
1033 fixture.prepare(&second).unwrap(),
1034 Outcome::Seeded,
1035 "a refused publication leaves the last good snapshot intact"
1036 );
1037 }
1038
1039 #[cfg(unix)]
1040 #[test]
1041 fn symlinked_workspace_is_never_followed_for_seed_or_capture() {
1042 let fixture = Fixture::new();
1043 let first = fixture.runtime("first");
1044 let source = fixture.source(&first);
1045 commit(&source, "base");
1046 fixture.capture(&first).unwrap();
1047 let second = fixture.runtime("second");
1048 let victim = fixture.runtime("victim");
1049 fs::write(victim.join("sentinel"), b"preserve").unwrap();
1050 std::os::unix::fs::symlink(&victim, second.join("_work")).unwrap();
1051 assert!(fixture.prepare(&second).is_err());
1052 assert!(fixture.capture(&second).is_err());
1053 assert_eq!(fs::read_dir(&victim).unwrap().count(), 1);
1054 }
1055}