forkctl 0.0.5

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

impl App {
    pub fn rebase(&mut self, selector: &str) -> Result<RebaseResult> {
        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
                    .as_ref()
                    .map(|target| target.selector.as_str())
                    == Some(selector),
                "pending rebase targets {}, not {selector}",
                pending
                    .target
                    .as_ref()
                    .map_or("unknown", |target| target.selector.as_str())
            );
            return self.finish_rebase(pending);
        }

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

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

    fn finish_rebase(&mut self, mut pending: PendingState) -> Result<RebaseResult> {
        let target = pending
            .target
            .clone()
            .context("pending rebase has no target")?;
        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.commit,
            "StGit base is {new_base}, expected {}",
            target.commit
        );
        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
        );

        let dropped = self.drop_upstream_merged(&target)?;
        self.manifest.base.target = target;
        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)
            .chain(dropped.removed_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(ReportEvidence {
            path: report_path.display().to_string(),
            object_id: self.file_object_id(&report_path)?,
        });
        self.write_pending(&pending)?;
        self.verify_pending_state(&pending)?;
        let report = pending.report.as_ref().expect("report was just recorded");
        Ok(RebaseResult {
            selected_target: self.manifest.base.target.selector.clone(),
            new_base,
            new_tip,
            recovery_tag: pending.backup_tag,
            report_path: report.path.clone(),
            report_object_id: report.object_id.clone(),
            dropped_patches: dropped.names,
        })
    }

    fn drop_upstream_merged(&mut self, target: &BaseTarget) -> Result<DroppedPatches> {
        let mut dropped = Vec::new();
        for patch in &self.manifest.patches {
            if patch.name == self.manifest.bookkeeping_patch {
                continue;
            }
            let commit = self.patch_commit(&patch.name)?;
            if self.patch_paths(&commit)?.is_empty() {
                dropped.push((patch.clone(), commit));
            }
        }
        if dropped.is_empty() {
            return Ok(DroppedPatches::default());
        }

        let arguments = std::iter::once("delete".to_string())
            .chain(dropped.iter().map(|(patch, _)| patch.name.clone()))
            .collect::<Vec<_>>();
        run(&self.repo, "stg", arguments)?;
        let dropped_names = dropped
            .iter()
            .map(|(patch, _)| patch.name.as_str())
            .collect::<std::collections::HashSet<_>>();
        self.manifest
            .patches
            .retain(|patch| !dropped_names.contains(patch.name.as_str()));

        let mut removed_exports = Vec::new();
        let mut names = Vec::new();
        for (patch, commit) in dropped {
            if let Some(export) = &patch.export {
                let path = self.repo.join(export);
                match fs::remove_file(&path) {
                    Ok(()) => removed_exports.push(path),
                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
                    Err(error) => return Err(error).with_context(|| format!("remove {export}")),
                }
            }
            names.push(patch.name.clone());
            self.manifest.history.push(PatchEvent {
                kind: PatchEventKind::UpstreamMerged,
                patch,
                commit,
                target: target.clone(),
            });
        }
        Ok(DroppedPatches {
            names,
            removed_exports,
        })
    }

    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
                .as_ref()
                .map_or_else(|| "unknown".to_string(), |target| target.selector.clone()),
            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(),
        })
    }
}

#[derive(Default)]
struct DroppedPatches {
    names: Vec<String>,
    removed_exports: Vec<PathBuf>,
}