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 });
134 let prompt_text = if state.config.cache_dir().is_some() {
135 format!("{prompt_text}\n\n{}", prompt::build_cache_note("fix", true))
136 } else {
137 prompt_text
138 };
139
140 state.rebase_fixes.push(RebaseFixRecord {
143 agent: ids[0].clone(),
144 paths: paths.clone(),
145 from: Some(orig.clone()),
146 finished: false,
147 error: None,
148 });
149 state.event(
150 "rebase",
151 format!(
152 "{branch} conflicts with {onto} ({} path(s)); fixer round {round} of {cap}",
153 paths.len()
154 ),
155 );
156 state.save()?;
157
158 let artifacts = agent::artifacts_dir(&state.dir());
163 let mut last = None;
164 for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
165 if i > 0 {
166 if let Some(r) = state.rebase_fixes.last_mut() {
167 r.agent = spec.id.clone();
168 }
169 state.save()?;
170 }
171 let mut seat = seat_of(state, &seat_key, &spec.id);
172 let stem = if i == 0 {
173 format!("rebase-fix-{round}")
174 } else {
175 format!("rebase-fix-{round}-{}", spec.id)
176 };
177 let out = agent::invoke(
178 &spec,
179 &mut seat,
180 &Invocation {
181 cwd: scratch,
182 prompt: &prompt_text,
183 timeout: Duration::from_secs(state.config.graph.timeout_fix),
184 allow_write: true,
185 sessions: state.config.graph.sessions,
186 artifacts: &artifacts,
187 stem: &stem,
188 run: &state.id,
189 node: "rebase",
190 cache_dir: state.config.cache_dir().as_deref(),
191 attachments: &[],
192 writable: &[],
193 },
194 )
195 .await;
196 let seat_name = seat.key.clone();
197 state.seats.insert(seat.key.clone(), seat);
198 if let Some(next) = ids.get(i + 1)
199 && agent::chain_advances(&out)
200 {
201 let (class, why) = crate::fixer::failure_of(&out);
202 crate::graph::record_handover(
203 state, "rebase", &seat_name, &spec.id, next, &class, &why,
204 );
205 continue;
206 }
207 last = Some((out, seat_name));
208 break;
209 }
210 let (out, seat_name) = last.expect("the fixer chain always has an entry");
211
212 let mut error = None;
213 let mut quota = false;
214 match out {
215 Ok(o) if o.quota_exhausted() => {
216 state.quota.push(QuotaLoss {
217 seat: seat_name,
218 node: "rebase".to_owned(),
219 at: Timestamp::now(),
220 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
221 });
222 error = Some("rate limited (quota); the fixer could not run".to_owned());
223 quota = true;
224 }
225 Ok(o) if !o.usable() => {
226 error = Some(format!(
227 "the fixer produced nothing usable (exit {:?}, timed out: {})",
228 o.exit_code, o.timed_out
229 ));
230 }
231 Ok(_) => {}
232 Err(e) => error = Some(format!("{e:#}")),
233 }
234
235 let finished = !git::rebase_in_progress(scratch).await;
236 if let Some(r) = state.rebase_fixes.last_mut() {
237 r.finished = finished;
238 r.error = error.clone();
239 }
240 state.save()?;
241
242 if quota {
243 let paths = git::unmerged_paths(scratch).await.unwrap_or_default();
245 let why = reason(
246 state.rebase_fixes.len(),
247 cap,
248 &paths,
249 &said,
250 "the fixer hit its rate limit",
251 );
252 abandon(&repo, scratch, branch, &orig).await;
253 return Ok(Rebased::Stopped(why));
254 }
255 }
256}
257
258async fn finish(
260 state: &mut RunState,
261 scratch: &Path,
262 branch: &str,
263 orig: &str,
264 onto_sha: &str,
265 touched: &[String],
266 said: &str,
267) -> Result<Rebased> {
268 let repo = state.repo.clone();
269 let spent = state.rebase_fixes.len();
270 let cap = state.config.graph.review_rounds;
271 let unmerged = git::unmerged_paths(scratch).await.unwrap_or_default();
272 let head = git::rev_parse(scratch, "HEAD").await.unwrap_or_default();
273 let mut candidates: Vec<String> = touched.to_vec();
278 if let Ok(changed) = git::git(scratch, &["diff", "--name-only", onto_sha, "HEAD"]).await {
279 for p in changed.lines().map(str::trim).filter(|l| !l.is_empty()) {
280 if !candidates.iter().any(|c| c == p) {
281 candidates.push(p.to_owned());
282 }
283 }
284 }
285 let marked: Vec<String> = candidates
286 .into_iter()
287 .filter(|p| has_markers(scratch, p))
288 .collect();
289
290 let emptied = head == onto_sha
295 && git::cherry(&repo, onto_sha, orig)
296 .await
297 .map_or(true, |(unmatched, _)| !unmatched.is_empty());
298
299 let dropped = if unmerged.is_empty() && marked.is_empty() && !emptied && !head.is_empty() {
304 dropped_commits(&repo, onto_sha, orig, &head).await
305 } else {
306 Vec::new()
307 };
308
309 let problem = if !unmerged.is_empty() {
310 Some(("paths are still unmerged", unmerged))
311 } else if !marked.is_empty() {
312 Some(("conflict markers were left in the tree", marked))
313 } else if emptied {
314 Some((
315 "the rebase ended with none of the branch's commits applied (all skipped)",
316 touched.to_vec(),
317 ))
318 } else if !dropped.is_empty() {
319 Some((
320 "the rebase dropped some of the branch's commits (skipped?)",
321 dropped,
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 {
329 None
330 };
331 match problem {
332 None => {
333 git::worktree_remove(&repo, scratch).await.ok();
334 state.event(
335 "rebase",
336 format!("{branch} rebased after {spent} fixer round(s)"),
337 );
338 state.save()?;
339 Ok(Rebased::Applied)
340 }
341 Some((what, paths)) => {
342 let why = reason(spent, cap, &paths, said, what);
343 abandon(&repo, scratch, branch, orig).await;
344 Ok(Rebased::Stopped(why))
345 }
346 }
347}
348
349async fn dropped_commits(repo: &Path, onto_sha: &str, orig: &str, head: &str) -> Vec<String> {
358 let Ok((unmatched, _)) = git::cherry(repo, onto_sha, orig).await else {
359 return Vec::new();
360 };
361 if unmatched.is_empty() {
362 return Vec::new();
363 }
364 let (Ok(origin), Ok(result)) = (
365 git::commit_keys(repo, &format!("{onto_sha}..{orig}")).await,
366 git::commit_keys(repo, &format!("{onto_sha}..{head}")).await,
367 ) else {
368 return Vec::new();
369 };
370 let expected: Vec<git::CommitKey> = origin
371 .into_iter()
372 .filter(|c| unmatched.contains(&c.sha))
373 .collect();
374 let have: Vec<String> = result.iter().map(|c| c.key.clone()).collect();
375 let mut taken = vec![false; result.len()];
376 let mut lost = Vec::new();
377 let mut unverified = Vec::new();
378 for c in missing_commits(&expected, &have) {
383 if !already_in_result(repo, &c.sha, orig, head).await {
388 unverified.push(c);
389 continue;
390 }
391 let mine = change_lines(repo, &c.sha).await;
392 for (i, r) in result.iter().enumerate() {
393 if !taken[i] && r.key == c.key && change_lines(repo, &r.sha).await == mine {
394 taken[i] = true;
395 break;
396 }
397 }
398 }
399 for c in unverified {
403 let mine = commit_paths(repo, &c.sha).await;
404 let mut found = false;
405 for (i, r) in result.iter().enumerate() {
406 if taken[i] || r.key != c.key {
407 continue;
408 }
409 let theirs = commit_paths(repo, &r.sha).await;
410 if theirs.iter().any(|p| mine.contains(p)) {
411 taken[i] = true;
412 found = true;
413 break;
414 }
415 }
416 if !found {
417 lost.push(c.key.rsplit('\u{1f}').next().unwrap_or(&c.key).to_owned());
418 }
419 }
420 lost
421}
422
423async fn change_lines(repo: &Path, sha: &str) -> Vec<String> {
427 git::git(
428 repo,
429 &["diff-tree", "-p", "-U0", "--no-commit-id", "--root", sha],
430 )
431 .await
432 .map(|o| {
433 o.lines()
434 .filter(|l| {
435 !(l.starts_with("diff ")
436 || l.starts_with("index ")
437 || l.starts_with("@@")
438 || l.starts_with("--- ")
439 || l.starts_with("+++ "))
440 })
441 .map(str::to_owned)
442 .collect()
443 })
444 .unwrap_or_default()
445}
446
447async fn commit_paths(repo: &Path, sha: &str) -> Vec<String> {
450 git::git(
451 repo,
452 &[
453 "diff-tree",
454 "--no-commit-id",
455 "--name-only",
456 "-r",
457 "--root",
458 "-z",
459 sha,
460 ],
461 )
462 .await
463 .map(|o| {
464 o.split('\0')
465 .filter(|l| !l.is_empty())
466 .map(str::to_owned)
467 .collect()
468 })
469 .unwrap_or_default()
470}
471
472async fn entry(repo: &Path, rev: &str, path: &str) -> Option<String> {
475 let out = git::git(repo, &["ls-tree", rev, "--", path]).await.ok()?;
476 out.split('\t')
477 .next()
478 .filter(|e| !e.is_empty())
479 .map(str::to_owned)
480}
481
482async fn already_in_result(repo: &Path, sha: &str, orig: &str, head: &str) -> bool {
491 if replay_is_noop(repo, sha, head).await {
492 return true;
493 }
494 for p in &commit_paths(repo, sha).await {
495 let got = entry(repo, head, p).await;
496 if got != entry(repo, sha, p).await && got != entry(repo, orig, p).await {
497 return false;
498 }
499 }
500 true
501}
502
503async fn replay_is_noop(repo: &Path, sha: &str, head: &str) -> bool {
506 let base = format!("{sha}^");
507 let Ok(out) = git::git_raw(
508 repo,
509 &[
510 "merge-tree",
511 "--write-tree",
512 &format!("--merge-base={base}"),
513 head,
514 sha,
515 ],
516 )
517 .await
518 else {
519 return false;
520 };
521 if !out.ok() {
522 return false;
523 }
524 let merged = out.stdout.lines().next().unwrap_or("").trim();
525 match git::tree_of(repo, head).await {
526 Ok(t) => !merged.is_empty() && merged == t,
527 Err(_) => false,
528 }
529}
530
531fn missing_commits(expected: &[git::CommitKey], have: &[String]) -> Vec<git::CommitKey> {
539 expected
540 .iter()
541 .filter(|c| {
542 let want = expected.iter().filter(|e| e.key == c.key).count();
543 let got = have.iter().filter(|k| **k == c.key).count();
544 got < want
545 })
546 .cloned()
547 .collect()
548}
549
550async fn abandon(repo: &Path, scratch: &Path, branch: &str, orig: &str) {
553 git::rebase_abort(repo, scratch).await;
554 let full = format!("refs/heads/{branch}");
555 if git::rev_parse(repo, &full).await.ok().as_deref() != Some(orig) {
556 git::git_raw(repo, &["update-ref", &full, orig]).await.ok();
557 }
558}
559
560fn reason(spent: usize, cap: usize, paths: &[String], said: &str, what: &str) -> String {
563 let shown: Vec<&str> = paths
564 .iter()
565 .take(PATHS_IN_REASON)
566 .map(String::as_str)
567 .collect();
568 let mut list = shown.join(", ");
569 if paths.len() > shown.len() {
570 list.push_str(&format!(" and {} more", paths.len() - shown.len()));
571 }
572 if list.is_empty() {
573 list.push_str("none recorded");
574 }
575 let mut s = format!(
576 "conflict not resolved after {spent} of {cap} fixer round(s) ({what}); remaining \
577 conflicted path(s): {list}"
578 );
579 let said = said.trim();
580 if !said.is_empty() {
581 s.push_str("; git said: ");
582 s.extend(said.chars().take(250));
583 }
584 s
585}
586
587async fn subjects(worktree: &Path, range: &str) -> Vec<String> {
589 let n = format!("-n{SUBJECTS}");
590 git::git(worktree, &["log", "--format=%s", &n, range])
591 .await
592 .map(|o| o.lines().map(str::to_owned).collect())
593 .unwrap_or_default()
594}
595
596fn has_markers(worktree: &Path, path: &str) -> bool {
597 std::fs::read_to_string(worktree.join(path)).is_ok_and(|t| {
598 t.lines().any(|l| l.starts_with("<<<<<<< ")) && t.lines().any(|l| l.starts_with(">>>>>>> "))
599 })
600}
601
602fn hunks(worktree: &Path, paths: &[String]) -> String {
605 let mut out = String::new();
606 for p in paths {
607 if out.len() >= HUNK_TOTAL {
608 out.push_str("\n(more conflicted files omitted)\n");
609 break;
610 }
611 out.push_str(&format!("=== {p} ===\n"));
612 let Ok(text) = std::fs::read_to_string(worktree.join(p)) else {
613 out.push_str("(not readable as text; use git to inspect it)\n");
614 continue;
615 };
616 let mut file = String::new();
617 let mut inside = false;
618 for line in text.lines() {
619 if line.starts_with("<<<<<<< ") {
620 inside = true;
621 }
622 if inside {
623 file.push_str(line);
624 file.push('\n');
625 }
626 if line.starts_with(">>>>>>> ") {
627 inside = false;
628 }
629 }
630 if file.len() > HUNK_PER_FILE {
631 file = file.chars().take(HUNK_PER_FILE).collect();
632 file.push_str("\n(truncated)\n");
633 }
634 out.push_str(&file);
635 }
636 out
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use crate::proc::Quiet as _;
643
644 fn ck(sha: &str, subject: &str) -> git::CommitKey {
645 git::CommitKey {
646 sha: sha.to_owned(),
647 key: format!("n\u{1f}e\u{1f}1 +0000\u{1f}{subject}"),
648 }
649 }
650
651 #[test]
652 fn nothing_is_missing_when_every_key_is_present() {
653 let exp = [ck("a", "one"), ck("b", "two")];
654 let have = vec![exp[1].key.clone(), exp[0].key.clone()];
655 assert!(missing_commits(&exp, &have).is_empty());
656 }
657
658 #[test]
659 fn a_dropped_commit_is_named_by_subject() {
660 let exp = [ck("a", "one"), ck("b", "two")];
661 let have = vec![exp[1].key.clone()];
662 assert_eq!(missing_commits(&exp, &have), vec![exp[0].clone()]);
663 }
664
665 #[test]
666 fn duplicate_keys_are_counted_not_collapsed() {
667 let exp = [ck("a", "same"), ck("b", "same")];
668 let have = vec![exp[0].key.clone()];
669 assert_eq!(missing_commits(&exp, &have), exp.to_vec());
670 }
671
672 #[test]
673 fn a_commit_without_a_key_match_is_a_candidate_even_if_a_twin_exists() {
674 let exp = [ck("a", "same"), ck("b", "same")];
675 let have = vec![exp[1].key.clone()];
676 assert_eq!(missing_commits(&exp, &have).len(), 2);
677 }
678
679 fn sh(dir: &Path, args: &[&str]) {
680 let o = std::process::Command::new("git")
681 .quiet()
682 .args(args)
683 .current_dir(dir)
684 .output()
685 .unwrap();
686 assert!(
687 o.status.success(),
688 "{args:?}: {}",
689 String::from_utf8_lossy(&o.stderr)
690 );
691 }
692
693 #[tokio::test]
694 async fn a_lost_mode_change_is_not_already_in_the_result() {
695 let t = tempfile::tempdir().unwrap();
696 let d = t.path();
697 sh(d, &["init", "-q", "-b", "main"]);
698 sh(d, &["config", "user.name", "t"]);
699 sh(d, &["config", "user.email", "t@example.com"]);
700 sh(d, &["config", "core.fileMode", "true"]);
701 std::fs::write(d.join("script.sh"), "echo\n").unwrap();
702 sh(d, &["add", "-A"]);
703 sh(d, &["commit", "-q", "-m", "base"]);
704 sh(d, &["update-index", "--chmod=+x", "script.sh"]);
705 sh(d, &["commit", "-q", "-m", "chmod"]);
706 let sha = git::git(d, &["rev-parse", "HEAD"]).await.unwrap();
707 let base = git::git(d, &["rev-parse", "HEAD~1"]).await.unwrap();
708 assert!(!already_in_result(d, &sha, &sha, &base).await);
710 assert!(already_in_result(d, &sha, &sha, &sha).await);
711 }
712
713 async fn commit(d: &Path, msg: &str) -> String {
714 sh(d, &["add", "-A"]);
715 sh(d, &["commit", "-q", "-m", msg]);
716 git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
717 }
718
719 #[tokio::test]
720 async fn a_change_inside_a_larger_upstream_edit_is_in_the_result() {
721 let t = tempfile::tempdir().unwrap();
722 let d = t.path();
723 sh(d, &["init", "-q", "-b", "main"]);
724 sh(d, &["config", "user.name", "t"]);
725 sh(d, &["config", "user.email", "t@example.com"]);
726 let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
727 std::fs::write(d.join("f.txt"), body).unwrap();
728 commit(d, "base").await;
729 std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
730 let c = commit(d, "c1").await;
731 sh(d, &["checkout", "-q", "-b", "up", "HEAD~1"]);
732 let up = body.replacen("old", "new", 1).replace("end", "end plus");
733 std::fs::write(d.join("f.txt"), up).unwrap();
734 let head = commit(d, "upstream").await;
735 assert!(already_in_result(d, &c, &c, &head).await);
736 }
737
738 #[tokio::test]
739 async fn a_dropped_commit_on_a_non_ascii_path_is_still_noticed() {
740 let t = tempfile::tempdir().unwrap();
741 let d = t.path();
742 sh(d, &["init", "-q", "-b", "main"]);
743 sh(d, &["config", "user.name", "t"]);
744 sh(d, &["config", "user.email", "t@example.com"]);
745 std::fs::write(d.join("a.txt"), "a\n").unwrap();
746 let base = commit(d, "base").await;
747 std::fs::write(d.join("日本語.txt"), "x\n").unwrap();
748 let c = commit(d, "c").await;
749 assert!(!already_in_result(d, &c, &c, &base).await);
750 }
751
752 async fn commit_dated(d: &Path, msg: &str) -> String {
753 sh(d, &["add", "-A"]);
754 let st = std::process::Command::new("git")
755 .quiet()
756 .current_dir(d)
757 .env("GIT_AUTHOR_DATE", "2020-01-01T00:00:00+0000")
758 .env("GIT_COMMITTER_DATE", "2020-01-01T00:00:00+0000")
759 .args(["commit", "-q", "-m", msg])
760 .status()
761 .unwrap();
762 assert!(st.success());
763 git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
764 }
765
766 #[tokio::test]
767 async fn a_surviving_resolved_commit_sharing_a_key_with_an_absorbed_one_is_not_lost() {
768 let t = tempfile::tempdir().unwrap();
769 let d = t.path();
770 sh(d, &["init", "-q", "-b", "main"]);
771 sh(d, &["config", "user.name", "t"]);
772 sh(d, &["config", "user.email", "t@example.com"]);
773 std::fs::write(d.join("z.txt"), "z\n").unwrap();
774 let base = commit(d, "base").await;
775 std::fs::write(d.join("a.txt"), "a\n").unwrap();
776 commit_dated(d, "same").await;
777 std::fs::write(d.join("b.txt"), "b\n").unwrap();
778 let orig = commit_dated(d, "same").await;
779 sh(d, &["checkout", "-q", "-b", "up", &base]);
782 std::fs::write(d.join("a.txt"), "a\n").unwrap();
783 std::fs::write(d.join("y.txt"), "y\n").unwrap();
784 std::fs::write(d.join("b.txt"), "other\n").unwrap();
785 let onto = commit(d, "upstream").await;
786 std::fs::write(d.join("b.txt"), "other\nb\n").unwrap();
788 let head = commit_dated(d, "same").await;
789 assert!(dropped_commits(d, &onto, &orig, &head).await.is_empty());
790 }
791
792 #[tokio::test]
793 async fn a_skipped_commit_is_not_masked_by_a_surviving_one_in_the_same_file() {
794 let t = tempfile::tempdir().unwrap();
795 let d = t.path();
796 sh(d, &["init", "-q", "-b", "main"]);
797 sh(d, &["config", "user.name", "t"]);
798 sh(d, &["config", "user.email", "t@example.com"]);
799 let body = "first\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
800 std::fs::write(d.join("f.txt"), body).unwrap();
801 let base = commit(d, "base").await;
802 std::fs::write(d.join("f.txt"), body.replacen("first", "mine", 1)).unwrap();
803 commit_dated(d, "same").await;
804 let two = body
805 .replacen("first", "mine", 1)
806 .replacen("last", "tail", 1);
807 std::fs::write(d.join("f.txt"), &two).unwrap();
808 let orig = commit_dated(d, "same").await;
809 sh(d, &["checkout", "-q", "-b", "up", &base]);
810 std::fs::write(d.join("f.txt"), body.replacen("first", "theirs", 1)).unwrap();
811 let onto = commit(d, "upstream").await;
812 std::fs::write(
814 d.join("f.txt"),
815 body.replacen("first", "theirs", 1)
816 .replacen("last", "tail", 1),
817 )
818 .unwrap();
819 let head = commit_dated(d, "same").await;
820 assert_eq!(dropped_commits(d, &onto, &orig, &head).await, vec!["same"]);
821 }
822}