StaleDrift.java

package network.ike.plugin.ws;

import org.apache.maven.api.plugin.MojoException;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;

/**
 * Detects <em>time-reversed synced drift</em> in a repository's pending
 * changes (IKE-Network/ike-issues#1082).
 *
 * <p>In a Syncthing-synchronized working set, ref alignment moves a
 * repository's refs without touching the synced tree — by design, the
 * tree is the medium of exchange. When the tree lags the refs, the
 * resulting status delta is time-reversed: <em>old</em> content presents
 * as pending changes. Such a delta reads exactly like WIP to every
 * commit surface, and committing it re-commits history backwards — the
 * 2026-08-31 incident deleted a teammate's day-old work from origin.
 *
 * <p>The discriminator is mechanical: a pending change whose content
 * byte-matches an older committed state of the same path carries no
 * authored content. Per change kind:
 *
 * <ul>
 *   <li><b>Modified</b> — the pending blob equals the path's blob at
 *       some earlier commit that touched the path (a revert to old
 *       content);</li>
 *   <li><b>Deleted</b> — the path's most recent add is itself a recent
 *       commit (the deletion undoes a recent add);</li>
 *   <li><b>Added</b> — the pending blob equals a historical blob of the
 *       same, currently absent, path (an old file re-appearing).</li>
 * </ul>
 *
 * <p>A delta whose <em>every</em> path is stale-shaped is drift, not
 * WIP: {@code ws:commit-publish} refuses it (escapable with
 * {@code -Dallow-stale-drift=true} for a deliberate hand-authored
 * revert). A mixed delta is warned about per path and committed —
 * deliberate reverts alongside novel work are legitimate.
 *
 * <p>All probes are failure-tolerant: a path whose history cannot be
 * read classifies as novel, and callers treat an analysis failure as
 * "no finding" — the guard fails open, like the lease fence, rather
 * than wedging commits.
 */
final class StaleDrift {

    /**
     * How many recent commits of the repository bound the "recent add"
     * test for deletions. Calibrated against the motivating incident,
     * where the drift spanned 16 and 28 commits in the two affected
     * repositories; 200 leaves generous headroom without scanning whole
     * histories.
     */
    static final int REPO_WINDOW = 200;

    /**
     * How many path-touching commits are probed when matching a
     * modified or added path's pending blob against history. A path's
     * blob only changes at commits that touch it, so this bounds the
     * per-path probe count, not a time window; the incident file had 15
     * touching commits in its whole history.
     */
    static final int PATH_HISTORY_LIMIT = 32;

    private StaleDrift() {
    }

    /** How a pending change alters its path. */
    enum ChangeKind {
        /** Content change to a tracked path. */
        MODIFIED,
        /** Path absent at HEAD, present in the pending change. */
        ADDED,
        /** Path present at HEAD, removed by the pending change. */
        DELETED
    }

    /**
     * One pending change, normalized from either the index or the
     * working tree.
     *
     * @param kind how the change alters the path
     * @param path the repo-relative path
     * @param blob the pending content's blob SHA, or {@code null} for
     *             {@link ChangeKind#DELETED}
     */
    record Change(ChangeKind kind, String path, String blob) {}

    /**
     * A stale-shaped change and the historical state it matches.
     *
     * @param change         the pending change
     * @param matchedCommit  the commit whose state the change restores
     *                       (for deletions: the recent add the deletion
     *                       undoes)
     * @param matchedSubject that commit's subject line, best-effort
     *                       ({@code ""} when unreadable)
     */
    record Finding(Change change, String matchedCommit, String matchedSubject) {}

    /**
     * The classification of a repository's pending changes.
     *
     * @param stale changes that byte-match an older committed state
     * @param novel changes carrying content history has never held
     */
    record Analysis(List<Finding> stale, List<Change> novel) {

        /** An analysis with no pending changes at all. */
        static final Analysis EMPTY = new Analysis(List.of(), List.of());

        /**
         * Whether every pending change is stale-shaped — the refusal
         * condition: such a delta holds no authored content.
         *
         * @return {@code true} when there is at least one stale change
         *         and no novel one
         */
        boolean whollyStale() {
            return !stale.isEmpty() && novel.isEmpty();
        }

        /**
         * Whether any pending change is stale-shaped — the warning
         * condition for mixed deltas.
         *
         * @return {@code true} when at least one change is stale
         */
        boolean hasStale() {
            return !stale.isEmpty();
        }
    }

