Skip to main content

magi/
rebase.rs

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