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.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    // The original workload's twelve swept publications remain in main's
371    // history and tree, even though their session records and refs are gone.
372    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    // Twins and continuations count as retained sessions too; leave fourteen
396    // unrelated sessions beside each selected identity's five scenario slots.
397    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    // Synthetic branch publications and supersessions remain real event streams.
547    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    // Swept sessions leave streams but no records or branch refs. Twelve
578    // publication/retirement pairs per identity preserve that source evidence.
579    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}