1use std::path::Path;
4
5use anyhow::{Context, Result, ensure};
6use serde::{Deserialize, Serialize};
7
8use super::Selection;
9use crate::{git, run::RunState};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct SourceProof {
14 pub run: String,
16 pub public_head: String,
18 pub source_head: String,
20 pub old_base: String,
22 pub new_base: String,
24 pub current_base: String,
26 pub commits: Vec<(String, String, String)>,
28 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 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 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 #[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}