forkctl 0.0.4

Control audited StGit downstream patch stacks
use super::{App, write_atomic};
use crate::process::{capture, run};
use crate::report::{self, ExportEvidence, RebaseReport};
use crate::state::{PendingOperation, PendingState};
use anyhow::{Context, Result, ensure};
use std::path::PathBuf;

impl App {
    pub fn rebase(&mut self, target: &str) -> Result<()> {
        self.require_clean()?;
        self.require_declared_branch()?;
        if let Some(pending) = self.read_pending()? {
            ensure!(
                pending.operation == PendingOperation::Rebase,
                "a {:?} operation is already pending",
                pending.operation
            );
            ensure!(
                pending.target_label.as_deref() == Some(target),
                "pending rebase targets {}, not {target}",
                pending.target_label.as_deref().unwrap_or("unknown")
            );
            return self.finish_rebase(pending);
        }

        self.verify()?;
        self.fetch_upstream(false)?;
        let target_sha = self.resolve_target(target)?;
        let mut pending = self.create_recovery(PendingOperation::Rebase)?;
        pending.target_label = Some(target.to_string());
        pending.target_sha = Some(target_sha.clone());
        self.write_pending(&pending)?;

        if let Err(error) = run(&self.repo, "stg", ["rebase", "--merged", &target_sha]) {
            eprintln!(
                "forkctl: rebase stopped; recovery tag: {}",
                pending.backup_tag
            );
            eprintln!(
                "forkctl: resolve with stg add --update, stg refresh, and stg goto {}; then rerun forkctl rebase --onto {target}",
                self.manifest.bookkeeping_patch
            );
            return Err(error).context("StGit rebase stopped");
        }
        self.finish_rebase(pending)
    }

    fn finish_rebase(&mut self, mut pending: PendingState) -> Result<()> {
        let target_sha = pending
            .target_sha
            .as_deref()
            .context("pending rebase has no target SHA")?;
        ensure!(
            capture(&self.repo, "stg", ["series", "--unapplied", "--count"])? == "0",
            "rebase is incomplete; apply all patches before resuming"
        );
        let new_base = capture(&self.repo, "stg", ["id", "{base}"])?;
        ensure!(
            new_base == target_sha,
            "StGit base is {new_base}, expected {target_sha}"
        );
        let actual_top = capture(&self.repo, "stg", ["top"])?;
        ensure!(
            actual_top == self.manifest.bookkeeping_patch,
            "top patch is {actual_top}, expected {}",
            self.manifest.bookkeeping_patch
        );

        self.manifest.base.label = pending
            .target_label
            .clone()
            .context("pending rebase has no target label")?;
        self.manifest.base.stack.clone_from(&new_base);
        self.manifest.base.canonical = capture(
            &self.repo,
            "git",
            [
                "merge-base",
                &new_base,
                self.upstream_tracking_ref().as_str(),
            ],
        )?;
        let exports = self.write_exports()?;
        self.write_manifest()?;
        let ledger = self.write_ledger()?;
        let paths = std::iter::once(self.manifest_path.clone())
            .chain(std::iter::once(ledger))
            .chain(exports)
            .collect::<Vec<PathBuf>>();
        self.stage_and_refresh_bookkeeping(&paths)?;

        let new_tip = capture(&self.repo, "git", ["rev-parse", "HEAD"])?;
        pending.new_base = Some(new_base.clone());
        pending.new_tip = Some(new_tip.clone());
        self.write_pending(&pending)?;
        self.verify()?;

        let range = capture(
            &self.repo,
            "git",
            [
                "range-diff",
                "--no-color",
                &format!("{}..{}", pending.old_base, pending.old_tip),
                &format!("{new_base}..{new_tip}"),
            ],
        )?;
        let report_path = self.report_path(&new_tip)?;
        let report = self.render_report(&pending, &new_base, &new_tip, &range)?;
        write_atomic(&report_path, report.as_bytes())?;
        pending.report = Some(report_path.display().to_string());
        self.write_pending(&pending)?;
        println!("forkctl: rebased and verified at {new_base}");
        println!("forkctl: recovery tag: {}", pending.backup_tag);
        println!("forkctl: review report: {}", report_path.display());
        println!("forkctl: run consumer semantic checks before forkctl publish");
        Ok(())
    }

    fn report_path(&self, new_tip: &str) -> Result<PathBuf> {
        let short = new_tip.get(..12).context("new tip is not a full SHA")?;
        self.git_private_path(&format!("forkctl/rebases/{short}.md"))
    }

    fn render_report(
        &self,
        pending: &PendingState,
        new_base: &str,
        new_tip: &str,
        range: &str,
    ) -> Result<String> {
        let exports = self
            .manifest
            .exported_patches()
            .map(|patch| {
                let path = patch.export.as_ref().expect("exported patch");
                Ok(ExportEvidence {
                    path: path.clone(),
                    hash: capture(&self.repo, "git", ["hash-object", path])?,
                })
            })
            .collect::<Result<Vec<_>>>()?;
        report::render(RebaseReport {
            target: pending
                .target_label
                .clone()
                .unwrap_or_else(|| "unknown".to_string()),
            old_base: pending.old_base.clone(),
            old_tip: pending.old_tip.clone(),
            new_base: new_base.to_string(),
            new_tip: new_tip.to_string(),
            recovery_tag: pending.backup_tag.clone(),
            exports,
            range_diff: range.to_string(),
        })
    }
}