YamlDepsSync.java

package network.ike.plugin.ws;

import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.workspace.Manifest;
import network.ike.workspace.ManifestException;
import network.ike.workspace.ManifestReader;
import network.ike.workspace.Subproject;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Re-derive each subproject's {@code depends-on} edges from POM
 * contents and rewrite {@code workspace.yaml} when they have drifted.
 *
 * <p><b>Why.</b> {@code ws:add} derives {@code depends-on} once at
 * add time. POMs change every commit; without a periodic re-derive the
 * YAML graph drifts from POM reality, and {@code ws:overview},
 * {@code ws:release} topo-sort, and {@code ws:cascade} all use the
 * stale graph silently. This sync runs as part of the post-mutation
 * hook so any goal that touches the workspace also leaves the YAML
 * matching the POMs.
 *
 * <p><b>Idempotent.</b> Same POMs in → same YAML out. Re-running
 * back-to-back produces no further change.
 *
 * <p><b>Merge, not replace.</b> Derivation only knows {@code build}
 * edges, so only build edges are machine-owned: hand-declared entries
 * with any other relationship ({@code bundle}, {@code content},
 * {@code tooling}) are preserved verbatim — comments included — and
 * are never downgraded to {@code build}
 * (IKE-Network/ike-issues#963, #964). See {@link DependsOnMerge}.
 *
 * <p><b>Acyclic by construction.</b> Contracting the module graph to
 * repo granularity can manufacture a cycle no module edge actually
 * forms. Before writing, the prospective repo-level ordering graph is
 * checked; on a cycle nothing is written and the contributing
 * module-level edges are reported with file locations
 * (IKE-Network/ike-issues#962, {@link DependsOnCycleGate}).
 *
 * <p>Subprojects that aren't cloned on disk are left untouched —
 * we can't read the POM that drives the derivation.
 *
 * <p>Subprojects whose checkout <em>drifts</em> from the manifest —
 * a different branch than the entry declares, or a detached HEAD away
 * from the {@code sha:} pin — are likewise left untouched: the POM on
 * disk does not represent the manifest's declared state, and deriving
 * from it strips edges that are real on the declared branch
 * (IKE-Network/ike-issues#968). A checkout without git metadata
 * derives as before — branch verification needs a repository.
 *
 * <p>See {@code IKE-Network/ike-issues#279}.
 */
final class YamlDepsSync {

    private YamlDepsSync() {}

    /**
     * Outcome of a sync: whether {@code workspace.yaml} was rewritten,
     * and one summary line per changed subproject for the re-derivation
     * commit message (IKE-Network/ike-issues#964).
     */
    record SyncResult(boolean changed, List<String> changeLines) {

        static final SyncResult UNCHANGED =
                new SyncResult(false, List.of());
    }

    /**
     * Refresh {@code depends-on} edges for the workspace at
     * {@code workspaceRoot}.
     *
     * @param workspaceRoot the workspace root directory
     * @param log           plugin log for the per-subproject summary
     * @return the sync outcome; unchanged when the manifest was already
     *         up to date, would have become cyclic, or could not be
     *         processed
     */
    static SyncResult run(File workspaceRoot, Log log) {
        Path manifestPath = workspaceRoot.toPath().resolve("workspace.yaml");
        if (!Files.isRegularFile(manifestPath)) {
            log.debug("yaml-deps-sync: no workspace.yaml — skipping");
            return SyncResult.UNCHANGED;
        }

        try {
            Manifest manifest = ManifestReader.read(manifestPath);
            String yaml = Files.readString(manifestPath, StandardCharsets.UTF_8);

            // ── Phase 1: derive and dry-run the merges; no writes ──
            Map<String, List<WsAddMojo.DerivedDep>> derivedByName =
                    new LinkedHashMap<>();
            Map<String, Map<String, List<WsAddMojo.PomRef>>> edgeSources =
                    new LinkedHashMap<>();
            Map<String, Set<String>> prospective = new LinkedHashMap<>();
            Set<String> known = manifest.subprojects().keySet();
            List<String> driftSkipped = new ArrayList<>();

            for (Map.Entry<String, Subproject> entry
                    : manifest.subprojects().entrySet()) {
                String name = entry.getKey();
                Subproject sub = entry.getValue();
                prospective.put(name,
                        DependsOnCycleGate.orderingTargets(sub, known));

                Path subDir = workspaceRoot.toPath().resolve(name);
                if (!Files.exists(subDir.resolve("pom.xml"))) {
                    // Not cloned — leave existing depends-on alone
                    continue;
                }
                if (Files.exists(subDir.resolve(".git"))) {
                    String drift = driftReason(subDir,
                            declaredBranch(sub, manifest), sub.sha());
                    if (drift != null) {
                        // Drifted checkout — its POM does not represent
                        // the manifest's declared state (#968).
                        driftSkipped.add(name + " — " + drift);
                        continue;
                    }
                }

                WsAddMojo.Derivation derivation =
                        WsAddMojo.deriveDependenciesDetailed(
                                workspaceRoot.toPath(), manifestPath,
                                subDir, name);

                DependsOnMerge.Result merged = DependsOnMerge.merge(
                        yaml, name, derivation.deps());
                if (!merged.changed(yaml)) continue;

                derivedByName.put(name, derivation.deps());
                edgeSources.put(name, derivation.producerSources());
                prospective.put(name,
                        orderingTargets(merged.finalRelationships(), known));
            }

            if (!driftSkipped.isEmpty()) {
                log.warn("  yaml-deps-sync: left " + driftSkipped.size()
                        + " drifted subproject(s) alone — the checkout "
                        + "disagrees with the manifest, so its POM does "
                        + "not represent the declared state; recorded "
                        + "depends-on kept (IKE-Network/ike-issues#968):");
                for (String skipped : driftSkipped) {
                    log.warn("    " + skipped);
                }
                log.warn("    Align checkouts to the manifest: "
                        + "mvn ws:reconcile-branches-publish -Dfrom=manifest");
                log.warn("    Or accept checkouts as truth:    "
                        + "mvn ws:reconcile-branches-publish");
            }

            if (derivedByName.isEmpty()) {
                log.debug("yaml-deps-sync: workspace.yaml is up to date");
                return SyncResult.UNCHANGED;
            }

            // ── Phase 2: acyclicity gate (#962) ────────────────────
            List<String> cycle = DependsOnCycleGate.findCycle(prospective);
            if (!cycle.isEmpty()) {
                log.error(DependsOnCycleGate.diagnostic(
                        workspaceRoot.toPath(), cycle, edgeSources));
                return SyncResult.UNCHANGED;
            }

            // ── Phase 3: apply the merges and write once ───────────
            String updated = yaml;
            int totalAdded = 0;
            int totalRemoved = 0;
            List<String> changeLines = new ArrayList<>();
            for (Map.Entry<String, List<WsAddMojo.DerivedDep>> entry
                    : derivedByName.entrySet()) {
                String name = entry.getKey();
                DependsOnMerge.Result merged = DependsOnMerge.merge(
                        updated, name, entry.getValue());
                updated = merged.yaml();

                List<String> added = new ArrayList<>(merged.addedBuild());
                added.addAll(merged.addedBundle());
                // A build edge whose derivation moved to bundle is a
                // supersede (#965), not a discard — report it apart from
                // true removals so the #964 hand-edit warning stays honest.
                List<String> superseded =
                        new ArrayList<>(merged.removedBuild());
                superseded.retainAll(merged.addedBundle());
                List<String> removed =
                        new ArrayList<>(merged.removedBuild());
                removed.removeAll(superseded);
                totalAdded += added.size();
                totalRemoved += removed.size();

                if (added.isEmpty() && removed.isEmpty()) {
                    // Text-only change: managed-marker annotation.
                    log.info("  workspace.yaml: " + name
                            + " depends-on annotated as managed");
                    changeLines.add(name + ": annotate managed block");
                    continue;
                }
                String summary = name + " depends-on (+" + added.size()
                        + ", -" + removed.size() + ")"
                        + (added.isEmpty() ? "" : " added " + added)
                        + (removed.isEmpty() ? "" : " removed " + removed)
                        + (superseded.isEmpty()
                                ? "" : " build→bundle " + superseded);
                changeLines.add(summary);
                if (removed.isEmpty()) {
                    log.info("  workspace.yaml: " + summary);
                } else {
                    // Removals may be discarding a hand edit — say so
                    // where the user can see it (#964).
                    log.warn("  workspace.yaml: " + summary
                            + " — build edges are re-derived from POMs; "
                            + "a deliberately absent edge needs a non-build "
                            + "relationship (e.g. bundle) to survive");
                }
            }

            if (!updated.equals(yaml)) {
                Files.writeString(manifestPath, updated, StandardCharsets.UTF_8);
                log.info("  yaml-deps-sync: " + totalAdded + " edge(s) added, "
                        + totalRemoved + " edge(s) removed");
                return new SyncResult(true, List.copyOf(changeLines));
            }
            log.debug("yaml-deps-sync: workspace.yaml is up to date");
            return SyncResult.UNCHANGED;
        } catch (IOException | ManifestException e) {
            log.warn("yaml-deps-sync: cannot update workspace.yaml — "
                    + e.getMessage());
            return SyncResult.UNCHANGED;
        }
    }

    /**
     * The branch the manifest declares for a subproject: its own
     * {@code branch:} field, falling back to {@code defaults.branch}.
     *
     * @param sub      the subproject entry
     * @param manifest the parsed manifest
     * @return the declared branch, or null when neither is set
     */
    private static String declaredBranch(Subproject sub, Manifest manifest) {
        if (sub.branch() != null && !sub.branch().isBlank()) {
            return sub.branch();
        }
        return manifest.defaults() == null
                ? null : manifest.defaults().branch();
    }

    /**
     * Why a cloned subproject's checkout cannot back a re-derivation,
     * or null when it can (IKE-Network/ike-issues#968).
     *
     * <p>A checkout on a different branch than the manifest declares —
     * or detached anywhere but the manifest's {@code sha:} pin — is the
     * same epistemic situation as an absent clone: the POM on disk does
     * not represent the declared state. Unreadable git state counts as
     * drift for the same reason.
     *
     * @param subDir         the subproject checkout directory
     * @param declaredBranch the manifest branch (may be null)
     * @param pinnedSha      the manifest {@code sha:} pin (may be null)
     * @return a human-readable drift description, or null when coherent
     */
    private static String driftReason(Path subDir, String declaredBranch,
                                      String pinnedSha) {
        try {
            String checkout = VcsOperations.currentBranch(subDir.toFile());
            if (checkout == null || checkout.isBlank()) {
                String head = VcsOperations.headSha(subDir.toFile());
                if (pinnedSha != null && pinnedSha.startsWith(head)) {
                    // Parked exactly on the manifest pin — coherent.
                    return null;
                }
                return "detached at " + head
                        + (declaredBranch == null ? ""
                                : ", manifest declares " + declaredBranch);
            }
            if (declaredBranch != null && !declaredBranch.isBlank()
                    && !declaredBranch.equals(checkout)) {
                return "checkout on " + checkout
                        + ", manifest declares " + declaredBranch;
            }
            return null;
        } catch (MojoException e) {
            return "git state unreadable (" + e.getMessage() + ")";
        }
    }

    /**
     * Ordering-edge targets of a merged entry set: every final entry
     * except {@code relationship: bundle}, restricted to known
     * subprojects — the same rule as
     * {@link DependsOnCycleGate#orderingTargets}.
     */
    private static Set<String> orderingTargets(
            Map<String, String> finalRelationships, Set<String> known) {
        Set<String> targets = new LinkedHashSet<>();
        for (Map.Entry<String, String> e : finalRelationships.entrySet()) {
            if ("bundle".equalsIgnoreCase(e.getValue())) continue;
            if (known.contains(e.getKey())) targets.add(e.getKey());
        }
        return targets;
    }
}