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 = rebase_target(scratch).await?;
87 let mut touched: Vec<String> = Vec::new();
90
91 loop {
92 if !git::rebase_in_progress(scratch).await {
93 let result = finish(state, scratch, branch, &orig, &onto_sha, &touched, &said).await?;
94 if result == Rebased::Applied && git::rev_parse(&repo, onto).await? != onto_sha {
95 state.event("rebase", "saved rebase completed; syncing the newer base");
96 state.save()?;
97 return Box::pin(rebase_with_fixer(state, scratch, branch, onto)).await;
98 }
99 return Ok(result);
100 }
101 let paths = git::unmerged_paths(scratch).await.unwrap_or_default();
102 for p in &paths {
103 if !touched.contains(p) {
104 touched.push(p.clone());
105 }
106 }
107 if let Launch::Advanced = launcher_stage(state, scratch, &orig, &onto_sha).await? {
112 continue;
113 }
114 let spent = stop_rounds(state, scratch).await;
115 if spent >= cap {
116 let why = reason(spent, cap, &paths, &said, "the rounds are spent");
117 abandon(&repo, scratch, branch, &orig).await;
118 return Ok(Rebased::Stopped(why));
119 }
120
121 let winner = state
122 .winner()
123 .cloned()
124 .context("resolving a rebase conflict needs a winning candidate")?;
125 let roles = state
126 .config
127 .resolve_roles()
128 .context("resolve the roster for the rebase fix")?;
129 let attempts = crate::fixer::attempts(state, &roles, &winner);
131 let ids: Vec<String> = attempts.iter().map(|(s, _)| s.id.clone()).collect();
132
133 let round = spent + 1;
134 let artifact_round = state.rebase_fixes.len() + 1;
135 let branch_subjects = subjects(scratch, &format!("{onto}..{branch}")).await;
136 let onto_subjects = subjects(scratch, &format!("{branch}..{onto}")).await;
137 let hunks = hunks(scratch, &paths);
138 let prompt_text = prompt::rebase_conflict(&prompt::RebaseConflict {
139 instruction: &state.instruction,
140 worktree: scratch,
141 branch,
142 onto,
143 paths: &paths,
144 branch_subjects: &branch_subjects,
145 onto_subjects: &onto_subjects,
146 hunks: &hunks,
147 round,
148 cap,
149 language: &state.config.graph.language,
150 github_language: &state.config.graph.github_language,
151 });
152 let prompt_text = if state.config.cache_dir().is_some() {
153 format!("{prompt_text}\n\n{}", prompt::build_cache_note("fix", true))
154 } else {
155 prompt_text
156 };
157
158 let stop = rebase_head(scratch).await;
161 let index_outside = Some(round_baseline(state, scratch, &paths).await?);
162 state.rebase_fixes.push(RebaseFixRecord {
163 agent: ids[0].clone(),
164 paths: paths.clone(),
165 from: Some(orig.clone()),
166 finished: false,
167 error: None,
168 stop,
169 index_outside,
170 launcher: None,
171 infra: None,
172 });
173 state.event(
174 "rebase",
175 format!(
176 "{branch} conflicts with {onto} ({} path(s)); fixer round {round} of {cap}",
177 paths.len()
178 ),
179 );
180 state.save()?;
181
182 let artifacts = agent::artifacts_dir(&state.dir());
187 let mut last = None;
188 for (i, (spec, seat_key)) in attempts.into_iter().enumerate() {
189 if i > 0 {
190 if let Some(r) = state.rebase_fixes.last_mut() {
191 r.agent = spec.id.clone();
192 }
193 state.save()?;
194 }
195 let mut seat = seat_of(state, &seat_key, &spec.id);
196 let stem = if i == 0 {
197 format!("rebase-fix-{artifact_round}")
198 } else {
199 format!("rebase-fix-{artifact_round}-{}", spec.id)
200 };
201 let out = agent::invoke(
202 &spec,
203 &mut seat,
204 &Invocation {
205 coordination: state.config.coordination.as_ref(),
206 fetched_base: state.fetched_base.as_ref(),
207 cwd: scratch,
208 prompt: &prompt_text,
209 timeout: Duration::from_secs(state.config.graph.timeout_fix),
210 allow_write: true,
211 sessions: state.config.graph.sessions,
212 artifacts: &artifacts,
213 stem: &stem,
214 run: &state.id,
215 node: "rebase",
216 cache_dir: state.config.cache_dir().as_deref(),
217 attachments: &[],
218 writable: &[],
219 },
220 )
221 .await;
222 let seat_name = seat.key.clone();
223 state.seats.insert(seat.key.clone(), seat);
224 if let Some(next) = ids.get(i + 1)
225 && agent::chain_advances(&out)
226 {
227 let (class, why) = crate::fixer::failure_of(&out);
228 crate::graph::record_handover(
229 state, "rebase", &seat_name, &spec.id, next, &class, &why,
230 );
231 continue;
232 }
233 last = Some((out, seat_name));
234 break;
235 }
236 let (out, seat_name) = last.expect("the fixer chain always has an entry");
237
238 let mut error = None;
239 let mut quota = false;
240 match out {
241 Ok(o) if o.quota_exhausted() => {
242 state.quota.push(QuotaLoss {
243 seat: seat_name,
244 node: "rebase".to_owned(),
245 at: Timestamp::now(),
246 reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
247 });
248 error = Some("rate limited (quota); the fixer could not run".to_owned());
249 quota = true;
250 }
251 Ok(o) if !o.usable() => {
252 error = Some(format!(
253 "the fixer produced nothing usable ({})",
254 crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
255 ));
256 }
257 Ok(_) => {}
258 Err(e) => error = Some(format!("{e:#}")),
259 }
260
261 if let Some(r) = state.rebase_fixes.last_mut() {
262 r.error = error.clone();
263 }
264 if !quota {
265 if let Launch::Nothing(why) = launcher_stage(state, scratch, &orig, &onto_sha).await? {
268 state.event("rebase", format!("launcher staging skipped: {why}"));
269 }
270 }
271 let finished = !git::rebase_in_progress(scratch).await;
272 if let Some(r) = state.rebase_fixes.last_mut() {
273 r.finished = finished;
274 }
275 state.save()?;
276
277 if quota {
278 let paths = git::unmerged_paths(scratch).await.unwrap_or_default();
280 let why = reason(
281 state.rebase_fixes.len(),
282 cap,
283 &paths,
284 &said,
285 "the fixer hit its rate limit",
286 );
287 abandon(&repo, scratch, branch, &orig).await;
288 return Ok(Rebased::Stopped(why));
289 }
290 }
291}
292
293#[derive(Debug)]
295enum Launch {
296 Nothing(String),
298 Advanced,
300}
301
302async fn rebase_head(scratch: &Path) -> Option<String> {
304 git::git(scratch, &["rev-parse", "-q", "--verify", "REBASE_HEAD"])
305 .await
306 .ok()
307 .filter(|s| !s.is_empty())
308}
309
310fn infra_stop(state: &mut RunState, detail: String) -> anyhow::Error {
313 if let Some(r) = state.rebase_fixes.last_mut() {
314 r.infra = Some(detail.clone());
315 }
316 block(state, "git metadata write", detail)
317}
318
319fn block(state: &mut RunState, kind: &str, detail: String) -> anyhow::Error {
320 let detail = format!("rebase recovery: {detail}");
321 state.resource_block = Some(crate::run::ResourceBlock {
322 cause: crate::run::ResourceBlockCause::Other,
323 node: "rebase".into(),
324 kind: kind.into(),
325 detail: detail.clone(),
326 at: Timestamp::now(),
327 });
328 state.event("resource", detail.clone());
329 state.save().ok();
330 crate::repo_lock::ResourceBlocked(detail).into()
331}
332
333async fn status_entries(scratch: &Path) -> Result<Vec<(String, String)>> {
335 let out = git::git_raw(
336 scratch,
337 &[
338 "status",
339 "--porcelain=v1",
340 "-z",
341 "--untracked-files=all",
342 "--no-renames",
343 ],
344 )
345 .await?;
346 anyhow::ensure!(out.ok(), "git status failed: {}", out.stderr);
347 Ok(out
348 .stdout
349 .split('\0')
350 .filter(|e| e.len() > 3)
351 .map(|e| (e[..2].to_owned(), e[3..].to_owned()))
352 .collect())
353}
354
355async fn index_outside(scratch: &Path, expected: &[String]) -> Result<String> {
358 let out = git::git_raw(
359 scratch,
360 &[
361 "diff",
362 "--cached",
363 "--raw",
364 "-z",
365 "--no-renames",
366 "--no-abbrev",
367 ],
368 )
369 .await?;
370 anyhow::ensure!(out.ok(), "git diff --cached failed: {}", out.stderr);
371 Ok(outside_diff(&out.stdout, expected))
372}
373
374fn outside_diff(raw: &str, expected: &[String]) -> String {
375 let mut parts = raw.split('\0').filter(|p| !p.is_empty());
376 let mut lines = Vec::new();
377 while let (Some(meta), Some(path)) = (parts.next(), parts.next()) {
378 if !expected.iter().any(|e| e == path) {
379 lines.push(format!("{meta}\t{path}"));
380 }
381 }
382 lines.join("\n")
383}
384
385async fn same_stop(state: &RunState, scratch: &Path) -> bool {
392 let Some(last) = state.rebase_fixes.last() else {
393 return false;
394 };
395 match &last.stop {
396 Some(stop) => rebase_head(scratch).await.as_deref() == Some(stop.as_str()),
397 None => {
398 !last.paths.is_empty()
399 && state
400 .rebase_fixes
401 .iter()
402 .all(|r| r.stop.is_none() && r.paths == last.paths)
403 }
404 }
405}
406
407fn stop_records(state: &RunState) -> &[RebaseFixRecord] {
410 let Some(last) = state.rebase_fixes.last() else {
411 return &[];
412 };
413 let start = state
414 .rebase_fixes
415 .iter()
416 .rposition(|r| r.stop != last.stop || (r.stop.is_none() && r.paths != last.paths))
417 .map_or(0, |i| i + 1);
418 &state.rebase_fixes[start..]
419}
420
421async fn stop_rounds(state: &RunState, scratch: &Path) -> usize {
422 if same_stop(state, scratch).await {
423 stop_records(state).len()
424 } else {
425 0
426 }
427}
428
429async fn round_baseline(state: &mut RunState, scratch: &Path, paths: &[String]) -> Result<String> {
430 if same_stop(state, scratch).await {
431 if let Some(before) = &stop_records(state)[0].index_outside {
432 return Ok(before.clone());
433 }
434 return Err(infra_stop(state,
435 "the saved stop has no original outside-index baseline; keep the resolution and have the original owner establish a verified baseline before continuation".into()));
436 }
437 index_outside(scratch, paths)
438 .await
439 .map_err(|e| infra_stop(state, format!("cannot capture index baseline: {e:#}")))
440}
441
442async fn legacy_baseline(
446 scratch: &Path,
447 paths: &[String],
448 lease: Option<crate::repo_lock::SeatLease>,
449) -> Result<(String, String, String)> {
450 let head = git::rev_parse(scratch, "HEAD").await?;
451 let pick = git::rev_parse(scratch, "REBASE_HEAD").await?;
452 let parent = git::rev_parse(scratch, "REBASE_HEAD^").await?;
453 let out = mutate(
454 scratch,
455 &[
456 "merge-tree",
457 "--write-tree",
458 "--name-only",
459 "--no-messages",
460 "-z",
461 &format!("--merge-base={parent}"),
462 &head,
463 &pick,
464 ],
465 lease,
466 )
467 .await?;
468 anyhow::ensure!(
469 out.code == Some(1),
470 "cannot reconstruct legacy conflict baseline: {}",
471 out.stderr
472 );
473 let mut fields = out.stdout.split('\0').filter(|p| !p.is_empty());
474 let tree = fields.next().context("merge-tree returned no tree")?;
475 let mut conflicts: Vec<&str> = fields.collect();
476 conflicts.sort_unstable();
477 let mut expected: Vec<&str> = paths.iter().map(String::as_str).collect();
478 expected.sort_unstable();
479 anyhow::ensure!(
480 conflicts == expected,
481 "reconstructed conflict paths differ from the saved stop"
482 );
483 let out = git::git_raw(
484 scratch,
485 &[
486 "diff",
487 "--raw",
488 "-z",
489 "--no-renames",
490 "--no-abbrev",
491 &head,
492 tree,
493 ],
494 )
495 .await?;
496 anyhow::ensure!(
497 out.ok(),
498 "cannot read reconstructed baseline: {}",
499 out.stderr
500 );
501 Ok((outside_diff(&out.stdout, paths), head, pick))
502}
503
504async fn rebase_target(scratch: &Path) -> Result<String> {
505 let path = git::git(scratch, &["rev-parse", "--git-path", "rebase-merge/onto"]).await?;
506 let target = std::fs::read_to_string(scratch.join(path))?;
507 git::rev_parse(scratch, &format!("{}^{{commit}}", target.trim())).await
508}
509
510async fn stop_identity(scratch: &Path, original: &str, onto: &str) -> Result<()> {
511 for (name, expected) in [("orig-head", original), ("onto", onto)] {
512 let path = git::git(
513 scratch,
514 &["rev-parse", "--git-path", &format!("rebase-merge/{name}")],
515 )
516 .await?;
517 let path = Path::new(&path);
518 let path = if path.is_absolute() {
519 path.to_path_buf()
520 } else {
521 scratch.join(path)
522 };
523 anyhow::ensure!(
524 std::fs::read_to_string(path)?.trim() == expected,
525 "rebase {name} changed"
526 );
527 }
528 Ok(())
529}
530
531async fn mutate(
534 scratch: &Path,
535 args: &[&str],
536 lease: Option<crate::repo_lock::SeatLease>,
537) -> Result<git::GitOut> {
538 use crate::proc::Quiet as _;
539 use std::process::Stdio;
540 use tokio::io::AsyncReadExt as _;
541 let wait = crate::repo_lock::WaitPolicy::current();
542 anyhow::ensure!(
543 !wait.cancelled(),
544 crate::repo_lock::ResourceBlocked("launcher Git operation cancelled".into())
545 );
546 let mut command = tokio::process::Command::new("git");
547 command
548 .quiet()
549 .args(args)
550 .current_dir(scratch)
551 .env("GIT_TERMINAL_PROMPT", "0")
552 .env("GIT_EDITOR", "true")
553 .stdin(Stdio::null())
554 .stdout(Stdio::piped())
555 .stderr(Stdio::piped())
556 .kill_on_drop(true);
557 let outputs = crate::retention::OutputLease::borrow(scratch)?
558 .into_iter()
559 .collect();
560 let mut running = crate::seat_process::Running::spawn_operation(command, lease, outputs)?;
561 let mut stdout = running.child().stdout.take().context("Git stdout")?;
562 let mut stderr = running.child().stderr.take().context("Git stderr")?;
563 let mut out = Vec::new();
564 let mut err = Vec::new();
565 let cancelled = async {
566 while !wait.cancelled() {
567 tokio::time::sleep(Duration::from_millis(50)).await;
568 }
569 };
570 let (status, _, _) = tokio::select! {
571 result = async { tokio::try_join!(
572 running.child().wait(),
573 stdout.read_to_end(&mut out),
574 stderr.read_to_end(&mut err)
575 ) } => result?,
576 () = cancelled => return Err(crate::repo_lock::ResourceBlocked("launcher Git operation cancelled".into()).into()),
577 };
578 running.finish().await;
579 Ok(git::GitOut {
580 code: status.code(),
581 stdout: String::from_utf8_lossy(&out).into_owned(),
582 stderr: String::from_utf8_lossy(&err).into_owned(),
583 })
584}
585
586async fn launcher_stage(
599 state: &mut RunState,
600 scratch: &Path,
601 original: &str,
602 onto: &str,
603) -> Result<Launch> {
604 if !git::rebase_in_progress(scratch).await {
605 return Ok(Launch::Nothing("no rebase in progress".into()));
606 }
607 if !same_stop(state, scratch).await {
608 return Ok(Launch::Nothing(
609 "no fixer round is recorded for this stop point".into(),
610 ));
611 }
612 let wait = {
613 let inherited = crate::repo_lock::WaitPolicy::current();
614 crate::repo_lock::WaitPolicy::new(
615 Duration::from_secs(state.config.graph.timeout_resource_wait),
616 move || inherited.cancelled(),
617 )
618 };
619 if wait.cancelled() {
620 return Err(block(
621 state,
622 "repository coordination",
623 "cancelled before staging; the resolved files are kept".into(),
624 ));
625 }
626 let lease = crate::repo_lock::SeatLease::operation_with(
627 scratch,
628 state.config.coordination.as_ref(),
629 &wait,
630 )
631 .await;
632 let lease = match lease {
633 Ok(l) => l,
634 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => {
635 return Err(block(state, "repository coordination", e.to_string()));
636 }
637 Err(e) => return Err(e),
638 };
639
640 if !git::rebase_in_progress(scratch).await || !same_stop(state, scratch).await {
643 return Ok(Launch::Nothing("the stop point changed".into()));
644 }
645 if let Err(e) = stop_identity(scratch, original, onto).await {
646 return Err(infra_stop(state, format!("{e:#}")));
647 }
648 let last = stop_records(state)[0].clone();
649 let lease = if last.index_outside.is_none() {
650 let (baseline, head, pick) = legacy_baseline(scratch, &last.paths, lease)
651 .await
652 .map_err(|e| infra_stop(state, format!("legacy baseline recovery: {e:#}")))?;
653 let lease = crate::repo_lock::SeatLease::operation_with(
656 scratch,
657 state.config.coordination.as_ref(),
658 &wait,
659 )
660 .await
661 .map_err(|e| block(state, "repository coordination", e.to_string()))?;
662 let validation = async {
663 stop_identity(scratch, original, onto).await?;
664 anyhow::ensure!(
665 same_stop(state, scratch).await
666 && git::rev_parse(scratch, "HEAD").await? == head
667 && rebase_head(scratch).await.as_deref() == Some(&pick),
668 "the stop changed during legacy baseline recovery"
669 );
670 anyhow::ensure!(
671 index_outside(scratch, &last.paths).await? == baseline,
672 "the legacy index differs from the reconstructed clean baseline"
673 );
674 Ok::<_, anyhow::Error>(())
675 }
676 .await;
677 if let Err(e) = validation {
678 return Err(infra_stop(
679 state,
680 format!("legacy baseline recovery: {e:#}"),
681 ));
682 }
683 let first = state.rebase_fixes.len() - stop_records(state).len();
684 state.rebase_fixes[first].index_outside = Some(baseline);
685 for record in &mut state.rebase_fixes[first..] {
686 record.stop = Some(pick.clone());
687 }
688 state.event(
689 "rebase",
690 "verified and saved the legacy stop baseline from the original three-way merge",
691 );
692 state.save()?;
693 lease
694 } else {
695 lease
696 };
697 if last.stop.is_none() {
698 let pick = rebase_head(scratch)
699 .await
700 .context("legacy stop has no REBASE_HEAD")?;
701 let first = state.rebase_fixes.len() - stop_records(state).len();
702 for record in &mut state.rebase_fixes[first..] {
703 record.stop = Some(pick.clone());
704 }
705 state.save()?;
706 }
707 let baseline = round_baseline(state, scratch, &last.paths).await?;
708 let unmerged = match git::unmerged_paths(scratch).await {
709 Ok(u) => u,
710 Err(e) => return Err(infra_stop(state, format!("cannot read the index: {e:#}"))),
711 };
712 if let Some(p) = unmerged.iter().find(|p| !last.paths.contains(p)) {
713 return Ok(Launch::Nothing(format!(
714 "`{p}` is unmerged but was not part of the recorded conflict"
715 )));
716 }
717 for p in &last.paths {
718 match std::fs::symlink_metadata(scratch.join(p)) {
719 Ok(m) if m.is_dir() => {
720 return Ok(Launch::Nothing(format!("`{p}` is a directory")));
721 }
722 Ok(m) if m.is_file() => {
723 if let Err(e) = std::fs::read(scratch.join(p)) {
724 return Err(infra_stop(state, format!("cannot read `{p}`: {e}")));
725 }
726 if has_markers(scratch, p, &["HEAD", "REBASE_HEAD"]).await? {
727 return Ok(Launch::Nothing(format!(
728 "`{p}` still holds conflict markers"
729 )));
730 }
731 }
732 Ok(_) => {}
733 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
735 Err(e) => return Err(infra_stop(state, format!("cannot inspect `{p}`: {e}"))),
736 }
737 }
738 let entries = match status_entries(scratch).await {
739 Ok(e) => e,
740 Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
741 };
742 let stray: Vec<&str> = entries
745 .iter()
746 .filter(|(_, p)| !last.paths.contains(p))
747 .filter(|(xy, _)| xy == "??" || !xy.ends_with(' '))
748 .map(|(_, p)| p.as_str())
749 .collect();
750 if !stray.is_empty() {
751 return Ok(Launch::Nothing(format!(
752 "changes outside the conflicted paths: {}",
753 stray.join(", ")
754 )));
755 }
756 match index_outside(scratch, &last.paths).await {
757 Ok(now) if now == baseline => {}
758 Ok(_) => {
759 return Ok(Launch::Nothing(
760 "the staged changes outside the conflicted paths changed".into(),
761 ));
762 }
763 Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
764 }
765
766 let set = |state: &mut RunState, step| {
767 if let Some(r) = state.rebase_fixes.last_mut() {
768 r.launcher = Some(step);
769 }
770 state.save()
771 };
772 let to_stage: Vec<&str> = last
776 .paths
777 .iter()
778 .filter(|p| {
779 unmerged.contains(p)
780 || entries
781 .iter()
782 .any(|(xy, path)| path == *p && !xy.ends_with(' '))
783 })
784 .map(String::as_str)
785 .collect();
786 if !to_stage.is_empty() {
787 set(state, crate::run::LauncherStep::Staging)?;
788 let mut args = vec!["--literal-pathspecs", "add", "-A", "--"];
789 args.extend(to_stage);
790 match mutate(scratch, &args, lease).await {
791 Ok(o) if o.ok() => {}
792 Ok(o) => {
793 let said = if o.stderr.is_empty() {
794 o.stdout
795 } else {
796 o.stderr
797 };
798 return Err(infra_stop(state, format!("staging was refused: {said}")));
799 }
800 Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
801 }
802 match git::unmerged_paths(scratch).await {
803 Ok(left) if left.is_empty() => {}
804 Ok(left) => {
805 return Err(infra_stop(
806 state,
807 format!("paths stayed unmerged after staging: {}", left.join(", ")),
808 ));
809 }
810 Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
811 }
812 return Box::pin(launcher_stage(state, scratch, original, onto)).await;
815 }
816 set(state, crate::run::LauncherStep::Staged)?;
817 let before = rebase_head(scratch).await;
818 set(state, crate::run::LauncherStep::Continuing)?;
819 let out = match mutate(scratch, &["rebase", "--continue"], lease).await {
820 Ok(o) => o,
821 Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
822 };
823 if !out.ok() && git::rebase_in_progress(scratch).await {
824 let again = git::unmerged_paths(scratch).await.unwrap_or_default();
825 if again.is_empty() || rebase_head(scratch).await == before {
828 let said = git::rebase_said(&if out.stderr.is_empty() {
829 out.stdout
830 } else {
831 out.stderr
832 });
833 return Err(infra_stop(state, format!("continue failed: {said}")));
834 }
835 }
836 state.event(
837 "rebase",
838 format!(
839 "launcher staged {} path(s) and continued the rebase",
840 unmerged.len()
841 ),
842 );
843 Ok(Launch::Advanced)
844}
845
846async fn finish(
848 state: &mut RunState,
849 scratch: &Path,
850 branch: &str,
851 orig: &str,
852 onto_sha: &str,
853 touched: &[String],
854 said: &str,
855) -> Result<Rebased> {
856 let repo = state.repo.clone();
857 let spent = state.rebase_fixes.len();
858 let cap = state.config.graph.review_rounds;
859 let unmerged = git::unmerged_paths(scratch).await.unwrap_or_default();
860 let head = git::rev_parse(scratch, "HEAD").await.unwrap_or_default();
861 let mut candidates: Vec<String> = touched.to_vec();
866 if let Ok(changed) = git::git(scratch, &["diff", "--name-only", onto_sha, "HEAD"]).await {
867 for p in changed.lines().map(str::trim).filter(|l| !l.is_empty()) {
868 if !candidates.iter().any(|c| c == p) {
869 candidates.push(p.to_owned());
870 }
871 }
872 }
873 let mut marked = Vec::new();
874 for p in candidates {
875 if has_markers(scratch, &p, &[orig, onto_sha]).await? {
876 marked.push(p);
877 }
878 }
879
880 let emptied = head == onto_sha
885 && git::cherry(&repo, onto_sha, orig)
886 .await
887 .map_or(true, |(unmatched, _)| !unmatched.is_empty());
888
889 let dropped = if unmerged.is_empty() && marked.is_empty() && !emptied && !head.is_empty() {
894 dropped_commits(&repo, scratch, onto_sha, orig, &head).await
895 } else {
896 Ok(Vec::new())
897 };
898
899 let problem = if !unmerged.is_empty() {
900 Some(("paths are still unmerged", unmerged))
901 } else if !marked.is_empty() {
902 Some(("conflict markers were left in the tree", marked))
903 } else if emptied {
904 Some((
905 "the rebase ended with none of the branch's commits applied (all skipped)",
906 touched.to_vec(),
907 ))
908 } else if head.is_empty() || !git::is_ancestor(&repo, onto_sha, &head).await {
909 Some((
910 "the rebase ended without the base in the result (abandoned or skipped)",
911 touched.to_vec(),
912 ))
913 } else if let Err(error) = &dropped {
914 Some((
915 "the rebase record could not be verified",
916 vec![format!("{error:#}")],
917 ))
918 } else if !dropped.as_ref().unwrap().is_empty() {
919 Some((
920 "the rebase dropped some of the branch's commits (skipped?)",
921 dropped.unwrap(),
922 ))
923 } else {
924 None
925 };
926 match problem {
927 None => {
928 git::worktree_remove(&repo, scratch).await.ok();
929 state.event(
930 "rebase",
931 format!("{branch} rebased after {spent} fixer round(s)"),
932 );
933 state.save()?;
934 Ok(Rebased::Applied)
935 }
936 Some((what, paths)) => {
937 let why = reason(spent, cap, &paths, said, what);
938 abandon(&repo, scratch, branch, orig).await;
939 Ok(Rebased::Stopped(why))
940 }
941 }
942}
943
944async fn dropped_commits(
947 repo: &Path,
948 scratch: &Path,
949 onto_sha: &str,
950 orig: &str,
951 head: &str,
952) -> Result<Vec<String>> {
953 let record = git::rebase_record(scratch).await?;
954 anyhow::ensure!(
955 record.heads.last().map(String::as_str) == Some(head),
956 "rebase final HEAD disagrees with record"
957 );
958 let mut previous = onto_sha;
959 let mut lost = Vec::new();
960 for (sha, picked_head) in record.picks.iter().zip(&record.heads) {
961 if picked_head == previous
962 && !absorbed_in_result(repo, sha, onto_sha, orig, head).await?
963 && !already_in_result(repo, sha, orig, head).await?
964 {
965 lost.push(git::git(repo, &["show", "-s", "--format=%s", sha]).await?);
966 }
967 previous = picked_head;
968 }
969 Ok(lost)
970}
971
972async fn commit_paths(repo: &Path, sha: &str) -> Result<Vec<String>> {
975 git::git(
976 repo,
977 &[
978 "diff-tree",
979 "--no-commit-id",
980 "--name-only",
981 "-r",
982 "--root",
983 "-z",
984 sha,
985 ],
986 )
987 .await
988 .map(|o| {
989 o.split('\0')
990 .filter(|l| !l.is_empty())
991 .map(str::to_owned)
992 .collect()
993 })
994}
995
996async fn entry(repo: &Path, rev: &str, path: &str) -> Result<Option<String>> {
999 let out = git::git(repo, &["--literal-pathspecs", "ls-tree", rev, "--", path]).await?;
1000 Ok(out
1001 .split('\t')
1002 .next()
1003 .filter(|e| !e.is_empty())
1004 .map(str::to_owned))
1005}
1006
1007async fn already_in_result(repo: &Path, sha: &str, orig: &str, head: &str) -> Result<bool> {
1016 if replay_is_noop(repo, sha, head).await? {
1017 return Ok(true);
1018 }
1019 for p in &commit_paths(repo, sha).await? {
1020 let got = entry(repo, head, p).await?;
1021 if got != entry(repo, sha, p).await? && got != entry(repo, orig, p).await? {
1022 return Ok(false);
1023 }
1024 }
1025 Ok(true)
1026}
1027
1028async fn replay_is_noop(repo: &Path, sha: &str, head: &str) -> Result<bool> {
1031 let base = format!("{sha}^");
1032 let out = git::git_raw(
1033 repo,
1034 &[
1035 "merge-tree",
1036 "--write-tree",
1037 &format!("--merge-base={base}"),
1038 head,
1039 sha,
1040 ],
1041 )
1042 .await?;
1043 anyhow::ensure!(
1044 out.code == Some(0) || out.code == Some(1),
1045 "merge-tree failed: {}",
1046 out.stderr
1047 );
1048 if !out.ok() {
1049 return Ok(false);
1050 }
1051 let merged = out
1052 .stdout
1053 .lines()
1054 .next()
1055 .context("merge-tree returned no tree")?
1056 .trim();
1057 Ok(merged == git::tree_of(repo, head).await?)
1058}
1059
1060async fn absorbed_in_result(
1067 repo: &Path,
1068 sha: &str,
1069 onto: &str,
1070 orig: &str,
1071 head: &str,
1072) -> Result<bool> {
1073 if !replay_is_noop(repo, sha, onto).await? {
1074 return Ok(false);
1075 }
1076 let paths = commit_paths(repo, sha).await?;
1077 if paths.is_empty() {
1078 return Ok(false);
1079 }
1080 let base = path_tree(repo, sha, &paths).await?;
1081 let upstream = path_tree(repo, onto, &paths).await?;
1082 let branch = path_tree(repo, orig, &paths).await?;
1083 let result = path_tree(repo, head, &paths).await?;
1084 let out = git::git_raw(
1085 repo,
1086 &[
1087 "merge-tree",
1088 "--write-tree",
1089 &format!("--merge-base={base}"),
1090 &upstream,
1091 &branch,
1092 ],
1093 )
1094 .await?;
1095 anyhow::ensure!(
1096 out.code == Some(0) || out.code == Some(1),
1097 "merge-tree failed: {}",
1098 out.stderr
1099 );
1100 if !out.ok() {
1101 return Ok(false);
1102 }
1103 Ok(out
1104 .stdout
1105 .lines()
1106 .next()
1107 .context("merge-tree returned no tree")?
1108 == result)
1109}
1110
1111async fn path_tree(repo: &Path, rev: &str, paths: &[String]) -> Result<String> {
1116 use crate::proc::Quiet as _;
1117 use std::fmt::Write as _;
1118 use std::process::Stdio;
1119 use tokio::io::AsyncWriteExt as _;
1120
1121 let mut entries = String::new();
1122 for (i, path) in paths.iter().enumerate() {
1123 let out = git::git(
1124 repo,
1125 &["--literal-pathspecs", "ls-tree", "-z", rev, "--", path],
1126 )
1127 .await?;
1128 if !out.is_empty() {
1129 let (entry, _) = out
1130 .split_once('\t')
1131 .context("ls-tree returned an invalid entry")?;
1132 writeln!(entries, "{entry}\t{i:016x}")?;
1133 }
1134 }
1135 #[cfg(test)]
1136 crate::web::request_cost::git();
1137 let mut child = tokio::process::Command::new("git")
1138 .args(["mktree"])
1139 .current_dir(repo)
1140 .quiet()
1141 .stdin(Stdio::piped())
1142 .stdout(Stdio::piped())
1143 .stderr(Stdio::piped())
1144 .kill_on_drop(true)
1145 .spawn()?;
1146 let mut stdin = child.stdin.take().context("mktree stdin missing")?;
1147 stdin.write_all(entries.as_bytes()).await?;
1148 drop(stdin);
1149 let out = child.wait_with_output().await?;
1150 anyhow::ensure!(
1151 out.status.success(),
1152 "mktree failed: {}",
1153 String::from_utf8_lossy(&out.stderr)
1154 );
1155 Ok(String::from_utf8(out.stdout)?.trim().to_owned())
1156}
1157
1158async fn abandon(repo: &Path, scratch: &Path, branch: &str, orig: &str) {
1161 git::rebase_abort(repo, scratch).await;
1162 let full = format!("refs/heads/{branch}");
1163 if git::rev_parse(repo, &full).await.ok().as_deref() != Some(orig) {
1164 git::git_raw(repo, &["update-ref", &full, orig]).await.ok();
1165 }
1166}
1167
1168fn reason(spent: usize, cap: usize, paths: &[String], said: &str, what: &str) -> String {
1171 let shown: Vec<&str> = paths
1172 .iter()
1173 .take(PATHS_IN_REASON)
1174 .map(String::as_str)
1175 .collect();
1176 let mut list = shown.join(", ");
1177 if paths.len() > shown.len() {
1178 list.push_str(&format!(" and {} more", paths.len() - shown.len()));
1179 }
1180 if list.is_empty() {
1181 list.push_str("none recorded");
1182 }
1183 let mut s = format!(
1184 "conflict not resolved after {spent} of {cap} fixer round(s) ({what}); remaining \
1185 conflicted path(s): {list}"
1186 );
1187 let said = git::rebase_said(said);
1188 let said = said.trim();
1189 if !said.is_empty() {
1190 s.push_str("; git said: ");
1191 s.extend(said.chars().take(250));
1192 }
1193 s
1194}
1195
1196async fn subjects(worktree: &Path, range: &str) -> Vec<String> {
1198 let n = format!("-n{SUBJECTS}");
1199 git::git(worktree, &["log", "--format=%s", &n, range])
1200 .await
1201 .map(|o| o.lines().map(str::to_owned).collect())
1202 .unwrap_or_default()
1203}
1204
1205async fn has_markers(worktree: &Path, path: &str, sides: &[&str]) -> Result<bool> {
1209 let Ok(text) = std::fs::read_to_string(worktree.join(path)) else {
1210 return Ok(false);
1211 };
1212 if text
1213 .lines()
1214 .any(|l| l.starts_with("<<<<<<<") || l.starts_with(">>>>>>>") || l.starts_with("|||||||"))
1215 {
1216 return Ok(true);
1217 }
1218 fn separators(text: &str) -> std::collections::BTreeMap<String, usize> {
1219 let mut counts = std::collections::BTreeMap::new();
1220 let mut previous = "";
1221 for line in text.lines() {
1222 if line == "=======" {
1223 *counts.entry(previous.to_owned()).or_default() += 1;
1224 }
1225 previous = line;
1226 }
1227 counts
1228 }
1229 let current = separators(&text);
1230 if current.is_empty() {
1231 return Ok(false);
1232 }
1233 let mut existing = std::collections::BTreeMap::<String, usize>::new();
1234 for side in sides {
1235 let spec = format!("{side}:{path}");
1238 let exists = git::git_raw(worktree, &["cat-file", "-e", &spec]).await?;
1239 if exists.ok() {
1240 for (heading, count) in separators(&git::git(worktree, &["show", &spec]).await?) {
1241 let before = existing.entry(heading).or_default();
1242 *before = (*before).max(count);
1243 }
1244 } else {
1245 git::rev_parse(worktree, &format!("{side}^{{tree}}")).await?;
1248 }
1249 }
1250 Ok(current
1251 .into_iter()
1252 .any(|(heading, count)| count > existing.get(&heading).copied().unwrap_or(0)))
1253}
1254
1255fn hunks(worktree: &Path, paths: &[String]) -> String {
1258 let mut out = String::new();
1259 for p in paths {
1260 if out.len() >= HUNK_TOTAL {
1261 out.push_str("\n(more conflicted files omitted)\n");
1262 break;
1263 }
1264 out.push_str(&format!("=== {p} ===\n"));
1265 let Ok(text) = std::fs::read_to_string(worktree.join(p)) else {
1266 out.push_str("(not readable as text; use git to inspect it)\n");
1267 continue;
1268 };
1269 let mut file = String::new();
1270 let mut inside = false;
1271 for line in text.lines() {
1272 if line.starts_with("<<<<<<< ") {
1273 inside = true;
1274 }
1275 if inside {
1276 file.push_str(line);
1277 file.push('\n');
1278 }
1279 if line.starts_with(">>>>>>> ") {
1280 inside = false;
1281 }
1282 }
1283 if file.len() > HUNK_PER_FILE {
1284 file = file.chars().take(HUNK_PER_FILE).collect();
1285 file.push_str("\n(truncated)\n");
1286 }
1287 out.push_str(&file);
1288 }
1289 out
1290}
1291
1292#[cfg(test)]
1293mod tests {
1294 use super::*;
1295 use crate::proc::Quiet as _;
1296 use std::path::PathBuf;
1297
1298 fn sh(dir: &Path, args: &[&str]) {
1299 let o = std::process::Command::new("git")
1300 .quiet()
1301 .args(args)
1302 .current_dir(dir)
1303 .output()
1304 .unwrap();
1305 assert!(
1306 o.status.success(),
1307 "{args:?}: {}",
1308 String::from_utf8_lossy(&o.stderr)
1309 );
1310 }
1311
1312 async fn record(d: &Path, picks: &[String], heads: &[String]) {
1313 for (name, values) in [("magi-rebase-picks", picks), ("magi-rebase-heads", heads)] {
1314 let path = git::git(d, &["rev-parse", "--git-path", name])
1315 .await
1316 .unwrap();
1317 std::fs::write(d.join(path), values.join("\n") + "\n").unwrap();
1318 }
1319 }
1320
1321 #[tokio::test]
1322 async fn a_lost_mode_change_is_not_already_in_the_result() {
1323 let t = tempfile::tempdir().unwrap();
1324 let d = t.path();
1325 sh(d, &["init", "-q", "-b", "main"]);
1326 sh(d, &["config", "user.name", "t"]);
1327 sh(d, &["config", "user.email", "t@example.com"]);
1328 sh(d, &["config", "core.fileMode", "true"]);
1329 std::fs::write(d.join("script.sh"), "echo\n").unwrap();
1330 sh(d, &["add", "-A"]);
1331 sh(d, &["commit", "-q", "-m", "base"]);
1332 sh(d, &["update-index", "--chmod=+x", "script.sh"]);
1333 sh(d, &["commit", "-q", "-m", "chmod"]);
1334 let sha = git::git(d, &["rev-parse", "HEAD"]).await.unwrap();
1335 let base = git::git(d, &["rev-parse", "HEAD~1"]).await.unwrap();
1336 assert!(!already_in_result(d, &sha, &sha, &base).await.unwrap());
1338 assert!(already_in_result(d, &sha, &sha, &sha).await.unwrap());
1339 }
1340
1341 async fn commit(d: &Path, msg: &str) -> String {
1342 sh(d, &["add", "-A"]);
1343 sh(d, &["commit", "-q", "-m", msg]);
1344 git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
1345 }
1346
1347 #[tokio::test]
1348 async fn candidate_path_trees_preserve_modes_deletions_and_unusual_names() {
1349 let t = tempfile::tempdir().unwrap();
1350 let d = t.path();
1351 sh(d, &["init", "-q", "-b", "main"]);
1352 sh(d, &["config", "user.name", "t"]);
1353 sh(d, &["config", "user.email", "t@example.com"]);
1354 sh(d, &["config", "core.fileMode", "true"]);
1355 let path = if cfg!(windows) {
1356 "nested/日本語 [literal].txt"
1357 } else {
1358 "nested/日本語\tline\n.txt"
1359 };
1360 std::fs::create_dir(d.join("nested")).unwrap();
1361 std::fs::write(d.join(path), "contents\n").unwrap();
1362 std::fs::write(d.join("g.txt"), "old\n").unwrap();
1363 let base = commit(d, "base").await;
1364 let paths = vec![path.to_owned()];
1365 let tree = path_tree(d, &base, &paths).await.unwrap();
1366 std::fs::write(d.join("g.txt"), "unrelated\n").unwrap();
1367 let other = commit(d, "unrelated path").await;
1368 assert_eq!(path_tree(d, &other, &paths).await.unwrap(), tree);
1369 sh(d, &["update-index", "--chmod=+x", path]);
1370 sh(d, &["commit", "-q", "-m", "chmod"]);
1371 assert_ne!(path_tree(d, "HEAD", &paths).await.unwrap(), tree);
1372 std::fs::remove_file(d.join(path)).unwrap();
1373 let removed = commit(d, "delete").await;
1374 let absent = path_tree(d, &removed, &paths).await.unwrap();
1375 assert_ne!(absent, tree);
1376 assert_eq!(absent, path_tree(d, &removed, &[]).await.unwrap());
1377 assert!(path_tree(d, "not-a-revision", &paths).await.is_err());
1378 }
1379
1380 #[tokio::test]
1381 async fn a_change_inside_a_larger_upstream_edit_is_in_the_result() {
1382 let t = tempfile::tempdir().unwrap();
1383 let d = t.path();
1384 sh(d, &["init", "-q", "-b", "main"]);
1385 sh(d, &["config", "user.name", "t"]);
1386 sh(d, &["config", "user.email", "t@example.com"]);
1387 let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
1388 std::fs::write(d.join("f.txt"), body).unwrap();
1389 commit(d, "base").await;
1390 std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1391 let c = commit(d, "c1").await;
1392 sh(d, &["checkout", "-q", "-b", "up", "HEAD~1"]);
1393 let up = body.replacen("old", "new", 1).replace("end", "end plus");
1394 std::fs::write(d.join("f.txt"), up).unwrap();
1395 let head = commit(d, "upstream").await;
1396 assert!(already_in_result(d, &c, &c, &head).await.unwrap());
1397 }
1398
1399 #[tokio::test]
1400 async fn a_dropped_commit_on_a_non_ascii_path_is_still_noticed() {
1401 let t = tempfile::tempdir().unwrap();
1402 let d = t.path();
1403 sh(d, &["init", "-q", "-b", "main"]);
1404 sh(d, &["config", "user.name", "t"]);
1405 sh(d, &["config", "user.email", "t@example.com"]);
1406 std::fs::write(d.join("a.txt"), "a\n").unwrap();
1407 let base = commit(d, "base").await;
1408 std::fs::write(d.join("日本語.txt"), "x\n").unwrap();
1409 let c = commit(d, "c").await;
1410 assert!(!already_in_result(d, &c, &c, &base).await.unwrap());
1411 }
1412
1413 async fn commit_dated(d: &Path, msg: &str) -> String {
1414 sh(d, &["add", "-A"]);
1415 let st = std::process::Command::new("git")
1416 .quiet()
1417 .current_dir(d)
1418 .env("GIT_AUTHOR_DATE", "2020-01-01T00:00:00+0000")
1419 .env("GIT_COMMITTER_DATE", "2020-01-01T00:00:00+0000")
1420 .args(["commit", "-q", "-m", msg])
1421 .status()
1422 .unwrap();
1423 assert!(st.success());
1424 git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
1425 }
1426
1427 #[tokio::test]
1428 async fn a_recorded_resolved_pick_after_an_absorbed_one_is_not_lost() {
1429 let t = tempfile::tempdir().unwrap();
1430 let d = t.path();
1431 sh(d, &["init", "-q", "-b", "main"]);
1432 sh(d, &["config", "user.name", "t"]);
1433 sh(d, &["config", "user.email", "t@example.com"]);
1434 std::fs::write(d.join("z.txt"), "z\n").unwrap();
1435 let base = commit(d, "base").await;
1436 std::fs::write(d.join("a.txt"), "a\n").unwrap();
1437 commit_dated(d, "same").await;
1438 std::fs::write(d.join("b.txt"), "b\n").unwrap();
1439 let orig = commit_dated(d, "same").await;
1440 sh(d, &["checkout", "-q", "-b", "up", &base]);
1443 std::fs::write(d.join("a.txt"), "a\n").unwrap();
1444 std::fs::write(d.join("y.txt"), "y\n").unwrap();
1445 std::fs::write(d.join("b.txt"), "other\n").unwrap();
1446 let onto = commit(d, "upstream").await;
1447 std::fs::write(d.join("b.txt"), "other\nb\n").unwrap();
1449 let head = commit_dated(d, "same").await;
1450 record(
1451 d,
1452 &[
1453 git::rev_parse(d, &format!("{orig}^")).await.unwrap(),
1454 orig.clone(),
1455 ],
1456 &[onto.clone(), head.clone()],
1457 )
1458 .await;
1459 assert!(
1460 dropped_commits(d, d, &onto, &orig, &head)
1461 .await
1462 .unwrap()
1463 .is_empty()
1464 );
1465 }
1466
1467 #[tokio::test]
1468 async fn a_skipped_commit_is_not_masked_by_a_surviving_one_in_the_same_file() {
1469 let t = tempfile::tempdir().unwrap();
1470 let d = t.path();
1471 sh(d, &["init", "-q", "-b", "main"]);
1472 sh(d, &["config", "user.name", "t"]);
1473 sh(d, &["config", "user.email", "t@example.com"]);
1474 let body = "first\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
1475 std::fs::write(d.join("f.txt"), body).unwrap();
1476 let base = commit(d, "base").await;
1477 std::fs::write(d.join("f.txt"), body.replacen("first", "mine", 1)).unwrap();
1478 commit_dated(d, "same").await;
1479 let two = body
1480 .replacen("first", "mine", 1)
1481 .replacen("last", "tail", 1);
1482 std::fs::write(d.join("f.txt"), &two).unwrap();
1483 let orig = commit_dated(d, "same").await;
1484 sh(d, &["checkout", "-q", "-b", "up", &base]);
1485 std::fs::write(d.join("f.txt"), body.replacen("first", "theirs", 1)).unwrap();
1486 let onto = commit(d, "upstream").await;
1487 std::fs::write(
1489 d.join("f.txt"),
1490 body.replacen("first", "theirs", 1)
1491 .replacen("last", "tail", 1),
1492 )
1493 .unwrap();
1494 let head = commit_dated(d, "same").await;
1495 record(
1496 d,
1497 &[
1498 git::rev_parse(d, &format!("{orig}^")).await.unwrap(),
1499 orig.clone(),
1500 ],
1501 &[onto.clone(), head.clone()],
1502 )
1503 .await;
1504 assert_eq!(
1505 dropped_commits(d, d, &onto, &orig, &head).await.unwrap(),
1506 vec!["same"]
1507 );
1508 }
1509
1510 #[tokio::test]
1511 async fn git_records_a_skipped_middle_pick_after_an_applied_pick() {
1512 let t = tempfile::tempdir().unwrap();
1513 let d = t.path();
1514 sh(d, &["init", "-q", "-b", "main"]);
1515 sh(d, &["config", "user.name", "t"]);
1516 sh(d, &["config", "user.email", "t@example.com"]);
1517 std::fs::write(d.join("f"), "base\n").unwrap();
1518 let base = commit(d, "base").await;
1519 sh(d, &["checkout", "-q", "-b", "topic"]);
1520 std::fs::write(d.join("absorbed"), "upstream too\n").unwrap();
1521 commit(d, "already upstream").await;
1522 std::fs::write(d.join("a"), "first\n").unwrap();
1523 let first = commit(d, "first").await;
1524 std::fs::write(d.join("f"), "middle\n").unwrap();
1525 let middle = commit(d, "lost middle").await;
1526 std::fs::write(d.join("z"), "last\n").unwrap();
1527 let orig = commit(d, "last").await;
1528 sh(d, &["checkout", "-q", "main"]);
1529 std::fs::write(d.join("absorbed"), "upstream too\n").unwrap();
1530 commit(d, "patch twin").await;
1531 std::fs::write(d.join("f"), "upstream\n").unwrap();
1532 let onto = commit(d, "upstream").await;
1533 let scratch = d.join("scratch");
1534 let git::RebaseStart::Conflicted(why) = git::rebase_start(d, &scratch, "topic", &onto)
1535 .await
1536 .unwrap()
1537 else {
1538 panic!("expected a conflict");
1539 };
1540 assert!(!why.contains("Executing:"), "{why}");
1541 let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1542 assert!(skipped.ok(), "{}", skipped.stderr);
1543 let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1544 let record = git::rebase_record(&scratch).await.unwrap();
1545 assert_eq!(record.picks, vec![first, middle, orig.clone()]);
1546 assert_eq!(record.heads[0], record.heads[1]);
1547 assert_ne!(record.heads[1], record.heads[2]);
1548 assert_eq!(
1549 dropped_commits(d, &scratch, &onto, &orig, &head)
1550 .await
1551 .unwrap(),
1552 vec!["lost middle"]
1553 );
1554 git::rebase_abort(d, &scratch).await;
1555 assert!(git::is_ancestor(d, &base, &head).await);
1556 }
1557
1558 #[test]
1559 fn recording_diagnostics_do_not_hide_other_git_output() {
1560 assert_eq!(
1561 git::rebase_said("before\nExecuting: record\nafter\n"),
1562 "before\nafter"
1563 );
1564 }
1565
1566 async fn rebase_successive_edits(
1571 absorbed: bool,
1572 extra_hunk: bool,
1573 unrelated_conflict: bool,
1574 ) -> Rebased {
1575 rebase_successive_edits_record(absorbed, extra_hunk, unrelated_conflict, None).await
1576 }
1577
1578 async fn rebase_successive_edits_record(
1579 absorbed: bool,
1580 extra_hunk: bool,
1581 unrelated_conflict: bool,
1582 damage: Option<&str>,
1583 ) -> Rebased {
1584 crate::run::pin_test_home();
1585 let t = tempfile::tempdir().unwrap();
1586 let d = t.path();
1587 sh(d, &["init", "-q", "-b", "main"]);
1588 sh(d, &["config", "user.name", "t"]);
1589 sh(d, &["config", "user.email", "t@example.com"]);
1590 sh(d, &["config", "core.autocrlf", "false"]);
1593 let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
1594 std::fs::write(d.join("f.txt"), body).unwrap();
1595 std::fs::write(d.join("conflict.txt"), "old\n").unwrap();
1596 if unrelated_conflict {
1597 std::fs::write(d.join("g.txt"), "old\n").unwrap();
1598 }
1599 let base = commit(d, "base").await;
1600 sh(d, &["checkout", "-q", "-b", "topic"]);
1601 std::fs::write(d.join("conflict.txt"), "branch\n").unwrap();
1602 commit(d, "c0").await;
1603 let mut middle = body.replacen("old", "new", 1);
1604 if extra_hunk {
1605 middle = middle.replace("\n5\n", "\nmissing\n");
1606 }
1607 std::fs::write(d.join("f.txt"), &middle).unwrap();
1608 let c1 = commit(d, "c1").await;
1609 std::fs::write(d.join("f.txt"), middle.replacen("new", "newer", 1)).unwrap();
1610 if unrelated_conflict {
1611 std::fs::write(d.join("g.txt"), "branch\n").unwrap();
1612 }
1613 let orig = commit(d, "c2").await;
1614 sh(d, &["checkout", "-q", "main"]);
1615 std::fs::write(d.join("conflict.txt"), "branch plus upstream\n").unwrap();
1616 let mut upstream = body
1617 .replacen("old", if absorbed { "new" } else { "theirs" }, 1)
1618 .replace("end", "upstream tail");
1619 if extra_hunk {
1620 upstream = upstream.replace("\n5\n", "\nupstream middle\n");
1621 }
1622 std::fs::write(d.join("f.txt"), &upstream).unwrap();
1623 if unrelated_conflict {
1624 std::fs::write(d.join("g.txt"), "upstream\n").unwrap();
1625 }
1626 let onto = commit(d, "upstream composite").await;
1627 assert!(git::cherry(d, &onto, &orig).await.unwrap().0.contains(&c1));
1629 let scratch = d.join("scratch");
1630 assert!(matches!(
1631 git::rebase_start(d, &scratch, "topic", &onto)
1632 .await
1633 .unwrap(),
1634 git::RebaseStart::Conflicted(_)
1635 ));
1636 std::fs::write(
1637 scratch.join("conflict.txt"),
1638 "branch plus upstream resolved\n",
1639 )
1640 .unwrap();
1641 sh(&scratch, &["add", "conflict.txt"]);
1642 let continued = git::git_raw(
1643 &scratch,
1644 &["-c", "core.editor=true", "rebase", "--continue"],
1645 )
1646 .await
1647 .unwrap();
1648 if absorbed && !extra_hunk {
1649 if unrelated_conflict {
1652 assert!(!continued.ok());
1653 assert_eq!(git::unmerged_paths(&scratch).await.unwrap(), vec!["g.txt"]);
1654 assert_eq!(git::rev_parse(&scratch, "REBASE_HEAD").await.unwrap(), orig);
1655 std::fs::write(scratch.join("g.txt"), "branch plus upstream resolved\n").unwrap();
1656 sh(&scratch, &["add", "g.txt"]);
1657 let resolved = git::git_raw(&scratch, &["rebase", "--continue"])
1658 .await
1659 .unwrap();
1660 assert!(resolved.ok(), "{}", resolved.stderr);
1661 let full = git::git_raw(
1664 d,
1665 &[
1666 "merge-tree",
1667 "--write-tree",
1668 &format!("--merge-base={c1}"),
1669 &onto,
1670 &orig,
1671 ],
1672 )
1673 .await
1674 .unwrap();
1675 assert_eq!(full.code, Some(1));
1676 } else {
1677 assert!(continued.ok(), "{}", continued.stderr);
1678 }
1679 } else {
1680 assert!(!continued.ok());
1681 assert_eq!(git::unmerged_paths(&scratch).await.unwrap(), vec!["f.txt"]);
1682 let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1683 if !absorbed {
1684 assert!(!skipped.ok());
1686 std::fs::write(
1687 scratch.join("f.txt"),
1688 upstream.replacen("theirs", "newer", 1),
1689 )
1690 .unwrap();
1691 sh(&scratch, &["add", "f.txt"]);
1692 let resolved = git::git_raw(
1693 &scratch,
1694 &["-c", "core.editor=true", "rebase", "--continue"],
1695 )
1696 .await
1697 .unwrap();
1698 assert!(resolved.ok(), "{}", resolved.stderr);
1699 } else {
1700 assert!(skipped.ok(), "{}", skipped.stderr);
1701 }
1702 }
1703 assert!(!git::rebase_in_progress(&scratch).await);
1704 assert_eq!(
1705 std::fs::read_to_string(scratch.join("f.txt")).unwrap(),
1706 upstream.replacen(if absorbed { "new" } else { "theirs" }, "newer", 1)
1707 );
1708 let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1709 assert!(!already_in_result(d, &c1, &orig, &head).await.unwrap());
1711 let mut state = RunState::new(
1712 d.to_path_buf(),
1713 "main".into(),
1714 base,
1715 "rebase".into(),
1716 crate::config::Config::default(),
1717 );
1718 if let Some(damage) = damage {
1719 let path = git::git(&scratch, &["rev-parse", "--git-path", "magi-rebase-heads"])
1720 .await
1721 .unwrap();
1722 match damage {
1723 "missing" => std::fs::remove_file(&path).unwrap(),
1724 "count" => std::fs::write(&path, "").unwrap(),
1725 "final" => {
1726 let text = std::fs::read_to_string(&path).unwrap();
1727 let mut heads: Vec<_> = text.lines().map(str::to_owned).collect();
1728 *heads.last_mut().unwrap() = onto.clone();
1729 std::fs::write(&path, heads.join("\n") + "\n").unwrap();
1730 }
1731 "invalid" => std::fs::write(&path, "invalid\n").unwrap(),
1732 _ => unreachable!(),
1733 }
1734 }
1735 let outcome = finish(
1736 &mut state,
1737 &scratch,
1738 "topic",
1739 &orig,
1740 &onto,
1741 &["conflict.txt".into()],
1742 "",
1743 )
1744 .await
1745 .unwrap();
1746 std::fs::remove_dir_all(state.dir()).ok();
1747 outcome
1748 }
1749
1750 #[tokio::test]
1751 async fn missing_or_inconsistent_rebase_records_stop_the_rebase() {
1752 for damage in ["missing", "count", "final", "invalid"] {
1753 let outcome = rebase_successive_edits_record(true, false, false, Some(damage)).await;
1754 assert!(
1755 matches!(outcome, Rebased::Stopped(why) if why.contains("record could not be verified")),
1756 "{damage}"
1757 );
1758 }
1759 }
1760
1761 #[tokio::test]
1762 async fn an_absorbed_middle_commit_superseded_by_a_later_edit_is_applied() {
1763 assert_eq!(
1764 rebase_successive_edits(true, false, false).await,
1765 Rebased::Applied
1766 );
1767 }
1768
1769 #[tokio::test]
1770 async fn an_unrelated_later_conflict_does_not_hide_an_absorbed_commit() {
1771 assert_eq!(
1772 rebase_successive_edits(true, false, true).await,
1773 Rebased::Applied
1774 );
1775 }
1776
1777 #[tokio::test]
1778 async fn a_skipped_middle_commit_superseded_by_a_later_edit_is_dropped() {
1779 let Rebased::Stopped(why) = rebase_successive_edits(false, false, false).await else {
1780 panic!("a skipped middle commit must stop the rebase");
1781 };
1782 assert!(why.contains("the rebase dropped some of the branch's commits (skipped?)"));
1783 assert!(why.contains("c1"));
1784 }
1785
1786 #[tokio::test]
1787 async fn a_partially_absorbed_middle_commit_with_a_skipped_hunk_is_dropped() {
1788 let Rebased::Stopped(why) = rebase_successive_edits(true, true, false).await else {
1789 panic!("an unabsorbed hunk must stop the rebase");
1790 };
1791 assert!(why.contains("the rebase dropped some of the branch's commits (skipped?)"));
1792 assert!(why.contains("c1"));
1793 }
1794
1795 #[tokio::test]
1796 async fn an_absorbed_commit_erased_by_the_fixer_is_dropped() {
1797 crate::run::pin_test_home();
1798 let t = tempfile::tempdir().unwrap();
1799 let d = t.path();
1800 sh(d, &["init", "-q", "-b", "main"]);
1801 sh(d, &["config", "user.name", "t"]);
1802 sh(d, &["config", "user.email", "t@example.com"]);
1803 sh(d, &["config", "rerere.enabled", "false"]);
1806 let body =
1807 "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlater\n10\n11\n12\n13\n14\n15\n16\n17\n18\nend\n";
1808 std::fs::write(d.join("f.txt"), body).unwrap();
1809 std::fs::write(d.join("conflict.txt"), "old\n").unwrap();
1810 let base = commit(d, "base").await;
1811 sh(d, &["checkout", "-q", "-b", "topic"]);
1812 std::fs::write(d.join("conflict.txt"), "branch\n").unwrap();
1813 commit(d, "c0").await;
1814 std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1815 let c1 = commit(d, "c1").await;
1816 std::fs::write(
1817 d.join("f.txt"),
1818 body.replacen("old", "new", 1)
1819 .replace("later", "branch tail"),
1820 )
1821 .unwrap();
1822 let orig = commit(d, "c2").await;
1823 sh(d, &["checkout", "-q", "main"]);
1824 std::fs::write(d.join("conflict.txt"), "upstream\n").unwrap();
1825 let upstream = body
1826 .replacen("old", "new", 1)
1827 .replace("end", "upstream tail");
1828 std::fs::write(d.join("f.txt"), &upstream).unwrap();
1829 let onto = commit(d, "upstream composite").await;
1830 let scratch = d.join("scratch");
1831 assert!(matches!(
1832 git::rebase_start(d, &scratch, "topic", &onto)
1833 .await
1834 .unwrap(),
1835 git::RebaseStart::Conflicted(_)
1836 ));
1837 std::fs::write(scratch.join("conflict.txt"), "resolved\n").unwrap();
1839 std::fs::write(scratch.join("f.txt"), upstream.replacen("new", "theirs", 1)).unwrap();
1840 sh(&scratch, &["add", "conflict.txt", "f.txt"]);
1841 let continued = git::git_raw(&scratch, &["rebase", "--continue"])
1842 .await
1843 .unwrap();
1844 assert_eq!(
1845 continued.code,
1846 Some(1),
1847 "{}\n{}",
1848 continued.stdout,
1849 continued.stderr
1850 );
1851 assert_eq!(git::rev_parse(&scratch, "REBASE_HEAD").await.unwrap(), c1);
1852 let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1853 assert!(skipped.ok(), "{}", skipped.stderr);
1854 let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1855 assert!(replay_is_noop(d, &c1, &onto).await.unwrap());
1856 let expected = git::git_raw(
1859 d,
1860 &[
1861 "merge-tree",
1862 "--write-tree",
1863 &format!("--merge-base={c1}"),
1864 &onto,
1865 &orig,
1866 ],
1867 )
1868 .await
1869 .unwrap();
1870 assert!(expected.ok(), "{}", expected.stderr);
1871 assert!(
1872 !absorbed_in_result(d, &c1, &onto, &orig, &head)
1873 .await
1874 .unwrap()
1875 );
1876 assert_eq!(
1877 dropped_commits(d, &scratch, &onto, &orig, &head)
1878 .await
1879 .unwrap(),
1880 vec!["c1"]
1881 );
1882 let mut state = RunState::new(
1883 d.to_path_buf(),
1884 "main".into(),
1885 base,
1886 "rebase".into(),
1887 crate::config::Config::default(),
1888 );
1889 let outcome = finish(
1890 &mut state,
1891 &scratch,
1892 "topic",
1893 &orig,
1894 &onto,
1895 &["conflict.txt".into()],
1896 "",
1897 )
1898 .await
1899 .unwrap();
1900 assert!(
1901 matches!(outcome, Rebased::Stopped(why) if why.contains("the rebase dropped some of the branch's commits (skipped?)") && why.contains("c1"))
1902 );
1903 std::fs::remove_dir_all(state.dir()).ok();
1904 }
1905
1906 #[tokio::test]
1907 async fn a_partially_absorbed_commit_missing_an_unrelated_hunk_is_dropped() {
1908 let t = tempfile::tempdir().unwrap();
1909 let d = t.path();
1910 sh(d, &["init", "-q", "-b", "main"]);
1911 sh(d, &["config", "user.name", "t"]);
1912 sh(d, &["config", "user.email", "t@example.com"]);
1913 std::fs::write(d.join("c0.txt"), "base c0\n").unwrap();
1914 let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
1915 std::fs::write(d.join("f.txt"), body).unwrap();
1916 std::fs::write(d.join("extra.txt"), "initial\n").unwrap();
1917 let base = commit(d, "base").await;
1918
1919 std::fs::write(d.join("c0.txt"), "branch c0\n").unwrap();
1921 commit_dated(d, "c0").await;
1922 std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1923 std::fs::write(d.join("extra.txt"), "c1 edit\n").unwrap();
1924 commit_dated(d, "c1").await;
1925 std::fs::write(d.join("f.txt"), body.replacen("old", "newer", 1)).unwrap();
1926 let orig = commit_dated(d, "c2").await;
1927
1928 sh(d, &["checkout", "-q", "-b", "up", &base]);
1930 std::fs::write(d.join("c0.txt"), "upstream c0\n").unwrap();
1931 let up = body.replacen("old", "new", 1).replace("last", "tail");
1932 std::fs::write(d.join("f.txt"), &up).unwrap();
1933 let onto = commit(d, "upstream").await;
1934
1935 std::fs::write(d.join("c0.txt"), "resolved c0\n").unwrap();
1937 commit_dated(d, "c0").await;
1938 let res = up.replacen("new", "newer", 1);
1939 std::fs::write(d.join("f.txt"), &res).unwrap();
1940 let head = commit_dated(d, "c2").await;
1942
1943 let c0 = git::rev_parse(d, &format!("{orig}~2")).await.unwrap();
1944 let c1 = git::rev_parse(d, &format!("{orig}^")).await.unwrap();
1945 let resolved = git::rev_parse(d, &format!("{head}^")).await.unwrap();
1946 record(
1947 d,
1948 &[c0, c1, orig.clone()],
1949 &[resolved.clone(), resolved, head.clone()],
1950 )
1951 .await;
1952 assert_eq!(
1953 dropped_commits(d, d, &onto, &orig, &head).await.unwrap(),
1954 vec!["c1"]
1955 );
1956 }
1957
1958 const UPSTREAM: &str = "a\nupstream test\nz\n";
1961 const TOPIC: &str = "a\ntopic test\nz\n";
1962 const BOTH: &str = "a\nupstream test\ntopic test\nz\n";
1963
1964 struct Fixture {
1965 _dir: tempfile::TempDir,
1966 scratch: PathBuf,
1967 state: RunState,
1968 }
1969
1970 impl Fixture {
1971 fn read(&self, name: &str) -> String {
1972 std::fs::read_to_string(self.scratch.join(name)).unwrap()
1973 }
1974 }
1975
1976 async fn fixture(stop_recorded: bool) -> Fixture {
1980 fixture_with(stop_recorded, "f.txt", 2).await
1981 }
1982
1983 async fn fixture_with(stop_recorded: bool, conflict: &str, commits: usize) -> Fixture {
1984 fixture_stops(stop_recorded, conflict, commits, false).await
1985 }
1986
1987 async fn fixture_stops(
1988 stop_recorded: bool,
1989 conflict: &str,
1990 commits: usize,
1991 second: bool,
1992 ) -> Fixture {
1993 crate::run::pin_test_home();
1994 let t = tempfile::tempdir().unwrap();
1995 let d = t.path().join("repo");
1996 std::fs::create_dir(&d).unwrap();
1997 sh(&d, &["init", "-q", "-b", "main"]);
1998 sh(&d, &["config", "user.name", "t"]);
1999 sh(&d, &["config", "user.email", "t@example.com"]);
2000 sh(&d, &["config", "core.autocrlf", "false"]);
2001 std::fs::write(d.join(conflict), "a\nbase\nz\n").unwrap();
2002 std::fs::write(d.join("automatic"), "base\n").unwrap();
2003 sh(&d, &["add", "."]);
2004 sh(&d, &["commit", "-qm", "base"]);
2005 let base = git::rev_parse(&d, "HEAD").await.unwrap();
2006 sh(&d, &["checkout", "-qb", "topic"]);
2007 std::fs::write(d.join(conflict), TOPIC).unwrap();
2008 std::fs::write(d.join("README.md"), "Heading\n=======\n").unwrap();
2009 sh(&d, &["add", "README.md"]);
2010 std::fs::write(d.join("automatic"), "topic applied cleanly\n").unwrap();
2011 sh(&d, &["commit", "-qam", "topic test"]);
2012 std::fs::write(d.join("g.txt"), "g\n").unwrap();
2013 sh(&d, &["add", "g.txt"]);
2014 sh(&d, &["commit", "-qm", "topic g"]);
2015 for n in 2..commits {
2016 std::fs::write(d.join(format!("commit-{n}")), format!("{n}\n")).unwrap();
2017 sh(&d, &["add", "."]);
2018 sh(&d, &["commit", "-qm", &format!("topic {n}")]);
2019 }
2020 sh(&d, &["checkout", "-q", "main"]);
2021 std::fs::write(d.join(conflict), UPSTREAM).unwrap();
2022 if second {
2023 std::fs::write(d.join("g.txt"), "upstream g\n").unwrap();
2024 sh(&d, &["add", "g.txt"]);
2025 }
2026 sh(&d, &["commit", "-qam", "upstream test"]);
2027 let scratch = t.path().join("scratch");
2028 let started = git::rebase_start(&d, &scratch, "topic", "main")
2029 .await
2030 .unwrap();
2031 assert!(matches!(started, git::RebaseStart::Conflicted(_)));
2032 let mut state = RunState::new(
2033 d.clone(),
2034 "main".into(),
2035 base,
2036 "rebase".into(),
2037 crate::config::Config::default(),
2038 );
2039 let paths = git::unmerged_paths(&scratch).await.unwrap();
2040 assert_eq!(paths, vec![conflict]);
2041 state.rebase_fixes.push(RebaseFixRecord {
2042 agent: "mock".into(),
2043 index_outside: index_outside(&scratch, &paths).await.ok(),
2044 stop: if stop_recorded {
2045 rebase_head(&scratch).await
2046 } else {
2047 None
2048 },
2049 paths,
2050 from: None,
2051 finished: false,
2052 error: None,
2053 launcher: None,
2054 infra: None,
2055 });
2056 Fixture {
2057 _dir: t,
2058 scratch,
2059 state,
2060 }
2061 }
2062
2063 async fn stage(f: &mut Fixture) -> Result<Launch> {
2064 let original = git::rev_parse(&f.state.repo, "topic").await?;
2065 let onto = git::rev_parse(&f.state.repo, "main").await?;
2066 launcher_stage(&mut f.state, &f.scratch, &original, &onto).await
2067 }
2068
2069 async fn topic_subjects(f: &Fixture) -> Vec<String> {
2070 subjects(&f.scratch, "main..HEAD").await
2071 }
2072
2073 #[tokio::test]
2074 async fn resolved_contents_with_an_unmerged_index_are_staged_and_continued() {
2075 let mut f = fixture(true).await;
2076 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2077 let out = stage(&mut f).await.unwrap();
2078 assert!(matches!(out, Launch::Advanced));
2079 assert!(!git::rebase_in_progress(&f.scratch).await);
2080 assert_eq!(f.read("f.txt"), BOTH);
2082 assert_eq!(f.read("g.txt"), "g\n");
2083 assert_eq!(topic_subjects(&f).await, vec!["topic g", "topic test"]);
2084 let rec = git::rebase_record(&f.scratch).await.unwrap();
2085 assert_eq!(rec.picks.len(), 2);
2086 assert_eq!(f.state.rebase_fixes.len(), 1, "no further round was spent");
2087 std::fs::remove_dir_all(f.state.dir()).ok();
2088 }
2089
2090 #[tokio::test]
2091 async fn a_pre_staging_record_is_matched_by_its_repeated_conflict_set() {
2092 let mut f = fixture(false).await;
2093 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2094 let again = f.state.rebase_fixes[0].clone();
2095 f.state.rebase_fixes.push(again);
2096 let out = stage(&mut f).await.unwrap();
2097 assert!(matches!(out, Launch::Advanced));
2098 assert!(!git::rebase_in_progress(&f.scratch).await);
2099
2100 let mut f = fixture(false).await;
2102 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2103 let mut other = f.state.rebase_fixes[0].clone();
2104 other.paths = vec!["elsewhere.txt".into()];
2105 f.state.rebase_fixes.insert(0, other);
2106 let out = stage(&mut f).await.unwrap();
2107 assert!(matches!(out, Launch::Nothing(_)));
2108 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2109 std::fs::remove_dir_all(f.state.dir()).ok();
2110 }
2111
2112 #[tokio::test]
2113 async fn remaining_markers_stage_nothing() {
2114 let mut f = fixture(true).await;
2115 let out = stage(&mut f).await.unwrap();
2116 assert!(matches!(&out, Launch::Nothing(why) if why.contains("markers")));
2117 assert!(git::rebase_in_progress(&f.scratch).await);
2118 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2119 assert!(f.state.resource_block.is_none());
2120 std::fs::remove_dir_all(f.state.dir()).ok();
2121 }
2122
2123 #[tokio::test]
2124 async fn partial_markers_are_rejected_even_after_staging() {
2125 for marker in ["<<<<<<< HEAD", "=======", ">>>>>>> topic", "||||||| base"] {
2126 for staged in [false, true] {
2127 let mut f = fixture(true).await;
2128 std::fs::write(f.scratch.join("f.txt"), format!("{BOTH}{marker}\n")).unwrap();
2129 if staged {
2130 sh(&f.scratch, &["add", "f.txt"]);
2131 }
2132 assert!(
2133 has_markers(&f.scratch, "f.txt", &["HEAD", "REBASE_HEAD"])
2134 .await
2135 .unwrap()
2136 );
2137 let out = stage(&mut f).await.unwrap();
2138 assert!(matches!(&out, Launch::Nothing(why) if why.contains("markers")));
2139 assert!(git::rebase_in_progress(&f.scratch).await);
2140 std::fs::remove_dir_all(f.state.dir()).ok();
2141 }
2142 }
2143 }
2144
2145 #[tokio::test]
2146 async fn existing_markdown_headings_are_not_conflict_markers() {
2147 let f = fixture(true).await;
2148 assert!(
2149 !has_markers(&f.scratch, "README.md", &["HEAD", "REBASE_HEAD"])
2150 .await
2151 .unwrap()
2152 );
2153 std::fs::write(
2154 f.scratch.join("README.md"),
2155 "Heading\n=======\nextra separator\n=======\n",
2156 )
2157 .unwrap();
2158 assert!(
2159 has_markers(&f.scratch, "README.md", &["HEAD", "REBASE_HEAD"])
2160 .await
2161 .unwrap()
2162 );
2163 std::fs::write(f.scratch.join("README.md"), "resolved code\n=======\n").unwrap();
2164 assert!(
2165 has_markers(&f.scratch, "README.md", &["HEAD", "REBASE_HEAD"])
2166 .await
2167 .unwrap()
2168 );
2169 std::fs::remove_dir_all(f.state.dir()).ok();
2170 }
2171
2172 #[tokio::test]
2173 async fn retries_keep_the_first_index_baseline() {
2174 let mut f = fixture(true).await;
2175 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2176 std::fs::write(f.scratch.join("automatic"), "unrelated staged edit\n").unwrap();
2177 sh(&f.scratch, &["add", "automatic"]);
2178 let out = stage(&mut f).await.unwrap();
2179 assert!(matches!(&out, Launch::Nothing(why) if why.contains("staged changes")));
2180 let paths = f.state.rebase_fixes[0].paths.clone();
2181 let baseline = round_baseline(&mut f.state, &f.scratch, &paths)
2182 .await
2183 .unwrap();
2184 assert_eq!(
2185 Some(&baseline),
2186 f.state.rebase_fixes[0].index_outside.as_ref()
2187 );
2188 assert_ne!(baseline, index_outside(&f.scratch, &paths).await.unwrap());
2189 let mut retry = f.state.rebase_fixes[0].clone();
2190 retry.index_outside = index_outside(&f.scratch, &paths).await.ok();
2192 f.state.rebase_fixes.push(retry);
2193 let out = stage(&mut f).await.unwrap();
2194 assert!(matches!(&out, Launch::Nothing(why) if why.contains("staged changes")));
2195 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2196 std::fs::remove_dir_all(f.state.dir()).ok();
2197 }
2198
2199 #[tokio::test]
2200 async fn legacy_records_without_a_baseline_block_without_adopting_the_index() {
2201 let mut f = fixture(false).await;
2202 f.state.rebase_fixes[0].index_outside = None;
2203 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2204 std::fs::write(f.scratch.join("automatic"), "unrelated staged edit\n").unwrap();
2205 sh(&f.scratch, &["add", "automatic"]);
2206 let error = stage(&mut f).await.unwrap_err();
2207 assert!(error.is::<crate::repo_lock::ResourceBlocked>());
2208 assert!(error.to_string().contains("baseline"));
2209 assert_eq!(f.state.rebase_fixes.len(), 1);
2210 assert!(f.state.rebase_fixes[0].index_outside.is_none());
2211 assert_eq!(f.read("f.txt"), BOTH);
2212 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2213 std::fs::remove_dir_all(f.state.dir()).ok();
2214 }
2215
2216 #[tokio::test]
2217 async fn legacy_baseline_is_reconstructed_and_saved_without_another_fixer() {
2218 for staged in [false, true] {
2219 let mut f = fixture_with(false, "f.txt", 16).await;
2220 f.state.rebase_fixes[0].index_outside = None;
2221 let again = f.state.rebase_fixes[0].clone();
2222 f.state.rebase_fixes.push(again);
2223 f.state.config.graph.review_rounds = 1;
2224 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2225 if staged {
2226 sh(&f.scratch, &["add", "f.txt"]);
2227 }
2228 let original = git::rev_parse(&f.state.repo, "topic").await.unwrap();
2229 assert!(matches!(stage(&mut f).await.unwrap(), Launch::Advanced));
2230 assert_eq!(f.state.rebase_fixes.len(), 2);
2231 assert!(f.state.rebase_fixes[0].index_outside.is_some());
2232 assert!(f.state.rebase_fixes.iter().all(|r| r.stop.is_some()));
2233 let saved = RunState::load(&f.state.id).unwrap();
2234 assert_eq!(
2235 saved.rebase_fixes[0].index_outside,
2236 f.state.rebase_fixes[0].index_outside
2237 );
2238 assert_eq!(f.read("f.txt"), BOTH);
2239 assert_eq!(topic_subjects(&f).await.len(), 16);
2240 assert_eq!(f.read("automatic"), "topic applied cleanly\n");
2241 let onto = git::rev_parse(&f.state.repo, "main").await.unwrap();
2242 assert_eq!(
2243 finish(
2244 &mut f.state,
2245 &f.scratch,
2246 "topic",
2247 &original,
2248 &onto,
2249 &["f.txt".into()],
2250 ""
2251 )
2252 .await
2253 .unwrap(),
2254 Rebased::Applied
2255 );
2256 std::fs::remove_dir_all(f.state.dir()).ok();
2257 }
2258 }
2259
2260 #[tokio::test]
2261 async fn successful_progression_gets_a_budget_for_the_next_conflicting_commit() {
2262 let mut f = fixture_stops(true, "f.txt", 2, true).await;
2263 f.state.config.graph.review_rounds = 1;
2264 assert_eq!(stop_rounds(&f.state, &f.scratch).await, 1);
2265 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2266 assert!(matches!(stage(&mut f).await.unwrap(), Launch::Advanced));
2267 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["g.txt"]);
2268 assert_eq!(stop_rounds(&f.state, &f.scratch).await, 0);
2269 let paths = git::unmerged_paths(&f.scratch).await.unwrap();
2270 let mut round = f.state.rebase_fixes[0].clone();
2271 round.index_outside = Some(
2272 round_baseline(&mut f.state, &f.scratch, &paths)
2273 .await
2274 .unwrap(),
2275 );
2276 round.paths = paths;
2277 round.stop = rebase_head(&f.scratch).await;
2278 f.state.rebase_fixes.push(round);
2279 assert_eq!(stop_rounds(&f.state, &f.scratch).await, 1);
2280 std::fs::write(f.scratch.join("g.txt"), "upstream g\ng\n").unwrap();
2281 assert_eq!(
2282 rebase_with_fixer(&mut f.state, &f.scratch, "topic", "main")
2283 .await
2284 .unwrap(),
2285 Rebased::Applied
2286 );
2287 assert_eq!(
2288 git::git(&f.state.repo, &["show", "topic:f.txt"])
2289 .await
2290 .unwrap(),
2291 BOTH.trim_end()
2292 );
2293 assert_eq!(
2294 git::git(&f.state.repo, &["show", "topic:g.txt"])
2295 .await
2296 .unwrap(),
2297 "upstream g\ng"
2298 );
2299 std::fs::remove_dir_all(f.state.dir()).ok();
2300 }
2301
2302 #[tokio::test]
2303 async fn resume_after_staging_denial_uses_the_saved_target_when_upstream_moves() {
2304 let mut f = fixture(true).await;
2305 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2306 let saved = rebase_target(&f.scratch).await.unwrap();
2307 let lock = git::git(&f.scratch, &["rev-parse", "--git-path", "index.lock"])
2308 .await
2309 .unwrap();
2310 let lock = f.scratch.join(lock);
2311 std::fs::write(&lock, "").unwrap();
2312 assert!(
2313 stage(&mut f)
2314 .await
2315 .unwrap_err()
2316 .is::<crate::repo_lock::ResourceBlocked>()
2317 );
2318 std::fs::remove_file(lock).unwrap();
2319 std::fs::write(f.state.repo.join("new-upstream"), "new base\n").unwrap();
2320 sh(&f.state.repo, &["add", "new-upstream"]);
2321 sh(&f.state.repo, &["commit", "-qm", "new upstream"]);
2322 assert_ne!(saved, git::rev_parse(&f.state.repo, "main").await.unwrap());
2323 assert_eq!(
2324 rebase_with_fixer(&mut f.state, &f.scratch, "topic", "main")
2325 .await
2326 .unwrap(),
2327 Rebased::Applied
2328 );
2329 assert!(git::is_ancestor(&f.state.repo, &saved, "topic").await);
2330 assert!(git::is_ancestor(&f.state.repo, "main", "topic").await);
2331 assert_eq!(
2332 git::git(&f.state.repo, &["show", "topic:f.txt"])
2333 .await
2334 .unwrap(),
2335 BOTH.trim_end()
2336 );
2337 assert_eq!(f.state.rebase_fixes.len(), 1);
2338 std::fs::remove_dir_all(f.state.dir()).ok();
2339 }
2340
2341 #[tokio::test]
2342 async fn unrelated_edits_and_unexpected_unmerged_paths_stage_nothing() {
2343 let mut f = fixture(true).await;
2344 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2345 std::fs::write(f.scratch.join("stray.txt"), "x\n").unwrap();
2346 let out = stage(&mut f).await.unwrap();
2347 assert!(matches!(&out, Launch::Nothing(why) if why.contains("stray.txt")));
2348 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2349 std::fs::remove_file(f.scratch.join("stray.txt")).unwrap();
2350
2351 f.state.rebase_fixes[0].paths = vec!["other.txt".into()];
2353 let out = stage(&mut f).await.unwrap();
2354 assert!(matches!(&out, Launch::Nothing(why) if why.contains("not part of")));
2355 assert!(git::rebase_in_progress(&f.scratch).await);
2356 std::fs::remove_dir_all(f.state.dir()).ok();
2357 }
2358
2359 #[tokio::test]
2360 async fn a_denied_index_write_is_an_infrastructure_stop_that_keeps_everything() {
2361 let mut f = fixture(true).await;
2362 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2363 let lock = git::git(&f.scratch, &["rev-parse", "--git-path", "index.lock"])
2364 .await
2365 .unwrap();
2366 let lock = f.scratch.join(lock);
2367 std::fs::write(&lock, "").unwrap();
2368
2369 let err = stage(&mut f).await.unwrap_err();
2370 assert!(err.is::<crate::repo_lock::ResourceBlocked>(), "{err:#}");
2371 let block = f.state.resource_block.clone().expect("a resource block");
2372 assert_eq!(block.kind, "git metadata write");
2373 assert!(f.state.rebase_fixes[0].infra.is_some());
2374 assert_eq!(f.state.rebase_fixes.len(), 1, "no round was spent");
2375 assert!(git::rebase_in_progress(&f.scratch).await);
2376 assert_eq!(f.read("f.txt"), BOTH);
2377
2378 std::fs::remove_file(&lock).unwrap();
2380 f.state.resource_block = None;
2381 let out = stage(&mut f).await.unwrap();
2382 assert!(matches!(out, Launch::Advanced));
2383 assert!(!git::rebase_in_progress(&f.scratch).await);
2384 assert_eq!(f.read("f.txt"), BOTH);
2385 std::fs::remove_dir_all(f.state.dir()).ok();
2386 }
2387
2388 #[tokio::test]
2389 async fn a_pass_interrupted_after_staging_is_continued_not_repeated() {
2390 let mut f = fixture(true).await;
2391 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2392 sh(&f.scratch, &["add", "f.txt"]);
2393 f.state.rebase_fixes[0].launcher = Some(crate::run::LauncherStep::Staged);
2394 assert!(git::unmerged_paths(&f.scratch).await.unwrap().is_empty());
2395 let out = stage(&mut f).await.unwrap();
2396 assert!(matches!(out, Launch::Advanced));
2397 assert!(!git::rebase_in_progress(&f.scratch).await);
2398 assert_eq!(topic_subjects(&f).await, vec!["topic g", "topic test"]);
2399 let out = stage(&mut f).await.unwrap();
2401 assert!(matches!(out, Launch::Nothing(_)));
2402 std::fs::remove_dir_all(f.state.dir()).ok();
2403 }
2404
2405 #[tokio::test]
2406 async fn recorded_conflict_edits_after_interrupted_staging_are_restaged() {
2407 for deleted in [false, true] {
2408 let mut f = fixture(true).await;
2409 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2410 sh(&f.scratch, &["add", "f.txt"]);
2411 f.state.rebase_fixes[0].launcher = Some(crate::run::LauncherStep::Staged);
2412 assert!(git::unmerged_paths(&f.scratch).await.unwrap().is_empty());
2413
2414 let revised = format!("{BOTH}additional test\n");
2415 if deleted {
2416 std::fs::remove_file(f.scratch.join("f.txt")).unwrap();
2417 } else {
2418 std::fs::write(f.scratch.join("f.txt"), &revised).unwrap();
2419 }
2420 let out = stage(&mut f).await.unwrap();
2421 assert!(matches!(out, Launch::Advanced));
2422 assert!(!git::rebase_in_progress(&f.scratch).await);
2423 assert_eq!(topic_subjects(&f).await, vec!["topic g", "topic test"]);
2424 if deleted {
2425 assert!(
2426 git::git(&f.scratch, &["ls-tree", "HEAD", "--", "f.txt"])
2427 .await
2428 .unwrap()
2429 .is_empty()
2430 );
2431 } else {
2432 assert_eq!(f.read("f.txt"), revised);
2433 assert_eq!(
2434 git::git(&f.scratch, &["show", "HEAD:f.txt"]).await.unwrap(),
2435 revised.trim_end()
2436 );
2437 }
2438 assert!(status_entries(&f.scratch).await.unwrap().is_empty());
2439 assert_eq!(f.state.rebase_fixes.len(), 1, "no further round was spent");
2440 std::fs::remove_dir_all(f.state.dir()).ok();
2441 }
2442 }
2443
2444 #[tokio::test]
2445 async fn a_cancelled_run_stages_nothing_and_keeps_the_resolved_files() {
2446 let mut f = fixture(true).await;
2447 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2448 let cancelled = crate::repo_lock::WaitPolicy::new(Duration::from_secs(5), || true);
2449 let err = cancelled.scope(stage(&mut f)).await.unwrap_err();
2450 assert!(err.is::<crate::repo_lock::ResourceBlocked>());
2451 assert!(git::rebase_in_progress(&f.scratch).await);
2452 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2453 assert_eq!(f.read("f.txt"), BOTH);
2454 std::fs::remove_dir_all(f.state.dir()).ok();
2455 }
2456 #[cfg(unix)]
2457 #[tokio::test]
2458 async fn cancelled_launcher_git_terminates_descendants_before_releasing_coordination() {
2459 for abort in [true, false] {
2460 cancelled_git_operation(abort).await;
2461 }
2462 }
2463
2464 #[cfg(unix)]
2465 async fn cancelled_git_operation(abort: bool) {
2466 let mut f = fixture(true).await;
2467 let scratch = f.scratch.clone();
2468 let state = &mut f.state;
2469 let policy = crate::config::test_coordination().unwrap();
2470 let common = crate::repo_lock::common_dir(&scratch)
2471 .await
2472 .unwrap()
2473 .unwrap();
2474 std::fs::write(common.join(&policy.contract_file), &policy.contract_token).unwrap();
2475 state.config.coordination = Some(policy.clone());
2476 let wait = crate::repo_lock::WaitPolicy::default();
2477 let lease = crate::repo_lock::SeatLease::operation_with(&scratch, Some(&policy), &wait)
2478 .await
2479 .unwrap();
2480 sh(
2481 &scratch,
2482 &[
2483 "config",
2484 "alias.block",
2485 "!echo $$ > operation.pid; exec sleep 30",
2486 ],
2487 );
2488 let cwd = scratch.clone();
2489 let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2490 let signal = cancelled.clone();
2491 let cancellation = crate::repo_lock::WaitPolicy::new(Duration::from_secs(5), move || {
2492 signal.load(std::sync::atomic::Ordering::SeqCst)
2493 });
2494 let operation =
2495 tokio::spawn(async move { cancellation.scope(mutate(&cwd, &["block"], lease)).await });
2496 let pid_file = scratch.join("operation.pid");
2497 let pid: i32 = tokio::time::timeout(Duration::from_secs(5), async {
2498 loop {
2499 if let Ok(text) = std::fs::read_to_string(&pid_file)
2500 && let Ok(pid) = text.trim().parse()
2501 {
2502 break pid;
2503 }
2504 tokio::time::sleep(Duration::from_millis(10)).await;
2505 }
2506 })
2507 .await
2508 .unwrap();
2509 assert!(crate::retention::OutputLease::take(&scratch, true).is_err());
2510 assert!(
2511 tokio::time::timeout(
2512 Duration::from_millis(100),
2513 crate::repo_lock::SeatLease::operation_with(&scratch, Some(&policy), &wait)
2514 )
2515 .await
2516 .is_err()
2517 );
2518 if abort {
2519 operation.abort();
2520 assert!(operation.await.unwrap_err().is_cancelled());
2521 } else {
2522 cancelled.store(true, std::sync::atomic::Ordering::SeqCst);
2523 let error = tokio::time::timeout(Duration::from_secs(5), operation)
2524 .await
2525 .unwrap()
2526 .unwrap()
2527 .unwrap_err();
2528 assert!(error.is::<crate::repo_lock::ResourceBlocked>());
2529 }
2530 let _next = crate::repo_lock::SeatLease::operation_with(&scratch, Some(&policy), &wait)
2532 .await
2533 .unwrap();
2534 assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
2536 let _output = crate::retention::OutputLease::take(&scratch, true).unwrap();
2537 assert!(git::rebase_in_progress(&scratch).await);
2538 assert_eq!(state.rebase_fixes.len(), 1);
2539 std::fs::remove_dir_all(state.dir()).ok();
2540 }
2541
2542 #[tokio::test]
2543 async fn recovery_keeps_sixteen_commits_and_automatic_changes() {
2544 let mut f = fixture_with(true, "f.txt", 16).await;
2545 f.state.config.graph.review_rounds = 1;
2546 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2547 let original = git::rev_parse(&f.state.repo, "topic").await.unwrap();
2548 let subjects = git::git(&f.scratch, &["log", "--format=%s", "main..topic"])
2549 .await
2550 .unwrap();
2551 assert_eq!(
2552 rebase_with_fixer(&mut f.state, &f.scratch, "topic", "main")
2553 .await
2554 .unwrap(),
2555 Rebased::Applied
2556 );
2557 let heads = git::git(&f.state.repo, &["rev-list", "--count", "main..topic"])
2558 .await
2559 .unwrap();
2560 assert_eq!(heads, "16");
2561 assert_eq!(
2562 git::git(&f.state.repo, &["show", "topic:f.txt"])
2563 .await
2564 .unwrap(),
2565 BOTH.trim_end()
2566 );
2567 assert_eq!(
2568 git::git(&f.state.repo, &["show", "topic:automatic"])
2569 .await
2570 .unwrap(),
2571 "topic applied cleanly"
2572 );
2573 assert_eq!(
2574 git::git(&f.state.repo, &["log", "--format=%s", "main..topic"])
2575 .await
2576 .unwrap(),
2577 subjects
2578 );
2579 for n in 2..16 {
2580 assert_eq!(
2581 git::git(&f.state.repo, &["show", &format!("topic:commit-{n}")])
2582 .await
2583 .unwrap(),
2584 n.to_string()
2585 );
2586 }
2587 assert_eq!(f.state.rebase_fixes.len(), 1);
2588 assert_ne!(
2589 git::rev_parse(&f.state.repo, "topic").await.unwrap(),
2590 original
2591 );
2592 std::fs::remove_dir_all(f.state.dir()).ok();
2593 }
2594
2595 #[tokio::test]
2596 async fn recovery_handles_literal_conflict_paths_with_whitespace() {
2597 let paths = [" spaced file name", "日本語 [literal].txt", "日本語.txt"].into_iter();
2601 #[cfg(not(windows))]
2602 let paths = paths.chain([" spaced file ", "tab\tand\nnewline"]);
2603 for path in paths {
2604 let mut f = fixture_with(true, path, 2).await;
2605 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), [path]);
2606 std::fs::write(f.scratch.join(path), BOTH).unwrap();
2607 assert!(matches!(stage(&mut f).await.unwrap(), Launch::Advanced));
2608 assert_eq!(f.read(path), BOTH);
2609 assert!(git::unmerged_paths(&f.scratch).await.unwrap().is_empty());
2610 std::fs::remove_dir_all(f.state.dir()).ok();
2611 }
2612 }
2613
2614 #[tokio::test]
2615 async fn changed_rebase_identity_blocks_staging_and_keeps_resolution() {
2616 for name in ["orig-head", "onto"] {
2617 let mut f = fixture(true).await;
2618 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2619 let metadata = git::git(
2620 &f.scratch,
2621 &["rev-parse", "--git-path", &format!("rebase-merge/{name}")],
2622 )
2623 .await
2624 .unwrap();
2625 std::fs::write(
2626 f.scratch.join(metadata),
2627 "0000000000000000000000000000000000000000\n",
2628 )
2629 .unwrap();
2630 let error = stage(&mut f).await.unwrap_err();
2631 assert!(error.is::<crate::repo_lock::ResourceBlocked>());
2632 assert!(error.to_string().contains(name));
2633 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2634 assert_eq!(f.read("f.txt"), BOTH);
2635 assert_eq!(f.state.rebase_fixes.len(), 1);
2636 std::fs::remove_dir_all(f.state.dir()).ok();
2637 }
2638 }
2639}