use std::path::Path;
use anyhow::Result;
#[derive(Debug, PartialEq, Eq)]
pub enum RebaseOutcome {
Completed,
Paused,
Stopped,
}
pub fn continue_rebase(workdir: &Path) -> Result<RebaseOutcome> {
let git_dir = super::absolute_git_dir(workdir)?;
rebase_outcome(&git_dir, super::run_git(workdir, &["rebase", "--continue"]))
}
pub fn rebase_outcome(git_dir: &Path, result: Result<()>) -> Result<RebaseOutcome> {
let in_progress = rebase_is_in_progress(git_dir);
match result {
Ok(()) if in_progress => Ok(RebaseOutcome::Paused),
Ok(()) => Ok(RebaseOutcome::Completed),
Err(_) if in_progress => Ok(RebaseOutcome::Stopped),
Err(e) => Err(e),
}
}
pub fn rebase(git_dir: &Path, workdir: &Path, upstream: &str) -> Result<RebaseOutcome> {
rebase_outcome(
git_dir,
super::run_git(
workdir,
&[
"rebase",
"--autostash",
"--update-refs",
"--rebase-merges",
upstream,
],
),
)
}
#[cfg(test)]
pub fn rebase_onto(workdir: &Path, newbase: &str, upstream: &str) -> Result<()> {
super::run_git(
workdir,
&[
"rebase",
"--onto",
newbase,
upstream,
"--autostash",
"--update-refs",
],
)
}
fn replay_identity(workdir: &Path, rev: &str) -> Result<String> {
super::run_git_stdout(
workdir,
&["show", "-s", "--format=%an%x00%ae%x00%at%x00%B", rev],
)
}
pub fn verify_paused_at(workdir: &Path, expect_stop: &str) -> Result<()> {
match stopped_on_the_replay(workdir, expect_stop) {
Ok(true) => Ok(()),
Ok(false) => {
let cause = mismatch_error(workdir, expect_stop);
Err(rebase_abort_then_cleanup(workdir, cause, || {}))
}
Err(e) => Err(rebase_abort_then_cleanup(workdir, e, || {})),
}
}
fn stopped_on_the_replay(workdir: &Path, expect_stop: &str) -> Result<bool> {
if let Some(sha) = stopped_sha(&super::absolute_git_dir(workdir)?) {
return Ok(shas_match(&sha, expect_stop));
}
Ok(replay_identity(workdir, "HEAD")? == replay_identity(workdir, expect_stop)?)
}
fn mismatch_error(workdir: &Path, expect_stop: &str) -> anyhow::Error {
let subject = super::run_git_stdout(workdir, &["show", "-s", "--format=%s", "HEAD"])
.unwrap_or_default()
.trim()
.to_string();
let stopped_on = match subject.as_str() {
"" => "another commit".to_string(),
subject => format!("`{subject}`"),
};
anyhow::anyhow!(
"Commit `{}` was not replayed — the rebase stopped on {stopped_on} instead\n\
Nothing was rewritten",
super::short_hash(expect_stop)
)
}
fn has_local_changes(workdir: &Path) -> bool {
super::run_git_stdout(
workdir,
&[
"status",
"--porcelain",
"--untracked-files=no",
"--ignore-submodules=dirty",
],
)
.map_or(true, |out| !out.trim().is_empty())
}
fn shas_match(a: &str, b: &str) -> bool {
let shortest = a.len().min(b.len());
shortest > 0 && a.as_bytes()[..shortest] == b.as_bytes()[..shortest]
}
fn stopped_sha(git_dir: &Path) -> Option<String> {
let sha = std::fs::read_to_string(git_dir.join("rebase-merge").join("stopped-sha")).ok()?;
let sha = sha.trim().to_string();
(!sha.is_empty()).then_some(sha)
}
fn replays_empty(workdir: &Path, sha: &str) -> bool {
let Ok(head_tree) = super::run_git_stdout(workdir, &["rev-parse", "HEAD^{tree}"]) else {
return false;
};
let Ok(merged) = super::run_git_stdout(
workdir,
&[
"merge-tree",
"--write-tree",
"--merge-base",
&format!("{sha}^"),
"HEAD",
sha,
],
) else {
return false;
};
merged.lines().next().map(str::trim) == Some(head_tree.trim())
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Protected<'a> {
pub named: &'a [String],
pub targets: &'a [String],
}
impl<'a> Protected<'a> {
pub fn named(named: &'a [String]) -> Self {
Self {
named,
targets: &[],
}
}
pub fn targeting(self, targets: &'a [String]) -> Self {
Self { targets, ..self }
}
}
pub fn carry_past_known_stops(
workdir: &Path,
git_dir: &Path,
protected: Protected<'_>,
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);
}
}
}
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)
}
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub struct StopId {
auto_merge: String,
step: Option<usize>,
}
impl StopId {
pub fn auto_merge(&self) -> &str {
&self.auto_merge
}
}
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),
})
}
fn carried_set(before: Option<&StopId>) -> std::collections::HashSet<StopId> {
before.cloned().into_iter().collect()
}
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.clone()) {
break;
}
if has_unmerged_paths(workdir) || !rerere_auto_updates(workdir) || resolves_to_head(workdir)
{
break;
}
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, || {})),
};
if outcome == RebaseOutcome::Stopped && stop_id(workdir, git_dir).as_ref() == Some(&id) {
break;
}
resolved += 1;
crate::core::msg::warn(&format!(
"`rerere` resolved the conflicts{} — carried on with its recorded resolution",
on.unwrap_or_default()
));
}
Ok((outcome, resolved))
}
fn resolves_to_head(workdir: &Path) -> bool {
match (
super::write_tree(workdir),
super::rev_parse(workdir, "HEAD^{tree}"),
) {
(Ok(index), Ok(head)) => index == head,
_ => true,
}
}
fn rerere_auto_updates(workdir: &Path) -> bool {
super::run_git_stdout(workdir, &["config", "--bool", "--get", "rerere.autoUpdate"])
.is_ok_and(|value| value.trim() == "true")
}
pub fn skip_empty_stops(
workdir: &Path,
git_dir: &Path,
protected: Protected<'_>,
mut outcome: RebaseOutcome,
) -> Result<RebaseOutcome> {
let mut skipped_already: std::collections::HashSet<String> = std::collections::HashSet::new();
while outcome == RebaseOutcome::Stopped && !has_unmerged_paths(workdir) {
let Some(sha) = stopped_sha(git_dir) else {
return Ok(outcome);
};
if !replays_empty(workdir, &sha) {
return Ok(outcome);
}
let short = super::short_hash(&sha).to_string();
let dirty = has_local_changes(workdir);
let in_list = |list: &[String]| list.iter().any(|hash| shas_match(hash, &sha));
let is_target = in_list(protected.targets);
if is_target || in_list(protected.named) {
let cause = anyhow::Error::new(ReplayedEmpty(sha.clone()));
if dirty {
return Err(cause.context(format!(
"Commit `{short}` {REPLAYS_EMPTY}\n\
Your uncommitted changes are in the way of the undo — commit or \
stash them, then run `loom abort`"
)));
}
return Err(rebase_abort_then_cleanup(
workdir,
cause.context(format!(
"Commit `{short}` {REPLAYS_EMPTY}\n\
Nothing was rewritten. Run `loom update` if it landed upstream{}",
if is_target {
String::new()
} else {
format!(", or `loom drop {short} -y` to remove it now")
}
)),
|| {},
));
}
if dirty {
return Ok(outcome);
}
if !skipped_already.insert(sha.clone()) {
return Ok(outcome);
}
match rebase_outcome(git_dir, super::run_git(workdir, &["rebase", "--skip"])) {
Ok(next) => outcome = next,
Err(e) => return Err(rebase_abort_then_cleanup(workdir, e, || {})),
}
crate::core::msg::warn(&format!(
"Dropped `{short}` — the history below it already has its change"
));
}
Ok(outcome)
}
pub const REPLAYS_EMPTY: &str = "is redundant — the history below it already has its change";
#[derive(Debug)]
pub(crate) struct ReplayedEmpty(String);
impl std::fmt::Display for ReplayedEmpty {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "commit {} replayed empty", self.0)
}
}
impl std::error::Error for ReplayedEmpty {}
pub fn replayed_empty_hash(err: &anyhow::Error) -> Option<&str> {
err.downcast_ref::<ReplayedEmpty>().map(|e| e.0.as_str())
}
#[derive(Debug)]
pub(crate) struct RebaseNotStarted;
impl std::fmt::Display for RebaseNotStarted {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("the rebase never started")
}
}
impl std::error::Error for RebaseNotStarted {}
pub fn before_rebase_starts<T>(result: Result<T>) -> Result<T> {
result.map_err(|e| {
let shown = format!("{e}");
anyhow::Error::new(RebaseNotStarted)
.context(e)
.context(shown)
})
}
pub fn rebase_never_started(err: &anyhow::Error) -> bool {
err.downcast_ref::<RebaseNotStarted>().is_some()
}
pub fn rebase_abort(workdir: &Path) -> Result<()> {
super::run_git(workdir, &["rebase", "--abort"])
}
pub fn rebase_is_over(workdir: &Path) -> bool {
super::absolute_git_dir(workdir).is_ok_and(|git_dir| !rebase_is_in_progress(&git_dir))
}
pub fn rebase_is_in_progress(git_dir: &Path) -> bool {
git_dir.join("rebase-merge").exists() || git_dir.join("rebase-apply").exists()
}
pub fn rebase_progress(git_dir: &Path) -> Option<(usize, usize)> {
let dir = ["rebase-merge", "rebase-apply"]
.iter()
.map(|d| git_dir.join(d))
.find(|d| d.exists())?;
let read = |name: &str| -> Option<usize> {
std::fs::read_to_string(dir.join(name))
.ok()?
.trim()
.parse()
.ok()
};
Some((read("msgnum")?, read("end")?))
}
pub fn rebase_abort_then_cleanup(
workdir: &Path,
cause: anyhow::Error,
cleanup: impl FnOnce(),
) -> anyhow::Error {
if rebase_is_over(workdir) {
cleanup();
return cause;
}
match rebase_abort(workdir) {
Ok(()) => {
cleanup();
cause
}
Err(_) => anyhow::anyhow!(
"{cause}\n\
The abort failed too, so the repository is left mid-rebase.\n\
Run `loom abort` once git is free ({})",
super::ABORT_FAILED_CAUSE
),
}
}
pub fn abort_after_failure(workdir: &Path) -> anyhow::Error {
let conflicted = has_unmerged_paths(workdir) || auto_merge_id(workdir).is_some();
match rebase_abort(workdir) {
Ok(()) if conflicted => anyhow::anyhow!("Rebase failed with conflicts — aborted"),
Ok(()) => anyhow::anyhow!(
"Rebase stopped before finishing — aborted\n\
Run `loom trace` to see why"
),
Err(_) => anyhow::anyhow!(
"Rebase failed, and the abort failed too — the repository is left mid-rebase.\n\
Run `loom abort` once git is free ({}).",
super::ABORT_FAILED_CAUSE
),
}
}
pub fn has_unmerged_paths(workdir: &Path) -> bool {
!unmerged_paths(workdir).is_empty()
}
pub fn unmerged_paths(workdir: &Path) -> Vec<String> {
super::run_git_stdout(workdir, &["diff", "-z", "--name-only", "--diff-filter=U"])
.map(|out| {
out.split('\0')
.filter(|p| !p.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
pub fn auto_merge_id(workdir: &Path) -> Option<String> {
let out =
super::run_git_stdout(workdir, &["rev-parse", "--verify", "--quiet", "AUTO_MERGE"]).ok()?;
let id = out.trim().to_string();
(!id.is_empty()).then_some(id)
}
pub fn continue_rebase_expecting_edit(workdir: &Path, after: AfterStop<'_>) -> Result<()> {
let git_dir = super::absolute_git_dir(workdir)?;
let mut named = after.protect.to_vec();
named.extend(after.expect.map(str::to_string));
let outcome = carry_past_known_stops(
workdir,
&git_dir,
Protected::named(&named).targeting(after.targets),
None,
continue_rebase(workdir)?,
)?;
let Some(expect_stop) = after.expect else {
return match outcome {
RebaseOutcome::Completed | RebaseOutcome::Paused => Ok(()),
RebaseOutcome::Stopped => Err(abort_after_failure(workdir)),
};
};
match outcome {
RebaseOutcome::Paused => verify_paused_at(workdir, expect_stop),
RebaseOutcome::Completed => Err(finished_without_stopping(expect_stop)),
RebaseOutcome::Stopped => Err(abort_after_failure(workdir)),
}
}
#[derive(Debug, Default)]
pub struct AfterStop<'a> {
expect: Option<&'a str>,
protect: &'a [String],
targets: &'a [String],
}
impl<'a> AfterStop<'a> {
pub fn nothing() -> Self {
Self::default()
}
pub fn rewrite(expect_stop: &'a str) -> Self {
Self {
expect: Some(expect_stop),
..Self::default()
}
}
pub fn protecting(self, protect: &'a [String]) -> Self {
Self { protect, ..self }
}
pub fn targeting(self, targets: &'a [String]) -> Self {
Self { targets, ..self }
}
}
pub fn finished_without_stopping(expect_stop: &str) -> anyhow::Error {
anyhow::anyhow!(
"The rebase finished without stopping at `{}` — history was rewritten\n\
`git reflog` has the previous tips",
super::short_hash(expect_stop)
)
}
#[cfg(test)]
#[path = "git_rebase_test.rs"]
mod tests;