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