use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use crate::git;
#[derive(Debug, Serialize, Deserialize)]
pub struct LoomState {
pub command: String,
pub rollback: Rollback,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub protect: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub targets: Vec<String>,
pub context: serde_json::Value,
}
impl LoomState {
pub fn protected(&self) -> git::Protected<'_> {
git::Protected::named(&self.protect).targeting(&self.targets)
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Rollback {
#[serde(default)]
pub reset_mixed_to: String,
#[serde(default)]
pub reset_hard_to: String,
#[serde(default)]
pub delete_branches: Vec<String>,
#[serde(default)]
pub saved_staged_patch: String,
#[serde(default)]
pub saved_worktree_patch: String,
}
impl Rollback {
pub fn takes_head_back(&self) -> bool {
!self.reset_mixed_to.is_empty() || !self.reset_hard_to.is_empty()
}
pub fn delete_temp_branches(&self, workdir: &Path) {
for branch in &self.delete_branches {
let _ = git::branch_delete(workdir, branch);
}
}
pub fn apply_abort(&self, workdir: &Path) -> Result<()> {
if !self.reset_mixed_to.is_empty() {
git::reset_mixed(workdir, &self.reset_mixed_to)?;
}
if !self.reset_hard_to.is_empty() {
git::reset_hard(workdir, &self.reset_hard_to)?;
}
for branch in &self.delete_branches {
let _ = git::branch_delete(workdir, branch);
}
if self.reset_mixed_to.is_empty() && self.reset_hard_to.is_empty() {
git::restore_staged_after_rebase(workdir, &self.saved_staged_patch);
} else {
git::restore_staged_patch(workdir, &self.saved_staged_patch);
}
if !self.saved_worktree_patch.is_empty()
&& let Err(e) = git::apply_patch(workdir, &self.saved_worktree_patch)
{
crate::core::msg::warn(&format!("could not re-apply working-tree changes: {e}"));
git::save_or_warn(
workdir,
"unrestored",
&self.saved_worktree_patch,
git::Replay::Worktree,
);
}
Ok(())
}
}
pub fn state_path(git_dir: &Path) -> PathBuf {
git_dir.join("loom").join("state.json")
}
pub fn save(git_dir: &Path, state: &LoomState) -> Result<()> {
let path = state_path(git_dir);
let parent = path
.parent()
.context("State file path has no parent directory")?;
std::fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create loom state directory '{}'",
parent.display()
)
})?;
let json = serde_json::to_string_pretty(state)?;
(|| -> std::io::Result<()> {
use std::io::Write;
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
tmp.write_all(json.as_bytes())?;
tmp.persist(&path)?;
Ok(())
})()
.with_context(|| format!("Failed to write state file '{}'", path.display()))
}
pub fn load(git_dir: &Path) -> Result<Option<LoomState>> {
let path = state_path(git_dir);
if !path.exists() {
return Ok(None);
}
let json = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read state file '{}'", path.display()))?;
let state: LoomState = serde_json::from_str(&json).with_context(|| {
format!(
"State file '{}' is corrupted or invalid\n\
Move it aside (keep it — it is the only record of what `loom abort` would undo),\n\
then run `loom abort` to cancel whatever git still has in progress",
path.display()
)
})?;
Ok(Some(state))
}
pub fn delete(git_dir: &Path) -> Result<()> {
let path = state_path(git_dir);
if path.exists() {
std::fs::remove_file(&path)
.with_context(|| format!("Failed to delete state file '{}'", path.display()))?;
}
Ok(())
}
pub fn discard_state_after(
workdir: &Path,
git_dir: &Path,
saved_staged_patch: &str,
cause: anyhow::Error,
) -> anyhow::Error {
let autostashed = !git::rebase_never_started(&cause);
git::rebase_abort_then_cleanup(workdir, cause, || {
if autostashed {
git::restore_staged_after_rebase(workdir, saved_staged_patch);
}
if let Err(e) = delete(git_dir) {
crate::core::msg::warn(&format!("could not remove the loom state file: {e}"));
}
})
}
#[derive(Debug, PartialEq, Eq)]
enum PauseReason {
Conflicts,
ResolvedConflicts,
Other,
}
fn pause_reason(workdir: &Path, before: Option<&str>) -> PauseReason {
if git::has_unmerged_paths(workdir) {
PauseReason::Conflicts
} else if git::auto_merge_id(workdir).is_some_and(|id| Some(id.as_str()) != before) {
PauseReason::ResolvedConflicts
} else {
PauseReason::Other
}
}
pub fn warn_paused(workdir: &Path, command: &str) {
let (note, hint, cause) = match pause_reason(workdir, None) {
PauseReason::Conflicts => (
format!("Conflicts detected — the `loom {}` is paused", command),
"resolve conflicts, stage them, then run: loom continue (or loom abort)",
"Conflicts detected — resolve them with git, then run:",
),
PauseReason::ResolvedConflicts => (
format!(
"`rerere` resolved the conflicts — the `loom {}` is paused",
command
),
"review the resolution, then run: loom continue (or loom abort)",
"`rerere` resolved the conflicts for you — review the result, then run:",
),
PauseReason::Other => (
format!("The `loom {}` is paused — it stopped part-way", command),
"run loom trace to see why, fix it, then run: loom continue (or loom abort)",
"The operation stopped part-way — run `loom trace` to see why, then:",
),
};
crate::core::agent_mode::note_paused(¬e, hint);
crate::core::msg::warn_reported(&format!(
"{}\n\
`loom continue` to complete the {}\n\
`loom abort` to cancel and restore original state",
cause, command
));
}
pub fn warn_paused_at_edit(command: Option<&str>) {
let (owner, abort_hint) = match command {
Some(c) => (
format!("The `loom {}` is paused at an `edit` step", c),
"to cancel and restore original state",
),
None => (
"The rebase is paused at an `edit` step".to_string(),
"to cancel it (no loom state to roll back)",
),
};
crate::core::agent_mode::note_paused(
&owner,
"finish the work there, then run: loom continue (or loom abort)",
);
crate::core::msg::warn_reported(&format!(
"{} — finish the work there, then run:\n\
`loom continue` to carry on\n\
`loom abort` {}",
owner, abort_hint
));
}
fn warn_still_paused(workdir: &Path, subject: &str, auto_merge_before: Option<&str>) {
let (note, hint, body) = match pause_reason(workdir, auto_merge_before) {
PauseReason::Conflicts => (
format!("Conflicts remain — the {} is still paused", subject),
"resolve conflicts, stage them, then run: loom continue (or loom abort)",
"Conflicts remain — resolve them and run `loom continue` again".to_string(),
),
PauseReason::ResolvedConflicts => (
format!(
"`rerere` resolved the next conflicts — the {} is still paused",
subject
),
"review the resolution, then run: loom continue (or loom abort)",
"`rerere` resolved the next conflicts — review the result and run `loom continue` again"
.to_string(),
),
PauseReason::Other => (
format!("The {} stopped again — it is still paused", subject),
"run loom trace to see why, fix it, then run: loom continue (or loom abort)",
format!(
"The {} stopped again — run `loom trace` to see why, then `loom continue`",
subject
),
),
};
crate::core::agent_mode::note_paused(¬e, hint);
crate::core::msg::warn_reported(&body);
}
pub fn continue_run() -> Result<()> {
let repo = crate::core::repo::open_repo()?;
let workdir = crate::core::repo::require_workdir(&repo, "continue")?.to_path_buf();
let git_dir = repo.path().to_path_buf();
continue_cmd(&workdir, &git_dir)
}
pub fn abort_run() -> Result<()> {
let repo = crate::core::repo::open_repo()?;
let workdir = crate::core::repo::require_workdir(&repo, "abort")?.to_path_buf();
let git_dir = repo.path().to_path_buf();
abort_cmd(&workdir, &git_dir)
}
pub fn continue_cmd(workdir: &Path, git_dir: &Path) -> Result<()> {
let Some(state) = load(git_dir)? else {
return continue_without_state(workdir, git_dir);
};
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) {
let outcome = git::carry_past_known_stops(
workdir,
git_dir,
state.protected(),
stop_before.as_ref(),
git::continue_rebase(workdir)?,
)
.map_err(|e| match git::replayed_empty_hash(&e) {
Some(_) if !git::rebase_is_in_progress(git_dir) => {
roll_back_failed_rebase(workdir, git_dir, &state, e)
}
_ => e,
})?;
match outcome {
git::RebaseOutcome::Paused => {
warn_paused_at_edit(Some(&state.command));
return Ok(());
}
git::RebaseOutcome::Stopped => {
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);
return Ok(());
}
git::MergeOutcome::Completed => {}
}
}
dispatch_after_continue(workdir, &state)?;
delete(git_dir)?;
Ok(())
}
pub fn roll_back_failed_rebase(
workdir: &Path,
git_dir: &Path,
state: &LoomState,
cause: anyhow::Error,
) -> anyhow::Error {
if git::rebase_is_in_progress(git_dir) || git::merge_is_in_progress(git_dir) {
crate::core::msg::warn(
"the rebase is still in progress, so nothing was rolled back — run `loom abort`",
);
return cause;
}
if git::rebase_never_started(&cause) && !state.rollback.takes_head_back() {
state.rollback.delete_temp_branches(workdir);
if let Err(e) = delete(git_dir) {
crate::core::msg::warn(&format!("could not remove the loom state file: {e}"));
}
return cause;
}
if let Err(e) = state.rollback.apply_abort(workdir) {
crate::core::msg::warn(&format!("{ROLLBACK_FAILED_HINT} ({e})"));
return cause;
}
if let Err(e) = delete(git_dir) {
crate::core::msg::warn(&format!("could not remove the loom state file: {e}"));
}
match git::replayed_empty_hash(&cause) {
Some(sha) if !git::reaches_from_head(workdir, sha) => {
let sha = sha.to_string();
empty_replay_rolled_back(cause, &sha, &state.command)
}
_ => cause,
}
}
fn empty_replay_rolled_back(cause: anyhow::Error, sha: &str, command: &str) -> anyhow::Error {
let short = git::short_hash(sha);
cause.context(format!(
"Commit `{short}` {}\n\
The `loom {command}` was rolled back, so there is nothing left to drop",
git::REPLAYS_EMPTY
))
}
static ABORT_FAILED_HINT: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
format!(
"the abort failed — the loom state was kept, so run `loom abort` again once git is free\n\
({})",
git::ABORT_FAILED_CAUSE
)
});
const ROLLBACK_FAILED_HINT: &str =
"the rollback is only half-applied — the loom state was kept so `loom abort` can finish it";
pub fn abort_cmd(workdir: &Path, git_dir: &Path) -> Result<()> {
let Some(state) = load(git_dir)? else {
return abort_without_state(workdir, git_dir);
};
if git::rebase_is_in_progress(git_dir) {
git::rebase_abort(workdir).context(ABORT_FAILED_HINT.as_str())?;
} else if git::merge_is_in_progress(git_dir) {
git::merge_abort(workdir).context(ABORT_FAILED_HINT.as_str())?;
}
state
.rollback
.apply_abort(workdir)
.context(ROLLBACK_FAILED_HINT)?;
delete(git_dir)?;
crate::core::msg::success(&format!(
"Aborted `loom {}` and restored original state",
state.command
));
Ok(())
}
const NO_OPERATION: &str = "No loom operation is in progress";
#[derive(Clone, Copy)]
enum GitOp {
Rebase,
Merge,
}
impl GitOp {
fn as_str(self) -> &'static str {
match self {
GitOp::Rebase => "rebase",
GitOp::Merge => "merge",
}
}
}
fn continue_without_state(workdir: &Path, git_dir: &Path) -> Result<()> {
let before = git::auto_merge_id(workdir);
let before = before.as_deref();
if git::rebase_is_in_progress(git_dir) {
match git::continue_rebase(workdir)? {
git::RebaseOutcome::Paused => warn_paused_at_edit(None),
git::RebaseOutcome::Stopped => {
warn_still_paused(workdir, GitOp::Rebase.as_str(), before)
}
git::RebaseOutcome::Completed => report_stateless_continue(GitOp::Rebase),
}
} else if git::merge_is_in_progress(git_dir) {
match git::continue_merge(workdir, git_dir)? {
git::MergeOutcome::Stopped => warn_still_paused(workdir, GitOp::Merge.as_str(), before),
git::MergeOutcome::Completed => report_stateless_continue(GitOp::Merge),
}
} else {
bail!(NO_OPERATION);
}
Ok(())
}
fn report_stateless_continue(op: GitOp) {
crate::core::msg::success(&format!(
"Completed the {} git had in progress (no loom state, so nothing else was done)",
op.as_str()
));
}
fn report_stateless_abort(op: GitOp) {
crate::core::msg::success(&format!(
"Canceled the {} git had in progress (no loom state to roll back)",
op.as_str()
));
}
fn stateless_abort_failed(op: GitOp) -> String {
format!(
"`git {} --abort` failed ({})",
op.as_str(),
git::ABORT_FAILED_CAUSE
)
}
fn abort_without_state(workdir: &Path, git_dir: &Path) -> Result<()> {
let op = if git::rebase_is_in_progress(git_dir) {
GitOp::Rebase
} else if git::merge_is_in_progress(git_dir) {
GitOp::Merge
} else {
bail!(NO_OPERATION);
};
match op {
GitOp::Rebase => git::rebase_abort(workdir),
GitOp::Merge => git::merge_abort(workdir),
}
.with_context(|| stateless_abort_failed(op))?;
report_stateless_abort(op);
Ok(())
}
fn dispatch_after_continue(workdir: &Path, state: &LoomState) -> Result<()> {
match state.command.as_str() {
"update" => crate::update::after_continue(workdir, &state.rollback, &state.context),
"commit" => crate::commit::after_continue(workdir, &state.rollback, &state.context),
"absorb" => crate::absorb::after_continue(workdir, &state.rollback, &state.context),
"drop" => crate::drop::after_continue(workdir, &state.rollback, &state.context),
"fold" => crate::fold::after_continue(workdir, &state.rollback, &state.context),
"reword" => crate::reword::after_continue(workdir, &state.rollback, &state.context),
"swap" => crate::swap::after_continue(workdir, &state.rollback, &state.context),
"merge" => crate::branch::merge::after_continue(&state.context),
other => bail!("Unknown command '{}' in loom state file", other),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_roundtrip() {
let state = LoomState {
command: "commit".to_string(),
rollback: Rollback {
reset_mixed_to: "abc123".to_string(),
delete_branches: vec!["new-branch".to_string()],
saved_staged_patch: "--- a/foo\n+++ b/foo\n".to_string(),
..Default::default()
},
context: serde_json::json!({ "branch_name": "feature" }),
protect: vec!["def456".to_string()],
targets: vec!["fed789".to_string()],
};
let json = serde_json::to_string_pretty(&state).unwrap();
let restored: LoomState = serde_json::from_str(&json).unwrap();
assert_eq!(restored.command, "commit");
assert_eq!(restored.rollback.reset_mixed_to, "abc123");
assert_eq!(restored.rollback.delete_branches, vec!["new-branch"]);
assert_eq!(restored.protect, vec!["def456"]);
assert_eq!(restored.targets, vec!["fed789"]);
}
#[test]
fn state_without_protect_loads() {
let json = r#"{"command":"fold","rollback":{},"context":null}"#;
let restored: LoomState = serde_json::from_str(json).unwrap();
assert!(restored.protect.is_empty());
assert!(restored.targets.is_empty());
}
#[test]
fn continue_refuses_when_a_protected_commit_replays_empty() {
let (t, keeper) = crate::core::test_helpers::repo_with_a_redundant_commit_above();
let workdir = t.workdir();
let git_dir = t.repo.path().to_path_buf();
let redundant = t.get_branch_target("alpha").to_string();
let mut graph = crate::core::weave::Weave::from_repo(&t.repo).unwrap();
assert!(graph.edit_commit(keeper));
let protect = vec![redundant.clone()];
let outcome = crate::core::weave::run_rebase_protecting(
&workdir,
Some(&graph.base_oid.to_string()),
&graph.to_todo(),
crate::git::Protected::named(&protect),
)
.unwrap();
assert_eq!(outcome, git::RebaseOutcome::Paused);
t.create_branch_at("_loom-track", &redundant);
save(
&git_dir,
&LoomState {
command: "fold".to_string(),
rollback: Rollback {
delete_branches: vec!["_loom-track".to_string()],
..Default::default()
},
context: serde_json::Value::Null,
protect,
targets: Vec::new(),
},
)
.unwrap();
let err = continue_cmd(&workdir, &git_dir).unwrap_err().to_string();
assert!(err.contains("is redundant"), "{err}");
assert!(
err.contains("loom drop"),
"this rollback keeps the commit, so the hint stands: {err}"
);
assert!(!git::rebase_is_in_progress(&git_dir), "{err}");
assert!(
!state_path(&git_dir).exists(),
"the state must go with the rollback, or `loom drop` is refused"
);
assert!(
!t.branch_exists("_loom-track"),
"the rollback runs too, not just the state removal"
);
}
#[test]
fn continue_does_not_offer_to_drop_a_target() {
let (t, keeper) = crate::core::test_helpers::repo_with_a_redundant_commit_above();
let workdir = t.workdir();
let git_dir = t.repo.path().to_path_buf();
let redundant = t.get_branch_target("alpha").to_string();
let mut graph = crate::core::weave::Weave::from_repo(&t.repo).unwrap();
assert!(graph.edit_commit(keeper));
let targets = vec![redundant];
crate::core::weave::run_rebase_protecting(
&workdir,
Some(&graph.base_oid.to_string()),
&graph.to_todo(),
crate::git::Protected::default().targeting(&targets),
)
.unwrap();
save(
&git_dir,
&LoomState {
command: "fold".to_string(),
rollback: Rollback::default(),
context: serde_json::Value::Null,
protect: Vec::new(),
targets,
},
)
.unwrap();
let err = continue_cmd(&workdir, &git_dir).unwrap_err().to_string();
assert!(err.contains("is redundant"), "{err}");
assert!(!err.contains("loom drop"), "{err}");
assert!(!git::rebase_is_in_progress(&git_dir), "{err}");
}
#[test]
fn continue_does_not_undo_over_a_dirty_tree() {
let (t, keeper) = crate::core::test_helpers::repo_with_a_redundant_commit_above();
let workdir = t.workdir();
let git_dir = t.repo.path().to_path_buf();
let mut graph = crate::core::weave::Weave::from_repo(&t.repo).unwrap();
assert!(graph.edit_commit(keeper));
let base = graph.base_oid.to_string();
let protect: Vec<String> =
git::run_git_stdout(&workdir, &["rev-list", &format!("{base}..alpha")])
.unwrap()
.lines()
.map(str::to_string)
.collect();
crate::core::weave::run_rebase_protecting(
&workdir,
Some(&base),
&graph.to_todo(),
crate::git::Protected::named(&protect),
)
.unwrap();
t.write_file("three.txt", "edited while paused\n");
save(
&git_dir,
&LoomState {
command: "commit".to_string(),
rollback: Rollback {
reset_mixed_to: base.clone(),
..Default::default()
},
context: serde_json::Value::Null,
protect,
targets: Vec::new(),
},
)
.unwrap();
let err = continue_cmd(&workdir, &git_dir).unwrap_err().to_string();
assert!(err.contains("is redundant"), "{err}");
assert!(err.contains("stash"), "{err}");
assert_eq!(t.read_file("three.txt"), "edited while paused\n", "{err}");
assert!(state_path(&git_dir).exists(), "nothing was undone: {err}");
assert!(git::rebase_is_in_progress(&git_dir), "{err}");
git::rebase_abort(&workdir).unwrap();
}
#[test]
fn continue_drops_the_drop_hint_when_the_rollback_removes_the_commit() {
let (t, keeper) = crate::core::test_helpers::repo_with_a_redundant_commit_above();
let workdir = t.workdir();
let git_dir = t.repo.path().to_path_buf();
let redundant = t.get_branch_target("alpha").to_string();
let mut graph = crate::core::weave::Weave::from_repo(&t.repo).unwrap();
assert!(graph.edit_commit(keeper));
let protect = vec![redundant];
crate::core::weave::run_rebase_protecting(
&workdir,
Some(&graph.base_oid.to_string()),
&graph.to_todo(),
crate::git::Protected::named(&protect),
)
.unwrap();
save(
&git_dir,
&LoomState {
command: "commit".to_string(),
rollback: Rollback {
reset_mixed_to: graph.base_oid.to_string(),
..Default::default()
},
context: serde_json::Value::Null,
protect,
targets: Vec::new(),
},
)
.unwrap();
let err = continue_cmd(&workdir, &git_dir).unwrap_err().to_string();
assert!(err.contains("is redundant"), "{err}");
assert!(!err.contains("loom drop"), "{err}");
assert!(err.contains("rolled back"), "{err}");
assert!(!state_path(&git_dir).exists(), "{err}");
}
#[test]
fn a_failed_abort_keeps_the_state_for_loom_abort() {
let t = repo_stopped_on_conflict();
let workdir = t.workdir();
let git_dir = t.repo.path().to_path_buf();
let state = LoomState {
command: "fold".to_string(),
rollback: Rollback {
delete_branches: vec!["topic".to_string()],
..Default::default()
},
context: serde_json::Value::Null,
protect: Vec::new(),
targets: Vec::new(),
};
save(&git_dir, &state).unwrap();
let err = roll_back_failed_rebase(&workdir, &git_dir, &state, anyhow::anyhow!("boom"));
assert_eq!(err.to_string(), "boom");
assert!(state_path(&git_dir).exists());
assert!(t.branch_exists("topic"), "the rollback must not have run");
git::rebase_abort(&workdir).unwrap();
}
#[test]
fn a_pre_flight_refusal_leaves_an_untouched_index_alone() {
let t = crate::core::test_helpers::TestRepo::new();
let workdir = t.workdir();
let git_dir = t.repo.path().to_path_buf();
t.write_file(
"staged.txt",
"the user's own
",
);
t.stage_files(&["staged.txt"]);
let staged = git::diff_cached(&workdir).unwrap();
let state = LoomState {
command: "fold".to_string(),
rollback: Rollback {
saved_staged_patch: staged.clone(),
..Default::default()
},
context: serde_json::Value::Null,
protect: Vec::new(),
targets: Vec::new(),
};
save(&git_dir, &state).unwrap();
let cause = git::before_rebase_starts::<()>(Err(anyhow::anyhow!("checked out elsewhere")))
.expect_err("tagged as raised before the rebase started");
roll_back_failed_rebase(&workdir, &git_dir, &state, cause);
assert_eq!(git::diff_cached(&workdir).unwrap(), staged);
assert!(!state_path(&git_dir).exists());
}
#[test]
fn a_failed_rollback_keeps_the_state_too() {
let t = crate::core::test_helpers::TestRepo::new();
let workdir = t.workdir();
let git_dir = t.repo.path().to_path_buf();
let state = LoomState {
command: "commit".to_string(),
rollback: Rollback {
reset_mixed_to: "0".repeat(40),
..Default::default()
},
context: serde_json::Value::Null,
protect: Vec::new(),
targets: Vec::new(),
};
save(&git_dir, &state).unwrap();
let err = roll_back_failed_rebase(&workdir, &git_dir, &state, anyhow::anyhow!("boom"));
assert_eq!(err.to_string(), "boom");
assert!(state_path(&git_dir).exists());
}
fn repo_stopped_on_conflict() -> crate::core::test_helpers::TestRepo {
use crate::core::test_helpers::TestRepo;
let test_repo = TestRepo::new();
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();
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();
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");
git::run_git(&workdir, &["rebase", &onto]).unwrap_err();
assert!(
git::has_unmerged_paths(&workdir),
"the rebase must conflict"
);
test_repo
}
#[test]
fn unmerged_paths_are_conflicts_to_resolve() {
let test_repo = repo_stopped_on_conflict();
assert_eq!(
pause_reason(&test_repo.workdir(), None),
PauseReason::Conflicts
);
}
#[test]
fn a_staged_conflict_is_resolved_only_when_it_is_a_new_one() {
let test_repo = repo_stopped_on_conflict();
let workdir = test_repo.workdir();
test_repo.write_file("f.txt", "resolved\n");
git::run_git(&workdir, &["add", "f.txt"]).unwrap();
assert_eq!(
pause_reason(&workdir, None),
PauseReason::ResolvedConflicts,
"nothing was pending before, so this conflict resolved itself"
);
let same = git::auto_merge_id(&workdir).unwrap();
assert_eq!(
pause_reason(&workdir, Some(&same)),
PauseReason::Other,
"an unchanged AUTO_MERGE must not be credited to rerere"
);
}
#[test]
fn an_abort_that_cannot_restage_parks_the_patch() {
let test_repo = crate::core::test_helpers::TestRepo::new();
test_repo.commit("A commit", "file1.txt");
let workdir = test_repo.workdir();
let rollback = Rollback {
reset_hard_to: test_repo.head_oid().to_string(),
saved_staged_patch: "not a patch at all\n".to_string(),
..Default::default()
};
rollback.apply_abort(&workdir).unwrap();
let parked = git::git_path(&workdir, "loom")
.unwrap()
.join("unrestored-staged-0.patch");
assert_eq!(
std::fs::read_to_string(&parked).unwrap(),
"not a patch at all\n",
"the state file is about to go, so this patch must not go with it"
);
}
#[test]
fn an_abort_that_cannot_restore_the_worktree_parks_that_patch() {
let test_repo = crate::core::test_helpers::TestRepo::new();
test_repo.commit("A commit", "file1.txt");
let workdir = test_repo.workdir();
let rollback = Rollback {
reset_hard_to: test_repo.head_oid().to_string(),
saved_worktree_patch: "not a patch at all\n".to_string(),
..Default::default()
};
rollback.apply_abort(&workdir).unwrap();
let parked = git::git_path(&workdir, "loom")
.unwrap()
.join("unrestored-0.patch");
assert_eq!(
std::fs::read_to_string(&parked).unwrap(),
"not a patch at all\n",
"the reset took these files and the state file is about to go"
);
}
#[test]
fn an_abort_without_a_reset_keeps_what_the_autostash_left_staged() {
let test_repo = crate::core::test_helpers::TestRepo::new();
test_repo.commit("A commit", "file1.txt");
let workdir = test_repo.workdir();
test_repo.write_file("file1.txt", "staged edit\n");
test_repo.write_file("brand-new.txt", "new\n");
test_repo.stage_files(&["file1.txt", "brand-new.txt"]);
let patch = git::diff_cached(&workdir).unwrap();
let before = test_repo.status_porcelain();
git::unstage_files(&workdir, &["file1.txt"]).unwrap();
Rollback {
saved_staged_patch: patch,
..Default::default()
}
.apply_abort(&workdir)
.unwrap();
assert_eq!(test_repo.status_porcelain(), before);
}
#[test]
fn a_clean_stop_has_no_conflict_to_report() {
let test_repo = crate::core::test_helpers::TestRepo::new();
assert_eq!(pause_reason(&test_repo.workdir(), None), PauseReason::Other);
}
#[test]
fn missing_state_returns_none() {
let dir = tempfile::tempdir().unwrap();
let result = load(dir.path()).unwrap();
assert!(result.is_none());
}
#[test]
fn corrupted_state_errors() {
let dir = tempfile::tempdir().unwrap();
let path = state_path(dir.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"not valid json").unwrap();
let result = load(dir.path());
let err = format!("{:#}", result.unwrap_err());
assert!(err.contains("state.json"), "{err}");
assert!(err.contains("loom abort"), "{err}");
}
fn loom_dir_entries(git_dir: &Path) -> Vec<String> {
let dir = state_path(git_dir).parent().unwrap().to_path_buf();
let mut names: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
#[test]
fn save_and_delete_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let state = LoomState {
command: "update".to_string(),
rollback: Rollback::default(),
context: serde_json::Value::Null,
protect: Vec::new(),
targets: Vec::new(),
};
save(dir.path(), &state).unwrap();
assert_eq!(
loom_dir_entries(dir.path()),
vec!["state.json"],
"the temp file the write goes through must not survive it"
);
delete(dir.path()).unwrap();
assert!(!state_path(dir.path()).exists());
delete(dir.path()).unwrap();
}
#[test]
fn save_replaces_an_existing_state_without_leaving_a_temp_file() {
let dir = tempfile::tempdir().unwrap();
let make = |command: &str| LoomState {
command: command.to_string(),
rollback: Rollback::default(),
context: serde_json::Value::Null,
protect: Vec::new(),
targets: Vec::new(),
};
save(dir.path(), &make("update")).unwrap();
save(dir.path(), &make("commit")).unwrap();
let loaded = load(dir.path()).unwrap().expect("state should load");
assert_eq!(
loaded.command, "commit",
"the rename must replace the old state"
);
assert_eq!(
loom_dir_entries(dir.path()),
vec!["state.json"],
"no temp file may outlive the saves"
);
}
#[test]
fn a_stale_temp_file_does_not_break_the_next_save() {
let dir = tempfile::tempdir().unwrap();
let path = state_path(dir.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let stale = path.with_file_name(".tmpAbC123");
std::fs::write(&stale, b"truncated {\"comm").unwrap();
save(
dir.path(),
&LoomState {
command: "commit".to_string(),
rollback: Rollback::default(),
context: serde_json::Value::Null,
protect: Vec::new(),
targets: Vec::new(),
},
)
.unwrap();
assert_eq!(
load(dir.path())
.unwrap()
.expect("state should load")
.command,
"commit"
);
assert!(
state_path(dir.path()).exists(),
"the stale file must not have been renamed over the real one"
);
}
#[test]
fn corrupt_state_error_never_suggests_deleting_it() {
let dir = tempfile::tempdir().unwrap();
let path = state_path(dir.path());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"{\"command\": \"comm").unwrap();
let err = format!("{:#}", load(dir.path()).unwrap_err());
assert!(
!err.to_lowercase().contains("delete"),
"deleting the state strands the rollback it records, got: {err}"
);
assert!(err.contains("Move it aside"), "{err}");
}
#[test]
fn a_failed_rollback_keeps_the_state_and_does_not_blame_git() {
let test_repo = crate::core::test_helpers::TestRepo::new();
test_repo.commit("first", "a.txt");
let workdir = test_repo.workdir();
let git_dir = test_repo.repo.path().to_path_buf();
save(
&git_dir,
&LoomState {
command: "commit".to_string(),
rollback: Rollback {
reset_mixed_to: "0".repeat(40),
..Default::default()
},
context: serde_json::Value::Null,
protect: Vec::new(),
targets: Vec::new(),
},
)
.unwrap();
let err = abort_cmd(&workdir, &git_dir).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("half-applied"), "{msg}");
assert!(
!msg.contains("index.lock"),
"the abort worked — git is not what failed here: {msg}"
);
assert!(
state_path(&git_dir).exists(),
"the record of what is left to undo must survive"
);
}
#[test]
fn continue_keeps_state_when_the_rebase_only_reaches_an_edit() {
let test_repo = crate::core::test_helpers::TestRepo::new();
let first = test_repo.commit("first", "a.txt");
test_repo.commit("second", "b.txt");
test_repo.commit("third", "c.txt");
let workdir = test_repo.workdir();
let git_dir = test_repo.repo.path().to_path_buf();
crate::git::run_git(
&workdir,
&[
"-c",
"sequence.editor=f() { sed 's/^pick/edit/' \"$1\" > \"$1.new\" && mv \"$1.new\" \"$1\"; }; f",
"rebase",
"-i",
&first.to_string(),
],
)
.unwrap();
save(
&git_dir,
&LoomState {
command: "drop".to_string(),
rollback: Rollback::default(),
context: serde_json::Value::Null,
protect: Vec::new(),
targets: Vec::new(),
},
)
.unwrap();
continue_cmd(&workdir, &git_dir).unwrap();
assert!(
crate::git::rebase_is_in_progress(&git_dir),
"the rebase only advanced to the next `edit` step"
);
assert!(
state_path(&git_dir).exists(),
"the state must survive a rebase that is still in progress"
);
}
}