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
336 .identities
337 .insert(key.clone(), Identity::new(key.clone(), "true"));
338 registry.checkouts.insert(
339 alias.clone(),
340 Checkout {
341 path: repo.clone(),
342 identity: key.clone(),
343 },
344 );
345 let mut import = Import::new(1);
346 let seed_files: Vec<_> = (1..=40)
347 .map(|i| {
348 (
349 format!("lib/m{i}.txt"),
350 format!(
351 "module {i}\n{}\n",
352 (1..=50)
353 .map(|n| n.to_string())
354 .collect::<Vec<_>>()
355 .join("\n")
356 ),
357 )
358 })
359 .collect();
360 let mut seed = import.commit(
361 "main",
362 None,
363 &format!("chore: seed r{identity}"),
364 &seed_files,
365 );
366 let mut history = BTreeMap::new();
369 for n in (5..=60).step_by(5) {
370 seed = import.commit(
371 "main",
372 Some(&format!(":{seed}")),
373 &format!("feat: old {n}"),
374 &[(format!("src/old-{n}.txt"), format!("{n}\n"))],
375 );
376 history.insert(n, seed);
377 }
378 let mut work = Vec::new();
379 let selected = identity <= 10 * scale.number() as usize;
380 if selected {
381 for n in 0..3 {
382 let class = if n == 2 {
383 Class::Landed
384 } else {
385 CLASSES[(2 * identity + n) % 7]
386 };
387 let branch = format!("work/r{identity}-{n}-{}", class_name(class));
388 add_work(&mut import, &mut work, &branch, launcher, class, true, seed)?;
389 }
390 }
391 let remaining = if selected { 14 } else { 19 };
394 for n in 10..10 + remaining {
395 let class = if n % 3 == 0 { Class::No } else { Class::Landed };
396 add_work(
397 &mut import,
398 &mut work,
399 &format!("keep/r{identity}-{n}"),
400 &format!("fixture-launcher-{}", n % 4),
401 class,
402 false,
403 seed,
404 )?;
405 }
406 let next = import.next;
407 let marks = import.run(&repo, &root.join(format!("marks-{identity}-work")))?;
408 let mut landing = Import::new(next);
409 let mut base = marks[&seed].clone();
410 let mut landing_marks = BTreeMap::new();
411 for entry in &work {
412 if entry.landed {
413 let subject = format!(
414 "{}\n\n{} {}",
415 entry.subject,
416 testing::landing_trailer(),
417 marks[&entry.first]
418 );
419 let mark = landing.commit(
420 "main",
421 Some(&base),
422 &subject,
423 &[(entry.path.clone(), entry.contents.clone())],
424 );
425 base = format!(":{mark}");
426 landing_marks.insert(entry.branch.to_string(), mark);
427 }
428 }
429 let landed = landing.run(&repo, &root.join(format!("marks-{identity}-landed")))?;
430 git(&repo, &["push", "-q", "origin", "main"])?;
431 git(
432 &repo,
433 &[
434 "symbolic-ref",
435 "refs/remotes/origin/HEAD",
436 "refs/remotes/origin/main",
437 ],
438 )?;
439 for entry in &work {
440 let run = root.join(format!("sessions/{}", entry.token));
441 let clone = run.join("clone");
442 let worktree = run.join("worktree");
443 std::fs::create_dir_all(&run).map_err(io)?;
444 git(
445 &repo,
446 &[
447 "clone",
448 "-q",
449 "--shared",
450 "--no-checkout",
451 &repo.to_string_lossy(),
452 &clone.to_string_lossy(),
453 ],
454 )?;
455 git(
456 &clone,
457 &[
458 "update-ref",
459 &format!("refs/heads/{}", entry.branch),
460 &marks[&entry.mark],
461 ],
462 )?;
463 if entry.class == Class::Live {
464 git(
465 &clone,
466 &[
467 "worktree",
468 "add",
469 "-q",
470 &worktree.to_string_lossy(),
471 &*entry.branch,
472 ],
473 )?;
474 }
475 let labels = BTreeMap::from([
476 ("launcher".into(), entry.launcher.clone()),
477 ("run".into(), "fixture-run".into()),
478 ]);
479 testing::write_session(
480 home,
481 &SessionSeed {
482 token: entry.token.clone(),
483 identity: key.clone(),
484 alias: alias.clone(),
485 branch: entry.branch.clone(),
486 checkout: repo.clone(),
487 clone,
488 worktree,
489 state: if entry.class == Class::Live {
490 onevcs::Lifecycle::Open
491 } else {
492 onevcs::Lifecycle::Closed
493 },
494 labels,
495 },
496 )?;
497 session_count += 1;
498 if entry.launcher == launcher {
499 labelled_count += 1;
500 }
501 let mut events = vec![(
502 EventKind::SessionOpened,
503 payload(json!({"branch":&*entry.branch})),
504 )];
505 if let Some(mark) = landing_marks.get(&*entry.branch) {
506 events.push((
507 EventKind::MergeCompleted,
508 payload(json!({"branch":&*entry.branch,"sha":landed[mark]})),
509 ));
510 }
511 if entry.class != Class::Live {
512 events.push((
513 EventKind::SessionClosed,
514 payload(json!({"branch":&*entry.branch})),
515 ));
516 }
517 testing::write_events(home, &entry.token, &key, &events)?;
518 stream_count += 1;
519 if entry.selected && entry.previous.is_none() {
520 expected.push(Expected {
521 identity: key.clone(),
522 branch: entry.branch.clone(),
523 session: if entry.class == Class::InPart {
524 token(format!(
525 "s-fixture-{}-{}",
526 entry.branch.replace('/', "-"),
527 entry.mark
528 ))?
529 } else {
530 entry.token.clone()
531 },
532 class: entry.class,
533 tip: ObjectId::parse(&marks[&entry.mark]).ok_or_else(|| {
534 invalid(format!(
535 "fixture tip {:?} is not an object name",
536 marks[&entry.mark]
537 ))
538 })?,
539 });
540 }
541 }
542 for entry in &work {
544 if let Some(mark) = landing_marks.get(&*entry.branch).filter(|_| entry.landed) {
545 testing::write_events(
546 home,
547 &format!("publish-{identity}-{}", entry.mark),
548 &key,
549 &[(
550 EventKind::MergeCompleted,
551 payload(json!({"branch":&*entry.branch,"sha":landed[mark]})),
552 )],
553 )?;
554 stream_count += 1;
555 }
556 if entry.class == Class::Superseded {
557 let twin = format!("{}-retry", entry.branch);
558 let mark = landing_marks[&twin];
559 testing::write_events(
560 home,
561 &format!("supersede-{identity}-{}", entry.mark),
562 &key,
563 &[(
564 EventKind::BranchSuperseded,
565 payload(
566 json!({"identity":key,"branch":&*entry.branch,"superseded_by":twin,"landing":landed[&mark],"labels":{}}),
567 ),
568 )],
569 )?;
570 stream_count += 1;
571 }
572 }
573 for n in 1..=60 {
576 let token = format!("history-{identity}-{n}");
577 let branch = format!("old/r{identity}-{n}");
578 testing::write_events(
579 home,
580 &token,
581 &key,
582 &[
583 (EventKind::SessionOpened, payload(json!({"branch":branch}))),
584 (EventKind::SessionClosed, payload(json!({"branch":branch}))),
585 ],
586 )?;
587 stream_count += 1;
588 if n % 5 == 0 {
589 for kind in [EventKind::MergeCompleted, EventKind::BranchRetired] {
590 testing::write_events(
591 home,
592 &format!(
593 "history-{}-{identity}-{n}",
594 if kind == EventKind::MergeCompleted {
595 "publish"
596 } else {
597 "retire"
598 }
599 ),
600 &key,
601 &[(
602 kind,
603 payload(if kind == EventKind::MergeCompleted {
604 json!({"branch":branch,"sha":marks[&history[&n]]})
605 } else {
606 json!({"identity":key,"branch":branch,"tip":marks[&history[&n]],
607 "class":"retirable","reason":null,
608 "proof":{"kind":"recorded-landing","commit":marks[&history[&n]]},
609 "superseded_by":null,"differing_paths":[],"mode":"automatic",
610 "trigger":"sweep","deleted":[],"failed":[],"slots_returned":[],
611 "run_roots_removed":[],"sessions_closed":[token]})
612 }),
613 )],
614 )?;
615 stream_count += 1;
616 }
617 }
618 }
619 Ok(BuiltIdentity {
620 registry,
621 expected,
622 sessions: session_count,
623 labelled: labelled_count,
624 streams: stream_count,
625 })
626}
627
628fn add_work(
629 import: &mut Import,
630 work: &mut Vec<Work>,
631 branch: &str,
632 launcher: &str,
633 class: Class,
634 selected: bool,
635 seed: usize,
636) -> Result<()> {
637 let path = format!("src/{branch}.txt");
638 let contents = if class == Class::Retirable {
639 "same\n"
640 } else if class == Class::Unknown {
641 "mine\n"
642 } else {
643 "one\n"
644 }
645 .to_owned();
646 let subject = if class == Class::Unknown {
647 format!("feat: retry {branch}")
648 } else {
649 format!("feat: {branch}")
650 };
651 let first = import.commit(
652 branch,
653 Some(&format!(":{seed}")),
654 &subject,
655 &[(path.clone(), contents.clone())],
656 );
657 let mut mark = first;
658 if class == Class::InPart {
659 mark = import.commit(
660 branch,
661 Some(&format!(":{first}")),
662 &format!("feat: {branch} more"),
663 &[(format!("src/{branch}-more.txt"), "two\n".into())],
664 );
665 }
666 work.push(Work {
667 branch: reference(branch.to_owned())?,
668 token: token(format!("s-fixture-{}-{first}", branch.replace('/', "-")))?,
669 launcher: launcher.into(),
670 class,
671 path: path.clone(),
672 contents: contents.clone(),
673 subject: subject.clone(),
674 mark,
675 first,
676 landed: matches!(class, Class::Landed | Class::InPart),
677 selected,
678 previous: None,
679 });
680 if matches!(class, Class::Retirable | Class::Superseded | Class::Unknown) {
681 let suffix = if class == Class::Superseded {
682 "retry"
683 } else if class == Class::Retirable {
684 "twin"
685 } else {
686 "other"
687 };
688 let twin = format!("{branch}-{suffix}");
689 let body = if class == Class::Retirable {
690 contents
691 } else {
692 "theirs\n".into()
693 };
694 let subject = if class == Class::Unknown {
695 subject
696 } else {
697 format!("feat: {branch} again")
698 };
699 let mark = import.commit(
700 &twin,
701 Some(&format!(":{seed}")),
702 &subject,
703 &[(path.clone(), body.clone())],
704 );
705 work.push(Work {
706 branch: reference(twin)?,
707 token: token(format!("s-fixture-{}-{mark}", branch.replace('/', "-")))?,
708 launcher: launcher.into(),
709 class: Class::Landed,
710 path,
711 contents: body,
712 subject,
713 mark,
714 first: mark,
715 landed: true,
716 selected,
717 previous: None,
718 });
719 } else if class == Class::InPart {
720 work.push(Work {
721 branch: reference(branch.to_owned())?,
722 token: token(format!("s-fixture-{}-{mark}", branch.replace('/', "-")))?,
723 launcher: launcher.into(),
724 class,
725 path,
726 contents,
727 subject,
728 mark,
729 first,
730 landed: false,
731 selected,
732 previous: Some(token(format!(
733 "s-fixture-{}-{first}",
734 branch.replace('/', "-")
735 ))?),
736 });
737 }
738 Ok(())
739}
740
741fn reference(name: String) -> Result<Ref> {
742 name.try_into().map_err(invalid)
743}
744fn token(name: String) -> Result<Token> {
745 name.try_into().map_err(invalid)
746}
747
748fn class_name(class: Class) -> &'static str {
749 match class {
750 Class::Landed => "landed",
751 Class::Retirable => "retirable",
752 Class::Superseded => "superseded",
753 Class::Live => "live",
754 Class::No => "no",
755 Class::Unknown => "unknown",
756 Class::InPart => "in-part",
757 }
758}
759fn payload(value: Value) -> Map<String, Value> {
760 value.as_object().expect("object literal").clone()
761}
762fn invalid(reason: impl Into<String>) -> Error {
763 Error::Invalid {
764 reason: reason.into(),
765 }
766}
767fn io(error: std::io::Error) -> Error {
768 invalid(error.to_string())
769}
770fn git(repo: &Path, args: &[&str]) -> Result<String> {
771 git_input(repo, args, &[])
772}
773fn git_input(repo: &Path, args: &[&str], input: &[u8]) -> Result<String> {
774 let mut child = Command::new("git")
775 .current_dir(repo)
776 .args(args)
777 .env_clear()
778 .env("PATH", std::env::var_os("PATH").unwrap_or_default())
779 .env("GIT_CONFIG_NOSYSTEM", "1")
780 .env(
781 "GIT_CONFIG_GLOBAL",
782 if cfg!(windows) { "NUL" } else { "/dev/null" },
783 )
784 .stdin(Stdio::piped())
785 .stdout(Stdio::piped())
786 .stderr(Stdio::piped())
787 .spawn()
788 .map_err(io)?;
789 child
790 .stdin
791 .take()
792 .expect("piped stdin")
793 .write_all(input)
794 .map_err(io)?;
795 let output = child.wait_with_output().map_err(io)?;
796 if !output.status.success() {
797 return Err(invalid(format!(
798 "fixture git {} in {}: {}",
799 args.join(" "),
800 repo.display(),
801 String::from_utf8_lossy(&output.stderr)
802 )));
803 }
804 String::from_utf8(output.stdout).map_err(|error| invalid(error.to_string()))
805}