    /**
     * Classifies a repository's <em>staged</em> changes — what
     * {@code git commit} would record right now.
     *
     * @param dir the repository root directory
     * @return the classification; {@link Analysis#EMPTY} when nothing
     *         is staged
     * @throws MojoException when the staged state itself cannot be read
     *         (per-path history probe failures classify as novel
     *         instead)
     */
    static Analysis analyzeStaged(File dir) throws MojoException {
        List<Change> changes = new ArrayList<>();
        // --no-abbrev: --raw output abbreviates blob ids by default, and
        // the classification compares them for equality with rev-parse
        // output, which is always full-length.
        String raw = capture(dir, "git", "diff", "--cached", "--raw",
                "--no-abbrev", "--no-renames")
                .orElseThrow(() -> new MojoException(
                        "git diff --cached --raw failed in " + dir));
        for (String line : raw.split("\n")) {
            if (line.isBlank()) {
                continue;
            }
            // :oldmode newmode oldsha newsha S\tpath
            int tab = line.indexOf('\t');
            if (tab < 0) {
                continue;
            }
            String path = line.substring(tab + 1);
            String[] meta = line.substring(0, tab).trim().split("\\s+");
            if (meta.length < 5) {
                continue;
            }
            String newSha = meta[3];
            char status = meta[4].charAt(0);
            switch (status) {
                case 'D' -> changes.add(
                        new Change(ChangeKind.DELETED, path, null));
                case 'A' -> changes.add(
                        new Change(ChangeKind.ADDED, path, newSha));
                // M (content) and T (typechange) both carry new content.
                default -> changes.add(
                        new Change(ChangeKind.MODIFIED, path, newSha));
            }
        }
        return analyze(dir, changes);
    }

    /**
     * Classifies a repository's <em>working-tree</em> changes — what
     * {@code ws:commit-publish}'s default sweep would stage — for the
     * read-only draft preview. Untracked files classify as added,
     * deletions on either side of the index as deleted, everything
     * else as modified; pending content is hashed from the working
     * tree.
     *
     * @param dir the repository root directory
     * @return the classification; {@link Analysis#EMPTY} when the tree
     *         is clean
     * @throws MojoException when the working-tree status itself cannot
     *         be read
     */
    static Analysis analyzeWorktree(File dir) throws MojoException {
        // Untrimmed: porcelain's two status columns are positional, and
        // the first line's leading space (e.g. " M path") is data.
        String porcelain = captureRaw(dir, "git", "status", "--porcelain")
                .orElseThrow(() -> new MojoException(
                        "git status --porcelain failed in " + dir));
        List<Change> changes = new ArrayList<>();
        for (String line : porcelain.split("\n")) {
            if (line.length() < 4) {
                continue;
            }
            char index = line.charAt(0);
            char worktree = line.charAt(1);
            String rawPath = line.substring(3);
            int arrow = rawPath.indexOf(" -> ");
            String path = arrow >= 0 ? rawPath.substring(arrow + 4) : rawPath;
            if (index == 'D' || worktree == 'D') {
                changes.add(new Change(ChangeKind.DELETED, path, null));
            } else if (index == '?' || index == 'A') {
                hashOf(dir, path).ifPresent(blob -> changes.add(
                        new Change(ChangeKind.ADDED, path, blob)));
            } else {
                hashOf(dir, path).ifPresent(blob -> changes.add(
                        new Change(ChangeKind.MODIFIED, path, blob)));
            }
        }
        return analyze(dir, changes);
    }

    /**
     * Hashes a working-tree file as git would, tolerantly: a path that
     * cannot be hashed (vanished mid-scan, permission) is simply left
     * out of the analysis.
     *
     * @param dir  the repository root directory
     * @param path the repo-relative path
     * @return the blob SHA, or empty when unhashable
     */
    private static Optional<String> hashOf(File dir, String path) {
        return capture(dir, "git", "hash-object", "--", path)
                .filter(sha -> !sha.isBlank());
    }

    /**
     * Classifies pending changes against the repository's history.
     *
     * @param dir     the repository root directory
     * @param changes the pending changes to classify
     * @return the classification; {@link Analysis#EMPTY} when
     *         {@code changes} is empty
     */
    static Analysis analyze(File dir, List<Change> changes) {
        if (changes.isEmpty()) {
            return Analysis.EMPTY;
        }
        // The recent-commit window for the deletion test. HEAD itself is
        // included deliberately: deleting a path HEAD just added undoes
        // HEAD, the most sharply time-reversed shape there is.
        Set<String> recentCommits = new HashSet<>(
                captureLines(dir, "git", "rev-list",
                        "-n", String.valueOf(REPO_WINDOW), "HEAD"));

        List<Finding> stale = new ArrayList<>();
        List<Change> novel = new ArrayList<>();
        for (Change change : changes) {
            Optional<String> matched = switch (change.kind()) {
                case MODIFIED, ADDED -> matchHistoricalBlob(dir, change);
                case DELETED -> matchRecentAdd(dir, change, recentCommits);
            };
            if (matched.isPresent()) {
                stale.add(new Finding(change, matched.get(),
                        subjectOf(dir, matched.get())));
            } else {
                novel.add(change);
            }
        }
        return new Analysis(List.copyOf(stale), List.copyOf(novel));
    }

