forkctl 0.0.8

Control audited StGit downstream patch stacks
use super::App;
use crate::error::DomainError;
use crate::process::{capture, run};
use crate::protocol::{
    CommandResult, ExecutionMode, MutationPlan, OperationAbortResult, OperationContinueResult,
    OperationStatusResult,
};
use crate::state::{OperationIntent, OperationKind};
use anyhow::{Context, Result};

impl App {
    pub fn operation_status(&self) -> Result<OperationStatusResult> {
        Ok(OperationStatusResult {
            operation: self.read_operation()?,
        })
    }

    pub fn operation_continue(&mut self, mode: ExecutionMode) -> Result<CommandResult> {
        let operation = self
            .read_operation()?
            .context("no forkctl operation is in progress")?;
        self.load_operation_manifest()?;
        if mode == ExecutionMode::Plan {
            return Ok(CommandResult::Plan(MutationPlan {
                command: "operation.continue".into(),
                reads: vec![self.operation_path()?.display().to_string()],
                writes: vec![format!(
                    "continue {:?} from {}",
                    operation.kind, operation.phase
                )],
                hooks: Vec::new(),
                ref_updates: Vec::new(),
                paths: Vec::new(),
                requires_confirmation: false,
            }));
        }
        let result = match operation.kind {
            OperationKind::Rebase => Some(Box::new(CommandResult::Rebase(Box::new(
                self.continue_rebase(operation)?,
            )))),
            OperationKind::PatchEdit => {
                let Some(OperationIntent::PatchEdit { patch }) = operation.intent.clone() else {
                    return Err(DomainError::operation_conflict(
                        "patch edit operation has no typed intent",
                        Some(&operation),
                    )
                    .into());
                };
                Some(Box::new(self.continue_patch_edit(&operation, patch)?))
            }
            OperationKind::PatchRefresh => {
                let Some(OperationIntent::PatchRefresh {
                    patch,
                    capture,
                    captured_paths,
                }) = operation.intent.clone()
                else {
                    return Err(DomainError::operation_conflict(
                        "patch refresh operation has no typed intent",
                        Some(&operation),
                    )
                    .into());
                };
                Some(Box::new(self.continue_patch_refresh(
                    &operation,
                    patch,
                    capture,
                    captured_paths,
                )?))
            }
        };
        Ok(CommandResult::OperationContinue(Box::new(
            OperationContinueResult {
                operation: self.read_operation()?,
                result,
            },
        )))
    }

    pub fn operation_abort(
        &mut self,
        confirmed: bool,
        mode: ExecutionMode,
    ) -> Result<CommandResult> {
        let operation = self
            .read_operation()?
            .context("no forkctl operation is in progress")?;
        let plan = MutationPlan {
            command: "operation.abort".into(),
            reads: vec![self.operation_path()?.display().to_string()],
            writes: vec![
                format!("restore HEAD to {}", operation.old_tip),
                "restore StGit operation state".into(),
                "restore tracked manifest from recovered stack".into(),
            ],
            hooks: Vec::new(),
            ref_updates: vec![format!("HEAD -> {}", operation.old_tip)],
            paths: self.dirty_paths()?,
            requires_confirmation: true,
        };
        if mode == ExecutionMode::Plan {
            return Ok(CommandResult::Plan(plan));
        }
        if !confirmed {
            return Err(DomainError::invalid_request("operation abort requires --yes").into());
        }
        self.restore_operation_stack(&operation)?;
        self.verify_restored_operation_stack(&operation)?;
        self.manifest = None;
        let rediscovered = App::discover(
            self.manifest_path
                .strip_prefix(&self.repo)
                .unwrap_or(&self.manifest_path),
        )?;
        self.manifest = rediscovered.manifest;
        let check = self.check_restored_repository(matches!(
            operation.kind,
            OperationKind::PatchRefresh | OperationKind::PatchEdit
        ))?;
        self.complete_local_operation(&operation)?;
        Ok(CommandResult::OperationAbort(OperationAbortResult {
            operation_id: operation.id,
            restored_tip: operation.old_tip,
            check,
        }))
    }

    fn restore_operation_stack(&self, operation: &crate::state::OperationState) -> Result<()> {
        if !capture(
            &self.repo,
            "git",
            ["diff", "--name-only", "--diff-filter=U"],
        )?
        .is_empty()
        {
            run(&self.repo, "stg", ["undo", "--hard"])?;
        }
        run(&self.repo, "stg", ["delete", "--all", "--conflicts=allow"])?;
        run(&self.repo, "git", ["reset", "--hard", &operation.old_tip])?;
        run(
            &self.repo,
            "stg",
            ["uncommit", "--to", &operation.old_base, "--exclusive"],
        )
    }

    fn verify_restored_operation_stack(
        &self,
        operation: &crate::state::OperationState,
    ) -> Result<()> {
        let actual_tip = capture(&self.repo, "git", ["rev-parse", "HEAD"])?;
        if actual_tip != operation.old_tip {
            return Err(DomainError::operation_conflict(
                format!(
                    "abort restored HEAD to {actual_tip}, expected {}",
                    operation.old_tip
                ),
                Some(operation),
            )
            .into());
        }
        let actual_base = capture(&self.repo, "stg", ["id", "{base}"])?;
        if actual_base != operation.old_base {
            return Err(DomainError::operation_conflict(
                format!(
                    "abort restored StGit base to {actual_base}, expected {}",
                    operation.old_base
                ),
                Some(operation),
            )
            .into());
        }
        let expected = operation
            .old_patches
            .iter()
            .map(|patch| patch.name.clone())
            .collect::<Vec<_>>();
        let actual = self.stg_series()?;
        if actual != expected {
            return Err(DomainError::operation_conflict(
                format!(
                    "abort restored patch order {}, expected {}",
                    actual.join(", "),
                    expected.join(", ")
                ),
                Some(operation),
            )
            .into());
        }
        for evidence in &operation.old_patches {
            let actual = self.patch_commit(&evidence.name)?;
            if actual != evidence.commit {
                return Err(DomainError::operation_conflict(
                    format!(
                        "abort restored patch {} to {actual}, expected {}",
                        evidence.name, evidence.commit
                    ),
                    Some(operation),
                )
                .into());
            }
        }
        Ok(())
    }
}