use std::fs;
use anyhow::{Context, Result, anyhow};
use objects::{object::ThreadName, store::ObjectStore};
use refs::Head;
use repo::Repository;
use serde_json::{Value, json};
use super::{
action_line::print_next_step,
advice::RecoveryAdvice,
command_runtime_contract,
ff_record::record_ff_advance,
git_overlay_health::{
RepositoryVerificationState, action_template, build_repository_verification_state,
repository_verification_primary_command,
},
snapshot::ensure_current_state,
worktree_safety::ensure_worktree_clean,
};
use crate::{
cli::{Cli, JsonOutputMode, json_output_mode_for_kind},
config::UserConfig,
};
mod rebase_ops;
mod rebase_state;
use rebase_ops::{
flush_rebase_batch, mint_rebase_transaction_id, replay_commits, replay_commits_silent,
};
pub(crate) use rebase_state::load_rebase_state as load_persisted_rebase_state;
use rebase_state::{
RebaseState, collect_commits_to_rebase, is_ancestor_of, load_rebase_state,
load_rebase_state_for_abort, save_rebase_state,
};
use super::ff_record::ff_advance_deferred;
const REBASE_STATE_FILE: &str = "REBASE_STATE";
pub(super) fn emit_rebase_progress(
repo: &Repository,
cli: Option<&Cli>,
payload: Value,
) -> Result<bool> {
let Some(cli) = cli else {
return Ok(false);
};
let contract =
command_runtime_contract("rebase").expect("rebase command contract should be registered");
if json_output_mode_for_kind(cli, Some(repo.config()), contract.json_kind)
!= JsonOutputMode::Jsonl
{
return Ok(false);
}
let mut object = payload
.as_object()
.cloned()
.ok_or_else(|| anyhow!("rebase progress payload must be a JSON object"))?;
object.insert(
"output_kind".to_string(),
Value::String("rebase_progress".to_string()),
);
println!(
"{}",
serde_json::to_string(&Value::Object(object)).context("serialize rebase progress")?
);
Ok(true)
}
pub(crate) enum OperatorContinueStatus {
Continued,
Completed,
Blocked,
}
pub fn cmd_rebase(
cli: &Cli,
thread: Option<&str>,
abort: bool,
cont: bool,
force: bool,
) -> Result<()> {
let cwd_repo = cli.open_repo()?;
let target_path = cwd_repo.active_worktree_path()?;
let repo = if target_path == *cwd_repo.root() {
cwd_repo
} else {
Repository::open(&target_path)?
};
if !force && !abort && !cont {
ensure_worktree_clean(&repo, "rebase")?;
}
run_rebase(&repo, thread, abort, cont, force, Some(cli))
}
pub(crate) fn cmd_rebase_silent(
repo: &Repository,
thread: Option<&str>,
abort: bool,
cont: bool,
) -> Result<()> {
run_rebase(repo, thread, abort, cont, false, None)
}
pub(crate) fn continue_rebase_for_operator(repo: &Repository) -> Result<OperatorContinueStatus> {
let rebase_state_path = repo.heddle_dir().join(REBASE_STATE_FILE);
if !rebase_state_path.exists() {
return Err(anyhow!(no_rebase_in_progress_advice("continue rebase")));
}
let before = load_rebase_state(&rebase_state_path)?;
if let Some(pre_conflict_head) = before.pre_conflict_head {
let current_state = repo
.current_state()?
.ok_or_else(|| anyhow!("No current state"))?;
if current_state.change_id != pre_conflict_head
&& Some(current_state.change_id) != before.pending_manual_resolution
{
let current_tree = repo
.store()
.get_tree(¤t_state.tree)?
.ok_or_else(|| anyhow!("Current state tree not found"))?;
let worktree_status = repo.compare_worktree_cached(¤t_tree)?;
let worktree_is_clean = worktree_status.modified.is_empty()
&& worktree_status.added.is_empty()
&& worktree_status.deleted.is_empty();
if !worktree_is_clean {
return Ok(OperatorContinueStatus::Blocked);
}
}
}
let before_index = before.current_index;
let before_pending_manual_resolution = before.pending_manual_resolution;
cmd_rebase_silent(repo, None, false, true)?;
if !rebase_state_path.exists() {
return Ok(OperatorContinueStatus::Completed);
}
let after = load_rebase_state(&rebase_state_path)?;
if after.pending_manual_resolution.is_some()
&& after.current_index == before_index
&& after.pending_manual_resolution == before_pending_manual_resolution
{
return Ok(OperatorContinueStatus::Blocked);
}
Ok(OperatorContinueStatus::Continued)
}
pub(crate) fn has_persisted_rebase_state(repo: &Repository) -> bool {
repo.heddle_dir().join(REBASE_STATE_FILE).exists()
}
fn run_rebase(
repo: &Repository,
thread: Option<&str>,
abort: bool,
cont: bool,
discard_local_changes: bool,
cli: Option<&Cli>,
) -> Result<()> {
let rebase_state_path = repo.heddle_dir().join(REBASE_STATE_FILE);
if abort {
return handle_abort(repo, &rebase_state_path, cli);
}
if cont {
return handle_continue(repo, &rebase_state_path, cli);
}
let target_thread = thread.ok_or_else(rebase_target_required_advice)?;
let current_change = ensure_current_state(
repo,
&UserConfig::load_default().unwrap_or_default(),
Some(format!(
"Bootstrap git-overlay before rebasing onto {}",
target_thread
)),
)?;
let current_state = repo
.store()
.get_state(¤t_change)?
.ok_or_else(|| anyhow!("Current state not found"))?;
let target_change_id = repo
.refs()
.get_thread(&ThreadName::new(target_thread))?
.ok_or_else(|| rebase_target_not_found_advice(target_thread))?;
if current_state.change_id == target_change_id {
emit_up_to_date_if_trusted(repo, cli)?;
return Ok(());
}
let is_ancestor = is_ancestor_of(repo, ¤t_state.change_id, &target_change_id)?;
if is_ancestor {
let advance = ff_advance_deferred(
repo,
target_thread,
&target_change_id,
discard_local_changes,
)?;
flush_rebase_batch(repo, &[advance], &mint_rebase_transaction_id())?;
if !emit_rebase_progress(
repo,
cli,
json!({
"status": "fast_forwarded",
"to": target_change_id.to_string(),
}),
)? && cli.is_some()
{
match repo.head_ref()? {
Head::Attached { thread } => {
println!("Fast-forwarded {} to {}", thread, target_change_id.short())
}
Head::Detached { .. } => {
println!("Fast-forwarded to {}", target_change_id.short())
}
}
}
return Ok(());
}
let commits_to_replay =
collect_commits_to_rebase(repo, ¤t_state.change_id, &target_change_id)?;
if commits_to_replay.is_empty() {
record_ff_advance(repo, target_thread, &target_change_id)?;
emit_up_to_date_if_trusted(repo, cli)?;
return Ok(());
}
let rebase_state = RebaseState {
onto: target_change_id,
commits_to_replay: commits_to_replay.clone(),
current_index: 0,
original_head: current_state.change_id,
pending_manual_resolution: None,
pre_conflict_head: None,
pending_advances: Vec::new(),
transaction_id: mint_rebase_transaction_id(),
};
save_rebase_state(&rebase_state_path, &rebase_state)?;
if !emit_rebase_progress(
repo,
cli,
json!({
"status": "started",
"commits": commits_to_replay.len(),
}),
)? && cli.is_some()
{
println!(
"Rebasing {} commits onto {}",
commits_to_replay.len(),
target_change_id.short()
);
}
if let Some(cli) = cli {
replay_commits(repo, &rebase_state_path, cli, discard_local_changes)
} else {
replay_commits_silent(repo, &rebase_state_path)
}
}
fn emit_up_to_date_if_trusted(repo: &Repository, cli: Option<&Cli>) -> Result<()> {
let Some(cli) = cli else {
return Ok(());
};
let trust = build_repository_verification_state(repo);
if trust.verified {
if !emit_rebase_progress(
repo,
Some(cli),
json!({
"status": "up_to_date",
}),
)? {
println!("Already up to date");
}
return Ok(());
}
emit_up_to_date_blocked_by_trust(repo, cli, trust)
}
fn emit_up_to_date_blocked_by_trust(
repo: &Repository,
cli: &Cli,
trust: RepositoryVerificationState,
) -> Result<()> {
let recommended_action = repository_verification_primary_command(&trust);
let summary = trust.summary;
let recovery_commands = trust.recovery_commands;
if !emit_rebase_progress(
repo,
Some(cli),
json!({
"status": "blocked",
"reason": "repository_verification",
"summary": summary,
"recommended_action": recommended_action.clone(),
"recommended_action_template": action_template(&recommended_action),
"recovery_commands": recovery_commands,
}),
)? {
println!(
"Rebase is up to date, but repository verification is blocked: {}",
summary
);
print_next_step(&recommended_action);
}
Ok(())
}
fn no_rebase_in_progress_advice(action: &'static str) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"no_rebase_in_progress",
"No rebase in progress",
"Inspect the current operation state with `heddle status`.",
"the repository has no persisted Heddle rebase state",
format!("{action} would need to move worktree and thread state for an active rebase"),
"repository state was left unchanged",
"heddle status",
vec!["heddle status".to_string()],
)
}
fn rebase_target_required_advice() -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"rebase_target_required",
"Refusing to rebase: target thread required",
"Inspect available threads with `heddle thread list`, then run `heddle rebase <thread>`.",
"rebase was requested without a target thread",
"rebase would need to move the current thread and worktree onto a specific target",
"repository state was left unchanged",
"heddle thread list",
vec![
"heddle thread list".to_string(),
"heddle rebase <thread>".to_string(),
],
)
}
fn rebase_target_not_found_advice(target_thread: &str) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"rebase_target_not_found",
format!("Refusing to rebase: thread '{target_thread}' not found"),
"Inspect available threads with `heddle thread list`, then rerun rebase with an existing thread.",
format!("no Heddle thread named '{target_thread}' was found"),
"rebase would need to move the current thread and worktree onto that target thread",
"repository state was left unchanged",
"heddle thread list",
vec!["heddle thread list".to_string()],
)
}
fn handle_abort(
repo: &Repository,
rebase_state_path: &std::path::Path,
cli: Option<&Cli>,
) -> Result<()> {
if !rebase_state_path.exists() {
return Err(anyhow!(no_rebase_in_progress_advice("abort rebase")));
}
let state = load_rebase_state_for_abort(rebase_state_path)?;
repo.goto_without_record_discard_local(&state.original_head)?;
fs::remove_file(rebase_state_path)?;
if !emit_rebase_progress(
repo,
cli,
json!({
"status": "aborted",
}),
)? && cli.is_some()
{
println!("Rebase aborted");
}
Ok(())
}
fn handle_continue(
repo: &Repository,
rebase_state_path: &std::path::Path,
cli: Option<&Cli>,
) -> Result<()> {
if !rebase_state_path.exists() {
return Err(anyhow!(no_rebase_in_progress_advice("continue rebase")));
}
if let Some(cli) = cli {
replay_commits(repo, rebase_state_path, cli, false)
} else {
replay_commits_silent(repo, rebase_state_path)
}
}