1use 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#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Rebased {
43 Applied,
46 Stopped(String),
50}
51
52const HUNK_PER_FILE: usize = 4_000;
54const HUNK_TOTAL: usize = 16_000;
55const SUBJECTS: usize = 20;
57const PATHS_IN_REASON: usize = 8;
59
60pub 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 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 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 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 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 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
262async 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 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 let emptied = head == onto_sha
299 && git::cherry(&repo, onto_sha, orig)
300 .await
301 .map_or(true, |(unmatched, _)| !unmatched.is_empty());
302
303 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
353async 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 for c in missing_commits(&expected, &have) {
387 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 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
427async 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
451async 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
476async 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
486async 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
507async 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
535fn 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
554async 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
564fn 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
591async 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
606fn 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 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 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 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 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}