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