Skip to main content

magi/
rebase.rs

1//! Rebasing the winning branch onto a moved base, with a fixer for conflicts.
2//!
3//! Both `graph::Runner::sync_to_base` (before review) and `land::Step::Rebase`
4//! (after the pull request exists) used to stop on a conflict and leave it to
5//! a person. A conflict is often a chore - two `pub mod` lines on the same
6//! spot - and what follows it (a rule the base gained meanwhile) is exactly
7//! what the gate-fix round handles, so both callers now come through
8//! [`rebase_with_fixer`], which hands the standing conflict to the fixer seat.
9//!
10//! The rules, none optional:
11//!
12//! - **magi resolves nothing itself.** No taking one side, no line merging:
13//!   the agent decides. Everything here is scaffolding around that call.
14//! - **The round is judged by what git says**, never by the fixer's report:
15//!   the rebase is no longer in progress, nothing is unmerged, no conflict
16//!   marker is left in a path that conflicted, and the base is an ancestor of
17//!   the result (an agent that ran `git rebase --abort` leaves a tidy tree
18//!   that contains no base at all). Whether the tree *builds* is left to the
19//!   review and gate that follow, which already know how to fix a breakage.
20//! - **The budget is `graph.review_rounds`, counted in
21//!   `RunState::rebase_fixes` per sequencer stop** and saved before the fixer
22//!   is called, shared by both callers, so a park or crash cannot hand a round back. It
23//!   touches neither the task's attempts nor land's own rebase budget.
24//! - **A failure restores the branch.** Whatever the fixer left, the branch
25//!   ref goes back to where it was and the throwaway worktree is removed, so
26//!   the fallback is exactly the old "conflict, a person decides".
27
28use std::path::Path;
29use std::time::Duration;
30
31use anyhow::{Context as _, Result};
32use jiff::Timestamp;
33
34use crate::agent::{self, Invocation};
35use crate::git;
36use crate::land::seat_of;
37use crate::prompt;
38use crate::run::{QuotaLoss, RebaseFixRecord, RunState};
39
40/// How [`rebase_with_fixer`] ended.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Rebased {
43    /// The branch now sits on top of the base (rebased cleanly or resolved by
44    /// the fixer); the throwaway worktree is gone.
45    Applied,
46    /// It did not apply. The branch is untouched, the worktree is gone, and
47    /// the string says what was tried: rounds spent, paths still conflicted,
48    /// and what git said.
49    Stopped(String),
50}
51
52/// Longest conflict excerpt shown to the fixer, per file and in total.
53const HUNK_PER_FILE: usize = 4_000;
54const HUNK_TOTAL: usize = 16_000;
55/// Commit subjects listed per side.
56const SUBJECTS: usize = 20;
57/// Paths named in a failure reason before "and N more".
58const PATHS_IN_REASON: usize = 8;
59
60/// Rebase `branch` onto `onto` in `scratch`, giving a conflict to the fixer.
61///
62/// Re-entrant: when `scratch` already holds a rebase in progress (a run that
63/// died in the middle of a round) it carries on from there instead of
64/// starting over, and the rounds already recorded still count.
65pub async fn rebase_with_fixer(
66    state: &mut RunState,
67    scratch: &Path,
68    branch: &str,
69    onto: &str,
70) -> Result<Rebased> {
71    let repo = state.repo.clone();
72    let cap = state.config.graph.review_rounds;
73    let orig = git::rev_parse(&repo, &format!("refs/heads/{branch}")).await?;
74
75    let mut said = String::new();
76    if !git::rebase_in_progress(scratch).await {
77        match git::rebase_start(&repo, scratch, branch, onto).await? {
78            git::RebaseStart::Applied => return Ok(Rebased::Applied),
79            git::RebaseStart::Failed(why) => return Ok(Rebased::Stopped(why)),
80            git::RebaseStart::Conflicted(why) => said = why,
81        }
82    }
83    // A tracking ref may have advanced while this rebase was parked. Finish
84    // the saved operation against its own target, then start an ordinary
85    // base sync if the requested target has moved.
86    let onto_sha = rebase_target(scratch).await?;
87    // Every path that was ever unmerged: the marker check on the finished
88    // tree looks at exactly these.
89    let mut touched: Vec<String> = Vec::new();
90
91    loop {
92        if !git::rebase_in_progress(scratch).await {
93            let result = finish(state, scratch, branch, &orig, &onto_sha, &touched, &said).await?;
94            if result == Rebased::Applied && git::rev_parse(&repo, onto).await? != onto_sha {
95                state.event("rebase", "saved rebase completed; syncing the newer base");
96                state.save()?;
97                return Box::pin(rebase_with_fixer(state, scratch, branch, onto)).await;
98            }
99            return Ok(result);
100        }
101        let paths = git::unmerged_paths(scratch).await.unwrap_or_default();
102        for p in &paths {
103            if !touched.contains(p) {
104                touched.push(p.clone());
105            }
106        }
107        // A fixer that already worked on *this* stop point may have left the
108        // contents resolved with only the staging undone (a run killed between
109        // the steps). Finish that before the budget is looked at:
110        // re-confirming resolved files is not a conflict round.
111        if let Launch::Advanced = launcher_stage(state, scratch, &orig, &onto_sha).await? {
112            continue;
113        }
114        let spent = stop_rounds(state, scratch).await;
115        if spent >= cap {
116            let why = reason(spent, cap, &paths, &said, "the rounds are spent");
117            abandon(&repo, scratch, branch, &orig).await;
118            return Ok(Rebased::Stopped(why));
119        }
120
121        let winner = state
122            .winner()
123            .cloned()
124            .context("resolving a rebase conflict needs a winning candidate")?;
125        let roles = state
126            .config
127            .resolve_roles()
128            .context("resolve the roster for the rebase fix")?;
129        // The chain (`crate::fixer`): one round however many agents it takes.
130        let attempts = crate::fixer::attempts(state, &roles, &winner);
131        let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
132
133        let round = spent + 1;
134        let artifact_round = state.rebase_fixes.len() + 1;
135        let branch_subjects = subjects(scratch, &format!("{onto}..{branch}")).await;
136        let onto_subjects = subjects(scratch, &format!("{branch}..{onto}")).await;
137        let hunks = hunks(scratch, &paths);
138        let prompt_text = prompt::rebase_conflict(&prompt::RebaseConflict {
139            instruction: &state.instruction,
140            worktree: scratch,
141            branch,
142            onto,
143            paths: &paths,
144            branch_subjects: &branch_subjects,
145            onto_subjects: &onto_subjects,
146            hunks: &hunks,
147            round,
148            cap,
149            language: &state.config.graph.language,
150            github_language: &state.config.graph.github_language,
151        });
152        let prompt_text = if state.config.cache_dir().is_some() {
153            format!("{prompt_text}\n\n{}", prompt::build_cache_note("fix", true))
154        } else {
155            prompt_text
156        };
157
158        // Spent before the call, on disk: a run killed mid-round must not
159        // get the round back.
160        let stop = rebase_head(scratch).await;
161        let index_outside = Some(round_baseline(state, scratch, &paths).await?);
162        state.rebase_fixes.push(RebaseFixRecord {
163            outage: None,
164            agent: ids[0].clone(),
165            paths: paths.clone(),
166            from: Some(orig.clone()),
167            finished: false,
168            error: None,
169            stop,
170            index_outside,
171            launcher: None,
172            infra: None,
173        });
174        state.event(
175            "rebase",
176            format!(
177                "{branch} conflicts with {onto} ({} path(s)); fixer round {round} of {cap}",
178                paths.len()
179            ),
180        );
181        state.save()?;
182
183        // Each id at most once, forward only. The round was spent and saved
184        // above, so a chain advance inside it spends nothing further and a
185        // crash cannot hand it back; only the record's `agent` follows the
186        // asked agent (saved before its call, like the round itself).
187        let artifacts = agent::artifacts_dir(&state.dir());
188        let mut last = None;
189        for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
190            if i > 0 {
191                if let Some(r) = state.rebase_fixes.last_mut() {
192                    r.agent = spec.id.clone();
193                }
194                state.save()?;
195            }
196            let mut seat = seat_of(state, &seat_key, &spec.id);
197            let stem = if i == 0 {
198                format!("rebase-fix-{artifact_round}")
199            } else {
200                format!("rebase-fix-{artifact_round}-{}", spec.id)
201            };
202            let out = agent::invoke(
203                &spec,
204                &mut seat,
205                &Invocation {
206                    coordination: state.config.coordination.as_ref(),
207                    fetched_base: state.fetched_base.as_ref(),
208                    cwd: scratch,
209                    prompt: &prompt_text,
210                    timeout: Duration::from_secs(state.config.graph.timeout_fix),
211                    allow_write: true,
212                    sessions: state.config.graph.sessions,
213                    artifacts: &artifacts,
214                    stem: &stem,
215                    run: &state.id,
216                    node: "rebase",
217                    cache_dir: state.config.cache_dir().as_deref(),
218                    attachments: &[],
219                    writable: &[],
220                },
221            )
222            .await;
223            let seat_name = seat.key.clone();
224            state.seats.insert(seat.key.clone(), seat);
225            if let Some(next) = ids.get(i + 1)
226                && agent::chain_advances(&out)
227            {
228                let (class, why) = crate::fixer::failure_of(&out);
229                crate::graph::record_handover(
230                    state, "rebase", &seat_name, &spec.id, next, &class, &why,
231                );
232                continue;
233            }
234            last = Some((out, seat_name));
235            break;
236        }
237        let (out, seat_name) = last.expect("the fixer chain always has an entry");
238
239        let mut error = None;
240        match out {
241            Ok(o) if o.quota_exhausted() => {
242                let loss = QuotaLoss::from_report(seat_name, "rebase", o.quota.as_ref());
243                if let Some(record) = state.rebase_fixes.last_mut() {
244                    record.outage = Some(loss.cause);
245                }
246                crate::graph::block_machine_outage(state, "rebase", std::slice::from_ref(&loss));
247                state.quota.push(loss);
248                state.save()?;
249                return Err(crate::repo_lock::ResourceBlocked(
250                    "rebase fixer unavailable; restore agent availability and resume".into(),
251                )
252                .into());
253            }
254            Ok(o) if !o.usable() => {
255                error = Some(format!(
256                    "the fixer produced nothing usable ({})",
257                    crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
258                ));
259            }
260            Ok(_) => {}
261            Err(e) => error = Some(format!("{e:#}")),
262        }
263
264        if let Some(r) = state.rebase_fixes.last_mut() {
265            r.error = error.clone();
266        }
267        // The seat resolved contents; the launcher owns Git's metadata.
268        // A denied write is a resumable stop with no further round.
269        if let Launch::Nothing(why) = launcher_stage(state, scratch, &orig, &onto_sha).await? {
270            state.event("rebase", format!("launcher staging skipped: {why}"));
271        }
272        let finished = !git::rebase_in_progress(scratch).await;
273        if let Some(r) = state.rebase_fixes.last_mut() {
274            r.finished = finished;
275        }
276        state.save()?;
277    }
278}
279
280/// What [`launcher_stage`] did.
281#[derive(Debug)]
282enum Launch {
283    /// Nothing was staged or continued; why, for the event log.
284    Nothing(String),
285    /// The sequencer moved (continued to the end or to the next stop).
286    Advanced,
287}
288
289/// The commit the sequencer is replaying right now.
290async fn rebase_head(scratch: &Path) -> Option<String> {
291    git::git(scratch, &["rev-parse", "-q", "--verify", "REBASE_HEAD"])
292        .await
293        .ok()
294        .filter(|s| !s.is_empty())
295}
296
297/// A denied or failed Git write is infrastructure: record it on the round,
298/// keep the worktree, branch and resolved contents, and stop resumably.
299fn infra_stop(state: &mut RunState, detail: String) -> anyhow::Error {
300    if let Some(r) = state.rebase_fixes.last_mut() {
301        r.infra = Some(detail.clone());
302    }
303    block(state, "git metadata write", detail)
304}
305
306fn block(state: &mut RunState, kind: &str, detail: String) -> anyhow::Error {
307    let detail = format!("rebase recovery: {detail}");
308    state.resource_block = Some(crate::run::ResourceBlock {
309        cause: crate::run::ResourceBlockCause::Other,
310        node: "rebase".into(),
311        kind: kind.into(),
312        detail: detail.clone(),
313        at: Timestamp::now(),
314    });
315    state.event("resource", detail.clone());
316    state.save().ok();
317    crate::repo_lock::ResourceBlocked(detail).into()
318}
319
320/// Status entries `(XY, path)` of the worktree, renames split.
321async fn status_entries(scratch: &Path) -> Result<Vec<(String, String)>> {
322    let out = git::git_raw(
323        scratch,
324        &[
325            "status",
326            "--porcelain=v1",
327            "-z",
328            "--untracked-files=all",
329            "--no-renames",
330        ],
331    )
332    .await?;
333    anyhow::ensure!(out.ok(), "git status failed: {}", out.stderr);
334    Ok(out
335        .stdout
336        .split('\0')
337        .filter(|e| e.len() > 3)
338        .map(|e| (e[..2].to_owned(), e[3..].to_owned()))
339        .collect())
340}
341
342/// Index-side changes against HEAD outside `expected` (what Git applied
343/// cleanly), as a comparable string.
344async fn index_outside(scratch: &Path, expected: &[String]) -> Result<String> {
345    let out = git::git_raw(
346        scratch,
347        &[
348            "diff",
349            "--cached",
350            "--raw",
351            "-z",
352            "--no-renames",
353            "--no-abbrev",
354        ],
355    )
356    .await?;
357    anyhow::ensure!(out.ok(), "git diff --cached failed: {}", out.stderr);
358    Ok(outside_diff(&out.stdout, expected))
359}
360
361fn outside_diff(raw: &str, expected: &[String]) -> String {
362    let mut parts = raw.split('\0').filter(|p| !p.is_empty());
363    let mut lines = Vec::new();
364    while let (Some(meta), Some(path)) = (parts.next(), parts.next()) {
365        if !expected.iter().any(|e| e == path) {
366            lines.push(format!("{meta}\t{path}"));
367        }
368    }
369    lines.join("\n")
370}
371
372/// Does `last` belong to the stop point the sequencer is at now?
373///
374/// A record carries the replayed commit. A record from before that was kept
375/// can only be matched by what it says about itself, so it counts only when
376/// every recorded round saw the same non-empty conflict set and none knows
377/// its stop: a different pick would not have repeated the same paths.
378async fn same_stop(state: &RunState, scratch: &Path) -> bool {
379    let Some(last) = state.rebase_fixes.last() else {
380        return false;
381    };
382    match &last.stop {
383        Some(stop) => rebase_head(scratch).await.as_deref() == Some(stop.as_str()),
384        None => {
385            !last.paths.is_empty()
386                && state
387                    .rebase_fixes
388                    .iter()
389                    .all(|r| r.stop.is_none() && r.paths == last.paths)
390        }
391    }
392}
393
394/// Records for the current stop form a contiguous suffix. Always retain the
395/// first record's paths and index baseline, even after a rejected seat edit.
396fn stop_records(state: &RunState) -> &[RebaseFixRecord] {
397    let Some(last) = state.rebase_fixes.last() else {
398        return &[];
399    };
400    let start = state
401        .rebase_fixes
402        .iter()
403        .rposition(|r| r.stop != last.stop || (r.stop.is_none() && r.paths != last.paths))
404        .map_or(0, |i| i + 1);
405    &state.rebase_fixes[start..]
406}
407
408async fn stop_rounds(state: &RunState, scratch: &Path) -> usize {
409    if same_stop(state, scratch).await {
410        stop_records(state)
411            .iter()
412            .filter(|record| record.outage.is_none())
413            .count()
414    } else {
415        0
416    }
417}
418
419async fn round_baseline(state: &mut RunState, scratch: &Path, paths: &[String]) -> Result<String> {
420    if same_stop(state, scratch).await {
421        if let Some(before) = &stop_records(state)[0].index_outside {
422            return Ok(before.clone());
423        }
424        return Err(infra_stop(state,
425            "the saved stop has no original outside-index baseline; keep the resolution and have the original owner establish a verified baseline before continuation".into()));
426    }
427    index_outside(scratch, paths)
428        .await
429        .map_err(|e| infra_stop(state, format!("cannot capture index baseline: {e:#}")))
430}
431
432/// Recompute the clean part of this pick without reading the fixer's index
433/// as a baseline. Only object data is written; the retained index, contents,
434/// branch and sequencer are untouched. Unsupported merge shapes fail closed.
435async fn legacy_baseline(
436    scratch: &Path,
437    paths: &[String],
438    lease: Option<crate::repo_lock::SeatLease>,
439) -> Result<(String, String, String)> {
440    let head = git::rev_parse(scratch, "HEAD").await?;
441    let pick = git::rev_parse(scratch, "REBASE_HEAD").await?;
442    let parent = git::rev_parse(scratch, "REBASE_HEAD^").await?;
443    let out = mutate(
444        scratch,
445        &[
446            "merge-tree",
447            "--write-tree",
448            "--name-only",
449            "--no-messages",
450            "-z",
451            &format!("--merge-base={parent}"),
452            &head,
453            &pick,
454        ],
455        lease,
456    )
457    .await?;
458    anyhow::ensure!(
459        out.code == Some(1),
460        "cannot reconstruct legacy conflict baseline: {}",
461        out.stderr
462    );
463    let mut fields = out.stdout.split('\0').filter(|p| !p.is_empty());
464    let tree = fields.next().context("merge-tree returned no tree")?;
465    let mut conflicts: Vec<&str> = fields.collect();
466    conflicts.sort_unstable();
467    let mut expected: Vec<&str> = paths.iter().map(String::as_str).collect();
468    expected.sort_unstable();
469    anyhow::ensure!(
470        conflicts == expected,
471        "reconstructed conflict paths differ from the saved stop"
472    );
473    let out = git::git_raw(
474        scratch,
475        &[
476            "diff",
477            "--raw",
478            "-z",
479            "--no-renames",
480            "--no-abbrev",
481            &head,
482            tree,
483        ],
484    )
485    .await?;
486    anyhow::ensure!(
487        out.ok(),
488        "cannot read reconstructed baseline: {}",
489        out.stderr
490    );
491    Ok((outside_diff(&out.stdout, paths), head, pick))
492}
493
494async fn rebase_target(scratch: &Path) -> Result<String> {
495    let path = git::git(scratch, &["rev-parse", "--git-path", "rebase-merge/onto"]).await?;
496    let target = std::fs::read_to_string(scratch.join(path))?;
497    git::rev_parse(scratch, &format!("{}^{{commit}}", target.trim())).await
498}
499
500async fn stop_identity(scratch: &Path, original: &str, onto: &str) -> Result<()> {
501    for (name, expected) in [("orig-head", original), ("onto", onto)] {
502        let path = git::git(
503            scratch,
504            &["rev-parse", "--git-path", &format!("rebase-merge/{name}")],
505        )
506        .await?;
507        let path = Path::new(&path);
508        let path = if path.is_absolute() {
509            path.to_path_buf()
510        } else {
511            scratch.join(path)
512        };
513        anyhow::ensure!(
514            std::fs::read_to_string(path)?.trim() == expected,
515            "rebase {name} changed"
516        );
517    }
518    Ok(())
519}
520
521// The command owns coordination until all hooks and descendants have stopped,
522// even if its caller is cancelled while waiting for Git.
523async fn mutate(
524    scratch: &Path,
525    args: &[&str],
526    lease: Option<crate::repo_lock::SeatLease>,
527) -> Result<git::GitOut> {
528    use crate::proc::Quiet as _;
529    use std::process::Stdio;
530    use tokio::io::AsyncReadExt as _;
531    let wait = crate::repo_lock::WaitPolicy::current();
532    anyhow::ensure!(
533        !wait.cancelled(),
534        crate::repo_lock::ResourceBlocked("launcher Git operation cancelled".into())
535    );
536    let mut command = tokio::process::Command::new("git");
537    command
538        .quiet()
539        .args(args)
540        .current_dir(scratch)
541        .env("GIT_TERMINAL_PROMPT", "0")
542        .env("GIT_EDITOR", "true")
543        .stdin(Stdio::null())
544        .stdout(Stdio::piped())
545        .stderr(Stdio::piped())
546        .kill_on_drop(true);
547    let outputs = crate::retention::OutputLease::borrow(scratch)?
548        .into_iter()
549        .collect();
550    let mut running = crate::seat_process::Running::spawn_operation(command, lease, outputs)?;
551    let mut stdout = running.child().stdout.take().context("Git stdout")?;
552    let mut stderr = running.child().stderr.take().context("Git stderr")?;
553    let mut out = Vec::new();
554    let mut err = Vec::new();
555    let cancelled = async {
556        while !wait.cancelled() {
557            tokio::time::sleep(Duration::from_millis(50)).await;
558        }
559    };
560    let (status, _, _) = tokio::select! {
561        result = async { tokio::try_join!(
562            running.child().wait(),
563            stdout.read_to_end(&mut out),
564            stderr.read_to_end(&mut err)
565        ) } => result?,
566        () = cancelled => return Err(crate::repo_lock::ResourceBlocked("launcher Git operation cancelled".into()).into()),
567    };
568    running.finish().await;
569    Ok(git::GitOut {
570        code: status.code(),
571        stdout: String::from_utf8_lossy(&out).into_owned(),
572        stderr: String::from_utf8_lossy(&err).into_owned(),
573    })
574}
575
576/// Stage what the fixer resolved and continue the sequencer, as the launcher.
577///
578/// The seat only edits contents; staging and continuing stay with the
579/// launcher. Everything here is re-derived from Git inside the
580/// repository's coordination lease, so an interrupted pass is simply run
581/// again: the current unmerged set must lie inside the round's recorded
582/// paths, none may still hold markers, nothing outside them may have been
583/// edited, and only then are exactly those paths staged (literal pathspecs)
584/// and `rebase --continue` run with the recording exec untouched. Content
585/// problems are `Nothing` (the ordinary round machinery goes on); a refused
586/// Git write is an `Err` resource block that spends nothing and keeps the
587/// worktree, the branch and the resolved files.
588async fn launcher_stage(
589    state: &mut RunState,
590    scratch: &Path,
591    original: &str,
592    onto: &str,
593) -> Result<Launch> {
594    if !git::rebase_in_progress(scratch).await {
595        return Ok(Launch::Nothing("no rebase in progress".into()));
596    }
597    if !same_stop(state, scratch).await {
598        return Ok(Launch::Nothing(
599            "no fixer round is recorded for this stop point".into(),
600        ));
601    }
602    let wait = {
603        let inherited = crate::repo_lock::WaitPolicy::current();
604        crate::repo_lock::WaitPolicy::new(
605            Duration::from_secs(state.config.graph.timeout_resource_wait),
606            move || inherited.cancelled(),
607        )
608    };
609    if wait.cancelled() {
610        return Err(block(
611            state,
612            "repository coordination",
613            "cancelled before staging; the resolved files are kept".into(),
614        ));
615    }
616    let lease = crate::repo_lock::SeatLease::operation_with(
617        scratch,
618        state.config.coordination.as_ref(),
619        &wait,
620    )
621    .await;
622    let lease = match lease {
623        Ok(l) => l,
624        Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => {
625            return Err(block(state, "repository coordination", e.to_string()));
626        }
627        Err(e) => return Err(e),
628    };
629
630    // Re-validated under the lease: the seat is gone, but an external
631    // updater is not.
632    if !git::rebase_in_progress(scratch).await || !same_stop(state, scratch).await {
633        return Ok(Launch::Nothing("the stop point changed".into()));
634    }
635    if let Err(e) = stop_identity(scratch, original, onto).await {
636        return Err(infra_stop(state, format!("{e:#}")));
637    }
638    let last = stop_records(state)[0].clone();
639    let lease = if last.index_outside.is_none() {
640        let (baseline, head, pick) = legacy_baseline(scratch, &last.paths, lease)
641            .await
642            .map_err(|e| infra_stop(state, format!("legacy baseline recovery: {e:#}")))?;
643        // The mutation's cleanup owner held the previous lease. Reacquire
644        // before accepting the result, then revalidate the exact stop/index.
645        let lease = crate::repo_lock::SeatLease::operation_with(
646            scratch,
647            state.config.coordination.as_ref(),
648            &wait,
649        )
650        .await
651        .map_err(|e| block(state, "repository coordination", e.to_string()))?;
652        let validation = async {
653            stop_identity(scratch, original, onto).await?;
654            anyhow::ensure!(
655                same_stop(state, scratch).await
656                    && git::rev_parse(scratch, "HEAD").await? == head
657                    && rebase_head(scratch).await.as_deref() == Some(&pick),
658                "the stop changed during legacy baseline recovery"
659            );
660            anyhow::ensure!(
661                index_outside(scratch, &last.paths).await? == baseline,
662                "the legacy index differs from the reconstructed clean baseline"
663            );
664            Ok::<_, anyhow::Error>(())
665        }
666        .await;
667        if let Err(e) = validation {
668            return Err(infra_stop(
669                state,
670                format!("legacy baseline recovery: {e:#}"),
671            ));
672        }
673        let first = state.rebase_fixes.len() - stop_records(state).len();
674        state.rebase_fixes[first].index_outside = Some(baseline);
675        for record in &mut state.rebase_fixes[first..] {
676            record.stop = Some(pick.clone());
677        }
678        state.event(
679            "rebase",
680            "verified and saved the legacy stop baseline from the original three-way merge",
681        );
682        state.save()?;
683        lease
684    } else {
685        lease
686    };
687    if last.stop.is_none() {
688        let pick = rebase_head(scratch)
689            .await
690            .context("legacy stop has no REBASE_HEAD")?;
691        let first = state.rebase_fixes.len() - stop_records(state).len();
692        for record in &mut state.rebase_fixes[first..] {
693            record.stop = Some(pick.clone());
694        }
695        state.save()?;
696    }
697    let baseline = round_baseline(state, scratch, &last.paths).await?;
698    let unmerged = match git::unmerged_paths(scratch).await {
699        Ok(u) => u,
700        Err(e) => return Err(infra_stop(state, format!("cannot read the index: {e:#}"))),
701    };
702    if let Some(p) = unmerged.iter().find(|p| !last.paths.contains(p)) {
703        return Ok(Launch::Nothing(format!(
704            "`{p}` is unmerged but was not part of the recorded conflict"
705        )));
706    }
707    for p in &last.paths {
708        match std::fs::symlink_metadata(scratch.join(p)) {
709            Ok(m) if m.is_dir() => {
710                return Ok(Launch::Nothing(format!("`{p}` is a directory")));
711            }
712            Ok(m) if m.is_file() => {
713                if let Err(e) = std::fs::read(scratch.join(p)) {
714                    return Err(infra_stop(state, format!("cannot read `{p}`: {e}")));
715                }
716                if has_markers(scratch, p, &["HEAD", "REBASE_HEAD"]).await? {
717                    return Ok(Launch::Nothing(format!(
718                        "`{p}` still holds conflict markers"
719                    )));
720                }
721            }
722            Ok(_) => {}
723            // Resolved by deleting the file.
724            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
725            Err(e) => return Err(infra_stop(state, format!("cannot inspect `{p}`: {e}"))),
726        }
727    }
728    let entries = match status_entries(scratch).await {
729        Ok(e) => e,
730        Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
731    };
732    // Whatever Git applied cleanly is staged and identical to the worktree;
733    // anything else outside the conflict is an edit nobody asked for.
734    let stray: Vec<&str> = entries
735        .iter()
736        .filter(|(_, p)| !last.paths.contains(p))
737        .filter(|(xy, _)| xy == "??" || !xy.ends_with(' '))
738        .map(|(_, p)| p.as_str())
739        .collect();
740    if !stray.is_empty() {
741        return Ok(Launch::Nothing(format!(
742            "changes outside the conflicted paths: {}",
743            stray.join(", ")
744        )));
745    }
746    match index_outside(scratch, &last.paths).await {
747        Ok(now) if now == baseline => {}
748        Ok(_) => {
749            return Ok(Launch::Nothing(
750                "the staged changes outside the conflicted paths changed".into(),
751            ));
752        }
753        Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
754    }
755
756    let set = |state: &mut RunState, step| {
757        if let Some(r) = state.rebase_fixes.last_mut() {
758            r.launcher = Some(step);
759        }
760        state.save()
761    };
762    // A previous pass may have staged the resolution before being interrupted.
763    // Include later worktree edits to those recorded paths as well, while
764    // leaving already-staged deletions (which have no path to add) alone.
765    let to_stage: Vec<&str> = last
766        .paths
767        .iter()
768        .filter(|p| {
769            unmerged.contains(p)
770                || entries
771                    .iter()
772                    .any(|(xy, path)| path == *p && !xy.ends_with(' '))
773        })
774        .map(String::as_str)
775        .collect();
776    if !to_stage.is_empty() {
777        set(state, crate::run::LauncherStep::Staging)?;
778        let mut args = vec!["--literal-pathspecs", "add", "-A", "--"];
779        args.extend(to_stage);
780        match mutate(scratch, &args, lease).await {
781            Ok(o) if o.ok() => {}
782            Ok(o) => {
783                let said = if o.stderr.is_empty() {
784                    o.stdout
785                } else {
786                    o.stderr
787                };
788                return Err(infra_stop(state, format!("staging was refused: {said}")));
789            }
790            Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
791        }
792        match git::unmerged_paths(scratch).await {
793            Ok(left) if left.is_empty() => {}
794            Ok(left) => {
795                return Err(infra_stop(
796                    state,
797                    format!("paths stayed unmerged after staging: {}", left.join(", ")),
798                ));
799            }
800            Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
801        }
802        // Running releases coordination only after all descendants stop.
803        // Reacquire it and revalidate the stop before the next mutation.
804        return Box::pin(launcher_stage(state, scratch, original, onto)).await;
805    }
806    set(state, crate::run::LauncherStep::Staged)?;
807    let before = rebase_head(scratch).await;
808    set(state, crate::run::LauncherStep::Continuing)?;
809    let out = match mutate(scratch, &["rebase", "--continue"], lease).await {
810        Ok(o) => o,
811        Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
812    };
813    if !out.ok() && git::rebase_in_progress(scratch).await {
814        let again = git::unmerged_paths(scratch).await.unwrap_or_default();
815        // A new conflict is the next stop point, not a failure; the same
816        // stop with nothing unmerged is git unable to write.
817        if again.is_empty() || rebase_head(scratch).await == before {
818            let said = git::rebase_said(&if out.stderr.is_empty() {
819                out.stdout
820            } else {
821                out.stderr
822            });
823            return Err(infra_stop(state, format!("continue failed: {said}")));
824        }
825    }
826    state.event(
827        "rebase",
828        format!(
829            "launcher staged {} path(s) and continued the rebase",
830            unmerged.len()
831        ),
832    );
833    Ok(Launch::Advanced)
834}
835
836/// Judge a rebase that is no longer in progress by what git says.
837async fn finish(
838    state: &mut RunState,
839    scratch: &Path,
840    branch: &str,
841    orig: &str,
842    onto_sha: &str,
843    touched: &[String],
844    said: &str,
845) -> Result<Rebased> {
846    let repo = state.repo.clone();
847    let spent = state.rebase_fixes.len();
848    let cap = state.config.graph.review_rounds;
849    let unmerged = git::unmerged_paths(scratch).await.unwrap_or_default();
850    let head = git::rev_parse(scratch, "HEAD").await.unwrap_or_default();
851    // Not only the paths seen unmerged at a round's start: one round may
852    // continue through several commits, and a later commit's conflict never
853    // shows up at a loop head. Everything the result changes relative to the
854    // base is checked.
855    let mut candidates: Vec<String> = touched.to_vec();
856    if let Ok(changed) = git::git(scratch, &["diff", "--name-only", onto_sha, "HEAD"]).await {
857        for p in changed.lines().map(str::trim).filter(|l| !l.is_empty()) {
858            if !candidates.iter().any(|c| c == p) {
859                candidates.push(p.to_owned());
860            }
861        }
862    }
863    let mut marked = Vec::new();
864    for p in candidates {
865        if has_markers(scratch, &p, &[orig, onto_sha]).await? {
866            marked.push(p);
867        }
868    }
869
870    // Skipping every conflicting commit leaves the branch equal to the base:
871    // the ancestry test below passes and the branch's work is gone. Empty is
872    // only acceptable when every original commit already has a patch twin on
873    // the base.
874    let emptied = head == onto_sha
875        && git::cherry(&repo, onto_sha, orig)
876            .await
877            .map_or(true, |(unmatched, _)| !unmatched.is_empty());
878
879    // A fixer that ran `git rebase --skip` on one of several commits lets the
880    // rest apply and the rebase finish; `emptied` only sees the whole change
881    // vanishing. Find the commits that are neither in the result nor already
882    // on the base.
883    let dropped = if unmerged.is_empty() && marked.is_empty() && !emptied && !head.is_empty() {
884        dropped_commits(&repo, scratch, onto_sha, orig, &head).await
885    } else {
886        Ok(Vec::new())
887    };
888
889    let problem = if !unmerged.is_empty() {
890        Some(("paths are still unmerged", unmerged))
891    } else if !marked.is_empty() {
892        Some(("conflict markers were left in the tree", marked))
893    } else if emptied {
894        Some((
895            "the rebase ended with none of the branch's commits applied (all skipped)",
896            touched.to_vec(),
897        ))
898    } else if head.is_empty() || !git::is_ancestor(&repo, onto_sha, &head).await {
899        Some((
900            "the rebase ended without the base in the result (abandoned or skipped)",
901            touched.to_vec(),
902        ))
903    } else if let Err(error) = &dropped {
904        Some((
905            "the rebase record could not be verified",
906            vec![format!("{error:#}")],
907        ))
908    } else if !dropped.as_ref().unwrap().is_empty() {
909        Some((
910            "the rebase dropped some of the branch's commits (skipped?)",
911            dropped.unwrap(),
912        ))
913    } else {
914        None
915    };
916    match problem {
917        None => {
918            git::worktree_remove(&repo, scratch).await.ok();
919            state.event(
920                "rebase",
921                format!("{branch} rebased after {spent} fixer round(s)"),
922            );
923            state.save()?;
924            Ok(Rebased::Applied)
925        }
926        Some((what, paths)) => {
927            let why = reason(spent, cap, &paths, said, what);
928            abandon(&repo, scratch, branch, orig).await;
929            Ok(Rebased::Stopped(why))
930        }
931    }
932}
933
934/// Only a pick whose exec observed the previous HEAD could have been skipped.
935/// Content checks distinguish a genuine loss from a change already upstream.
936async fn dropped_commits(
937    repo: &Path,
938    scratch: &Path,
939    onto_sha: &str,
940    orig: &str,
941    head: &str,
942) -> Result<Vec<String>> {
943    let record = git::rebase_record(scratch).await?;
944    anyhow::ensure!(
945        record.heads.last().map(String::as_str) == Some(head),
946        "rebase final HEAD disagrees with record"
947    );
948    let mut previous = onto_sha;
949    let mut lost = Vec::new();
950    for (sha, picked_head) in record.picks.iter().zip(&record.heads) {
951        if picked_head == previous
952            && !absorbed_in_result(repo, sha, onto_sha, orig, head).await?
953            && !already_in_result(repo, sha, orig, head).await?
954        {
955            lost.push(git::git(repo, &["show", "-s", "--format=%s", sha]).await?);
956        }
957        previous = picked_head;
958    }
959    Ok(lost)
960}
961
962/// Paths `sha` changes, NUL-separated from git so a quoted name is never
963/// misread. Read failures propagate to the rebase verdict.
964async fn commit_paths(repo: &Path, sha: &str) -> Result<Vec<String>> {
965    git::git(
966        repo,
967        &[
968            "diff-tree",
969            "--no-commit-id",
970            "--name-only",
971            "-r",
972            "--root",
973            "-z",
974            sha,
975        ],
976    )
977    .await
978    .map(|o| {
979        o.split('\0')
980            .filter(|l| !l.is_empty())
981            .map(str::to_owned)
982            .collect()
983    })
984}
985
986/// `mode type blob` of `path` at `rev`, or `None` when it does not exist
987/// there. Unlike `rev-parse rev:path` this carries the file mode.
988async fn entry(repo: &Path, rev: &str, path: &str) -> Result<Option<String>> {
989    let out = git::git(repo, &["--literal-pathspecs", "ls-tree", rev, "--", path]).await?;
990    Ok(out
991        .split('\t')
992        .next()
993        .filter(|e| !e.is_empty())
994        .map(str::to_owned))
995}
996
997/// Is `sha`'s change represented in `head`?
998///
999/// First by merging: replaying `sha` onto `head` changes nothing when its
1000/// change is already there, in whatever company (extra upstream edits in the
1001/// same file included). Failing that, per touched path: `head` holds what
1002/// `sha` left there, or what the branch's final tip `orig` has there (a later
1003/// commit may have changed the path again). Mode is part of that comparison,
1004/// and paths come NUL-separated so a quoted name is never misread.
1005async fn already_in_result(repo: &Path, sha: &str, orig: &str, head: &str) -> Result<bool> {
1006    if replay_is_noop(repo, sha, head).await? {
1007        return Ok(true);
1008    }
1009    for p in &commit_paths(repo, sha).await? {
1010        let got = entry(repo, head, p).await?;
1011        if got != entry(repo, sha, p).await? && got != entry(repo, orig, p).await? {
1012            return Ok(false);
1013        }
1014    }
1015    Ok(true)
1016}
1017
1018/// Does merging `sha` (against its parent) into `head` cleanly yield `head`'s
1019/// own tree? Conflicts and changes return false; read failures propagate.
1020async fn replay_is_noop(repo: &Path, sha: &str, head: &str) -> Result<bool> {
1021    let base = format!("{sha}^");
1022    let out = git::git_raw(
1023        repo,
1024        &[
1025            "merge-tree",
1026            "--write-tree",
1027            &format!("--merge-base={base}"),
1028            head,
1029            sha,
1030        ],
1031    )
1032    .await?;
1033    anyhow::ensure!(
1034        out.code == Some(0) || out.code == Some(1),
1035        "merge-tree failed: {}",
1036        out.stderr
1037    );
1038    if !out.ok() {
1039        return Ok(false);
1040    }
1041    let merged = out
1042        .stdout
1043        .lines()
1044        .next()
1045        .context("merge-tree returned no tree")?
1046        .trim();
1047    Ok(merged == git::tree_of(repo, head).await?)
1048}
1049
1050/// An absorbed change may be superseded by later branch commits, so replaying
1051/// it alone onto the result can conflict. Starting at `sha`, merge only those
1052/// later changes with the base and require every path `sha` touched to match
1053/// the result (including modes). Project all four trees onto just those paths
1054/// first: conflicts elsewhere cannot invalidate this commit's surviving effect.
1055/// A conflict on a candidate path or a failed lookup is not evidence of survival.
1056async fn absorbed_in_result(
1057    repo: &Path,
1058    sha: &str,
1059    onto: &str,
1060    orig: &str,
1061    head: &str,
1062) -> Result<bool> {
1063    if !replay_is_noop(repo, sha, onto).await? {
1064        return Ok(false);
1065    }
1066    let paths = commit_paths(repo, sha).await?;
1067    if paths.is_empty() {
1068        return Ok(false);
1069    }
1070    let base = path_tree(repo, sha, &paths).await?;
1071    let upstream = path_tree(repo, onto, &paths).await?;
1072    let branch = path_tree(repo, orig, &paths).await?;
1073    let result = path_tree(repo, head, &paths).await?;
1074    let out = git::git_raw(
1075        repo,
1076        &[
1077            "merge-tree",
1078            "--write-tree",
1079            &format!("--merge-base={base}"),
1080            &upstream,
1081            &branch,
1082        ],
1083    )
1084    .await?;
1085    anyhow::ensure!(
1086        out.code == Some(0) || out.code == Some(1),
1087        "merge-tree failed: {}",
1088        out.stderr
1089    );
1090    if !out.ok() {
1091        return Ok(false);
1092    }
1093    Ok(out
1094        .stdout
1095        .lines()
1096        .next()
1097        .context("merge-tree returned no tree")?
1098        == result)
1099}
1100
1101/// A tree containing only the candidate paths, under stable synthetic names.
1102/// Preserve each entry's object and mode, and leave missing entries absent.
1103/// Flattening the names lets mktree handle nested and unusual paths without
1104/// an index or a working tree, and keeps unrelated paths out of the merge.
1105async fn path_tree(repo: &Path, rev: &str, paths: &[String]) -> Result<String> {
1106    use crate::proc::Quiet as _;
1107    use std::fmt::Write as _;
1108    use std::process::Stdio;
1109    use tokio::io::AsyncWriteExt as _;
1110
1111    let mut entries = String::new();
1112    for (i, path) in paths.iter().enumerate() {
1113        let out = git::git(
1114            repo,
1115            &["--literal-pathspecs", "ls-tree", "-z", rev, "--", path],
1116        )
1117        .await?;
1118        if !out.is_empty() {
1119            let (entry, _) = out
1120                .split_once('\t')
1121                .context("ls-tree returned an invalid entry")?;
1122            writeln!(entries, "{entry}\t{i:016x}")?;
1123        }
1124    }
1125    #[cfg(test)]
1126    crate::web::request_cost::git();
1127    let mut child = tokio::process::Command::new("git")
1128        .args(["mktree"])
1129        .current_dir(repo)
1130        .quiet()
1131        .stdin(Stdio::piped())
1132        .stdout(Stdio::piped())
1133        .stderr(Stdio::piped())
1134        .kill_on_drop(true)
1135        .spawn()?;
1136    let mut stdin = child.stdin.take().context("mktree stdin missing")?;
1137    stdin.write_all(entries.as_bytes()).await?;
1138    drop(stdin);
1139    let out = child.wait_with_output().await?;
1140    anyhow::ensure!(
1141        out.status.success(),
1142        "mktree failed: {}",
1143        String::from_utf8_lossy(&out.stderr)
1144    );
1145    Ok(String::from_utf8(out.stdout)?.trim().to_owned())
1146}
1147
1148/// Give up: abort whatever is standing, drop the worktree and put the branch
1149/// ref back where it was, whatever a fixer did to it.
1150async fn abandon(repo: &Path, scratch: &Path, branch: &str, orig: &str) {
1151    git::rebase_abort(repo, scratch).await;
1152    let full = format!("refs/heads/{branch}");
1153    if git::rev_parse(repo, &full).await.ok().as_deref() != Some(orig) {
1154        git::git_raw(repo, &["update-ref", &full, orig]).await.ok();
1155    }
1156}
1157
1158/// The reason a caller records and the conductor quotes: it says what was
1159/// tried, so nobody has to open the run directory to find out.
1160fn reason(spent: usize, cap: usize, paths: &[String], said: &str, what: &str) -> String {
1161    let shown: Vec<&str> = paths
1162        .iter()
1163        .take(PATHS_IN_REASON)
1164        .map(String::as_str)
1165        .collect();
1166    let mut list = shown.join(", ");
1167    if paths.len() > shown.len() {
1168        list.push_str(&format!(" and {} more", paths.len() - shown.len()));
1169    }
1170    if list.is_empty() {
1171        list.push_str("none recorded");
1172    }
1173    let mut s = format!(
1174        "conflict not resolved after {spent} of {cap} fixer round(s) ({what}); remaining \
1175         conflicted path(s): {list}"
1176    );
1177    let said = git::rebase_said(said);
1178    let said = said.trim();
1179    if !said.is_empty() {
1180        s.push_str("; git said: ");
1181        s.extend(said.chars().take(250));
1182    }
1183    s
1184}
1185
1186/// Commit subjects in `range`, newest first, capped.
1187async fn subjects(worktree: &Path, range: &str) -> Vec<String> {
1188    let n = format!("-n{SUBJECTS}");
1189    git::git(worktree, &["log", "--format=%s", &n, range])
1190        .await
1191        .map(|o| o.lines().map(str::to_owned).collect())
1192        .unwrap_or_default()
1193}
1194
1195/// A separator alone is ambiguous (for example a Setext heading). Allow
1196/// separators already present under the same heading on either side, while
1197/// rejecting new ones left by a resolution. Opening, closing and diff3 markers stand on their own.
1198async fn has_markers(worktree: &Path, path: &str, sides: &[&str]) -> Result<bool> {
1199    let Ok(text) = std::fs::read_to_string(worktree.join(path)) else {
1200        return Ok(false);
1201    };
1202    if text
1203        .lines()
1204        .any(|l| l.starts_with("<<<<<<<") || l.starts_with(">>>>>>>") || l.starts_with("|||||||"))
1205    {
1206        return Ok(true);
1207    }
1208    fn separators(text: &str) -> std::collections::BTreeMap<String, usize> {
1209        let mut counts = std::collections::BTreeMap::new();
1210        let mut previous = "";
1211        for line in text.lines() {
1212            if line == "=======" {
1213                *counts.entry(previous.to_owned()).or_default() += 1;
1214            }
1215            previous = line;
1216        }
1217        counts
1218    }
1219    let current = separators(&text);
1220    if current.is_empty() {
1221        return Ok(false);
1222    }
1223    let mut existing = std::collections::BTreeMap::<String, usize>::new();
1224    for side in sides {
1225        // A missing file is expected for add/add conflicts. Other failures
1226        // must not be interpreted as an empty baseline.
1227        let spec = format!("{side}:{path}");
1228        let exists = git::git_raw(worktree, &["cat-file", "-e", &spec]).await?;
1229        if exists.ok() {
1230            for (heading, count) in separators(&git::git(worktree, &["show", &spec]).await?) {
1231                let before = existing.entry(heading).or_default();
1232                *before = (*before).max(count);
1233            }
1234        } else {
1235            // Verify the tree itself remains readable before treating a
1236            // missing entry as an empty side.
1237            git::rev_parse(worktree, &format!("{side}^{{tree}}")).await?;
1238        }
1239    }
1240    Ok(current
1241        .into_iter()
1242        .any(|(heading, count)| count > existing.get(&heading).copied().unwrap_or(0)))
1243}
1244
1245/// The conflicted regions of `paths`, marker lines included, truncated. This
1246/// only *shows* the conflict; nothing here decides how to resolve it.
1247fn hunks(worktree: &Path, paths: &[String]) -> String {
1248    let mut out = String::new();
1249    for p in paths {
1250        if out.len() >= HUNK_TOTAL {
1251            out.push_str("\n(more conflicted files omitted)\n");
1252            break;
1253        }
1254        out.push_str(&format!("=== {p} ===\n"));
1255        let Ok(text) = std::fs::read_to_string(worktree.join(p)) else {
1256            out.push_str("(not readable as text; use git to inspect it)\n");
1257            continue;
1258        };
1259        let mut file = String::new();
1260        let mut inside = false;
1261        for line in text.lines() {
1262            if line.starts_with("<<<<<<< ") {
1263                inside = true;
1264            }
1265            if inside {
1266                file.push_str(line);
1267                file.push('\n');
1268            }
1269            if line.starts_with(">>>>>>> ") {
1270                inside = false;
1271            }
1272        }
1273        if file.len() > HUNK_PER_FILE {
1274            file = file.chars().take(HUNK_PER_FILE).collect();
1275            file.push_str("\n(truncated)\n");
1276        }
1277        out.push_str(&file);
1278    }
1279    out
1280}
1281
1282#[cfg(test)]
1283mod tests {
1284    use super::*;
1285    use crate::proc::Quiet as _;
1286    use std::path::PathBuf;
1287
1288    fn sh(dir: &Path, args: &[&str]) {
1289        let o = std::process::Command::new("git")
1290            .quiet()
1291            .args(args)
1292            .current_dir(dir)
1293            .output()
1294            .unwrap();
1295        assert!(
1296            o.status.success(),
1297            "{args:?}: {}",
1298            String::from_utf8_lossy(&o.stderr)
1299        );
1300    }
1301
1302    async fn record(d: &Path, picks: &[String], heads: &[String]) {
1303        for (name, values) in [("magi-rebase-picks", picks), ("magi-rebase-heads", heads)] {
1304            let path = git::git(d, &["rev-parse", "--git-path", name])
1305                .await
1306                .unwrap();
1307            std::fs::write(d.join(path), values.join("\n") + "\n").unwrap();
1308        }
1309    }
1310
1311    #[tokio::test]
1312    async fn a_lost_mode_change_is_not_already_in_the_result() {
1313        let t = tempfile::tempdir().unwrap();
1314        let d = t.path();
1315        sh(d, &["init", "-q", "-b", "main"]);
1316        sh(d, &["config", "user.name", "t"]);
1317        sh(d, &["config", "user.email", "t@example.com"]);
1318        sh(d, &["config", "core.fileMode", "true"]);
1319        std::fs::write(d.join("script.sh"), "echo\n").unwrap();
1320        sh(d, &["add", "-A"]);
1321        sh(d, &["commit", "-q", "-m", "base"]);
1322        sh(d, &["update-index", "--chmod=+x", "script.sh"]);
1323        sh(d, &["commit", "-q", "-m", "chmod"]);
1324        let sha = git::git(d, &["rev-parse", "HEAD"]).await.unwrap();
1325        let base = git::git(d, &["rev-parse", "HEAD~1"]).await.unwrap();
1326        // The result is the base: same blob, lost mode.
1327        assert!(!already_in_result(d, &sha, &sha, &base).await.unwrap());
1328        assert!(already_in_result(d, &sha, &sha, &sha).await.unwrap());
1329    }
1330
1331    async fn commit(d: &Path, msg: &str) -> String {
1332        sh(d, &["add", "-A"]);
1333        sh(d, &["commit", "-q", "-m", msg]);
1334        git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
1335    }
1336
1337    #[tokio::test]
1338    async fn candidate_path_trees_preserve_modes_deletions_and_unusual_names() {
1339        let t = tempfile::tempdir().unwrap();
1340        let d = t.path();
1341        sh(d, &["init", "-q", "-b", "main"]);
1342        sh(d, &["config", "user.name", "t"]);
1343        sh(d, &["config", "user.email", "t@example.com"]);
1344        sh(d, &["config", "core.fileMode", "true"]);
1345        let path = if cfg!(windows) {
1346            "nested/日本語 [literal].txt"
1347        } else {
1348            "nested/日本語\tline\n.txt"
1349        };
1350        std::fs::create_dir(d.join("nested")).unwrap();
1351        std::fs::write(d.join(path), "contents\n").unwrap();
1352        std::fs::write(d.join("g.txt"), "old\n").unwrap();
1353        let base = commit(d, "base").await;
1354        let paths = vec![path.to_owned()];
1355        let tree = path_tree(d, &base, &paths).await.unwrap();
1356        std::fs::write(d.join("g.txt"), "unrelated\n").unwrap();
1357        let other = commit(d, "unrelated path").await;
1358        assert_eq!(path_tree(d, &other, &paths).await.unwrap(), tree);
1359        sh(d, &["update-index", "--chmod=+x", path]);
1360        sh(d, &["commit", "-q", "-m", "chmod"]);
1361        assert_ne!(path_tree(d, "HEAD", &paths).await.unwrap(), tree);
1362        std::fs::remove_file(d.join(path)).unwrap();
1363        let removed = commit(d, "delete").await;
1364        let absent = path_tree(d, &removed, &paths).await.unwrap();
1365        assert_ne!(absent, tree);
1366        assert_eq!(absent, path_tree(d, &removed, &[]).await.unwrap());
1367        assert!(path_tree(d, "not-a-revision", &paths).await.is_err());
1368    }
1369
1370    #[tokio::test]
1371    async fn a_change_inside_a_larger_upstream_edit_is_in_the_result() {
1372        let t = tempfile::tempdir().unwrap();
1373        let d = t.path();
1374        sh(d, &["init", "-q", "-b", "main"]);
1375        sh(d, &["config", "user.name", "t"]);
1376        sh(d, &["config", "user.email", "t@example.com"]);
1377        let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
1378        std::fs::write(d.join("f.txt"), body).unwrap();
1379        commit(d, "base").await;
1380        std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1381        let c = commit(d, "c1").await;
1382        sh(d, &["checkout", "-q", "-b", "up", "HEAD~1"]);
1383        let up = body.replacen("old", "new", 1).replace("end", "end plus");
1384        std::fs::write(d.join("f.txt"), up).unwrap();
1385        let head = commit(d, "upstream").await;
1386        assert!(already_in_result(d, &c, &c, &head).await.unwrap());
1387    }
1388
1389    #[tokio::test]
1390    async fn a_dropped_commit_on_a_non_ascii_path_is_still_noticed() {
1391        let t = tempfile::tempdir().unwrap();
1392        let d = t.path();
1393        sh(d, &["init", "-q", "-b", "main"]);
1394        sh(d, &["config", "user.name", "t"]);
1395        sh(d, &["config", "user.email", "t@example.com"]);
1396        std::fs::write(d.join("a.txt"), "a\n").unwrap();
1397        let base = commit(d, "base").await;
1398        std::fs::write(d.join("日本語.txt"), "x\n").unwrap();
1399        let c = commit(d, "c").await;
1400        assert!(!already_in_result(d, &c, &c, &base).await.unwrap());
1401    }
1402
1403    async fn commit_dated(d: &Path, msg: &str) -> String {
1404        sh(d, &["add", "-A"]);
1405        let st = std::process::Command::new("git")
1406            .quiet()
1407            .current_dir(d)
1408            .env("GIT_AUTHOR_DATE", "2020-01-01T00:00:00+0000")
1409            .env("GIT_COMMITTER_DATE", "2020-01-01T00:00:00+0000")
1410            .args(["commit", "-q", "-m", msg])
1411            .status()
1412            .unwrap();
1413        assert!(st.success());
1414        git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
1415    }
1416
1417    #[tokio::test]
1418    async fn a_recorded_resolved_pick_after_an_absorbed_one_is_not_lost() {
1419        let t = tempfile::tempdir().unwrap();
1420        let d = t.path();
1421        sh(d, &["init", "-q", "-b", "main"]);
1422        sh(d, &["config", "user.name", "t"]);
1423        sh(d, &["config", "user.email", "t@example.com"]);
1424        std::fs::write(d.join("z.txt"), "z\n").unwrap();
1425        let base = commit(d, "base").await;
1426        std::fs::write(d.join("a.txt"), "a\n").unwrap();
1427        commit_dated(d, "same").await;
1428        std::fs::write(d.join("b.txt"), "b\n").unwrap();
1429        let orig = commit_dated(d, "same").await;
1430        // Upstream folds the first change into a differently-shaped commit
1431        // and adds a conflicting b.txt.
1432        sh(d, &["checkout", "-q", "-b", "up", &base]);
1433        std::fs::write(d.join("a.txt"), "a\n").unwrap();
1434        std::fs::write(d.join("y.txt"), "y\n").unwrap();
1435        std::fs::write(d.join("b.txt"), "other\n").unwrap();
1436        let onto = commit(d, "upstream").await;
1437        // The rebase result: first commit omitted as empty, second resolved.
1438        std::fs::write(d.join("b.txt"), "other\nb\n").unwrap();
1439        let head = commit_dated(d, "same").await;
1440        record(
1441            d,
1442            &[
1443                git::rev_parse(d, &format!("{orig}^")).await.unwrap(),
1444                orig.clone(),
1445            ],
1446            &[onto.clone(), head.clone()],
1447        )
1448        .await;
1449        assert!(
1450            dropped_commits(d, d, &onto, &orig, &head)
1451                .await
1452                .unwrap()
1453                .is_empty()
1454        );
1455    }
1456
1457    #[tokio::test]
1458    async fn a_skipped_commit_is_not_masked_by_a_surviving_one_in_the_same_file() {
1459        let t = tempfile::tempdir().unwrap();
1460        let d = t.path();
1461        sh(d, &["init", "-q", "-b", "main"]);
1462        sh(d, &["config", "user.name", "t"]);
1463        sh(d, &["config", "user.email", "t@example.com"]);
1464        let body = "first\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
1465        std::fs::write(d.join("f.txt"), body).unwrap();
1466        let base = commit(d, "base").await;
1467        std::fs::write(d.join("f.txt"), body.replacen("first", "mine", 1)).unwrap();
1468        commit_dated(d, "same").await;
1469        let two = body
1470            .replacen("first", "mine", 1)
1471            .replacen("last", "tail", 1);
1472        std::fs::write(d.join("f.txt"), &two).unwrap();
1473        let orig = commit_dated(d, "same").await;
1474        sh(d, &["checkout", "-q", "-b", "up", &base]);
1475        std::fs::write(d.join("f.txt"), body.replacen("first", "theirs", 1)).unwrap();
1476        let onto = commit(d, "upstream").await;
1477        // First commit skipped, second applied untouched.
1478        std::fs::write(
1479            d.join("f.txt"),
1480            body.replacen("first", "theirs", 1)
1481                .replacen("last", "tail", 1),
1482        )
1483        .unwrap();
1484        let head = commit_dated(d, "same").await;
1485        record(
1486            d,
1487            &[
1488                git::rev_parse(d, &format!("{orig}^")).await.unwrap(),
1489                orig.clone(),
1490            ],
1491            &[onto.clone(), head.clone()],
1492        )
1493        .await;
1494        assert_eq!(
1495            dropped_commits(d, d, &onto, &orig, &head).await.unwrap(),
1496            vec!["same"]
1497        );
1498    }
1499
1500    #[tokio::test]
1501    async fn git_records_a_skipped_middle_pick_after_an_applied_pick() {
1502        let t = tempfile::tempdir().unwrap();
1503        let d = t.path();
1504        sh(d, &["init", "-q", "-b", "main"]);
1505        sh(d, &["config", "user.name", "t"]);
1506        sh(d, &["config", "user.email", "t@example.com"]);
1507        std::fs::write(d.join("f"), "base\n").unwrap();
1508        let base = commit(d, "base").await;
1509        sh(d, &["checkout", "-q", "-b", "topic"]);
1510        std::fs::write(d.join("absorbed"), "upstream too\n").unwrap();
1511        commit(d, "already upstream").await;
1512        std::fs::write(d.join("a"), "first\n").unwrap();
1513        let first = commit(d, "first").await;
1514        std::fs::write(d.join("f"), "middle\n").unwrap();
1515        let middle = commit(d, "lost middle").await;
1516        std::fs::write(d.join("z"), "last\n").unwrap();
1517        let orig = commit(d, "last").await;
1518        sh(d, &["checkout", "-q", "main"]);
1519        std::fs::write(d.join("absorbed"), "upstream too\n").unwrap();
1520        commit(d, "patch twin").await;
1521        std::fs::write(d.join("f"), "upstream\n").unwrap();
1522        let onto = commit(d, "upstream").await;
1523        let scratch = d.join("scratch");
1524        let git::RebaseStart::Conflicted(why) = git::rebase_start(d, &scratch, "topic", &onto)
1525            .await
1526            .unwrap()
1527        else {
1528            panic!("expected a conflict");
1529        };
1530        assert!(!why.contains("Executing:"), "{why}");
1531        let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1532        assert!(skipped.ok(), "{}", skipped.stderr);
1533        let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1534        let record = git::rebase_record(&scratch).await.unwrap();
1535        assert_eq!(record.picks, vec![first, middle, orig.clone()]);
1536        assert_eq!(record.heads[0], record.heads[1]);
1537        assert_ne!(record.heads[1], record.heads[2]);
1538        assert_eq!(
1539            dropped_commits(d, &scratch, &onto, &orig, &head)
1540                .await
1541                .unwrap(),
1542            vec!["lost middle"]
1543        );
1544        git::rebase_abort(d, &scratch).await;
1545        assert!(git::is_ancestor(d, &base, &head).await);
1546    }
1547
1548    #[test]
1549    fn recording_diagnostics_do_not_hide_other_git_output() {
1550        assert_eq!(
1551            git::rebase_said("before\nExecuting: record\nafter\n"),
1552            "before\nafter"
1553        );
1554    }
1555
1556    // Exercise the actual rebase and finish path. C0 conflicts in another
1557    // file; C1 and C2 successively change the first line of f.txt. Upstream
1558    // also changes a distant line, so neither C1 nor the original tip has
1559    // the result's whole-file snapshot.
1560    async fn rebase_successive_edits(
1561        absorbed: bool,
1562        extra_hunk: bool,
1563        unrelated_conflict: bool,
1564    ) -> Rebased {
1565        rebase_successive_edits_record(absorbed, extra_hunk, unrelated_conflict, None).await
1566    }
1567
1568    async fn rebase_successive_edits_record(
1569        absorbed: bool,
1570        extra_hunk: bool,
1571        unrelated_conflict: bool,
1572        damage: Option<&str>,
1573    ) -> Rebased {
1574        crate::run::pin_test_home();
1575        let t = tempfile::tempdir().unwrap();
1576        let d = t.path();
1577        sh(d, &["init", "-q", "-b", "main"]);
1578        sh(d, &["config", "user.name", "t"]);
1579        sh(d, &["config", "user.email", "t@example.com"]);
1580        // Keep Git checkouts consistent with this fixture's LF byte strings,
1581        // even when the host enables automatic CRLF conversion.
1582        sh(d, &["config", "core.autocrlf", "false"]);
1583        let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
1584        std::fs::write(d.join("f.txt"), body).unwrap();
1585        std::fs::write(d.join("conflict.txt"), "old\n").unwrap();
1586        if unrelated_conflict {
1587            std::fs::write(d.join("g.txt"), "old\n").unwrap();
1588        }
1589        let base = commit(d, "base").await;
1590        sh(d, &["checkout", "-q", "-b", "topic"]);
1591        std::fs::write(d.join("conflict.txt"), "branch\n").unwrap();
1592        commit(d, "c0").await;
1593        let mut middle = body.replacen("old", "new", 1);
1594        if extra_hunk {
1595            middle = middle.replace("\n5\n", "\nmissing\n");
1596        }
1597        std::fs::write(d.join("f.txt"), &middle).unwrap();
1598        let c1 = commit(d, "c1").await;
1599        std::fs::write(d.join("f.txt"), middle.replacen("new", "newer", 1)).unwrap();
1600        if unrelated_conflict {
1601            std::fs::write(d.join("g.txt"), "branch\n").unwrap();
1602        }
1603        let orig = commit(d, "c2").await;
1604        sh(d, &["checkout", "-q", "main"]);
1605        std::fs::write(d.join("conflict.txt"), "branch plus upstream\n").unwrap();
1606        let mut upstream = body
1607            .replacen("old", if absorbed { "new" } else { "theirs" }, 1)
1608            .replace("end", "upstream tail");
1609        if extra_hunk {
1610            upstream = upstream.replace("\n5\n", "\nupstream middle\n");
1611        }
1612        std::fs::write(d.join("f.txt"), &upstream).unwrap();
1613        if unrelated_conflict {
1614            std::fs::write(d.join("g.txt"), "upstream\n").unwrap();
1615        }
1616        let onto = commit(d, "upstream composite").await;
1617        // The composite has no patch-id twin for C1.
1618        assert!(git::cherry(d, &onto, &orig).await.unwrap().0.contains(&c1));
1619        let scratch = d.join("scratch");
1620        assert!(matches!(
1621            git::rebase_start(d, &scratch, "topic", &onto)
1622                .await
1623                .unwrap(),
1624            git::RebaseStart::Conflicted(_)
1625        ));
1626        std::fs::write(
1627            scratch.join("conflict.txt"),
1628            "branch plus upstream resolved\n",
1629        )
1630        .unwrap();
1631        sh(&scratch, &["add", "conflict.txt"]);
1632        let continued = git::git_raw(
1633            &scratch,
1634            &["-c", "core.editor=true", "rebase", "--continue"],
1635        )
1636        .await
1637        .unwrap();
1638        if absorbed && !extra_hunk {
1639            // C1 becomes empty automatically. C2 may conflict on a path C1
1640            // never touched, while its f.txt change has applied correctly.
1641            if unrelated_conflict {
1642                assert!(!continued.ok());
1643                assert_eq!(git::unmerged_paths(&scratch).await.unwrap(), vec!["g.txt"]);
1644                assert_eq!(git::rev_parse(&scratch, "REBASE_HEAD").await.unwrap(), orig);
1645                std::fs::write(scratch.join("g.txt"), "branch plus upstream resolved\n").unwrap();
1646                sh(&scratch, &["add", "g.txt"]);
1647                let resolved = git::git_raw(&scratch, &["rebase", "--continue"])
1648                    .await
1649                    .unwrap();
1650                assert!(resolved.ok(), "{}", resolved.stderr);
1651                // A whole-tree merge would reject the correctly rebased C1
1652                // solely because of the unrelated g.txt conflict.
1653                let full = git::git_raw(
1654                    d,
1655                    &[
1656                        "merge-tree",
1657                        "--write-tree",
1658                        &format!("--merge-base={c1}"),
1659                        &onto,
1660                        &orig,
1661                    ],
1662                )
1663                .await
1664                .unwrap();
1665                assert_eq!(full.code, Some(1));
1666            } else {
1667                assert!(continued.ok(), "{}", continued.stderr);
1668            }
1669        } else {
1670            assert!(!continued.ok());
1671            assert_eq!(git::unmerged_paths(&scratch).await.unwrap(), vec!["f.txt"]);
1672            let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1673            if !absorbed {
1674                // C2 now conflicts because its predecessor was really skipped.
1675                assert!(!skipped.ok());
1676                std::fs::write(
1677                    scratch.join("f.txt"),
1678                    upstream.replacen("theirs", "newer", 1),
1679                )
1680                .unwrap();
1681                sh(&scratch, &["add", "f.txt"]);
1682                let resolved = git::git_raw(
1683                    &scratch,
1684                    &["-c", "core.editor=true", "rebase", "--continue"],
1685                )
1686                .await
1687                .unwrap();
1688                assert!(resolved.ok(), "{}", resolved.stderr);
1689            } else {
1690                assert!(skipped.ok(), "{}", skipped.stderr);
1691            }
1692        }
1693        assert!(!git::rebase_in_progress(&scratch).await);
1694        assert_eq!(
1695            std::fs::read_to_string(scratch.join("f.txt")).unwrap(),
1696            upstream.replacen(if absorbed { "new" } else { "theirs" }, "newer", 1)
1697        );
1698        let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1699        // The old final-tree-only check cannot account for C1 in any case.
1700        assert!(!already_in_result(d, &c1, &orig, &head).await.unwrap());
1701        let mut state = RunState::new(
1702            d.to_path_buf(),
1703            "main".into(),
1704            base,
1705            "rebase".into(),
1706            crate::config::Config::default(),
1707        );
1708        if let Some(damage) = damage {
1709            let path = git::git(&scratch, &["rev-parse", "--git-path", "magi-rebase-heads"])
1710                .await
1711                .unwrap();
1712            match damage {
1713                "missing" => std::fs::remove_file(&path).unwrap(),
1714                "count" => std::fs::write(&path, "").unwrap(),
1715                "final" => {
1716                    let text = std::fs::read_to_string(&path).unwrap();
1717                    let mut heads: Vec<_> = text.lines().map(str::to_owned).collect();
1718                    *heads.last_mut().unwrap() = onto.clone();
1719                    std::fs::write(&path, heads.join("\n") + "\n").unwrap();
1720                }
1721                "invalid" => std::fs::write(&path, "invalid\n").unwrap(),
1722                _ => unreachable!(),
1723            }
1724        }
1725        let outcome = finish(
1726            &mut state,
1727            &scratch,
1728            "topic",
1729            &orig,
1730            &onto,
1731            &["conflict.txt".into()],
1732            "",
1733        )
1734        .await
1735        .unwrap();
1736        std::fs::remove_dir_all(state.dir()).ok();
1737        outcome
1738    }
1739
1740    #[tokio::test]
1741    async fn missing_or_inconsistent_rebase_records_stop_the_rebase() {
1742        for damage in ["missing", "count", "final", "invalid"] {
1743            let outcome = rebase_successive_edits_record(true, false, false, Some(damage)).await;
1744            assert!(
1745                matches!(outcome, Rebased::Stopped(why) if why.contains("record could not be verified")),
1746                "{damage}"
1747            );
1748        }
1749    }
1750
1751    #[tokio::test]
1752    async fn an_absorbed_middle_commit_superseded_by_a_later_edit_is_applied() {
1753        assert_eq!(
1754            rebase_successive_edits(true, false, false).await,
1755            Rebased::Applied
1756        );
1757    }
1758
1759    #[tokio::test]
1760    async fn an_unrelated_later_conflict_does_not_hide_an_absorbed_commit() {
1761        assert_eq!(
1762            rebase_successive_edits(true, false, true).await,
1763            Rebased::Applied
1764        );
1765    }
1766
1767    #[tokio::test]
1768    async fn a_skipped_middle_commit_superseded_by_a_later_edit_is_dropped() {
1769        let Rebased::Stopped(why) = rebase_successive_edits(false, false, false).await else {
1770            panic!("a skipped middle commit must stop the rebase");
1771        };
1772        assert!(why.contains("the rebase dropped some of the branch's commits (skipped?)"));
1773        assert!(why.contains("c1"));
1774    }
1775
1776    #[tokio::test]
1777    async fn a_partially_absorbed_middle_commit_with_a_skipped_hunk_is_dropped() {
1778        let Rebased::Stopped(why) = rebase_successive_edits(true, true, false).await else {
1779            panic!("an unabsorbed hunk must stop the rebase");
1780        };
1781        assert!(why.contains("the rebase dropped some of the branch's commits (skipped?)"));
1782        assert!(why.contains("c1"));
1783    }
1784
1785    #[tokio::test]
1786    async fn an_absorbed_commit_erased_by_the_fixer_is_dropped() {
1787        crate::run::pin_test_home();
1788        let t = tempfile::tempdir().unwrap();
1789        let d = t.path();
1790        sh(d, &["init", "-q", "-b", "main"]);
1791        sh(d, &["config", "user.name", "t"]);
1792        sh(d, &["config", "user.email", "t@example.com"]);
1793        // This fixture controls each resolution itself; inherited rerere can
1794        // interfere with that sequence (including MERGE_RR lock contention).
1795        sh(d, &["config", "rerere.enabled", "false"]);
1796        let body =
1797            "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlater\n10\n11\n12\n13\n14\n15\n16\n17\n18\nend\n";
1798        std::fs::write(d.join("f.txt"), body).unwrap();
1799        std::fs::write(d.join("conflict.txt"), "old\n").unwrap();
1800        let base = commit(d, "base").await;
1801        sh(d, &["checkout", "-q", "-b", "topic"]);
1802        std::fs::write(d.join("conflict.txt"), "branch\n").unwrap();
1803        commit(d, "c0").await;
1804        std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1805        let c1 = commit(d, "c1").await;
1806        std::fs::write(
1807            d.join("f.txt"),
1808            body.replacen("old", "new", 1)
1809                .replace("later", "branch tail"),
1810        )
1811        .unwrap();
1812        let orig = commit(d, "c2").await;
1813        sh(d, &["checkout", "-q", "main"]);
1814        std::fs::write(d.join("conflict.txt"), "upstream\n").unwrap();
1815        let upstream = body
1816            .replacen("old", "new", 1)
1817            .replace("end", "upstream tail");
1818        std::fs::write(d.join("f.txt"), &upstream).unwrap();
1819        let onto = commit(d, "upstream composite").await;
1820        let scratch = d.join("scratch");
1821        assert!(matches!(
1822            git::rebase_start(d, &scratch, "topic", &onto)
1823                .await
1824                .unwrap(),
1825            git::RebaseStart::Conflicted(_)
1826        ));
1827        // While resolving C0, the fixer erases C1's absorbed first-line edit.
1828        std::fs::write(scratch.join("conflict.txt"), "resolved\n").unwrap();
1829        std::fs::write(scratch.join("f.txt"), upstream.replacen("new", "theirs", 1)).unwrap();
1830        sh(&scratch, &["add", "conflict.txt", "f.txt"]);
1831        let continued = git::git_raw(&scratch, &["rebase", "--continue"])
1832            .await
1833            .unwrap();
1834        assert_eq!(
1835            continued.code,
1836            Some(1),
1837            "{}\n{}",
1838            continued.stdout,
1839            continued.stderr
1840        );
1841        assert_eq!(git::rev_parse(&scratch, "REBASE_HEAD").await.unwrap(), c1);
1842        let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1843        assert!(skipped.ok(), "{}", skipped.stderr);
1844        let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1845        assert!(replay_is_noop(d, &c1, &onto).await.unwrap());
1846        // The later branch commit edits a different hunk, so the clean merge
1847        // still requires "new" on line one. The final result has "theirs".
1848        let expected = git::git_raw(
1849            d,
1850            &[
1851                "merge-tree",
1852                "--write-tree",
1853                &format!("--merge-base={c1}"),
1854                &onto,
1855                &orig,
1856            ],
1857        )
1858        .await
1859        .unwrap();
1860        assert!(expected.ok(), "{}", expected.stderr);
1861        assert!(
1862            !absorbed_in_result(d, &c1, &onto, &orig, &head)
1863                .await
1864                .unwrap()
1865        );
1866        assert_eq!(
1867            dropped_commits(d, &scratch, &onto, &orig, &head)
1868                .await
1869                .unwrap(),
1870            vec!["c1"]
1871        );
1872        let mut state = RunState::new(
1873            d.to_path_buf(),
1874            "main".into(),
1875            base,
1876            "rebase".into(),
1877            crate::config::Config::default(),
1878        );
1879        let outcome = finish(
1880            &mut state,
1881            &scratch,
1882            "topic",
1883            &orig,
1884            &onto,
1885            &["conflict.txt".into()],
1886            "",
1887        )
1888        .await
1889        .unwrap();
1890        assert!(
1891            matches!(outcome, Rebased::Stopped(why) if why.contains("the rebase dropped some of the branch's commits (skipped?)") && why.contains("c1"))
1892        );
1893        std::fs::remove_dir_all(state.dir()).ok();
1894    }
1895
1896    #[tokio::test]
1897    async fn a_partially_absorbed_commit_missing_an_unrelated_hunk_is_dropped() {
1898        let t = tempfile::tempdir().unwrap();
1899        let d = t.path();
1900        sh(d, &["init", "-q", "-b", "main"]);
1901        sh(d, &["config", "user.name", "t"]);
1902        sh(d, &["config", "user.email", "t@example.com"]);
1903        std::fs::write(d.join("c0.txt"), "base c0\n").unwrap();
1904        let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
1905        std::fs::write(d.join("f.txt"), body).unwrap();
1906        std::fs::write(d.join("extra.txt"), "initial\n").unwrap();
1907        let base = commit(d, "base").await;
1908
1909        // Branch commits: C0, C1 (touches f.txt AND extra.txt), C2
1910        std::fs::write(d.join("c0.txt"), "branch c0\n").unwrap();
1911        commit_dated(d, "c0").await;
1912        std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1913        std::fs::write(d.join("extra.txt"), "c1 edit\n").unwrap();
1914        commit_dated(d, "c1").await;
1915        std::fs::write(d.join("f.txt"), body.replacen("old", "newer", 1)).unwrap();
1916        let orig = commit_dated(d, "c2").await;
1917
1918        // Upstream absorbed C1's f.txt edit, but NOT extra.txt
1919        sh(d, &["checkout", "-q", "-b", "up", &base]);
1920        std::fs::write(d.join("c0.txt"), "upstream c0\n").unwrap();
1921        let up = body.replacen("old", "new", 1).replace("last", "tail");
1922        std::fs::write(d.join("f.txt"), &up).unwrap();
1923        let onto = commit(d, "upstream").await;
1924
1925        // Result: C0 resolved, C1 was skipped or dropped despite extra.txt missing, C2 applied
1926        std::fs::write(d.join("c0.txt"), "resolved c0\n").unwrap();
1927        commit_dated(d, "c0").await;
1928        let res = up.replacen("new", "newer", 1);
1929        std::fs::write(d.join("f.txt"), &res).unwrap();
1930        // extra.txt remains "initial\n"
1931        let head = commit_dated(d, "c2").await;
1932
1933        let c0 = git::rev_parse(d, &format!("{orig}~2")).await.unwrap();
1934        let c1 = git::rev_parse(d, &format!("{orig}^")).await.unwrap();
1935        let resolved = git::rev_parse(d, &format!("{head}^")).await.unwrap();
1936        record(
1937            d,
1938            &[c0, c1, orig.clone()],
1939            &[resolved.clone(), resolved, head.clone()],
1940        )
1941        .await;
1942        assert_eq!(
1943            dropped_commits(d, d, &onto, &orig, &head).await.unwrap(),
1944            vec!["c1"]
1945        );
1946    }
1947
1948    // ---- launcher-owned staging -------------------------------------------
1949
1950    const UPSTREAM: &str = "a\nupstream test\nz\n";
1951    const TOPIC: &str = "a\ntopic test\nz\n";
1952    const BOTH: &str = "a\nupstream test\ntopic test\nz\n";
1953
1954    struct Fixture {
1955        _dir: tempfile::TempDir,
1956        scratch: PathBuf,
1957        state: RunState,
1958    }
1959
1960    impl Fixture {
1961        fn read(&self, name: &str) -> String {
1962            std::fs::read_to_string(self.scratch.join(name)).unwrap()
1963        }
1964    }
1965
1966    /// `topic` has two commits on `main`'s fork point: the first conflicts on
1967    /// `f.txt`, the second adds `g.txt`. The rebase stands on the first and
1968    /// one fixer round is recorded, as `rebase_with_fixer` would have.
1969    async fn fixture(stop_recorded: bool) -> Fixture {
1970        fixture_with(stop_recorded, "f.txt", 2).await
1971    }
1972
1973    async fn fixture_with(stop_recorded: bool, conflict: &str, commits: usize) -> Fixture {
1974        fixture_stops(stop_recorded, conflict, commits, false).await
1975    }
1976
1977    async fn fixture_stops(
1978        stop_recorded: bool,
1979        conflict: &str,
1980        commits: usize,
1981        second: bool,
1982    ) -> Fixture {
1983        crate::run::pin_test_home();
1984        let t = tempfile::tempdir().unwrap();
1985        let d = t.path().join("repo");
1986        std::fs::create_dir(&d).unwrap();
1987        sh(&d, &["init", "-q", "-b", "main"]);
1988        sh(&d, &["config", "user.name", "t"]);
1989        sh(&d, &["config", "user.email", "t@example.com"]);
1990        sh(&d, &["config", "core.autocrlf", "false"]);
1991        std::fs::write(d.join(conflict), "a\nbase\nz\n").unwrap();
1992        std::fs::write(d.join("automatic"), "base\n").unwrap();
1993        sh(&d, &["add", "."]);
1994        sh(&d, &["commit", "-qm", "base"]);
1995        let base = git::rev_parse(&d, "HEAD").await.unwrap();
1996        sh(&d, &["checkout", "-qb", "topic"]);
1997        std::fs::write(d.join(conflict), TOPIC).unwrap();
1998        std::fs::write(d.join("README.md"), "Heading\n=======\n").unwrap();
1999        sh(&d, &["add", "README.md"]);
2000        std::fs::write(d.join("automatic"), "topic applied cleanly\n").unwrap();
2001        sh(&d, &["commit", "-qam", "topic test"]);
2002        std::fs::write(d.join("g.txt"), "g\n").unwrap();
2003        sh(&d, &["add", "g.txt"]);
2004        sh(&d, &["commit", "-qm", "topic g"]);
2005        for n in 2..commits {
2006            std::fs::write(d.join(format!("commit-{n}")), format!("{n}\n")).unwrap();
2007            sh(&d, &["add", "."]);
2008            sh(&d, &["commit", "-qm", &format!("topic {n}")]);
2009        }
2010        sh(&d, &["checkout", "-q", "main"]);
2011        std::fs::write(d.join(conflict), UPSTREAM).unwrap();
2012        if second {
2013            std::fs::write(d.join("g.txt"), "upstream g\n").unwrap();
2014            sh(&d, &["add", "g.txt"]);
2015        }
2016        sh(&d, &["commit", "-qam", "upstream test"]);
2017        let scratch = t.path().join("scratch");
2018        let started = git::rebase_start(&d, &scratch, "topic", "main")
2019            .await
2020            .unwrap();
2021        assert!(matches!(started, git::RebaseStart::Conflicted(_)));
2022        let mut state = RunState::new(
2023            d.clone(),
2024            "main".into(),
2025            base,
2026            "rebase".into(),
2027            crate::config::Config::default(),
2028        );
2029        let paths = git::unmerged_paths(&scratch).await.unwrap();
2030        assert_eq!(paths, vec![conflict]);
2031        state.rebase_fixes.push(RebaseFixRecord {
2032            outage: None,
2033            agent: "mock".into(),
2034            index_outside: index_outside(&scratch, &paths).await.ok(),
2035            stop: if stop_recorded {
2036                rebase_head(&scratch).await
2037            } else {
2038                None
2039            },
2040            paths,
2041            from: None,
2042            finished: false,
2043            error: None,
2044            launcher: None,
2045            infra: None,
2046        });
2047        Fixture {
2048            _dir: t,
2049            scratch,
2050            state,
2051        }
2052    }
2053
2054    async fn stage(f: &mut Fixture) -> Result<Launch> {
2055        let original = git::rev_parse(&f.state.repo, "topic").await?;
2056        let onto = git::rev_parse(&f.state.repo, "main").await?;
2057        launcher_stage(&mut f.state, &f.scratch, &original, &onto).await
2058    }
2059
2060    async fn topic_subjects(f: &Fixture) -> Vec<String> {
2061        subjects(&f.scratch, "main..HEAD").await
2062    }
2063
2064    #[tokio::test]
2065    async fn resolved_contents_with_an_unmerged_index_are_staged_and_continued() {
2066        let mut f = fixture(true).await;
2067        std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2068        let out = stage(&mut f).await.unwrap();
2069        assert!(matches!(out, Launch::Advanced));
2070        assert!(!git::rebase_in_progress(&f.scratch).await);
2071        // Both sides' tests survive and the later commit was replayed too.
2072        assert_eq!(f.read("f.txt"), BOTH);
2073        assert_eq!(f.read("g.txt"), "g\n");
2074        assert_eq!(topic_subjects(&f).await, vec!["topic g", "topic test"]);
2075        let rec = git::rebase_record(&f.scratch).await.unwrap();
2076        assert_eq!(rec.picks.len(), 2);
2077        assert_eq!(f.state.rebase_fixes.len(), 1, "no further round was spent");
2078        std::fs::remove_dir_all(f.state.dir()).ok();
2079    }
2080
2081    #[tokio::test]
2082    async fn a_pre_staging_record_is_matched_by_its_repeated_conflict_set() {
2083        let mut f = fixture(false).await;
2084        std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2085        let again = f.state.rebase_fixes[0].clone();
2086        f.state.rebase_fixes.push(again);
2087        let out = stage(&mut f).await.unwrap();
2088        assert!(matches!(out, Launch::Advanced));
2089        assert!(!git::rebase_in_progress(&f.scratch).await);
2090
2091        // Records that disagree about the conflict identify nothing.
2092        let mut f = fixture(false).await;
2093        std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2094        let mut other = f.state.rebase_fixes[0].clone();
2095        other.paths = vec!["elsewhere.txt".into()];
2096        f.state.rebase_fixes.insert(0, other);
2097        let out = stage(&mut f).await.unwrap();
2098        assert!(matches!(out, Launch::Nothing(_)));
2099        assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2100        std::fs::remove_dir_all(f.state.dir()).ok();
2101    }
2102
2103    #[tokio::test]
2104    async fn remaining_markers_stage_nothing() {
2105        let mut f = fixture(true).await;
2106        let out = stage(&mut f).await.unwrap();
2107        assert!(matches!(&out, Launch::Nothing(why) if why.contains("markers")));
2108        assert!(git::rebase_in_progress(&f.scratch).await);
2109        assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2110        assert!(f.state.resource_block.is_none());
2111        std::fs::remove_dir_all(f.state.dir()).ok();
2112    }
2113
2114    #[tokio::test]
2115    async fn partial_markers_are_rejected_even_after_staging() {
2116        for marker in ["<<<<<<< HEAD", "=======", ">>>>>>> topic", "||||||| base"] {
2117            for staged in [false, true] {
2118                let mut f = fixture(true).await;
2119                std::fs::write(f.scratch.join("f.txt"), format!("{BOTH}{marker}\n")).unwrap();
2120                if staged {
2121                    sh(&f.scratch, &["add", "f.txt"]);
2122                }
2123                assert!(
2124                    has_markers(&f.scratch, "f.txt", &["HEAD", "REBASE_HEAD"])
2125                        .await
2126                        .unwrap()
2127                );
2128                let out = stage(&mut f).await.unwrap();
2129                assert!(matches!(&out, Launch::Nothing(why) if why.contains("markers")));
2130                assert!(git::rebase_in_progress(&f.scratch).await);
2131                std::fs::remove_dir_all(f.state.dir()).ok();
2132            }
2133        }
2134    }
2135
2136    #[tokio::test]
2137    async fn existing_markdown_headings_are_not_conflict_markers() {
2138        let f = fixture(true).await;
2139        assert!(
2140            !has_markers(&f.scratch, "README.md", &["HEAD", "REBASE_HEAD"])
2141                .await
2142                .unwrap()
2143        );
2144        std::fs::write(
2145            f.scratch.join("README.md"),
2146            "Heading\n=======\nextra separator\n=======\n",
2147        )
2148        .unwrap();
2149        assert!(
2150            has_markers(&f.scratch, "README.md", &["HEAD", "REBASE_HEAD"])
2151                .await
2152                .unwrap()
2153        );
2154        std::fs::write(f.scratch.join("README.md"), "resolved code\n=======\n").unwrap();
2155        assert!(
2156            has_markers(&f.scratch, "README.md", &["HEAD", "REBASE_HEAD"])
2157                .await
2158                .unwrap()
2159        );
2160        std::fs::remove_dir_all(f.state.dir()).ok();
2161    }
2162
2163    #[tokio::test]
2164    async fn retries_keep_the_first_index_baseline() {
2165        let mut f = fixture(true).await;
2166        std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2167        std::fs::write(f.scratch.join("automatic"), "unrelated staged edit\n").unwrap();
2168        sh(&f.scratch, &["add", "automatic"]);
2169        let out = stage(&mut f).await.unwrap();
2170        assert!(matches!(&out, Launch::Nothing(why) if why.contains("staged changes")));
2171        let paths = f.state.rebase_fixes[0].paths.clone();
2172        let baseline = round_baseline(&mut f.state, &f.scratch, &paths)
2173            .await
2174            .unwrap();
2175        assert_eq!(
2176            Some(&baseline),
2177            f.state.rebase_fixes[0].index_outside.as_ref()
2178        );
2179        assert_ne!(baseline, index_outside(&f.scratch, &paths).await.unwrap());
2180        let mut retry = f.state.rebase_fixes[0].clone();
2181        // Even a polluted later record cannot replace the first baseline.
2182        retry.index_outside = index_outside(&f.scratch, &paths).await.ok();
2183        f.state.rebase_fixes.push(retry);
2184        let out = stage(&mut f).await.unwrap();
2185        assert!(matches!(&out, Launch::Nothing(why) if why.contains("staged changes")));
2186        assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2187        std::fs::remove_dir_all(f.state.dir()).ok();
2188    }
2189
2190    #[tokio::test]
2191    async fn legacy_records_without_a_baseline_block_without_adopting_the_index() {
2192        let mut f = fixture(false).await;
2193        f.state.rebase_fixes[0].index_outside = None;
2194        std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2195        std::fs::write(f.scratch.join("automatic"), "unrelated staged edit\n").unwrap();
2196        sh(&f.scratch, &["add", "automatic"]);
2197        let error = stage(&mut f).await.unwrap_err();
2198        assert!(error.is::<crate::repo_lock::ResourceBlocked>());
2199        assert!(error.to_string().contains("baseline"));
2200        assert_eq!(f.state.rebase_fixes.len(), 1);
2201        assert!(f.state.rebase_fixes[0].index_outside.is_none());
2202        assert_eq!(f.read("f.txt"), BOTH);
2203        assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2204        std::fs::remove_dir_all(f.state.dir()).ok();
2205    }
2206
2207    #[tokio::test]
2208    async fn legacy_baseline_is_reconstructed_and_saved_without_another_fixer() {
2209        for staged in [false, true] {
2210            let mut f = fixture_with(false, "f.txt", 16).await;
2211            f.state.rebase_fixes[0].index_outside = None;
2212            let again = f.state.rebase_fixes[0].clone();
2213            f.state.rebase_fixes.push(again);
2214            f.state.config.graph.review_rounds = 1;
2215            std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2216            if staged {
2217                sh(&f.scratch, &["add", "f.txt"]);
2218            }
2219            let original = git::rev_parse(&f.state.repo, "topic").await.unwrap();
2220            assert!(matches!(stage(&mut f).await.unwrap(), Launch::Advanced));
2221            assert_eq!(f.state.rebase_fixes.len(), 2);
2222            assert!(f.state.rebase_fixes[0].index_outside.is_some());
2223            assert!(f.state.rebase_fixes.iter().all(|r| r.stop.is_some()));
2224            let saved = RunState::load(&f.state.id).unwrap();
2225            assert_eq!(
2226                saved.rebase_fixes[0].index_outside,
2227                f.state.rebase_fixes[0].index_outside
2228            );
2229            assert_eq!(f.read("f.txt"), BOTH);
2230            assert_eq!(topic_subjects(&f).await.len(), 16);
2231            assert_eq!(f.read("automatic"), "topic applied cleanly\n");
2232            let onto = git::rev_parse(&f.state.repo, "main").await.unwrap();
2233            assert_eq!(
2234                finish(
2235                    &mut f.state,
2236                    &f.scratch,
2237                    "topic",
2238                    &original,
2239                    &onto,
2240                    &["f.txt".into()],
2241                    ""
2242                )
2243                .await
2244                .unwrap(),
2245                Rebased::Applied
2246            );
2247            std::fs::remove_dir_all(f.state.dir()).ok();
2248        }
2249    }
2250
2251    #[tokio::test]
2252    async fn successful_progression_gets_a_budget_for_the_next_conflicting_commit() {
2253        let mut f = fixture_stops(true, "f.txt", 2, true).await;
2254        f.state.config.graph.review_rounds = 1;
2255        assert_eq!(stop_rounds(&f.state, &f.scratch).await, 1);
2256        std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2257        assert!(matches!(stage(&mut f).await.unwrap(), Launch::Advanced));
2258        assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["g.txt"]);
2259        assert_eq!(stop_rounds(&f.state, &f.scratch).await, 0);
2260        let paths = git::unmerged_paths(&f.scratch).await.unwrap();
2261        let mut round = f.state.rebase_fixes[0].clone();
2262        round.index_outside = Some(
2263            round_baseline(&mut f.state, &f.scratch, &paths)
2264                .await
2265                .unwrap(),
2266        );
2267        round.paths = paths;
2268        round.stop = rebase_head(&f.scratch).await;
2269        f.state.rebase_fixes.push(round);
2270        assert_eq!(stop_rounds(&f.state, &f.scratch).await, 1);
2271        std::fs::write(f.scratch.join("g.txt"), "upstream g\ng\n").unwrap();
2272        assert_eq!(
2273            rebase_with_fixer(&mut f.state, &f.scratch, "topic", "main")
2274                .await
2275                .unwrap(),
2276            Rebased::Applied
2277        );
2278        assert_eq!(
2279            git::git(&f.state.repo, &["show", "topic:f.txt"])
2280                .await
2281                .unwrap(),
2282            BOTH.trim_end()
2283        );
2284        assert_eq!(
2285            git::git(&f.state.repo, &["show", "topic:g.txt"])
2286                .await
2287                .unwrap(),
2288            "upstream g\ng"
2289        );
2290        std::fs::remove_dir_all(f.state.dir()).ok();
2291    }
2292
2293    #[tokio::test]
2294    async fn resume_after_staging_denial_uses_the_saved_target_when_upstream_moves() {
2295        let mut f = fixture(true).await;
2296        std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2297        let saved = rebase_target(&f.scratch).await.unwrap();
2298        let lock = git::git(&f.scratch, &["rev-parse", "--git-path", "index.lock"])
2299            .await
2300            .unwrap();
2301        let lock = f.scratch.join(lock);
2302        std::fs::write(&lock, "").unwrap();
2303        assert!(
2304            stage(&mut f)
2305                .await
2306                .unwrap_err()
2307                .is::<crate::repo_lock::ResourceBlocked>()
2308        );
2309        std::fs::remove_file(lock).unwrap();
2310        std::fs::write(f.state.repo.join("new-upstream"), "new base\n").unwrap();
2311        sh(&f.state.repo, &["add", "new-upstream"]);
2312        sh(&f.state.repo, &["commit", "-qm", "new upstream"]);
2313        assert_ne!(saved, git::rev_parse(&f.state.repo, "main").await.unwrap());
2314        assert_eq!(
2315            rebase_with_fixer(&mut f.state, &f.scratch, "topic", "main")
2316                .await
2317                .unwrap(),
2318            Rebased::Applied
2319        );
2320        assert!(git::is_ancestor(&f.state.repo, &saved, "topic").await);
2321        assert!(git::is_ancestor(&f.state.repo, "main", "topic").await);
2322        assert_eq!(
2323            git::git(&f.state.repo, &["show", "topic:f.txt"])
2324                .await
2325                .unwrap(),
2326            BOTH.trim_end()
2327        );
2328        assert_eq!(f.state.rebase_fixes.len(), 1);
2329        std::fs::remove_dir_all(f.state.dir()).ok();
2330    }
2331
2332    #[tokio::test]
2333    async fn unrelated_edits_and_unexpected_unmerged_paths_stage_nothing() {
2334        let mut f = fixture(true).await;
2335        std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2336        std::fs::write(f.scratch.join("stray.txt"), "x\n").unwrap();
2337        let out = stage(&mut f).await.unwrap();
2338        assert!(matches!(&out, Launch::Nothing(why) if why.contains("stray.txt")));
2339        assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2340        std::fs::remove_file(f.scratch.join("stray.txt")).unwrap();
2341
2342        // The round only ever covered some other path.
2343        f.state.rebase_fixes[0].paths = vec!["other.txt".into()];
2344        let out = stage(&mut f).await.unwrap();
2345        assert!(matches!(&out, Launch::Nothing(why) if why.contains("not part of")));
2346        assert!(git::rebase_in_progress(&f.scratch).await);
2347        std::fs::remove_dir_all(f.state.dir()).ok();
2348    }
2349
2350    #[tokio::test]
2351    async fn a_denied_index_write_is_an_infrastructure_stop_that_keeps_everything() {
2352        let mut f = fixture(true).await;
2353        std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2354        let lock = git::git(&f.scratch, &["rev-parse", "--git-path", "index.lock"])
2355            .await
2356            .unwrap();
2357        let lock = f.scratch.join(lock);
2358        std::fs::write(&lock, "").unwrap();
2359
2360        let err = stage(&mut f).await.unwrap_err();
2361        assert!(err.is::<crate::repo_lock::ResourceBlocked>(), "{err:#}");
2362        let block = f.state.resource_block.clone().expect("a resource block");
2363        assert_eq!(block.kind, "git metadata write");
2364        assert!(f.state.rebase_fixes[0].infra.is_some());
2365        assert_eq!(f.state.rebase_fixes.len(), 1, "no round was spent");
2366        assert!(git::rebase_in_progress(&f.scratch).await);
2367        assert_eq!(f.read("f.txt"), BOTH);
2368
2369        // A later pass (the lock gone) finishes the same stop point.
2370        std::fs::remove_file(&lock).unwrap();
2371        f.state.resource_block = None;
2372        let out = stage(&mut f).await.unwrap();
2373        assert!(matches!(out, Launch::Advanced));
2374        assert!(!git::rebase_in_progress(&f.scratch).await);
2375        assert_eq!(f.read("f.txt"), BOTH);
2376        std::fs::remove_dir_all(f.state.dir()).ok();
2377    }
2378
2379    #[tokio::test]
2380    async fn a_pass_interrupted_after_staging_is_continued_not_repeated() {
2381        let mut f = fixture(true).await;
2382        std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2383        sh(&f.scratch, &["add", "f.txt"]);
2384        f.state.rebase_fixes[0].launcher = Some(crate::run::LauncherStep::Staged);
2385        assert!(git::unmerged_paths(&f.scratch).await.unwrap().is_empty());
2386        let out = stage(&mut f).await.unwrap();
2387        assert!(matches!(out, Launch::Advanced));
2388        assert!(!git::rebase_in_progress(&f.scratch).await);
2389        assert_eq!(topic_subjects(&f).await, vec!["topic g", "topic test"]);
2390        // Nothing is left to do on a second pass.
2391        let out = stage(&mut f).await.unwrap();
2392        assert!(matches!(out, Launch::Nothing(_)));
2393        std::fs::remove_dir_all(f.state.dir()).ok();
2394    }
2395
2396    #[tokio::test]
2397    async fn recorded_conflict_edits_after_interrupted_staging_are_restaged() {
2398        for deleted in [false, true] {
2399            let mut f = fixture(true).await;
2400            std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2401            sh(&f.scratch, &["add", "f.txt"]);
2402            f.state.rebase_fixes[0].launcher = Some(crate::run::LauncherStep::Staged);
2403            assert!(git::unmerged_paths(&f.scratch).await.unwrap().is_empty());
2404
2405            let revised = format!("{BOTH}additional test\n");
2406            if deleted {
2407                std::fs::remove_file(f.scratch.join("f.txt")).unwrap();
2408            } else {
2409                std::fs::write(f.scratch.join("f.txt"), &revised).unwrap();
2410            }
2411            let out = stage(&mut f).await.unwrap();
2412            assert!(matches!(out, Launch::Advanced));
2413            assert!(!git::rebase_in_progress(&f.scratch).await);
2414            assert_eq!(topic_subjects(&f).await, vec!["topic g", "topic test"]);
2415            if deleted {
2416                assert!(
2417                    git::git(&f.scratch, &["ls-tree", "HEAD", "--", "f.txt"])
2418                        .await
2419                        .unwrap()
2420                        .is_empty()
2421                );
2422            } else {
2423                assert_eq!(f.read("f.txt"), revised);
2424                assert_eq!(
2425                    git::git(&f.scratch, &["show", "HEAD:f.txt"]).await.unwrap(),
2426                    revised.trim_end()
2427                );
2428            }
2429            assert!(status_entries(&f.scratch).await.unwrap().is_empty());
2430            assert_eq!(f.state.rebase_fixes.len(), 1, "no further round was spent");
2431            std::fs::remove_dir_all(f.state.dir()).ok();
2432        }
2433    }
2434
2435    #[tokio::test]
2436    async fn a_cancelled_run_stages_nothing_and_keeps_the_resolved_files() {
2437        let mut f = fixture(true).await;
2438        std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2439        let cancelled = crate::repo_lock::WaitPolicy::new(Duration::from_secs(5), || true);
2440        let err = cancelled.scope(stage(&mut f)).await.unwrap_err();
2441        assert!(err.is::<crate::repo_lock::ResourceBlocked>());
2442        assert!(git::rebase_in_progress(&f.scratch).await);
2443        assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2444        assert_eq!(f.read("f.txt"), BOTH);
2445        std::fs::remove_dir_all(f.state.dir()).ok();
2446    }
2447    #[cfg(unix)]
2448    #[tokio::test]
2449    async fn cancelled_launcher_git_terminates_descendants_before_releasing_coordination() {
2450        for abort in [true, false] {
2451            cancelled_git_operation(abort).await;
2452        }
2453    }
2454
2455    #[cfg(unix)]
2456    async fn cancelled_git_operation(abort: bool) {
2457        let mut f = fixture(true).await;
2458        let scratch = f.scratch.clone();
2459        let state = &mut f.state;
2460        let policy = crate::config::test_coordination().unwrap();
2461        let common = crate::repo_lock::common_dir(&scratch)
2462            .await
2463            .unwrap()
2464            .unwrap();
2465        std::fs::write(common.join(&policy.contract_file), &policy.contract_token).unwrap();
2466        state.config.coordination = Some(policy.clone());
2467        let wait = crate::repo_lock::WaitPolicy::default();
2468        let lease = crate::repo_lock::SeatLease::operation_with(&scratch, Some(&policy), &wait)
2469            .await
2470            .unwrap();
2471        sh(
2472            &scratch,
2473            &[
2474                "config",
2475                "alias.block",
2476                "!echo $$ > operation.pid; exec sleep 30",
2477            ],
2478        );
2479        let cwd = scratch.clone();
2480        let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2481        let signal = cancelled.clone();
2482        let cancellation = crate::repo_lock::WaitPolicy::new(Duration::from_secs(5), move || {
2483            signal.load(std::sync::atomic::Ordering::SeqCst)
2484        });
2485        let operation =
2486            tokio::spawn(async move { cancellation.scope(mutate(&cwd, &["block"], lease)).await });
2487        let pid_file = scratch.join("operation.pid");
2488        let pid: i32 = tokio::time::timeout(Duration::from_secs(5), async {
2489            loop {
2490                if let Ok(text) = std::fs::read_to_string(&pid_file)
2491                    && let Ok(pid) = text.trim().parse()
2492                {
2493                    break pid;
2494                }
2495                tokio::time::sleep(Duration::from_millis(10)).await;
2496            }
2497        })
2498        .await
2499        .unwrap();
2500        assert!(crate::retention::OutputLease::take(&scratch, true).is_err());
2501        assert!(
2502            tokio::time::timeout(
2503                Duration::from_millis(100),
2504                crate::repo_lock::SeatLease::operation_with(&scratch, Some(&policy), &wait)
2505            )
2506            .await
2507            .is_err()
2508        );
2509        if abort {
2510            operation.abort();
2511            assert!(operation.await.unwrap_err().is_cancelled());
2512        } else {
2513            cancelled.store(true, std::sync::atomic::Ordering::SeqCst);
2514            let error = tokio::time::timeout(Duration::from_secs(5), operation)
2515                .await
2516                .unwrap()
2517                .unwrap()
2518                .unwrap_err();
2519            assert!(error.is::<crate::repo_lock::ResourceBlocked>());
2520        }
2521        // Acquiring the same lease waits for the independent cleanup owner.
2522        let _next = crate::repo_lock::SeatLease::operation_with(&scratch, Some(&policy), &wait)
2523            .await
2524            .unwrap();
2525        // SAFETY: signal zero only checks whether the known test process lives.
2526        assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
2527        let _output = crate::retention::OutputLease::take(&scratch, true).unwrap();
2528        assert!(git::rebase_in_progress(&scratch).await);
2529        assert_eq!(state.rebase_fixes.len(), 1);
2530        std::fs::remove_dir_all(state.dir()).ok();
2531    }
2532
2533    #[tokio::test]
2534    async fn recovery_keeps_sixteen_commits_and_automatic_changes() {
2535        let mut f = fixture_with(true, "f.txt", 16).await;
2536        f.state.config.graph.review_rounds = 1;
2537        std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2538        let original = git::rev_parse(&f.state.repo, "topic").await.unwrap();
2539        let subjects = git::git(&f.scratch, &["log", "--format=%s", "main..topic"])
2540            .await
2541            .unwrap();
2542        assert_eq!(
2543            rebase_with_fixer(&mut f.state, &f.scratch, "topic", "main")
2544                .await
2545                .unwrap(),
2546            Rebased::Applied
2547        );
2548        let heads = git::git(&f.state.repo, &["rev-list", "--count", "main..topic"])
2549            .await
2550            .unwrap();
2551        assert_eq!(heads, "16");
2552        assert_eq!(
2553            git::git(&f.state.repo, &["show", "topic:f.txt"])
2554                .await
2555                .unwrap(),
2556            BOTH.trim_end()
2557        );
2558        assert_eq!(
2559            git::git(&f.state.repo, &["show", "topic:automatic"])
2560                .await
2561                .unwrap(),
2562            "topic applied cleanly"
2563        );
2564        assert_eq!(
2565            git::git(&f.state.repo, &["log", "--format=%s", "main..topic"])
2566                .await
2567                .unwrap(),
2568            subjects
2569        );
2570        for n in 2..16 {
2571            assert_eq!(
2572                git::git(&f.state.repo, &["show", &format!("topic:commit-{n}")])
2573                    .await
2574                    .unwrap(),
2575                n.to_string()
2576            );
2577        }
2578        assert_eq!(f.state.rebase_fixes.len(), 1);
2579        assert_ne!(
2580            git::rev_parse(&f.state.repo, "topic").await.unwrap(),
2581            original
2582        );
2583        std::fs::remove_dir_all(f.state.dir()).ok();
2584    }
2585
2586    #[tokio::test]
2587    async fn recovery_handles_literal_conflict_paths_with_whitespace() {
2588        // Win32 rejects trailing spaces and control characters in file names.
2589        // Exercise literal pathspecs there with valid whitespace and brackets;
2590        // retain trailing whitespace and control-character coverage on Unix.
2591        let paths = [" spaced file name", "日本語 [literal].txt", "日本語.txt"].into_iter();
2592        #[cfg(not(windows))]
2593        let paths = paths.chain([" spaced file ", "tab\tand\nnewline"]);
2594        for path in paths {
2595            let mut f = fixture_with(true, path, 2).await;
2596            assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), [path]);
2597            std::fs::write(f.scratch.join(path), BOTH).unwrap();
2598            assert!(matches!(stage(&mut f).await.unwrap(), Launch::Advanced));
2599            assert_eq!(f.read(path), BOTH);
2600            assert!(git::unmerged_paths(&f.scratch).await.unwrap().is_empty());
2601            std::fs::remove_dir_all(f.state.dir()).ok();
2602        }
2603    }
2604
2605    #[tokio::test]
2606    async fn changed_rebase_identity_blocks_staging_and_keeps_resolution() {
2607        for name in ["orig-head", "onto"] {
2608            let mut f = fixture(true).await;
2609            std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2610            let metadata = git::git(
2611                &f.scratch,
2612                &["rev-parse", "--git-path", &format!("rebase-merge/{name}")],
2613            )
2614            .await
2615            .unwrap();
2616            std::fs::write(
2617                f.scratch.join(metadata),
2618                "0000000000000000000000000000000000000000\n",
2619            )
2620            .unwrap();
2621            let error = stage(&mut f).await.unwrap_err();
2622            assert!(error.is::<crate::repo_lock::ResourceBlocked>());
2623            assert!(error.to_string().contains(name));
2624            assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2625            assert_eq!(f.read("f.txt"), BOTH);
2626            assert_eq!(f.state.rebase_fixes.len(), 1);
2627            std::fs::remove_dir_all(f.state.dir()).ok();
2628        }
2629    }
2630}