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