From 0ef7358480351199eb4e53c73d529586e55f3bda Mon Sep 17 00:00:00 2001 From: Nicolas Arnaud-Cormos Date: Tue, 22 Sep 2026 21:09:58 +0200 Subject: [PATCH] feat(rebase): carry a rebase past a conflict `rerere` already resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git always stops on a conflict, even one `rerere` replayed a recorded resolution for, so a branch rebasing over a sibling that landed upstream asked again for a resolution the user had already given once — at the integration merge, where the two first met. Loom now takes that stop itself, reporting each one. Without `rerere.autoUpdate` the replay reaches the working tree only, so the resolution is staged here: `git rerere remaining` lists the conflicted paths `rerere` did *not* resolve, and an empty list therefore means every one of them was. It prints nothing when `rerere` is off too, so that only counts alongside the `MERGE_RR` `rerere` writes for a conflict it tracks, and the index is read back afterwards — continuing over an entry still unmerged would commit a conflicted tree. An empty replay and a replayed resolution uncover each other, so the two carries alternate until neither moves. The `AUTO_MERGE` ids already continued past are kept, so a step failing for another reason — a hook turning the commit down — ends the carry instead of repeating for ever. `loom init` now sets `rerere.enabled` and `rerere.autoUpdate` for the repository, leaving alone any key it already carries: an integration branch meets the same conflicts on every update, so resolving each one once is what the workflow assumes. Change-Id: I874d932b7ae457fbbc33747851b3ff0faff486be --- docs/src/commands/init.md | 7 +- docs/src/commands/reword.md | 10 +- specs/003-reword.md | 2 +- specs/004-weave.md | 2 +- specs/009-init.md | 6 + specs/014-continue-abort.md | 15 +- src/core/transaction.rs | 14 +- src/core/weave.rs | 16 ++- src/git/git_rebase.rs | 153 +++++++++++++++++++- src/git/git_rebase_test.rs | 279 ++++++++++++++++++++++++++++++------ src/git/mod.rs | 11 +- src/init.rs | 34 +++++ src/init_test.rs | 40 ++++++ src/reword.rs | 5 +- 14 files changed, 521 insertions(+), 73 deletions(-) diff --git a/docs/src/commands/init.md b/docs/src/commands/init.md index d8956018..4f8b1b03 100644 --- a/docs/src/commands/init.md +++ b/docs/src/commands/init.md @@ -19,8 +19,13 @@ git loom init [name] 1. Creates a new local branch at the upstream tip 2. Configures upstream tracking (e.g. `origin/main`) 3. Switches HEAD to the new branch +4. Turns `rerere` on for the repository -All three happen in a single atomic operation. +The first three happen in a single atomic operation. + +### Conflict Reuse + +`init` sets `rerere.enabled` and `rerere.autoUpdate` in the repository config, unless they are already set there. An integration branch meets the same conflicts on every [update](update.md) — a feature branch landing upstream makes the branches beside it rebase over it — and with `rerere` you resolve each one once: loom replays the recorded resolution and carries the rebase past it instead of stopping again. ### Upstream Detection diff --git a/docs/src/commands/reword.md b/docs/src/commands/reword.md index 03fe783f..48825934 100644 --- a/docs/src/commands/reword.md +++ b/docs/src/commands/reword.md @@ -50,7 +50,7 @@ A redundant commit that is *not* the one you are rewording is dropped, and loom #### Conflicts -Every commit above the target gets a new hash, so any merge commit in the way has to be rebuilt instead of reused. A merge you originally resolved by hand will conflict again — a merge commit records the tree it produced, never the resolution that produced it. (With `rerere` enabled, git replays your recorded resolution, so the file has no conflict markers — the reword still pauses, and you stage the replayed resolution before `loom continue`.) +Every commit above the target gets a new hash, so any merge commit in the way has to be rebuilt instead of reused. A merge you originally resolved by hand will conflict again — a merge commit records the tree it produced, never the resolution that produced it. When that happens the reword pauses rather than throwing away the new message: @@ -64,12 +64,12 @@ git add shared.rs && git loom continue # ✓ Updated commit message for `ab12cd3` (now `mqt` (e45f678)) ``` -With `rerere.autoUpdate` set, git stages the replayed resolution too, and the pause says so: +With `rerere` on — which [init](init.md) sets up — a conflict you have already resolved once is replayed, so there is nothing left to settle and the reword carries on by itself, saying what it took: ```bash -# ! `rerere` resolved the conflicts for you — review the result, then run: -# `loom continue` to complete the reword -# `loom abort` to cancel and restore original state +git loom reword osy -m "Fix authentication bug" +# ! `rerere` resolved the conflicts replaying `ab12cd3` — carried on with its recorded resolution +# ✓ Updated commit message for `ab12cd3` (now `mqt` (e45f678)) ``` `git loom abort` restores the original message, HEAD, and every branch ref. See [continue](continue.md) and [abort](abort.md). diff --git a/specs/003-reword.md b/specs/003-reword.md index 6f2aef33..ac960462 100644 --- a/specs/003-reword.md +++ b/specs/003-reword.md @@ -32,7 +32,7 @@ Change the target message and replay descendants with native interactive rebase. (Spec 014); - keep the commit's `Change-Id` (Spec 002): with `-m` it is re-appended to the new message unless that message already carries one; with the editor it is restored after editing. A commit without one receives a fresh one when generation is enabled. -Rebuilding a descendant merge can conflict, including a merge previously resolved manually. With `rerere`, Git may replay the resolution but rebase still stops; `rerere.autoUpdate` may also stage it. +Rebuilding a descendant merge can conflict, including a merge previously resolved manually. With `rerere`, Git replays the resolution but the rebase still stops; the reword then carries on by itself (Spec 014). On replay conflict, retain the amended message and pause with exactly this guidance: diff --git a/specs/004-weave.md b/specs/004-weave.md index da1f896a..b9afb3d1 100644 --- a/specs/004-weave.md +++ b/specs/004-weave.md @@ -68,7 +68,7 @@ Emit branch sections first in dependency order. Each resets to its fork and ends Replay the complete base-to-HEAD range with `--rebase-merges`. Preserve/create merge topology, branch refs, uncommitted changes, and empty commits. -Conflict policy belongs to the caller: +Conflict policy belongs to the caller, except for a stop `rerere` resolved in full: every weave rebase carries past that one itself, staging the replayed resolution when `rerere.autoUpdate` did not (Spec 014), so no caller ever sees it. - Resumable owners (`update`, `commit`, `absorb`, `drop commit`, `swap`, `branch merge`, supported `reword` replay, and simple supported `fold` paths) return `Stopped`, save `.git/loom/state.json`, and allow `loom continue` or `loom abort`. - Out-of-scope paths (including `split`, excluded `fold` paths, and non-pausing reword failures) explicitly abort and restore the original repository. Reword's supported replay conflict is governed by Spec 003. diff --git a/specs/009-init.md b/specs/009-init.md index f2ab3a66..e3274bc6 100644 --- a/specs/009-init.md +++ b/specs/009-init.md @@ -54,6 +54,12 @@ git-loom init [name] - If no candidates are found, error with guidance to add a remote 4. **Creation**: The branch is created at the upstream tip, tracking is configured, and HEAD is switched to it in one atomic operation. +5. **Repository setup**: `rerere.enabled` and `rerere.autoUpdate` are set to + true in the repository config. An integration branch meets the same conflicts + on every update, and a resolution `rerere` recorded is one loom replays and + carries the rebase past by itself (Spec 014). Keys the repository config + already carries are left alone, and a config that cannot be written does not + fail the init. ## Upstream Detection diff --git a/specs/014-continue-abort.md b/specs/014-continue-abort.md index 745c58a4..ce0dcc67 100644 --- a/specs/014-continue-abort.md +++ b/specs/014-continue-abort.md @@ -70,7 +70,20 @@ A clean index can also mean the opposite of a breakdown: `rerere` replays a recorded resolution, and with `rerere.autoUpdate` it stages the result, so the rebase stops on a conflict that is already resolved. -Loom therefore describes the pause from what it finds: +Loom carries a rebase past that stop itself and reports each one: a conflict +already resolved leaves nothing to ask about. Without `rerere.autoUpdate` the +replay reaches the working tree only, and loom stages it — `git rerere +remaining` names the conflicted paths `rerere` did *not* resolve, so an empty +list means every one of them was. It prints nothing at all when `rerere` is off, +so that only counts alongside the `MERGE_RR` `rerere` writes for a conflict it +tracks, and the index is read back afterwards: continuing over an entry still +unmerged would commit a conflicted tree. Loom keeps the stops it has continued +past, each one the `AUTO_MERGE` id together with the step it belongs to — two +steps can conflict into the same tree — so a step failing for another reason — a +hook turning the commit down — ends the carry instead of repeating for ever. A +merge outside a rebase is not carried at all. + +Loom describes whatever pause is left from what it finds: | State | Message | |-------|---------| diff --git a/src/core/transaction.rs b/src/core/transaction.rs index 17aa695e..9aa83cd6 100644 --- a/src/core/transaction.rs +++ b/src/core/transaction.rs @@ -366,9 +366,10 @@ pub fn continue_cmd(workdir: &Path, git_dir: &Path) -> Result<()> { return continue_without_state(workdir, git_dir); }; - // Read before continuing: `AUTO_MERGE` only says which conflict git is on - // once there is something to compare it against. - let auto_merge_before = git::auto_merge_id(workdir); + // Read before continuing: the stop git is on only says which conflict the + // user was already looking at while it is still the current one. + let stop_before = git::stop_id(workdir, git_dir); + let auto_merge_before = stop_before.as_ref().map(git::StopId::auto_merge); if git::rebase_is_in_progress(git_dir) { // A stop on a commit the new history already contains is not a conflict // to resolve (`skip_empty_stops` establishes that before skipping @@ -379,10 +380,11 @@ pub fn continue_cmd(workdir: &Path, git_dir: &Path) -> Result<()> { // tree is dirty, and its message says to save that work before undoing // anything. Everything else is the user's to look at, with the state // still describing what `loom abort` would undo. - let outcome = git::skip_empty_stops( + let outcome = git::carry_past_known_stops( workdir, git_dir, &state.protect, + stop_before.as_ref(), git::continue_rebase(workdir)?, ) .map_err(|e| match git::replayed_empty_hash(&e) { @@ -397,7 +399,7 @@ pub fn continue_cmd(workdir: &Path, git_dir: &Path) -> Result<()> { return Ok(()); } git::RebaseOutcome::Stopped => { - warn_still_paused(workdir, "operation", auto_merge_before.as_deref()); + warn_still_paused(workdir, "operation", auto_merge_before); return Ok(()); } git::RebaseOutcome::Completed => {} @@ -405,7 +407,7 @@ pub fn continue_cmd(workdir: &Path, git_dir: &Path) -> Result<()> { } else if git::merge_is_in_progress(git_dir) { match git::continue_merge(workdir, git_dir)? { git::MergeOutcome::Stopped => { - warn_still_paused(workdir, "operation", auto_merge_before.as_deref()); + warn_still_paused(workdir, "operation", auto_merge_before); return Ok(()); } git::MergeOutcome::Completed => {} diff --git a/src/core/weave.rs b/src/core/weave.rs index 32166c97..fe934f01 100644 --- a/src/core/weave.rs +++ b/src/core/weave.rs @@ -1615,7 +1615,7 @@ pub fn run_rebase_expecting_edit( /// /// Does NOT abort on a conflict — the outcome is the caller's, and a resumable /// one must carry `protected` in its `LoomState` so `loom continue` keeps -/// protecting them. `protected` holds full object names: `skip_empty_stops` +/// protecting them. `protected` holds full object names: the empty-stop skip /// matches on the shorter string, so a short ID would over-protect. pub fn run_rebase_protecting( workdir: &Path, @@ -1646,10 +1646,11 @@ fn halt_on_empty( protected: &[String], ) -> Result { let git_dir = git::absolute_git_dir(workdir)?; - git::skip_empty_stops( + git::carry_past_known_stops( workdir, &git_dir, protected, + None, run_rebase_with_empty(workdir, upstream, todo_content, git::empty_stop_value())?, ) } @@ -1704,17 +1705,22 @@ pub fn not_in_the_weave(oid: Oid) -> anyhow::Error { /// /// Returns `Paused` when it stopped at an `edit` the todo asked for and /// `Stopped` when it stopped part-way. Does NOT abort. A commit whose changes -/// the new base already has is dropped by the sequencer. +/// the new base already has is dropped by the sequencer, and a conflict +/// `rerere` already resolved is carried past (see +/// [`git::continue_rerere_stops`]). pub fn run_rebase( workdir: &Path, upstream: Option<&str>, todo_content: &str, ) -> Result { - run_rebase_with_empty(workdir, upstream, todo_content, "drop") + // Tagged: a caller's undo must know this failure rewrote nothing. + let git_dir = git::before_rebase_starts(git::absolute_git_dir(workdir))?; + let outcome = run_rebase_with_empty(workdir, upstream, todo_content, "drop")?; + git::continue_rerere_stops(workdir, &git_dir, None, outcome) } /// [`run_rebase`] with git's `--empty` mode chosen: `stop` reports a commit -/// that replayed empty instead of dropping it (see [`git::skip_empty_stops`]). +/// that replayed empty instead of dropping it (see [`git::carry_past_known_stops`]). fn run_rebase_with_empty( workdir: &Path, upstream: Option<&str>, diff --git a/src/git/git_rebase.rs b/src/git/git_rebase.rs index 1bff1be2..e743549d 100644 --- a/src/git/git_rebase.rs +++ b/src/git/git_rebase.rs @@ -220,6 +220,151 @@ fn replays_empty(workdir: &Path, sha: &str) -> bool { merged.lines().next().map(str::trim) == Some(head_tree.trim()) } +/// Carry a rebase past every stop loom can account for on its own: a commit the +/// new history already has, and a conflict `rerere` had already resolved. +/// +/// The two uncover each other — a skip can land on a replayed resolution and +/// the other way round — so they alternate until neither moves. +/// `before` identifies the stop the caller was already on, if there was one +/// (Spec 014). +pub fn carry_past_known_stops( + workdir: &Path, + git_dir: &Path, + protected: &[String], + before: Option<&StopId>, + outcome: RebaseOutcome, +) -> Result { + let mut carried = carried_set(before); + let mut outcome = outcome; + loop { + outcome = skip_empty_stops(workdir, git_dir, protected, outcome)?; + let (next, resolved) = rerere_continue_loop(workdir, git_dir, &mut carried, outcome)?; + outcome = next; + if resolved == 0 { + return Ok(outcome); + } + } +} + +/// Carry a rebase past every stop `rerere` already resolved, for a caller with +/// no empty stops to skip — its todo ran under `--empty=drop`. +pub fn continue_rerere_stops( + workdir: &Path, + git_dir: &Path, + before: Option<&StopId>, + outcome: RebaseOutcome, +) -> Result { + let mut carried = carried_set(before); + Ok(rerere_continue_loop(workdir, git_dir, &mut carried, outcome)?.0) +} + +/// Which conflict stop a rebase is on: the `AUTO_MERGE` id git wrote for it and +/// the step that produced it. +/// +/// The id alone does not name a stop — two steps merging the same commit into +/// the same tree write the same `AUTO_MERGE` — and neither does the step +/// number, which a step that fails to commit keeps. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct StopId { + auto_merge: String, + step: Option, +} + +impl StopId { + /// The `AUTO_MERGE` id alone, for callers that only ask whether git moved + /// on to another conflict. + pub fn auto_merge(&self) -> &str { + &self.auto_merge + } +} + +/// The stop git is on, `None` when it is on none: a stop without an +/// `AUTO_MERGE` did not come from a conflict (Spec 014). +pub fn stop_id(workdir: &Path, git_dir: &Path) -> Option { + Some(StopId { + auto_merge: auto_merge_id(workdir)?, + step: rebase_progress(git_dir).map(|(current, _)| current), + }) +} + +/// The stops a rerere continue must not count as news, seeded with the one the +/// caller was already on. +fn carried_set(before: Option<&StopId>) -> std::collections::HashSet { + before.cloned().into_iter().collect() +} + +/// Continue past every stop `rerere` resolved, reporting each one; returns the +/// outcome and how many stops it carried past. +/// +/// A stop with an `AUTO_MERGE` came from a conflict (Spec 014), and one +/// `rerere` replayed has nothing left to resolve, so loom takes it and carries +/// on. `carried` holds the stops already continued past, so a step that fails +/// for another reason — a hook turning the commit down — stays on the same stop +/// and is handed back rather than retried for ever. +fn rerere_continue_loop( + workdir: &Path, + git_dir: &Path, + carried: &mut std::collections::HashSet, + mut outcome: RebaseOutcome, +) -> Result<(RebaseOutcome, usize)> { + let mut resolved = 0; + while outcome == RebaseOutcome::Stopped { + let Some(id) = stop_id(workdir, git_dir) else { + break; + }; + if !carried.insert(id) { + break; + } + if has_unmerged_paths(workdir) && !stage_replayed_resolution(workdir, git_dir) { + break; + } + // Read before the continue, which is about to leave that stop behind. + let on = + stopped_sha(git_dir).map(|sha| format!(" replaying `{}`", super::short_hash(&sha))); + outcome = match continue_rebase(workdir) { + Ok(next) => next, + Err(e) => return Err(rebase_abort_then_cleanup(workdir, e, || {})), + }; + resolved += 1; + crate::core::msg::warn(&format!( + "`rerere` resolved the conflicts{} — carried on with its recorded resolution", + on.unwrap_or_default() + )); + } + Ok((outcome, resolved)) +} + +/// Stage the resolution `rerere` replayed into the working tree — what +/// `rerere.autoUpdate` would have done — and say whether every conflicted path +/// was one it resolved. +/// +/// `git rerere remaining` lists the conflicted paths `rerere` did *not* +/// resolve, and prints nothing at all when `rerere` is off, so an empty list +/// only means "all resolved" alongside the `MERGE_RR` `rerere` writes for a +/// conflict it is tracking. The paths go back as `:(top,literal)` pathspecs for +/// the reason `git_apply` gives: git reports them from the repository root, and +/// a real file named `a[12].txt` is otherwise a glob. +fn stage_replayed_resolution(workdir: &Path, git_dir: &Path) -> bool { + if !git_dir.join("MERGE_RR").exists() { + return false; + } + let Ok(remaining) = super::run_git_stdout(workdir, &["rerere", "remaining"]) else { + return false; + }; + if !remaining.trim().is_empty() { + return false; + } + let specs: Vec = unmerged_paths(workdir) + .iter() + .map(|path| format!(":(top,literal){path}")) + .collect(); + let mut args = vec!["add", "--"]; + args.extend(specs.iter().map(String::as_str)); + // Re-read rather than trust the exit: a partial `add` leaves the rest + // unmerged, and continuing over that would commit a conflicted tree. + super::run_git(workdir, &args).is_ok() && !has_unmerged_paths(workdir) +} + /// Carry a rebase past every commit whose changes the new history already has, /// refusing when one of them is in `protected` (Spec 004). /// @@ -497,7 +642,13 @@ pub fn continue_rebase_expecting_edit(workdir: &Path, after: AfterStop<'_>) -> R let git_dir = super::absolute_git_dir(workdir)?; let mut protected = after.protect.to_vec(); protected.extend(after.expect.map(str::to_string)); - let outcome = skip_empty_stops(workdir, &git_dir, &protected, continue_rebase(workdir)?)?; + let outcome = carry_past_known_stops( + workdir, + &git_dir, + &protected, + None, + continue_rebase(workdir)?, + )?; let Some(expect_stop) = after.expect else { return match outcome { diff --git a/src/git/git_rebase_test.rs b/src/git/git_rebase_test.rs index c2292f50..df2fe6da 100644 --- a/src/git/git_rebase_test.rs +++ b/src/git/git_rebase_test.rs @@ -167,10 +167,9 @@ fn a_failed_abort_skips_the_cleanup_and_keeps_the_cause() { super::rebase_abort(&workdir).unwrap(); } -/// Build a repo where `rerere` has recorded a resolution for a conflict, then -/// replay that same conflict in a rebase. With `rerere.autoUpdate` on, git -/// stages the recorded resolution and the stop leaves a clean index — which -/// must not be mistaken for a rebase that broke down. +/// With `rerere.autoUpdate` on, git stages the recorded resolution and the stop +/// leaves a clean index — which must not be mistaken for a rebase that broke +/// down when an out-of-scope command reports why it aborted. #[test] fn rerere_resolved_stop_is_still_a_conflict() { let test_repo = TestRepo::new(); @@ -178,48 +177,14 @@ fn rerere_resolved_stop_is_still_a_conflict() { test_repo.set_config("rerere.autoUpdate", "true"); let workdir = test_repo.workdir(); - test_repo.write_file("f.txt", "base\n"); - test_repo.stage_files(&["f.txt"]); - test_repo.commit_staged("base"); - let base = test_repo.head_oid().to_string(); + let (onto, base) = conflicting_base(&test_repo); + record_resolution(&test_repo, &onto, &base, "topic1"); + replay_conflict(&test_repo, &onto, &base, "topic2"); - test_repo.write_file("f.txt", "onto side\n"); - test_repo.stage_files(&["f.txt"]); - test_repo.commit_staged("onto side"); - let onto = test_repo.head_oid().to_string(); - - // The same conflicting topic twice: the first rebase records the - // resolution, the second one has rerere replay it. - let conflict_on = |topic: &str| { - test_repo.create_branch_at(topic, &base); - test_repo.switch_branch(topic); - test_repo.write_file("f.txt", "topic side\n"); - test_repo.stage_files(&["f.txt"]); - test_repo.commit_staged("topic side"); - crate::git::run_git(&workdir, &["rebase", &onto]).unwrap_err(); - assert!( - super::rebase_is_in_progress(test_repo.repo.path()), - "the conflict must be what stopped the rebase" - ); - }; - - conflict_on("topic1"); - test_repo.write_file("f.txt", "resolved\n"); - crate::git::run_git(&workdir, &["add", "f.txt"]).unwrap(); - assert_eq!( - super::continue_rebase(&workdir).unwrap(), - super::RebaseOutcome::Completed - ); - - conflict_on("topic2"); - - assert!( - super::rebase_is_in_progress(test_repo.repo.path()), - "the replayed conflict still stops the rebase" - ); assert_eq!( test_repo.read_file("f.txt"), - "resolved\n", + "resolved +", "rerere should have replayed the recorded resolution" ); assert!( @@ -238,6 +203,230 @@ fn rerere_resolved_stop_is_still_a_conflict() { ); } +/// A conflict `rerere` replayed and `rerere.autoUpdate` staged leaves nothing +/// to resolve, so the rebase is carried to the end instead of handed back. +#[test] +fn rerere_resolved_stop_is_carried_past() { + let test_repo = TestRepo::new(); + test_repo.set_config("rerere.enabled", "true"); + test_repo.set_config("rerere.autoUpdate", "true"); + let workdir = test_repo.workdir(); + let git_dir = test_repo.repo.path().to_path_buf(); + + let (onto, base) = conflicting_base(&test_repo); + record_resolution(&test_repo, &onto, &base, "topic1"); + replay_conflict(&test_repo, &onto, &base, "topic2"); + + assert_eq!( + super::carry_past_known_stops(&workdir, &git_dir, &[], None, super::RebaseOutcome::Stopped) + .unwrap(), + super::RebaseOutcome::Completed + ); + assert_eq!( + test_repo.read_file("f.txt"), + "resolved +" + ); + assert!(!super::rebase_is_in_progress(&git_dir)); +} + +/// Without `rerere.autoUpdate` the replayed resolution sits in the working tree +/// with the index still unmerged, which loom stages itself. +#[test] +fn rerere_resolved_stop_is_carried_past_without_autoupdate() { + let test_repo = TestRepo::new(); + test_repo.set_config("rerere.enabled", "true"); + test_repo.set_config("rerere.autoUpdate", "false"); + let workdir = test_repo.workdir(); + let git_dir = test_repo.repo.path().to_path_buf(); + + let (onto, base) = conflicting_base(&test_repo); + record_resolution(&test_repo, &onto, &base, "topic1"); + replay_conflict(&test_repo, &onto, &base, "topic2"); + assert!( + super::has_unmerged_paths(&workdir), + "without autoUpdate rerere leaves the resolution unstaged" + ); + + assert_eq!( + super::carry_past_known_stops(&workdir, &git_dir, &[], None, super::RebaseOutcome::Stopped) + .unwrap(), + super::RebaseOutcome::Completed + ); + assert_eq!( + test_repo.read_file("f.txt"), + "resolved +" + ); + assert!(!super::rebase_is_in_progress(&git_dir)); +} + +/// Two stops can share one `AUTO_MERGE`, and the second is still a stop of its +/// own to carry past — it is the step that tells them apart. +#[test] +fn two_stops_sharing_one_auto_merge_are_both_carried_past() { + let test_repo = TestRepo::new(); + test_repo.set_config("rerere.enabled", "true"); + test_repo.set_config("rerere.autoUpdate", "true"); + let workdir = test_repo.workdir(); + let git_dir = test_repo.repo.path().to_path_buf(); + + let (onto, base) = conflicting_base(&test_repo); + record_resolution(&test_repo, &onto, &base, "topic1"); + test_repo.create_branch_at("topic2", &base); + test_repo.switch_branch("topic2"); + test_repo.write_file( + "f.txt", + "topic side +", + ); + test_repo.stage_files(&["f.txt"]); + test_repo.commit_staged("topic side"); + let pick = test_repo.head_oid().to_string(); + + // Picking one commit twice off the same reset makes both conflicts merge + // the same commit into the same tree, down to the marker labels, so git + // writes one AUTO_MERGE for the two stops. + let todo = format!( + "f() {{ printf '%s\n' 'reset {onto}' 'pick {pick}' 'reset {onto}' 'pick {pick}' > \"$1\"; }}; f" + ); + let start = || { + let ran = std::process::Command::new("git") + .current_dir(&workdir) + .args(["rebase", "--interactive", "--rebase-merges", &onto]) + .env("GIT_SEQUENCE_EDITOR", &todo) + .env("GIT_EDITOR", "true") + .output() + .unwrap(); + assert!(!ran.status.success(), "the first conflict must stop it"); + }; + + start(); + let first = super::stop_id(&workdir, &git_dir).expect("stopped on a conflict"); + assert_eq!( + super::continue_rebase(&workdir).unwrap(), + super::RebaseOutcome::Stopped + ); + let second = super::stop_id(&workdir, &git_dir).expect("stopped on a conflict"); + assert_eq!(first.auto_merge(), second.auto_merge()); + assert_ne!(first, second, "two steps, so two stops"); + super::rebase_abort(&workdir).unwrap(); + + start(); + assert_eq!( + super::carry_past_known_stops(&workdir, &git_dir, &[], None, super::RebaseOutcome::Stopped) + .unwrap(), + super::RebaseOutcome::Completed + ); + assert!(!super::rebase_is_in_progress(&git_dir)); + assert_eq!( + test_repo.read_file("f.txt"), + "resolved +" + ); +} + +/// With `rerere` off, `git rerere remaining` prints nothing at all — which must +/// never read as "everything was resolved" over a tree full of conflict markers. +#[test] +fn conflict_with_rerere_off_is_not_carried_past() { + let test_repo = TestRepo::new(); + test_repo.set_config("rerere.enabled", "false"); + let workdir = test_repo.workdir(); + let git_dir = test_repo.repo.path().to_path_buf(); + + let (onto, base) = conflicting_base(&test_repo); + replay_conflict(&test_repo, &onto, &base, "topic"); + + assert_eq!( + super::carry_past_known_stops(&workdir, &git_dir, &[], None, super::RebaseOutcome::Stopped) + .unwrap(), + super::RebaseOutcome::Stopped + ); + assert!(test_repo.read_file("f.txt").contains("<<<<<<<")); + assert!(super::has_unmerged_paths(&workdir)); + super::rebase_abort(&workdir).unwrap(); +} + +/// A conflict with no recorded resolution is the user's to settle: `rerere` +/// leaves the index unmerged, and nothing may continue over that. +#[test] +fn unresolved_conflict_is_not_carried_past() { + let test_repo = TestRepo::new(); + test_repo.set_config("rerere.enabled", "true"); + test_repo.set_config("rerere.autoUpdate", "true"); + let workdir = test_repo.workdir(); + let git_dir = test_repo.repo.path().to_path_buf(); + + let (onto, base) = conflicting_base(&test_repo); + replay_conflict(&test_repo, &onto, &base, "topic"); + + assert_eq!( + super::carry_past_known_stops(&workdir, &git_dir, &[], None, super::RebaseOutcome::Stopped) + .unwrap(), + super::RebaseOutcome::Stopped + ); + assert!(super::has_unmerged_paths(&workdir)); + super::rebase_abort(&workdir).unwrap(); +} + +/// A base commit and a tip that every topic branch built on the base conflicts +/// with, as `(onto, base)`. +fn conflicting_base(test_repo: &TestRepo) -> (String, String) { + test_repo.write_file( + "f.txt", "base +", + ); + test_repo.stage_files(&["f.txt"]); + test_repo.commit_staged("base"); + let base = test_repo.head_oid().to_string(); + + test_repo.write_file( + "f.txt", + "onto side +", + ); + test_repo.stage_files(&["f.txt"]); + test_repo.commit_staged("onto side"); + (test_repo.head_oid().to_string(), base) +} + +/// Rebase a fresh topic onto `onto` and leave the rebase stopped on the +/// conflict, with the repo checked out on that topic. +fn replay_conflict(test_repo: &TestRepo, onto: &str, base: &str, topic: &str) { + let workdir = test_repo.workdir(); + test_repo.create_branch_at(topic, base); + test_repo.switch_branch(topic); + test_repo.write_file( + "f.txt", + "topic side +", + ); + test_repo.stage_files(&["f.txt"]); + test_repo.commit_staged("topic side"); + crate::git::run_git(&workdir, &["rebase", onto]).unwrap_err(); + assert!( + super::rebase_is_in_progress(test_repo.repo.path()), + "the conflict must be what stopped the rebase" + ); +} + +/// Teach `rerere` how this conflict is resolved, by resolving it once. +fn record_resolution(test_repo: &TestRepo, onto: &str, base: &str, topic: &str) { + let workdir = test_repo.workdir(); + replay_conflict(test_repo, onto, base, topic); + test_repo.write_file( + "f.txt", + "resolved +", + ); + crate::git::run_git(&workdir, &["add", "f.txt"]).unwrap(); + assert_eq!( + super::continue_rebase(&workdir).unwrap(), + super::RebaseOutcome::Completed + ); +} + /// The reftable backend keeps refs in `.git/reftable/`, so `AUTO_MERGE` is no /// file under the git dir there — reading it must go through git. #[test] @@ -427,7 +616,7 @@ fn an_empty_stop_is_not_skipped_over_local_changes() { test_repo.write_file("other.txt", "edited while paused\n"); let outcome = - crate::git::skip_empty_stops(&workdir, &git_dir, &[], crate::git::RebaseOutcome::Stopped) + super::skip_empty_stops(&workdir, &git_dir, &[], crate::git::RebaseOutcome::Stopped) .unwrap(); assert_eq!(outcome, crate::git::RebaseOutcome::Stopped); @@ -588,7 +777,7 @@ fn a_protected_commit_is_refused_even_with_local_changes() { ); let protect = [stopped]; - let err = crate::git::skip_empty_stops(&workdir, &git_dir, &protect, outcome) + let err = super::skip_empty_stops(&workdir, &git_dir, &protect, outcome) .unwrap_err() .to_string(); diff --git a/src/git/mod.rs b/src/git/mod.rs index c2e27004..70134d5a 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -32,11 +32,12 @@ pub use git_merge::{MergeOutcome, continue_merge, merge_abort, merge_is_in_progr #[cfg(test)] pub use git_rebase::rebase_onto; pub use git_rebase::{ - AfterStop, REPLAYS_EMPTY, RebaseOutcome, abort_after_failure, auto_merge_id, - before_rebase_starts, continue_rebase, continue_rebase_expecting_edit, - finished_without_stopping, has_unmerged_paths, rebase, rebase_abort, rebase_abort_then_cleanup, - rebase_is_in_progress, rebase_is_over, rebase_never_started, rebase_outcome, rebase_progress, - replayed_empty_hash, skip_empty_stops, unmerged_paths, verify_paused_at, + AfterStop, REPLAYS_EMPTY, RebaseOutcome, StopId, abort_after_failure, auto_merge_id, + before_rebase_starts, carry_past_known_stops, continue_rebase, continue_rebase_expecting_edit, + continue_rerere_stops, finished_without_stopping, has_unmerged_paths, rebase, rebase_abort, + rebase_abort_then_cleanup, rebase_is_in_progress, rebase_is_over, rebase_never_started, + rebase_outcome, rebase_progress, replayed_empty_hash, stop_id, unmerged_paths, + verify_paused_at, }; pub use git_worktree::ensure_not_checked_out_elsewhere; diff --git a/src/init.rs b/src/init.rs index ca6ee7a6..29677fb4 100644 --- a/src/init.rs +++ b/src/init.rs @@ -28,6 +28,8 @@ pub fn run(name: Option) -> Result<()> { git::branch_switch_create_tracking(workdir, &name, &upstream)?; + enable_rerere(&repo); + msg::success(&format!( "Initialized integration branch `{}` tracking `{}`", name, upstream @@ -36,6 +38,38 @@ pub fn run(name: Option) -> Result<()> { Ok(()) } +/// Turn `rerere` on for this repository, so a conflict resolved once is +/// replayed the next time it comes back — which is what lets a rebase carry +/// itself past it instead of asking again (Spec 014). An integration branch +/// meets the same conflicts on every update, so this is the setting the +/// workflow assumes. +/// +/// Only keys this repository does not already carry are written: a local choice +/// stays the user's. Best-effort — a config loom cannot write is no reason to +/// fail the init. +fn enable_rerere(repo: &Repository) { + let Ok(mut config) = repo + .config() + .and_then(|config| config.open_level(git2::ConfigLevel::Local)) + else { + return; + }; + + let mut set = Vec::new(); + for key in ["rerere.enabled", "rerere.autoUpdate"] { + if config.get_entry(key).is_err() && config.set_bool(key, true).is_ok() { + set.push(key); + } + } + + if !set.is_empty() { + msg::success(&format!( + "Enabled `{}` for this repository", + set.join("` and `") + )); + } +} + /// Detect the upstream tracking ref to use for the new integration branch. /// /// Strategy: diff --git a/src/init_test.rs b/src/init_test.rs index ceb5e5ef..5b9b7eed 100644 --- a/src/init_test.rs +++ b/src/init_test.rs @@ -183,3 +183,43 @@ fn init_no_upstream_remote_uses_origin_on_github() { let upstream_name = upstream.name().unwrap().unwrap(); assert_eq!(upstream_name, "origin/main"); } + +/// A conflict resolved once must not be asked about again on the next update, +/// so the workflow's own `rerere` settings are part of setting the repo up. +#[test] +fn init_turns_rerere_on_for_the_repository() { + let test_repo = TestRepo::new_with_remote(); + test_repo.switch_branch("main"); + test_repo.delete_branch("integration"); + + test_repo.in_dir(|| super::run(None)).unwrap(); + + let local = test_repo + .repo + .config() + .unwrap() + .open_level(git2::ConfigLevel::Local) + .unwrap(); + assert!(local.get_bool("rerere.enabled").unwrap()); + assert!(local.get_bool("rerere.autoUpdate").unwrap()); +} + +/// A setting the repository already carries is the user's, whatever it says. +#[test] +fn init_leaves_an_existing_rerere_setting_alone() { + let test_repo = TestRepo::new_with_remote(); + test_repo.switch_branch("main"); + test_repo.delete_branch("integration"); + test_repo.set_config("rerere.autoUpdate", "false"); + + test_repo.in_dir(|| super::run(None)).unwrap(); + + let local = test_repo + .repo + .config() + .unwrap() + .open_level(git2::ConfigLevel::Local) + .unwrap(); + assert!(!local.get_bool("rerere.autoUpdate").unwrap()); + assert!(local.get_bool("rerere.enabled").unwrap()); +} diff --git a/src/reword.rs b/src/reword.rs index 790c08ef..43de8160 100644 --- a/src/reword.rs +++ b/src/reword.rs @@ -154,8 +154,9 @@ pub fn reword_commit(repo: &Repository, commit_hash: &str, message: Option