1use std::collections::BTreeMap;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13
14use onevcs::registry::{Checkout, Identity, Registry};
15use onevcs::testing::{self, ObjectId, Ref, SessionSeed, Token};
16use onevcs::{Error, EventKind, Result};
17use serde_json::{json, Map, Value};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Scale {
22 One,
24 Ten,
26}
27
28impl Scale {
29 pub fn number(self) -> u32 {
31 match self {
32 Self::One => 1,
33 Self::Ten => 10,
34 }
35 }
36 pub fn counts(self) -> [usize; 4] {
38 match self {
39 Self::One => [20, 371, 41, 2308],
40 Self::Ten => [200, 3714, 414, 23085],
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Class {
48 Landed,
50 Retirable,
52 Superseded,
54 Live,
56 No,
58 Unknown,
60 InPart,
62}
63
64const CLASSES: [Class; 7] = [
65 Class::Landed,
66 Class::Retirable,
67 Class::Superseded,
68 Class::Live,
69 Class::No,
70 Class::Unknown,
71 Class::InPart,
72];
73
74#[derive(Debug, Clone)]
76pub struct Expected {
77 pub identity: String,
79 pub branch: Ref,
81 pub session: Token,
83 pub class: Class,
85 pub tip: ObjectId,
87}
88
89#[derive(Debug)]
91pub struct Fixture {
92 pub root: PathBuf,
94 pub home: PathBuf,
96 pub launcher: String,
98 pub scale: Scale,
100 pub expected: Vec<Expected>,
102}
103
104struct Work {
105 branch: Ref,
106 token: Token,
107 launcher: String,
108 class: Class,
109 path: String,
110 contents: String,
111 subject: String,
112 mark: usize,
113 first: usize,
114 landed: bool,
115 selected: bool,
116 previous: Option<Token>,
117}
118
119struct Import {
120 bytes: Vec<u8>,
121 next: usize,
122}
123
124impl Import {
125 fn new(next: usize) -> Self {
126 Self {
127 bytes: Vec::new(),
128 next,
129 }
130 }
131 fn blob(&mut self, text: &str) -> usize {
132 let mark = self.next;
133 self.next += 1;
134 write!(
135 self.bytes,
136 "blob\nmark :{mark}\ndata {}\n{text}\n",
137 text.len()
138 )
139 .expect("memory write");
140 mark
141 }
142 fn commit(
143 &mut self,
144 branch: &str,
145 parent: Option<&str>,
146 subject: &str,
147 files: &[(String, String)],
148 ) -> usize {
149 let files: Vec<_> = files
150 .iter()
151 .map(|(path, body)| (path, self.blob(body)))
152 .collect();
153 let mark = self.next;
154 self.next += 1;
155 write!(self.bytes, "commit refs/heads/{branch}\nmark :{mark}\ncommitter Fixture <fixture@example.invalid> 1700000000 +0000\ndata {}\n{subject}\n",subject.len()).expect("memory write");
156 if let Some(parent) = parent {
157 writeln!(self.bytes, "from {parent}").expect("memory write");
158 }
159 for (path, blob) in files {
160 writeln!(self.bytes, "M 100644 :{blob} {path}").expect("memory write");
161 }
162 self.bytes.push(b'\n');
163 mark
164 }
165 fn run(self, repo: &Path, marks: &Path) -> Result<BTreeMap<usize, String>> {
166 git_input(
167 repo,
168 &[
169 "fast-import",
170 "--quiet",
171 &format!("--export-marks={}", marks.display()),
172 ],
173 &self.bytes,
174 )?;
175 let text = std::fs::read_to_string(marks).map_err(io)?;
176 text.lines()
177 .map(|line| {
178 let (mark, sha) = line
179 .split_once(' ')
180 .ok_or_else(|| invalid("malformed Git export mark"))?;
181 let mark = mark
182 .strip_prefix(':')
183 .and_then(|v| v.parse().ok())
184 .ok_or_else(|| invalid("malformed Git mark"))?;
185 Ok((mark, sha.to_owned()))
186 })
187 .collect()
188 }
189}
190
191pub fn build(root: &Path, scale: Scale) -> Result<Fixture> {
194 if root.exists() && std::fs::read_dir(root).map_err(io)?.next().is_some() {
195 return Err(invalid(format!(
196 "fixture root {} must be empty",
197 root.display()
198 )));
199 }
200 std::fs::create_dir_all(root).map_err(io)?;
201 let root = std::fs::canonicalize(root).map_err(io)?;
202 let home = root.join("home");
203 std::fs::create_dir_all(&home).map_err(io)?;
204 std::fs::write(root.join(".gitconfig"), "[user]\nname=Fixture\nemail=fixture@example.invalid\n[commit]\ngpgsign=false\n[maintenance]\nauto=false\n").map_err(io)?;
205 std::fs::write(
206 home.join("rules.yml"),
207 "version: 1\nrules: []\ndefault: {publication: local-direct, approvals: none}\n",
208 )
209 .map_err(io)?;
210 let mut registry = Registry {
211 version: testing::registry_version(),
212 identities: BTreeMap::new(),
213 checkouts: BTreeMap::new(),
214 rules: Some(home.join("rules.yml")),
215 };
216 let mut expected = Vec::new();
217 let mut stream_count = 0;
218 let mut session_count = 0;
219 let mut labelled_count = 0;
220 let launcher = "fixture-launcher-measured".to_owned();
221 let next = std::sync::atomic::AtomicUsize::new(1);
222 let workers = std::thread::available_parallelism()
223 .map_or(1, usize::from)
224 .min(8);
225 let built = std::thread::scope(|scope| {
226 let handles: Vec<_> = (0..workers)
227 .map(|_| {
228 scope.spawn(|| {
229 let mut parts = Vec::new();
230 loop {
231 let identity = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
232 if identity > scale.counts()[0] {
233 break;
234 }
235 parts.push(build_identity(&root, &home, &launcher, scale, identity)?);
236 }
237 Ok::<_, Error>(parts)
238 })
239 })
240 .collect();
241 let mut all = Vec::new();
242 for handle in handles {
243 all.extend(
244 handle
245 .join()
246 .map_err(|_| invalid("fixture builder worker panicked"))??,
247 );
248 }
249 Ok::<_, Error>(all)
250 })?;
251 for part in built {
252 registry.identities.extend(part.registry.identities);
253 registry.checkouts.extend(part.registry.checkouts);
254 expected.extend(part.expected);
255 session_count += part.sessions;
256 labelled_count += part.labelled;
257 stream_count += part.streams;
258 }
259 expected.sort_by(|a, b| (&a.identity, &*a.branch).cmp(&(&b.identity, &*b.branch)));
260 testing::write_registry(&home, ®istry)?;
261 let counts = scale.counts();
262 if [
263 registry.identities.len(),
264 session_count,
265 labelled_count,
266 stream_count,
267 ] != counts
268 {
269 return Err(invalid(format!(
270 "fixture shape differs: expected {counts:?}, got {:?}",
271 [
272 registry.identities.len(),
273 session_count,
274 labelled_count,
275 stream_count
276 ]
277 )));
278 }
279 Ok(Fixture {
280 root,
281 home,
282 launcher,
283 scale,
284 expected,
285 })
286}
287
288struct BuiltIdentity {
289 registry: Registry,
290 expected: Vec<Expected>,
291 sessions: usize,
292 labelled: usize,
293 streams: usize,
294}
295
296fn build_identity(
297 root: &Path,
298 home: &Path,
299 launcher: &str,
300 scale: Scale,
301 identity: usize,
302) -> Result<BuiltIdentity> {
303 let mut registry = Registry {
304 version: testing::registry_version(),
305 identities: BTreeMap::new(),
306 checkouts: BTreeMap::new(),
307 rules: None,
308 };
309 let mut expected = Vec::new();
310 let mut session_count = 0;
311 let mut labelled_count = 0;
312 let mut stream_count = 0;
313 let repo = root.join(format!("checkouts/r{identity}"));
314 let origin = root.join(format!("origins/r{identity}.git"));
315 std::fs::create_dir_all(&repo).map_err(io)?;
316 std::fs::create_dir_all(origin.parent().expect("origin parent")).map_err(io)?;
317 git(&repo, &["init", "-q", "-b", "main"])?;
318 git(
319 &repo,
320 &[
321 "init",
322 "-q",
323 "--bare",
324 "-b",
325 "main",
326 &origin.to_string_lossy(),
327 ],
328 )?;
329 git(
330 &repo,
331 &["remote", "add", "origin", &origin.to_string_lossy()],
332 )?;
333 let key = origin.to_string_lossy().into_owned();
334 let alias = format!("r{identity}");
335 registry.identities.insert(
336 key.clone(),
337 Identity {
338 origin: key.clone(),
339 gate: "true".into(),
340 },
341 );
342 registry.checkouts.insert(
343 alias.clone(),
344 Checkout {
345 path: repo.clone(),
346 identity: key.clone(),
347 },
348 );
349 let mut import = Import::new(1);
350 let seed_files: Vec<_> = (1..=40)
351 .map(|i| {
352 (
353 format!("lib/m{i}.txt"),
354 format!(
355 "module {i}\n{}\n",
356 (1..=50)
357 .map(|n| n.to_string())
358 .collect::<Vec<_>>()
359 .join("\n")
360 ),
361 )
362 })
363 .collect();
364 let mut seed = import.commit(
365 "main",
366 None,
367 &format!("chore: seed r{identity}"),
368 &seed_files,
369 );
370 let mut history = BTreeMap::new();
373 for n in (5..=60).step_by(5) {
374 seed = import.commit(
375 "main",
376 Some(&format!(":{seed}")),
377 &format!("feat: old {n}"),
378 &[(format!("src/old-{n}.txt"), format!("{n}\n"))],
379 );
380 history.insert(n, seed);
381 }
382 let mut work = Vec::new();
383 let selected = identity <= 10 * scale.number() as usize;
384 if selected {
385 for n in 0..3 {
386 let class = if n == 2 {
387 Class::Landed
388 } else {
389 CLASSES[(2 * identity + n) % 7]
390 };
391 let branch = format!("work/r{identity}-{n}-{}", class_name(class));
392 add_work(&mut import, &mut work, &branch, launcher, class, true, seed)?;
393 }
394 }
395 let remaining = if selected { 14 } else { 19 };
398 for n in 10..10 + remaining {
399 let class = if n % 3 == 0 { Class::No } else { Class::Landed };
400 add_work(
401 &mut import,
402 &mut work,
403 &format!("keep/r{identity}-{n}"),
404 &format!("fixture-launcher-{}", n % 4),
405 class,
406 false,
407 seed,
408 )?;
409 }
410 let next = import.next;
411 let marks = import.run(&repo, &root.join(format!("marks-{identity}-work")))?;
412 let mut landing = Import::new(next);
413 let mut base = marks[&seed].clone();
414 let mut landing_marks = BTreeMap::new();
415 for entry in &work {
416 if entry.landed {
417 let subject = format!(
418 "{}\n\n{} {}",
419 entry.subject,
420 testing::landing_trailer(),
421 marks[&entry.first]
422 );
423 let mark = landing.commit(
424 "main",
425 Some(&base),
426 &subject,
427 &[(entry.path.clone(), entry.contents.clone())],
428 );
429 base = format!(":{mark}");
430 landing_marks.insert(entry.branch.to_string(), mark);
431 }
432 }
433 let landed = landing.run(&repo, &root.join(format!("marks-{identity}-landed")))?;
434 git(&repo, &["push", "-q", "origin", "main"])?;
435 git(
436 &repo,
437 &[
438 "symbolic-ref",
439 "refs/remotes/origin/HEAD",
440 "refs/remotes/origin/main",
441 ],
442 )?;
443 for entry in &work {
444 let run = root.join(format!("sessions/{}", entry.token));
445 let clone = run.join("clone");
446 let worktree = run.join("worktree");
447 std::fs::create_dir_all(&run).map_err(io)?;
448 git(
449 &repo,
450 &[
451 "clone",
452 "-q",
453 "--shared",
454 "--no-checkout",
455 &repo.to_string_lossy(),
456 &clone.to_string_lossy(),
457 ],
458 )?;
459 git(
460 &clone,
461 &[
462 "update-ref",
463 &format!("refs/heads/{}", entry.branch),
464 &marks[&entry.mark],
465 ],
466 )?;
467 if entry.class == Class::Live {
468 git(
469 &clone,
470 &[
471 "worktree",
472 "add",
473 "-q",
474 &worktree.to_string_lossy(),
475 &*entry.branch,
476 ],
477 )?;
478 }
479 let labels = BTreeMap::from([
480 ("launcher".into(), entry.launcher.clone()),
481 ("run".into(), "fixture-run".into()),
482 ]);
483 testing::write_session(
484 home,
485 &SessionSeed {
486 token: entry.token.clone(),
487 identity: key.clone(),
488 alias: alias.clone(),
489 branch: entry.branch.clone(),
490 checkout: repo.clone(),
491 clone,
492 worktree,
493 state: if entry.class == Class::Live {
494 onevcs::Lifecycle::Open
495 } else {
496 onevcs::Lifecycle::Closed
497 },
498 labels,
499 },
500 )?;
501 session_count += 1;
502 if entry.launcher == launcher {
503 labelled_count += 1;
504 }
505 let mut events = vec![(
506 EventKind::SessionOpened,
507 payload(json!({"branch":&*entry.branch})),
508 )];
509 if let Some(mark) = landing_marks.get(&*entry.branch) {
510 events.push((
511 EventKind::MergeCompleted,
512 payload(json!({"branch":&*entry.branch,"sha":landed[mark]})),
513 ));
514 }
515 if entry.class != Class::Live {
516 events.push((
517 EventKind::SessionClosed,
518 payload(json!({"branch":&*entry.branch})),
519 ));
520 }
521 testing::write_events(home, &entry.token, &key, &events)?;
522 stream_count += 1;
523 if entry.selected && entry.previous.is_none() {
524 expected.push(Expected {
525 identity: key.clone(),
526 branch: entry.branch.clone(),
527 session: if entry.class == Class::InPart {
528 token(format!(
529 "s-fixture-{}-{}",
530 entry.branch.replace('/', "-"),
531 entry.mark
532 ))?
533 } else {
534 entry.token.clone()
535 },
536 class: entry.class,
537 tip: ObjectId::parse(&marks[&entry.mark]).ok_or_else(|| {
538 invalid(format!(
539 "fixture tip {:?} is not an object name",
540 marks[&entry.mark]
541 ))
542 })?,
543 });
544 }
545 }
546 for entry in &work {
548 if let Some(mark) = landing_marks.get(&*entry.branch).filter(|_| entry.landed) {
549 testing::write_events(
550 home,
551 &format!("publish-{identity}-{}", entry.mark),
552 &key,
553 &[(
554 EventKind::MergeCompleted,
555 payload(json!({"branch":&*entry.branch,"sha":landed[mark]})),
556 )],
557 )?;
558 stream_count += 1;
559 }
560 if entry.class == Class::Superseded {
561 let twin = format!("{}-retry", entry.branch);
562 let mark = landing_marks[&twin];
563 testing::write_events(
564 home,
565 &format!("supersede-{identity}-{}", entry.mark),
566 &key,
567 &[(
568 EventKind::BranchSuperseded,
569 payload(
570 json!({"identity":key,"branch":&*entry.branch,"superseded_by":twin,"landing":landed[&mark],"labels":{}}),
571 ),
572 )],
573 )?;
574 stream_count += 1;
575 }
576 }
577 for n in 1..=60 {
580 let token = format!("history-{identity}-{n}");
581 let branch = format!("old/r{identity}-{n}");
582 testing::write_events(
583 home,
584 &token,
585 &key,
586 &[
587 (EventKind::SessionOpened, payload(json!({"branch":branch}))),
588 (EventKind::SessionClosed, payload(json!({"branch":branch}))),
589 ],
590 )?;
591 stream_count += 1;
592 if n % 5 == 0 {
593 for kind in [EventKind::MergeCompleted, EventKind::BranchRetired] {
594 testing::write_events(
595 home,
596 &format!(
597 "history-{}-{identity}-{n}",
598 if kind == EventKind::MergeCompleted {
599 "publish"
600 } else {
601 "retire"
602 }
603 ),
604 &key,
605 &[(
606 kind,
607 payload(if kind == EventKind::MergeCompleted {
608 json!({"branch":branch,"sha":marks[&history[&n]]})
609 } else {
610 json!({"identity":key,"branch":branch,"tip":marks[&history[&n]],
611 "class":"retirable","reason":null,
612 "proof":{"kind":"recorded-landing","commit":marks[&history[&n]]},
613 "superseded_by":null,"differing_paths":[],"mode":"automatic",
614 "trigger":"sweep","deleted":[],"failed":[],"slots_returned":[],
615 "run_roots_removed":[],"sessions_closed":[token]})
616 }),
617 )],
618 )?;
619 stream_count += 1;
620 }
621 }
622 }
623 Ok(BuiltIdentity {
624 registry,
625 expected,
626 sessions: session_count,
627 labelled: labelled_count,
628 streams: stream_count,
629 })
630}
631
632fn add_work(
633 import: &mut Import,
634 work: &mut Vec<Work>,
635 branch: &str,
636 launcher: &str,
637 class: Class,
638 selected: bool,
639 seed: usize,
640) -> Result<()> {
641 let path = format!("src/{branch}.txt");
642 let contents = if class == Class::Retirable {
643 "same\n"
644 } else if class == Class::Unknown {
645 "mine\n"
646 } else {
647 "one\n"
648 }
649 .to_owned();
650 let subject = if class == Class::Unknown {
651 format!("feat: retry {branch}")
652 } else {
653 format!("feat: {branch}")
654 };
655 let first = import.commit(
656 branch,
657 Some(&format!(":{seed}")),
658 &subject,
659 &[(path.clone(), contents.clone())],
660 );
661 let mut mark = first;
662 if class == Class::InPart {
663 mark = import.commit(
664 branch,
665 Some(&format!(":{first}")),
666 &format!("feat: {branch} more"),
667 &[(format!("src/{branch}-more.txt"), "two\n".into())],
668 );
669 }
670 work.push(Work {
671 branch: reference(branch.to_owned())?,
672 token: token(format!("s-fixture-{}-{first}", branch.replace('/', "-")))?,
673 launcher: launcher.into(),
674 class,
675 path: path.clone(),
676 contents: contents.clone(),
677 subject: subject.clone(),
678 mark,
679 first,
680 landed: matches!(class, Class::Landed | Class::InPart),
681 selected,
682 previous: None,
683 });
684 if matches!(class, Class::Retirable | Class::Superseded | Class::Unknown) {
685 let suffix = if class == Class::Superseded {
686 "retry"
687 } else if class == Class::Retirable {
688 "twin"
689 } else {
690 "other"
691 };
692 let twin = format!("{branch}-{suffix}");
693 let body = if class == Class::Retirable {
694 contents
695 } else {
696 "theirs\n".into()
697 };
698 let subject = if class == Class::Unknown {
699 subject
700 } else {
701 format!("feat: {branch} again")
702 };
703 let mark = import.commit(
704 &twin,
705 Some(&format!(":{seed}")),
706 &subject,
707 &[(path.clone(), body.clone())],
708 );
709 work.push(Work {
710 branch: reference(twin)?,
711 token: token(format!("s-fixture-{}-{mark}", branch.replace('/', "-")))?,
712 launcher: launcher.into(),
713 class: Class::Landed,
714 path,
715 contents: body,
716 subject,
717 mark,
718 first: mark,
719 landed: true,
720 selected,
721 previous: None,
722 });
723 } else if class == Class::InPart {
724 work.push(Work {
725 branch: reference(branch.to_owned())?,
726 token: token(format!("s-fixture-{}-{mark}", branch.replace('/', "-")))?,
727 launcher: launcher.into(),
728 class,
729 path,
730 contents,
731 subject,
732 mark,
733 first,
734 landed: false,
735 selected,
736 previous: Some(token(format!(
737 "s-fixture-{}-{first}",
738 branch.replace('/', "-")
739 ))?),
740 });
741 }
742 Ok(())
743}
744
745fn reference(name: String) -> Result<Ref> {
746 name.try_into().map_err(invalid)
747}
748fn token(name: String) -> Result<Token> {
749 name.try_into().map_err(invalid)
750}
751
752fn class_name(class: Class) -> &'static str {
753 match class {
754 Class::Landed => "landed",
755 Class::Retirable => "retirable",
756 Class::Superseded => "superseded",
757 Class::Live => "live",
758 Class::No => "no",
759 Class::Unknown => "unknown",
760 Class::InPart => "in-part",
761 }
762}
763fn payload(value: Value) -> Map<String, Value> {
764 value.as_object().expect("object literal").clone()
765}
766fn invalid(reason: impl Into<String>) -> Error {
767 Error::Invalid {
768 reason: reason.into(),
769 }
770}
771fn io(error: std::io::Error) -> Error {
772 invalid(error.to_string())
773}
774fn git(repo: &Path, args: &[&str]) -> Result<String> {
775 git_input(repo, args, &[])
776}
777fn git_input(repo: &Path, args: &[&str], input: &[u8]) -> Result<String> {
778 let mut child = Command::new("git")
779 .current_dir(repo)
780 .args(args)
781 .env_clear()
782 .env("PATH", std::env::var_os("PATH").unwrap_or_default())
783 .env("GIT_CONFIG_NOSYSTEM", "1")
784 .env(
785 "GIT_CONFIG_GLOBAL",
786 if cfg!(windows) { "NUL" } else { "/dev/null" },
787 )
788 .stdin(Stdio::piped())
789 .stdout(Stdio::piped())
790 .stderr(Stdio::piped())
791 .spawn()
792 .map_err(io)?;
793 child
794 .stdin
795 .take()
796 .expect("piped stdin")
797 .write_all(input)
798 .map_err(io)?;
799 let output = child.wait_with_output().map_err(io)?;
800 if !output.status.success() {
801 return Err(invalid(format!(
802 "fixture git {} in {}: {}",
803 args.join(" "),
804 repo.display(),
805 String::from_utf8_lossy(&output.stderr)
806 )));
807 }
808 String::from_utf8(output.stdout).map_err(|error| invalid(error.to_string()))
809}