Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/src/commands/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions docs/src/commands/reword.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion specs/003-reword.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion specs/004-weave.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions specs/009-init.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 14 additions & 1 deletion specs/014-continue-abort.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-------|---------|
Expand Down
14 changes: 8 additions & 6 deletions src/core/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -397,15 +399,15 @@ 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 => {}
}
} 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 => {}
Expand Down
16 changes: 11 additions & 5 deletions src/core/weave.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1646,10 +1646,11 @@ fn halt_on_empty(
protected: &[String],
) -> Result<RebaseOutcome> {
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())?,
)
}
Expand Down Expand Up @@ -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<RebaseOutcome> {
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>,
Expand Down
153 changes: 152 additions & 1 deletion src/git/git_rebase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RebaseOutcome> {
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<RebaseOutcome> {
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<usize>,
}

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<StopId> {
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<StopId> {
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<StopId>,
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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<String> = 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).
///
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading