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 scope: crate::agent::InvocationScope::Repository,
183 coordination: state.config.coordination.as_ref(),
184 fetched_base: state.fetched_base.as_ref(),
185 cwd: scratch,
186 prompt: &prompt_text,
187 timeout: Duration::from_secs(state.config.graph.timeout_fix),
188 allow_write: true,
189 unsandboxed: false,
190 sessions: state.config.graph.sessions,
191 artifacts: &artifacts,
192 stem: &stem,
193 run: &state.id,
194 node: "rebase",
195 cache_dir: state.config.cache_dir().as_deref(),
196 attachments: &[],
197 writable: &[],
198 },
199 )
200 .await;
201 let seat_name = seat.key.clone();
202 state.seats.insert(seat.key.clone(), seat);
203 if let Some(next) = ids.get(i + 1)
204 && agent::chain_advances(&out)
205 {
206 let (class, why) = crate::fixer::failure_of(&out);
207 crate::graph::record_handover(
208 state, "rebase", &seat_name, &spec.id, next, &class, &why,
209 );
210 continue;
211 }
212 last = Some((out, seat_name));
213 break;
214 }
215 let (out, seat_name) = last.expect("the fixer chain always has an entry");
216
217 let mut error = None;
218 let mut quota = false;
219 match out {
220 Ok(o) if o.quota_exhausted() => {
221 state.quota.push(QuotaLoss {
222 seat: seat_name,
223 node: "rebase".to_owned(),
224 at: Timestamp::now(),
225 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
226 });
227 error = Some("rate limited (quota); the fixer could not run".to_owned());
228 quota = true;
229 }
230 Ok(o) if !o.usable() => {
231 error = Some(format!(
232 "the fixer produced nothing usable ({})",
233 crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
234 ));
235 }
236 Ok(_) => {}
237 Err(e) => error = Some(format!("{e:#}")),
238 }
239
240 let finished = !git::rebase_in_progress(scratch).await;
241 if let Some(r) = state.rebase_fixes.last_mut() {
242 r.finished = finished;
243 r.error = error.clone();
244 }
245 state.save()?;
246
247 if quota {
248 let paths = git::unmerged_paths(scratch).await.unwrap_or_default();
250 let why = reason(
251 state.rebase_fixes.len(),
252 cap,
253 &paths,
254 &said,
255 "the fixer hit its rate limit",
256 );
257 abandon(&repo, scratch, branch, &orig).await;
258 return Ok(Rebased::Stopped(why));
259 }
260 }
261}
262
263async fn finish(
265 state: &mut RunState,
266 scratch: &Path,
267 branch: &str,
268 orig: &str,
269 onto_sha: &str,
270 touched: &[String],
271 said: &str,
272) -> Result<Rebased> {
273 let repo = state.repo.clone();
274 let spent = state.rebase_fixes.len();
275 let cap = state.config.graph.review_rounds;
276 let unmerged = git::unmerged_paths(scratch).await.unwrap_or_default();
277 let head = git::rev_parse(scratch, "HEAD").await.unwrap_or_default();
278 let mut candidates: Vec<String> = touched.to_vec();
283 if let Ok(changed) = git::git(scratch, &["diff", "--name-only", onto_sha, "HEAD"]).await {
284 for p in changed.lines().map(str::trim).filter(|l| !l.is_empty()) {
285 if !candidates.iter().any(|c| c == p) {
286 candidates.push(p.to_owned());
287 }
288 }
289 }
290 let marked: Vec<String> = candidates
291 .into_iter()
292 .filter(|p| has_markers(scratch, p))
293 .collect();
294
295 let emptied = head == onto_sha
300 && git::cherry(&repo, onto_sha, orig)
301 .await
302 .map_or(true, |(unmatched, _)| !unmatched.is_empty());
303
304 let dropped = if unmerged.is_empty() && marked.is_empty() && !emptied && !head.is_empty() {
309 dropped_commits(&repo, scratch, onto_sha, orig, &head).await
310 } else {
311 Ok(Vec::new())
312 };
313
314 let problem = if !unmerged.is_empty() {
315 Some(("paths are still unmerged", unmerged))
316 } else if !marked.is_empty() {
317 Some(("conflict markers were left in the tree", marked))
318 } else if emptied {
319 Some((
320 "the rebase ended with none of the branch's commits applied (all skipped)",
321 touched.to_vec(),
322 ))
323 } else if head.is_empty() || !git::is_ancestor(&repo, onto_sha, &head).await {
324 Some((
325 "the rebase ended without the base in the result (abandoned or skipped)",
326 touched.to_vec(),
327 ))
328 } else if let Err(error) = &dropped {
329 Some((
330 "the rebase record could not be verified",
331 vec![format!("{error:#}")],
332 ))
333 } else if !dropped.as_ref().unwrap().is_empty() {
334 Some((
335 "the rebase dropped some of the branch's commits (skipped?)",
336 dropped.unwrap(),
337 ))
338 } else {
339 None
340 };
341 match problem {
342 None => {
343 git::worktree_remove(&repo, scratch).await.ok();
344 state.event(
345 "rebase",
346 format!("{branch} rebased after {spent} fixer round(s)"),
347 );
348 state.save()?;
349 Ok(Rebased::Applied)
350 }
351 Some((what, paths)) => {
352 let why = reason(spent, cap, &paths, said, what);
353 abandon(&repo, scratch, branch, orig).await;
354 Ok(Rebased::Stopped(why))
355 }
356 }
357}
358
359async fn dropped_commits(
362 repo: &Path,
363 scratch: &Path,
364 onto_sha: &str,
365 orig: &str,
366 head: &str,
367) -> Result<Vec<String>> {
368 let record = git::rebase_record(scratch).await?;
369 anyhow::ensure!(
370 record.heads.last().map(String::as_str) == Some(head),
371 "rebase final HEAD disagrees with record"
372 );
373 let mut previous = onto_sha;
374 let mut lost = Vec::new();
375 for (sha, picked_head) in record.picks.iter().zip(&record.heads) {
376 if picked_head == previous
377 && !absorbed_in_result(repo, sha, onto_sha, orig, head).await?
378 && !already_in_result(repo, sha, orig, head).await?
379 {
380 lost.push(git::git(repo, &["show", "-s", "--format=%s", sha]).await?);
381 }
382 previous = picked_head;
383 }
384 Ok(lost)
385}
386
387async fn commit_paths(repo: &Path, sha: &str) -> Result<Vec<String>> {
390 git::git(
391 repo,
392 &[
393 "diff-tree",
394 "--no-commit-id",
395 "--name-only",
396 "-r",
397 "--root",
398 "-z",
399 sha,
400 ],
401 )
402 .await
403 .map(|o| {
404 o.split('\0')
405 .filter(|l| !l.is_empty())
406 .map(str::to_owned)
407 .collect()
408 })
409}
410
411async fn entry(repo: &Path, rev: &str, path: &str) -> Result<Option<String>> {
414 let out = git::git(repo, &["--literal-pathspecs", "ls-tree", rev, "--", path]).await?;
415 Ok(out
416 .split('\t')
417 .next()
418 .filter(|e| !e.is_empty())
419 .map(str::to_owned))
420}
421
422async fn already_in_result(repo: &Path, sha: &str, orig: &str, head: &str) -> Result<bool> {
431 if replay_is_noop(repo, sha, head).await? {
432 return Ok(true);
433 }
434 for p in &commit_paths(repo, sha).await? {
435 let got = entry(repo, head, p).await?;
436 if got != entry(repo, sha, p).await? && got != entry(repo, orig, p).await? {
437 return Ok(false);
438 }
439 }
440 Ok(true)
441}
442
443async fn replay_is_noop(repo: &Path, sha: &str, head: &str) -> Result<bool> {
446 let base = format!("{sha}^");
447 let out = git::git_raw(
448 repo,
449 &[
450 "merge-tree",
451 "--write-tree",
452 &format!("--merge-base={base}"),
453 head,
454 sha,
455 ],
456 )
457 .await?;
458 anyhow::ensure!(
459 out.code == Some(0) || out.code == Some(1),
460 "merge-tree failed: {}",
461 out.stderr
462 );
463 if !out.ok() {
464 return Ok(false);
465 }
466 let merged = out
467 .stdout
468 .lines()
469 .next()
470 .context("merge-tree returned no tree")?
471 .trim();
472 Ok(merged == git::tree_of(repo, head).await?)
473}
474
475async fn absorbed_in_result(
482 repo: &Path,
483 sha: &str,
484 onto: &str,
485 orig: &str,
486 head: &str,
487) -> Result<bool> {
488 if !replay_is_noop(repo, sha, onto).await? {
489 return Ok(false);
490 }
491 let paths = commit_paths(repo, sha).await?;
492 if paths.is_empty() {
493 return Ok(false);
494 }
495 let base = path_tree(repo, sha, &paths).await?;
496 let upstream = path_tree(repo, onto, &paths).await?;
497 let branch = path_tree(repo, orig, &paths).await?;
498 let result = path_tree(repo, head, &paths).await?;
499 let out = git::git_raw(
500 repo,
501 &[
502 "merge-tree",
503 "--write-tree",
504 &format!("--merge-base={base}"),
505 &upstream,
506 &branch,
507 ],
508 )
509 .await?;
510 anyhow::ensure!(
511 out.code == Some(0) || out.code == Some(1),
512 "merge-tree failed: {}",
513 out.stderr
514 );
515 if !out.ok() {
516 return Ok(false);
517 }
518 Ok(out
519 .stdout
520 .lines()
521 .next()
522 .context("merge-tree returned no tree")?
523 == result)
524}
525
526async fn path_tree(repo: &Path, rev: &str, paths: &[String]) -> Result<String> {
531 use crate::proc::Quiet as _;
532 use std::fmt::Write as _;
533 use std::process::Stdio;
534 use tokio::io::AsyncWriteExt as _;
535
536 let mut entries = String::new();
537 for (i, path) in paths.iter().enumerate() {
538 let out = git::git(
539 repo,
540 &["--literal-pathspecs", "ls-tree", "-z", rev, "--", path],
541 )
542 .await?;
543 if !out.is_empty() {
544 let (entry, _) = out
545 .split_once('\t')
546 .context("ls-tree returned an invalid entry")?;
547 writeln!(entries, "{entry}\t{i:016x}")?;
548 }
549 }
550 #[cfg(test)]
551 crate::web::request_cost::git();
552 let mut child = tokio::process::Command::new("git")
553 .args(["mktree"])
554 .current_dir(repo)
555 .quiet()
556 .stdin(Stdio::piped())
557 .stdout(Stdio::piped())
558 .stderr(Stdio::piped())
559 .kill_on_drop(true)
560 .spawn()?;
561 let mut stdin = child.stdin.take().context("mktree stdin missing")?;
562 stdin.write_all(entries.as_bytes()).await?;
563 drop(stdin);
564 let out = child.wait_with_output().await?;
565 anyhow::ensure!(
566 out.status.success(),
567 "mktree failed: {}",
568 String::from_utf8_lossy(&out.stderr)
569 );
570 Ok(String::from_utf8(out.stdout)?.trim().to_owned())
571}
572
573async fn abandon(repo: &Path, scratch: &Path, branch: &str, orig: &str) {
576 git::rebase_abort(repo, scratch).await;
577 let full = format!("refs/heads/{branch}");
578 if git::rev_parse(repo, &full).await.ok().as_deref() != Some(orig) {
579 git::git_raw(repo, &["update-ref", &full, orig]).await.ok();
580 }
581}
582
583fn reason(spent: usize, cap: usize, paths: &[String], said: &str, what: &str) -> String {
586 let shown: Vec<&str> = paths
587 .iter()
588 .take(PATHS_IN_REASON)
589 .map(String::as_str)
590 .collect();
591 let mut list = shown.join(", ");
592 if paths.len() > shown.len() {
593 list.push_str(&format!(" and {} more", paths.len() - shown.len()));
594 }
595 if list.is_empty() {
596 list.push_str("none recorded");
597 }
598 let mut s = format!(
599 "conflict not resolved after {spent} of {cap} fixer round(s) ({what}); remaining \
600 conflicted path(s): {list}"
601 );
602 let said = git::rebase_said(said);
603 let said = said.trim();
604 if !said.is_empty() {
605 s.push_str("; git said: ");
606 s.extend(said.chars().take(250));
607 }
608 s
609}
610
611async fn subjects(worktree: &Path, range: &str) -> Vec<String> {
613 let n = format!("-n{SUBJECTS}");
614 git::git(worktree, &["log", "--format=%s", &n, range])
615 .await
616 .map(|o| o.lines().map(str::to_owned).collect())
617 .unwrap_or_default()
618}
619
620fn has_markers(worktree: &Path, path: &str) -> bool {
621 std::fs::read_to_string(worktree.join(path)).is_ok_and(|t| {
622 t.lines().any(|l| l.starts_with("<<<<<<< ")) && t.lines().any(|l| l.starts_with(">>>>>>> "))
623 })
624}
625
626fn hunks(worktree: &Path, paths: &[String]) -> String {
629 let mut out = String::new();
630 for p in paths {
631 if out.len() >= HUNK_TOTAL {
632 out.push_str("\n(more conflicted files omitted)\n");
633 break;
634 }
635 out.push_str(&format!("=== {p} ===\n"));
636 let Ok(text) = std::fs::read_to_string(worktree.join(p)) else {
637 out.push_str("(not readable as text; use git to inspect it)\n");
638 continue;
639 };
640 let mut file = String::new();
641 let mut inside = false;
642 for line in text.lines() {
643 if line.starts_with("<<<<<<< ") {
644 inside = true;
645 }
646 if inside {
647 file.push_str(line);
648 file.push('\n');
649 }
650 if line.starts_with(">>>>>>> ") {
651 inside = false;
652 }
653 }
654 if file.len() > HUNK_PER_FILE {
655 file = file.chars().take(HUNK_PER_FILE).collect();
656 file.push_str("\n(truncated)\n");
657 }
658 out.push_str(&file);
659 }
660 out
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666 use crate::proc::Quiet as _;
667
668 fn sh(dir: &Path, args: &[&str]) {
669 let o = std::process::Command::new("git")
670 .quiet()
671 .args(args)
672 .current_dir(dir)
673 .output()
674 .unwrap();
675 assert!(
676 o.status.success(),
677 "{args:?}: {}",
678 String::from_utf8_lossy(&o.stderr)
679 );
680 }
681
682 async fn record(d: &Path, picks: &[String], heads: &[String]) {
683 for (name, values) in [("magi-rebase-picks", picks), ("magi-rebase-heads", heads)] {
684 let path = git::git(d, &["rev-parse", "--git-path", name])
685 .await
686 .unwrap();
687 std::fs::write(d.join(path), values.join("\n") + "\n").unwrap();
688 }
689 }
690
691 #[tokio::test]
692 async fn a_lost_mode_change_is_not_already_in_the_result() {
693 let t = tempfile::tempdir().unwrap();
694 let d = t.path();
695 sh(d, &["init", "-q", "-b", "main"]);
696 sh(d, &["config", "user.name", "t"]);
697 sh(d, &["config", "user.email", "t@example.com"]);
698 sh(d, &["config", "core.fileMode", "true"]);
699 std::fs::write(d.join("script.sh"), "echo\n").unwrap();
700 sh(d, &["add", "-A"]);
701 sh(d, &["commit", "-q", "-m", "base"]);
702 sh(d, &["update-index", "--chmod=+x", "script.sh"]);
703 sh(d, &["commit", "-q", "-m", "chmod"]);
704 let sha = git::git(d, &["rev-parse", "HEAD"]).await.unwrap();
705 let base = git::git(d, &["rev-parse", "HEAD~1"]).await.unwrap();
706 assert!(!already_in_result(d, &sha, &sha, &base).await.unwrap());
708 assert!(already_in_result(d, &sha, &sha, &sha).await.unwrap());
709 }
710
711 async fn commit(d: &Path, msg: &str) -> String {
712 sh(d, &["add", "-A"]);
713 sh(d, &["commit", "-q", "-m", msg]);
714 git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
715 }
716
717 #[tokio::test]
718 async fn candidate_path_trees_preserve_modes_deletions_and_unusual_names() {
719 let t = tempfile::tempdir().unwrap();
720 let d = t.path();
721 sh(d, &["init", "-q", "-b", "main"]);
722 sh(d, &["config", "user.name", "t"]);
723 sh(d, &["config", "user.email", "t@example.com"]);
724 sh(d, &["config", "core.fileMode", "true"]);
725 let path = if cfg!(windows) {
726 "nested/日本語 [literal].txt"
727 } else {
728 "nested/日本語\tline\n.txt"
729 };
730 std::fs::create_dir(d.join("nested")).unwrap();
731 std::fs::write(d.join(path), "contents\n").unwrap();
732 std::fs::write(d.join("g.txt"), "old\n").unwrap();
733 let base = commit(d, "base").await;
734 let paths = vec![path.to_owned()];
735 let tree = path_tree(d, &base, &paths).await.unwrap();
736 std::fs::write(d.join("g.txt"), "unrelated\n").unwrap();
737 let other = commit(d, "unrelated path").await;
738 assert_eq!(path_tree(d, &other, &paths).await.unwrap(), tree);
739 sh(d, &["update-index", "--chmod=+x", path]);
740 sh(d, &["commit", "-q", "-m", "chmod"]);
741 assert_ne!(path_tree(d, "HEAD", &paths).await.unwrap(), tree);
742 std::fs::remove_file(d.join(path)).unwrap();
743 let removed = commit(d, "delete").await;
744 let absent = path_tree(d, &removed, &paths).await.unwrap();
745 assert_ne!(absent, tree);
746 assert_eq!(absent, path_tree(d, &removed, &[]).await.unwrap());
747 assert!(path_tree(d, "not-a-revision", &paths).await.is_err());
748 }
749
750 #[tokio::test]
751 async fn a_change_inside_a_larger_upstream_edit_is_in_the_result() {
752 let t = tempfile::tempdir().unwrap();
753 let d = t.path();
754 sh(d, &["init", "-q", "-b", "main"]);
755 sh(d, &["config", "user.name", "t"]);
756 sh(d, &["config", "user.email", "t@example.com"]);
757 let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
758 std::fs::write(d.join("f.txt"), body).unwrap();
759 commit(d, "base").await;
760 std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
761 let c = commit(d, "c1").await;
762 sh(d, &["checkout", "-q", "-b", "up", "HEAD~1"]);
763 let up = body.replacen("old", "new", 1).replace("end", "end plus");
764 std::fs::write(d.join("f.txt"), up).unwrap();
765 let head = commit(d, "upstream").await;
766 assert!(already_in_result(d, &c, &c, &head).await.unwrap());
767 }
768
769 #[tokio::test]
770 async fn a_dropped_commit_on_a_non_ascii_path_is_still_noticed() {
771 let t = tempfile::tempdir().unwrap();
772 let d = t.path();
773 sh(d, &["init", "-q", "-b", "main"]);
774 sh(d, &["config", "user.name", "t"]);
775 sh(d, &["config", "user.email", "t@example.com"]);
776 std::fs::write(d.join("a.txt"), "a\n").unwrap();
777 let base = commit(d, "base").await;
778 std::fs::write(d.join("日本語.txt"), "x\n").unwrap();
779 let c = commit(d, "c").await;
780 assert!(!already_in_result(d, &c, &c, &base).await.unwrap());
781 }
782
783 async fn commit_dated(d: &Path, msg: &str) -> String {
784 sh(d, &["add", "-A"]);
785 let st = std::process::Command::new("git")
786 .quiet()
787 .current_dir(d)
788 .env("GIT_AUTHOR_DATE", "2020-01-01T00:00:00+0000")
789 .env("GIT_COMMITTER_DATE", "2020-01-01T00:00:00+0000")
790 .args(["commit", "-q", "-m", msg])
791 .status()
792 .unwrap();
793 assert!(st.success());
794 git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
795 }
796
797 #[tokio::test]
798 async fn a_recorded_resolved_pick_after_an_absorbed_one_is_not_lost() {
799 let t = tempfile::tempdir().unwrap();
800 let d = t.path();
801 sh(d, &["init", "-q", "-b", "main"]);
802 sh(d, &["config", "user.name", "t"]);
803 sh(d, &["config", "user.email", "t@example.com"]);
804 std::fs::write(d.join("z.txt"), "z\n").unwrap();
805 let base = commit(d, "base").await;
806 std::fs::write(d.join("a.txt"), "a\n").unwrap();
807 commit_dated(d, "same").await;
808 std::fs::write(d.join("b.txt"), "b\n").unwrap();
809 let orig = commit_dated(d, "same").await;
810 sh(d, &["checkout", "-q", "-b", "up", &base]);
813 std::fs::write(d.join("a.txt"), "a\n").unwrap();
814 std::fs::write(d.join("y.txt"), "y\n").unwrap();
815 std::fs::write(d.join("b.txt"), "other\n").unwrap();
816 let onto = commit(d, "upstream").await;
817 std::fs::write(d.join("b.txt"), "other\nb\n").unwrap();
819 let head = commit_dated(d, "same").await;
820 record(
821 d,
822 &[
823 git::rev_parse(d, &format!("{orig}^")).await.unwrap(),
824 orig.clone(),
825 ],
826 &[onto.clone(), head.clone()],
827 )
828 .await;
829 assert!(
830 dropped_commits(d, d, &onto, &orig, &head)
831 .await
832 .unwrap()
833 .is_empty()
834 );
835 }
836
837 #[tokio::test]
838 async fn a_skipped_commit_is_not_masked_by_a_surviving_one_in_the_same_file() {
839 let t = tempfile::tempdir().unwrap();
840 let d = t.path();
841 sh(d, &["init", "-q", "-b", "main"]);
842 sh(d, &["config", "user.name", "t"]);
843 sh(d, &["config", "user.email", "t@example.com"]);
844 let body = "first\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
845 std::fs::write(d.join("f.txt"), body).unwrap();
846 let base = commit(d, "base").await;
847 std::fs::write(d.join("f.txt"), body.replacen("first", "mine", 1)).unwrap();
848 commit_dated(d, "same").await;
849 let two = body
850 .replacen("first", "mine", 1)
851 .replacen("last", "tail", 1);
852 std::fs::write(d.join("f.txt"), &two).unwrap();
853 let orig = commit_dated(d, "same").await;
854 sh(d, &["checkout", "-q", "-b", "up", &base]);
855 std::fs::write(d.join("f.txt"), body.replacen("first", "theirs", 1)).unwrap();
856 let onto = commit(d, "upstream").await;
857 std::fs::write(
859 d.join("f.txt"),
860 body.replacen("first", "theirs", 1)
861 .replacen("last", "tail", 1),
862 )
863 .unwrap();
864 let head = commit_dated(d, "same").await;
865 record(
866 d,
867 &[
868 git::rev_parse(d, &format!("{orig}^")).await.unwrap(),
869 orig.clone(),
870 ],
871 &[onto.clone(), head.clone()],
872 )
873 .await;
874 assert_eq!(
875 dropped_commits(d, d, &onto, &orig, &head).await.unwrap(),
876 vec!["same"]
877 );
878 }
879
880 #[tokio::test]
881 async fn git_records_a_skipped_middle_pick_after_an_applied_pick() {
882 let t = tempfile::tempdir().unwrap();
883 let d = t.path();
884 sh(d, &["init", "-q", "-b", "main"]);
885 sh(d, &["config", "user.name", "t"]);
886 sh(d, &["config", "user.email", "t@example.com"]);
887 std::fs::write(d.join("f"), "base\n").unwrap();
888 let base = commit(d, "base").await;
889 sh(d, &["checkout", "-q", "-b", "topic"]);
890 std::fs::write(d.join("absorbed"), "upstream too\n").unwrap();
891 commit(d, "already upstream").await;
892 std::fs::write(d.join("a"), "first\n").unwrap();
893 let first = commit(d, "first").await;
894 std::fs::write(d.join("f"), "middle\n").unwrap();
895 let middle = commit(d, "lost middle").await;
896 std::fs::write(d.join("z"), "last\n").unwrap();
897 let orig = commit(d, "last").await;
898 sh(d, &["checkout", "-q", "main"]);
899 std::fs::write(d.join("absorbed"), "upstream too\n").unwrap();
900 commit(d, "patch twin").await;
901 std::fs::write(d.join("f"), "upstream\n").unwrap();
902 let onto = commit(d, "upstream").await;
903 let scratch = d.join("scratch");
904 let git::RebaseStart::Conflicted(why) = git::rebase_start(d, &scratch, "topic", &onto)
905 .await
906 .unwrap()
907 else {
908 panic!("expected a conflict");
909 };
910 assert!(!why.contains("Executing:"), "{why}");
911 let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
912 assert!(skipped.ok(), "{}", skipped.stderr);
913 let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
914 let record = git::rebase_record(&scratch).await.unwrap();
915 assert_eq!(record.picks, vec![first, middle, orig.clone()]);
916 assert_eq!(record.heads[0], record.heads[1]);
917 assert_ne!(record.heads[1], record.heads[2]);
918 assert_eq!(
919 dropped_commits(d, &scratch, &onto, &orig, &head)
920 .await
921 .unwrap(),
922 vec!["lost middle"]
923 );
924 git::rebase_abort(d, &scratch).await;
925 assert!(git::is_ancestor(d, &base, &head).await);
926 }
927
928 #[test]
929 fn recording_diagnostics_do_not_hide_other_git_output() {
930 assert_eq!(
931 git::rebase_said("before\nExecuting: record\nafter\n"),
932 "before\nafter"
933 );
934 }
935
936 async fn rebase_successive_edits(
941 absorbed: bool,
942 extra_hunk: bool,
943 unrelated_conflict: bool,
944 ) -> Rebased {
945 rebase_successive_edits_record(absorbed, extra_hunk, unrelated_conflict, None).await
946 }
947
948 async fn rebase_successive_edits_record(
949 absorbed: bool,
950 extra_hunk: bool,
951 unrelated_conflict: bool,
952 damage: Option<&str>,
953 ) -> Rebased {
954 crate::run::pin_test_home();
955 let t = tempfile::tempdir().unwrap();
956 let d = t.path();
957 sh(d, &["init", "-q", "-b", "main"]);
958 sh(d, &["config", "user.name", "t"]);
959 sh(d, &["config", "user.email", "t@example.com"]);
960 sh(d, &["config", "core.autocrlf", "false"]);
963 let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
964 std::fs::write(d.join("f.txt"), body).unwrap();
965 std::fs::write(d.join("conflict.txt"), "old\n").unwrap();
966 if unrelated_conflict {
967 std::fs::write(d.join("g.txt"), "old\n").unwrap();
968 }
969 let base = commit(d, "base").await;
970 sh(d, &["checkout", "-q", "-b", "topic"]);
971 std::fs::write(d.join("conflict.txt"), "branch\n").unwrap();
972 commit(d, "c0").await;
973 let mut middle = body.replacen("old", "new", 1);
974 if extra_hunk {
975 middle = middle.replace("\n5\n", "\nmissing\n");
976 }
977 std::fs::write(d.join("f.txt"), &middle).unwrap();
978 let c1 = commit(d, "c1").await;
979 std::fs::write(d.join("f.txt"), middle.replacen("new", "newer", 1)).unwrap();
980 if unrelated_conflict {
981 std::fs::write(d.join("g.txt"), "branch\n").unwrap();
982 }
983 let orig = commit(d, "c2").await;
984 sh(d, &["checkout", "-q", "main"]);
985 std::fs::write(d.join("conflict.txt"), "branch plus upstream\n").unwrap();
986 let mut upstream = body
987 .replacen("old", if absorbed { "new" } else { "theirs" }, 1)
988 .replace("end", "upstream tail");
989 if extra_hunk {
990 upstream = upstream.replace("\n5\n", "\nupstream middle\n");
991 }
992 std::fs::write(d.join("f.txt"), &upstream).unwrap();
993 if unrelated_conflict {
994 std::fs::write(d.join("g.txt"), "upstream\n").unwrap();
995 }
996 let onto = commit(d, "upstream composite").await;
997 assert!(git::cherry(d, &onto, &orig).await.unwrap().0.contains(&c1));
999 let scratch = d.join("scratch");
1000 assert!(matches!(
1001 git::rebase_start(d, &scratch, "topic", &onto)
1002 .await
1003 .unwrap(),
1004 git::RebaseStart::Conflicted(_)
1005 ));
1006 std::fs::write(
1007 scratch.join("conflict.txt"),
1008 "branch plus upstream resolved\n",
1009 )
1010 .unwrap();
1011 sh(&scratch, &["add", "conflict.txt"]);
1012 let continued = git::git_raw(
1013 &scratch,
1014 &["-c", "core.editor=true", "rebase", "--continue"],
1015 )
1016 .await
1017 .unwrap();
1018 if absorbed && !extra_hunk {
1019 if unrelated_conflict {
1022 assert!(!continued.ok());
1023 assert_eq!(git::unmerged_paths(&scratch).await.unwrap(), vec!["g.txt"]);
1024 assert_eq!(git::rev_parse(&scratch, "REBASE_HEAD").await.unwrap(), orig);
1025 std::fs::write(scratch.join("g.txt"), "branch plus upstream resolved\n").unwrap();
1026 sh(&scratch, &["add", "g.txt"]);
1027 let resolved = git::git_raw(&scratch, &["rebase", "--continue"])
1028 .await
1029 .unwrap();
1030 assert!(resolved.ok(), "{}", resolved.stderr);
1031 let full = git::git_raw(
1034 d,
1035 &[
1036 "merge-tree",
1037 "--write-tree",
1038 &format!("--merge-base={c1}"),
1039 &onto,
1040 &orig,
1041 ],
1042 )
1043 .await
1044 .unwrap();
1045 assert_eq!(full.code, Some(1));
1046 } else {
1047 assert!(continued.ok(), "{}", continued.stderr);
1048 }
1049 } else {
1050 assert!(!continued.ok());
1051 assert_eq!(git::unmerged_paths(&scratch).await.unwrap(), vec!["f.txt"]);
1052 let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1053 if !absorbed {
1054 assert!(!skipped.ok());
1056 std::fs::write(
1057 scratch.join("f.txt"),
1058 upstream.replacen("theirs", "newer", 1),
1059 )
1060 .unwrap();
1061 sh(&scratch, &["add", "f.txt"]);
1062 let resolved = git::git_raw(
1063 &scratch,
1064 &["-c", "core.editor=true", "rebase", "--continue"],
1065 )
1066 .await
1067 .unwrap();
1068 assert!(resolved.ok(), "{}", resolved.stderr);
1069 } else {
1070 assert!(skipped.ok(), "{}", skipped.stderr);
1071 }
1072 }
1073 assert!(!git::rebase_in_progress(&scratch).await);
1074 assert_eq!(
1075 std::fs::read_to_string(scratch.join("f.txt")).unwrap(),
1076 upstream.replacen(if absorbed { "new" } else { "theirs" }, "newer", 1)
1077 );
1078 let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1079 assert!(!already_in_result(d, &c1, &orig, &head).await.unwrap());
1081 let mut state = RunState::new(
1082 d.to_path_buf(),
1083 "main".into(),
1084 base,
1085 "rebase".into(),
1086 crate::config::Config::default(),
1087 );
1088 if let Some(damage) = damage {
1089 let path = git::git(&scratch, &["rev-parse", "--git-path", "magi-rebase-heads"])
1090 .await
1091 .unwrap();
1092 match damage {
1093 "missing" => std::fs::remove_file(&path).unwrap(),
1094 "count" => std::fs::write(&path, "").unwrap(),
1095 "final" => {
1096 let text = std::fs::read_to_string(&path).unwrap();
1097 let mut heads: Vec<_> = text.lines().map(str::to_owned).collect();
1098 *heads.last_mut().unwrap() = onto.clone();
1099 std::fs::write(&path, heads.join("\n") + "\n").unwrap();
1100 }
1101 "invalid" => std::fs::write(&path, "invalid\n").unwrap(),
1102 _ => unreachable!(),
1103 }
1104 }
1105 let outcome = finish(
1106 &mut state,
1107 &scratch,
1108 "topic",
1109 &orig,
1110 &onto,
1111 &["conflict.txt".into()],
1112 "",
1113 )
1114 .await
1115 .unwrap();
1116 std::fs::remove_dir_all(state.dir()).ok();
1117 outcome
1118 }
1119
1120 #[tokio::test]
1121 async fn missing_or_inconsistent_rebase_records_stop_the_rebase() {
1122 for damage in ["missing", "count", "final", "invalid"] {
1123 let outcome = rebase_successive_edits_record(true, false, false, Some(damage)).await;
1124 assert!(
1125 matches!(outcome, Rebased::Stopped(why) if why.contains("record could not be verified")),
1126 "{damage}"
1127 );
1128 }
1129 }
1130
1131 #[tokio::test]
1132 async fn an_absorbed_middle_commit_superseded_by_a_later_edit_is_applied() {
1133 assert_eq!(
1134 rebase_successive_edits(true, false, false).await,
1135 Rebased::Applied
1136 );
1137 }
1138
1139 #[tokio::test]
1140 async fn an_unrelated_later_conflict_does_not_hide_an_absorbed_commit() {
1141 assert_eq!(
1142 rebase_successive_edits(true, false, true).await,
1143 Rebased::Applied
1144 );
1145 }
1146
1147 #[tokio::test]
1148 async fn a_skipped_middle_commit_superseded_by_a_later_edit_is_dropped() {
1149 let Rebased::Stopped(why) = rebase_successive_edits(false, false, false).await else {
1150 panic!("a skipped middle commit must stop the rebase");
1151 };
1152 assert!(why.contains("the rebase dropped some of the branch's commits (skipped?)"));
1153 assert!(why.contains("c1"));
1154 }
1155
1156 #[tokio::test]
1157 async fn a_partially_absorbed_middle_commit_with_a_skipped_hunk_is_dropped() {
1158 let Rebased::Stopped(why) = rebase_successive_edits(true, true, false).await else {
1159 panic!("an unabsorbed hunk must stop the rebase");
1160 };
1161 assert!(why.contains("the rebase dropped some of the branch's commits (skipped?)"));
1162 assert!(why.contains("c1"));
1163 }
1164
1165 #[tokio::test]
1166 async fn an_absorbed_commit_erased_by_the_fixer_is_dropped() {
1167 crate::run::pin_test_home();
1168 let t = tempfile::tempdir().unwrap();
1169 let d = t.path();
1170 sh(d, &["init", "-q", "-b", "main"]);
1171 sh(d, &["config", "user.name", "t"]);
1172 sh(d, &["config", "user.email", "t@example.com"]);
1173 sh(d, &["config", "rerere.enabled", "false"]);
1176 let body =
1177 "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlater\n10\n11\n12\n13\n14\n15\n16\n17\n18\nend\n";
1178 std::fs::write(d.join("f.txt"), body).unwrap();
1179 std::fs::write(d.join("conflict.txt"), "old\n").unwrap();
1180 let base = commit(d, "base").await;
1181 sh(d, &["checkout", "-q", "-b", "topic"]);
1182 std::fs::write(d.join("conflict.txt"), "branch\n").unwrap();
1183 commit(d, "c0").await;
1184 std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1185 let c1 = commit(d, "c1").await;
1186 std::fs::write(
1187 d.join("f.txt"),
1188 body.replacen("old", "new", 1)
1189 .replace("later", "branch tail"),
1190 )
1191 .unwrap();
1192 let orig = commit(d, "c2").await;
1193 sh(d, &["checkout", "-q", "main"]);
1194 std::fs::write(d.join("conflict.txt"), "upstream\n").unwrap();
1195 let upstream = body
1196 .replacen("old", "new", 1)
1197 .replace("end", "upstream tail");
1198 std::fs::write(d.join("f.txt"), &upstream).unwrap();
1199 let onto = commit(d, "upstream composite").await;
1200 let scratch = d.join("scratch");
1201 assert!(matches!(
1202 git::rebase_start(d, &scratch, "topic", &onto)
1203 .await
1204 .unwrap(),
1205 git::RebaseStart::Conflicted(_)
1206 ));
1207 std::fs::write(scratch.join("conflict.txt"), "resolved\n").unwrap();
1209 std::fs::write(scratch.join("f.txt"), upstream.replacen("new", "theirs", 1)).unwrap();
1210 sh(&scratch, &["add", "conflict.txt", "f.txt"]);
1211 let continued = git::git_raw(&scratch, &["rebase", "--continue"])
1212 .await
1213 .unwrap();
1214 assert_eq!(
1215 continued.code,
1216 Some(1),
1217 "{}\n{}",
1218 continued.stdout,
1219 continued.stderr
1220 );
1221 assert_eq!(git::rev_parse(&scratch, "REBASE_HEAD").await.unwrap(), c1);
1222 let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1223 assert!(skipped.ok(), "{}", skipped.stderr);
1224 let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1225 assert!(replay_is_noop(d, &c1, &onto).await.unwrap());
1226 let expected = git::git_raw(
1229 d,
1230 &[
1231 "merge-tree",
1232 "--write-tree",
1233 &format!("--merge-base={c1}"),
1234 &onto,
1235 &orig,
1236 ],
1237 )
1238 .await
1239 .unwrap();
1240 assert!(expected.ok(), "{}", expected.stderr);
1241 assert!(
1242 !absorbed_in_result(d, &c1, &onto, &orig, &head)
1243 .await
1244 .unwrap()
1245 );
1246 assert_eq!(
1247 dropped_commits(d, &scratch, &onto, &orig, &head)
1248 .await
1249 .unwrap(),
1250 vec!["c1"]
1251 );
1252 let mut state = RunState::new(
1253 d.to_path_buf(),
1254 "main".into(),
1255 base,
1256 "rebase".into(),
1257 crate::config::Config::default(),
1258 );
1259 let outcome = finish(
1260 &mut state,
1261 &scratch,
1262 "topic",
1263 &orig,
1264 &onto,
1265 &["conflict.txt".into()],
1266 "",
1267 )
1268 .await
1269 .unwrap();
1270 assert!(
1271 matches!(outcome, Rebased::Stopped(why) if why.contains("the rebase dropped some of the branch's commits (skipped?)") && why.contains("c1"))
1272 );
1273 std::fs::remove_dir_all(state.dir()).ok();
1274 }
1275
1276 #[tokio::test]
1277 async fn a_partially_absorbed_commit_missing_an_unrelated_hunk_is_dropped() {
1278 let t = tempfile::tempdir().unwrap();
1279 let d = t.path();
1280 sh(d, &["init", "-q", "-b", "main"]);
1281 sh(d, &["config", "user.name", "t"]);
1282 sh(d, &["config", "user.email", "t@example.com"]);
1283 std::fs::write(d.join("c0.txt"), "base c0\n").unwrap();
1284 let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
1285 std::fs::write(d.join("f.txt"), body).unwrap();
1286 std::fs::write(d.join("extra.txt"), "initial\n").unwrap();
1287 let base = commit(d, "base").await;
1288
1289 std::fs::write(d.join("c0.txt"), "branch c0\n").unwrap();
1291 commit_dated(d, "c0").await;
1292 std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1293 std::fs::write(d.join("extra.txt"), "c1 edit\n").unwrap();
1294 commit_dated(d, "c1").await;
1295 std::fs::write(d.join("f.txt"), body.replacen("old", "newer", 1)).unwrap();
1296 let orig = commit_dated(d, "c2").await;
1297
1298 sh(d, &["checkout", "-q", "-b", "up", &base]);
1300 std::fs::write(d.join("c0.txt"), "upstream c0\n").unwrap();
1301 let up = body.replacen("old", "new", 1).replace("last", "tail");
1302 std::fs::write(d.join("f.txt"), &up).unwrap();
1303 let onto = commit(d, "upstream").await;
1304
1305 std::fs::write(d.join("c0.txt"), "resolved c0\n").unwrap();
1307 commit_dated(d, "c0").await;
1308 let res = up.replacen("new", "newer", 1);
1309 std::fs::write(d.join("f.txt"), &res).unwrap();
1310 let head = commit_dated(d, "c2").await;
1312
1313 let c0 = git::rev_parse(d, &format!("{orig}~2")).await.unwrap();
1314 let c1 = git::rev_parse(d, &format!("{orig}^")).await.unwrap();
1315 let resolved = git::rev_parse(d, &format!("{head}^")).await.unwrap();
1316 record(
1317 d,
1318 &[c0, c1, orig.clone()],
1319 &[resolved.clone(), resolved, head.clone()],
1320 )
1321 .await;
1322 assert_eq!(
1323 dropped_commits(d, d, &onto, &orig, &head).await.unwrap(),
1324 vec!["c1"]
1325 );
1326 }
1327}