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