WorkspaceReleaseRollback.java

package network.ike.plugin.ws;

import network.ike.plugin.ws.vcs.VcsOperations;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

/**
 * Rolls a working set back from a failed release mission
 * (IKE-Network/ike-issues#1010) — the mechanized form of the hand
 * resets that a mid-mission failure previously demanded across every
 * repository.
 *
 * <p>A mission's local footprint is exactly its release-cadence commits
 * ({@code release: set version to …}, {@code post-release: bump to …},
 * the alignment and manifest-sync subjects) plus any tags pointing at
 * them. Rollback walks each repository's <em>unpushed</em> range from
 * {@code HEAD} downward, discarding consecutive cadence commits and
 * the local tags on them, and resets to the first non-cadence commit.
 * Pushed history is never touched: a cadence commit already on origin
 * belongs to a completed mission, and a failed mission that got as far as
 * pushing is a publication event a human must judge.
 *
 * <p>Refusals (per repository, and the publish is all-or-nothing over
 * the set): a working tree with uncommitted changes, no upstream to
 * define the unpushed range, or mission commits buried beneath later
 * non-cadence work — each names the repository and the remediation.
 */
final class WorkspaceReleaseRollback {

    /** Subjects the release mission authors; anything else is work. */
    private static final List<String> CADENCE_PREFIXES = List.of(
            "release: set version to ",
            "post-release: bump to ",
            "post-release: sync workspace.yaml",
            "workspace: align inter-subproject versions",
            "workspace: pin working-set state");

    private WorkspaceReleaseRollback() {}

    /**
     * One repository's rollback plan.
     *
     * @param name      the working-set member name (or the root's label)
     * @param dir       the repository directory
     * @param targetSha the commit to reset to; {@code null} when there
     *                  is nothing to discard
     * @param discards  {@code <sha> <subject>} lines to discard,
     *                  newest first
     * @param tags      local tags on the discarded commits
     * @param refusal   why this repository blocks the rollback, or
     *                  {@code null} when it does not
     */
    record RepoPlan(String name, File dir, String targetSha,
                    List<String> discards, List<String> tags,
                    String refusal) {

        /** @return whether this repository has anything to discard */
        boolean hasWork() {
            return targetSha != null && !discards.isEmpty();
        }
    }

    /**
     * Inspect one repository and plan its rollback without mutating
     * anything.
     *
     * @param name the member name used in reports
     * @param dir  the repository directory
     * @return the plan, carrying a refusal instead of work when the
     *         repository cannot be rolled back safely
     * @throws MojoException when git itself fails
     */
    static RepoPlan plan(String name, File dir) throws MojoException {
        if (!VcsOperations.isClean(dir)) {
            return new RepoPlan(name, dir, null, List.of(), List.of(),
                    "uncommitted changes — commit or stash by hand first");
        }
        String branch = VcsOperations.currentBranch(dir);
        String remote = VcsOperations
                .remoteSha(dir, "origin", branch).orElse(null);
        if (remote == null) {
            return new RepoPlan(name, dir, null, List.of(), List.of(),
                    "no origin/" + branch
                            + " — cannot define the unpushed range");
        }

        List<String> unpushed = logOneline(dir, remote + "..HEAD");
        List<String> discards = new ArrayList<>();
        int walked = 0;
        for (String line : unpushed) {
            if (!isCadence(subjectOf(line))) break;
            discards.add(line);
            walked++;
        }
        for (int i = walked; i < unpushed.size(); i++) {
            if (isCadence(subjectOf(unpushed.get(i)))) {
                return new RepoPlan(name, dir, null, List.of(), List.of(),
                        "mission commits buried beneath later work ("
                                + subjectOf(unpushed.get(walked))
                                + " sits above them) — resolve by hand");
            }
        }
        if (discards.isEmpty()) {
            return new RepoPlan(name, dir, null, List.of(), List.of(),
                    null);
        }

        String lastDiscarded = shaOf(discards.get(discards.size() - 1));
        String target = capture(dir,
                "git", "rev-parse", lastDiscarded + "^").strip();
        List<String> tags = new ArrayList<>();
        for (String line : discards) {
            for (String tag : capture(dir, "git", "tag", "--points-at",
                    shaOf(line)).split("\n")) {
                if (!tag.isBlank()) tags.add(tag.strip());
            }
        }
        return new RepoPlan(name, dir, target, List.copyOf(discards),
                List.copyOf(tags), null);
    }

    /**
     * Apply a plan: delete its local tags, then hard-reset to the
     * target. Call only on plans where {@link RepoPlan#hasWork()} is
     * true and {@link RepoPlan#refusal()} is null.
     *
     * @param plan the repository plan to apply
     * @param log  Maven logger for the reset trace
     * @throws MojoException when git fails mid-apply
     */
    static void apply(RepoPlan plan, Log log) throws MojoException {
        for (String tag : plan.tags()) {
            capture(plan.dir(), "git", "tag", "-d", tag);
            log.info("    tag deleted: " + tag);
        }
        VcsOperations.resetHard(plan.dir(), log, plan.targetSha());
    }

    private static boolean isCadence(String subject) {
        for (String prefix : CADENCE_PREFIXES) {
            if (subject.startsWith(prefix)) return true;
        }
        return false;
    }

    private static String subjectOf(String onelineEntry) {
        int space = onelineEntry.indexOf(' ');
        return space < 0 ? "" : onelineEntry.substring(space + 1);
    }

    private static String shaOf(String onelineEntry) {
        int space = onelineEntry.indexOf(' ');
        return space < 0 ? onelineEntry : onelineEntry.substring(0, space);
    }

    private static List<String> logOneline(File dir, String range)
            throws MojoException {
        String out = capture(dir, "git", "log", range,
                "--oneline", "--no-decorate");
        return out.isBlank() ? List.of() : List.of(out.split("\n"));
    }

    // Local capture: VcsOperations has no tag or rev-parse helpers, and
    // this class's needs are read-mostly one-liners.
    private static String capture(File dir, String... command)
            throws MojoException {
        try {
            Process process = new ProcessBuilder(command)
                    .directory(dir).redirectErrorStream(true).start();
            ByteArrayOutputStream buffer = new ByteArrayOutputStream();
            try (InputStream in = process.getInputStream()) {
                in.transferTo(buffer);
            }
            int exit = process.waitFor();
            String output = buffer.toString(StandardCharsets.UTF_8);
            if (exit != 0) {
                throw new MojoException(String.join(" ", command)
                        + " failed in " + dir + ": " + output.strip());
            }
            return output;
        } catch (IOException e) {
            throw new MojoException("Could not run git in " + dir
                    + ": " + e.getMessage(), e);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new MojoException("Interrupted running git in " + dir, e);
        }
    }
}