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
494pub(crate) async 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
500pub(crate) async fn saved_base_identity(scratch: &Path, branch: &str) -> Result<String> {
502 let read = |name: &'static str| async move {
503 let path = git::git(
504 scratch,
505 &["rev-parse", "--git-path", &format!("rebase-merge/{name}")],
506 )
507 .await?;
508 anyhow::Ok(
509 std::fs::read_to_string(scratch.join(path))?
510 .trim()
511 .to_owned(),
512 )
513 };
514 anyhow::ensure!(
515 read("head-name").await? == format!("refs/heads/{branch}"),
516 "saved rebase belongs to another branch"
517 );
518 git::rev_parse(scratch, &format!("{}^{{commit}}", read("orig-head").await?)).await
519}
520
521async fn stop_identity(scratch: &Path, original: &str, onto: &str) -> Result<()> {
522 for (name, expected) in [("orig-head", original), ("onto", onto)] {
523 let path = git::git(
524 scratch,
525 &["rev-parse", "--git-path", &format!("rebase-merge/{name}")],
526 )
527 .await?;
528 let path = Path::new(&path);
529 let path = if path.is_absolute() {
530 path.to_path_buf()
531 } else {
532 scratch.join(path)
533 };
534 anyhow::ensure!(
535 std::fs::read_to_string(path)?.trim() == expected,
536 "rebase {name} changed"
537 );
538 }
539 Ok(())
540}
541
542async fn mutate(
545 scratch: &Path,
546 args: &[&str],
547 lease: Option<crate::repo_lock::SeatLease>,
548) -> Result<git::GitOut> {
549 use crate::proc::Quiet as _;
550 use std::process::Stdio;
551 use tokio::io::AsyncReadExt as _;
552 let wait = crate::repo_lock::WaitPolicy::current();
553 anyhow::ensure!(
554 !wait.cancelled(),
555 crate::repo_lock::ResourceBlocked("launcher Git operation cancelled".into())
556 );
557 let mut command = tokio::process::Command::new("git");
558 command
559 .quiet()
560 .args(args)
561 .current_dir(scratch)
562 .env("GIT_TERMINAL_PROMPT", "0")
563 .env("GIT_EDITOR", "true")
564 .stdin(Stdio::null())
565 .stdout(Stdio::piped())
566 .stderr(Stdio::piped())
567 .kill_on_drop(true);
568 let outputs = crate::retention::OutputLease::borrow(scratch)?
569 .into_iter()
570 .collect();
571 let mut running = crate::seat_process::Running::spawn_operation(command, lease, outputs)?;
572 let mut stdout = running.child().stdout.take().context("Git stdout")?;
573 let mut stderr = running.child().stderr.take().context("Git stderr")?;
574 let mut out = Vec::new();
575 let mut err = Vec::new();
576 let cancelled = async {
577 while !wait.cancelled() {
578 tokio::time::sleep(Duration::from_millis(50)).await;
579 }
580 };
581 let (status, _, _) = tokio::select! {
582 result = async { tokio::try_join!(
583 running.child().wait(),
584 stdout.read_to_end(&mut out),
585 stderr.read_to_end(&mut err)
586 ) } => result?,
587 () = cancelled => return Err(crate::repo_lock::ResourceBlocked("launcher Git operation cancelled".into()).into()),
588 };
589 running.finish().await;
590 Ok(git::GitOut {
591 code: status.code(),
592 stdout: String::from_utf8_lossy(&out).into_owned(),
593 stderr: String::from_utf8_lossy(&err).into_owned(),
594 })
595}
596
597async fn launcher_stage(
610 state: &mut RunState,
611 scratch: &Path,
612 original: &str,
613 onto: &str,
614) -> Result<Launch> {
615 if !git::rebase_in_progress(scratch).await {
616 return Ok(Launch::Nothing("no rebase in progress".into()));
617 }
618 if !same_stop(state, scratch).await {
619 return Ok(Launch::Nothing(
620 "no fixer round is recorded for this stop point".into(),
621 ));
622 }
623 let wait = {
624 let inherited = crate::repo_lock::WaitPolicy::current();
625 crate::repo_lock::WaitPolicy::new(
626 Duration::from_secs(state.config.graph.timeout_resource_wait),
627 move || inherited.cancelled(),
628 )
629 };
630 if wait.cancelled() {
631 return Err(block(
632 state,
633 "repository coordination",
634 "cancelled before staging; the resolved files are kept".into(),
635 ));
636 }
637 let lease = crate::repo_lock::SeatLease::operation_with(
638 scratch,
639 state.config.coordination.as_ref(),
640 &wait,
641 )
642 .await;
643 let lease = match lease {
644 Ok(l) => l,
645 Err(e) if e.is::<crate::repo_lock::ResourceBlocked>() => {
646 return Err(block(state, "repository coordination", e.to_string()));
647 }
648 Err(e) => return Err(e),
649 };
650
651 if !git::rebase_in_progress(scratch).await || !same_stop(state, scratch).await {
654 return Ok(Launch::Nothing("the stop point changed".into()));
655 }
656 if let Err(e) = stop_identity(scratch, original, onto).await {
657 return Err(infra_stop(state, format!("{e:#}")));
658 }
659 let last = stop_records(state)[0].clone();
660 let lease = if last.index_outside.is_none() {
661 let (baseline, head, pick) = legacy_baseline(scratch, &last.paths, lease)
662 .await
663 .map_err(|e| infra_stop(state, format!("legacy baseline recovery: {e:#}")))?;
664 let lease = crate::repo_lock::SeatLease::operation_with(
667 scratch,
668 state.config.coordination.as_ref(),
669 &wait,
670 )
671 .await
672 .map_err(|e| block(state, "repository coordination", e.to_string()))?;
673 let validation = async {
674 stop_identity(scratch, original, onto).await?;
675 anyhow::ensure!(
676 same_stop(state, scratch).await
677 && git::rev_parse(scratch, "HEAD").await? == head
678 && rebase_head(scratch).await.as_deref() == Some(&pick),
679 "the stop changed during legacy baseline recovery"
680 );
681 anyhow::ensure!(
682 index_outside(scratch, &last.paths).await? == baseline,
683 "the legacy index differs from the reconstructed clean baseline"
684 );
685 Ok::<_, anyhow::Error>(())
686 }
687 .await;
688 if let Err(e) = validation {
689 return Err(infra_stop(
690 state,
691 format!("legacy baseline recovery: {e:#}"),
692 ));
693 }
694 let first = state.rebase_fixes.len() - stop_records(state).len();
695 state.rebase_fixes[first].index_outside = Some(baseline);
696 for record in &mut state.rebase_fixes[first..] {
697 record.stop = Some(pick.clone());
698 }
699 state.event(
700 "rebase",
701 "verified and saved the legacy stop baseline from the original three-way merge",
702 );
703 state.save()?;
704 lease
705 } else {
706 lease
707 };
708 if last.stop.is_none() {
709 let pick = rebase_head(scratch)
710 .await
711 .context("legacy stop has no REBASE_HEAD")?;
712 let first = state.rebase_fixes.len() - stop_records(state).len();
713 for record in &mut state.rebase_fixes[first..] {
714 record.stop = Some(pick.clone());
715 }
716 state.save()?;
717 }
718 let baseline = round_baseline(state, scratch, &last.paths).await?;
719 let unmerged = match git::unmerged_paths(scratch).await {
720 Ok(u) => u,
721 Err(e) => return Err(infra_stop(state, format!("cannot read the index: {e:#}"))),
722 };
723 if let Some(p) = unmerged.iter().find(|p| !last.paths.contains(p)) {
724 return Ok(Launch::Nothing(format!(
725 "`{p}` is unmerged but was not part of the recorded conflict"
726 )));
727 }
728 for p in &last.paths {
729 match std::fs::symlink_metadata(scratch.join(p)) {
730 Ok(m) if m.is_dir() => {
731 return Ok(Launch::Nothing(format!("`{p}` is a directory")));
732 }
733 Ok(m) if m.is_file() => {
734 if let Err(e) = std::fs::read(scratch.join(p)) {
735 return Err(infra_stop(state, format!("cannot read `{p}`: {e}")));
736 }
737 if has_markers(scratch, p, &["HEAD", "REBASE_HEAD"]).await? {
738 return Ok(Launch::Nothing(format!(
739 "`{p}` still holds conflict markers"
740 )));
741 }
742 }
743 Ok(_) => {}
744 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
746 Err(e) => return Err(infra_stop(state, format!("cannot inspect `{p}`: {e}"))),
747 }
748 }
749 let entries = match status_entries(scratch).await {
750 Ok(e) => e,
751 Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
752 };
753 let stray: Vec<&str> = entries
756 .iter()
757 .filter(|(_, p)| !last.paths.contains(p))
758 .filter(|(xy, _)| xy == "??" || !xy.ends_with(' '))
759 .map(|(_, p)| p.as_str())
760 .collect();
761 if !stray.is_empty() {
762 return Ok(Launch::Nothing(format!(
763 "changes outside the conflicted paths: {}",
764 stray.join(", ")
765 )));
766 }
767 match index_outside(scratch, &last.paths).await {
768 Ok(now) if now == baseline => {}
769 Ok(_) => {
770 return Ok(Launch::Nothing(
771 "the staged changes outside the conflicted paths changed".into(),
772 ));
773 }
774 Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
775 }
776
777 let set = |state: &mut RunState, step| {
778 if let Some(r) = state.rebase_fixes.last_mut() {
779 r.launcher = Some(step);
780 }
781 state.save()
782 };
783 let to_stage: Vec<&str> = last
787 .paths
788 .iter()
789 .filter(|p| {
790 unmerged.contains(p)
791 || entries
792 .iter()
793 .any(|(xy, path)| path == *p && !xy.ends_with(' '))
794 })
795 .map(String::as_str)
796 .collect();
797 if !to_stage.is_empty() {
798 set(state, crate::run::LauncherStep::Staging)?;
799 let mut args = vec!["--literal-pathspecs", "add", "-A", "--"];
800 args.extend(to_stage);
801 match mutate(scratch, &args, lease).await {
802 Ok(o) if o.ok() => {}
803 Ok(o) => {
804 let said = if o.stderr.is_empty() {
805 o.stdout
806 } else {
807 o.stderr
808 };
809 return Err(infra_stop(state, format!("staging was refused: {said}")));
810 }
811 Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
812 }
813 match git::unmerged_paths(scratch).await {
814 Ok(left) if left.is_empty() => {}
815 Ok(left) => {
816 return Err(infra_stop(
817 state,
818 format!("paths stayed unmerged after staging: {}", left.join(", ")),
819 ));
820 }
821 Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
822 }
823 return Box::pin(launcher_stage(state, scratch, original, onto)).await;
826 }
827 set(state, crate::run::LauncherStep::Staged)?;
828 let before = rebase_head(scratch).await;
829 set(state, crate::run::LauncherStep::Continuing)?;
830 let out = match mutate(scratch, &["rebase", "--continue"], lease).await {
831 Ok(o) => o,
832 Err(e) => return Err(infra_stop(state, format!("{e:#}"))),
833 };
834 if !out.ok() && git::rebase_in_progress(scratch).await {
835 let again = git::unmerged_paths(scratch).await.unwrap_or_default();
836 if again.is_empty() || rebase_head(scratch).await == before {
839 let said = git::rebase_said(&if out.stderr.is_empty() {
840 out.stdout
841 } else {
842 out.stderr
843 });
844 return Err(infra_stop(state, format!("continue failed: {said}")));
845 }
846 }
847 state.event(
848 "rebase",
849 format!(
850 "launcher staged {} path(s) and continued the rebase",
851 unmerged.len()
852 ),
853 );
854 Ok(Launch::Advanced)
855}
856
857type CompletedProblem = Option<(&'static str, Vec<String>)>;
858
859async fn completed_result(
861 repo: &Path,
862 scratch: &Path,
863 orig: &str,
864 onto_sha: &str,
865 touched: &[String],
866) -> Result<(String, CompletedProblem)> {
867 let unmerged = git::unmerged_paths(scratch).await.unwrap_or_default();
868 let head = git::rev_parse(scratch, "HEAD").await.unwrap_or_default();
869 let mut candidates: Vec<String> = touched.to_vec();
874 if let Ok(changed) = git::git(scratch, &["diff", "--name-only", onto_sha, "HEAD"]).await {
875 for p in changed.lines().map(str::trim).filter(|l| !l.is_empty()) {
876 if !candidates.iter().any(|c| c == p) {
877 candidates.push(p.to_owned());
878 }
879 }
880 }
881 let mut marked = Vec::new();
882 for p in candidates {
883 if has_markers(scratch, &p, &[orig, onto_sha]).await? {
884 marked.push(p);
885 }
886 }
887
888 let emptied = head == onto_sha
893 && git::cherry(repo, onto_sha, orig)
894 .await
895 .map_or(true, |(unmatched, _)| !unmatched.is_empty());
896
897 let dropped = if unmerged.is_empty() && marked.is_empty() && !emptied && !head.is_empty() {
902 dropped_commits(repo, scratch, onto_sha, orig, &head).await
903 } else {
904 Ok(Vec::new())
905 };
906
907 let problem = if !unmerged.is_empty() {
908 Some(("paths are still unmerged", unmerged))
909 } else if !marked.is_empty() {
910 Some(("conflict markers were left in the tree", marked))
911 } else if emptied {
912 Some((
913 "the rebase ended with none of the branch's commits applied (all skipped)",
914 touched.to_vec(),
915 ))
916 } else if head.is_empty() || !git::is_ancestor(repo, onto_sha, &head).await {
917 Some((
918 "the rebase ended without the base in the result (abandoned or skipped)",
919 touched.to_vec(),
920 ))
921 } else if let Err(error) = &dropped {
922 Some((
923 "the rebase record could not be verified",
924 vec![format!("{error:#}")],
925 ))
926 } else if !dropped.as_ref().unwrap().is_empty() {
927 Some((
928 "the rebase dropped some of the branch's commits (skipped?)",
929 dropped.unwrap(),
930 ))
931 } else {
932 None
933 };
934 Ok((head, problem))
935}
936
937pub(crate) async fn recover_completed_base(
939 state: &RunState,
940 scratch: &Path,
941 branch: &str,
942 op: &crate::base_sync_recovery::Operation,
943 head: &str,
944) -> Result<bool> {
945 anyhow::ensure!(
946 scratch == state.dir().join("base-sync"),
947 "unexpected scratch location"
948 );
949 anyhow::ensure!(
950 !git::rebase_in_progress(scratch).await,
951 "scratch rebase is still active"
952 );
953 for (path, revision, expected) in [
954 (scratch, "HEAD".to_owned(), head),
955 (scratch, "ORIG_HEAD".to_owned(), op.head.as_str()),
956 (state.repo.as_path(), format!("refs/heads/{branch}"), head),
957 ] {
958 anyhow::ensure!(
959 git::rev_parse(path, &revision).await? == expected,
960 "completed scratch identity changed"
961 );
962 }
963 anyhow::ensure!(
964 git::current_branch(scratch).await?.as_deref() == Some(branch),
965 "scratch belongs to another branch"
966 );
967 let common = |path: &Path| {
968 let path = path.to_owned();
969 async move {
970 let common = git::git(
971 &path,
972 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
973 )
974 .await?;
975 anyhow::Ok(std::fs::canonicalize(common)?)
976 }
977 };
978 anyhow::ensure!(
979 common(scratch).await? == common(&state.repo).await?,
980 "scratch belongs to another repository"
981 );
982 anyhow::ensure!(
983 git::is_clean(scratch).await?,
984 "completed scratch has uncommitted changes"
985 );
986 let record = git::rebase_record(scratch).await?;
987 let mut previous = op.tip.as_str();
990 for result in &record.heads {
991 if result != previous {
992 anyhow::ensure!(
993 git::rev_parse(scratch, &format!("{result}^")).await? == previous,
994 "recorded replay does not start at the reserved base or contains extra commits"
995 );
996 }
997 previous = result;
998 }
999 for pick in record.picks {
1000 anyhow::ensure!(
1001 git::is_ancestor(&state.repo, &pick, &op.head).await,
1002 "record contains a foreign pick"
1003 );
1004 }
1005 let touched: Vec<_> = state
1006 .rebase_fixes
1007 .iter()
1008 .filter(|round| round.from.as_deref() == Some(op.head.as_str()))
1009 .flat_map(|round| round.paths.iter().cloned())
1010 .collect();
1011 let (result, problem) =
1012 completed_result(&state.repo, scratch, &op.head, &op.tip, &touched).await?;
1013 Ok(result == head && problem.is_none())
1014}
1015
1016pub(crate) async fn finish(
1018 state: &mut RunState,
1019 scratch: &Path,
1020 branch: &str,
1021 orig: &str,
1022 onto_sha: &str,
1023 touched: &[String],
1024 said: &str,
1025) -> Result<Rebased> {
1026 let repo = state.repo.clone();
1027 let spent = state.rebase_fixes.len();
1028 let cap = state.config.graph.review_rounds;
1029 let (head, problem) = completed_result(&repo, scratch, orig, onto_sha, touched).await?;
1030 match problem {
1031 None => {
1032 if scratch == state.dir().join("base-sync")
1036 && state.winner().is_some_and(|winner| winner.branch == branch)
1037 && let Some(op) = state.base_sync_recovery.operations.last_mut()
1038 && !op.finished
1039 && op.head == orig
1040 && op.tip == onto_sha
1041 {
1042 op.validated_head = Some(head.clone());
1043 state.save()?;
1044 }
1045 git::worktree_remove(&repo, scratch).await.ok();
1046 state.event(
1047 "rebase",
1048 format!("{branch} rebased after {spent} fixer round(s)"),
1049 );
1050 state.save()?;
1051 Ok(Rebased::Applied)
1052 }
1053 Some((what, paths)) => {
1054 let why = reason(spent, cap, &paths, said, what);
1055 abandon(&repo, scratch, branch, orig).await;
1056 Ok(Rebased::Stopped(why))
1057 }
1058 }
1059}
1060
1061async fn dropped_commits(
1064 repo: &Path,
1065 scratch: &Path,
1066 onto_sha: &str,
1067 orig: &str,
1068 head: &str,
1069) -> Result<Vec<String>> {
1070 let record = git::rebase_record(scratch).await?;
1071 anyhow::ensure!(
1072 record.heads.last().map(String::as_str) == Some(head),
1073 "rebase final HEAD disagrees with record"
1074 );
1075 let mut previous = onto_sha;
1076 let mut lost = Vec::new();
1077 for (sha, picked_head) in record.picks.iter().zip(&record.heads) {
1078 if picked_head == previous
1079 && !absorbed_in_result(repo, sha, onto_sha, orig, head).await?
1080 && !already_in_result(repo, sha, orig, head).await?
1081 {
1082 lost.push(git::git(repo, &["show", "-s", "--format=%s", sha]).await?);
1083 }
1084 previous = picked_head;
1085 }
1086 Ok(lost)
1087}
1088
1089async fn commit_paths(repo: &Path, sha: &str) -> Result<Vec<String>> {
1092 git::git(
1093 repo,
1094 &[
1095 "diff-tree",
1096 "--no-commit-id",
1097 "--name-only",
1098 "-r",
1099 "--root",
1100 "-z",
1101 sha,
1102 ],
1103 )
1104 .await
1105 .map(|o| {
1106 o.split('\0')
1107 .filter(|l| !l.is_empty())
1108 .map(str::to_owned)
1109 .collect()
1110 })
1111}
1112
1113async fn entry(repo: &Path, rev: &str, path: &str) -> Result<Option<String>> {
1116 let out = git::git(repo, &["--literal-pathspecs", "ls-tree", rev, "--", path]).await?;
1117 Ok(out
1118 .split('\t')
1119 .next()
1120 .filter(|e| !e.is_empty())
1121 .map(str::to_owned))
1122}
1123
1124async fn already_in_result(repo: &Path, sha: &str, orig: &str, head: &str) -> Result<bool> {
1133 if replay_is_noop(repo, sha, head).await? {
1134 return Ok(true);
1135 }
1136 for p in &commit_paths(repo, sha).await? {
1137 let got = entry(repo, head, p).await?;
1138 if got != entry(repo, sha, p).await? && got != entry(repo, orig, p).await? {
1139 return Ok(false);
1140 }
1141 }
1142 Ok(true)
1143}
1144
1145async fn replay_is_noop(repo: &Path, sha: &str, head: &str) -> Result<bool> {
1148 let base = format!("{sha}^");
1149 let out = git::git_raw(
1150 repo,
1151 &[
1152 "merge-tree",
1153 "--write-tree",
1154 &format!("--merge-base={base}"),
1155 head,
1156 sha,
1157 ],
1158 )
1159 .await?;
1160 anyhow::ensure!(
1161 out.code == Some(0) || out.code == Some(1),
1162 "merge-tree failed: {}",
1163 out.stderr
1164 );
1165 if !out.ok() {
1166 return Ok(false);
1167 }
1168 let merged = out
1169 .stdout
1170 .lines()
1171 .next()
1172 .context("merge-tree returned no tree")?
1173 .trim();
1174 Ok(merged == git::tree_of(repo, head).await?)
1175}
1176
1177async fn absorbed_in_result(
1184 repo: &Path,
1185 sha: &str,
1186 onto: &str,
1187 orig: &str,
1188 head: &str,
1189) -> Result<bool> {
1190 if !replay_is_noop(repo, sha, onto).await? {
1191 return Ok(false);
1192 }
1193 let paths = commit_paths(repo, sha).await?;
1194 if paths.is_empty() {
1195 return Ok(false);
1196 }
1197 let base = path_tree(repo, sha, &paths).await?;
1198 let upstream = path_tree(repo, onto, &paths).await?;
1199 let branch = path_tree(repo, orig, &paths).await?;
1200 let result = path_tree(repo, head, &paths).await?;
1201 let out = git::git_raw(
1202 repo,
1203 &[
1204 "merge-tree",
1205 "--write-tree",
1206 &format!("--merge-base={base}"),
1207 &upstream,
1208 &branch,
1209 ],
1210 )
1211 .await?;
1212 anyhow::ensure!(
1213 out.code == Some(0) || out.code == Some(1),
1214 "merge-tree failed: {}",
1215 out.stderr
1216 );
1217 if !out.ok() {
1218 return Ok(false);
1219 }
1220 Ok(out
1221 .stdout
1222 .lines()
1223 .next()
1224 .context("merge-tree returned no tree")?
1225 == result)
1226}
1227
1228async fn path_tree(repo: &Path, rev: &str, paths: &[String]) -> Result<String> {
1233 use crate::proc::Quiet as _;
1234 use std::fmt::Write as _;
1235 use std::process::Stdio;
1236 use tokio::io::AsyncWriteExt as _;
1237
1238 let mut entries = String::new();
1239 for (i, path) in paths.iter().enumerate() {
1240 let out = git::git(
1241 repo,
1242 &["--literal-pathspecs", "ls-tree", "-z", rev, "--", path],
1243 )
1244 .await?;
1245 if !out.is_empty() {
1246 let (entry, _) = out
1247 .split_once('\t')
1248 .context("ls-tree returned an invalid entry")?;
1249 writeln!(entries, "{entry}\t{i:016x}")?;
1250 }
1251 }
1252 #[cfg(test)]
1253 crate::web::request_cost::git();
1254 let mut child = tokio::process::Command::new("git")
1255 .args(["mktree"])
1256 .current_dir(repo)
1257 .quiet()
1258 .stdin(Stdio::piped())
1259 .stdout(Stdio::piped())
1260 .stderr(Stdio::piped())
1261 .kill_on_drop(true)
1262 .spawn()?;
1263 let mut stdin = child.stdin.take().context("mktree stdin missing")?;
1264 stdin.write_all(entries.as_bytes()).await?;
1265 drop(stdin);
1266 let out = child.wait_with_output().await?;
1267 anyhow::ensure!(
1268 out.status.success(),
1269 "mktree failed: {}",
1270 String::from_utf8_lossy(&out.stderr)
1271 );
1272 Ok(String::from_utf8(out.stdout)?.trim().to_owned())
1273}
1274
1275async fn abandon(repo: &Path, scratch: &Path, branch: &str, orig: &str) {
1278 git::rebase_abort(repo, scratch).await;
1279 let full = format!("refs/heads/{branch}");
1280 if git::rev_parse(repo, &full).await.ok().as_deref() != Some(orig) {
1281 git::git_raw(repo, &["update-ref", &full, orig]).await.ok();
1282 }
1283}
1284
1285fn reason(spent: usize, cap: usize, paths: &[String], said: &str, what: &str) -> String {
1288 let shown: Vec<&str> = paths
1289 .iter()
1290 .take(PATHS_IN_REASON)
1291 .map(String::as_str)
1292 .collect();
1293 let mut list = shown.join(", ");
1294 if paths.len() > shown.len() {
1295 list.push_str(&format!(" and {} more", paths.len() - shown.len()));
1296 }
1297 if list.is_empty() {
1298 list.push_str("none recorded");
1299 }
1300 let mut s = format!(
1301 "conflict not resolved after {spent} of {cap} fixer round(s) ({what}); remaining \
1302 conflicted path(s): {list}"
1303 );
1304 let said = git::rebase_said(said);
1305 let said = said.trim();
1306 if !said.is_empty() {
1307 s.push_str("; git said: ");
1308 s.extend(said.chars().take(250));
1309 }
1310 s
1311}
1312
1313async fn subjects(worktree: &Path, range: &str) -> Vec<String> {
1315 let n = format!("-n{SUBJECTS}");
1316 git::git(worktree, &["log", "--format=%s", &n, range])
1317 .await
1318 .map(|o| o.lines().map(str::to_owned).collect())
1319 .unwrap_or_default()
1320}
1321
1322async fn has_markers(worktree: &Path, path: &str, sides: &[&str]) -> Result<bool> {
1326 let Ok(text) = std::fs::read_to_string(worktree.join(path)) else {
1327 return Ok(false);
1328 };
1329 if text
1330 .lines()
1331 .any(|l| l.starts_with("<<<<<<<") || l.starts_with(">>>>>>>") || l.starts_with("|||||||"))
1332 {
1333 return Ok(true);
1334 }
1335 fn separators(text: &str) -> std::collections::BTreeMap<String, usize> {
1336 let mut counts = std::collections::BTreeMap::new();
1337 let mut previous = "";
1338 for line in text.lines() {
1339 if line == "=======" {
1340 *counts.entry(previous.to_owned()).or_default() += 1;
1341 }
1342 previous = line;
1343 }
1344 counts
1345 }
1346 let current = separators(&text);
1347 if current.is_empty() {
1348 return Ok(false);
1349 }
1350 let mut existing = std::collections::BTreeMap::<String, usize>::new();
1351 for side in sides {
1352 let spec = format!("{side}:{path}");
1355 let exists = git::git_raw(worktree, &["cat-file", "-e", &spec]).await?;
1356 if exists.ok() {
1357 for (heading, count) in separators(&git::git(worktree, &["show", &spec]).await?) {
1358 let before = existing.entry(heading).or_default();
1359 *before = (*before).max(count);
1360 }
1361 } else {
1362 git::rev_parse(worktree, &format!("{side}^{{tree}}")).await?;
1365 }
1366 }
1367 Ok(current
1368 .into_iter()
1369 .any(|(heading, count)| count > existing.get(&heading).copied().unwrap_or(0)))
1370}
1371
1372fn hunks(worktree: &Path, paths: &[String]) -> String {
1375 let mut out = String::new();
1376 for p in paths {
1377 if out.len() >= HUNK_TOTAL {
1378 out.push_str("\n(more conflicted files omitted)\n");
1379 break;
1380 }
1381 out.push_str(&format!("=== {p} ===\n"));
1382 let Ok(text) = std::fs::read_to_string(worktree.join(p)) else {
1383 out.push_str("(not readable as text; use git to inspect it)\n");
1384 continue;
1385 };
1386 let mut file = String::new();
1387 let mut inside = false;
1388 for line in text.lines() {
1389 if line.starts_with("<<<<<<< ") {
1390 inside = true;
1391 }
1392 if inside {
1393 file.push_str(line);
1394 file.push('\n');
1395 }
1396 if line.starts_with(">>>>>>> ") {
1397 inside = false;
1398 }
1399 }
1400 if file.len() > HUNK_PER_FILE {
1401 file = file.chars().take(HUNK_PER_FILE).collect();
1402 file.push_str("\n(truncated)\n");
1403 }
1404 out.push_str(&file);
1405 }
1406 out
1407}
1408
1409#[cfg(test)]
1410mod tests {
1411 use super::*;
1412 use crate::proc::Quiet as _;
1413 use std::path::PathBuf;
1414
1415 fn sh(dir: &Path, args: &[&str]) {
1416 let o = std::process::Command::new("git")
1417 .quiet()
1418 .args(args)
1419 .current_dir(dir)
1420 .output()
1421 .unwrap();
1422 assert!(
1423 o.status.success(),
1424 "{args:?}: {}",
1425 String::from_utf8_lossy(&o.stderr)
1426 );
1427 }
1428
1429 async fn record(d: &Path, picks: &[String], heads: &[String]) {
1430 for (name, values) in [("magi-rebase-picks", picks), ("magi-rebase-heads", heads)] {
1431 let path = git::git(d, &["rev-parse", "--git-path", name])
1432 .await
1433 .unwrap();
1434 std::fs::write(d.join(path), values.join("\n") + "\n").unwrap();
1435 }
1436 }
1437
1438 #[tokio::test]
1439 async fn a_lost_mode_change_is_not_already_in_the_result() {
1440 let t = tempfile::tempdir().unwrap();
1441 let d = t.path();
1442 sh(d, &["init", "-q", "-b", "main"]);
1443 sh(d, &["config", "user.name", "t"]);
1444 sh(d, &["config", "user.email", "t@example.com"]);
1445 sh(d, &["config", "core.fileMode", "true"]);
1446 std::fs::write(d.join("script.sh"), "echo\n").unwrap();
1447 sh(d, &["add", "-A"]);
1448 sh(d, &["commit", "-q", "-m", "base"]);
1449 sh(d, &["update-index", "--chmod=+x", "script.sh"]);
1450 sh(d, &["commit", "-q", "-m", "chmod"]);
1451 let sha = git::git(d, &["rev-parse", "HEAD"]).await.unwrap();
1452 let base = git::git(d, &["rev-parse", "HEAD~1"]).await.unwrap();
1453 assert!(!already_in_result(d, &sha, &sha, &base).await.unwrap());
1455 assert!(already_in_result(d, &sha, &sha, &sha).await.unwrap());
1456 }
1457
1458 async fn commit(d: &Path, msg: &str) -> String {
1459 sh(d, &["add", "-A"]);
1460 sh(d, &["commit", "-q", "-m", msg]);
1461 git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
1462 }
1463
1464 #[tokio::test]
1465 async fn candidate_path_trees_preserve_modes_deletions_and_unusual_names() {
1466 let t = tempfile::tempdir().unwrap();
1467 let d = t.path();
1468 sh(d, &["init", "-q", "-b", "main"]);
1469 sh(d, &["config", "user.name", "t"]);
1470 sh(d, &["config", "user.email", "t@example.com"]);
1471 sh(d, &["config", "core.fileMode", "true"]);
1472 let path = if cfg!(windows) {
1473 "nested/日本語 [literal].txt"
1474 } else {
1475 "nested/日本語\tline\n.txt"
1476 };
1477 std::fs::create_dir(d.join("nested")).unwrap();
1478 std::fs::write(d.join(path), "contents\n").unwrap();
1479 std::fs::write(d.join("g.txt"), "old\n").unwrap();
1480 let base = commit(d, "base").await;
1481 let paths = vec![path.to_owned()];
1482 let tree = path_tree(d, &base, &paths).await.unwrap();
1483 std::fs::write(d.join("g.txt"), "unrelated\n").unwrap();
1484 let other = commit(d, "unrelated path").await;
1485 assert_eq!(path_tree(d, &other, &paths).await.unwrap(), tree);
1486 sh(d, &["update-index", "--chmod=+x", path]);
1487 sh(d, &["commit", "-q", "-m", "chmod"]);
1488 assert_ne!(path_tree(d, "HEAD", &paths).await.unwrap(), tree);
1489 std::fs::remove_file(d.join(path)).unwrap();
1490 let removed = commit(d, "delete").await;
1491 let absent = path_tree(d, &removed, &paths).await.unwrap();
1492 assert_ne!(absent, tree);
1493 assert_eq!(absent, path_tree(d, &removed, &[]).await.unwrap());
1494 assert!(path_tree(d, "not-a-revision", &paths).await.is_err());
1495 }
1496
1497 #[tokio::test]
1498 async fn a_change_inside_a_larger_upstream_edit_is_in_the_result() {
1499 let t = tempfile::tempdir().unwrap();
1500 let d = t.path();
1501 sh(d, &["init", "-q", "-b", "main"]);
1502 sh(d, &["config", "user.name", "t"]);
1503 sh(d, &["config", "user.email", "t@example.com"]);
1504 let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
1505 std::fs::write(d.join("f.txt"), body).unwrap();
1506 commit(d, "base").await;
1507 std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1508 let c = commit(d, "c1").await;
1509 sh(d, &["checkout", "-q", "-b", "up", "HEAD~1"]);
1510 let up = body.replacen("old", "new", 1).replace("end", "end plus");
1511 std::fs::write(d.join("f.txt"), up).unwrap();
1512 let head = commit(d, "upstream").await;
1513 assert!(already_in_result(d, &c, &c, &head).await.unwrap());
1514 }
1515
1516 #[tokio::test]
1517 async fn a_dropped_commit_on_a_non_ascii_path_is_still_noticed() {
1518 let t = tempfile::tempdir().unwrap();
1519 let d = t.path();
1520 sh(d, &["init", "-q", "-b", "main"]);
1521 sh(d, &["config", "user.name", "t"]);
1522 sh(d, &["config", "user.email", "t@example.com"]);
1523 std::fs::write(d.join("a.txt"), "a\n").unwrap();
1524 let base = commit(d, "base").await;
1525 std::fs::write(d.join("日本語.txt"), "x\n").unwrap();
1526 let c = commit(d, "c").await;
1527 assert!(!already_in_result(d, &c, &c, &base).await.unwrap());
1528 }
1529
1530 async fn commit_dated(d: &Path, msg: &str) -> String {
1531 sh(d, &["add", "-A"]);
1532 let st = std::process::Command::new("git")
1533 .quiet()
1534 .current_dir(d)
1535 .env("GIT_AUTHOR_DATE", "2020-01-01T00:00:00+0000")
1536 .env("GIT_COMMITTER_DATE", "2020-01-01T00:00:00+0000")
1537 .args(["commit", "-q", "-m", msg])
1538 .status()
1539 .unwrap();
1540 assert!(st.success());
1541 git::git(d, &["rev-parse", "HEAD"]).await.unwrap()
1542 }
1543
1544 #[tokio::test]
1545 async fn a_recorded_resolved_pick_after_an_absorbed_one_is_not_lost() {
1546 let t = tempfile::tempdir().unwrap();
1547 let d = t.path();
1548 sh(d, &["init", "-q", "-b", "main"]);
1549 sh(d, &["config", "user.name", "t"]);
1550 sh(d, &["config", "user.email", "t@example.com"]);
1551 std::fs::write(d.join("z.txt"), "z\n").unwrap();
1552 let base = commit(d, "base").await;
1553 std::fs::write(d.join("a.txt"), "a\n").unwrap();
1554 commit_dated(d, "same").await;
1555 std::fs::write(d.join("b.txt"), "b\n").unwrap();
1556 let orig = commit_dated(d, "same").await;
1557 sh(d, &["checkout", "-q", "-b", "up", &base]);
1560 std::fs::write(d.join("a.txt"), "a\n").unwrap();
1561 std::fs::write(d.join("y.txt"), "y\n").unwrap();
1562 std::fs::write(d.join("b.txt"), "other\n").unwrap();
1563 let onto = commit(d, "upstream").await;
1564 std::fs::write(d.join("b.txt"), "other\nb\n").unwrap();
1566 let head = commit_dated(d, "same").await;
1567 record(
1568 d,
1569 &[
1570 git::rev_parse(d, &format!("{orig}^")).await.unwrap(),
1571 orig.clone(),
1572 ],
1573 &[onto.clone(), head.clone()],
1574 )
1575 .await;
1576 assert!(
1577 dropped_commits(d, d, &onto, &orig, &head)
1578 .await
1579 .unwrap()
1580 .is_empty()
1581 );
1582 }
1583
1584 #[tokio::test]
1585 async fn a_skipped_commit_is_not_masked_by_a_surviving_one_in_the_same_file() {
1586 let t = tempfile::tempdir().unwrap();
1587 let d = t.path();
1588 sh(d, &["init", "-q", "-b", "main"]);
1589 sh(d, &["config", "user.name", "t"]);
1590 sh(d, &["config", "user.email", "t@example.com"]);
1591 let body = "first\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
1592 std::fs::write(d.join("f.txt"), body).unwrap();
1593 let base = commit(d, "base").await;
1594 std::fs::write(d.join("f.txt"), body.replacen("first", "mine", 1)).unwrap();
1595 commit_dated(d, "same").await;
1596 let two = body
1597 .replacen("first", "mine", 1)
1598 .replacen("last", "tail", 1);
1599 std::fs::write(d.join("f.txt"), &two).unwrap();
1600 let orig = commit_dated(d, "same").await;
1601 sh(d, &["checkout", "-q", "-b", "up", &base]);
1602 std::fs::write(d.join("f.txt"), body.replacen("first", "theirs", 1)).unwrap();
1603 let onto = commit(d, "upstream").await;
1604 std::fs::write(
1606 d.join("f.txt"),
1607 body.replacen("first", "theirs", 1)
1608 .replacen("last", "tail", 1),
1609 )
1610 .unwrap();
1611 let head = commit_dated(d, "same").await;
1612 record(
1613 d,
1614 &[
1615 git::rev_parse(d, &format!("{orig}^")).await.unwrap(),
1616 orig.clone(),
1617 ],
1618 &[onto.clone(), head.clone()],
1619 )
1620 .await;
1621 assert_eq!(
1622 dropped_commits(d, d, &onto, &orig, &head).await.unwrap(),
1623 vec!["same"]
1624 );
1625 }
1626
1627 #[tokio::test]
1628 async fn git_records_a_skipped_middle_pick_after_an_applied_pick() {
1629 let t = tempfile::tempdir().unwrap();
1630 let d = t.path();
1631 sh(d, &["init", "-q", "-b", "main"]);
1632 sh(d, &["config", "user.name", "t"]);
1633 sh(d, &["config", "user.email", "t@example.com"]);
1634 std::fs::write(d.join("f"), "base\n").unwrap();
1635 let base = commit(d, "base").await;
1636 sh(d, &["checkout", "-q", "-b", "topic"]);
1637 std::fs::write(d.join("absorbed"), "upstream too\n").unwrap();
1638 commit(d, "already upstream").await;
1639 std::fs::write(d.join("a"), "first\n").unwrap();
1640 let first = commit(d, "first").await;
1641 std::fs::write(d.join("f"), "middle\n").unwrap();
1642 let middle = commit(d, "lost middle").await;
1643 std::fs::write(d.join("z"), "last\n").unwrap();
1644 let orig = commit(d, "last").await;
1645 sh(d, &["checkout", "-q", "main"]);
1646 std::fs::write(d.join("absorbed"), "upstream too\n").unwrap();
1647 commit(d, "patch twin").await;
1648 std::fs::write(d.join("f"), "upstream\n").unwrap();
1649 let onto = commit(d, "upstream").await;
1650 let scratch = d.join("scratch");
1651 let git::RebaseStart::Conflicted(why) = git::rebase_start(d, &scratch, "topic", &onto)
1652 .await
1653 .unwrap()
1654 else {
1655 panic!("expected a conflict");
1656 };
1657 assert!(!why.contains("Executing:"), "{why}");
1658 let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1659 assert!(skipped.ok(), "{}", skipped.stderr);
1660 let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1661 let record = git::rebase_record(&scratch).await.unwrap();
1662 assert_eq!(record.picks, vec![first, middle, orig.clone()]);
1663 assert_eq!(record.heads[0], record.heads[1]);
1664 assert_ne!(record.heads[1], record.heads[2]);
1665 assert_eq!(
1666 dropped_commits(d, &scratch, &onto, &orig, &head)
1667 .await
1668 .unwrap(),
1669 vec!["lost middle"]
1670 );
1671 git::rebase_abort(d, &scratch).await;
1672 assert!(git::is_ancestor(d, &base, &head).await);
1673 }
1674
1675 #[test]
1676 fn recording_diagnostics_do_not_hide_other_git_output() {
1677 assert_eq!(
1678 git::rebase_said("before\nExecuting: record\nafter\n"),
1679 "before\nafter"
1680 );
1681 }
1682
1683 async fn rebase_successive_edits(
1688 absorbed: bool,
1689 extra_hunk: bool,
1690 unrelated_conflict: bool,
1691 ) -> Rebased {
1692 rebase_successive_edits_record(absorbed, extra_hunk, unrelated_conflict, None).await
1693 }
1694
1695 async fn rebase_successive_edits_record(
1696 absorbed: bool,
1697 extra_hunk: bool,
1698 unrelated_conflict: bool,
1699 damage: Option<&str>,
1700 ) -> Rebased {
1701 crate::run::pin_test_home();
1702 let t = tempfile::tempdir().unwrap();
1703 let d = t.path();
1704 sh(d, &["init", "-q", "-b", "main"]);
1705 sh(d, &["config", "user.name", "t"]);
1706 sh(d, &["config", "user.email", "t@example.com"]);
1707 sh(d, &["config", "core.autocrlf", "false"]);
1710 let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nend\n";
1711 std::fs::write(d.join("f.txt"), body).unwrap();
1712 std::fs::write(d.join("conflict.txt"), "old\n").unwrap();
1713 if unrelated_conflict {
1714 std::fs::write(d.join("g.txt"), "old\n").unwrap();
1715 }
1716 let base = commit(d, "base").await;
1717 sh(d, &["checkout", "-q", "-b", "topic"]);
1718 std::fs::write(d.join("conflict.txt"), "branch\n").unwrap();
1719 commit(d, "c0").await;
1720 let mut middle = body.replacen("old", "new", 1);
1721 if extra_hunk {
1722 middle = middle.replace("\n5\n", "\nmissing\n");
1723 }
1724 std::fs::write(d.join("f.txt"), &middle).unwrap();
1725 let c1 = commit(d, "c1").await;
1726 std::fs::write(d.join("f.txt"), middle.replacen("new", "newer", 1)).unwrap();
1727 if unrelated_conflict {
1728 std::fs::write(d.join("g.txt"), "branch\n").unwrap();
1729 }
1730 let orig = commit(d, "c2").await;
1731 sh(d, &["checkout", "-q", "main"]);
1732 std::fs::write(d.join("conflict.txt"), "branch plus upstream\n").unwrap();
1733 let mut upstream = body
1734 .replacen("old", if absorbed { "new" } else { "theirs" }, 1)
1735 .replace("end", "upstream tail");
1736 if extra_hunk {
1737 upstream = upstream.replace("\n5\n", "\nupstream middle\n");
1738 }
1739 std::fs::write(d.join("f.txt"), &upstream).unwrap();
1740 if unrelated_conflict {
1741 std::fs::write(d.join("g.txt"), "upstream\n").unwrap();
1742 }
1743 let onto = commit(d, "upstream composite").await;
1744 assert!(git::cherry(d, &onto, &orig).await.unwrap().0.contains(&c1));
1746 let scratch = d.join("scratch");
1747 assert!(matches!(
1748 git::rebase_start(d, &scratch, "topic", &onto)
1749 .await
1750 .unwrap(),
1751 git::RebaseStart::Conflicted(_)
1752 ));
1753 std::fs::write(
1754 scratch.join("conflict.txt"),
1755 "branch plus upstream resolved\n",
1756 )
1757 .unwrap();
1758 sh(&scratch, &["add", "conflict.txt"]);
1759 let continued = git::git_raw(
1760 &scratch,
1761 &["-c", "core.editor=true", "rebase", "--continue"],
1762 )
1763 .await
1764 .unwrap();
1765 if absorbed && !extra_hunk {
1766 if unrelated_conflict {
1769 assert!(!continued.ok());
1770 assert_eq!(git::unmerged_paths(&scratch).await.unwrap(), vec!["g.txt"]);
1771 assert_eq!(git::rev_parse(&scratch, "REBASE_HEAD").await.unwrap(), orig);
1772 std::fs::write(scratch.join("g.txt"), "branch plus upstream resolved\n").unwrap();
1773 sh(&scratch, &["add", "g.txt"]);
1774 let resolved = git::git_raw(&scratch, &["rebase", "--continue"])
1775 .await
1776 .unwrap();
1777 assert!(resolved.ok(), "{}", resolved.stderr);
1778 let full = git::git_raw(
1781 d,
1782 &[
1783 "merge-tree",
1784 "--write-tree",
1785 &format!("--merge-base={c1}"),
1786 &onto,
1787 &orig,
1788 ],
1789 )
1790 .await
1791 .unwrap();
1792 assert_eq!(full.code, Some(1));
1793 } else {
1794 assert!(continued.ok(), "{}", continued.stderr);
1795 }
1796 } else {
1797 assert!(!continued.ok());
1798 assert_eq!(git::unmerged_paths(&scratch).await.unwrap(), vec!["f.txt"]);
1799 let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1800 if !absorbed {
1801 assert!(!skipped.ok());
1803 std::fs::write(
1804 scratch.join("f.txt"),
1805 upstream.replacen("theirs", "newer", 1),
1806 )
1807 .unwrap();
1808 sh(&scratch, &["add", "f.txt"]);
1809 let resolved = git::git_raw(
1810 &scratch,
1811 &["-c", "core.editor=true", "rebase", "--continue"],
1812 )
1813 .await
1814 .unwrap();
1815 assert!(resolved.ok(), "{}", resolved.stderr);
1816 } else {
1817 assert!(skipped.ok(), "{}", skipped.stderr);
1818 }
1819 }
1820 assert!(!git::rebase_in_progress(&scratch).await);
1821 assert_eq!(
1822 std::fs::read_to_string(scratch.join("f.txt")).unwrap(),
1823 upstream.replacen(if absorbed { "new" } else { "theirs" }, "newer", 1)
1824 );
1825 let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1826 assert!(!already_in_result(d, &c1, &orig, &head).await.unwrap());
1828 let mut state = RunState::new(
1829 d.to_path_buf(),
1830 "main".into(),
1831 base,
1832 "rebase".into(),
1833 crate::config::Config::default(),
1834 );
1835 if let Some(damage) = damage {
1836 let path = git::git(&scratch, &["rev-parse", "--git-path", "magi-rebase-heads"])
1837 .await
1838 .unwrap();
1839 match damage {
1840 "missing" => std::fs::remove_file(&path).unwrap(),
1841 "count" => std::fs::write(&path, "").unwrap(),
1842 "final" => {
1843 let text = std::fs::read_to_string(&path).unwrap();
1844 let mut heads: Vec<_> = text.lines().map(str::to_owned).collect();
1845 *heads.last_mut().unwrap() = onto.clone();
1846 std::fs::write(&path, heads.join("\n") + "\n").unwrap();
1847 }
1848 "invalid" => std::fs::write(&path, "invalid\n").unwrap(),
1849 _ => unreachable!(),
1850 }
1851 }
1852 let outcome = finish(
1853 &mut state,
1854 &scratch,
1855 "topic",
1856 &orig,
1857 &onto,
1858 &["conflict.txt".into()],
1859 "",
1860 )
1861 .await
1862 .unwrap();
1863 std::fs::remove_dir_all(state.dir()).ok();
1864 outcome
1865 }
1866
1867 #[tokio::test]
1868 async fn missing_or_inconsistent_rebase_records_stop_the_rebase() {
1869 for damage in ["missing", "count", "final", "invalid"] {
1870 let outcome = rebase_successive_edits_record(true, false, false, Some(damage)).await;
1871 assert!(
1872 matches!(outcome, Rebased::Stopped(why) if why.contains("record could not be verified")),
1873 "{damage}"
1874 );
1875 }
1876 }
1877
1878 #[tokio::test]
1879 async fn an_absorbed_middle_commit_superseded_by_a_later_edit_is_applied() {
1880 assert_eq!(
1881 rebase_successive_edits(true, false, false).await,
1882 Rebased::Applied
1883 );
1884 }
1885
1886 #[tokio::test]
1887 async fn an_unrelated_later_conflict_does_not_hide_an_absorbed_commit() {
1888 assert_eq!(
1889 rebase_successive_edits(true, false, true).await,
1890 Rebased::Applied
1891 );
1892 }
1893
1894 #[tokio::test]
1895 async fn a_skipped_middle_commit_superseded_by_a_later_edit_is_dropped() {
1896 let Rebased::Stopped(why) = rebase_successive_edits(false, false, false).await else {
1897 panic!("a skipped middle commit must stop the rebase");
1898 };
1899 assert!(why.contains("the rebase dropped some of the branch's commits (skipped?)"));
1900 assert!(why.contains("c1"));
1901 }
1902
1903 #[tokio::test]
1904 async fn a_partially_absorbed_middle_commit_with_a_skipped_hunk_is_dropped() {
1905 let Rebased::Stopped(why) = rebase_successive_edits(true, true, false).await else {
1906 panic!("an unabsorbed hunk must stop the rebase");
1907 };
1908 assert!(why.contains("the rebase dropped some of the branch's commits (skipped?)"));
1909 assert!(why.contains("c1"));
1910 }
1911
1912 #[tokio::test]
1913 async fn an_absorbed_commit_erased_by_the_fixer_is_dropped() {
1914 crate::run::pin_test_home();
1915 let t = tempfile::tempdir().unwrap();
1916 let d = t.path();
1917 sh(d, &["init", "-q", "-b", "main"]);
1918 sh(d, &["config", "user.name", "t"]);
1919 sh(d, &["config", "user.email", "t@example.com"]);
1920 sh(d, &["config", "rerere.enabled", "false"]);
1923 let body =
1924 "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlater\n10\n11\n12\n13\n14\n15\n16\n17\n18\nend\n";
1925 std::fs::write(d.join("f.txt"), body).unwrap();
1926 std::fs::write(d.join("conflict.txt"), "old\n").unwrap();
1927 let base = commit(d, "base").await;
1928 sh(d, &["checkout", "-q", "-b", "topic"]);
1929 std::fs::write(d.join("conflict.txt"), "branch\n").unwrap();
1930 commit(d, "c0").await;
1931 std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
1932 let c1 = commit(d, "c1").await;
1933 std::fs::write(
1934 d.join("f.txt"),
1935 body.replacen("old", "new", 1)
1936 .replace("later", "branch tail"),
1937 )
1938 .unwrap();
1939 let orig = commit(d, "c2").await;
1940 sh(d, &["checkout", "-q", "main"]);
1941 std::fs::write(d.join("conflict.txt"), "upstream\n").unwrap();
1942 let upstream = body
1943 .replacen("old", "new", 1)
1944 .replace("end", "upstream tail");
1945 std::fs::write(d.join("f.txt"), &upstream).unwrap();
1946 let onto = commit(d, "upstream composite").await;
1947 let scratch = d.join("scratch");
1948 assert!(matches!(
1949 git::rebase_start(d, &scratch, "topic", &onto)
1950 .await
1951 .unwrap(),
1952 git::RebaseStart::Conflicted(_)
1953 ));
1954 std::fs::write(scratch.join("conflict.txt"), "resolved\n").unwrap();
1956 std::fs::write(scratch.join("f.txt"), upstream.replacen("new", "theirs", 1)).unwrap();
1957 sh(&scratch, &["add", "conflict.txt", "f.txt"]);
1958 let continued = git::git_raw(&scratch, &["rebase", "--continue"])
1959 .await
1960 .unwrap();
1961 assert_eq!(
1962 continued.code,
1963 Some(1),
1964 "{}\n{}",
1965 continued.stdout,
1966 continued.stderr
1967 );
1968 assert_eq!(git::rev_parse(&scratch, "REBASE_HEAD").await.unwrap(), c1);
1969 let skipped = git::git_raw(&scratch, &["rebase", "--skip"]).await.unwrap();
1970 assert!(skipped.ok(), "{}", skipped.stderr);
1971 let head = git::rev_parse(&scratch, "HEAD").await.unwrap();
1972 assert!(replay_is_noop(d, &c1, &onto).await.unwrap());
1973 let expected = git::git_raw(
1976 d,
1977 &[
1978 "merge-tree",
1979 "--write-tree",
1980 &format!("--merge-base={c1}"),
1981 &onto,
1982 &orig,
1983 ],
1984 )
1985 .await
1986 .unwrap();
1987 assert!(expected.ok(), "{}", expected.stderr);
1988 assert!(
1989 !absorbed_in_result(d, &c1, &onto, &orig, &head)
1990 .await
1991 .unwrap()
1992 );
1993 assert_eq!(
1994 dropped_commits(d, &scratch, &onto, &orig, &head)
1995 .await
1996 .unwrap(),
1997 vec!["c1"]
1998 );
1999 let mut state = RunState::new(
2000 d.to_path_buf(),
2001 "main".into(),
2002 base,
2003 "rebase".into(),
2004 crate::config::Config::default(),
2005 );
2006 let outcome = finish(
2007 &mut state,
2008 &scratch,
2009 "topic",
2010 &orig,
2011 &onto,
2012 &["conflict.txt".into()],
2013 "",
2014 )
2015 .await
2016 .unwrap();
2017 assert!(
2018 matches!(outcome, Rebased::Stopped(why) if why.contains("the rebase dropped some of the branch's commits (skipped?)") && why.contains("c1"))
2019 );
2020 std::fs::remove_dir_all(state.dir()).ok();
2021 }
2022
2023 #[tokio::test]
2024 async fn a_partially_absorbed_commit_missing_an_unrelated_hunk_is_dropped() {
2025 let t = tempfile::tempdir().unwrap();
2026 let d = t.path();
2027 sh(d, &["init", "-q", "-b", "main"]);
2028 sh(d, &["config", "user.name", "t"]);
2029 sh(d, &["config", "user.email", "t@example.com"]);
2030 std::fs::write(d.join("c0.txt"), "base c0\n").unwrap();
2031 let body = "old\n1\n2\n3\n4\n5\n6\n7\n8\n9\nlast\n";
2032 std::fs::write(d.join("f.txt"), body).unwrap();
2033 std::fs::write(d.join("extra.txt"), "initial\n").unwrap();
2034 let base = commit(d, "base").await;
2035
2036 std::fs::write(d.join("c0.txt"), "branch c0\n").unwrap();
2038 commit_dated(d, "c0").await;
2039 std::fs::write(d.join("f.txt"), body.replacen("old", "new", 1)).unwrap();
2040 std::fs::write(d.join("extra.txt"), "c1 edit\n").unwrap();
2041 commit_dated(d, "c1").await;
2042 std::fs::write(d.join("f.txt"), body.replacen("old", "newer", 1)).unwrap();
2043 let orig = commit_dated(d, "c2").await;
2044
2045 sh(d, &["checkout", "-q", "-b", "up", &base]);
2047 std::fs::write(d.join("c0.txt"), "upstream c0\n").unwrap();
2048 let up = body.replacen("old", "new", 1).replace("last", "tail");
2049 std::fs::write(d.join("f.txt"), &up).unwrap();
2050 let onto = commit(d, "upstream").await;
2051
2052 std::fs::write(d.join("c0.txt"), "resolved c0\n").unwrap();
2054 commit_dated(d, "c0").await;
2055 let res = up.replacen("new", "newer", 1);
2056 std::fs::write(d.join("f.txt"), &res).unwrap();
2057 let head = commit_dated(d, "c2").await;
2059
2060 let c0 = git::rev_parse(d, &format!("{orig}~2")).await.unwrap();
2061 let c1 = git::rev_parse(d, &format!("{orig}^")).await.unwrap();
2062 let resolved = git::rev_parse(d, &format!("{head}^")).await.unwrap();
2063 record(
2064 d,
2065 &[c0, c1, orig.clone()],
2066 &[resolved.clone(), resolved, head.clone()],
2067 )
2068 .await;
2069 assert_eq!(
2070 dropped_commits(d, d, &onto, &orig, &head).await.unwrap(),
2071 vec!["c1"]
2072 );
2073 }
2074
2075 const UPSTREAM: &str = "a\nupstream test\nz\n";
2078 const TOPIC: &str = "a\ntopic test\nz\n";
2079 const BOTH: &str = "a\nupstream test\ntopic test\nz\n";
2080
2081 struct Fixture {
2082 _dir: tempfile::TempDir,
2083 scratch: PathBuf,
2084 state: RunState,
2085 }
2086
2087 impl Fixture {
2088 fn read(&self, name: &str) -> String {
2089 std::fs::read_to_string(self.scratch.join(name)).unwrap()
2090 }
2091 }
2092
2093 async fn fixture(stop_recorded: bool) -> Fixture {
2097 fixture_with(stop_recorded, "f.txt", 2).await
2098 }
2099
2100 async fn fixture_with(stop_recorded: bool, conflict: &str, commits: usize) -> Fixture {
2101 fixture_stops(stop_recorded, conflict, commits, false).await
2102 }
2103
2104 async fn fixture_stops(
2105 stop_recorded: bool,
2106 conflict: &str,
2107 commits: usize,
2108 second: bool,
2109 ) -> Fixture {
2110 crate::run::pin_test_home();
2111 let t = tempfile::tempdir().unwrap();
2112 let d = t.path().join("repo");
2113 std::fs::create_dir(&d).unwrap();
2114 sh(&d, &["init", "-q", "-b", "main"]);
2115 sh(&d, &["config", "user.name", "t"]);
2116 sh(&d, &["config", "user.email", "t@example.com"]);
2117 sh(&d, &["config", "core.autocrlf", "false"]);
2118 std::fs::write(d.join(conflict), "a\nbase\nz\n").unwrap();
2119 std::fs::write(d.join("automatic"), "base\n").unwrap();
2120 sh(&d, &["add", "."]);
2121 sh(&d, &["commit", "-qm", "base"]);
2122 let base = git::rev_parse(&d, "HEAD").await.unwrap();
2123 sh(&d, &["checkout", "-qb", "topic"]);
2124 std::fs::write(d.join(conflict), TOPIC).unwrap();
2125 std::fs::write(d.join("README.md"), "Heading\n=======\n").unwrap();
2126 sh(&d, &["add", "README.md"]);
2127 std::fs::write(d.join("automatic"), "topic applied cleanly\n").unwrap();
2128 sh(&d, &["commit", "-qam", "topic test"]);
2129 std::fs::write(d.join("g.txt"), "g\n").unwrap();
2130 sh(&d, &["add", "g.txt"]);
2131 sh(&d, &["commit", "-qm", "topic g"]);
2132 for n in 2..commits {
2133 std::fs::write(d.join(format!("commit-{n}")), format!("{n}\n")).unwrap();
2134 sh(&d, &["add", "."]);
2135 sh(&d, &["commit", "-qm", &format!("topic {n}")]);
2136 }
2137 sh(&d, &["checkout", "-q", "main"]);
2138 std::fs::write(d.join(conflict), UPSTREAM).unwrap();
2139 if second {
2140 std::fs::write(d.join("g.txt"), "upstream g\n").unwrap();
2141 sh(&d, &["add", "g.txt"]);
2142 }
2143 sh(&d, &["commit", "-qam", "upstream test"]);
2144 let scratch = t.path().join("scratch");
2145 let started = git::rebase_start(&d, &scratch, "topic", "main")
2146 .await
2147 .unwrap();
2148 assert!(matches!(started, git::RebaseStart::Conflicted(_)));
2149 let mut state = RunState::new(
2150 d.clone(),
2151 "main".into(),
2152 base,
2153 "rebase".into(),
2154 crate::config::Config::default(),
2155 );
2156 let paths = git::unmerged_paths(&scratch).await.unwrap();
2157 assert_eq!(paths, vec![conflict]);
2158 state.rebase_fixes.push(RebaseFixRecord {
2159 outage: None,
2160 agent: "mock".into(),
2161 index_outside: index_outside(&scratch, &paths).await.ok(),
2162 stop: if stop_recorded {
2163 rebase_head(&scratch).await
2164 } else {
2165 None
2166 },
2167 paths,
2168 from: None,
2169 finished: false,
2170 error: None,
2171 launcher: None,
2172 infra: None,
2173 });
2174 Fixture {
2175 _dir: t,
2176 scratch,
2177 state,
2178 }
2179 }
2180
2181 async fn stage(f: &mut Fixture) -> Result<Launch> {
2182 let original = git::rev_parse(&f.state.repo, "topic").await?;
2183 let onto = git::rev_parse(&f.state.repo, "main").await?;
2184 launcher_stage(&mut f.state, &f.scratch, &original, &onto).await
2185 }
2186
2187 async fn topic_subjects(f: &Fixture) -> Vec<String> {
2188 subjects(&f.scratch, "main..HEAD").await
2189 }
2190
2191 #[tokio::test]
2192 async fn resolved_contents_with_an_unmerged_index_are_staged_and_continued() {
2193 let mut f = fixture(true).await;
2194 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2195 let out = stage(&mut f).await.unwrap();
2196 assert!(matches!(out, Launch::Advanced));
2197 assert!(!git::rebase_in_progress(&f.scratch).await);
2198 assert_eq!(f.read("f.txt"), BOTH);
2200 assert_eq!(f.read("g.txt"), "g\n");
2201 assert_eq!(topic_subjects(&f).await, vec!["topic g", "topic test"]);
2202 let rec = git::rebase_record(&f.scratch).await.unwrap();
2203 assert_eq!(rec.picks.len(), 2);
2204 assert_eq!(f.state.rebase_fixes.len(), 1, "no further round was spent");
2205 std::fs::remove_dir_all(f.state.dir()).ok();
2206 }
2207
2208 #[tokio::test]
2209 async fn a_pre_staging_record_is_matched_by_its_repeated_conflict_set() {
2210 let mut f = fixture(false).await;
2211 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2212 let again = f.state.rebase_fixes[0].clone();
2213 f.state.rebase_fixes.push(again);
2214 let out = stage(&mut f).await.unwrap();
2215 assert!(matches!(out, Launch::Advanced));
2216 assert!(!git::rebase_in_progress(&f.scratch).await);
2217
2218 let mut f = fixture(false).await;
2220 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2221 let mut other = f.state.rebase_fixes[0].clone();
2222 other.paths = vec!["elsewhere.txt".into()];
2223 f.state.rebase_fixes.insert(0, other);
2224 let out = stage(&mut f).await.unwrap();
2225 assert!(matches!(out, Launch::Nothing(_)));
2226 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2227 std::fs::remove_dir_all(f.state.dir()).ok();
2228 }
2229
2230 #[tokio::test]
2231 async fn remaining_markers_stage_nothing() {
2232 let mut f = fixture(true).await;
2233 let out = stage(&mut f).await.unwrap();
2234 assert!(matches!(&out, Launch::Nothing(why) if why.contains("markers")));
2235 assert!(git::rebase_in_progress(&f.scratch).await);
2236 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2237 assert!(f.state.resource_block.is_none());
2238 std::fs::remove_dir_all(f.state.dir()).ok();
2239 }
2240
2241 #[tokio::test]
2242 async fn partial_markers_are_rejected_even_after_staging() {
2243 for marker in ["<<<<<<< HEAD", "=======", ">>>>>>> topic", "||||||| base"] {
2244 for staged in [false, true] {
2245 let mut f = fixture(true).await;
2246 std::fs::write(f.scratch.join("f.txt"), format!("{BOTH}{marker}\n")).unwrap();
2247 if staged {
2248 sh(&f.scratch, &["add", "f.txt"]);
2249 }
2250 assert!(
2251 has_markers(&f.scratch, "f.txt", &["HEAD", "REBASE_HEAD"])
2252 .await
2253 .unwrap()
2254 );
2255 let out = stage(&mut f).await.unwrap();
2256 assert!(matches!(&out, Launch::Nothing(why) if why.contains("markers")));
2257 assert!(git::rebase_in_progress(&f.scratch).await);
2258 std::fs::remove_dir_all(f.state.dir()).ok();
2259 }
2260 }
2261 }
2262
2263 #[tokio::test]
2264 async fn existing_markdown_headings_are_not_conflict_markers() {
2265 let f = fixture(true).await;
2266 assert!(
2267 !has_markers(&f.scratch, "README.md", &["HEAD", "REBASE_HEAD"])
2268 .await
2269 .unwrap()
2270 );
2271 std::fs::write(
2272 f.scratch.join("README.md"),
2273 "Heading\n=======\nextra separator\n=======\n",
2274 )
2275 .unwrap();
2276 assert!(
2277 has_markers(&f.scratch, "README.md", &["HEAD", "REBASE_HEAD"])
2278 .await
2279 .unwrap()
2280 );
2281 std::fs::write(f.scratch.join("README.md"), "resolved code\n=======\n").unwrap();
2282 assert!(
2283 has_markers(&f.scratch, "README.md", &["HEAD", "REBASE_HEAD"])
2284 .await
2285 .unwrap()
2286 );
2287 std::fs::remove_dir_all(f.state.dir()).ok();
2288 }
2289
2290 #[tokio::test]
2291 async fn retries_keep_the_first_index_baseline() {
2292 let mut f = fixture(true).await;
2293 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2294 std::fs::write(f.scratch.join("automatic"), "unrelated staged edit\n").unwrap();
2295 sh(&f.scratch, &["add", "automatic"]);
2296 let out = stage(&mut f).await.unwrap();
2297 assert!(matches!(&out, Launch::Nothing(why) if why.contains("staged changes")));
2298 let paths = f.state.rebase_fixes[0].paths.clone();
2299 let baseline = round_baseline(&mut f.state, &f.scratch, &paths)
2300 .await
2301 .unwrap();
2302 assert_eq!(
2303 Some(&baseline),
2304 f.state.rebase_fixes[0].index_outside.as_ref()
2305 );
2306 assert_ne!(baseline, index_outside(&f.scratch, &paths).await.unwrap());
2307 let mut retry = f.state.rebase_fixes[0].clone();
2308 retry.index_outside = index_outside(&f.scratch, &paths).await.ok();
2310 f.state.rebase_fixes.push(retry);
2311 let out = stage(&mut f).await.unwrap();
2312 assert!(matches!(&out, Launch::Nothing(why) if why.contains("staged changes")));
2313 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2314 std::fs::remove_dir_all(f.state.dir()).ok();
2315 }
2316
2317 #[tokio::test]
2318 async fn legacy_records_without_a_baseline_block_without_adopting_the_index() {
2319 let mut f = fixture(false).await;
2320 f.state.rebase_fixes[0].index_outside = None;
2321 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2322 std::fs::write(f.scratch.join("automatic"), "unrelated staged edit\n").unwrap();
2323 sh(&f.scratch, &["add", "automatic"]);
2324 let error = stage(&mut f).await.unwrap_err();
2325 assert!(error.is::<crate::repo_lock::ResourceBlocked>());
2326 assert!(error.to_string().contains("baseline"));
2327 assert_eq!(f.state.rebase_fixes.len(), 1);
2328 assert!(f.state.rebase_fixes[0].index_outside.is_none());
2329 assert_eq!(f.read("f.txt"), BOTH);
2330 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2331 std::fs::remove_dir_all(f.state.dir()).ok();
2332 }
2333
2334 #[tokio::test]
2335 async fn legacy_baseline_is_reconstructed_and_saved_without_another_fixer() {
2336 for staged in [false, true] {
2337 let mut f = fixture_with(false, "f.txt", 16).await;
2338 f.state.rebase_fixes[0].index_outside = None;
2339 let again = f.state.rebase_fixes[0].clone();
2340 f.state.rebase_fixes.push(again);
2341 f.state.config.graph.review_rounds = 1;
2342 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2343 if staged {
2344 sh(&f.scratch, &["add", "f.txt"]);
2345 }
2346 let original = git::rev_parse(&f.state.repo, "topic").await.unwrap();
2347 assert!(matches!(stage(&mut f).await.unwrap(), Launch::Advanced));
2348 assert_eq!(f.state.rebase_fixes.len(), 2);
2349 assert!(f.state.rebase_fixes[0].index_outside.is_some());
2350 assert!(f.state.rebase_fixes.iter().all(|r| r.stop.is_some()));
2351 let saved = RunState::load(&f.state.id).unwrap();
2352 assert_eq!(
2353 saved.rebase_fixes[0].index_outside,
2354 f.state.rebase_fixes[0].index_outside
2355 );
2356 assert_eq!(f.read("f.txt"), BOTH);
2357 assert_eq!(topic_subjects(&f).await.len(), 16);
2358 assert_eq!(f.read("automatic"), "topic applied cleanly\n");
2359 let onto = git::rev_parse(&f.state.repo, "main").await.unwrap();
2360 assert_eq!(
2361 finish(
2362 &mut f.state,
2363 &f.scratch,
2364 "topic",
2365 &original,
2366 &onto,
2367 &["f.txt".into()],
2368 ""
2369 )
2370 .await
2371 .unwrap(),
2372 Rebased::Applied
2373 );
2374 std::fs::remove_dir_all(f.state.dir()).ok();
2375 }
2376 }
2377
2378 #[tokio::test]
2379 async fn successful_progression_gets_a_budget_for_the_next_conflicting_commit() {
2380 let mut f = fixture_stops(true, "f.txt", 2, true).await;
2381 f.state.config.graph.review_rounds = 1;
2382 assert_eq!(stop_rounds(&f.state, &f.scratch).await, 1);
2383 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2384 assert!(matches!(stage(&mut f).await.unwrap(), Launch::Advanced));
2385 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["g.txt"]);
2386 assert_eq!(stop_rounds(&f.state, &f.scratch).await, 0);
2387 let paths = git::unmerged_paths(&f.scratch).await.unwrap();
2388 let mut round = f.state.rebase_fixes[0].clone();
2389 round.index_outside = Some(
2390 round_baseline(&mut f.state, &f.scratch, &paths)
2391 .await
2392 .unwrap(),
2393 );
2394 round.paths = paths;
2395 round.stop = rebase_head(&f.scratch).await;
2396 f.state.rebase_fixes.push(round);
2397 assert_eq!(stop_rounds(&f.state, &f.scratch).await, 1);
2398 std::fs::write(f.scratch.join("g.txt"), "upstream g\ng\n").unwrap();
2399 assert_eq!(
2400 rebase_with_fixer(&mut f.state, &f.scratch, "topic", "main")
2401 .await
2402 .unwrap(),
2403 Rebased::Applied
2404 );
2405 assert_eq!(
2406 git::git(&f.state.repo, &["show", "topic:f.txt"])
2407 .await
2408 .unwrap(),
2409 BOTH.trim_end()
2410 );
2411 assert_eq!(
2412 git::git(&f.state.repo, &["show", "topic:g.txt"])
2413 .await
2414 .unwrap(),
2415 "upstream g\ng"
2416 );
2417 std::fs::remove_dir_all(f.state.dir()).ok();
2418 }
2419
2420 #[tokio::test]
2421 async fn resume_after_staging_denial_uses_the_saved_target_when_upstream_moves() {
2422 let mut f = fixture(true).await;
2423 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2424 let saved = rebase_target(&f.scratch).await.unwrap();
2425 let lock = git::git(&f.scratch, &["rev-parse", "--git-path", "index.lock"])
2426 .await
2427 .unwrap();
2428 let lock = f.scratch.join(lock);
2429 std::fs::write(&lock, "").unwrap();
2430 assert!(
2431 stage(&mut f)
2432 .await
2433 .unwrap_err()
2434 .is::<crate::repo_lock::ResourceBlocked>()
2435 );
2436 std::fs::remove_file(lock).unwrap();
2437 std::fs::write(f.state.repo.join("new-upstream"), "new base\n").unwrap();
2438 sh(&f.state.repo, &["add", "new-upstream"]);
2439 sh(&f.state.repo, &["commit", "-qm", "new upstream"]);
2440 assert_ne!(saved, git::rev_parse(&f.state.repo, "main").await.unwrap());
2441 assert_eq!(
2442 rebase_with_fixer(&mut f.state, &f.scratch, "topic", "main")
2443 .await
2444 .unwrap(),
2445 Rebased::Applied
2446 );
2447 assert!(git::is_ancestor(&f.state.repo, &saved, "topic").await);
2448 assert!(git::is_ancestor(&f.state.repo, "main", "topic").await);
2449 assert_eq!(
2450 git::git(&f.state.repo, &["show", "topic:f.txt"])
2451 .await
2452 .unwrap(),
2453 BOTH.trim_end()
2454 );
2455 assert_eq!(f.state.rebase_fixes.len(), 1);
2456 std::fs::remove_dir_all(f.state.dir()).ok();
2457 }
2458
2459 #[tokio::test]
2460 async fn unrelated_edits_and_unexpected_unmerged_paths_stage_nothing() {
2461 let mut f = fixture(true).await;
2462 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2463 std::fs::write(f.scratch.join("stray.txt"), "x\n").unwrap();
2464 let out = stage(&mut f).await.unwrap();
2465 assert!(matches!(&out, Launch::Nothing(why) if why.contains("stray.txt")));
2466 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2467 std::fs::remove_file(f.scratch.join("stray.txt")).unwrap();
2468
2469 f.state.rebase_fixes[0].paths = vec!["other.txt".into()];
2471 let out = stage(&mut f).await.unwrap();
2472 assert!(matches!(&out, Launch::Nothing(why) if why.contains("not part of")));
2473 assert!(git::rebase_in_progress(&f.scratch).await);
2474 std::fs::remove_dir_all(f.state.dir()).ok();
2475 }
2476
2477 #[tokio::test]
2478 async fn a_denied_index_write_is_an_infrastructure_stop_that_keeps_everything() {
2479 let mut f = fixture(true).await;
2480 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2481 let lock = git::git(&f.scratch, &["rev-parse", "--git-path", "index.lock"])
2482 .await
2483 .unwrap();
2484 let lock = f.scratch.join(lock);
2485 std::fs::write(&lock, "").unwrap();
2486
2487 let err = stage(&mut f).await.unwrap_err();
2488 assert!(err.is::<crate::repo_lock::ResourceBlocked>(), "{err:#}");
2489 let block = f.state.resource_block.clone().expect("a resource block");
2490 assert_eq!(block.kind, "git metadata write");
2491 assert!(f.state.rebase_fixes[0].infra.is_some());
2492 assert_eq!(f.state.rebase_fixes.len(), 1, "no round was spent");
2493 assert!(git::rebase_in_progress(&f.scratch).await);
2494 assert_eq!(f.read("f.txt"), BOTH);
2495
2496 std::fs::remove_file(&lock).unwrap();
2498 f.state.resource_block = None;
2499 let out = stage(&mut f).await.unwrap();
2500 assert!(matches!(out, Launch::Advanced));
2501 assert!(!git::rebase_in_progress(&f.scratch).await);
2502 assert_eq!(f.read("f.txt"), BOTH);
2503 std::fs::remove_dir_all(f.state.dir()).ok();
2504 }
2505
2506 #[tokio::test]
2507 async fn a_pass_interrupted_after_staging_is_continued_not_repeated() {
2508 let mut f = fixture(true).await;
2509 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2510 sh(&f.scratch, &["add", "f.txt"]);
2511 f.state.rebase_fixes[0].launcher = Some(crate::run::LauncherStep::Staged);
2512 assert!(git::unmerged_paths(&f.scratch).await.unwrap().is_empty());
2513 let out = stage(&mut f).await.unwrap();
2514 assert!(matches!(out, Launch::Advanced));
2515 assert!(!git::rebase_in_progress(&f.scratch).await);
2516 assert_eq!(topic_subjects(&f).await, vec!["topic g", "topic test"]);
2517 let out = stage(&mut f).await.unwrap();
2519 assert!(matches!(out, Launch::Nothing(_)));
2520 std::fs::remove_dir_all(f.state.dir()).ok();
2521 }
2522
2523 #[tokio::test]
2524 async fn recorded_conflict_edits_after_interrupted_staging_are_restaged() {
2525 for deleted in [false, true] {
2526 let mut f = fixture(true).await;
2527 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2528 sh(&f.scratch, &["add", "f.txt"]);
2529 f.state.rebase_fixes[0].launcher = Some(crate::run::LauncherStep::Staged);
2530 assert!(git::unmerged_paths(&f.scratch).await.unwrap().is_empty());
2531
2532 let revised = format!("{BOTH}additional test\n");
2533 if deleted {
2534 std::fs::remove_file(f.scratch.join("f.txt")).unwrap();
2535 } else {
2536 std::fs::write(f.scratch.join("f.txt"), &revised).unwrap();
2537 }
2538 let out = stage(&mut f).await.unwrap();
2539 assert!(matches!(out, Launch::Advanced));
2540 assert!(!git::rebase_in_progress(&f.scratch).await);
2541 assert_eq!(topic_subjects(&f).await, vec!["topic g", "topic test"]);
2542 if deleted {
2543 assert!(
2544 git::git(&f.scratch, &["ls-tree", "HEAD", "--", "f.txt"])
2545 .await
2546 .unwrap()
2547 .is_empty()
2548 );
2549 } else {
2550 assert_eq!(f.read("f.txt"), revised);
2551 assert_eq!(
2552 git::git(&f.scratch, &["show", "HEAD:f.txt"]).await.unwrap(),
2553 revised.trim_end()
2554 );
2555 }
2556 assert!(status_entries(&f.scratch).await.unwrap().is_empty());
2557 assert_eq!(f.state.rebase_fixes.len(), 1, "no further round was spent");
2558 std::fs::remove_dir_all(f.state.dir()).ok();
2559 }
2560 }
2561
2562 #[tokio::test]
2563 async fn a_cancelled_run_stages_nothing_and_keeps_the_resolved_files() {
2564 let mut f = fixture(true).await;
2565 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2566 let cancelled = crate::repo_lock::WaitPolicy::new(Duration::from_secs(5), || true);
2567 let err = cancelled.scope(stage(&mut f)).await.unwrap_err();
2568 assert!(err.is::<crate::repo_lock::ResourceBlocked>());
2569 assert!(git::rebase_in_progress(&f.scratch).await);
2570 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2571 assert_eq!(f.read("f.txt"), BOTH);
2572 std::fs::remove_dir_all(f.state.dir()).ok();
2573 }
2574 #[cfg(unix)]
2575 #[tokio::test]
2576 async fn cancelled_launcher_git_terminates_descendants_before_releasing_coordination() {
2577 for abort in [true, false] {
2578 cancelled_git_operation(abort).await;
2579 }
2580 }
2581
2582 #[cfg(unix)]
2583 async fn cancelled_git_operation(abort: bool) {
2584 let mut f = fixture(true).await;
2585 let scratch = f.scratch.clone();
2586 let state = &mut f.state;
2587 let policy = crate::config::test_coordination().unwrap();
2588 let common = crate::repo_lock::common_dir(&scratch)
2589 .await
2590 .unwrap()
2591 .unwrap();
2592 std::fs::write(common.join(&policy.contract_file), &policy.contract_token).unwrap();
2593 state.config.coordination = Some(policy.clone());
2594 let wait = crate::repo_lock::WaitPolicy::default();
2595 let lease = crate::repo_lock::SeatLease::operation_with(&scratch, Some(&policy), &wait)
2596 .await
2597 .unwrap();
2598 sh(
2599 &scratch,
2600 &[
2601 "config",
2602 "alias.block",
2603 "!echo $$ > operation.pid; exec sleep 30",
2604 ],
2605 );
2606 let cwd = scratch.clone();
2607 let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2608 let signal = cancelled.clone();
2609 let cancellation = crate::repo_lock::WaitPolicy::new(Duration::from_secs(5), move || {
2610 signal.load(std::sync::atomic::Ordering::SeqCst)
2611 });
2612 let operation =
2613 tokio::spawn(async move { cancellation.scope(mutate(&cwd, &["block"], lease)).await });
2614 let pid_file = scratch.join("operation.pid");
2615 let pid: i32 = tokio::time::timeout(Duration::from_secs(5), async {
2616 loop {
2617 if let Ok(text) = std::fs::read_to_string(&pid_file)
2618 && let Ok(pid) = text.trim().parse()
2619 {
2620 break pid;
2621 }
2622 tokio::time::sleep(Duration::from_millis(10)).await;
2623 }
2624 })
2625 .await
2626 .unwrap();
2627 assert!(crate::retention::OutputLease::take(&scratch, true).is_err());
2628 assert!(
2629 tokio::time::timeout(
2630 Duration::from_millis(100),
2631 crate::repo_lock::SeatLease::operation_with(&scratch, Some(&policy), &wait)
2632 )
2633 .await
2634 .is_err()
2635 );
2636 if abort {
2637 operation.abort();
2638 assert!(operation.await.unwrap_err().is_cancelled());
2639 } else {
2640 cancelled.store(true, std::sync::atomic::Ordering::SeqCst);
2641 let error = tokio::time::timeout(Duration::from_secs(5), operation)
2642 .await
2643 .unwrap()
2644 .unwrap()
2645 .unwrap_err();
2646 assert!(error.is::<crate::repo_lock::ResourceBlocked>());
2647 }
2648 let _next = crate::repo_lock::SeatLease::operation_with(&scratch, Some(&policy), &wait)
2650 .await
2651 .unwrap();
2652 assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
2654 let _output = crate::retention::OutputLease::take(&scratch, true).unwrap();
2655 assert!(git::rebase_in_progress(&scratch).await);
2656 assert_eq!(state.rebase_fixes.len(), 1);
2657 std::fs::remove_dir_all(state.dir()).ok();
2658 }
2659
2660 #[tokio::test]
2661 async fn recovery_keeps_sixteen_commits_and_automatic_changes() {
2662 let mut f = fixture_with(true, "f.txt", 16).await;
2663 f.state.config.graph.review_rounds = 1;
2664 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2665 let original = git::rev_parse(&f.state.repo, "topic").await.unwrap();
2666 let subjects = git::git(&f.scratch, &["log", "--format=%s", "main..topic"])
2667 .await
2668 .unwrap();
2669 assert_eq!(
2670 rebase_with_fixer(&mut f.state, &f.scratch, "topic", "main")
2671 .await
2672 .unwrap(),
2673 Rebased::Applied
2674 );
2675 let heads = git::git(&f.state.repo, &["rev-list", "--count", "main..topic"])
2676 .await
2677 .unwrap();
2678 assert_eq!(heads, "16");
2679 assert_eq!(
2680 git::git(&f.state.repo, &["show", "topic:f.txt"])
2681 .await
2682 .unwrap(),
2683 BOTH.trim_end()
2684 );
2685 assert_eq!(
2686 git::git(&f.state.repo, &["show", "topic:automatic"])
2687 .await
2688 .unwrap(),
2689 "topic applied cleanly"
2690 );
2691 assert_eq!(
2692 git::git(&f.state.repo, &["log", "--format=%s", "main..topic"])
2693 .await
2694 .unwrap(),
2695 subjects
2696 );
2697 for n in 2..16 {
2698 assert_eq!(
2699 git::git(&f.state.repo, &["show", &format!("topic:commit-{n}")])
2700 .await
2701 .unwrap(),
2702 n.to_string()
2703 );
2704 }
2705 assert_eq!(f.state.rebase_fixes.len(), 1);
2706 assert_ne!(
2707 git::rev_parse(&f.state.repo, "topic").await.unwrap(),
2708 original
2709 );
2710 std::fs::remove_dir_all(f.state.dir()).ok();
2711 }
2712
2713 #[tokio::test]
2714 async fn recovery_handles_literal_conflict_paths_with_whitespace() {
2715 let paths = [" spaced file name", "日本語 [literal].txt", "日本語.txt"].into_iter();
2719 #[cfg(not(windows))]
2720 let paths = paths.chain([" spaced file ", "tab\tand\nnewline"]);
2721 for path in paths {
2722 let mut f = fixture_with(true, path, 2).await;
2723 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), [path]);
2724 std::fs::write(f.scratch.join(path), BOTH).unwrap();
2725 assert!(matches!(stage(&mut f).await.unwrap(), Launch::Advanced));
2726 assert_eq!(f.read(path), BOTH);
2727 assert!(git::unmerged_paths(&f.scratch).await.unwrap().is_empty());
2728 std::fs::remove_dir_all(f.state.dir()).ok();
2729 }
2730 }
2731
2732 #[tokio::test]
2733 async fn changed_rebase_identity_blocks_staging_and_keeps_resolution() {
2734 for name in ["orig-head", "onto"] {
2735 let mut f = fixture(true).await;
2736 std::fs::write(f.scratch.join("f.txt"), BOTH).unwrap();
2737 let metadata = git::git(
2738 &f.scratch,
2739 &["rev-parse", "--git-path", &format!("rebase-merge/{name}")],
2740 )
2741 .await
2742 .unwrap();
2743 std::fs::write(
2744 f.scratch.join(metadata),
2745 "0000000000000000000000000000000000000000\n",
2746 )
2747 .unwrap();
2748 let error = stage(&mut f).await.unwrap_err();
2749 assert!(error.is::<crate::repo_lock::ResourceBlocked>());
2750 assert!(error.to_string().contains(name));
2751 assert_eq!(git::unmerged_paths(&f.scratch).await.unwrap(), ["f.txt"]);
2752 assert_eq!(f.read("f.txt"), BOTH);
2753 assert_eq!(f.state.rebase_fixes.len(), 1);
2754 std::fs::remove_dir_all(f.state.dir()).ok();
2755 }
2756 }
2757}