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,
pub context: serde_json::Value,
}
#[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 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);
}
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)
{
eprintln!("Warning: could not re-apply working-tree changes: {}", e);
}
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);
if let Some(parent) = path.parent() {
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::fs::write(&path, json)
.with_context(|| format!("Failed to write state file '{}'", path.display()))?;
Ok(())
}
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", path.display()))?;
Ok(Some(state))
}
pub fn load_required(git_dir: &Path) -> Result<LoomState> {
load(git_dir)?.with_context(|| "No loom operation is in progress".to_string())
}
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 warn_conflict_paused(command: &str) {
crate::core::msg::warn(&format!(
"Conflicts detected — resolve them with git, then run:\n\
`loom continue` to complete the {}\n\
`loom abort` to cancel and restore original state",
command
));
}
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 state = load_required(git_dir)?;
if git::rebase_is_in_progress(git_dir) {
match git::continue_rebase(workdir)? {
git::RebaseOutcome::Conflicted => {
crate::core::msg::warn(
"Conflicts remain — resolve them and run `loom continue` again",
);
return Ok(());
}
git::RebaseOutcome::Completed => {}
}
} else if git::merge_is_in_progress(git_dir) {
match git::continue_merge(workdir, git_dir)? {
git::MergeOutcome::Conflicted => {
crate::core::msg::warn(
"Conflicts remain — resolve them and run `loom continue` again",
);
return Ok(());
}
git::MergeOutcome::Completed => {}
}
}
dispatch_after_continue(workdir, &state)?;
delete(git_dir)?;
Ok(())
}
pub fn abort_cmd(workdir: &Path, git_dir: &Path) -> Result<()> {
let state = load_required(git_dir)?;
if git::rebase_is_in_progress(git_dir) {
let _ = git::rebase_abort(workdir);
} else if git::merge_is_in_progress(git_dir) {
let _ = git::merge_abort(workdir);
}
state.rollback.apply_abort(workdir)?;
delete(git_dir)?;
crate::core::msg::success(&format!(
"Aborted `loom {}` and restored original state",
state.command
));
Ok(())
}
fn dispatch_after_continue(workdir: &Path, state: &LoomState) -> Result<()> {
match state.command.as_str() {
"update" => crate::update::after_continue(workdir, &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.context),
"fold" => crate::fold::after_continue(workdir, &state.context),
"swap" => crate::swap::after_continue(workdir, &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" }),
};
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"]);
}
#[test]
fn missing_state_returns_none() {
let dir = tempfile::tempdir().unwrap();
let result = load(dir.path()).unwrap();
assert!(result.is_none());
}
#[test]
fn load_required_errors_on_missing() {
let dir = tempfile::tempdir().unwrap();
let result = load_required(dir.path());
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("No loom operation is in progress")
);
}
#[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());
assert!(result.is_err());
}
#[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,
};
save(dir.path(), &state).unwrap();
assert!(state_path(dir.path()).exists());
delete(dir.path()).unwrap();
assert!(!state_path(dir.path()).exists());
delete(dir.path()).unwrap();
}
}