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, scratch, onto_sha, orig, &head).await
310    } else {
311        Ok(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 head.is_empty() || !git::is_ancestor(&repo, onto_sha, &head).await {
324        Some((
325            "the rebase ended without the base in the result (abandoned or skipped)",
326            touched.to_vec(),
327        ))
328    } else if let Err(error) = &dropped {
329        Some((
330            "the rebase record could not be verified",
331            vec![format!("{error:#}")],
332        ))
333    } else if !dropped.as_ref().unwrap().is_empty() {
334        Some((
335            "the rebase dropped some of the branch's commits (skipped?)",
336            dropped.unwrap(),
337        ))
338    } else {
339        None
340    };
341    match problem {
342        None => {
343            git::worktree_remove(&repo, scratch).await.ok();
344            state.event(
345                "rebase",
346                format!("{branch} rebased after {spent} fixer round(s)"),
347            );
348            state.save()?;
349            Ok(Rebased::Applied)
350        }
351        Some((what, paths)) => {
352            let why = reason(spent, cap, &paths, said, what);
353            abandon(&repo, scratch, branch, orig).await;
354            Ok(Rebased::Stopped(why))
355        }
356    }
357}
358
359/// Only a pick whose exec observed the previous HEAD could have been skipped.
360/// Content checks distinguish a genuine loss from a change already upstream.
361async fn dropped_commits(
362    repo: &Path,
363    scratch: &Path,
364    onto_sha: &str,
365    orig: &str,
366    head: &str,
367) -> Result<Vec<String>> {
368    let record = git::rebase_record(scratch).await?;
369    anyhow::ensure!(
370        record.heads.last().map(String::as_str) == Some(head),
371        "rebase final HEAD disagrees with record"
372    );
373    let mut previous = onto_sha;
374    let mut lost = Vec::new();
375    for (sha, picked_head) in record.picks.iter().zip(&record.heads) {
376        if picked_head == previous
377            && !absorbed_in_result(repo, sha, onto_sha, orig, head).await?
378            && !already_in_result(repo, sha, orig, head).await?
379        {
380            lost.push(git::git(repo, &["show", "-s", "--format=%s", sha]).await?);
381        }
382        previous = picked_head;
383    }
384    Ok(lost)
385}
386
387/// Paths `sha` changes, NUL-separated from git so a quoted name is never
388/// misread. Read failures propagate to the rebase verdict.
389async fn commit_paths(repo: &Path, sha: &str) -> Result<Vec<String>> {
390    git::git(
391        repo,
392        &[
393            "diff-tree",
394            "--no-commit-id",
395            "--name-only",
396            "-r",
397            "--root",
398            "-z",
399            sha,
400        ],
401    )
402    .await
403    .map(|o| {
404        o.split('\0')
405            .filter(|l| !l.is_empty())
406            .map(str::to_owned)
407            .collect()
408    })
409}
410
411/// `mode type blob` of `path` at `rev`, or `None` when it does not exist
412/// there. Unlike `rev-parse rev:path` this carries the file mode.
413async fn entry(repo: &Path, rev: &str, path: &str) -> Result<Option<String>> {
414    let out = git::git(repo, &["--literal-pathspecs", "ls-tree", rev, "--", path]).await?;
415    Ok(out
416        .split('\t')
417        .next()
418        .filter(|e| !e.is_empty())
419        .map(str::to_owned))
420}
421
422/// Is `sha`'s change represented in `head`?
423///
424/// First by merging: replaying `sha` onto `head` changes nothing when its
425/// change is already there, in whatever company (extra upstream edits in the
426/// same file included). Failing that, per touched path: `head` holds what
427/// `sha` left there, or what the branch's final tip `orig` has there (a later
428/// commit may have changed the path again). Mode is part of that comparison,
429/// and paths come NUL-separated so a quoted name is never misread.
430async fn already_in_result(repo: &Path, sha: &str, orig: &str, head: &str) -> Result<bool> {
431    if replay_is_noop(repo, sha, head).await? {
432        return Ok(true);
433    }
434    for p in &commit_paths(repo, sha).await? {
435        let got = entry(repo, head, p).await?;
436        if got != entry(repo, sha, p).await? && got != entry(repo, orig, p).await? {
437            return Ok(false);
438        }
439    }
440    Ok(true)
441}
442
443/// Does merging `sha` (against its parent) into `head` cleanly yield `head`'s
444/// own tree? Conflicts and changes return false; read failures propagate.
445async fn replay_is_noop(repo: &Path, sha: &str, head: &str) -> Result<bool> {
446    let base = format!("{sha}^");
447    let out = git::git_raw(
448        repo,
449        &[
450            "merge-tree",
451            "--write-tree",
452            &format!("--merge-base={base}"),
453            head,
454            sha,
455        ],
456    )
457    .await?;
458    anyhow::ensure!(
459        out.code == Some(0) || out.code == Some(1),
460        "merge-tree failed: {}",
461        out.stderr
462    );
463    if !out.ok() {
464        return Ok(false);
465    }
466    let merged = out
467        .stdout
468        .lines()
469        .next()
470        .context("merge-tree returned no tree")?
471        .trim();
472    Ok(merged == git::tree_of(repo, head).await?)
473}
474
475/// An absorbed change may be superseded by later branch commits, so replaying
476/// it alone onto the result can conflict. Starting at `sha`, merge only those
477/// later changes with the base and require every path `sha` touched to match
478/// the result (including modes). Project all four trees onto just those paths
479/// first: conflicts elsewhere cannot invalidate this commit's surviving effect.
480/// A conflict on a candidate path or a failed lookup is not evidence of survival.
481async fn absorbed_in_result(
482    repo: &Path,
483    sha: &str,
484    onto: &str,
485    orig: &str,
486    head: &str,
487) -> Result<bool> {
488    if !replay_is_noop(repo, sha, onto).await? {
489        return Ok(false);
490    }
491    let paths = commit_paths(repo, sha).await?;
492    if paths.is_empty() {
493        return Ok(false);
494    }
495    let base = path_tree(repo, sha, &paths).await?;
496    let upstream = path_tree(repo, onto, &paths).await?;
497    let branch = path_tree(repo, orig, &paths).await?;
498    let result = path_tree(repo, head, &paths).await?;
499    let out = git::git_raw(
500        repo,
501        &[
502            "merge-tree",
503            "--write-tree",
504            &format!("--merge-base={base}"),
505            &upstream,
506            &branch,
507        ],
508    )
509    .await?;
510    anyhow::ensure!(
511        out.code == Some(0) || out.code == Some(1),
512        "merge-tree failed: {}",
513        out.stderr
514    );
515    if !out.ok() {
516        return Ok(false);
517    }
518    Ok(out
519        .stdout
520        .lines()
521        .next()
522        .context("merge-tree returned no tree")?
523        == result)
524}
525
526/// A tree containing only the candidate paths, under stable synthetic names.
527/// Preserve each entry's object and mode, and leave missing entries absent.
528/// Flattening the names lets mktree handle nested and unusual paths without
529/// an index or a working tree, and keeps unrelated paths out of the merge.
530async fn path_tree(repo: &Path, rev: &str, paths: &[String]) -> Result<String> {
531    use crate::proc::Quiet as _;
532    use std::fmt::Write as _;
533    use std::process::Stdio;
534    use tokio::io::AsyncWriteExt as _;
535
536    let mut entries = String::new();
537    for (i, path) in paths.iter().enumerate() {
538        let out = git::git(
539            repo,
540            &["--literal-pathspecs", "ls-tree", "-z", rev, "--", path],
541        )
542        .await?;
543        if !out.is_empty() {
544            let (entry, _) = out
545                .split_once('\t')
546                .context("ls-tree returned an invalid entry")?;
547            writeln!(entries, "{entry}\t{i:016x}")?;
548        }
549    }
550    #[cfg(test)]
551    crate::web::request_cost::git();
552    let mut child = tokio::process::Command::new("git")
553        .args(["mktree"])
554        .current_dir(repo)
555        .quiet()
556        .stdin(Stdio::piped())
557        .stdout(Stdio::piped())
558        .stderr(Stdio::piped())
559        .kill_on_drop(true)
560        .spawn()?;
561    let mut stdin = child.stdin.take().context("mktree stdin missing")?;
562    stdin.write_all(entries.as_bytes()).await?;
563    drop(stdin);
564    let out = child.wait_with_output().await?;
565    anyhow::ensure!(
566        out.status.success(),
567        "mktree failed: {}",
568        String::from_utf8_lossy(&out.stderr)
569    );
570    Ok(String::from_utf8(out.stdout)?.trim().to_owned())
571}
572
573/// Give up: abort whatever is standing, drop the worktree and put the branch
574/// ref back where it was, whatever a fixer did to it.
575async fn abandon(repo: &Path, scratch: &Path, branch: &str, orig: &str) {
576    git::rebase_abort(repo, scratch).await;
577    let full = format!("refs/heads/{branch}");
578    if git::rev_parse(repo, &full).await.ok().as_deref() != Some(orig) {
579        git::git_raw(repo, &["update-ref", &full, orig]).await.ok();
580    }
581}
582
583/// The reason a caller records and the conductor quotes: it says what was
584/// tried, so nobody has to open the run directory to find out.
585fn reason(spent: usize, cap: usize, paths: &[String], said: &str, what: &str) -> String {
586    let shown: Vec<&str> = paths
587        .iter()
588        .take(PATHS_IN_REASON)
589        .map(String::as_str)
590        .collect();
591    let mut list = shown.join(", ");
592    if paths.len() > shown.len() {
593        list.push_str(&format!(" and {} more", paths.len() - shown.len()));
594    }
595    if list.is_empty() {
596        list.push_str("none recorded");
597    }
598    let mut s = format!(
599        "conflict not resolved after {spent} of {cap} fixer round(s) ({what}); remaining \
600         conflicted path(s): {list}"
601    );
602    let said = git::rebase_said(said);
603    let said = said.trim();
604    if !said.is_empty() {
605        s.push_str("; git said: ");
606        s.extend(said.chars().take(250));
607    }
608    s
609}
610
611/// Commit subjects in `range`, newest first, capped.
612async fn subjects(worktree: &Path, range: &str) -> Vec<String> {
613    let n = format!("-n{SUBJECTS}");
614    git::git(worktree, &["log", "--format=%s", &n, range])
615        .await
616        .map(|o| o.lines().map(str::to_owned).collect())
617        .unwrap_or_default()
618}
619
620fn has_markers(worktree: &Path, path: &str) -> bool {
621    std::fs::read_to_string(worktree.join(path)).is_ok_and(|t| {
622        t.lines().any(|l| l.starts_with("<<<<<<< ")) && t.lines().any(|l| l.starts_with(">>>>>>> "))
623    })
624}
625
626/// The conflicted regions of `paths`, marker lines included, truncated. This
627/// only *shows* the conflict; nothing here decides how to resolve it.
628fn hunks(worktree: &Path, paths: &[String]) -> String {
629    let mut out = String::new();
630    for p in paths {
631        if out.len() >= HUNK_TOTAL {
632            out.push_str("\n(more conflicted files omitted)\n");
633            break;
634        }
635        out.push_str(&format!("=== {p} ===\n"));
636        let Ok(text) = std::fs::read_to_string(worktree.join(p)) else {
637            out.push_str("(not readable as text; use git to inspect it)\n");
638            continue;
639        };
640        let mut file = String::new();
641        let mut inside = false;
642        for line in text.lines() {
643            if line.starts_with("<<<<<<< ") {
644                inside = true;
645            }
646            if inside {
647                file.push_str(line);
648                file.push('\n');
649            }
650            if line.starts_with(">>>>>>> ") {
651                inside = false;
652            }
653        }
654        if file.len() > HUNK_PER_FILE {
655            file = file.chars().take(HUNK_PER_FILE).collect();
656            file.push_str("\n(truncated)\n");
657        }
658        out.push_str(&file);
659    }
660    out
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    use crate::proc::Quiet as _;
667
668    fn sh(dir: &Path, args: &[&str]) {
669        let o = std::process::Command::new("git")
670            .quiet()
671            .args(args)
672            .current_dir(dir)
673            .output()
674            .unwrap();
675        assert!(
676            o.status.success(),
677            "{args:?}: {}",
678            String::from_utf8_lossy(&o.stderr)
679        );
680    }
681
682    async fn record(d: &Path, picks: &[String], heads: &[String]) {
683        for (name, values) in [("magi-rebase-picks", picks), ("magi-rebase-heads", heads)] {
684            let path = git::git(d, &["rev-parse", "--git-path", name])
685                .await
686                .unwrap();
687            std::fs::write(d.join(path), values.join("\n") + "\n").unwrap();
688        }
689    }
690
691    #[tokio::test]
692    async fn a_lost_mode_change_is_not_already_in_the_result() {
693        let t = tempfile::tempdir().unwrap();
694        let d = t.path();
695        sh(d, &["init", "-q", "-b", "main"]);
696        sh(d, &["config", "user.name", "t"]);
697        sh(d, &["config", "user.email", "t@example.com"]);
698        sh(d, &["config", "core.fileMode", "true"]);
699        std::fs::write(d.join("script.sh"), "echo\n").unwrap();
700        sh(d, &["add", "-A"]);
701        sh(d, &["commit", "-q", "-m", "base"]);
702        sh(d, &["update-index", "--chmod=+x", "script.sh"]);
703        sh(d, &["commit", "-q", "-m", "chmod"]);
704        let sha = git::git(d, &["rev-parse", "HEAD"]).await.unwrap();
705        let base = git::git(d, &["rev-parse", "HEAD~1"]).await.unwrap();
706        // The result is the base: same blob, lost mode.
707        assert!(!already_in_result(d, &sha, &sha, &base).await.unwrap());
708        assert!(already_in_result(d, &sha, &sha, &sha).await.unwrap());
709    }
710
711    async fn commit(d: &Path, msg: &str) -> String {
712        sh(d, &["add", "-A"]);
713        sh(d, &["commit", "-q", "-m", msg]);
714        git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
715    }
716
717    #[tokio::test]
718    async fn candidate_path_trees_preserve_modes_deletions_and_unusual_names() {
719        let t = tempfile::tempdir().unwrap();
720        let d = t.path();
721        sh(d, &["init", "-q", "-b", "main"]);
722        sh(d, &["config", "user.name", "t"]);
723        sh(d, &["config", "user.email", "t@example.com"]);
724        sh(d, &["config", "core.fileMode", "true"]);
725        let path = if cfg!(windows) {
726            "nested/日本語 [literal].txt"
727        } else {
728            "nested/日本語\tline\n.txt"
729        };
730        std::fs::create_dir(d.join("nested")).unwrap();
731        std::fs::write(d.join(path), "contents\n").unwrap();
732        std::fs::write(d.join("g.txt"), "old\n").unwrap();
733        let base = commit(d, "base").await;
734        let paths = vec![path.to_owned()];
735        let tree = path_tree(d, &base, &paths).await.unwrap();
736        std::fs::write(d.join("g.txt"), "unrelated\n").unwrap();
737        let other = commit(d, "unrelated path").await;
738        assert_eq!(path_tree(d, &other, &paths).await.unwrap(), tree);
739        sh(d, &["update-index", "--chmod=+x", path]);
740        sh(d, &["commit", "-q", "-m", "chmod"]);
741        assert_ne!(path_tree(d, "HEAD", &paths).await.unwrap(), tree);
742        std::fs::remove_file(d.join(path)).unwrap();
743        let removed = commit(d, "delete").await;
744        let absent = path_tree(d, &removed, &paths).await.unwrap();
745        assert_ne!(absent, tree);
746        assert_eq!(absent, path_tree(d, &removed, &[]).await.unwrap());
747        assert!(path_tree(d, "not-a-revision", &paths).await.is_err());
748    }
749
750    #[tokio::test]
751    async fn a_change_inside_a_larger_upstream_edit_is_in_the_result() {
752        let t = tempfile::tempdir().unwrap();
753        let d = t.path();
754        sh(d, &["init", "-q", "-b", "main"]);
755        sh(d, &["config", "user.name", "t"]);
756        sh(d, &["config", "user.email", "t@example.com"]);
757        let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
758        std::fs::write(d.join("f.txt"), body).unwrap();
759        commit(d, "base").await;
760        std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
761        let c = commit(d, "c1").await;
762        sh(d, &["checkout", "-q", "-b", "up", "HEAD~1"]);
763        let up = body.replacen("old", "new", 1).replace("end", "end plus");
764        std::fs::write(d.join("f.txt"), up).unwrap();
765        let head = commit(d, "upstream").await;
766        assert!(already_in_result(d, &c, &c, &head).await.unwrap());
767    }
768
769    #[tokio::test]
770    async fn a_dropped_commit_on_a_non_ascii_path_is_still_noticed() {
771        let t = tempfile::tempdir().unwrap();
772        let d = t.path();
773        sh(d, &["init", "-q", "-b", "main"]);
774        sh(d, &["config", "user.name", "t"]);
775        sh(d, &["config", "user.email", "t@example.com"]);
776        std::fs::write(d.join("a.txt"), "a\n").unwrap();
777        let base = commit(d, "base").await;
778        std::fs::write(d.join("日本語.txt"), "x\n").unwrap();
779        let c = commit(d, "c").await;
780        assert!(!already_in_result(d, &c, &c, &base).await.unwrap());
781    }
782
783    async fn commit_dated(d: &Path, msg: &str) -> String {
784        sh(d, &["add", "-A"]);
785        let st = std::process::Command::new("git")
786            .quiet()
787            .current_dir(d)
788            .env("GIT_AUTHOR_DATE", "2020-01-01T00:00:00+0000")
789            .env("GIT_COMMITTER_DATE", "2020-01-01T00:00:00+0000")
790            .args(["commit", "-q", "-m", msg])
791            .status()
792            .unwrap();
793        assert!(st.success());
794        git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
795    }
796
797    #[tokio::test]
798    async fn a_recorded_resolved_pick_after_an_absorbed_one_is_not_lost() {
799        let t = tempfile::tempdir().unwrap();
800        let d = t.path();
801        sh(d, &["init", "-q", "-b", "main"]);
802        sh(d, &["config", "user.name", "t"]);
803        sh(d, &["config", "user.email", "t@example.com"]);
804        std::fs::write(d.join("z.txt"), "z\n").unwrap();
805        let base = commit(d, "base").await;
806        std::fs::write(d.join("a.txt"), "a\n").unwrap();
807        commit_dated(d, "same").await;
808        std::fs::write(d.join("b.txt"), "b\n").unwrap();
809        let orig = commit_dated(d, "same").await;
810        // Upstream folds the first change into a differently-shaped commit
811        // and adds a conflicting b.txt.
812        sh(d, &["checkout", "-q", "-b", "up", &base]);
813        std::fs::write(d.join("a.txt"), "a\n").unwrap();
814        std::fs::write(d.join("y.txt"), "y\n").unwrap();
815        std::fs::write(d.join("b.txt"), "other\n").unwrap();
816        let onto = commit(d, "upstream").await;
817        // The rebase result: first commit omitted as empty, second resolved.
818        std::fs::write(d.join("b.txt"), "other\nb\n").unwrap();
819        let head = commit_dated(d, "same").await;
820        record(
821            d,
822            &[
823                git::rev_parse(d, &format!("{orig}^")).await.unwrap(),
824                orig.clone(),
825            ],
826            &[onto.clone(), head.clone()],
827        )
828        .await;
829        assert!(
830            dropped_commits(d, d, &onto, &orig, &head)
831                .await
832                .unwrap()
833                .is_empty()
834        );
835    }
836
837    #[tokio::test]
838    async fn a_skipped_commit_is_not_masked_by_a_surviving_one_in_the_same_file() {
839        let t = tempfile::tempdir().unwrap();
840        let d = t.path();
841        sh(d, &["init", "-q", "-b", "main"]);
842        sh(d, &["config", "user.name", "t"]);
843        sh(d, &["config", "user.email", "t@example.com"]);
844        let body = "first\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
845        std::fs::write(d.join("f.txt"), body).unwrap();
846        let base = commit(d, "base").await;
847        std::fs::write(d.join("f.txt"), body.replacen("first", "mine", 1)).unwrap();
848        commit_dated(d, "same").await;
849        let two = body
850            .replacen("first", "mine", 1)
851            .replacen("last", "tail", 1);
852        std::fs::write(d.join("f.txt"), &two).unwrap();
853        let orig = commit_dated(d, "same").await;
854        sh(d, &["checkout", "-q", "-b", "up", &base]);
855        std::fs::write(d.join("f.txt"), body.replacen("first", "theirs", 1)).unwrap();
856        let onto = commit(d, "upstream").await;
857        // First commit skipped, second applied untouched.
858        std::fs::write(
859            d.join("f.txt"),
860            body.replacen("first", "theirs", 1)
861                .replacen("last", "tail", 1),
862        )
863        .unwrap();
864        let head = commit_dated(d, "same").await;
865        record(
866            d,
867            &[
868                git::rev_parse(d, &format!("{orig}^")).await.unwrap(),
869                orig.clone(),
870            ],
871            &[onto.clone(), head.clone()],
872        )
873        .await;
874        assert_eq!(
875            dropped_commits(d, d, &onto, &orig, &head).await.unwrap(),
876            vec!["same"]
877        );
878    }
879
880    #[tokio::test]
881    async fn git_records_a_skipped_middle_pick_after_an_applied_pick() {
882        let t = tempfile::tempdir().unwrap();
883        let d = t.path();
884        sh(d, &["init", "-q", "-b", "main"]);
885        sh(d, &["config", "user.name", "t"]);
886        sh(d, &["config", "user.email", "t@example.com"]);
887        std::fs::write(d.join("f"), "base\n").unwrap();
888        let base = commit(d, "base").await;
889        sh(d, &["checkout", "-q", "-b", "topic"]);
890        std::fs::write(d.join("absorbed"), "upstream too\n").unwrap();
891        commit(d, "already upstream").await;
892        std::fs::write(d.join("a"), "first\n").unwrap();
893        let first = commit(d, "first").await;
894        std::fs::write(d.join("f"), "middle\n").unwrap();
895        let middle = commit(d, "lost middle").await;
896        std::fs::write(d.join("z"), "last\n").unwrap();
897        let orig = commit(d, "last").await;
898        sh(d, &["checkout", "-q", "main"]);
899        std::fs::write(d.join("absorbed"), "upstream too\n").unwrap();
900        commit(d, "patch twin").await;
901        std::fs::write(d.join("f"), "upstream\n").unwrap();
902        let onto = commit(d, "upstream").await;
903        let scratch = d.join("scratch");
904        let git::RebaseStart::Conflicted(why) = git::rebase_start(d, &scratch, "topic", &onto)
905            .await
906            .unwrap()
907        else {
908            panic!("expected a conflict");
909        };
910        assert!(!why.contains("Executing:"), "{why}");
911        let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
912        assert!(skipped.ok(), "{}", skipped.stderr);
913        let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
914        let record = git::rebase_record(&scratch).await.unwrap();
915        assert_eq!(record.picks, vec![first, middle, orig.clone()]);
916        assert_eq!(record.heads[0], record.heads[1]);
917        assert_ne!(record.heads[1], record.heads[2]);
918        assert_eq!(
919            dropped_commits(d, &scratch, &onto, &orig, &head)
920                .await
921                .unwrap(),
922            vec!["lost middle"]
923        );
924        git::rebase_abort(d, &scratch).await;
925        assert!(git::is_ancestor(d, &base, &head).await);
926    }
927
928    #[test]
929    fn recording_diagnostics_do_not_hide_other_git_output() {
930        assert_eq!(
931            git::rebase_said("before\nExecuting: record\nafter\n"),
932            "before\nafter"
933        );
934    }
935
936    // Exercise the actual rebase and finish path. C0 conflicts in another
937    // file; C1 and C2 successively change the first line of f.txt. Upstream
938    // also changes a distant line, so neither C1 nor the original tip has
939    // the result's whole-file snapshot.
940    async fn rebase_successive_edits(
941        absorbed: bool,
942        extra_hunk: bool,
943        unrelated_conflict: bool,
944    ) -> Rebased {
945        rebase_successive_edits_record(absorbed, extra_hunk, unrelated_conflict, None).await
946    }
947
948    async fn rebase_successive_edits_record(
949        absorbed: bool,
950        extra_hunk: bool,
951        unrelated_conflict: bool,
952        damage: Option<&str>,
953    ) -> Rebased {
954        crate::run::pin_test_home();
955        let t = tempfile::tempdir().unwrap();
956        let d = t.path();
957        sh(d, &["init", "-q", "-b", "main"]);
958        sh(d, &["config", "user.name", "t"]);
959        sh(d, &["config", "user.email", "t@example.com"]);
960        // Keep Git checkouts consistent with this fixture's LF byte strings,
961        // even when the host enables automatic CRLF conversion.
962        sh(d, &["config", "core.autocrlf", "false"]);
963        let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
964        std::fs::write(d.join("f.txt"), body).unwrap();
965        std::fs::write(d.join("conflict.txt"), "old\n").unwrap();
966        if unrelated_conflict {
967            std::fs::write(d.join("g.txt"), "old\n").unwrap();
968        }
969        let base = commit(d, "base").await;
970        sh(d, &["checkout", "-q", "-b", "topic"]);
971        std::fs::write(d.join("conflict.txt"), "branch\n").unwrap();
972        commit(d, "c0").await;
973        let mut middle = body.replacen("old", "new", 1);
974        if extra_hunk {
975            middle = middle.replace("\n5\n", "\nmissing\n");
976        }
977        std::fs::write(d.join("f.txt"), &middle).unwrap();
978        let c1 = commit(d, "c1").await;
979        std::fs::write(d.join("f.txt"), middle.replacen("new", "newer", 1)).unwrap();
980        if unrelated_conflict {
981            std::fs::write(d.join("g.txt"), "branch\n").unwrap();
982        }
983        let orig = commit(d, "c2").await;
984        sh(d, &["checkout", "-q", "main"]);
985        std::fs::write(d.join("conflict.txt"), "branch plus upstream\n").unwrap();
986        let mut upstream = body
987            .replacen("old", if absorbed { "new" } else { "theirs" }, 1)
988            .replace("end", "upstream tail");
989        if extra_hunk {
990            upstream = upstream.replace("\n5\n", "\nupstream middle\n");
991        }
992        std::fs::write(d.join("f.txt"), &upstream).unwrap();
993        if unrelated_conflict {
994            std::fs::write(d.join("g.txt"), "upstream\n").unwrap();
995        }
996        let onto = commit(d, "upstream composite").await;
997        // The composite has no patch-id twin for C1.
998        assert!(git::cherry(d, &onto, &orig).await.unwrap().0.contains(&c1));
999        let scratch = d.join("scratch");
1000        assert!(matches!(
1001            git::rebase_start(d, &scratch, "topic", &onto)
1002                .await
1003                .unwrap(),
1004            git::RebaseStart::Conflicted(_)
1005        ));
1006        std::fs::write(
1007            scratch.join("conflict.txt"),
1008            "branch plus upstream resolved\n",
1009        )
1010        .unwrap();
1011        sh(&scratch, &["add", "conflict.txt"]);
1012        let continued = git::git_raw(
1013            &scratch,
1014            &["-c", "core.editor=true", "rebase", "--continue"],
1015        )
1016        .await
1017        .unwrap();
1018        if absorbed && !extra_hunk {
1019            // C1 becomes empty automatically. C2 may conflict on a path C1
1020            // never touched, while its f.txt change has applied correctly.
1021            if unrelated_conflict {
1022                assert!(!continued.ok());
1023                assert_eq!(git::unmerged_paths(&scratch).await.unwrap(), vec!["g.txt"]);
1024                assert_eq!(git::rev_parse(&scratch, "REBASE_HEAD").await.unwrap(), orig);
1025                std::fs::write(scratch.join("g.txt"), "branch plus upstream resolved\n").unwrap();
1026                sh(&scratch, &["add", "g.txt"]);
1027                let resolved = git::git_raw(&scratch, &["rebase", "--continue"])
1028                    .await
1029                    .unwrap();
1030                assert!(resolved.ok(), "{}", resolved.stderr);
1031                // A whole-tree merge would reject the correctly rebased C1
1032                // solely because of the unrelated g.txt conflict.
1033                let full = git::git_raw(
1034                    d,
1035                    &[
1036                        "merge-tree",
1037                        "--write-tree",
1038                        &format!("--merge-base={c1}"),
1039                        &onto,
1040                        &orig,
1041                    ],
1042                )
1043                .await
1044                .unwrap();
1045                assert_eq!(full.code, Some(1));
1046            } else {
1047                assert!(continued.ok(), "{}", continued.stderr);
1048            }
1049        } else {
1050            assert!(!continued.ok());
1051            assert_eq!(git::unmerged_paths(&scratch).await.unwrap(), vec!["f.txt"]);
1052            let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1053            if !absorbed {
1054                // C2 now conflicts because its predecessor was really skipped.
1055                assert!(!skipped.ok());
1056                std::fs::write(
1057                    scratch.join("f.txt"),
1058                    upstream.replacen("theirs", "newer", 1),
1059                )
1060                .unwrap();
1061                sh(&scratch, &["add", "f.txt"]);
1062                let resolved = git::git_raw(
1063                    &scratch,
1064                    &["-c", "core.editor=true", "rebase", "--continue"],
1065                )
1066                .await
1067                .unwrap();
1068                assert!(resolved.ok(), "{}", resolved.stderr);
1069            } else {
1070                assert!(skipped.ok(), "{}", skipped.stderr);
1071            }
1072        }
1073        assert!(!git::rebase_in_progress(&scratch).await);
1074        assert_eq!(
1075            std::fs::read_to_string(scratch.join("f.txt")).unwrap(),
1076            upstream.replacen(if absorbed { "new" } else { "theirs" }, "newer", 1)
1077        );
1078        let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1079        // The old final-tree-only check cannot account for C1 in any case.
1080        assert!(!already_in_result(d, &c1, &orig, &head).await.unwrap());
1081        let mut state = RunState::new(
1082            d.to_path_buf(),
1083            "main".into(),
1084            base,
1085            "rebase".into(),
1086            crate::config::Config::default(),
1087        );
1088        if let Some(damage) = damage {
1089            let path = git::git(&scratch, &["rev-parse", "--git-path", "magi-rebase-heads"])
1090                .await
1091                .unwrap();
1092            match damage {
1093                "missing" => std::fs::remove_file(&path).unwrap(),
1094                "count" => std::fs::write(&path, "").unwrap(),
1095                "final" => {
1096                    let text = std::fs::read_to_string(&path).unwrap();
1097                    let mut heads: Vec<_> = text.lines().map(str::to_owned).collect();
1098                    *heads.last_mut().unwrap() = onto.clone();
1099                    std::fs::write(&path, heads.join("\n") + "\n").unwrap();
1100                }
1101                "invalid" => std::fs::write(&path, "invalid\n").unwrap(),
1102                _ => unreachable!(),
1103            }
1104        }
1105        let outcome = finish(
1106            &mut state,
1107            &scratch,
1108            "topic",
1109            &orig,
1110            &onto,
1111            &["conflict.txt".into()],
1112            "",
1113        )
1114        .await
1115        .unwrap();
1116        std::fs::remove_dir_all(state.dir()).ok();
1117        outcome
1118    }
1119
1120    #[tokio::test]
1121    async fn missing_or_inconsistent_rebase_records_stop_the_rebase() {
1122        for damage in ["missing", "count", "final", "invalid"] {
1123            let outcome = rebase_successive_edits_record(true, false, false, Some(damage)).await;
1124            assert!(
1125                matches!(outcome, Rebased::Stopped(why) if why.contains("record could not be verified")),
1126                "{damage}"
1127            );
1128        }
1129    }
1130
1131    #[tokio::test]
1132    async fn an_absorbed_middle_commit_superseded_by_a_later_edit_is_applied() {
1133        assert_eq!(
1134            rebase_successive_edits(true, false, false).await,
1135            Rebased::Applied
1136        );
1137    }
1138
1139    #[tokio::test]
1140    async fn an_unrelated_later_conflict_does_not_hide_an_absorbed_commit() {
1141        assert_eq!(
1142            rebase_successive_edits(true, false, true).await,
1143            Rebased::Applied
1144        );
1145    }
1146
1147    #[tokio::test]
1148    async fn a_skipped_middle_commit_superseded_by_a_later_edit_is_dropped() {
1149        let Rebased::Stopped(why) = rebase_successive_edits(false, false, false).await else {
1150            panic!("a skipped middle commit must stop the rebase");
1151        };
1152        assert!(why.contains("the rebase dropped some of the branch's commits (skipped?)"));
1153        assert!(why.contains("c1"));
1154    }
1155
1156    #[tokio::test]
1157    async fn a_partially_absorbed_middle_commit_with_a_skipped_hunk_is_dropped() {
1158        let Rebased::Stopped(why) = rebase_successive_edits(true, true, false).await else {
1159            panic!("an unabsorbed hunk must stop the rebase");
1160        };
1161        assert!(why.contains("the rebase dropped some of the branch's commits (skipped?)"));
1162        assert!(why.contains("c1"));
1163    }
1164
1165    #[tokio::test]
1166    async fn an_absorbed_commit_erased_by_the_fixer_is_dropped() {
1167        crate::run::pin_test_home();
1168        let t = tempfile::tempdir().unwrap();
1169        let d = t.path();
1170        sh(d, &["init", "-q", "-b", "main"]);
1171        sh(d, &["config", "user.name", "t"]);
1172        sh(d, &["config", "user.email", "t@example.com"]);
1173        // This fixture controls each resolution itself; inherited rerere can
1174        // interfere with that sequence (including MERGE_RR lock contention).
1175        sh(d, &["config", "rerere.enabled", "false"]);
1176        let body =
1177            "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlater\n10\n11\n12\n13\n14\n15\n16\n17\n18\nend\n";
1178        std::fs::write(d.join("f.txt"), body).unwrap();
1179        std::fs::write(d.join("conflict.txt"), "old\n").unwrap();
1180        let base = commit(d, "base").await;
1181        sh(d, &["checkout", "-q", "-b", "topic"]);
1182        std::fs::write(d.join("conflict.txt"), "branch\n").unwrap();
1183        commit(d, "c0").await;
1184        std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1185        let c1 = commit(d, "c1").await;
1186        std::fs::write(
1187            d.join("f.txt"),
1188            body.replacen("old", "new", 1)
1189                .replace("later", "branch tail"),
1190        )
1191        .unwrap();
1192        let orig = commit(d, "c2").await;
1193        sh(d, &["checkout", "-q", "main"]);
1194        std::fs::write(d.join("conflict.txt"), "upstream\n").unwrap();
1195        let upstream = body
1196            .replacen("old", "new", 1)
1197            .replace("end", "upstream tail");
1198        std::fs::write(d.join("f.txt"), &upstream).unwrap();
1199        let onto = commit(d, "upstream composite").await;
1200        let scratch = d.join("scratch");
1201        assert!(matches!(
1202            git::rebase_start(d, &scratch, "topic", &onto)
1203                .await
1204                .unwrap(),
1205            git::RebaseStart::Conflicted(_)
1206        ));
1207        // While resolving C0, the fixer erases C1's absorbed first-line edit.
1208        std::fs::write(scratch.join("conflict.txt"), "resolved\n").unwrap();
1209        std::fs::write(scratch.join("f.txt"), upstream.replacen("new", "theirs", 1)).unwrap();
1210        sh(&scratch, &["add", "conflict.txt", "f.txt"]);
1211        let continued = git::git_raw(&scratch, &["rebase", "--continue"])
1212            .await
1213            .unwrap();
1214        assert_eq!(
1215            continued.code,
1216            Some(1),
1217            "{}\n{}",
1218            continued.stdout,
1219            continued.stderr
1220        );
1221        assert_eq!(git::rev_parse(&scratch, "REBASE_HEAD").await.unwrap(), c1);
1222        let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1223        assert!(skipped.ok(), "{}", skipped.stderr);
1224        let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1225        assert!(replay_is_noop(d, &c1, &onto).await.unwrap());
1226        // The later branch commit edits a different hunk, so the clean merge
1227        // still requires "new" on line one. The final result has "theirs".
1228        let expected = git::git_raw(
1229            d,
1230            &[
1231                "merge-tree",
1232                "--write-tree",
1233                &format!("--merge-base={c1}"),
1234                &onto,
1235                &orig,
1236            ],
1237        )
1238        .await
1239        .unwrap();
1240        assert!(expected.ok(), "{}", expected.stderr);
1241        assert!(
1242            !absorbed_in_result(d, &c1, &onto, &orig, &head)
1243                .await
1244                .unwrap()
1245        );
1246        assert_eq!(
1247            dropped_commits(d, &scratch, &onto, &orig, &head)
1248                .await
1249                .unwrap(),
1250            vec!["c1"]
1251        );
1252        let mut state = RunState::new(
1253            d.to_path_buf(),
1254            "main".into(),
1255            base,
1256            "rebase".into(),
1257            crate::config::Config::default(),
1258        );
1259        let outcome = finish(
1260            &mut state,
1261            &scratch,
1262            "topic",
1263            &orig,
1264            &onto,
1265            &["conflict.txt".into()],
1266            "",
1267        )
1268        .await
1269        .unwrap();
1270        assert!(
1271            matches!(outcome, Rebased::Stopped(why) if why.contains("the rebase dropped some of the branch's commits (skipped?)") && why.contains("c1"))
1272        );
1273        std::fs::remove_dir_all(state.dir()).ok();
1274    }
1275
1276    #[tokio::test]
1277    async fn a_partially_absorbed_commit_missing_an_unrelated_hunk_is_dropped() {
1278        let t = tempfile::tempdir().unwrap();
1279        let d = t.path();
1280        sh(d, &["init", "-q", "-b", "main"]);
1281        sh(d, &["config", "user.name", "t"]);
1282        sh(d, &["config", "user.email", "t@example.com"]);
1283        std::fs::write(d.join("c0.txt"), "base c0\n").unwrap();
1284        let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
1285        std::fs::write(d.join("f.txt"), body).unwrap();
1286        std::fs::write(d.join("extra.txt"), "initial\n").unwrap();
1287        let base = commit(d, "base").await;
1288
1289        // Branch commits: C0, C1 (touches f.txt AND extra.txt), C2
1290        std::fs::write(d.join("c0.txt"), "branch c0\n").unwrap();
1291        commit_dated(d, "c0").await;
1292        std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1293        std::fs::write(d.join("extra.txt"), "c1 edit\n").unwrap();
1294        commit_dated(d, "c1").await;
1295        std::fs::write(d.join("f.txt"), body.replacen("old", "newer", 1)).unwrap();
1296        let orig = commit_dated(d, "c2").await;
1297
1298        // Upstream absorbed C1's f.txt edit, but NOT extra.txt
1299        sh(d, &["checkout", "-q", "-b", "up", &base]);
1300        std::fs::write(d.join("c0.txt"), "upstream c0\n").unwrap();
1301        let up = body.replacen("old", "new", 1).replace("last", "tail");
1302        std::fs::write(d.join("f.txt"), &up).unwrap();
1303        let onto = commit(d, "upstream").await;
1304
1305        // Result: C0 resolved, C1 was skipped or dropped despite extra.txt missing, C2 applied
1306        std::fs::write(d.join("c0.txt"), "resolved c0\n").unwrap();
1307        commit_dated(d, "c0").await;
1308        let res = up.replacen("new", "newer", 1);
1309        std::fs::write(d.join("f.txt"), &res).unwrap();
1310        // extra.txt remains "initial\n"
1311        let head = commit_dated(d, "c2").await;
1312
1313        let c0 = git::rev_parse(d, &format!("{orig}~2")).await.unwrap();
1314        let c1 = git::rev_parse(d, &format!("{orig}^")).await.unwrap();
1315        let resolved = git::rev_parse(d, &format!("{head}^")).await.unwrap();
1316        record(
1317            d,
1318            &[c0, c1, orig.clone()],
1319            &[resolved.clone(), resolved, head.clone()],
1320        )
1321        .await;
1322        assert_eq!(
1323            dropped_commits(d, d, &onto, &orig, &head).await.unwrap(),
1324            vec!["c1"]
1325        );
1326    }
1327}