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`** and saved before the fixer is called, shared
22//!   by both callers, so a park or a 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    let onto_sha = git::rev_parse(&repo, onto).await?;
84    // Every path that was ever unmerged: the marker check on the finished
85    // tree looks at exactly these.
86    let mut touched: Vec<String> = Vec::new();
87
88    loop {
89        if !git::rebase_in_progress(scratch).await {
90            return finish(state, scratch, branch, &orig, &onto_sha, &touched, &said).await;
91        }
92        let paths = git::unmerged_paths(scratch).await.unwrap_or_default();
93        for p in &paths {
94            if !touched.contains(p) {
95                touched.push(p.clone());
96            }
97        }
98        let spent = state.rebase_fixes.len();
99        if spent >= cap {
100            let why = reason(spent, cap, &paths, &said, "the rounds are spent");
101            abandon(&repo, scratch, branch, &orig).await;
102            return Ok(Rebased::Stopped(why));
103        }
104
105        let winner = state
106            .winner()
107            .cloned()
108            .context("resolving a rebase conflict needs a winning candidate")?;
109        let roles = state
110            .config
111            .resolve_roles()
112            .context("resolve the roster for the rebase fix")?;
113        // The chain (`crate::fixer`): one round however many agents it takes.
114        let attempts = crate::fixer::attempts(state, &roles, &winner);
115        let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
116
117        let round = spent + 1;
118        let branch_subjects = subjects(scratch, &format!("{onto}..{branch}")).await;
119        let onto_subjects = subjects(scratch, &format!("{branch}..{onto}")).await;
120        let hunks = hunks(scratch, &paths);
121        let prompt_text = prompt::rebase_conflict(&prompt::RebaseConflict {
122            instruction: &state.instruction,
123            worktree: scratch,
124            branch,
125            onto,
126            paths: &paths,
127            branch_subjects: &branch_subjects,
128            onto_subjects: &onto_subjects,
129            hunks: &hunks,
130            round,
131            cap,
132            language: &state.config.graph.language,
133            github_language: &state.config.graph.github_language,
134        });
135        let prompt_text = if state.config.cache_dir().is_some() {
136            format!("{prompt_text}\n\n{}", prompt::build_cache_note("fix", true))
137        } else {
138            prompt_text
139        };
140
141        // Spent before the call, on disk: a run killed mid-round must not
142        // get the round back.
143        state.rebase_fixes.push(RebaseFixRecord {
144            agent: ids[0].clone(),
145            paths: paths.clone(),
146            from: Some(orig.clone()),
147            finished: false,
148            error: None,
149        });
150        state.event(
151            "rebase",
152            format!(
153                "{branch} conflicts with {onto} ({} path(s)); fixer round {round} of {cap}",
154                paths.len()
155            ),
156        );
157        state.save()?;
158
159        // Each id at most once, forward only. The round was spent and saved
160        // above, so a chain advance inside it spends nothing further and a
161        // crash cannot hand it back; only the record's `agent` follows the
162        // asked agent (saved before its call, like the round itself).
163        let artifacts = agent::artifacts_dir(&state.dir());
164        let mut last = None;
165        for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
166            if i > 0 {
167                if let Some(r) = state.rebase_fixes.last_mut() {
168                    r.agent = spec.id.clone();
169                }
170                state.save()?;
171            }
172            let mut seat = seat_of(state, &seat_key, &spec.id);
173            let stem = if i == 0 {
174                format!("rebase-fix-{round}")
175            } else {
176                format!("rebase-fix-{round}-{}", spec.id)
177            };
178            let out = agent::invoke(
179                &spec,
180                &mut seat,
181                &Invocation {
182                    scope: crate::agent::InvocationScope::Repository,
183                    coordination: state.config.coordination.as_ref(),
184                    fetched_base: state.fetched_base.as_ref(),
185                    cwd: scratch,
186                    prompt: &prompt_text,
187                    timeout: Duration::from_secs(state.config.graph.timeout_fix),
188                    allow_write: true,
189                    unsandboxed: false,
190                    sessions: state.config.graph.sessions,
191                    artifacts: &artifacts,
192                    stem: &stem,
193                    run: &state.id,
194                    node: "rebase",
195                    cache_dir: state.config.cache_dir().as_deref(),
196                    attachments: &[],
197                    writable: &[],
198                },
199            )
200            .await;
201            let seat_name = seat.key.clone();
202            state.seats.insert(seat.key.clone(), seat);
203            if let Some(next) = ids.get(i + 1)
204                && agent::chain_advances(&out)
205            {
206                let (class, why) = crate::fixer::failure_of(&out);
207                crate::graph::record_handover(
208                    state, "rebase", &seat_name, &spec.id, next, &class, &why,
209                );
210                continue;
211            }
212            last = Some((out, seat_name));
213            break;
214        }
215        let (out, seat_name) = last.expect("the fixer chain always has an entry");
216
217        let mut error = None;
218        let mut quota = false;
219        match out {
220            Ok(o) if o.quota_exhausted() => {
221                state.quota.push(QuotaLoss {
222                    seat: seat_name,
223                    node: "rebase".to_owned(),
224                    at: Timestamp::now(),
225                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
226                });
227                error = Some("rate limited (quota); the fixer could not run".to_owned());
228                quota = true;
229            }
230            Ok(o) if !o.usable() => {
231                error = Some(format!(
232                    "the fixer produced nothing usable ({})",
233                    crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
234                ));
235            }
236            Ok(_) => {}
237            Err(e) => error = Some(format!("{e:#}")),
238        }
239
240        let finished = !git::rebase_in_progress(scratch).await;
241        if let Some(r) = state.rebase_fixes.last_mut() {
242            r.finished = finished;
243            r.error = error.clone();
244        }
245        state.save()?;
246
247        if quota {
248            // A retry now fails the same way; do not burn the rest.
249            let paths = git::unmerged_paths(scratch).await.unwrap_or_default();
250            let why = reason(
251                state.rebase_fixes.len(),
252                cap,
253                &paths,
254                &said,
255                "the fixer hit its rate limit",
256            );
257            abandon(&repo, scratch, branch, &orig).await;
258            return Ok(Rebased::Stopped(why));
259        }
260    }
261}
262
263/// Judge a rebase that is no longer in progress by what git says.
264async fn finish(
265    state: &mut RunState,
266    scratch: &Path,
267    branch: &str,
268    orig: &str,
269    onto_sha: &str,
270    touched: &[String],
271    said: &str,
272) -> Result<Rebased> {
273    let repo = state.repo.clone();
274    let spent = state.rebase_fixes.len();
275    let cap = state.config.graph.review_rounds;
276    let unmerged = git::unmerged_paths(scratch).await.unwrap_or_default();
277    let head = git::rev_parse(scratch, "HEAD").await.unwrap_or_default();
278    // Not only the paths seen unmerged at a round's start: one round may
279    // continue through several commits, and a later commit's conflict never
280    // shows up at a loop head. Everything the result changes relative to the
281    // base is checked.
282    let mut candidates: Vec<String> = touched.to_vec();
283    if let Ok(changed) = git::git(scratch, &["diff", "--name-only", onto_sha, "HEAD"]).await {
284        for p in changed.lines().map(str::trim).filter(|l| !l.is_empty()) {
285            if !candidates.iter().any(|c| c == p) {
286                candidates.push(p.to_owned());
287            }
288        }
289    }
290    let marked: Vec<String> = candidates
291        .into_iter()
292        .filter(|p| has_markers(scratch, p))
293        .collect();
294
295    // Skipping every conflicting commit leaves the branch equal to the base:
296    // the ancestry test below passes and the branch's work is gone. Empty is
297    // only acceptable when every original commit already has a patch twin on
298    // the base.
299    let emptied = head == onto_sha
300        && git::cherry(&repo, onto_sha, orig)
301            .await
302            .map_or(true, |(unmatched, _)| !unmatched.is_empty());
303
304    // A fixer that ran `git rebase --skip` on one of several commits lets the
305    // rest apply and the rebase finish; `emptied` only sees the whole change
306    // vanishing. Find the commits that are neither in the result nor already
307    // on the base.
308    let dropped = if unmerged.is_empty() && marked.is_empty() && !emptied && !head.is_empty() {
309        dropped_commits(&repo, onto_sha, orig, &head).await
310    } else {
311        Vec::new()
312    };
313
314    let problem = if !unmerged.is_empty() {
315        Some(("paths are still unmerged", unmerged))
316    } else if !marked.is_empty() {
317        Some(("conflict markers were left in the tree", marked))
318    } else if emptied {
319        Some((
320            "the rebase ended with none of the branch's commits applied (all skipped)",
321            touched.to_vec(),
322        ))
323    } else if !dropped.is_empty() {
324        Some((
325            "the rebase dropped some of the branch's commits (skipped?)",
326            dropped,
327        ))
328    } else if head.is_empty() || !git::is_ancestor(&repo, onto_sha, &head).await {
329        Some((
330            "the rebase ended without the base in the result (abandoned or skipped)",
331            touched.to_vec(),
332        ))
333    } else {
334        None
335    };
336    match problem {
337        None => {
338            git::worktree_remove(&repo, scratch).await.ok();
339            state.event(
340                "rebase",
341                format!("{branch} rebased after {spent} fixer round(s)"),
342            );
343            state.save()?;
344            Ok(Rebased::Applied)
345        }
346        Some((what, paths)) => {
347            let why = reason(spent, cap, &paths, said, what);
348            abandon(&repo, scratch, branch, orig).await;
349            Ok(Rebased::Stopped(why))
350        }
351    }
352}
353
354/// Subjects of the commits `orig` had over `onto_sha` that the rebased `head`
355/// no longer represents. Empty when all survive or the check could not run
356/// (the other checks still apply).
357///
358/// Commits are matched by what a rebase preserves (author, author date,
359/// subject), not by patch-id: a commit the fixer resolved has a new patch-id
360/// by design. A commit whose patch already exists on the base is not expected
361/// in the result, and one with a patch twin in the result is verified by content like any other candidate.
362async fn dropped_commits(repo: &Path, onto_sha: &str, orig: &str, head: &str) -> Vec<String> {
363    let Ok((unmatched, _)) = git::cherry(repo, onto_sha, orig).await else {
364        return Vec::new();
365    };
366    if unmatched.is_empty() {
367        return Vec::new();
368    }
369    let (Ok(origin), Ok(result)) = (
370        git::commit_keys(repo, &format!("{onto_sha}..{orig}")).await,
371        git::commit_keys(repo, &format!("{onto_sha}..{head}")).await,
372    ) else {
373        return Vec::new();
374    };
375    let expected: Vec<git::CommitKey> = origin
376        .into_iter()
377        .filter(|c| unmatched.contains(&c.sha))
378        .collect();
379    let have: Vec<String> = result.iter().map(|c| c.key.clone()).collect();
380    let mut taken = vec![false; result.len()];
381    let mut lost = Vec::new();
382    let mut unverified = Vec::new();
383    // Content-verified candidates first: one whose exact change is present as
384    // a result commit under its key has claimed that survivor, so it cannot
385    // also be taken as evidence for a different, unverified commit. (One
386    // absorbed upstream matches nothing and claims nothing.)
387    for c in missing_commits(&expected, &have) {
388        // Git also drops a commit on its own when its change is already on
389        // the base under a different patch (e.g. folded into one upstream
390        // commit). Absorption alone is insufficient: the fixer may remove it
391        // again. Verify its paths in the final result, allowing only changes
392        // explained by the base and the original branch's later commits.
393        if absorbed_in_result(repo, &c.sha, onto_sha, orig, head).await {
394            continue;
395        }
396        if !already_in_result(repo, &c.sha, orig, head).await {
397            unverified.push(c);
398            continue;
399        }
400        let mine = change_lines(repo, &c.sha).await;
401        for (i, r) in result.iter().enumerate() {
402            if !taken[i] && r.key == c.key && change_lines(repo, &r.sha).await == mine {
403                taken[i] = true;
404                break;
405            }
406        }
407    }
408    // The rest may have survived with a conflict-resolved (new) content. A
409    // result commit under the same key that touches one of its paths, and is
410    // not already accounted for, is that survivor.
411    for c in unverified {
412        let mine = commit_paths(repo, &c.sha).await;
413        let mut found = false;
414        for (i, r) in result.iter().enumerate() {
415            if taken[i] || r.key != c.key {
416                continue;
417            }
418            let theirs = commit_paths(repo, &r.sha).await;
419            if theirs.iter().any(|p| mine.contains(p)) {
420                taken[i] = true;
421                found = true;
422                break;
423            }
424        }
425        if !found {
426            lost.push(c.key.rsplit('\u{1f}').next().unwrap_or(&c.key).to_owned());
427        }
428    }
429    lost
430}
431
432/// The added/removed lines of `sha`'s patch, headers and hunk positions left
433/// out, so a commit that was merely re-applied at a different offset compares
434/// equal to its original.
435async fn change_lines(repo: &Path, sha: &str) -> Vec<String> {
436    git::git(
437        repo,
438        &["diff-tree", "-p", "-U0", "--no-commit-id", "--root", sha],
439    )
440    .await
441    .map(|o| {
442        o.lines()
443            .filter(|l| {
444                !(l.starts_with("diff ")
445                    || l.starts_with("index ")
446                    || l.starts_with("@@")
447                    || l.starts_with("--- ")
448                    || l.starts_with("+++ "))
449            })
450            .map(str::to_owned)
451            .collect()
452    })
453    .unwrap_or_default()
454}
455
456/// Paths `sha` changes, NUL-separated from git so a quoted name is never
457/// misread. Empty when git cannot say.
458async fn commit_paths(repo: &Path, sha: &str) -> Vec<String> {
459    git::git(
460        repo,
461        &[
462            "diff-tree",
463            "--no-commit-id",
464            "--name-only",
465            "-r",
466            "--root",
467            "-z",
468            sha,
469        ],
470    )
471    .await
472    .map(|o| {
473        o.split('\0')
474            .filter(|l| !l.is_empty())
475            .map(str::to_owned)
476            .collect()
477    })
478    .unwrap_or_default()
479}
480
481/// `mode type blob` of `path` at `rev`, or `None` when it does not exist
482/// there. Unlike `rev-parse rev:path` this carries the file mode.
483async fn entry(repo: &Path, rev: &str, path: &str) -> Option<String> {
484    let out = git::git(repo, &["ls-tree", rev, "--", path]).await.ok()?;
485    out.split('\t')
486        .next()
487        .filter(|e| !e.is_empty())
488        .map(str::to_owned)
489}
490
491/// Is `sha`'s change represented in `head`?
492///
493/// First by merging: replaying `sha` onto `head` changes nothing when its
494/// change is already there, in whatever company (extra upstream edits in the
495/// same file included). Failing that, per touched path: `head` holds what
496/// `sha` left there, or what the branch's final tip `orig` has there (a later
497/// commit may have changed the path again). Mode is part of that comparison,
498/// and paths come NUL-separated so a quoted name is never misread.
499async fn already_in_result(repo: &Path, sha: &str, orig: &str, head: &str) -> bool {
500    if replay_is_noop(repo, sha, head).await {
501        return true;
502    }
503    for p in &commit_paths(repo, sha).await {
504        let got = entry(repo, head, p).await;
505        if got != entry(repo, sha, p).await && got != entry(repo, orig, p).await {
506            return false;
507        }
508    }
509    true
510}
511
512/// Does merging `sha` (against its parent) into `head` cleanly yield `head`'s
513/// own tree? False when it conflicts, changes anything, or git cannot say.
514async fn replay_is_noop(repo: &Path, sha: &str, head: &str) -> bool {
515    let base = format!("{sha}^");
516    let Ok(out) = git::git_raw(
517        repo,
518        &[
519            "merge-tree",
520            "--write-tree",
521            &format!("--merge-base={base}"),
522            head,
523            sha,
524        ],
525    )
526    .await
527    else {
528        return false;
529    };
530    if !out.ok() {
531        return false;
532    }
533    let merged = out.stdout.lines().next().unwrap_or("").trim();
534    match git::tree_of(repo, head).await {
535        Ok(t) => !merged.is_empty() && merged == t,
536        Err(_) => false,
537    }
538}
539
540/// An absorbed change may be superseded by later branch commits, so replaying
541/// it alone onto the result can conflict. Starting at `sha`, merge only those
542/// later changes with the base and require every path `sha` touched to match
543/// the result (including modes). Project all four trees onto just those paths
544/// first: conflicts elsewhere cannot invalidate this commit's surviving effect.
545/// A conflict on a candidate path or a failed lookup is not evidence of survival.
546async fn absorbed_in_result(repo: &Path, sha: &str, onto: &str, orig: &str, head: &str) -> bool {
547    if !replay_is_noop(repo, sha, onto).await {
548        return false;
549    }
550    let paths = commit_paths(repo, sha).await;
551    if paths.is_empty() {
552        return false;
553    }
554    let (Some(base), Some(upstream), Some(branch), Some(result)) = (
555        path_tree(repo, sha, &paths).await,
556        path_tree(repo, onto, &paths).await,
557        path_tree(repo, orig, &paths).await,
558        path_tree(repo, head, &paths).await,
559    ) else {
560        return false;
561    };
562    let Ok(out) = git::git_raw(
563        repo,
564        &[
565            "merge-tree",
566            "--write-tree",
567            &format!("--merge-base={base}"),
568            &upstream,
569            &branch,
570        ],
571    )
572    .await
573    else {
574        return false;
575    };
576    if !out.ok() {
577        return false;
578    }
579    out.stdout.lines().next().is_some_and(|tree| tree == result)
580}
581
582/// A tree containing only the candidate paths, under stable synthetic names.
583/// Preserve each entry's object and mode, and leave missing entries absent.
584/// Flattening the names lets mktree handle nested and unusual paths without
585/// an index or a working tree, and keeps unrelated paths out of the merge.
586async fn path_tree(repo: &Path, rev: &str, paths: &[String]) -> Option<String> {
587    use crate::proc::Quiet as _;
588    use std::fmt::Write as _;
589    use std::process::Stdio;
590    use tokio::io::AsyncWriteExt as _;
591
592    let mut entries = String::new();
593    for (i, path) in paths.iter().enumerate() {
594        let out = git::git(
595            repo,
596            &["--literal-pathspecs", "ls-tree", "-z", rev, "--", path],
597        )
598        .await
599        .ok()?;
600        if !out.is_empty() {
601            let (entry, _) = out.split_once('\t')?;
602            writeln!(entries, "{entry}\t{i:016x}").ok()?;
603        }
604    }
605    #[cfg(test)]
606    crate::web::request_cost::git();
607    let mut child = tokio::process::Command::new("git")
608        .args(["mktree"])
609        .current_dir(repo)
610        .quiet()
611        .stdin(Stdio::piped())
612        .stdout(Stdio::piped())
613        .stderr(Stdio::piped())
614        .kill_on_drop(true)
615        .spawn()
616        .ok()?;
617    let mut stdin = child.stdin.take()?;
618    stdin.write_all(entries.as_bytes()).await.ok()?;
619    drop(stdin);
620    let out = child.wait_with_output().await.ok()?;
621    if !out.status.success() {
622        return None;
623    }
624    Some(String::from_utf8(out.stdout).ok()?.trim().to_owned())
625}
626
627/// The pure half of [`dropped_commits`]: the `expected` commits that may be
628/// missing from the result, judged by key. Keys are counted (a multiset); when
629/// the result holds fewer commits under a key than were expected, *every*
630/// expected commit with that key is a candidate, because the key cannot say
631/// which of them survived. The caller verifies each candidate by content
632/// ([`already_in_result`]); a patch twin in the result is not excused here for
633/// the same reason.
634fn missing_commits(expected: &[git::CommitKey], have: &[String]) -> Vec<git::CommitKey> {
635    expected
636        .iter()
637        .filter(|c| {
638            let want = expected.iter().filter(|e| e.key == c.key).count();
639            let got = have.iter().filter(|k| **k == c.key).count();
640            got < want
641        })
642        .cloned()
643        .collect()
644}
645
646/// Give up: abort whatever is standing, drop the worktree and put the branch
647/// ref back where it was, whatever a fixer did to it.
648async fn abandon(repo: &Path, scratch: &Path, branch: &str, orig: &str) {
649    git::rebase_abort(repo, scratch).await;
650    let full = format!("refs/heads/{branch}");
651    if git::rev_parse(repo, &full).await.ok().as_deref() != Some(orig) {
652        git::git_raw(repo, &["update-ref", &full, orig]).await.ok();
653    }
654}
655
656/// The reason a caller records and the conductor quotes: it says what was
657/// tried, so nobody has to open the run directory to find out.
658fn reason(spent: usize, cap: usize, paths: &[String], said: &str, what: &str) -> String {
659    let shown: Vec<&str> = paths
660        .iter()
661        .take(PATHS_IN_REASON)
662        .map(String::as_str)
663        .collect();
664    let mut list = shown.join(", ");
665    if paths.len() > shown.len() {
666        list.push_str(&format!(" and {} more", paths.len() - shown.len()));
667    }
668    if list.is_empty() {
669        list.push_str("none recorded");
670    }
671    let mut s = format!(
672        "conflict not resolved after {spent} of {cap} fixer round(s) ({what}); remaining \
673         conflicted path(s): {list}"
674    );
675    let said = said.trim();
676    if !said.is_empty() {
677        s.push_str("; git said: ");
678        s.extend(said.chars().take(250));
679    }
680    s
681}
682
683/// Commit subjects in `range`, newest first, capped.
684async fn subjects(worktree: &Path, range: &str) -> Vec<String> {
685    let n = format!("-n{SUBJECTS}");
686    git::git(worktree, &["log", "--format=%s", &n, range])
687        .await
688        .map(|o| o.lines().map(str::to_owned).collect())
689        .unwrap_or_default()
690}
691
692fn has_markers(worktree: &Path, path: &str) -> bool {
693    std::fs::read_to_string(worktree.join(path)).is_ok_and(|t| {
694        t.lines().any(|l| l.starts_with("<<<<<<< ")) && t.lines().any(|l| l.starts_with(">>>>>>> "))
695    })
696}
697
698/// The conflicted regions of `paths`, marker lines included, truncated. This
699/// only *shows* the conflict; nothing here decides how to resolve it.
700fn hunks(worktree: &Path, paths: &[String]) -> String {
701    let mut out = String::new();
702    for p in paths {
703        if out.len() >= HUNK_TOTAL {
704            out.push_str("\n(more conflicted files omitted)\n");
705            break;
706        }
707        out.push_str(&format!("=== {p} ===\n"));
708        let Ok(text) = std::fs::read_to_string(worktree.join(p)) else {
709            out.push_str("(not readable as text; use git to inspect it)\n");
710            continue;
711        };
712        let mut file = String::new();
713        let mut inside = false;
714        for line in text.lines() {
715            if line.starts_with("<<<<<<< ") {
716                inside = true;
717            }
718            if inside {
719                file.push_str(line);
720                file.push('\n');
721            }
722            if line.starts_with(">>>>>>> ") {
723                inside = false;
724            }
725        }
726        if file.len() > HUNK_PER_FILE {
727            file = file.chars().take(HUNK_PER_FILE).collect();
728            file.push_str("\n(truncated)\n");
729        }
730        out.push_str(&file);
731    }
732    out
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738    use crate::proc::Quiet as _;
739
740    fn ck(sha: &str, subject: &str) -> git::CommitKey {
741        git::CommitKey {
742            sha: sha.to_owned(),
743            key: format!("n\u{1f}e\u{1f}1 +0000\u{1f}{subject}"),
744        }
745    }
746
747    #[test]
748    fn nothing_is_missing_when_every_key_is_present() {
749        let exp = [ck("a", "one"), ck("b", "two")];
750        let have = vec![exp[1].key.clone(), exp[0].key.clone()];
751        assert!(missing_commits(&exp, &have).is_empty());
752    }
753
754    #[test]
755    fn a_dropped_commit_is_named_by_subject() {
756        let exp = [ck("a", "one"), ck("b", "two")];
757        let have = vec![exp[1].key.clone()];
758        assert_eq!(missing_commits(&exp, &have), vec![exp[0].clone()]);
759    }
760
761    #[test]
762    fn duplicate_keys_are_counted_not_collapsed() {
763        let exp = [ck("a", "same"), ck("b", "same")];
764        let have = vec![exp[0].key.clone()];
765        assert_eq!(missing_commits(&exp, &have), exp.to_vec());
766    }
767
768    #[test]
769    fn a_commit_without_a_key_match_is_a_candidate_even_if_a_twin_exists() {
770        let exp = [ck("a", "same"), ck("b", "same")];
771        let have = vec![exp[1].key.clone()];
772        assert_eq!(missing_commits(&exp, &have).len(), 2);
773    }
774
775    fn sh(dir: &Path, args: &[&str]) {
776        let o = std::process::Command::new("git")
777            .quiet()
778            .args(args)
779            .current_dir(dir)
780            .output()
781            .unwrap();
782        assert!(
783            o.status.success(),
784            "{args:?}: {}",
785            String::from_utf8_lossy(&o.stderr)
786        );
787    }
788
789    #[tokio::test]
790    async fn a_lost_mode_change_is_not_already_in_the_result() {
791        let t = tempfile::tempdir().unwrap();
792        let d = t.path();
793        sh(d, &["init", "-q", "-b", "main"]);
794        sh(d, &["config", "user.name", "t"]);
795        sh(d, &["config", "user.email", "t@example.com"]);
796        sh(d, &["config", "core.fileMode", "true"]);
797        std::fs::write(d.join("script.sh"), "echo\n").unwrap();
798        sh(d, &["add", "-A"]);
799        sh(d, &["commit", "-q", "-m", "base"]);
800        sh(d, &["update-index", "--chmod=+x", "script.sh"]);
801        sh(d, &["commit", "-q", "-m", "chmod"]);
802        let sha = git::git(d, &["rev-parse", "HEAD"]).await.unwrap();
803        let base = git::git(d, &["rev-parse", "HEAD~1"]).await.unwrap();
804        // The result is the base: same blob, lost mode.
805        assert!(!already_in_result(d, &sha, &sha, &base).await);
806        assert!(already_in_result(d, &sha, &sha, &sha).await);
807    }
808
809    async fn commit(d: &Path, msg: &str) -> String {
810        sh(d, &["add", "-A"]);
811        sh(d, &["commit", "-q", "-m", msg]);
812        git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
813    }
814
815    #[tokio::test]
816    async fn candidate_path_trees_preserve_modes_deletions_and_unusual_names() {
817        let t = tempfile::tempdir().unwrap();
818        let d = t.path();
819        sh(d, &["init", "-q", "-b", "main"]);
820        sh(d, &["config", "user.name", "t"]);
821        sh(d, &["config", "user.email", "t@example.com"]);
822        sh(d, &["config", "core.fileMode", "true"]);
823        let path = if cfg!(windows) {
824            "nested/日本語 [literal].txt"
825        } else {
826            "nested/日本語\tline\n.txt"
827        };
828        std::fs::create_dir(d.join("nested")).unwrap();
829        std::fs::write(d.join(path), "contents\n").unwrap();
830        std::fs::write(d.join("g.txt"), "old\n").unwrap();
831        let base = commit(d, "base").await;
832        let paths = vec![path.to_owned()];
833        let tree = path_tree(d, &base, &paths).await.unwrap();
834        std::fs::write(d.join("g.txt"), "unrelated\n").unwrap();
835        let other = commit(d, "unrelated path").await;
836        assert_eq!(path_tree(d, &other, &paths).await.unwrap(), tree);
837        sh(d, &["update-index", "--chmod=+x", path]);
838        sh(d, &["commit", "-q", "-m", "chmod"]);
839        assert_ne!(path_tree(d, "HEAD", &paths).await.unwrap(), tree);
840        std::fs::remove_file(d.join(path)).unwrap();
841        let removed = commit(d, "delete").await;
842        let absent = path_tree(d, &removed, &paths).await.unwrap();
843        assert_ne!(absent, tree);
844        assert_eq!(absent, path_tree(d, &removed, &[]).await.unwrap());
845        assert!(path_tree(d, "not-a-revision", &paths).await.is_none());
846    }
847
848    #[tokio::test]
849    async fn a_change_inside_a_larger_upstream_edit_is_in_the_result() {
850        let t = tempfile::tempdir().unwrap();
851        let d = t.path();
852        sh(d, &["init", "-q", "-b", "main"]);
853        sh(d, &["config", "user.name", "t"]);
854        sh(d, &["config", "user.email", "t@example.com"]);
855        let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
856        std::fs::write(d.join("f.txt"), body).unwrap();
857        commit(d, "base").await;
858        std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
859        let c = commit(d, "c1").await;
860        sh(d, &["checkout", "-q", "-b", "up", "HEAD~1"]);
861        let up = body.replacen("old", "new", 1).replace("end", "end plus");
862        std::fs::write(d.join("f.txt"), up).unwrap();
863        let head = commit(d, "upstream").await;
864        assert!(already_in_result(d, &c, &c, &head).await);
865    }
866
867    #[tokio::test]
868    async fn a_dropped_commit_on_a_non_ascii_path_is_still_noticed() {
869        let t = tempfile::tempdir().unwrap();
870        let d = t.path();
871        sh(d, &["init", "-q", "-b", "main"]);
872        sh(d, &["config", "user.name", "t"]);
873        sh(d, &["config", "user.email", "t@example.com"]);
874        std::fs::write(d.join("a.txt"), "a\n").unwrap();
875        let base = commit(d, "base").await;
876        std::fs::write(d.join("日本語.txt"), "x\n").unwrap();
877        let c = commit(d, "c").await;
878        assert!(!already_in_result(d, &c, &c, &base).await);
879    }
880
881    async fn commit_dated(d: &Path, msg: &str) -> String {
882        sh(d, &["add", "-A"]);
883        let st = std::process::Command::new("git")
884            .quiet()
885            .current_dir(d)
886            .env("GIT_AUTHOR_DATE", "2020-01-01T00:00:00+0000")
887            .env("GIT_COMMITTER_DATE", "2020-01-01T00:00:00+0000")
888            .args(["commit", "-q", "-m", msg])
889            .status()
890            .unwrap();
891        assert!(st.success());
892        git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
893    }
894
895    #[tokio::test]
896    async fn a_surviving_resolved_commit_sharing_a_key_with_an_absorbed_one_is_not_lost() {
897        let t = tempfile::tempdir().unwrap();
898        let d = t.path();
899        sh(d, &["init", "-q", "-b", "main"]);
900        sh(d, &["config", "user.name", "t"]);
901        sh(d, &["config", "user.email", "t@example.com"]);
902        std::fs::write(d.join("z.txt"), "z\n").unwrap();
903        let base = commit(d, "base").await;
904        std::fs::write(d.join("a.txt"), "a\n").unwrap();
905        commit_dated(d, "same").await;
906        std::fs::write(d.join("b.txt"), "b\n").unwrap();
907        let orig = commit_dated(d, "same").await;
908        // Upstream folds the first change into a differently-shaped commit
909        // and adds a conflicting b.txt.
910        sh(d, &["checkout", "-q", "-b", "up", &base]);
911        std::fs::write(d.join("a.txt"), "a\n").unwrap();
912        std::fs::write(d.join("y.txt"), "y\n").unwrap();
913        std::fs::write(d.join("b.txt"), "other\n").unwrap();
914        let onto = commit(d, "upstream").await;
915        // The rebase result: first commit omitted as empty, second resolved.
916        std::fs::write(d.join("b.txt"), "other\nb\n").unwrap();
917        let head = commit_dated(d, "same").await;
918        assert!(dropped_commits(d, &onto, &orig, &head).await.is_empty());
919    }
920
921    #[tokio::test]
922    async fn a_skipped_commit_is_not_masked_by_a_surviving_one_in_the_same_file() {
923        let t = tempfile::tempdir().unwrap();
924        let d = t.path();
925        sh(d, &["init", "-q", "-b", "main"]);
926        sh(d, &["config", "user.name", "t"]);
927        sh(d, &["config", "user.email", "t@example.com"]);
928        let body = "first\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
929        std::fs::write(d.join("f.txt"), body).unwrap();
930        let base = commit(d, "base").await;
931        std::fs::write(d.join("f.txt"), body.replacen("first", "mine", 1)).unwrap();
932        commit_dated(d, "same").await;
933        let two = body
934            .replacen("first", "mine", 1)
935            .replacen("last", "tail", 1);
936        std::fs::write(d.join("f.txt"), &two).unwrap();
937        let orig = commit_dated(d, "same").await;
938        sh(d, &["checkout", "-q", "-b", "up", &base]);
939        std::fs::write(d.join("f.txt"), body.replacen("first", "theirs", 1)).unwrap();
940        let onto = commit(d, "upstream").await;
941        // First commit skipped, second applied untouched.
942        std::fs::write(
943            d.join("f.txt"),
944            body.replacen("first", "theirs", 1)
945                .replacen("last", "tail", 1),
946        )
947        .unwrap();
948        let head = commit_dated(d, "same").await;
949        assert_eq!(dropped_commits(d, &onto, &orig, &head).await, vec!["same"]);
950    }
951
952    // Exercise the actual rebase and finish path. C0 conflicts in another
953    // file; C1 and C2 successively change the first line of f.txt. Upstream
954    // also changes a distant line, so neither C1 nor the original tip has
955    // the result's whole-file snapshot.
956    async fn rebase_successive_edits(
957        absorbed: bool,
958        extra_hunk: bool,
959        unrelated_conflict: bool,
960    ) -> Rebased {
961        crate::run::pin_test_home();
962        let t = tempfile::tempdir().unwrap();
963        let d = t.path();
964        sh(d, &["init", "-q", "-b", "main"]);
965        sh(d, &["config", "user.name", "t"]);
966        sh(d, &["config", "user.email", "t@example.com"]);
967        // Keep Git checkouts consistent with this fixture's LF byte strings,
968        // even when the host enables automatic CRLF conversion.
969        sh(d, &["config", "core.autocrlf", "false"]);
970        let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
971        std::fs::write(d.join("f.txt"), body).unwrap();
972        std::fs::write(d.join("conflict.txt"), "old\n").unwrap();
973        if unrelated_conflict {
974            std::fs::write(d.join("g.txt"), "old\n").unwrap();
975        }
976        let base = commit(d, "base").await;
977        sh(d, &["checkout", "-q", "-b", "topic"]);
978        std::fs::write(d.join("conflict.txt"), "branch\n").unwrap();
979        commit(d, "c0").await;
980        let mut middle = body.replacen("old", "new", 1);
981        if extra_hunk {
982            middle = middle.replace("\n5\n", "\nmissing\n");
983        }
984        std::fs::write(d.join("f.txt"), &middle).unwrap();
985        let c1 = commit(d, "c1").await;
986        std::fs::write(d.join("f.txt"), middle.replacen("new", "newer", 1)).unwrap();
987        if unrelated_conflict {
988            std::fs::write(d.join("g.txt"), "branch\n").unwrap();
989        }
990        let orig = commit(d, "c2").await;
991        sh(d, &["checkout", "-q", "main"]);
992        std::fs::write(d.join("conflict.txt"), "branch plus upstream\n").unwrap();
993        let mut upstream = body
994            .replacen("old", if absorbed { "new" } else { "theirs" }, 1)
995            .replace("end", "upstream tail");
996        if extra_hunk {
997            upstream = upstream.replace("\n5\n", "\nupstream middle\n");
998        }
999        std::fs::write(d.join("f.txt"), &upstream).unwrap();
1000        if unrelated_conflict {
1001            std::fs::write(d.join("g.txt"), "upstream\n").unwrap();
1002        }
1003        let onto = commit(d, "upstream composite").await;
1004        // The composite has no patch-id twin for C1.
1005        assert!(git::cherry(d, &onto, &orig).await.unwrap().0.contains(&c1));
1006        let scratch = d.join("scratch");
1007        assert!(matches!(
1008            git::rebase_start(d, &scratch, "topic", &onto)
1009                .await
1010                .unwrap(),
1011            git::RebaseStart::Conflicted(_)
1012        ));
1013        std::fs::write(
1014            scratch.join("conflict.txt"),
1015            "branch plus upstream resolved\n",
1016        )
1017        .unwrap();
1018        sh(&scratch, &["add", "conflict.txt"]);
1019        let continued = git::git_raw(
1020            &scratch,
1021            &["-c", "core.editor=true", "rebase", "--continue"],
1022        )
1023        .await
1024        .unwrap();
1025        if absorbed && !extra_hunk {
1026            // C1 becomes empty automatically. C2 may conflict on a path C1
1027            // never touched, while its f.txt change has applied correctly.
1028            if unrelated_conflict {
1029                assert!(!continued.ok());
1030                assert_eq!(git::unmerged_paths(&scratch).await.unwrap(), vec!["g.txt"]);
1031                assert_eq!(git::rev_parse(&scratch, "REBASE_HEAD").await.unwrap(), orig);
1032                std::fs::write(scratch.join("g.txt"), "branch plus upstream resolved\n").unwrap();
1033                sh(&scratch, &["add", "g.txt"]);
1034                let resolved = git::git_raw(&scratch, &["rebase", "--continue"])
1035                    .await
1036                    .unwrap();
1037                assert!(resolved.ok(), "{}", resolved.stderr);
1038                // A whole-tree merge would reject the correctly rebased C1
1039                // solely because of the unrelated g.txt conflict.
1040                let full = git::git_raw(
1041                    d,
1042                    &[
1043                        "merge-tree",
1044                        "--write-tree",
1045                        &format!("--merge-base={c1}"),
1046                        &onto,
1047                        &orig,
1048                    ],
1049                )
1050                .await
1051                .unwrap();
1052                assert_eq!(full.code, Some(1));
1053            } else {
1054                assert!(continued.ok(), "{}", continued.stderr);
1055            }
1056        } else {
1057            assert!(!continued.ok());
1058            assert_eq!(git::unmerged_paths(&scratch).await.unwrap(), vec!["f.txt"]);
1059            let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1060            if !absorbed {
1061                // C2 now conflicts because its predecessor was really skipped.
1062                assert!(!skipped.ok());
1063                std::fs::write(
1064                    scratch.join("f.txt"),
1065                    upstream.replacen("theirs", "newer", 1),
1066                )
1067                .unwrap();
1068                sh(&scratch, &["add", "f.txt"]);
1069                let resolved = git::git_raw(
1070                    &scratch,
1071                    &["-c", "core.editor=true", "rebase", "--continue"],
1072                )
1073                .await
1074                .unwrap();
1075                assert!(resolved.ok(), "{}", resolved.stderr);
1076            } else {
1077                assert!(skipped.ok(), "{}", skipped.stderr);
1078            }
1079        }
1080        assert!(!git::rebase_in_progress(&scratch).await);
1081        assert_eq!(
1082            std::fs::read_to_string(scratch.join("f.txt")).unwrap(),
1083            upstream.replacen(if absorbed { "new" } else { "theirs" }, "newer", 1)
1084        );
1085        let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1086        // The old final-tree-only check cannot account for C1 in any case.
1087        assert!(!already_in_result(d, &c1, &orig, &head).await);
1088        let mut state = RunState::new(
1089            d.to_path_buf(),
1090            "main".into(),
1091            base,
1092            "rebase".into(),
1093            crate::config::Config::default(),
1094        );
1095        let outcome = finish(
1096            &mut state,
1097            &scratch,
1098            "topic",
1099            &orig,
1100            &onto,
1101            &["conflict.txt".into()],
1102            "",
1103        )
1104        .await
1105        .unwrap();
1106        std::fs::remove_dir_all(state.dir()).ok();
1107        outcome
1108    }
1109
1110    #[tokio::test]
1111    async fn an_absorbed_middle_commit_superseded_by_a_later_edit_is_applied() {
1112        assert_eq!(
1113            rebase_successive_edits(true, false, false).await,
1114            Rebased::Applied
1115        );
1116    }
1117
1118    #[tokio::test]
1119    async fn an_unrelated_later_conflict_does_not_hide_an_absorbed_commit() {
1120        assert_eq!(
1121            rebase_successive_edits(true, false, true).await,
1122            Rebased::Applied
1123        );
1124    }
1125
1126    #[tokio::test]
1127    async fn a_skipped_middle_commit_superseded_by_a_later_edit_is_dropped() {
1128        let Rebased::Stopped(why) = rebase_successive_edits(false, false, false).await else {
1129            panic!("a skipped middle commit must stop the rebase");
1130        };
1131        assert!(why.contains("the rebase dropped some of the branch's commits (skipped?)"));
1132        assert!(why.contains("c1"));
1133    }
1134
1135    #[tokio::test]
1136    async fn a_partially_absorbed_middle_commit_with_a_skipped_hunk_is_dropped() {
1137        let Rebased::Stopped(why) = rebase_successive_edits(true, true, false).await else {
1138            panic!("an unabsorbed hunk must stop the rebase");
1139        };
1140        assert!(why.contains("the rebase dropped some of the branch's commits (skipped?)"));
1141        assert!(why.contains("c1"));
1142    }
1143
1144    #[tokio::test]
1145    async fn an_absorbed_commit_erased_by_the_fixer_is_dropped() {
1146        crate::run::pin_test_home();
1147        let t = tempfile::tempdir().unwrap();
1148        let d = t.path();
1149        sh(d, &["init", "-q", "-b", "main"]);
1150        sh(d, &["config", "user.name", "t"]);
1151        sh(d, &["config", "user.email", "t@example.com"]);
1152        // This fixture controls each resolution itself; inherited rerere can
1153        // interfere with that sequence (including MERGE_RR lock contention).
1154        sh(d, &["config", "rerere.enabled", "false"]);
1155        let body =
1156            "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlater\n10\n11\n12\n13\n14\n15\n16\n17\n18\nend\n";
1157        std::fs::write(d.join("f.txt"), body).unwrap();
1158        std::fs::write(d.join("conflict.txt"), "old\n").unwrap();
1159        let base = commit(d, "base").await;
1160        sh(d, &["checkout", "-q", "-b", "topic"]);
1161        std::fs::write(d.join("conflict.txt"), "branch\n").unwrap();
1162        commit(d, "c0").await;
1163        std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1164        let c1 = commit(d, "c1").await;
1165        std::fs::write(
1166            d.join("f.txt"),
1167            body.replacen("old", "new", 1)
1168                .replace("later", "branch tail"),
1169        )
1170        .unwrap();
1171        let orig = commit(d, "c2").await;
1172        sh(d, &["checkout", "-q", "main"]);
1173        std::fs::write(d.join("conflict.txt"), "upstream\n").unwrap();
1174        let upstream = body
1175            .replacen("old", "new", 1)
1176            .replace("end", "upstream tail");
1177        std::fs::write(d.join("f.txt"), &upstream).unwrap();
1178        let onto = commit(d, "upstream composite").await;
1179        let scratch = d.join("scratch");
1180        assert!(matches!(
1181            git::rebase_start(d, &scratch, "topic", &onto)
1182                .await
1183                .unwrap(),
1184            git::RebaseStart::Conflicted(_)
1185        ));
1186        // While resolving C0, the fixer erases C1's absorbed first-line edit.
1187        std::fs::write(scratch.join("conflict.txt"), "resolved\n").unwrap();
1188        std::fs::write(scratch.join("f.txt"), upstream.replacen("new", "theirs", 1)).unwrap();
1189        sh(&scratch, &["add", "conflict.txt", "f.txt"]);
1190        let continued = git::git_raw(&scratch, &["rebase", "--continue"])
1191            .await
1192            .unwrap();
1193        assert_eq!(
1194            continued.code,
1195            Some(1),
1196            "{}\n{}",
1197            continued.stdout,
1198            continued.stderr
1199        );
1200        assert_eq!(git::rev_parse(&scratch, "REBASE_HEAD").await.unwrap(), c1);
1201        let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1202        assert!(skipped.ok(), "{}", skipped.stderr);
1203        let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1204        assert!(replay_is_noop(d, &c1, &onto).await);
1205        // The later branch commit edits a different hunk, so the clean merge
1206        // still requires "new" on line one. The final result has "theirs".
1207        let expected = git::git_raw(
1208            d,
1209            &[
1210                "merge-tree",
1211                "--write-tree",
1212                &format!("--merge-base={c1}"),
1213                &onto,
1214                &orig,
1215            ],
1216        )
1217        .await
1218        .unwrap();
1219        assert!(expected.ok(), "{}", expected.stderr);
1220        assert!(!absorbed_in_result(d, &c1, &onto, &orig, &head).await);
1221        assert_eq!(dropped_commits(d, &onto, &orig, &head).await, vec!["c1"]);
1222        let mut state = RunState::new(
1223            d.to_path_buf(),
1224            "main".into(),
1225            base,
1226            "rebase".into(),
1227            crate::config::Config::default(),
1228        );
1229        let outcome = finish(
1230            &mut state,
1231            &scratch,
1232            "topic",
1233            &orig,
1234            &onto,
1235            &["conflict.txt".into()],
1236            "",
1237        )
1238        .await
1239        .unwrap();
1240        assert!(
1241            matches!(outcome, Rebased::Stopped(why) if why.contains("the rebase dropped some of the branch's commits (skipped?)") && why.contains("c1"))
1242        );
1243        std::fs::remove_dir_all(state.dir()).ok();
1244    }
1245
1246    #[tokio::test]
1247    async fn a_partially_absorbed_commit_missing_an_unrelated_hunk_is_dropped() {
1248        let t = tempfile::tempdir().unwrap();
1249        let d = t.path();
1250        sh(d, &["init", "-q", "-b", "main"]);
1251        sh(d, &["config", "user.name", "t"]);
1252        sh(d, &["config", "user.email", "t@example.com"]);
1253        std::fs::write(d.join("c0.txt"), "base c0\n").unwrap();
1254        let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
1255        std::fs::write(d.join("f.txt"), body).unwrap();
1256        std::fs::write(d.join("extra.txt"), "initial\n").unwrap();
1257        let base = commit(d, "base").await;
1258
1259        // Branch commits: C0, C1 (touches f.txt AND extra.txt), C2
1260        std::fs::write(d.join("c0.txt"), "branch c0\n").unwrap();
1261        commit_dated(d, "c0").await;
1262        std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1263        std::fs::write(d.join("extra.txt"), "c1 edit\n").unwrap();
1264        commit_dated(d, "c1").await;
1265        std::fs::write(d.join("f.txt"), body.replacen("old", "newer", 1)).unwrap();
1266        let orig = commit_dated(d, "c2").await;
1267
1268        // Upstream absorbed C1's f.txt edit, but NOT extra.txt
1269        sh(d, &["checkout", "-q", "-b", "up", &base]);
1270        std::fs::write(d.join("c0.txt"), "upstream c0\n").unwrap();
1271        let up = body.replacen("old", "new", 1).replace("last", "tail");
1272        std::fs::write(d.join("f.txt"), &up).unwrap();
1273        let onto = commit(d, "upstream").await;
1274
1275        // Result: C0 resolved, C1 was skipped or dropped despite extra.txt missing, C2 applied
1276        std::fs::write(d.join("c0.txt"), "resolved c0\n").unwrap();
1277        commit_dated(d, "c0").await;
1278        let res = up.replacen("new", "newer", 1);
1279        std::fs::write(d.join("f.txt"), &res).unwrap();
1280        // extra.txt remains "initial\n"
1281        let head = commit_dated(d, "c2").await;
1282
1283        assert_eq!(dropped_commits(d, &onto, &orig, &head).await, vec!["c1"]);
1284    }
1285}