Skip to main content

runner_manager_platform/
git_cache.rs

1//! Independent local Git snapshots for native repository runners. The manager
2//! never fetches from GitHub: checkout keeps its own authentication and ref
3//! selection. Only Git objects and generated Git metadata cross job boundaries.
4
5use 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
67/// Every component below a known root must be a real directory. In particular,
68/// a workflow's symlinked _work must never redirect manager writes elsewhere.
69fn 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(&current) {
81            Ok(()) => {}
82            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
83            Err(error) => return Err(error),
84        }
85        real_directory(&current)?;
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    // A deliberately shared dependency namespace still has distinct Git stores
169    // for distinct repositories. Casing stays in the checkout metadata.
170    // Bound the Windows path budget and avoid reserved Windows directory names.
171    // Metadata checks the full target too, so a digest collision is a cache
172    // miss rather than permission to reuse another repository's objects.
173    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    // The caller's namespace lease excludes LRU pruning. Serialize only this
211    // repository, so a large copy never blocks unrelated dependency-cache
212    // launches or Git operations for other repositories.
213    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    // Inherited Git variables must not redirect a command to another repository,
226    // object store, credentials file, SSH helper or per-command config.
227    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
324/// Record the repository, and seed only an empty standard checkout. A first
325/// attempt is cold; normal checkout authenticates and downloads as before.
326/// The caller must hold the namespace's normal runner lease during this call.
327pub 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    // Check all ancestors before examining or writing the work directory.
344    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    // Keep staging outside retained _work: after a manager crash, ordinary
357    // native cleanup removes it even for a persistent slot. The runtime and
358    // its work directory normally share the filesystem needed for rename.
359    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    // Fresh metadata prevents stale tags, remote refs and branch settings from
379    // leaking into commands such as git describe in the next job.
380    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    // A shallow local clone can fall back to upload-pack and omit objects
389    // unreachable from its current refs. Copy immutable objects directly so
390    // other cached PR tips remain useful without carrying their refs forward.
391    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    // No checkout files were copied. Even cancellation leaves at most our Git
409    // metadata in the disposable workspace, with no reference to the cache.
410    if !checkout.exists() {
411        fs::create_dir(&checkout)?;
412    }
413    fs::rename(fresh_git, checkout.join(".git"))?;
414    Ok(Outcome::Seeded)
415}
416
417/// Read the bounded launch plan; it contains no credentials or cache paths.
418pub 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
443/// Merge only immutable loose objects and pack files, never configuration,
444/// alternates, hooks, refs or working files from a previous generation.
445fn 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        // Two individually bounded inputs can have an oversized union. Never
517        // publish a shallow file that our next launch would refuse to read.
518        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
526/// Called only after the runner has stopped and before the workspace is removed.
527/// The caller must hold the namespace's normal runner lease during this call.
528/// Publish through an atomic pointer, retaining the previous good generation on
529/// every failure. Snapshots borrow no objects from the attempt or other caches.
530pub 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    // actions/checkout's standard HTTPS remote has no .git suffix. Normalize
551    // ordinary `git clone https://.../Repo.git` checkouts to that spelling.
552    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, &copy, true, &template)?;
567    independent(&copy)?;
568    command(&copy, &["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, &copy.join("objects"))?;
574        // Objects from another shallow attempt need their own boundary markers;
575        // otherwise fsck treats their intentionally absent parents as corruption.
576        merge_shallow(&box_dir.join(previous.generation.to_string()), &copy)?;
577    }
578    command(&copy, &["fsck", "--full", "--no-dangling"])?;
579    let generation = uuid::Uuid::new_v4();
580    let published = box_dir.join(generation.to_string());
581    fs::rename(&copy, &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        // Exact UUID-owned generation, checked above; never scan for arbitrary
599        // repositories or recursively delete a workflow-supplied path.
600        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        // Delete the entire local cache. The seeded repository remains valid.
761        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(&copy, &["rev-parse", "--is-shallow-repository"]).unwrap(),
917            b"true\n"
918        );
919        let next = commit(&remote, "next PR merge");
920        command(
921            &copy,
922            &["fetch", "--depth=1", remote.to_str().unwrap(), &next],
923        )
924        .unwrap();
925        command(&copy, &["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}