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