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                    coordination: state.config.coordination.as_ref(),
183                    fetched_base: state.fetched_base.as_ref(),
184                    cwd: scratch,
185                    prompt: &prompt_text,
186                    timeout: Duration::from_secs(state.config.graph.timeout_fix),
187                    allow_write: true,
188                    unsandboxed: false,
189                    sessions: state.config.graph.sessions,
190                    artifacts: &artifacts,
191                    stem: &stem,
192                    run: &state.id,
193                    node: "rebase",
194                    cache_dir: state.config.cache_dir().as_deref(),
195                    attachments: &[],
196                    writable: &[],
197                },
198            )
199            .await;
200            let seat_name = seat.key.clone();
201            state.seats.insert(seat.key.clone(), seat);
202            if let Some(next) = ids.get(i + 1)
203                && agent::chain_advances(&out)
204            {
205                let (class, why) = crate::fixer::failure_of(&out);
206                crate::graph::record_handover(
207                    state, "rebase", &seat_name, &spec.id, next, &class, &why,
208                );
209                continue;
210            }
211            last = Some((out, seat_name));
212            break;
213        }
214        let (out, seat_name) = last.expect("the fixer chain always has an entry");
215
216        let mut error = None;
217        let mut quota = false;
218        match out {
219            Ok(o) if o.quota_exhausted() => {
220                state.quota.push(QuotaLoss {
221                    seat: seat_name,
222                    node: "rebase".to_owned(),
223                    at: Timestamp::now(),
224                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
225                });
226                error = Some("rate limited (quota); the fixer could not run".to_owned());
227                quota = true;
228            }
229            Ok(o) if !o.usable() => {
230                error = Some(format!(
231                    "the fixer produced nothing usable ({})",
232                    crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
233                ));
234            }
235            Ok(_) => {}
236            Err(e) => error = Some(format!("{e:#}")),
237        }
238
239        let finished = !git::rebase_in_progress(scratch).await;
240        if let Some(r) = state.rebase_fixes.last_mut() {
241            r.finished = finished;
242            r.error = error.clone();
243        }
244        state.save()?;
245
246        if quota {
247            // A retry now fails the same way; do not burn the rest.
248            let paths = git::unmerged_paths(scratch).await.unwrap_or_default();
249            let why = reason(
250                state.rebase_fixes.len(),
251                cap,
252                &paths,
253                &said,
254                "the fixer hit its rate limit",
255            );
256            abandon(&repo, scratch, branch, &orig).await;
257            return Ok(Rebased::Stopped(why));
258        }
259    }
260}
261
262/// Judge a rebase that is no longer in progress by what git says.
263async fn finish(
264    state: &mut RunState,
265    scratch: &Path,
266    branch: &str,
267    orig: &str,
268    onto_sha: &str,
269    touched: &[String],
270    said: &str,
271) -> Result<Rebased> {
272    let repo = state.repo.clone();
273    let spent = state.rebase_fixes.len();
274    let cap = state.config.graph.review_rounds;
275    let unmerged = git::unmerged_paths(scratch).await.unwrap_or_default();
276    let head = git::rev_parse(scratch, "HEAD").await.unwrap_or_default();
277    // Not only the paths seen unmerged at a round's start: one round may
278    // continue through several commits, and a later commit's conflict never
279    // shows up at a loop head. Everything the result changes relative to the
280    // base is checked.
281    let mut candidates: Vec<String> = touched.to_vec();
282    if let Ok(changed) = git::git(scratch, &["diff", "--name-only", onto_sha, "HEAD"]).await {
283        for p in changed.lines().map(str::trim).filter(|l| !l.is_empty()) {
284            if !candidates.iter().any(|c| c == p) {
285                candidates.push(p.to_owned());
286            }
287        }
288    }
289    let marked: Vec<String> = candidates
290        .into_iter()
291        .filter(|p| has_markers(scratch, p))
292        .collect();
293
294    // Skipping every conflicting commit leaves the branch equal to the base:
295    // the ancestry test below passes and the branch's work is gone. Empty is
296    // only acceptable when every original commit already has a patch twin on
297    // the base.
298    let emptied = head == onto_sha
299        && git::cherry(&repo, onto_sha, orig)
300            .await
301            .map_or(true, |(unmatched, _)| !unmatched.is_empty());
302
303    // A fixer that ran `git rebase --skip` on one of several commits lets the
304    // rest apply and the rebase finish; `emptied` only sees the whole change
305    // vanishing. Find the commits that are neither in the result nor already
306    // on the base.
307    let dropped = if unmerged.is_empty() && marked.is_empty() && !emptied && !head.is_empty() {
308        dropped_commits(&repo, onto_sha, orig, &head).await
309    } else {
310        Vec::new()
311    };
312
313    let problem = if !unmerged.is_empty() {
314        Some(("paths are still unmerged", unmerged))
315    } else if !marked.is_empty() {
316        Some(("conflict markers were left in the tree", marked))
317    } else if emptied {
318        Some((
319            "the rebase ended with none of the branch's commits applied (all skipped)",
320            touched.to_vec(),
321        ))
322    } else if !dropped.is_empty() {
323        Some((
324            "the rebase dropped some of the branch's commits (skipped?)",
325            dropped,
326        ))
327    } else if head.is_empty() || !git::is_ancestor(&repo, onto_sha, &head).await {
328        Some((
329            "the rebase ended without the base in the result (abandoned or skipped)",
330            touched.to_vec(),
331        ))
332    } else {
333        None
334    };
335    match problem {
336        None => {
337            git::worktree_remove(&repo, scratch).await.ok();
338            state.event(
339                "rebase",
340                format!("{branch} rebased after {spent} fixer round(s)"),
341            );
342            state.save()?;
343            Ok(Rebased::Applied)
344        }
345        Some((what, paths)) => {
346            let why = reason(spent, cap, &paths, said, what);
347            abandon(&repo, scratch, branch, orig).await;
348            Ok(Rebased::Stopped(why))
349        }
350    }
351}
352
353/// Subjects of the commits `orig` had over `onto_sha` that the rebased `head`
354/// no longer represents. Empty when all survive or the check could not run
355/// (the other checks still apply).
356///
357/// Commits are matched by what a rebase preserves (author, author date,
358/// subject), not by patch-id: a commit the fixer resolved has a new patch-id
359/// by design. A commit whose patch already exists on the base is not expected
360/// in the result, and one with a patch twin in the result is verified by content like any other candidate.
361async fn dropped_commits(repo: &Path, onto_sha: &str, orig: &str, head: &str) -> Vec<String> {
362    let Ok((unmatched, _)) = git::cherry(repo, onto_sha, orig).await else {
363        return Vec::new();
364    };
365    if unmatched.is_empty() {
366        return Vec::new();
367    }
368    let (Ok(origin), Ok(result)) = (
369        git::commit_keys(repo, &format!("{onto_sha}..{orig}")).await,
370        git::commit_keys(repo, &format!("{onto_sha}..{head}")).await,
371    ) else {
372        return Vec::new();
373    };
374    let expected: Vec<git::CommitKey> = origin
375        .into_iter()
376        .filter(|c| unmatched.contains(&c.sha))
377        .collect();
378    let have: Vec<String> = result.iter().map(|c| c.key.clone()).collect();
379    let mut taken = vec![false; result.len()];
380    let mut lost = Vec::new();
381    let mut unverified = Vec::new();
382    // Content-verified candidates first: one whose exact change is present as
383    // a result commit under its key has claimed that survivor, so it cannot
384    // also be taken as evidence for a different, unverified commit. (One
385    // absorbed upstream matches nothing and claims nothing.)
386    for c in missing_commits(&expected, &have) {
387        // Git also drops a commit on its own when its change is already on
388        // the base under a different patch (e.g. folded into one upstream
389        // commit). That is not a loss: every path it touched holds the same
390        // content in the result.
391        if !already_in_result(repo, &c.sha, orig, head).await {
392            unverified.push(c);
393            continue;
394        }
395        let mine = change_lines(repo, &c.sha).await;
396        for (i, r) in result.iter().enumerate() {
397            if !taken[i] && r.key == c.key && change_lines(repo, &r.sha).await == mine {
398                taken[i] = true;
399                break;
400            }
401        }
402    }
403    // The rest may have survived with a conflict-resolved (new) content. A
404    // result commit under the same key that touches one of its paths, and is
405    // not already accounted for, is that survivor.
406    for c in unverified {
407        let mine = commit_paths(repo, &c.sha).await;
408        let mut found = false;
409        for (i, r) in result.iter().enumerate() {
410            if taken[i] || r.key != c.key {
411                continue;
412            }
413            let theirs = commit_paths(repo, &r.sha).await;
414            if theirs.iter().any(|p| mine.contains(p)) {
415                taken[i] = true;
416                found = true;
417                break;
418            }
419        }
420        if !found {
421            lost.push(c.key.rsplit('\u{1f}').next().unwrap_or(&c.key).to_owned());
422        }
423    }
424    lost
425}
426
427/// The added/removed lines of `sha`'s patch, headers and hunk positions left
428/// out, so a commit that was merely re-applied at a different offset compares
429/// equal to its original.
430async fn change_lines(repo: &Path, sha: &str) -> Vec<String> {
431    git::git(
432        repo,
433        &["diff-tree", "-p", "-U0", "--no-commit-id", "--root", sha],
434    )
435    .await
436    .map(|o| {
437        o.lines()
438            .filter(|l| {
439                !(l.starts_with("diff ")
440                    || l.starts_with("index ")
441                    || l.starts_with("@@")
442                    || l.starts_with("--- ")
443                    || l.starts_with("+++ "))
444            })
445            .map(str::to_owned)
446            .collect()
447    })
448    .unwrap_or_default()
449}
450
451/// Paths `sha` changes, NUL-separated from git so a quoted name is never
452/// misread. Empty when git cannot say.
453async fn commit_paths(repo: &Path, sha: &str) -> Vec<String> {
454    git::git(
455        repo,
456        &[
457            "diff-tree",
458            "--no-commit-id",
459            "--name-only",
460            "-r",
461            "--root",
462            "-z",
463            sha,
464        ],
465    )
466    .await
467    .map(|o| {
468        o.split('\0')
469            .filter(|l| !l.is_empty())
470            .map(str::to_owned)
471            .collect()
472    })
473    .unwrap_or_default()
474}
475
476/// `mode type blob` of `path` at `rev`, or `None` when it does not exist
477/// there. Unlike `rev-parse rev:path` this carries the file mode.
478async fn entry(repo: &Path, rev: &str, path: &str) -> Option<String> {
479    let out = git::git(repo, &["ls-tree", rev, "--", path]).await.ok()?;
480    out.split('\t')
481        .next()
482        .filter(|e| !e.is_empty())
483        .map(str::to_owned)
484}
485
486/// Is `sha`'s change represented in `head`?
487///
488/// First by merging: replaying `sha` onto `head` changes nothing when its
489/// change is already there, in whatever company (extra upstream edits in the
490/// same file included). Failing that, per touched path: `head` holds what
491/// `sha` left there, or what the branch's final tip `orig` has there (a later
492/// commit may have changed the path again). Mode is part of that comparison,
493/// and paths come NUL-separated so a quoted name is never misread.
494async fn already_in_result(repo: &Path, sha: &str, orig: &str, head: &str) -> bool {
495    if replay_is_noop(repo, sha, head).await {
496        return true;
497    }
498    for p in &commit_paths(repo, sha).await {
499        let got = entry(repo, head, p).await;
500        if got != entry(repo, sha, p).await && got != entry(repo, orig, p).await {
501            return false;
502        }
503    }
504    true
505}
506
507/// Does merging `sha` (against its parent) into `head` cleanly yield `head`'s
508/// own tree? False when it conflicts, changes anything, or git cannot say.
509async fn replay_is_noop(repo: &Path, sha: &str, head: &str) -> bool {
510    let base = format!("{sha}^");
511    let Ok(out) = git::git_raw(
512        repo,
513        &[
514            "merge-tree",
515            "--write-tree",
516            &format!("--merge-base={base}"),
517            head,
518            sha,
519        ],
520    )
521    .await
522    else {
523        return false;
524    };
525    if !out.ok() {
526        return false;
527    }
528    let merged = out.stdout.lines().next().unwrap_or("").trim();
529    match git::tree_of(repo, head).await {
530        Ok(t) => !merged.is_empty() && merged == t,
531        Err(_) => false,
532    }
533}
534
535/// The pure half of [`dropped_commits`]: the `expected` commits that may be
536/// missing from the result, judged by key. Keys are counted (a multiset); when
537/// the result holds fewer commits under a key than were expected, *every*
538/// expected commit with that key is a candidate, because the key cannot say
539/// which of them survived. The caller verifies each candidate by content
540/// ([`already_in_result`]); a patch twin in the result is not excused here for
541/// the same reason.
542fn missing_commits(expected: &[git::CommitKey], have: &[String]) -> Vec<git::CommitKey> {
543    expected
544        .iter()
545        .filter(|c| {
546            let want = expected.iter().filter(|e| e.key == c.key).count();
547            let got = have.iter().filter(|k| **k == c.key).count();
548            got < want
549        })
550        .cloned()
551        .collect()
552}
553
554/// Give up: abort whatever is standing, drop the worktree and put the branch
555/// ref back where it was, whatever a fixer did to it.
556async fn abandon(repo: &Path, scratch: &Path, branch: &str, orig: &str) {
557    git::rebase_abort(repo, scratch).await;
558    let full = format!("refs/heads/{branch}");
559    if git::rev_parse(repo, &full).await.ok().as_deref() != Some(orig) {
560        git::git_raw(repo, &["update-ref", &full, orig]).await.ok();
561    }
562}
563
564/// The reason a caller records and the conductor quotes: it says what was
565/// tried, so nobody has to open the run directory to find out.
566fn reason(spent: usize, cap: usize, paths: &[String], said: &str, what: &str) -> String {
567    let shown: Vec<&str> = paths
568        .iter()
569        .take(PATHS_IN_REASON)
570        .map(String::as_str)
571        .collect();
572    let mut list = shown.join(", ");
573    if paths.len() > shown.len() {
574        list.push_str(&format!(" and {} more", paths.len() - shown.len()));
575    }
576    if list.is_empty() {
577        list.push_str("none recorded");
578    }
579    let mut s = format!(
580        "conflict not resolved after {spent} of {cap} fixer round(s) ({what}); remaining \
581         conflicted path(s): {list}"
582    );
583    let said = said.trim();
584    if !said.is_empty() {
585        s.push_str("; git said: ");
586        s.extend(said.chars().take(250));
587    }
588    s
589}
590
591/// Commit subjects in `range`, newest first, capped.
592async fn subjects(worktree: &Path, range: &str) -> Vec<String> {
593    let n = format!("-n{SUBJECTS}");
594    git::git(worktree, &["log", "--format=%s", &n, range])
595        .await
596        .map(|o| o.lines().map(str::to_owned).collect())
597        .unwrap_or_default()
598}
599
600fn has_markers(worktree: &Path, path: &str) -> bool {
601    std::fs::read_to_string(worktree.join(path)).is_ok_and(|t| {
602        t.lines().any(|l| l.starts_with("<<<<<<< ")) && t.lines().any(|l| l.starts_with(">>>>>>> "))
603    })
604}
605
606/// The conflicted regions of `paths`, marker lines included, truncated. This
607/// only *shows* the conflict; nothing here decides how to resolve it.
608fn hunks(worktree: &Path, paths: &[String]) -> String {
609    let mut out = String::new();
610    for p in paths {
611        if out.len() >= HUNK_TOTAL {
612            out.push_str("\n(more conflicted files omitted)\n");
613            break;
614        }
615        out.push_str(&format!("=== {p} ===\n"));
616        let Ok(text) = std::fs::read_to_string(worktree.join(p)) else {
617            out.push_str("(not readable as text; use git to inspect it)\n");
618            continue;
619        };
620        let mut file = String::new();
621        let mut inside = false;
622        for line in text.lines() {
623            if line.starts_with("<<<<<<< ") {
624                inside = true;
625            }
626            if inside {
627                file.push_str(line);
628                file.push('\n');
629            }
630            if line.starts_with(">>>>>>> ") {
631                inside = false;
632            }
633        }
634        if file.len() > HUNK_PER_FILE {
635            file = file.chars().take(HUNK_PER_FILE).collect();
636            file.push_str("\n(truncated)\n");
637        }
638        out.push_str(&file);
639    }
640    out
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use crate::proc::Quiet as _;
647
648    fn ck(sha: &str, subject: &str) -> git::CommitKey {
649        git::CommitKey {
650            sha: sha.to_owned(),
651            key: format!("n\u{1f}e\u{1f}1 +0000\u{1f}{subject}"),
652        }
653    }
654
655    #[test]
656    fn nothing_is_missing_when_every_key_is_present() {
657        let exp = [ck("a", "one"), ck("b", "two")];
658        let have = vec![exp[1].key.clone(), exp[0].key.clone()];
659        assert!(missing_commits(&exp, &have).is_empty());
660    }
661
662    #[test]
663    fn a_dropped_commit_is_named_by_subject() {
664        let exp = [ck("a", "one"), ck("b", "two")];
665        let have = vec![exp[1].key.clone()];
666        assert_eq!(missing_commits(&exp, &have), vec![exp[0].clone()]);
667    }
668
669    #[test]
670    fn duplicate_keys_are_counted_not_collapsed() {
671        let exp = [ck("a", "same"), ck("b", "same")];
672        let have = vec![exp[0].key.clone()];
673        assert_eq!(missing_commits(&exp, &have), exp.to_vec());
674    }
675
676    #[test]
677    fn a_commit_without_a_key_match_is_a_candidate_even_if_a_twin_exists() {
678        let exp = [ck("a", "same"), ck("b", "same")];
679        let have = vec![exp[1].key.clone()];
680        assert_eq!(missing_commits(&exp, &have).len(), 2);
681    }
682
683    fn sh(dir: &Path, args: &[&str]) {
684        let o = std::process::Command::new("git")
685            .quiet()
686            .args(args)
687            .current_dir(dir)
688            .output()
689            .unwrap();
690        assert!(
691            o.status.success(),
692            "{args:?}: {}",
693            String::from_utf8_lossy(&o.stderr)
694        );
695    }
696
697    #[tokio::test]
698    async fn a_lost_mode_change_is_not_already_in_the_result() {
699        let t = tempfile::tempdir().unwrap();
700        let d = t.path();
701        sh(d, &["init", "-q", "-b", "main"]);
702        sh(d, &["config", "user.name", "t"]);
703        sh(d, &["config", "user.email", "t@example.com"]);
704        sh(d, &["config", "core.fileMode", "true"]);
705        std::fs::write(d.join("script.sh"), "echo\n").unwrap();
706        sh(d, &["add", "-A"]);
707        sh(d, &["commit", "-q", "-m", "base"]);
708        sh(d, &["update-index", "--chmod=+x", "script.sh"]);
709        sh(d, &["commit", "-q", "-m", "chmod"]);
710        let sha = git::git(d, &["rev-parse", "HEAD"]).await.unwrap();
711        let base = git::git(d, &["rev-parse", "HEAD~1"]).await.unwrap();
712        // The result is the base: same blob, lost mode.
713        assert!(!already_in_result(d, &sha, &sha, &base).await);
714        assert!(already_in_result(d, &sha, &sha, &sha).await);
715    }
716
717    async fn commit(d: &Path, msg: &str) -> String {
718        sh(d, &["add", "-A"]);
719        sh(d, &["commit", "-q", "-m", msg]);
720        git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
721    }
722
723    #[tokio::test]
724    async fn a_change_inside_a_larger_upstream_edit_is_in_the_result() {
725        let t = tempfile::tempdir().unwrap();
726        let d = t.path();
727        sh(d, &["init", "-q", "-b", "main"]);
728        sh(d, &["config", "user.name", "t"]);
729        sh(d, &["config", "user.email", "t@example.com"]);
730        let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
731        std::fs::write(d.join("f.txt"), body).unwrap();
732        commit(d, "base").await;
733        std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
734        let c = commit(d, "c1").await;
735        sh(d, &["checkout", "-q", "-b", "up", "HEAD~1"]);
736        let up = body.replacen("old", "new", 1).replace("end", "end plus");
737        std::fs::write(d.join("f.txt"), up).unwrap();
738        let head = commit(d, "upstream").await;
739        assert!(already_in_result(d, &c, &c, &head).await);
740    }
741
742    #[tokio::test]
743    async fn a_dropped_commit_on_a_non_ascii_path_is_still_noticed() {
744        let t = tempfile::tempdir().unwrap();
745        let d = t.path();
746        sh(d, &["init", "-q", "-b", "main"]);
747        sh(d, &["config", "user.name", "t"]);
748        sh(d, &["config", "user.email", "t@example.com"]);
749        std::fs::write(d.join("a.txt"), "a\n").unwrap();
750        let base = commit(d, "base").await;
751        std::fs::write(d.join("日本語.txt"), "x\n").unwrap();
752        let c = commit(d, "c").await;
753        assert!(!already_in_result(d, &c, &c, &base).await);
754    }
755
756    async fn commit_dated(d: &Path, msg: &str) -> String {
757        sh(d, &["add", "-A"]);
758        let st = std::process::Command::new("git")
759            .quiet()
760            .current_dir(d)
761            .env("GIT_AUTHOR_DATE", "2020-01-01T00:00:00+0000")
762            .env("GIT_COMMITTER_DATE", "2020-01-01T00:00:00+0000")
763            .args(["commit", "-q", "-m", msg])
764            .status()
765            .unwrap();
766        assert!(st.success());
767        git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
768    }
769
770    #[tokio::test]
771    async fn a_surviving_resolved_commit_sharing_a_key_with_an_absorbed_one_is_not_lost() {
772        let t = tempfile::tempdir().unwrap();
773        let d = t.path();
774        sh(d, &["init", "-q", "-b", "main"]);
775        sh(d, &["config", "user.name", "t"]);
776        sh(d, &["config", "user.email", "t@example.com"]);
777        std::fs::write(d.join("z.txt"), "z\n").unwrap();
778        let base = commit(d, "base").await;
779        std::fs::write(d.join("a.txt"), "a\n").unwrap();
780        commit_dated(d, "same").await;
781        std::fs::write(d.join("b.txt"), "b\n").unwrap();
782        let orig = commit_dated(d, "same").await;
783        // Upstream folds the first change into a differently-shaped commit
784        // and adds a conflicting b.txt.
785        sh(d, &["checkout", "-q", "-b", "up", &base]);
786        std::fs::write(d.join("a.txt"), "a\n").unwrap();
787        std::fs::write(d.join("y.txt"), "y\n").unwrap();
788        std::fs::write(d.join("b.txt"), "other\n").unwrap();
789        let onto = commit(d, "upstream").await;
790        // The rebase result: first commit omitted as empty, second resolved.
791        std::fs::write(d.join("b.txt"), "other\nb\n").unwrap();
792        let head = commit_dated(d, "same").await;
793        assert!(dropped_commits(d, &onto, &orig, &head).await.is_empty());
794    }
795
796    #[tokio::test]
797    async fn a_skipped_commit_is_not_masked_by_a_surviving_one_in_the_same_file() {
798        let t = tempfile::tempdir().unwrap();
799        let d = t.path();
800        sh(d, &["init", "-q", "-b", "main"]);
801        sh(d, &["config", "user.name", "t"]);
802        sh(d, &["config", "user.email", "t@example.com"]);
803        let body = "first\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
804        std::fs::write(d.join("f.txt"), body).unwrap();
805        let base = commit(d, "base").await;
806        std::fs::write(d.join("f.txt"), body.replacen("first", "mine", 1)).unwrap();
807        commit_dated(d, "same").await;
808        let two = body
809            .replacen("first", "mine", 1)
810            .replacen("last", "tail", 1);
811        std::fs::write(d.join("f.txt"), &two).unwrap();
812        let orig = commit_dated(d, "same").await;
813        sh(d, &["checkout", "-q", "-b", "up", &base]);
814        std::fs::write(d.join("f.txt"), body.replacen("first", "theirs", 1)).unwrap();
815        let onto = commit(d, "upstream").await;
816        // First commit skipped, second applied untouched.
817        std::fs::write(
818            d.join("f.txt"),
819            body.replacen("first", "theirs", 1)
820                .replacen("last", "tail", 1),
821        )
822        .unwrap();
823        let head = commit_dated(d, "same").await;
824        assert_eq!(dropped_commits(d, &onto, &orig, &head).await, vec!["same"]);
825    }
826}