Skip to main content

onevcs_testing/
recovery.rs

1// llmlint: ignore[new_code_lands_in_a_project] AGENTS.md assigns onevcs-testing
2// to onevcs's workspace targets through crateSource (crates/**/*), as its existing
3// sources are owned; a second crate project would duplicate those workspace checks.
4//! Full-size real-Git recovery fixtures shared by the producer and its consumers.
5//!
6//! Git imports create real trees, commits and refs in batches. Persistence is written
7//! through onevcs's test-support bridge, using its production types and emitter.
8
9use 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/// One of the two unchanged host-shaped workload sizes.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Scale {
22    /// 20 identities, 371 sessions, 41 labelled sessions, 2308 streams.
23    One,
24    /// 200 identities, 3714 sessions, 414 labelled sessions, 23085 streams.
25    Ten,
26}
27
28impl Scale {
29    /// Numeric scale recorded in telemetry.
30    pub fn number(self) -> u32 {
31        match self {
32            Self::One => 1,
33            Self::Ten => 10,
34        }
35    }
36    /// Exact workload counts, in identity/session/labelled/stream order.
37    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/// A recovery branch's semantic class in the fixture.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Class {
48    /// Recorded landing of the complete branch.
49    Landed,
50    /// Content identical to a landed twin.
51    Retirable,
52    /// Landed retry superseded this differing branch.
53    Superseded,
54    /// Held by the fixture's live process.
55    Live,
56    /// Work not carried by the base.
57    No,
58    /// Same subject reached the base with different content.
59    Unknown,
60    /// A recorded landing predates additional work.
61    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/// One selected branch, and the class its real evidence must establish.
75#[derive(Debug, Clone)]
76pub struct Expected {
77    /// Normalized identity.
78    pub identity: String,
79    /// Branch ref suffix.
80    pub branch: Ref,
81    /// Last session naming the branch.
82    pub session: Token,
83    /// Semantic fixture class.
84    pub class: Class,
85    /// Actual full branch object name.
86    pub tip: ObjectId,
87}
88
89/// A generated host, whose live session owners remain this process while it lives.
90#[derive(Debug)]
91pub struct Fixture {
92    /// Empty-root preparation directory owned by the caller.
93    pub root: PathBuf,
94    /// State root to pass as ONEVCS_HOME.
95    pub home: PathBuf,
96    /// Launcher label selected by the recovery read.
97    pub launcher: String,
98    /// Exact required workload size.
99    pub scale: Scale,
100    /// Expected selected branches, including the withheld ones.
101    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
191/// Build the required workload once, refusing a nonempty root. Nothing touches the
192/// caller's real registry, Git configuration, branches or sessions.
193pub 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, &registry)?;
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    // The original workload's twelve swept publications remain in main's
367    // history and tree, even though their session records and refs are gone.
368    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    // Twins and continuations count as retained sessions too; leave fourteen
392    // unrelated sessions beside each selected identity's five scenario slots.
393    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    // Synthetic branch publications and supersessions remain real event streams.
543    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    // Swept sessions leave streams but no records or branch refs. Twelve
574    // publication/retirement pairs per identity preserve that source evidence.
575    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}