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