1use 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
27pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Mode {
40 Fetch {
42 library: bool,
44 },
45 Pin,
47}
48
49pub 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
62fn 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
77fn 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
85fn 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
99fn has_entries(dir: &Path) -> bool {
101 fs::read_dir(dir).is_ok_and(|mut entries| entries.next().is_some())
102}
103
104fn 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 let sparse =
133 git(&checkout, &["config", "--get", "core.sparseCheckout"]).as_deref() == Ok("true");
134 if sparse && !others.is_empty() {
135 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 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(©.from), &root.join(©.to))? {
166 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
177fn 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
190fn 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
205fn 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
235fn 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
289pub 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 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 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 const NO_UPKEEP: [&str; 2] = ["gc.auto=0", "maintenance.auto=false"];
355
356 fn g(dir: &Path, args: &[&str]) -> String {
358 git(dir, args).unwrap_or_else(|e| panic!("git {args:?}: {e}"))
359 }
360
361 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 struct Scratch {
375 root: PathBuf,
376 one: String,
378 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 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 fn public(&self) -> PathBuf {
422 self.root.join("public")
423 }
424
425 fn url(&self) -> String {
427 self.root.join("remote.git").to_string_lossy().into_owned()
428 }
429
430 fn checkout(&self, path: &str) -> PathBuf {
432 self.public().join("private").join(path)
433 }
434
435 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 fn game_says(&self) -> String {
468 fs::read_to_string(self.checkout("game/portraits/a.portrait")).unwrap()
469 }
470
471 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 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 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 assert!(scratch.checkout("README.md").is_file());
557 assert!(!scratch.checkout("library").exists());
558 assert!(!scratch.checkout("originals").exists());
559 }
560
561 #[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 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 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 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 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 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 scratch.set_pin(&scratch.one);
623 scratch.fetch(false).unwrap();
624 assert_eq!(scratch.game_says(), "one");
625 scratch.fetch(false).unwrap();
627 assert_eq!(scratch.game_says(), "one");
628 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 scratch.fetch(false).unwrap();
664 assert!(scratch.checkout("library/tiny-tales/face.png").is_file());
665
666 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 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 assert!(
707 scratch
708 .fetch(false)
709 .unwrap_err()
710 .starts_with("private.rev: ")
711 );
712 assert!(!scratch.checkout("").exists());
713
714 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 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 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 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 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 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 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 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 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 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 let error = scratch.pin().unwrap_err();
853 assert!(error.starts_with("data/credits.ron: "), "{error}");
854
855 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 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 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}