Skip to main content

shuyi_cli/
private_assets.rs

1//! `private-assets [--library | --pin]`: fetches the files a game may not
2//! hand out (bought art, say) from a private repository, checked out into
3//! a git-ignored folder of the project ([`PrivateAssets::checkout`]) at the
4//! commit the project's pin file names ([`PrivateAssets::pin_file`]).
5//!
6//! With no flag the command clones the checkout if it isn't there (only
7//! [`PrivateAssets::folders`], and without the history's files), fetches,
8//! and puts it at the pinned commit; then it copies
9//! [`PrivateAssets::copies`] out of it into the project (voice clips that
10//! ship as files beside the game, say). `--library` also brings
11//! [`PrivateAssets::library`]. `--pin` writes the commit the checkout is
12//! at into the pin file, after checking that it is pushed to the
13//! repository's `main` and that every file in the credited folders has a
14//! credit ([`PrivateAssets::credits`]). A full clone made by hand is left
15//! whole.
16
17use std::fmt::Write as _;
18use std::fs;
19use std::path::Path;
20use std::process::Command;
21
22use shuyi_assets::credits::{self, Places};
23
24use crate::project::{Credited, PrivateAssets};
25use crate::web::copy_tree;
26
27/// The usage line, with `command` as the tool's name.
28pub fn usage(rules: &PrivateAssets, command: &str) -> String {
29    let flags = if rules.library.is_some() {
30        "[--library | --pin]"
31    } else {
32        "[--pin]"
33    };
34    format!("usage: {command} private-assets {flags}")
35}
36
37/// What the command was asked to do.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Mode {
40    /// Clone or update the checkout and put it at the pinned commit.
41    Fetch {
42        /// Whether to bring the library folder too.
43        library: bool,
44    },
45    /// Write the commit the checkout is at into the pin file.
46    Pin,
47}
48
49/// Parses the arguments after `private-assets`. The error is the usage
50/// line.
51pub fn parse_args(rules: &PrivateAssets, command: &str, args: &[String]) -> Result<Mode, String> {
52    match args {
53        [] => Ok(Mode::Fetch { library: false }),
54        [flag] if flag == "--library" && rules.library.is_some() => {
55            Ok(Mode::Fetch { library: true })
56        }
57        [flag] if flag == "--pin" => Ok(Mode::Pin),
58        _ => Err(usage(rules, command)),
59    }
60}
61
62/// Runs `git <args>` in `dir`: its output, or what it printed as the error.
63/// It never stops to ask for a password on the terminal.
64fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
65    let output = Command::new("git")
66        .args(args)
67        .current_dir(dir)
68        .env("GIT_TERMINAL_PROMPT", "0")
69        .output()
70        .map_err(|e| format!("spawn git: {e}"))?;
71    if !output.status.success() {
72        return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
73    }
74    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
75}
76
77/// The error for a clone or fetch of `url` that git refused with `said`.
78fn no_access(rules: &PrivateAssets, url: &str, said: &str) -> String {
79    format!(
80        "can't read {url}\n  git said: {said}\n  {}",
81        rules.no_access
82    )
83}
84
85/// The commit named by the pin file: 40 lower-case hex digits.
86fn read_pin(rules: &PrivateAssets, root: &Path) -> Result<String, String> {
87    let file = &rules.pin_file;
88    let text = fs::read_to_string(root.join(file)).map_err(|e| format!("{file}: {e}"))?;
89    let pin = text.trim();
90    let is_hex = |c: char| c.is_ascii_digit() || ('a'..='f').contains(&c);
91    if pin.len() != 40 || !pin.chars().all(is_hex) {
92        return Err(format!(
93            "{file}: expected a 40-character commit id, found `{pin}`"
94        ));
95    }
96    Ok(pin.to_string())
97}
98
99/// Whether `dir` is a folder with something in it.
100fn has_entries(dir: &Path) -> bool {
101    fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_some())
102}
103
104/// Clones `url` into the checkout if it isn't there, fetches, and puts it
105/// at the pinned commit. A clone made here holds only the rules' folders
106/// (and the library once asked for with `library`); a full clone made by
107/// hand is left whole. Returns a one-line summary.
108fn fetch(rules: &PrivateAssets, root: &Path, url: &str, library: bool) -> Result<String, String> {
109    let name = &rules.checkout;
110    let pin = read_pin(rules, root)?;
111    let checkout = root.join(name);
112    let (first, others) = rules
113        .folders
114        .split_first()
115        .ok_or("shuyi.ron: `private_assets.folders` is empty")?;
116    if checkout.join(".git").exists() {
117        git(&checkout, &["fetch", "--quiet", "origin"]).map_err(|e| no_access(rules, url, &e))?;
118    } else {
119        if has_entries(&checkout) {
120            return Err(format!(
121                "{name}/ has files but isn't a git checkout: move it away first"
122            ));
123        }
124        let clone = ["clone", "--quiet", "--filter=blob:none", "--no-checkout"];
125        let long_paths = ["--config", "core.longpaths=true"];
126        git(root, &[&clone[..], &long_paths[..], &[url, name]].concat())
127            .map_err(|e| no_access(rules, url, &e))?;
128        git(&checkout, &["sparse-checkout", "set", "--cone", first])?;
129    }
130
131    // Unset (a full clone) is an error from `git config --get`.
132    let sparse =
133        git(&checkout, &["config", "--get", "core.sparseCheckout"]).as_deref() == Ok("true");
134    if sparse && !others.is_empty() {
135        // A clone made before the other folders existed holds only the
136        // first.
137        let others: Vec<&str> = others.iter().map(String::as_str).collect();
138        git(
139            &checkout,
140            &[&["sparse-checkout", "add"][..], &others].concat(),
141        )?;
142    }
143    if let Some(dir) = rules.library.as_deref().filter(|_| library && sparse) {
144        git(&checkout, &["sparse-checkout", "add", dir])?;
145    }
146
147    // A clone whose files were never checked out (new, or a first fetch that
148    // stopped half-way) has an empty index. Otherwise a checkout already at
149    // the pin is left as it is (on its branch, if any).
150    let never_checked_out = git(&checkout, &["ls-files"])?.is_empty();
151    if never_checked_out || git(&checkout, &["rev-parse", "HEAD"])? != pin {
152        git(&checkout, &["checkout", "--quiet", "--detach", &pin]).map_err(|e| {
153            format!("can't check out the pinned commit {pin} in {name}/\n  git said: {e}")
154        })?;
155    }
156
157    let holds = if sparse {
158        let folders = git(&checkout, &["sparse-checkout", "list"])?;
159        folders.lines().collect::<Vec<_>>().join(", ")
160    } else {
161        "the whole repository".to_string()
162    };
163    let mut copied = String::new();
164    for copy in &rules.copies {
165        if let Some(files) = copy_out(&checkout.join(&copy.from), &root.join(&copy.to))? {
166            // Writing to a `String` can't fail.
167            let _ = write!(
168                copied,
169                "; {}/ holds its {files} {} files",
170                copy.to, copy.from
171            );
172        }
173    }
174    Ok(format!("{name}/ is at {pin} ({holds}){copied}"))
175}
176
177/// Replaces `dst` with a copy of the checkout's folder `src`, returning
178/// how many files it holds. With no such folder, `dst` is left as it is
179/// and `None` is returned.
180fn copy_out(src: &Path, dst: &Path) -> Result<Option<usize>, String> {
181    if !src.is_dir() {
182        return Ok(None);
183    }
184    if dst.exists() {
185        fs::remove_dir_all(dst).map_err(|e| format!("clear {}: {e}", dst.display()))?;
186    }
187    copy_tree(src, dst, &|_| true).map(Some)
188}
189
190/// The paths (`/`-separated, relative to `dir`, each after `prefix`) of
191/// every file in `dir` and the folders below it.
192fn files_below(dir: &Path, prefix: &str, paths: &mut Vec<String>) -> Result<(), String> {
193    let entries = fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
194    for entry in entries.flatten() {
195        let path = format!("{prefix}{}", entry.file_name().to_string_lossy());
196        if entry.path().is_dir() {
197            files_below(&entry.path(), &format!("{path}/"), paths)?;
198        } else {
199            paths.push(path);
200        }
201    }
202    Ok(())
203}
204
205/// The files of the checkout's folder `dir` that no entry of the project's
206/// credits file covers, sorted. None for a folder that isn't there.
207fn uncredited(
208    rules: &PrivateAssets,
209    credited: &Credited,
210    root: &Path,
211    dir: &str,
212) -> Result<Vec<String>, String> {
213    let credits_file = &credited.file;
214    let source =
215        fs::read_to_string(root.join(credits_file)).map_err(|e| format!("{credits_file}: {e}"))?;
216    let places = Places {
217        audio_manifest: &credited.audio_manifest,
218        private_root: &credited.private_root,
219    };
220    let file = credits::from_source(credits_file, &source, &places).map_err(|errors| {
221        let lines: Vec<String> = errors.iter().map(ToString::to_string).collect();
222        lines.join("\n")
223    })?;
224    let mut files = Vec::new();
225    let dir = root.join(&rules.checkout).join(dir);
226    if dir.is_dir() {
227        files_below(&dir, "", &mut files)?;
228    }
229    files.sort_unstable();
230    let paths: Vec<&str> = files.iter().map(String::as_str).collect();
231    let uncredited = credits::uncredited(&file, &paths);
232    Ok(uncredited.into_iter().map(str::to_owned).collect())
233}
234
235/// Writes the commit the checkout is at into the pin file. Refuses when
236/// there are uncommitted changes, when a file in a credited folder has no
237/// credit, or when the commit isn't on the private repository's `main`
238/// yet: the builds that ship fetch the pin from there.
239fn pin(rules: &PrivateAssets, command: &str, root: &Path, url: &str) -> Result<String, String> {
240    let name = &rules.checkout;
241    let checkout = root.join(name);
242    if !checkout.join(".git").exists() {
243        return Err(format!(
244            "{name}/ isn't there: run `{command} private-assets` first"
245        ));
246    }
247    let changes = git(&checkout, &["status", "--porcelain"])?;
248    if !changes.is_empty() {
249        return Err(format!(
250            "{name}/ has changes that aren't committed:\n{changes}\n\
251             commit and push them, then run this again"
252        ));
253    }
254    if let Some(credited) = &rules.credits {
255        for dir in &credited.folders {
256            let uncredited = uncredited(rules, credited, root, dir)?;
257            if !uncredited.is_empty() {
258                let hint = credited
259                    .hint
260                    .as_ref()
261                    .map(|hint| format!(" ({hint})"))
262                    .unwrap_or_default();
263                return Err(format!(
264                    "these files in {name}/{dir}/ have no credit:\n  {}\n\
265                     add their path to an entry's `private` list in {}{hint}, then run \
266                     this again",
267                    uncredited.join("\n  "),
268                    credited.file
269                ));
270            }
271        }
272    }
273    git(&checkout, &["fetch", "--quiet", "origin"]).map_err(|e| no_access(rules, url, &e))?;
274    let head = git(&checkout, &["rev-parse", "HEAD"])?;
275    let pushed = git(&checkout, &["branch", "--remotes", "--contains", &head])?;
276    if !pushed.lines().any(|branch| branch.trim() == "origin/main") {
277        return Err(format!(
278            "commit {head} isn't on the private repository's main branch: push it \
279             (`git -C {name} push origin HEAD:main`), then run this again"
280        ));
281    }
282    let file = &rules.pin_file;
283    fs::write(root.join(file), format!("{head}\n")).map_err(|e| format!("{file}: {e}"))?;
284    Ok(format!(
285        "{file} now names {head}: commit it with your change"
286    ))
287}
288
289/// Runs the command in the project at `root` against the private
290/// repository at `url` (the rules' own, but for tests). `command` is the
291/// tool's name in what it says.
292pub fn run(
293    rules: &PrivateAssets,
294    command: &str,
295    root: &Path,
296    url: &str,
297    mode: Mode,
298) -> Result<String, String> {
299    match mode {
300        Mode::Fetch { library } => fetch(rules, root, url, library),
301        Mode::Pin => pin(rules, command, root, url),
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use std::path::PathBuf;
308
309    use super::*;
310    use crate::project::CopyOut;
311
312    /// A game's private repository: `game/`, `test/` and `voice/` checked
313    /// out into `private/`, `library/` on demand, the voices copied out,
314    /// `game/` and `test/` credited.
315    fn rules() -> PrivateAssets {
316        let strings = |items: &[&str]| items.iter().map(|s| (*s).to_owned()).collect();
317        PrivateAssets {
318            url: "https://example.org/private.git".to_owned(),
319            checkout: "private".to_owned(),
320            pin_file: "private.rev".to_owned(),
321            folders: strings(&["game", "test", "voice"]),
322            library: Some("library".to_owned()),
323            copies: vec![CopyOut {
324                from: "voice".to_owned(),
325                to: "voice".to_owned(),
326            }],
327            credits: Some(Credited {
328                file: "data/credits.ron".to_owned(),
329                folders: strings(&["game", "test"]),
330                audio_manifest: "audio/audio.ron".to_owned(),
331                private_root: "private/game".to_owned(),
332                hint: Some("a new pack also needs a row in the assets list".to_owned()),
333            }),
334            no_access: "Only the team can read this private repository; without it the \
335                        game runs with the public placeholders."
336                .to_owned(),
337        }
338    }
339
340    /// Who commits in the scratch repositories (a runner has no git identity).
341    const AS_TESTER: [&str; 6] = [
342        "-c",
343        "user.name=tester",
344        "-c",
345        "user.email=tester@example.com",
346        "-c",
347        "commit.gpgsign=false",
348    ];
349
350    /// Settings that keep git's own housekeeping out of a scratch
351    /// repository. A commit or a push starts it in the background, and its
352    /// repack then moves files under `objects/` while the next command
353    /// copies them (ticket 0120: seen on a Mac runner with git 2.55).
354    const NO_UPKEEP: [&str; 2] = ["gc.auto=0", "maintenance.auto=false"];
355
356    /// `git <args>` in `dir`, which must work.
357    fn g(dir: &Path, args: &[&str]) -> String {
358        git(dir, args).unwrap_or_else(|e| panic!("git {args:?}: {e}"))
359    }
360
361    /// Commits everything in `dir` and returns the commit id.
362    fn commit(dir: &Path, message: &str) -> String {
363        g(dir, &["add", "--all"]);
364        g(
365            dir,
366            &[&AS_TESTER[..], &["commit", "--quiet", "-m", message]].concat(),
367        );
368        g(dir, &["rev-parse", "HEAD"])
369    }
370
371    /// A scratch folder under `env::temp_dir()` holding a stand-in for the
372    /// private repository (`remote.git`, filled from `src/`) and for this
373    /// one (`public/`).
374    struct Scratch {
375        root: PathBuf,
376        /// The private repository's first commit: `game/` says `one`.
377        one: String,
378        /// Its second commit, the tip of `main`: `game/` says `two`, and
379        /// there is a `voice/` folder with a manifest and a clip.
380        two: String,
381    }
382
383    impl Scratch {
384        fn new(name: &str) -> Self {
385            let root =
386                std::env::temp_dir().join(format!("shuyi-private-{name}-{}", std::process::id()));
387            let _ = fs::remove_dir_all(&root);
388            let src = root.join("src");
389            for dir in ["game/portraits", "library/tiny-tales", "originals"] {
390                fs::create_dir_all(src.join(dir)).unwrap();
391            }
392            fs::create_dir_all(root.join("public")).unwrap();
393            g(&root, &["init", "--quiet", "--initial-branch=main", "src"]);
394            for setting in NO_UPKEEP {
395                let (key, value) = setting.split_once('=').unwrap();
396                g(&src, &["config", key, value]);
397            }
398            fs::write(src.join("README.md"), "private").unwrap();
399            fs::write(src.join("game/portraits/a.portrait"), "one").unwrap();
400            fs::write(src.join("library/tiny-tales/face.png"), "face").unwrap();
401            fs::write(src.join("originals/pack.zip"), "zip").unwrap();
402            let one = commit(&src, "one");
403            fs::write(src.join("game/portraits/a.portrait"), "two").unwrap();
404            fs::create_dir_all(src.join("voice/en/scene")).unwrap();
405            fs::write(src.join("voice/en/voice.ron"), "manifest").unwrap();
406            fs::write(src.join("voice/en/scene/line.ogg"), "clip").unwrap();
407            let two = commit(&src, "two");
408            // `--no-local`: over git's own protocol, as from a real remote,
409            // not by copying the files under `src/.git/objects`.
410            let [gc, maintenance] = NO_UPKEEP;
411            let bare = ["clone", "--quiet", "--bare", "--no-local"];
412            let quiet = ["--config", gc, "--config", maintenance];
413            g(
414                &root,
415                &[&bare[..], &quiet[..], &["src", "remote.git"]].concat(),
416            );
417            Self { root, one, two }
418        }
419
420        /// The stand-in for this repository's root.
421        fn public(&self) -> PathBuf {
422            self.root.join("public")
423        }
424
425        /// The address of the stand-in private repository.
426        fn url(&self) -> String {
427            self.root.join("remote.git").to_string_lossy().into_owned()
428        }
429
430        /// A path inside the checkout, `public/private/`.
431        fn checkout(&self, path: &str) -> PathBuf {
432            self.public().join("private").join(path)
433        }
434
435        /// Writes this repository's credits file: one bought work that
436        /// covers `private` (RON strings, e.g. `"portraits/"`).
437        fn set_credits(&self, private: &str) {
438            let dir = self.public().join("data");
439            fs::create_dir_all(&dir).unwrap();
440            let credits = format!(
441                "(credits: [(id: \"pack\", group: Art, title: \"Pack\", author: \"Dee\", \
442                 source: \"https://example.org\", license: \"Custom (Dee)\", \
443                 private: [{private}])])"
444            );
445            fs::write(dir.join("credits.ron"), credits).unwrap();
446        }
447
448        fn set_pin(&self, commit: &str) {
449            fs::write(self.public().join("private.rev"), format!("{commit}\n")).unwrap();
450        }
451
452        fn fetch(&self, library: bool) -> Result<String, String> {
453            run(
454                &rules(),
455                "shuyi",
456                &self.public(),
457                &self.url(),
458                Mode::Fetch { library },
459            )
460        }
461
462        fn pin(&self) -> Result<String, String> {
463            run(&rules(), "shuyi", &self.public(), &self.url(), Mode::Pin)
464        }
465
466        /// What `game/portraits/a.portrait` says in the checkout.
467        fn game_says(&self) -> String {
468            fs::read_to_string(self.checkout("game/portraits/a.portrait")).unwrap()
469        }
470
471        /// A full clone of the private repository in place of the checkout,
472        /// as on the machine the art was bought on.
473        fn clone_whole(&self) {
474            g(
475                &self.public(),
476                &["clone", "--quiet", &self.url(), "private"],
477            );
478        }
479    }
480
481    impl Drop for Scratch {
482        fn drop(&mut self) {
483            let _ = fs::remove_dir_all(&self.root);
484        }
485    }
486
487    fn args(items: &[&str]) -> Vec<String> {
488        items.iter().map(|s| (*s).to_string()).collect()
489    }
490
491    #[test]
492    fn parse_args_reads_the_mode() {
493        let parse = |items: &[&str]| parse_args(&rules(), "shuyi", &args(items));
494        assert_eq!(parse(&[]), Ok(Mode::Fetch { library: false }));
495        assert_eq!(parse(&["--library"]), Ok(Mode::Fetch { library: true }));
496        assert_eq!(parse(&["--pin"]), Ok(Mode::Pin));
497        let usage = "usage: shuyi private-assets [--library | --pin]";
498        for bad in [&["--bogus"][..], &["--library", "--pin"], &["--pin", "x"]] {
499            assert_eq!(parse(bad), Err(usage.to_owned()), "{bad:?}");
500        }
501        // Without a library there is no `--library`.
502        let bare = PrivateAssets {
503            library: None,
504            ..rules()
505        };
506        let usage = "usage: tools private-assets [--pin]";
507        assert_eq!(usage, super::usage(&bare, "tools"));
508        let library = parse_args(&bare, "tools", &args(&["--library"]));
509        assert_eq!(library, Err(usage.to_owned()));
510    }
511
512    #[test]
513    fn the_pin_file_must_name_a_commit() {
514        let scratch = Scratch::new("pin-file");
515        let root = scratch.public();
516        assert!(
517            read_pin(&rules(), &root)
518                .unwrap_err()
519                .starts_with("private.rev: ")
520        );
521        scratch.set_pin(&scratch.one);
522        assert_eq!(read_pin(&rules(), &root), Ok(scratch.one.clone()));
523        for bad in [
524            "",
525            "main",
526            &scratch.one[..39],
527            &format!("{}0", scratch.one),
528            &scratch.one.to_uppercase().replace('0', "A"),
529            &format!("g{}", &scratch.one[1..]),
530        ] {
531            scratch.set_pin(bad);
532            assert_eq!(
533                read_pin(&rules(), &root),
534                Err(format!(
535                    "private.rev: expected a 40-character commit id, found `{bad}`"
536                )),
537            );
538        }
539    }
540
541    #[test]
542    fn a_first_fetch_clones_only_the_game_and_voice_folders() {
543        let scratch = Scratch::new("first");
544        scratch.set_pin(&scratch.two);
545        // An empty folder is fine to clone into.
546        fs::create_dir_all(scratch.checkout("")).unwrap();
547        assert_eq!(
548            scratch.fetch(false),
549            Ok(format!(
550                "private/ is at {} (game, test, voice); voice/ holds its 2 voice files",
551                scratch.two
552            ))
553        );
554        assert_eq!(scratch.game_says(), "two");
555        // Files in the private repository's root come along; nothing else.
556        assert!(scratch.checkout("README.md").is_file());
557        assert!(!scratch.checkout("library").exists());
558        assert!(!scratch.checkout("originals").exists());
559    }
560
561    /// The private repository's `voice/` is copied to the project's
562    /// `voice/`, replacing what was there; without one, `voice/` is left
563    /// alone.
564    #[test]
565    fn a_fetch_copies_the_voice_folder_beside_the_game() {
566        let scratch = Scratch::new("voice");
567        let voice = scratch.public().join("voice");
568        let read = |path: &str| fs::read_to_string(voice.join(path)).ok();
569        // The first commit has no voices: a folder made by hand stays.
570        scratch.set_pin(&scratch.one);
571        fs::create_dir_all(voice.join("en")).unwrap();
572        fs::write(voice.join("en/mine.ogg"), "mine").unwrap();
573        assert_eq!(
574            scratch.fetch(false),
575            Ok(format!(
576                "private/ is at {} (game, test, voice)",
577                scratch.one
578            ))
579        );
580        assert_eq!(read("en/mine.ogg").as_deref(), Some("mine"));
581        // The second has: its files replace the folder's.
582        scratch.set_pin(&scratch.two);
583        scratch.fetch(false).unwrap();
584        assert_eq!(read("en/voice.ron").as_deref(), Some("manifest"));
585        assert_eq!(read("en/scene/line.ogg").as_deref(), Some("clip"));
586        assert_eq!(read("en/mine.ogg"), None);
587        // A clone made before voices existed (only `game/`) gets them too.
588        let old = Scratch::new("voice-old-clone");
589        old.set_pin(&old.two);
590        old.fetch(false).unwrap();
591        let checkout = old.checkout("");
592        git(&checkout, &["sparse-checkout", "set", "--cone", "game"]).unwrap();
593        assert!(!old.checkout("voice").exists());
594        fs::remove_dir_all(old.public().join("voice")).unwrap();
595        old.fetch(false).unwrap();
596        assert!(old.public().join("voice/en/voice.ron").is_file());
597    }
598
599    #[test]
600    fn copy_out_needs_a_source_folder() {
601        let root = std::env::temp_dir().join(format!("shuyi-copy-out-{}", std::process::id()));
602        let _ = fs::remove_dir_all(&root);
603        fs::create_dir_all(root.join("dst")).unwrap();
604        fs::write(root.join("dst/kept.ogg"), "kept").unwrap();
605        // No source, or a file by that name: nothing happens.
606        assert_eq!(copy_out(&root.join("src"), &root.join("dst")), Ok(None));
607        fs::write(root.join("src"), "a file").unwrap();
608        assert_eq!(copy_out(&root.join("src"), &root.join("dst")), Ok(None));
609        assert!(root.join("dst/kept.ogg").is_file());
610        // An empty source empties the destination.
611        fs::remove_file(root.join("src")).unwrap();
612        fs::create_dir_all(root.join("src")).unwrap();
613        assert_eq!(copy_out(&root.join("src"), &root.join("dst")), Ok(Some(0)));
614        assert!(!root.join("dst/kept.ogg").exists());
615        fs::remove_dir_all(&root).unwrap();
616    }
617
618    #[test]
619    fn a_fetch_puts_the_checkout_at_the_pinned_commit() {
620        let scratch = Scratch::new("pinned");
621        // The pin is an older commit than the tip of `main`.
622        scratch.set_pin(&scratch.one);
623        scratch.fetch(false).unwrap();
624        assert_eq!(scratch.game_says(), "one");
625        // Fetching again changes nothing.
626        scratch.fetch(false).unwrap();
627        assert_eq!(scratch.game_says(), "one");
628        // The pin moves: so does the checkout.
629        scratch.set_pin(&scratch.two);
630        scratch.fetch(false).unwrap();
631        assert_eq!(scratch.game_says(), "two");
632    }
633
634    #[test]
635    fn a_fetch_sees_commits_pushed_after_the_clone() {
636        let scratch = Scratch::new("later");
637        scratch.set_pin(&scratch.two);
638        scratch.fetch(false).unwrap();
639        let src = scratch.root.join("src");
640        fs::write(src.join("game/portraits/a.portrait"), "three").unwrap();
641        let three = commit(&src, "three");
642        g(&src, &["push", "--quiet", "../remote.git", "main"]);
643        scratch.set_pin(&three);
644        scratch.fetch(false).unwrap();
645        assert_eq!(scratch.game_says(), "three");
646    }
647
648    #[test]
649    fn library_brings_the_library_folder_and_keeps_it() {
650        let scratch = Scratch::new("library");
651        scratch.set_pin(&scratch.two);
652        scratch.fetch(false).unwrap();
653        assert_eq!(
654            scratch.fetch(true),
655            Ok(format!(
656                "private/ is at {} (game, library, test, voice); voice/ holds its 2 voice files",
657                scratch.two
658            ))
659        );
660        assert!(scratch.checkout("library/tiny-tales/face.png").is_file());
661        assert!(!scratch.checkout("originals").exists());
662        // A later plain fetch keeps it.
663        scratch.fetch(false).unwrap();
664        assert!(scratch.checkout("library/tiny-tales/face.png").is_file());
665
666        // Asked for on the first fetch, it comes with the clone.
667        let first = Scratch::new("library-first");
668        first.set_pin(&first.one);
669        first.fetch(true).unwrap();
670        assert!(first.checkout("library/tiny-tales/face.png").is_file());
671        assert_eq!(first.game_says(), "one");
672    }
673
674    #[test]
675    fn a_whole_clone_is_left_whole_and_on_its_branch() {
676        let scratch = Scratch::new("whole");
677        scratch.clone_whole();
678        scratch.set_pin(&scratch.two);
679        assert_eq!(
680            scratch.fetch(true),
681            Ok(format!(
682                "private/ is at {} (the whole repository); voice/ holds its 2 voice files",
683                scratch.two
684            ))
685        );
686        assert!(scratch.checkout("originals/pack.zip").is_file());
687        assert!(scratch.checkout("library/tiny-tales/face.png").is_file());
688        let branch = |scratch: &Scratch| git(&scratch.checkout(""), &["symbolic-ref", "HEAD"]);
689        assert_eq!(branch(&scratch), Ok("refs/heads/main".to_string()));
690        // A pin elsewhere moves it off the branch, which stays where it was.
691        scratch.set_pin(&scratch.one);
692        scratch.fetch(false).unwrap();
693        assert_eq!(scratch.game_says(), "one");
694        assert!(branch(&scratch).is_err());
695        assert_eq!(
696            g(&scratch.checkout(""), &["rev-parse", "main"]),
697            scratch.two
698        );
699        assert!(scratch.checkout("originals/pack.zip").is_file());
700    }
701
702    #[test]
703    fn a_fetch_says_why_it_failed() {
704        let scratch = Scratch::new("fails");
705        // No pin file.
706        assert!(
707            scratch
708                .fetch(false)
709                .unwrap_err()
710                .starts_with("private.rev: ")
711        );
712        assert!(!scratch.checkout("").exists());
713
714        // A folder that isn't a checkout is never cloned over.
715        scratch.set_pin(&scratch.two);
716        fs::create_dir_all(scratch.checkout("game")).unwrap();
717        assert_eq!(
718            scratch.fetch(false),
719            Err("private/ has files but isn't a git checkout: move it away first".into())
720        );
721        fs::remove_dir_all(scratch.checkout("")).unwrap();
722
723        // A repository that can't be read.
724        let nowhere = scratch.root.join("nowhere.git");
725        let nowhere = nowhere.to_string_lossy();
726        let fetch = Mode::Fetch { library: false };
727        let error = run(&rules(), "shuyi", &scratch.public(), &nowhere, fetch).unwrap_err();
728        assert!(
729            error.starts_with(&format!("can't read {nowhere}\n  git said: ")),
730            "{error}"
731        );
732        assert!(error.contains("private repository"), "{error}");
733        assert!(error.contains("public placeholders"), "{error}");
734
735        // A pin that isn't a commit of the repository.
736        let unknown = "0123456789abcdef0123456789abcdef01234567";
737        scratch.set_pin(unknown);
738        let error = scratch.fetch(false).unwrap_err();
739        assert!(
740            error.starts_with(&format!(
741                "can't check out the pinned commit {unknown} in private/\n  git said: "
742            )),
743            "{error}"
744        );
745
746        // That left a clone with no files checked out. With a good pin the
747        // next fetch finishes the job, though the clone is already "at" it.
748        assert!(!scratch.checkout("game").exists());
749        scratch.set_pin(&scratch.two);
750        scratch.fetch(false).unwrap();
751        assert_eq!(scratch.game_says(), "two");
752        assert!(!scratch.checkout("library").exists());
753
754        // The repository going away fails the fetch.
755        fs::remove_dir_all(scratch.root.join("remote.git")).unwrap();
756        let error = scratch.fetch(false).unwrap_err();
757        assert!(error.starts_with("can't read "), "{error}");
758    }
759
760    #[test]
761    fn pin_writes_the_commit_once_it_is_pushed() {
762        let scratch = Scratch::new("pin");
763        assert_eq!(
764            scratch.pin(),
765            Err("private/ isn't there: run `shuyi private-assets` first".into())
766        );
767
768        scratch.clone_whole();
769        let checkout = scratch.checkout("");
770        scratch.set_pin(&scratch.one);
771        scratch.set_credits("\"portraits/\"");
772
773        // A new file that isn't committed.
774        fs::write(scratch.checkout("game/portraits/b.portrait"), "new").unwrap();
775        let error = scratch.pin().unwrap_err();
776        assert!(
777            error.starts_with("private/ has changes that aren't committed:\n"),
778            "{error}"
779        );
780        assert!(error.contains("game/portraits/b.portrait"), "{error}");
781
782        // Committed, not pushed.
783        let new = commit(&checkout, "b");
784        let error = scratch.pin().unwrap_err();
785        assert!(
786            error.starts_with(&format!(
787                "commit {new} isn't on the private repository's main branch"
788            )),
789            "{error}"
790        );
791        assert_eq!(
792            read_pin(&rules(), &scratch.public()),
793            Ok(scratch.one.clone())
794        );
795
796        // Pushed, but to another branch than `main`: the builds that ship
797        // can't be sure to find it there.
798        g(&checkout, &["push", "--quiet", "origin", "HEAD:side"]);
799        let error = scratch.pin().unwrap_err();
800        assert!(
801            error.starts_with(&format!(
802                "commit {new} isn't on the private repository's main branch"
803            )),
804            "{error}"
805        );
806        assert_eq!(
807            read_pin(&rules(), &scratch.public()),
808            Ok(scratch.one.clone())
809        );
810
811        // Pushed to `main`.
812        g(&checkout, &["push", "--quiet", "origin", "HEAD:main"]);
813        assert_eq!(
814            scratch.pin(),
815            Ok(format!(
816                "private.rev now names {new}: commit it with your change"
817            ))
818        );
819        assert_eq!(
820            fs::read_to_string(scratch.public().join("private.rev")).unwrap(),
821            format!("{new}\n")
822        );
823
824        // The private repository can't be read: no pin is written.
825        scratch.set_pin(&scratch.one);
826        fs::remove_dir_all(scratch.root.join("remote.git")).unwrap();
827        assert!(scratch.pin().unwrap_err().starts_with("can't read "));
828        assert_eq!(
829            read_pin(&rules(), &scratch.public()),
830            Ok(scratch.one.clone())
831        );
832    }
833
834    #[test]
835    fn pin_refuses_a_bought_file_without_a_credit() {
836        let scratch = Scratch::new("pin-credit");
837        scratch.clone_whole();
838        let checkout = scratch.checkout("");
839        scratch.set_pin(&scratch.one);
840        for (path, text) in [
841            ("game/README.md", "ours"),
842            ("game/units/deep/b.png", "b"),
843            ("game/units/a.png", "a"),
844        ] {
845            fs::create_dir_all(scratch.checkout(path).parent().unwrap()).unwrap();
846            fs::write(scratch.checkout(path), text).unwrap();
847        }
848        let new = commit(&checkout, "units");
849        g(&checkout, &["push", "--quiet", "origin", "HEAD:main"]);
850
851        // No credits file in this repository.
852        let error = scratch.pin().unwrap_err();
853        assert!(error.starts_with("data/credits.ron: "), "{error}");
854
855        // A credits file that doesn't load.
856        scratch.set_credits("\"/units/\"");
857        assert_eq!(
858            scratch.pin(),
859            Err(
860                "data/credits.ron:1: credit \"pack\": private path \"/units/\" must \
861                 be a path inside private/game/"
862                    .into()
863            )
864        );
865
866        // The credit covers the portrait only: the units are named, in
867        // order, and the folder's own note isn't.
868        scratch.set_credits("\"portraits/\"");
869        assert_eq!(
870            scratch.pin(),
871            Err(
872                "these files in private/game/ have no credit:\n  units/a.png\n  \
873                 units/deep/b.png\nadd their path to an entry's `private` list in \
874                 data/credits.ron (a new pack also needs a row in the assets list), \
875                 then run this again"
876                    .into()
877            )
878        );
879        assert_eq!(
880            read_pin(&rules(), &scratch.public()),
881            Ok(scratch.one.clone())
882        );
883
884        scratch.set_credits("\"portraits/\", \"units/\"");
885        assert!(scratch.pin().is_ok());
886        assert_eq!(read_pin(&rules(), &scratch.public()), Ok(new.clone()));
887
888        // The second credited folder needs its credits too.
889        fs::create_dir_all(scratch.checkout("test/tiles")).unwrap();
890        fs::write(scratch.checkout("test/tiles/t.png"), "t").unwrap();
891        let with_test = commit(&checkout, "test tiles");
892        g(&checkout, &["push", "--quiet", "origin", "HEAD:main"]);
893        assert_eq!(
894            scratch.pin(),
895            Err(
896                "these files in private/test/ have no credit:\n  tiles/t.png\n\
897                 add their path to an entry's `private` list in \
898                 data/credits.ron (a new pack also needs a row in the assets list), \
899                 then run this again"
900                    .into()
901            )
902        );
903        assert_eq!(read_pin(&rules(), &scratch.public()), Ok(new));
904        scratch.set_credits("\"portraits/\", \"units/\", \"tiles/\"");
905        assert!(scratch.pin().is_ok());
906        assert_eq!(read_pin(&rules(), &scratch.public()), Ok(with_test));
907    }
908}