Skip to main content

magi/pr_recovery/
lineage.rs

1//! Reproduce a retained launcher's rebase without moving any repository refs.
2
3use std::path::Path;
4
5use anyhow::{Context, Result, ensure};
6use serde::{Deserialize, Serialize};
7
8use super::Selection;
9use crate::{git, run::RunState};
10
11/// Exact replay correspondence retained with the operator's selection.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct SourceProof {
14    /// Retained historical run supplying the launcher provenance.
15    pub run: String,
16    /// Exact public update lease approved by the operator.
17    pub public_head: String,
18    /// Complete adopted implementation, including subsequent review fixes.
19    pub source_head: String,
20    /// Shared base before replaying the selected public implementation.
21    pub old_base: String,
22    /// Immutable base recorded by the successful launcher base-sync.
23    pub new_base: String,
24    /// Fetched landing base against which this selection was approved.
25    pub current_base: String,
26    /// Original commit, rebased commit and reproduced tree, in replay order.
27    pub commits: Vec<(String, String, String)>,
28    /// All later reviewed changes are adopted, rather than just the replayed tip.
29    pub fixes: Vec<String>,
30}
31
32struct ReplayDir(std::path::PathBuf);
33
34impl ReplayDir {
35    fn new() -> Result<Self> {
36        let path =
37            std::env::temp_dir().join(format!("magi-source-replay-{:016x}", crate::rng::entropy()));
38        let builder = std::fs::DirBuilder::new();
39        #[cfg(unix)]
40        let builder = {
41            use std::os::unix::fs::DirBuilderExt;
42            let mut private = builder;
43            private.mode(0o700);
44            private
45        };
46        builder
47            .create(&path)
48            .context("creating private source replay directory")?;
49        Ok(Self(path))
50    }
51}
52
53impl Drop for ReplayDir {
54    fn drop(&mut self) {
55        let _ = std::fs::remove_dir_all(&self.0);
56    }
57}
58
59async fn linear(repo: &Path, base: &str, head: &str) -> Result<Vec<String>> {
60    ensure!(
61        git::ancestry(repo, base, head).await?,
62        "{base} is not an ancestor of {head}"
63    );
64    let raw = git::git(
65        repo,
66        &[
67            "rev-list",
68            "--reverse",
69            "--parents",
70            &format!("{base}..{head}"),
71        ],
72    )
73    .await?;
74    let mut previous = base.to_owned();
75    let mut commits = Vec::new();
76    for line in raw.lines() {
77        let words: Vec<_> = line.split_whitespace().collect();
78        ensure!(
79            words.len() == 2 && words[1] == previous,
80            "unsupported non-linear source history at {line}; inspect the retained run before reconstructing"
81        );
82        let tree = git::rev_parse(repo, &format!("{}^{{tree}}", words[0])).await?;
83        let parent_tree = git::rev_parse(repo, &format!("{previous}^{{tree}}")).await?;
84        ensure!(tree != parent_tree, "unsupported empty commit {}", words[0]);
85        previous = words[0].to_owned();
86        commits.push(previous.clone());
87    }
88    Ok(commits)
89}
90
91pub(super) async fn prove(
92    selected: &Selection,
93    repo: &Path,
94    home: &Path,
95    current_base: &str,
96) -> Result<SourceProof> {
97    let id = selected
98        .source_run
99        .as_deref()
100        .context("missing --source-run")?;
101    ensure!(
102        crate::run::is_run_id(id),
103        "source-run requires its full run id"
104    );
105    let run = RunState::load_under(id, home).with_context(|| {
106        format!("missing or unreadable source-run {id}; restore retained run provenance")
107    })?;
108    let common = |path: &Path| {
109        let path = path.to_owned();
110        async move {
111            git::git(
112                &path,
113                &["rev-parse", "--path-format=absolute", "--git-common-dir"],
114            )
115            .await
116        }
117    };
118    ensure!(
119        common(repo).await? == common(&run.repo).await?,
120        "source-run repository differs from recovery repository"
121    );
122    let task = selected
123        .branch
124        .strip_prefix("magi/task-")
125        .context("invalid selected task branch")?;
126    ensure!(
127        run.id == id && run.origin.as_ref().and_then(|o| o.task.as_deref()) == Some(task),
128        "source-run belongs to another task"
129    );
130    let original = run.pr_recovery.as_ref().context(
131        "source-run lacks its original public-head selection; ancestry cannot be inferred",
132    )?;
133    ensure!(
134        original.url == selected.url
135            && original.branch == selected.branch
136            && original.head == selected.head
137            && original.source() == selected.head,
138        "source-run PR/public lease or original implementation does not match the selection"
139    );
140    let sync = run
141        .base_sync
142        .as_ref()
143        .context("source-run lacks base-sync provenance")?;
144    ensure!(
145        sync.attempts > 0 && sync.conflict.is_none() && sync.already_in.is_none(),
146        "source-run has no successful base-sync; inspect its conflict/provenance"
147    );
148    ensure!(
149        git::ancestry(repo, &sync.tip, current_base).await?,
150        "recorded rebase base {} is outside current landing base {current_base}",
151        sync.tip
152    );
153    let source = git::rev_parse(repo, &format!("{}^{{commit}}", selected.source())).await?;
154    ensure!(
155        source == selected.source(),
156        "source-head must be a full SHA"
157    );
158    ensure!(
159        run.reviews.last().is_some_and(|r| r.head == source),
160        "source {source} is not the source-run's final recorded review head; inspect reviews and retain the exact reviewed source"
161    );
162    let mut retained = false;
163    for candidate in &run.candidates {
164        if git::commit_of(repo, &candidate.branch).await.as_deref() == Some(&source)
165            && run.events.iter().any(|e| {
166                e.node == "land"
167                    && e.message
168                        == format!(
169                            "rebased {} onto {}/{}",
170                            candidate.branch, run.config.merge.remote, run.base_branch
171                        )
172            })
173        {
174            retained = true;
175        }
176    }
177    ensure!(
178        retained,
179        "source {source} lacks a retained candidate ref and successful launcher rebase event in run {id}"
180    );
181    let old_base = git::merge_base(repo, &selected.head, &sync.tip).await?;
182    let originals = linear(repo, &old_base, &selected.head).await?;
183    let rebased = linear(repo, &sync.tip, &source).await?;
184    ensure!(
185        !originals.is_empty() && rebased.len() >= originals.len(),
186        "source {source} lacks the complete public implementation {} (base {old_base}, onto {})",
187        selected.head,
188        sync.tip
189    );
190
191    // A private bare object database isolates replay from the retained repository.
192    // No branch, checkout, index, sequencer or historical run record is modified.
193    let temp = ReplayDir::new()?;
194    let objects = temp.0.join("replay.git");
195    git::git(
196        repo,
197        &[
198            "clone",
199            "--bare",
200            "--shared",
201            "--quiet",
202            "--",
203            &repo.to_string_lossy(),
204            &objects.to_string_lossy(),
205        ],
206    )
207    .await?;
208    let mut parent = old_base.clone();
209    let mut onto = sync.tip.clone();
210    let mut commits = Vec::new();
211    for (old, new) in originals.iter().zip(&rebased) {
212        let replay = git::git_raw(
213            &objects,
214            &[
215                "merge-tree",
216                "--write-tree",
217                &format!("--merge-base={parent}"),
218                &onto,
219                old,
220            ],
221        )
222        .await?;
223        ensure!(
224            replay.ok(),
225            "cannot reproduce {old} -> {new} onto {onto}: {}; {}; restore provenance or reconstruct and review explicitly",
226            replay.stdout,
227            replay.stderr
228        );
229        let tree = replay
230            .stdout
231            .lines()
232            .next()
233            .context("replay returned no tree")?
234            .to_owned();
235        let expected = git::rev_parse(repo, &format!("{new}^{{tree}}")).await?;
236        if tree != expected {
237            let paths = git::git(&objects, &["diff", "--name-status", &tree, &expected]).await?;
238            anyhow::bail!(
239                "modified implementation {old} -> {new} (base {old_base}, onto {}): tree {tree} != {expected}; differing paths:\n{paths}",
240                sync.tip
241            );
242        }
243        commits.push((old.clone(), new.clone(), tree));
244        parent = old.clone();
245        onto = new.clone();
246    }
247    Ok(SourceProof {
248        run: id.to_owned(),
249        public_head: selected.head.clone(),
250        source_head: source,
251        old_base,
252        new_base: sync.tip.clone(),
253        current_base: current_base.to_owned(),
254        fixes: rebased[originals.len()..].to_vec(),
255        commits,
256    })
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::{config::Config, pr_recovery::Disposition, run::Origin};
263    use serde_json::json;
264
265    async fn commit(repo: &Path, name: &str, contents: &str) -> String {
266        std::fs::write(repo.join(name), contents).unwrap();
267        git::git(repo, &["add", "."]).await.unwrap();
268        git::git(repo, &["commit", "-qm", name]).await.unwrap();
269        git::rev_parse(repo, "HEAD").await.unwrap()
270    }
271
272    async fn fixture() -> (tempfile::TempDir, Selection, RunState, String) {
273        let temp = tempfile::tempdir().unwrap();
274        let repo = temp.path().join("repo");
275        std::fs::create_dir(&repo).unwrap();
276        git::git(&repo, &["init", "-q", "-b", "main"])
277            .await
278            .unwrap();
279        git::git(&repo, &["config", "user.name", "Test"])
280            .await
281            .unwrap();
282        git::git(&repo, &["config", "user.email", "test@example.invalid"])
283            .await
284            .unwrap();
285        let base = commit(&repo, "shared", "old\n\n\n\n\n\n\n\n\n\noriginal\n").await;
286        git::git(&repo, &["checkout", "-qb", "public"])
287            .await
288            .unwrap();
289        for i in 0..4 {
290            commit(
291                &repo,
292                &format!("implementation-{i}"),
293                &format!("selected {i}\n"),
294            )
295            .await;
296        }
297        let public = commit(
298            &repo,
299            "shared",
300            "old\n\n\n\n\n\n\n\n\n\nmeasured implementation\n",
301        )
302        .await;
303        git::git(&repo, &["checkout", "-q", "main"]).await.unwrap();
304        commit(&repo, "new-main-only", "keep new main\n").await;
305        let new_base = commit(&repo, "shared", "new main\n\n\n\n\n\n\n\n\n\noriginal\n").await;
306        git::git(&repo, &["checkout", "-qb", "retained", &public])
307            .await
308            .unwrap();
309        git::git(&repo, &["rebase", "--onto", &new_base, &base])
310            .await
311            .unwrap();
312        let rebased = git::rev_parse(&repo, "HEAD").await.unwrap();
313        let source = commit(&repo, "review-fix", "keep complete reviewed fix\n").await;
314        let mut selected = Selection {
315            url: "https://example.test/team/project/pull/1".into(),
316            branch: "magi/task-t".into(),
317            head: public,
318            source_head: Some(source.clone()),
319            source_run: None,
320            source_proof: None,
321            disposition: Some(Disposition::Land),
322            instruction: "preserve implementation".into(),
323            approved_at: jiff::Timestamp::now(),
324        };
325        let mut state = RunState::new(
326            repo,
327            "main".into(),
328            new_base.clone(),
329            selected.instruction.clone(),
330            Config::default(),
331        );
332        state.origin = Some(Origin::queue("t"));
333        let mut original = selected.clone();
334        original.source_head = None;
335        original.disposition = None;
336        state.pr_recovery = Some(original);
337        state.base_sync = Some(crate::run::BaseSync {
338            tip: new_base.clone(),
339            behind: 0,
340            attempts: 1,
341            conflict: None,
342            already_in: None,
343        });
344        state.candidates.push(serde_json::from_value(json!({
345            "index":0,"label":"A","agent":"existing branch","branch":"retained","worktree":"unused"
346        })).unwrap());
347        state.reviews = [rebased, source]
348            .into_iter()
349            .enumerate()
350            .map(|(i, head)| {
351                serde_json::from_value(json!({"round":i+1,"head":head,"reviews":[]})).unwrap()
352            })
353            .collect();
354        state.event("land", "rebased retained onto origin/main");
355        selected.source_run = Some(state.id.clone());
356        state.save_under(temp.path()).unwrap();
357        (temp, selected, state, new_base)
358    }
359
360    #[tokio::test]
361    async fn actual_rebase_preserves_all_five_commits_main_and_later_review_fixes() {
362        let (temp, mut selected, state, base) = fixture().await;
363        assert!(!git::is_ancestor(&state.repo, &selected.head, selected.source()).await);
364        let proof = prove(&selected, &state.repo, temp.path(), &base)
365            .await
366            .unwrap();
367        assert_eq!(proof.commits.len(), 5);
368        assert_eq!(proof.fixes, [selected.source()]);
369        for i in 0..4 {
370            assert_eq!(
371                git::git(
372                    &state.repo,
373                    &["show", &format!("{}:implementation-{i}", selected.source())]
374                )
375                .await
376                .unwrap(),
377                format!("selected {i}")
378            );
379        }
380        assert_eq!(
381            git::git(
382                &state.repo,
383                &["show", &format!("{}:shared", selected.source())]
384            )
385            .await
386            .unwrap(),
387            "new main\n\n\n\n\n\n\n\n\n\nmeasured implementation"
388        );
389        assert_eq!(
390            git::git(
391                &state.repo,
392                &["show", &format!("{}:review-fix", selected.source())]
393            )
394            .await
395            .unwrap(),
396            "keep complete reviewed fix"
397        );
398        assert_eq!(
399            git::git(
400                &state.repo,
401                &["show", &format!("{}:new-main-only", selected.source())]
402            )
403            .await
404            .unwrap(),
405            "keep new main"
406        );
407        selected.source_proof = Some(Box::new(proof.clone()));
408        let saved = serde_json::to_string(&selected).unwrap();
409        let restored: Selection = serde_json::from_str(&saved).unwrap();
410        assert_eq!(
411            restored.source_proof.as_deref(),
412            Some(
413                &prove(&restored, &state.repo, temp.path(), &base)
414                    .await
415                    .unwrap()
416            )
417        );
418        assert!(git::status_porcelain(&state.repo).await.unwrap().is_empty());
419    }
420
421    #[tokio::test]
422    async fn missing_launcher_records_foreign_identity_and_wrong_base_are_rejected() {
423        let (temp, selected, state, base) = fixture().await;
424        assert!(
425            prove(&selected, &state.repo, temp.path(), &selected.head)
426                .await
427                .unwrap_err()
428                .to_string()
429                .contains("outside current")
430        );
431        for missing in [
432            "original-selection",
433            "rebase-event",
434            "retained-ref",
435            "review-head",
436            "task",
437            "conflict",
438        ] {
439            let mut damaged = state.clone();
440            match missing {
441                "original-selection" => damaged.pr_recovery = None,
442                "rebase-event" => damaged.events.clear(),
443                "retained-ref" => damaged.candidates.clear(),
444                "review-head" => damaged.reviews.last_mut().unwrap().head = selected.head.clone(),
445                "task" => damaged.origin = Some(Origin::queue("other")),
446                _ => damaged.base_sync.as_mut().unwrap().conflict = Some("conflicted".into()),
447            }
448            damaged.save_under(temp.path()).unwrap();
449            assert!(
450                prove(&selected, &state.repo, temp.path(), &base)
451                    .await
452                    .is_err(),
453                "{missing}"
454            );
455        }
456    }
457
458    #[tokio::test]
459    async fn saved_proof_is_recomputed_and_never_silently_replaced() {
460        let (temp, mut selected, mut state, base) = fixture().await;
461        selected
462            .prepare_source(&state.repo, temp.path(), &base)
463            .await
464            .unwrap();
465        selected
466            .verify_source_under(&state.repo, temp.path())
467            .await
468            .unwrap();
469        let proof = selected.source_proof.clone();
470        assert!(
471            selected
472                .prepare_source(&state.repo, temp.path(), &base)
473                .await
474                .is_err()
475        );
476        assert_eq!(selected.source_proof, proof);
477        selected.source_proof.as_mut().unwrap().commits[0].2 = "0".repeat(40);
478        assert!(
479            selected
480                .verify_source_under(&state.repo, temp.path())
481                .await
482                .unwrap_err()
483                .to_string()
484                .contains("proof changed")
485        );
486        selected.source_proof = proof;
487        state.base_sync = None;
488        state.save_under(temp.path()).unwrap();
489        assert!(
490            selected
491                .verify_source_under(&state.repo, temp.path())
492                .await
493                .unwrap_err()
494                .to_string()
495                .contains("base-sync")
496        );
497    }
498
499    #[tokio::test]
500    async fn unrelated_modified_missing_and_moved_public_evidence_fail_closed() {
501        let (temp, mut selected, mut state, base) = fixture().await;
502        let home = temp.path();
503        let public = selected.head.clone();
504        selected.head = base.clone();
505        assert!(
506            prove(&selected, &state.repo, home, &base)
507                .await
508                .unwrap_err()
509                .to_string()
510                .contains("lease")
511        );
512        selected.head = public;
513        let sync = state.base_sync.take();
514        state.save_under(home).unwrap();
515        assert!(
516            prove(&selected, &state.repo, home, &base)
517                .await
518                .unwrap_err()
519                .to_string()
520                .contains("base-sync")
521        );
522        state.base_sync = sync;
523        state.save_under(home).unwrap();
524        let mut legacy = serde_json::to_value(&selected).unwrap();
525        legacy.as_object_mut().unwrap().remove("source_proof");
526        legacy.as_object_mut().unwrap().remove("source_run");
527        let legacy: Selection = serde_json::from_value(legacy).unwrap();
528        assert!(
529            legacy
530                .verify_source(&state.repo)
531                .await
532                .unwrap_err()
533                .to_string()
534                .contains("--source-run")
535        );
536        // Make the final review metadata name a genuinely modified rebase result.
537        git::git(&state.repo, &["checkout", "-q", "retained~1"])
538            .await
539            .unwrap();
540        std::fs::write(
541            state.repo.join("implementation-0"),
542            "replaced implementation\n",
543        )
544        .unwrap();
545        git::git(&state.repo, &["add", "."]).await.unwrap();
546        git::git(&state.repo, &["commit", "--amend", "--no-edit", "-q"])
547            .await
548            .unwrap();
549        let modified = commit(&state.repo, "review-fix", "later review\n").await;
550        git::git(&state.repo, &["branch", "-f", "retained", &modified])
551            .await
552            .unwrap();
553        selected.source_head = Some(modified.clone());
554        state.reviews.last_mut().unwrap().head = modified;
555        state.save_under(home).unwrap();
556        let error = prove(&selected, &state.repo, home, &base)
557            .await
558            .unwrap_err()
559            .to_string();
560        assert!(
561            error.contains("modified implementation") && error.contains("implementation-0"),
562            "{error}"
563        );
564        git::git(&state.repo, &["checkout", "-q", "--orphan", "unrelated"])
565            .await
566            .unwrap();
567        git::git(&state.repo, &["rm", "-rf", "."]).await.unwrap();
568        let unrelated = commit(&state.repo, "other", "unrelated\n").await;
569        selected.source_head = Some(unrelated.clone());
570        state.reviews.last_mut().unwrap().head = unrelated.clone();
571        git::git(&state.repo, &["branch", "-f", "retained", &unrelated])
572            .await
573            .unwrap();
574        state.save_under(home).unwrap();
575        assert!(prove(&selected, &state.repo, home, &base).await.is_err());
576        selected.source_run = Some("20000101-000000-abcd".into());
577        assert!(
578            prove(&selected, &state.repo, home, &base)
579                .await
580                .unwrap_err()
581                .to_string()
582                .contains("provenance")
583        );
584    }
585
586    /// Operator-supplied paths allow read-only validation of an actual retained history.
587    #[tokio::test]
588    #[ignore = "requires a local retained run and repository"]
589    async fn inspect_retained_rebase_history() {
590        let env = |name| std::env::var(name).unwrap();
591        let home = std::path::PathBuf::from(env("RECOVERY_HOME"));
592        let state = RunState::load_under(&env("RECOVERY_RUN"), &home).unwrap();
593        let mut selected = state.pr_recovery.clone().unwrap();
594        selected.source_run = Some(state.id.clone());
595        selected.source_head = Some(env("RECOVERY_SOURCE"));
596        let proof = prove(&selected, &state.repo, &home, &env("RECOVERY_BASE"))
597            .await
598            .unwrap();
599        assert_eq!(proof.commits.len(), 5);
600        assert_eq!(proof.fixes, [selected.source()]);
601        eprintln!("{}", serde_json::to_string_pretty(&proof).unwrap());
602    }
603}