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, onto_sha, orig, &head).await
310 } else {
311 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 !dropped.is_empty() {
324 Some((
325 "the rebase dropped some of the branch's commits (skipped?)",
326 dropped,
327 ))
328 } else if head.is_empty() || !git::is_ancestor(&repo, onto_sha, &head).await {
329 Some((
330 "the rebase ended without the base in the result (abandoned or skipped)",
331 touched.to_vec(),
332 ))
333 } else {
334 None
335 };
336 match problem {
337 None => {
338 git::worktree_remove(&repo, scratch).await.ok();
339 state.event(
340 "rebase",
341 format!("{branch} rebased after {spent} fixer round(s)"),
342 );
343 state.save()?;
344 Ok(Rebased::Applied)
345 }
346 Some((what, paths)) => {
347 let why = reason(spent, cap, &paths, said, what);
348 abandon(&repo, scratch, branch, orig).await;
349 Ok(Rebased::Stopped(why))
350 }
351 }
352}
353
354async fn dropped_commits(repo: &Path, onto_sha: &str, orig: &str, head: &str) -> Vec<String> {
363 let Ok((unmatched, _)) = git::cherry(repo, onto_sha, orig).await else {
364 return Vec::new();
365 };
366 if unmatched.is_empty() {
367 return Vec::new();
368 }
369 let (Ok(origin), Ok(result)) = (
370 git::commit_keys(repo, &format!("{onto_sha}..{orig}")).await,
371 git::commit_keys(repo, &format!("{onto_sha}..{head}")).await,
372 ) else {
373 return Vec::new();
374 };
375 let expected: Vec<git::CommitKey> = origin
376 .into_iter()
377 .filter(|c| unmatched.contains(&c.sha))
378 .collect();
379 let have: Vec<String> = result.iter().map(|c| c.key.clone()).collect();
380 let mut taken = vec![false; result.len()];
381 let mut lost = Vec::new();
382 let mut unverified = Vec::new();
383 for c in missing_commits(&expected, &have) {
388 if absorbed_in_result(repo, &c.sha, onto_sha, orig, head).await {
394 continue;
395 }
396 if !already_in_result(repo, &c.sha, orig, head).await {
397 unverified.push(c);
398 continue;
399 }
400 let mine = change_lines(repo, &c.sha).await;
401 for (i, r) in result.iter().enumerate() {
402 if !taken[i] && r.key == c.key && change_lines(repo, &r.sha).await == mine {
403 taken[i] = true;
404 break;
405 }
406 }
407 }
408 for c in unverified {
412 let mine = commit_paths(repo, &c.sha).await;
413 let mut found = false;
414 for (i, r) in result.iter().enumerate() {
415 if taken[i] || r.key != c.key {
416 continue;
417 }
418 let theirs = commit_paths(repo, &r.sha).await;
419 if theirs.iter().any(|p| mine.contains(p)) {
420 taken[i] = true;
421 found = true;
422 break;
423 }
424 }
425 if !found {
426 lost.push(c.key.rsplit('\u{1f}').next().unwrap_or(&c.key).to_owned());
427 }
428 }
429 lost
430}
431
432async fn change_lines(repo: &Path, sha: &str) -> Vec<String> {
436 git::git(
437 repo,
438 &["diff-tree", "-p", "-U0", "--no-commit-id", "--root", sha],
439 )
440 .await
441 .map(|o| {
442 o.lines()
443 .filter(|l| {
444 !(l.starts_with("diff ")
445 || l.starts_with("index ")
446 || l.starts_with("@@")
447 || l.starts_with("--- ")
448 || l.starts_with("+++ "))
449 })
450 .map(str::to_owned)
451 .collect()
452 })
453 .unwrap_or_default()
454}
455
456async fn commit_paths(repo: &Path, sha: &str) -> Vec<String> {
459 git::git(
460 repo,
461 &[
462 "diff-tree",
463 "--no-commit-id",
464 "--name-only",
465 "-r",
466 "--root",
467 "-z",
468 sha,
469 ],
470 )
471 .await
472 .map(|o| {
473 o.split('\0')
474 .filter(|l| !l.is_empty())
475 .map(str::to_owned)
476 .collect()
477 })
478 .unwrap_or_default()
479}
480
481async fn entry(repo: &Path, rev: &str, path: &str) -> Option<String> {
484 let out = git::git(repo, &["ls-tree", rev, "--", path]).await.ok()?;
485 out.split('\t')
486 .next()
487 .filter(|e| !e.is_empty())
488 .map(str::to_owned)
489}
490
491async fn already_in_result(repo: &Path, sha: &str, orig: &str, head: &str) -> bool {
500 if replay_is_noop(repo, sha, head).await {
501 return true;
502 }
503 for p in &commit_paths(repo, sha).await {
504 let got = entry(repo, head, p).await;
505 if got != entry(repo, sha, p).await && got != entry(repo, orig, p).await {
506 return false;
507 }
508 }
509 true
510}
511
512async fn replay_is_noop(repo: &Path, sha: &str, head: &str) -> bool {
515 let base = format!("{sha}^");
516 let Ok(out) = git::git_raw(
517 repo,
518 &[
519 "merge-tree",
520 "--write-tree",
521 &format!("--merge-base={base}"),
522 head,
523 sha,
524 ],
525 )
526 .await
527 else {
528 return false;
529 };
530 if !out.ok() {
531 return false;
532 }
533 let merged = out.stdout.lines().next().unwrap_or("").trim();
534 match git::tree_of(repo, head).await {
535 Ok(t) => !merged.is_empty() && merged == t,
536 Err(_) => false,
537 }
538}
539
540async fn absorbed_in_result(repo: &Path, sha: &str, onto: &str, orig: &str, head: &str) -> bool {
547 if !replay_is_noop(repo, sha, onto).await {
548 return false;
549 }
550 let paths = commit_paths(repo, sha).await;
551 if paths.is_empty() {
552 return false;
553 }
554 let (Some(base), Some(upstream), Some(branch), Some(result)) = (
555 path_tree(repo, sha, &paths).await,
556 path_tree(repo, onto, &paths).await,
557 path_tree(repo, orig, &paths).await,
558 path_tree(repo, head, &paths).await,
559 ) else {
560 return false;
561 };
562 let Ok(out) = git::git_raw(
563 repo,
564 &[
565 "merge-tree",
566 "--write-tree",
567 &format!("--merge-base={base}"),
568 &upstream,
569 &branch,
570 ],
571 )
572 .await
573 else {
574 return false;
575 };
576 if !out.ok() {
577 return false;
578 }
579 out.stdout.lines().next().is_some_and(|tree| tree == result)
580}
581
582async fn path_tree(repo: &Path, rev: &str, paths: &[String]) -> Option<String> {
587 use crate::proc::Quiet as _;
588 use std::fmt::Write as _;
589 use std::process::Stdio;
590 use tokio::io::AsyncWriteExt as _;
591
592 let mut entries = String::new();
593 for (i, path) in paths.iter().enumerate() {
594 let out = git::git(
595 repo,
596 &["--literal-pathspecs", "ls-tree", "-z", rev, "--", path],
597 )
598 .await
599 .ok()?;
600 if !out.is_empty() {
601 let (entry, _) = out.split_once('\t')?;
602 writeln!(entries, "{entry}\t{i:016x}").ok()?;
603 }
604 }
605 #[cfg(test)]
606 crate::web::request_cost::git();
607 let mut child = tokio::process::Command::new("git")
608 .args(["mktree"])
609 .current_dir(repo)
610 .quiet()
611 .stdin(Stdio::piped())
612 .stdout(Stdio::piped())
613 .stderr(Stdio::piped())
614 .kill_on_drop(true)
615 .spawn()
616 .ok()?;
617 let mut stdin = child.stdin.take()?;
618 stdin.write_all(entries.as_bytes()).await.ok()?;
619 drop(stdin);
620 let out = child.wait_with_output().await.ok()?;
621 if !out.status.success() {
622 return None;
623 }
624 Some(String::from_utf8(out.stdout).ok()?.trim().to_owned())
625}
626
627fn missing_commits(expected: &[git::CommitKey], have: &[String]) -> Vec<git::CommitKey> {
635 expected
636 .iter()
637 .filter(|c| {
638 let want = expected.iter().filter(|e| e.key == c.key).count();
639 let got = have.iter().filter(|k| **k == c.key).count();
640 got < want
641 })
642 .cloned()
643 .collect()
644}
645
646async fn abandon(repo: &Path, scratch: &Path, branch: &str, orig: &str) {
649 git::rebase_abort(repo, scratch).await;
650 let full = format!("refs/heads/{branch}");
651 if git::rev_parse(repo, &full).await.ok().as_deref() != Some(orig) {
652 git::git_raw(repo, &["update-ref", &full, orig]).await.ok();
653 }
654}
655
656fn reason(spent: usize, cap: usize, paths: &[String], said: &str, what: &str) -> String {
659 let shown: Vec<&str> = paths
660 .iter()
661 .take(PATHS_IN_REASON)
662 .map(String::as_str)
663 .collect();
664 let mut list = shown.join(", ");
665 if paths.len() > shown.len() {
666 list.push_str(&format!(" and {} more", paths.len() - shown.len()));
667 }
668 if list.is_empty() {
669 list.push_str("none recorded");
670 }
671 let mut s = format!(
672 "conflict not resolved after {spent} of {cap} fixer round(s) ({what}); remaining \
673 conflicted path(s): {list}"
674 );
675 let said = said.trim();
676 if !said.is_empty() {
677 s.push_str("; git said: ");
678 s.extend(said.chars().take(250));
679 }
680 s
681}
682
683async fn subjects(worktree: &Path, range: &str) -> Vec<String> {
685 let n = format!("-n{SUBJECTS}");
686 git::git(worktree, &["log", "--format=%s", &n, range])
687 .await
688 .map(|o| o.lines().map(str::to_owned).collect())
689 .unwrap_or_default()
690}
691
692fn has_markers(worktree: &Path, path: &str) -> bool {
693 std::fs::read_to_string(worktree.join(path)).is_ok_and(|t| {
694 t.lines().any(|l| l.starts_with("<<<<<<< ")) && t.lines().any(|l| l.starts_with(">>>>>>> "))
695 })
696}
697
698fn hunks(worktree: &Path, paths: &[String]) -> String {
701 let mut out = String::new();
702 for p in paths {
703 if out.len() >= HUNK_TOTAL {
704 out.push_str("\n(more conflicted files omitted)\n");
705 break;
706 }
707 out.push_str(&format!("=== {p} ===\n"));
708 let Ok(text) = std::fs::read_to_string(worktree.join(p)) else {
709 out.push_str("(not readable as text; use git to inspect it)\n");
710 continue;
711 };
712 let mut file = String::new();
713 let mut inside = false;
714 for line in text.lines() {
715 if line.starts_with("<<<<<<< ") {
716 inside = true;
717 }
718 if inside {
719 file.push_str(line);
720 file.push('\n');
721 }
722 if line.starts_with(">>>>>>> ") {
723 inside = false;
724 }
725 }
726 if file.len() > HUNK_PER_FILE {
727 file = file.chars().take(HUNK_PER_FILE).collect();
728 file.push_str("\n(truncated)\n");
729 }
730 out.push_str(&file);
731 }
732 out
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use crate::proc::Quiet as _;
739
740 fn ck(sha: &str, subject: &str) -> git::CommitKey {
741 git::CommitKey {
742 sha: sha.to_owned(),
743 key: format!("n\u{1f}e\u{1f}1 +0000\u{1f}{subject}"),
744 }
745 }
746
747 #[test]
748 fn nothing_is_missing_when_every_key_is_present() {
749 let exp = [ck("a", "one"), ck("b", "two")];
750 let have = vec![exp[1].key.clone(), exp[0].key.clone()];
751 assert!(missing_commits(&exp, &have).is_empty());
752 }
753
754 #[test]
755 fn a_dropped_commit_is_named_by_subject() {
756 let exp = [ck("a", "one"), ck("b", "two")];
757 let have = vec![exp[1].key.clone()];
758 assert_eq!(missing_commits(&exp, &have), vec![exp[0].clone()]);
759 }
760
761 #[test]
762 fn duplicate_keys_are_counted_not_collapsed() {
763 let exp = [ck("a", "same"), ck("b", "same")];
764 let have = vec![exp[0].key.clone()];
765 assert_eq!(missing_commits(&exp, &have), exp.to_vec());
766 }
767
768 #[test]
769 fn a_commit_without_a_key_match_is_a_candidate_even_if_a_twin_exists() {
770 let exp = [ck("a", "same"), ck("b", "same")];
771 let have = vec![exp[1].key.clone()];
772 assert_eq!(missing_commits(&exp, &have).len(), 2);
773 }
774
775 fn sh(dir: &Path, args: &[&str]) {
776 let o = std::process::Command::new("git")
777 .quiet()
778 .args(args)
779 .current_dir(dir)
780 .output()
781 .unwrap();
782 assert!(
783 o.status.success(),
784 "{args:?}: {}",
785 String::from_utf8_lossy(&o.stderr)
786 );
787 }
788
789 #[tokio::test]
790 async fn a_lost_mode_change_is_not_already_in_the_result() {
791 let t = tempfile::tempdir().unwrap();
792 let d = t.path();
793 sh(d, &["init", "-q", "-b", "main"]);
794 sh(d, &["config", "user.name", "t"]);
795 sh(d, &["config", "user.email", "t@example.com"]);
796 sh(d, &["config", "core.fileMode", "true"]);
797 std::fs::write(d.join("script.sh"), "echo\n").unwrap();
798 sh(d, &["add", "-A"]);
799 sh(d, &["commit", "-q", "-m", "base"]);
800 sh(d, &["update-index", "--chmod=+x", "script.sh"]);
801 sh(d, &["commit", "-q", "-m", "chmod"]);
802 let sha = git::git(d, &["rev-parse", "HEAD"]).await.unwrap();
803 let base = git::git(d, &["rev-parse", "HEAD~1"]).await.unwrap();
804 assert!(!already_in_result(d, &sha, &sha, &base).await);
806 assert!(already_in_result(d, &sha, &sha, &sha).await);
807 }
808
809 async fn commit(d: &Path, msg: &str) -> String {
810 sh(d, &["add", "-A"]);
811 sh(d, &["commit", "-q", "-m", msg]);
812 git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
813 }
814
815 #[tokio::test]
816 async fn candidate_path_trees_preserve_modes_deletions_and_unusual_names() {
817 let t = tempfile::tempdir().unwrap();
818 let d = t.path();
819 sh(d, &["init", "-q", "-b", "main"]);
820 sh(d, &["config", "user.name", "t"]);
821 sh(d, &["config", "user.email", "t@example.com"]);
822 sh(d, &["config", "core.fileMode", "true"]);
823 let path = if cfg!(windows) {
824 "nested/日本語 [literal].txt"
825 } else {
826 "nested/日本語\tline\n.txt"
827 };
828 std::fs::create_dir(d.join("nested")).unwrap();
829 std::fs::write(d.join(path), "contents\n").unwrap();
830 std::fs::write(d.join("g.txt"), "old\n").unwrap();
831 let base = commit(d, "base").await;
832 let paths = vec![path.to_owned()];
833 let tree = path_tree(d, &base, &paths).await.unwrap();
834 std::fs::write(d.join("g.txt"), "unrelated\n").unwrap();
835 let other = commit(d, "unrelated path").await;
836 assert_eq!(path_tree(d, &other, &paths).await.unwrap(), tree);
837 sh(d, &["update-index", "--chmod=+x", path]);
838 sh(d, &["commit", "-q", "-m", "chmod"]);
839 assert_ne!(path_tree(d, "HEAD", &paths).await.unwrap(), tree);
840 std::fs::remove_file(d.join(path)).unwrap();
841 let removed = commit(d, "delete").await;
842 let absent = path_tree(d, &removed, &paths).await.unwrap();
843 assert_ne!(absent, tree);
844 assert_eq!(absent, path_tree(d, &removed, &[]).await.unwrap());
845 assert!(path_tree(d, "not-a-revision", &paths).await.is_none());
846 }
847
848 #[tokio::test]
849 async fn a_change_inside_a_larger_upstream_edit_is_in_the_result() {
850 let t = tempfile::tempdir().unwrap();
851 let d = t.path();
852 sh(d, &["init", "-q", "-b", "main"]);
853 sh(d, &["config", "user.name", "t"]);
854 sh(d, &["config", "user.email", "t@example.com"]);
855 let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
856 std::fs::write(d.join("f.txt"), body).unwrap();
857 commit(d, "base").await;
858 std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
859 let c = commit(d, "c1").await;
860 sh(d, &["checkout", "-q", "-b", "up", "HEAD~1"]);
861 let up = body.replacen("old", "new", 1).replace("end", "end plus");
862 std::fs::write(d.join("f.txt"), up).unwrap();
863 let head = commit(d, "upstream").await;
864 assert!(already_in_result(d, &c, &c, &head).await);
865 }
866
867 #[tokio::test]
868 async fn a_dropped_commit_on_a_non_ascii_path_is_still_noticed() {
869 let t = tempfile::tempdir().unwrap();
870 let d = t.path();
871 sh(d, &["init", "-q", "-b", "main"]);
872 sh(d, &["config", "user.name", "t"]);
873 sh(d, &["config", "user.email", "t@example.com"]);
874 std::fs::write(d.join("a.txt"), "a\n").unwrap();
875 let base = commit(d, "base").await;
876 std::fs::write(d.join("日本語.txt"), "x\n").unwrap();
877 let c = commit(d, "c").await;
878 assert!(!already_in_result(d, &c, &c, &base).await);
879 }
880
881 async fn commit_dated(d: &Path, msg: &str) -> String {
882 sh(d, &["add", "-A"]);
883 let st = std::process::Command::new("git")
884 .quiet()
885 .current_dir(d)
886 .env("GIT_AUTHOR_DATE", "2020-01-01T00:00:00+0000")
887 .env("GIT_COMMITTER_DATE", "2020-01-01T00:00:00+0000")
888 .args(["commit", "-q", "-m", msg])
889 .status()
890 .unwrap();
891 assert!(st.success());
892 git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
893 }
894
895 #[tokio::test]
896 async fn a_surviving_resolved_commit_sharing_a_key_with_an_absorbed_one_is_not_lost() {
897 let t = tempfile::tempdir().unwrap();
898 let d = t.path();
899 sh(d, &["init", "-q", "-b", "main"]);
900 sh(d, &["config", "user.name", "t"]);
901 sh(d, &["config", "user.email", "t@example.com"]);
902 std::fs::write(d.join("z.txt"), "z\n").unwrap();
903 let base = commit(d, "base").await;
904 std::fs::write(d.join("a.txt"), "a\n").unwrap();
905 commit_dated(d, "same").await;
906 std::fs::write(d.join("b.txt"), "b\n").unwrap();
907 let orig = commit_dated(d, "same").await;
908 sh(d, &["checkout", "-q", "-b", "up", &base]);
911 std::fs::write(d.join("a.txt"), "a\n").unwrap();
912 std::fs::write(d.join("y.txt"), "y\n").unwrap();
913 std::fs::write(d.join("b.txt"), "other\n").unwrap();
914 let onto = commit(d, "upstream").await;
915 std::fs::write(d.join("b.txt"), "other\nb\n").unwrap();
917 let head = commit_dated(d, "same").await;
918 assert!(dropped_commits(d, &onto, &orig, &head).await.is_empty());
919 }
920
921 #[tokio::test]
922 async fn a_skipped_commit_is_not_masked_by_a_surviving_one_in_the_same_file() {
923 let t = tempfile::tempdir().unwrap();
924 let d = t.path();
925 sh(d, &["init", "-q", "-b", "main"]);
926 sh(d, &["config", "user.name", "t"]);
927 sh(d, &["config", "user.email", "t@example.com"]);
928 let body = "first\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
929 std::fs::write(d.join("f.txt"), body).unwrap();
930 let base = commit(d, "base").await;
931 std::fs::write(d.join("f.txt"), body.replacen("first", "mine", 1)).unwrap();
932 commit_dated(d, "same").await;
933 let two = body
934 .replacen("first", "mine", 1)
935 .replacen("last", "tail", 1);
936 std::fs::write(d.join("f.txt"), &two).unwrap();
937 let orig = commit_dated(d, "same").await;
938 sh(d, &["checkout", "-q", "-b", "up", &base]);
939 std::fs::write(d.join("f.txt"), body.replacen("first", "theirs", 1)).unwrap();
940 let onto = commit(d, "upstream").await;
941 std::fs::write(
943 d.join("f.txt"),
944 body.replacen("first", "theirs", 1)
945 .replacen("last", "tail", 1),
946 )
947 .unwrap();
948 let head = commit_dated(d, "same").await;
949 assert_eq!(dropped_commits(d, &onto, &orig, &head).await, vec!["same"]);
950 }
951
952 async fn rebase_successive_edits(
957 absorbed: bool,
958 extra_hunk: bool,
959 unrelated_conflict: bool,
960 ) -> Rebased {
961 crate::run::pin_test_home();
962 let t = tempfile::tempdir().unwrap();
963 let d = t.path();
964 sh(d, &["init", "-q", "-b", "main"]);
965 sh(d, &["config", "user.name", "t"]);
966 sh(d, &["config", "user.email", "t@example.com"]);
967 sh(d, &["config", "core.autocrlf", "false"]);
970 let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
971 std::fs::write(d.join("f.txt"), body).unwrap();
972 std::fs::write(d.join("conflict.txt"), "old\n").unwrap();
973 if unrelated_conflict {
974 std::fs::write(d.join("g.txt"), "old\n").unwrap();
975 }
976 let base = commit(d, "base").await;
977 sh(d, &["checkout", "-q", "-b", "topic"]);
978 std::fs::write(d.join("conflict.txt"), "branch\n").unwrap();
979 commit(d, "c0").await;
980 let mut middle = body.replacen("old", "new", 1);
981 if extra_hunk {
982 middle = middle.replace("\n5\n", "\nmissing\n");
983 }
984 std::fs::write(d.join("f.txt"), &middle).unwrap();
985 let c1 = commit(d, "c1").await;
986 std::fs::write(d.join("f.txt"), middle.replacen("new", "newer", 1)).unwrap();
987 if unrelated_conflict {
988 std::fs::write(d.join("g.txt"), "branch\n").unwrap();
989 }
990 let orig = commit(d, "c2").await;
991 sh(d, &["checkout", "-q", "main"]);
992 std::fs::write(d.join("conflict.txt"), "branch plus upstream\n").unwrap();
993 let mut upstream = body
994 .replacen("old", if absorbed { "new" } else { "theirs" }, 1)
995 .replace("end", "upstream tail");
996 if extra_hunk {
997 upstream = upstream.replace("\n5\n", "\nupstream middle\n");
998 }
999 std::fs::write(d.join("f.txt"), &upstream).unwrap();
1000 if unrelated_conflict {
1001 std::fs::write(d.join("g.txt"), "upstream\n").unwrap();
1002 }
1003 let onto = commit(d, "upstream composite").await;
1004 assert!(git::cherry(d, &onto, &orig).await.unwrap().0.contains(&c1));
1006 let scratch = d.join("scratch");
1007 assert!(matches!(
1008 git::rebase_start(d, &scratch, "topic", &onto)
1009 .await
1010 .unwrap(),
1011 git::RebaseStart::Conflicted(_)
1012 ));
1013 std::fs::write(
1014 scratch.join("conflict.txt"),
1015 "branch plus upstream resolved\n",
1016 )
1017 .unwrap();
1018 sh(&scratch, &["add", "conflict.txt"]);
1019 let continued = git::git_raw(
1020 &scratch,
1021 &["-c", "core.editor=true", "rebase", "--continue"],
1022 )
1023 .await
1024 .unwrap();
1025 if absorbed && !extra_hunk {
1026 if unrelated_conflict {
1029 assert!(!continued.ok());
1030 assert_eq!(git::unmerged_paths(&scratch).await.unwrap(), vec!["g.txt"]);
1031 assert_eq!(git::rev_parse(&scratch, "REBASE_HEAD").await.unwrap(), orig);
1032 std::fs::write(scratch.join("g.txt"), "branch plus upstream resolved\n").unwrap();
1033 sh(&scratch, &["add", "g.txt"]);
1034 let resolved = git::git_raw(&scratch, &["rebase", "--continue"])
1035 .await
1036 .unwrap();
1037 assert!(resolved.ok(), "{}", resolved.stderr);
1038 let full = git::git_raw(
1041 d,
1042 &[
1043 "merge-tree",
1044 "--write-tree",
1045 &format!("--merge-base={c1}"),
1046 &onto,
1047 &orig,
1048 ],
1049 )
1050 .await
1051 .unwrap();
1052 assert_eq!(full.code, Some(1));
1053 } else {
1054 assert!(continued.ok(), "{}", continued.stderr);
1055 }
1056 } else {
1057 assert!(!continued.ok());
1058 assert_eq!(git::unmerged_paths(&scratch).await.unwrap(), vec!["f.txt"]);
1059 let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1060 if !absorbed {
1061 assert!(!skipped.ok());
1063 std::fs::write(
1064 scratch.join("f.txt"),
1065 upstream.replacen("theirs", "newer", 1),
1066 )
1067 .unwrap();
1068 sh(&scratch, &["add", "f.txt"]);
1069 let resolved = git::git_raw(
1070 &scratch,
1071 &["-c", "core.editor=true", "rebase", "--continue"],
1072 )
1073 .await
1074 .unwrap();
1075 assert!(resolved.ok(), "{}", resolved.stderr);
1076 } else {
1077 assert!(skipped.ok(), "{}", skipped.stderr);
1078 }
1079 }
1080 assert!(!git::rebase_in_progress(&scratch).await);
1081 assert_eq!(
1082 std::fs::read_to_string(scratch.join("f.txt")).unwrap(),
1083 upstream.replacen(if absorbed { "new" } else { "theirs" }, "newer", 1)
1084 );
1085 let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1086 assert!(!already_in_result(d, &c1, &orig, &head).await);
1088 let mut state = RunState::new(
1089 d.to_path_buf(),
1090 "main".into(),
1091 base,
1092 "rebase".into(),
1093 crate::config::Config::default(),
1094 );
1095 let outcome = finish(
1096 &mut state,
1097 &scratch,
1098 "topic",
1099 &orig,
1100 &onto,
1101 &["conflict.txt".into()],
1102 "",
1103 )
1104 .await
1105 .unwrap();
1106 std::fs::remove_dir_all(state.dir()).ok();
1107 outcome
1108 }
1109
1110 #[tokio::test]
1111 async fn an_absorbed_middle_commit_superseded_by_a_later_edit_is_applied() {
1112 assert_eq!(
1113 rebase_successive_edits(true, false, false).await,
1114 Rebased::Applied
1115 );
1116 }
1117
1118 #[tokio::test]
1119 async fn an_unrelated_later_conflict_does_not_hide_an_absorbed_commit() {
1120 assert_eq!(
1121 rebase_successive_edits(true, false, true).await,
1122 Rebased::Applied
1123 );
1124 }
1125
1126 #[tokio::test]
1127 async fn a_skipped_middle_commit_superseded_by_a_later_edit_is_dropped() {
1128 let Rebased::Stopped(why) = rebase_successive_edits(false, false, false).await else {
1129 panic!("a skipped middle commit must stop the rebase");
1130 };
1131 assert!(why.contains("the rebase dropped some of the branch's commits (skipped?)"));
1132 assert!(why.contains("c1"));
1133 }
1134
1135 #[tokio::test]
1136 async fn a_partially_absorbed_middle_commit_with_a_skipped_hunk_is_dropped() {
1137 let Rebased::Stopped(why) = rebase_successive_edits(true, true, false).await else {
1138 panic!("an unabsorbed hunk must stop the rebase");
1139 };
1140 assert!(why.contains("the rebase dropped some of the branch's commits (skipped?)"));
1141 assert!(why.contains("c1"));
1142 }
1143
1144 #[tokio::test]
1145 async fn an_absorbed_commit_erased_by_the_fixer_is_dropped() {
1146 crate::run::pin_test_home();
1147 let t = tempfile::tempdir().unwrap();
1148 let d = t.path();
1149 sh(d, &["init", "-q", "-b", "main"]);
1150 sh(d, &["config", "user.name", "t"]);
1151 sh(d, &["config", "user.email", "t@example.com"]);
1152 sh(d, &["config", "rerere.enabled", "false"]);
1155 let body =
1156 "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlater\n10\n11\n12\n13\n14\n15\n16\n17\n18\nend\n";
1157 std::fs::write(d.join("f.txt"), body).unwrap();
1158 std::fs::write(d.join("conflict.txt"), "old\n").unwrap();
1159 let base = commit(d, "base").await;
1160 sh(d, &["checkout", "-q", "-b", "topic"]);
1161 std::fs::write(d.join("conflict.txt"), "branch\n").unwrap();
1162 commit(d, "c0").await;
1163 std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1164 let c1 = commit(d, "c1").await;
1165 std::fs::write(
1166 d.join("f.txt"),
1167 body.replacen("old", "new", 1)
1168 .replace("later", "branch tail"),
1169 )
1170 .unwrap();
1171 let orig = commit(d, "c2").await;
1172 sh(d, &["checkout", "-q", "main"]);
1173 std::fs::write(d.join("conflict.txt"), "upstream\n").unwrap();
1174 let upstream = body
1175 .replacen("old", "new", 1)
1176 .replace("end", "upstream tail");
1177 std::fs::write(d.join("f.txt"), &upstream).unwrap();
1178 let onto = commit(d, "upstream composite").await;
1179 let scratch = d.join("scratch");
1180 assert!(matches!(
1181 git::rebase_start(d, &scratch, "topic", &onto)
1182 .await
1183 .unwrap(),
1184 git::RebaseStart::Conflicted(_)
1185 ));
1186 std::fs::write(scratch.join("conflict.txt"), "resolved\n").unwrap();
1188 std::fs::write(scratch.join("f.txt"), upstream.replacen("new", "theirs", 1)).unwrap();
1189 sh(&scratch, &["add", "conflict.txt", "f.txt"]);
1190 let continued = git::git_raw(&scratch, &["rebase", "--continue"])
1191 .await
1192 .unwrap();
1193 assert_eq!(
1194 continued.code,
1195 Some(1),
1196 "{}\n{}",
1197 continued.stdout,
1198 continued.stderr
1199 );
1200 assert_eq!(git::rev_parse(&scratch, "REBASE_HEAD").await.unwrap(), c1);
1201 let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1202 assert!(skipped.ok(), "{}", skipped.stderr);
1203 let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1204 assert!(replay_is_noop(d, &c1, &onto).await);
1205 let expected = git::git_raw(
1208 d,
1209 &[
1210 "merge-tree",
1211 "--write-tree",
1212 &format!("--merge-base={c1}"),
1213 &onto,
1214 &orig,
1215 ],
1216 )
1217 .await
1218 .unwrap();
1219 assert!(expected.ok(), "{}", expected.stderr);
1220 assert!(!absorbed_in_result(d, &c1, &onto, &orig, &head).await);
1221 assert_eq!(dropped_commits(d, &onto, &orig, &head).await, vec!["c1"]);
1222 let mut state = RunState::new(
1223 d.to_path_buf(),
1224 "main".into(),
1225 base,
1226 "rebase".into(),
1227 crate::config::Config::default(),
1228 );
1229 let outcome = finish(
1230 &mut state,
1231 &scratch,
1232 "topic",
1233 &orig,
1234 &onto,
1235 &["conflict.txt".into()],
1236 "",
1237 )
1238 .await
1239 .unwrap();
1240 assert!(
1241 matches!(outcome, Rebased::Stopped(why) if why.contains("the rebase dropped some of the branch's commits (skipped?)") && why.contains("c1"))
1242 );
1243 std::fs::remove_dir_all(state.dir()).ok();
1244 }
1245
1246 #[tokio::test]
1247 async fn a_partially_absorbed_commit_missing_an_unrelated_hunk_is_dropped() {
1248 let t = tempfile::tempdir().unwrap();
1249 let d = t.path();
1250 sh(d, &["init", "-q", "-b", "main"]);
1251 sh(d, &["config", "user.name", "t"]);
1252 sh(d, &["config", "user.email", "t@example.com"]);
1253 std::fs::write(d.join("c0.txt"), "base c0\n").unwrap();
1254 let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
1255 std::fs::write(d.join("f.txt"), body).unwrap();
1256 std::fs::write(d.join("extra.txt"), "initial\n").unwrap();
1257 let base = commit(d, "base").await;
1258
1259 std::fs::write(d.join("c0.txt"), "branch c0\n").unwrap();
1261 commit_dated(d, "c0").await;
1262 std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1263 std::fs::write(d.join("extra.txt"), "c1 edit\n").unwrap();
1264 commit_dated(d, "c1").await;
1265 std::fs::write(d.join("f.txt"), body.replacen("old", "newer", 1)).unwrap();
1266 let orig = commit_dated(d, "c2").await;
1267
1268 sh(d, &["checkout", "-q", "-b", "up", &base]);
1270 std::fs::write(d.join("c0.txt"), "upstream c0\n").unwrap();
1271 let up = body.replacen("old", "new", 1).replace("last", "tail");
1272 std::fs::write(d.join("f.txt"), &up).unwrap();
1273 let onto = commit(d, "upstream").await;
1274
1275 std::fs::write(d.join("c0.txt"), "resolved c0\n").unwrap();
1277 commit_dated(d, "c0").await;
1278 let res = up.replacen("new", "newer", 1);
1279 std::fs::write(d.join("f.txt"), &res).unwrap();
1280 let head = commit_dated(d, "c2").await;
1282
1283 assert_eq!(dropped_commits(d, &onto, &orig, &head).await, vec!["c1"]);
1284 }
1285}