    /**
     * Finds a historical commit at which the path held exactly the
     * pending blob. Only commits that touched the path can change its
     * blob, so the probe walks the path's own history; the commit at
     * the head of that walk is skipped for modifications (its blob is
     * the current content the pending change differs from).
     *
     * @param dir    the repository root directory
     * @param change the modified or added change
     * @return the matching commit SHA, or empty when the content is
     *         novel (or history is unreadable)
     */
    private static Optional<String> matchHistoricalBlob(File dir, Change change) {
        List<String> touching = captureLines(dir, "git", "log",
                "-n", String.valueOf(PATH_HISTORY_LIMIT),
                "--format=%H", "HEAD", "--", change.path());
        boolean skipFirst = change.kind() == ChangeKind.MODIFIED;
        for (String commit : touching) {
            if (skipFirst) {
                skipFirst = false;
                continue;
            }
            Optional<String> blobAt = capture(dir, "git", "rev-parse",
                    commit + ":" + change.path());
            if (blobAt.isPresent() && blobAt.get().equals(change.blob())) {
                return Optional.of(commit);
            }
        }
        return Optional.empty();
    }

    /**
     * Tests whether a deletion undoes a recent add: the path's most
     * recent add commit lies inside the repository's recent-commit
     * window. A deletion of a long-established path is an ordinary
     * deletion, not drift.
     *
     * @param dir           the repository root directory
     * @param change        the deleted change
     * @param recentCommits the repository's last {@link #REPO_WINDOW}
     *                      commit SHAs
     * @return the add commit the deletion undoes, or empty
     */
    private static Optional<String> matchRecentAdd(File dir, Change change,
                                                   Set<String> recentCommits) {
        List<String> added = captureLines(dir, "git", "log", "-n", "1",
                "--diff-filter=A", "--format=%H", "HEAD", "--", change.path());
        if (added.isEmpty() || !recentCommits.contains(added.get(0))) {
            return Optional.empty();
        }
        return Optional.of(added.get(0));
    }

    /**
     * Best-effort subject line of a commit, for the finding report.
     *
     * @param dir    the repository root directory
     * @param commit the commit SHA
     * @return the subject, or {@code ""} when unreadable
     */
    private static String subjectOf(File dir, String commit) {
        return capture(dir, "git", "show", "-s", "--format=%s", commit)
                .orElse("");
    }

    /**
     * Formats an analysis's stale findings as report lines, one per
     * finding, naming the historical state each change restores.
     *
     * @param analysis the analysis to format
     * @return one line per stale finding
     */
    static List<String> describeStale(Analysis analysis) {
        List<String> lines = new ArrayList<>();
        for (Finding finding : analysis.stale()) {
            String shortSha = finding.matchedCommit().length() >= 8
                    ? finding.matchedCommit().substring(0, 8)
                    : finding.matchedCommit();
            String verb = switch (finding.change().kind()) {
                case MODIFIED -> "reverts to its state at";
                case ADDED -> "re-adds its content from";
                case DELETED -> "undoes its addition in";
            };
            lines.add(finding.change().path() + " — " + verb + " "
                    + shortSha
                    + (finding.matchedSubject().isEmpty()
                            ? "" : " (" + finding.matchedSubject() + ")"));
        }
        return lines;
    }

    /**
     * Runs a command and captures trimmed stdout, empty on any failure
     * — every history probe is tolerant by construction.
     *
     * @param dir     the working directory
     * @param command the command and arguments
     * @return trimmed stdout, or empty on non-zero exit or I/O failure
     */
    private static Optional<String> capture(File dir, String... command) {
        return captureRaw(dir, command).map(String::trim);
    }

    /**
     * Runs a command and captures stdout verbatim — for output whose
     * leading whitespace is data, like {@code git status --porcelain}
     * status columns. Empty on any failure.
     *
     * @param dir     the working directory
     * @param command the command and arguments
     * @return stdout untrimmed, or empty on non-zero exit or I/O failure
     */
    private static Optional<String> captureRaw(File dir, String... command) {
        try {
            Process process = new ProcessBuilder(command)
                    .directory(dir)
                    .redirectErrorStream(false)
                    .start();
            String stdout = new String(
                    process.getInputStream().readAllBytes(),
                    StandardCharsets.UTF_8);
            // Drain stderr so the subprocess can never block on a full pipe.
            process.getErrorStream().readAllBytes();
            if (process.waitFor() != 0) {
                return Optional.empty();
            }
            return Optional.of(stdout);
        } catch (IOException e) {
            return Optional.empty();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return Optional.empty();
        }
    }

    /**
     * Runs a command and captures stdout as lines, empty list on any
     * failure.
     *
     * @param dir     the working directory
     * @param command the command and arguments
     * @return the non-blank stdout lines, or an empty list on failure
     */
    private static List<String> captureLines(File dir, String... command) {
        Optional<String> out = capture(dir, command);
        if (out.isEmpty() || out.get().isBlank()) {
            return List.of();
        }
        List<String> lines = new ArrayList<>();
        for (String line : out.get().split("\n")) {
            if (!line.isBlank()) {
                lines.add(line.trim());
            }
        }
        return lines;
    }
}