SubprojectInitializer.java

package network.ike.plugin.ws.bootstrap;

import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.Ansi;
import network.ike.plugin.ws.MavenWrapper;
import network.ike.plugin.ws.PostMutationSync;
import network.ike.plugin.ws.WsGoal;
import network.ike.plugin.ws.WorkspaceReport;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.workspace.Defaults;
import network.ike.workspace.Subproject;
import network.ike.workspace.WorkspaceGraph;
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.List;

/**
 * Walks the subprojects declared in {@code workspace.yaml} and ensures
 * each one is cloned and initialized with the workspace-standard
 * configuration: post-checkout git hook, Maven wrapper at the declared
 * version, {@code .mvn/jvm.config}, and the per-subproject
 * {@code CLAUDE.md} / {@code CLAUDE-<name>.md} pair.
 *
 * <p>Subsumes the per-subproject half of the retired
 * {@code InitWorkspaceMojo} (folded into {@code ws:scaffold-init} per
 * IKE-Network/ike-issues#393). Three initialization modes per
 * subproject:
 *
 * <ol>
 *   <li><b>Already cloned</b> — directory has {@code .git/}; fetch +
 *       rebase if clean, otherwise skip with a warning.</li>
 *   <li><b>Syncthing working tree</b> — directory exists but no
 *       {@code .git/}. Initializes git in-place: {@code git init},
 *       adds the remote, fetches, and resets to match the remote
 *       branch. This preserves file content synced from another
 *       machine.</li>
 *   <li><b>Fresh clone</b> — no directory; runs {@code git clone}.</li>
 * </ol>
 *
 * <p>Subprojects are initialized in topological (dependency) order.
 *
 * @see WorkspaceBootstrap for the "no workspace.yaml yet" half of
 *      {@code ws:scaffold-init}
 */
public final class SubprojectInitializer {

    private final WorkspaceGraph graph;
    private final File root;
    private final String workspaceName;
    private final Log log;

    /**
     * Bind an initializer to an already-loaded workspace.
     *
     * @param graph         the loaded workspace graph
     * @param root          the workspace root directory
     * @param workspaceName workspace artifactId, used in the report header
     * @param log           the mojo logger
     */
    public SubprojectInitializer(WorkspaceGraph graph, File root,
                                  String workspaceName, Log log) {
        this.graph = graph;
        this.root = root;
        this.workspaceName = workspaceName;
        this.log = log;
    }

    /** Outcome counters for one initialization pass. */
    public record Result(int cloned, int syncthing, int updated,
                          int skipped, int wrappers, int alreadyClean,
                          List<String[]> rows) {
        /** True when every declared subproject was already a healthy clone. */
        public boolean nothingChanged() {
            return cloned == 0 && syncthing == 0 && updated == 0 && wrappers == 0;
        }
    }

    /**
     * Run the full per-subproject initialization pass and emit the
     * workspace-level docs ({@code GOALS.md}, {@code WS-REFERENCE.md},
     * {@code CLAUDE.md}, {@code CLAUDE-&lt;name&gt;.md}).
     *
     * @return the per-subproject outcome counters
     * @throws MojoException if any clone/fetch step fails
     */
    public Result run() throws MojoException {
        Defaults defaults = graph.manifest().defaults();
        List<String> sorted = graph.topologicalSort();

        log.info("");
        log.info(workspaceName + " — Scaffold-Init");
        log.info("══════════════════════════════════════════════════════════════");
        log.info("  Target: all (" + sorted.size() + " components)");
        log.info("  Root:   " + root.getAbsolutePath());
        if (defaults.mavenVersion() != null) {
            log.info("  Maven:  " + defaults.mavenVersion() + " (default)");
        }
        log.info("");

        int cloned = 0;
        int syncthing = 0;
        int updated = 0;
        int skipped = 0;
        int wrappers = 0;
        int alreadyClean = 0;
        List<String[]> rows = new ArrayList<>();

        for (String name : sorted) {
            Subproject subproject = graph.manifest().subprojects().get(name);
            File dir = new File(root, name);
            File gitDir = new File(dir, ".git");

            if (gitDir.exists()) {
                if (VcsOperations.isClean(dir)) {
                    try {
                        String branch = VcsOperations.currentBranch(dir);
                        ReleaseSupport.exec(dir, log,
                                "git", "fetch", "origin", "--quiet");
                        ReleaseSupport.exec(dir, log,
                                "git", "rebase", "origin/" + branch, "--quiet");
                        log.info(Ansi.green("  ✓ ") + name
                                + " — updated (" + branch + ")");
                        updated++;
                        rows.add(new String[]{name, "updated",
                                subproject.repo() != null ? subproject.repo() : "—", "✓"});
                    } catch (MojoException e) {
                        log.warn(Ansi.yellow("  ⚠ ") + name
                                + " — fetch/rebase failed: " + e.getMessage());
                        rows.add(new String[]{name, "update-failed",
                                subproject.repo() != null ? subproject.repo() : "—",
                                e.getMessage()});
                        skipped++;
                    }
                } else {
                    String files = VcsOperations.unstagedFiles(dir);
                    log.warn(Ansi.yellow("  ⚠ ") + name
                            + " — skipped update (uncommitted changes: "
                            + files + ")");
                    skipped++;
                    rows.add(new String[]{name, "skipped",
                            subproject.repo() != null ? subproject.repo() : "—",
                            "uncommitted changes"});
                }
                if (ensureMavenWrapper(dir, subproject, defaults)) {
                    wrappers++;
                }
                ensureJvmConfig(dir);
                ensureClaudeNotes(dir.toPath(), name);
                writeSubprojectClaudeMd(dir.toPath(), subproject);
                checkoutSha(dir, subproject);
                alreadyClean++;
                continue;
            }

            String repo = subproject.repo();
            String branch = subproject.branch();

            if (repo == null || repo.isEmpty()) {
                log.warn(Ansi.yellow("  ⚠ ") + name + " — no repo URL, skipping");
                rows.add(new String[]{name, "skipped", "—", "no repo URL"});
                continue;
            }

            if (dir.exists()) {
                // Syncthing working tree — init git in-place
                log.info(Ansi.cyan("  ↻ ") + name
                        + " — initializing git in existing directory (Syncthing)");
                initSyncthingRepo(dir, repo, branch);
                installHooks(dir);
                if (ensureMavenWrapper(dir, subproject, defaults)) {
                    wrappers++;
                }
                ensureJvmConfig(dir);
                ensureClaudeNotes(dir.toPath(), name);
                writeSubprojectClaudeMd(dir.toPath(), subproject);
                checkoutSha(dir, subproject);
                syncthing++;
                rows.add(new String[]{name, "syncthing-init", repo, "✓"});
            } else {
                log.info(Ansi.cyan("  ↓ ") + name + " — cloning from " + repo);
                cloneRepo(root, name, repo, branch);
                File subprojectDir = new File(root, name);
                installHooks(subprojectDir);
                if (ensureMavenWrapper(subprojectDir, subproject, defaults)) {
                    wrappers++;
                }
                ensureJvmConfig(subprojectDir);
                ensureClaudeNotes(subprojectDir.toPath(), name);
                writeSubprojectClaudeMd(subprojectDir.toPath(), subproject);
                checkoutSha(subprojectDir, subproject);
                cloned++;
                rows.add(new String[]{name, "cloned", repo, "✓"});
            }
        }

        // Ensure Maven wrapper at the workspace root itself (the aggregator POM)
        if (ensureWorkspaceRootWrapper(root, defaults)) {
            wrappers++;
        }

        log.info("");
        StringBuilder summary = new StringBuilder();
        summary.append(cloned).append(" cloned");
        if (syncthing > 0) {
            summary.append(", ").append(syncthing).append(" Syncthing-initialized");
        }
        if (updated > 0) {
            summary.append(", ").append(updated).append(" updated");
        }
        if (skipped > 0) {
            summary.append(", ").append(skipped).append(" skipped");
        }
        if (wrappers > 0) {
            summary.append(", ").append(wrappers).append(" Maven wrappers installed/updated");
        }
        log.info("  Done: " + summary);
        log.info("");

        writeGoalCheatsheet(root.toPath());
        writeWorkspaceReference(root.toPath());
        ensureClaudeNotes(root.toPath(), workspaceName);
        writeWorkspaceClaudeMd(root.toPath(), graph);

        Result result = new Result(cloned, syncthing, updated, skipped,
                wrappers, alreadyClean, rows);
        WorkspaceReport.write(root.toPath(), WsGoal.SCAFFOLD_INIT.qualified(),
                buildInitMarkdownReport(result), log);

        PostMutationSync.refresh(root, log);
        return result;
    }

    // ── Git operations ──────────────────────────────────────────

    private void initSyncthingRepo(File dir, String repo, String branch)
            throws MojoException {
        ReleaseSupport.exec(dir, log, "git", "init");
        ReleaseSupport.exec(dir, log, "git", "remote", "add", "origin", repo);
        ReleaseSupport.exec(dir, log, "git", "fetch", "origin", branch);
        // Mixed reset: updates HEAD and index to match remote, keeps working tree
        ReleaseSupport.exec(dir, log,
                "git", "reset", "origin/" + branch);
    }

    private void cloneRepo(File root, String name, String repo, String branch)
            throws MojoException {
        ReleaseSupport.exec(root, log,
                "git", "clone", "-b", branch, repo, name);
    }

    private void checkoutSha(File dir, Subproject subproject) {
        if (subproject.sha() == null || subproject.sha().isBlank()) {
            return;
        }
        try {
            String currentSha = ReleaseSupport.execCapture(dir,
                    "git", "rev-parse", "HEAD");
            if (currentSha.startsWith(subproject.sha())
                    || subproject.sha().startsWith(currentSha)) {
                return; // already at the right commit
            }
            log.info("    Checking out SHA: " + subproject.sha().substring(0, 8));
            ReleaseSupport.exec(dir, log,
                    "git", "checkout", subproject.sha());
        } catch (MojoException e) {
            log.warn("    Could not checkout SHA " + subproject.sha()
                    + ": " + e.getMessage());
        }
    }

    /**
     * Install a defensive post-checkout hook in the subproject's
     * {@code .git/hooks/} directory. Skips if a hook already exists
     * (don't overwrite custom hooks).
     */
    private void installHooks(File subprojectDir) {
        File hooksDir = new File(subprojectDir, ".git/hooks");
        File postCheckout = new File(hooksDir, "post-checkout");

        if (postCheckout.exists()) {
            log.debug("  Hook already exists: " + postCheckout);
            return;
        }

        try {
            if (!hooksDir.exists()) {
                hooksDir.mkdirs();
            }
            String hookScript = "#!/bin/sh\n"
                    + "# Installed by ws:scaffold-init — warns on direct branching.\n"
                    + "# Remove this file to disable the check.\n"
                    + "mvn -q ike:check-branch 2>/dev/null\n";
            Files.writeString(postCheckout.toPath(), hookScript,
                    StandardCharsets.UTF_8);
            postCheckout.setExecutable(true);
            log.info("    Installed post-checkout hook");
        } catch (IOException e) {
            log.warn("    Could not install hook: " + e.getMessage());
        }
    }

    // ── Maven wrapper management ────────────────────────────────

    private static String resolveMavenVersion(Subproject subproject, Defaults defaults) {
        if (subproject.mavenVersion() != null) {
            return subproject.mavenVersion();
        }
        return defaults.mavenVersion();
    }

    private boolean ensureWorkspaceRootWrapper(File root, Defaults defaults) {
        String mavenVersion = defaults.mavenVersion();
        if (mavenVersion == null) {
            return false;
        }
        File pomFile = new File(root, "pom.xml");
        if (!pomFile.exists()) {
            return false;
        }
        return writeWrapperVersion(root.toPath(), mavenVersion, "workspace root");
    }

    private boolean ensureMavenWrapper(File subprojectDir, Subproject subproject,
                                        Defaults defaults) {
        String mavenVersion = resolveMavenVersion(subproject, defaults);
        if (mavenVersion == null) {
            return false;
        }
        File pomFile = new File(subprojectDir, "pom.xml");
        if (!pomFile.exists()) {
            return false;
        }
        return writeWrapperVersion(subprojectDir.toPath(), mavenVersion, null);
    }

    /**
     * Shared wrapper writer. Skips when the wrapper is already standard
     * and pinned to the requested version; otherwise rewrites the
     * properties file, creates {@code mvnw} / {@code mvnw.cmd} when
     * missing, and replaces the legacy custom wrapper in full.
     */
    private boolean writeWrapperVersion(Path dir, String mavenVersion,
                                         String rootLabel) {
        try {
            String currentVersion = MavenWrapper.readPinnedVersion(dir);
            boolean legacy = MavenWrapper.isLegacyWrapper(dir);

            if (currentVersion != null && !legacy
                    && mavenVersion.equals(currentVersion)) {
                log.debug("    " + (rootLabel != null ? rootLabel + " " : "")
                        + "Maven wrapper already at " + mavenVersion);
                return false;
            }

            String prefix = rootLabel != null ? "  ↻ " + rootLabel + " — " : "    ";
            if (legacy) {
                log.info(prefix + "replacing legacy Maven wrapper (Maven "
                        + mavenVersion + ")");
            } else if (currentVersion != null) {
                log.info(prefix + "updating Maven wrapper: " + currentVersion
                        + " → " + mavenVersion);
            } else {
                log.info((rootLabel != null ? "  + " + rootLabel + " — " : "    ")
                        + "installing Maven wrapper for Maven " + mavenVersion);
            }

            Path propsFile = dir.resolve(".mvn").resolve("wrapper")
                    .resolve("maven-wrapper.properties");
            MavenWrapper.writePropertiesFile(propsFile, mavenVersion);

            Path mvnw = dir.resolve("mvnw");
            if (legacy || !Files.exists(mvnw)) {
                MavenWrapper.writeMvnwScript(mvnw);
            }
            Path mvnwCmd = dir.resolve("mvnw.cmd");
            if (legacy || !Files.exists(mvnwCmd)) {
                MavenWrapper.writeMvnwCmdScript(mvnwCmd);
            }

            return true;
        } catch (IOException e) {
            log.warn("    Could not install Maven wrapper: " + e.getMessage());
            return false;
        }
    }

    private void ensureJvmConfig(File subprojectDir) {
        File pomFile = new File(subprojectDir, "pom.xml");
        if (!pomFile.exists()) {
            return;
        }

        try {
            Path mvnDir = subprojectDir.toPath().resolve(".mvn");
            Path jvmConfig = mvnDir.resolve("jvm.config");

            if (Files.exists(jvmConfig)) {
                return;
            }

            Files.createDirectories(mvnDir);
            String config = "-Dpolyglotimpl.AttachLibraryFailureAction=ignore\n";
            Files.writeString(jvmConfig, config, StandardCharsets.UTF_8);
            log.info("    Created .mvn/jvm.config");
        } catch (IOException e) {
            log.warn("    Could not create jvm.config: " + e.getMessage());
        }
    }

    // ── CLAUDE.md generation ────────────────────────────────────

    private void writeWorkspaceClaudeMd(Path wsRoot, WorkspaceGraph graph) {
        Path file = wsRoot.resolve("CLAUDE.md");
        try {
            Files.writeString(file, generateWorkspaceClaudeMd(workspaceName, graph),
                    StandardCharsets.UTF_8);
            log.info("  Updated CLAUDE.md");
        } catch (IOException e) {
            log.debug("Could not write workspace CLAUDE.md: " + e.getMessage());
        }
    }

    private void writeSubprojectClaudeMd(Path subprojectDir, Subproject subproject) {
        Path file = subprojectDir.resolve("CLAUDE.md");
        try {
            Files.writeString(file, generateComponentClaudeMd(subproject),
                    StandardCharsets.UTF_8);
        } catch (IOException e) {
            log.debug("Could not write CLAUDE.md for " + subproject.name()
                    + ": " + e.getMessage());
        }
    }

    private void ensureClaudeNotes(Path dir, String name) {
        Path notesFile = dir.resolve("CLAUDE-" + name + ".md");
        if (Files.exists(notesFile)) {
            return;
        }
        try {
            Path existingClaudeMd = dir.resolve("CLAUDE.md");
            if (Files.exists(existingClaudeMd)) {
                String existing = Files.readString(existingClaudeMd, StandardCharsets.UTF_8);
                String migrated = "# " + name + " — Project Notes\n\n"
                        + "<!-- Migrated from CLAUDE.md by ws:scaffold-init.\n"
                        + "     This file is for hand-authored, project-specific information.\n"
                        + "     Commit this file to git. -->\n\n"
                        + existing;
                Files.writeString(notesFile, migrated, StandardCharsets.UTF_8);
                log.info("    Migrated CLAUDE.md → CLAUDE-" + name + ".md");
            } else {
                Files.writeString(notesFile, generateClaudeNotes(name),
                        StandardCharsets.UTF_8);
                log.info("    Created CLAUDE-" + name + ".md (template)");
            }
        } catch (IOException e) {
            log.debug("Could not create CLAUDE-" + name + ".md: " + e.getMessage());
        }
    }

    /**
     * Generate {@code CLAUDE.md} for the workspace root. Static so the
     * generator can be unit-tested without instantiating the mojo.
     *
     * @param wsName the workspace name
     * @param graph  the loaded workspace graph (unused in the body but
     *               kept so future per-subproject summaries can be
     *               threaded through without an API break)
     * @return the markdown content
     */
    public static String generateWorkspaceClaudeMd(String wsName, WorkspaceGraph graph) {
        StringBuilder sb = new StringBuilder();
        sb.append("# ").append(wsName).append("\n\n");

        sb.append("""
                ## First Steps

                Run `mvn ws:scaffold-init` to clone components, then `mvn validate` to unpack
                full build standards into `.claude/standards/`.

                ## Build

                ```bash
                mvn clean verify -DskipTests -T4   # compile + javadoc
                mvn clean verify -T4                # full build with tests
                ```

                ## Key Conventions

                - Maven 4 with POM modelVersion 4.1.0
                - `<subprojects>` (not `<modules>`) for aggregation
                - All projects use `--enable-preview` (Java 25)
                - Parent: `network.ike.platform:ike-parent` (from ike-platform)

                ## Prohibited Patterns

                These are the most critical rules. Full standards are in `.claude/standards/MAVEN.md`
                after building.

                - **Never use `maven-antrun-plugin`** — use a proper Maven goal or `exec-maven-plugin`
                  with an external script
                - **Never use `build-helper-maven-plugin` for multi-execution property chaining** —
                  write a proper Maven goal in `ike-maven-plugin` instead
                - **Never embed shell commands inline in POM** — extract to a named script
                - **Never use `git add -A` or `git add .`** — stage specific files

                ## Project-Specific Notes

                """);

        sb.append("See `WS-REFERENCE.md` for complete workspace goal documentation.\n");
        sb.append("See `CLAUDE-").append(wsName)
                .append(".md` for workspace-specific information.\n");
        sb.append("See `.claude/standards/` (after `mvn validate`) for full build standards.\n");

        return sb.toString();
    }

    /**
     * Generate {@code CLAUDE.md} for a subproject directory. Static for
     * testability.
     *
     * @param subproject the subproject definition
     * @return the markdown content
     */
    public static String generateComponentClaudeMd(Subproject subproject) {
        StringBuilder sb = new StringBuilder();
        sb.append("# ").append(subproject.name()).append("\n\n");

        if (subproject.description() != null && !subproject.description().isBlank()) {
            sb.append(subproject.description().strip()).append("\n\n");
        }

        sb.append("""
                ## Build Standards

                Files in `.claude/standards/` are build artifacts unpacked from `ike-build-standards`. DO NOT edit or commit them. See the workspace root CLAUDE.md for details.

                ## Build

                ```bash
                mvn clean verify -DskipTests -T4
                ```

                ## Key Facts

                """);

        if (subproject.groupId() != null) {
            sb.append("- GroupId: `").append(subproject.groupId()).append("`\n");
        }
        if (subproject.version() != null) {
            sb.append("- Version: `").append(subproject.version()).append("`\n");
        }
        sb.append("- Uses `--enable-preview` (Java 25)\n");
        sb.append("- BOM: imports `dev.ikm.ike:ike-bom` for dependency version management\n");

        sb.append("""

                ## Prohibited Patterns

                - **Never use `maven-antrun-plugin`** — use a proper Maven goal or `exec-maven-plugin`
                - **Never use `build-helper-maven-plugin` for multi-execution property chaining** —
                  write a proper Maven goal in `ike-maven-plugin`
                - **Never embed shell commands inline in POM** — extract to a named script

                """);

        sb.append("See `.claude/standards/` (after `mvn validate`) for full standards.\n");
        sb.append("See `CLAUDE-").append(subproject.name())
                .append(".md` for project-specific notes.\n");

        return sb.toString();
    }

    /**
     * Generate the starter template for hand-authored project notes.
     * Static for testability.
     *
     * @param name the subproject or workspace name
     * @return the markdown content
     */
    public static String generateClaudeNotes(String name) {
        return "# " + name + " — Project Notes\n\n"
                + "<!-- This file is for hand-authored, project-specific information.\n"
                + "     It is created by ws:scaffold-init but never overwritten.\n"
                + "     Commit this file to git. -->\n\n"
                + "## Architecture\n\n"
                + "## Key Classes\n\n"
                + "## Testing Notes\n";
    }

    // ── GOALS.md and WS-REFERENCE.md generation ─────────────────

    private void writeGoalCheatsheet(Path wsRoot) {
        Path goalsFile = wsRoot.resolve("GOALS.md");
        try {
            Files.writeString(goalsFile, generateGoalCheatsheet(),
                    StandardCharsets.UTF_8);
            log.info("  Updated GOALS.md");
        } catch (IOException e) {
            log.debug("Could not write GOALS.md: " + e.getMessage());
        }
    }

    /**
     * Generate the workspace goal cheatsheet ({@code GOALS.md}). Static
     * so the content can be diffed independently of the mojo.
     *
     * @return the markdown content
     */
    public static String generateGoalCheatsheet() {
        return """
                # Workspace Goals

                All goals are available in IntelliJ's Maven tool window
                under **Plugins > ws** and **Plugins > ike**.

                ## Workspace Management

                | Goal | Description |
                |------|-------------|
                | `ws:scaffold-init` | Bootstrap a new workspace (no manifest yet) or clone declared subprojects (manifest present). Idempotent. |
                | `ws:add` | Add a subproject repo (prompts for URL) |
                | `ws:scaffold-publish` | Reconcile workspace state (versions, groupIds, scaffold conventions) — subsumes the retired scaffold-upgrade goals |
                | `ws:graph` | Print dependency graph (text or DOT format) |
                | `ws:stignore` | Generate Syncthing ignore rules |
                | `ws:remove` | Remove a subproject (prompts for name) |
                | `ws:help` | List all ws: goals with descriptions |

                ## Verification

                | Goal | Description |
                |------|-------------|
                | `ws:scaffold-draft` | Check manifest, parents, BOM cascade, VCS state (folds verify per #393) |
                | `ws:verify-convergence` | Transitive dependency convergence (slow) |
                | `ws:overview` | Workspace overview (manifest, graph, status, cascade) |
                | `ws:check-branch` | Warn when a subproject branch deviates from workspace.yaml |

                ## Version Alignment

                | Goal | Description |
                |------|-------------|
                | `ws:align-draft` | Preview inter-subproject POM version alignment (AlignmentReconciler) |
                | `ws:align-publish` | Apply POM version alignment |
                | `ws:reconcile-branches-draft` / `-publish` | Reconcile branch fields against on-disk state |
                | `ws:scaffold-draft -DupdateParent=true` | Preview parent-POM version cascade (along with other reconciliation) |
                | `ws:scaffold-publish -DparentVersion=<v>` | Pin parent to specific version and cascade |

                ## Branch Coordination

                | Goal | Description |
                |------|-------------|
                | `ws:switch-draft` | Preview switching subprojects to a coordinated branch |
                | `ws:switch-publish` | Switch subprojects to a coordinated branch |
                | `ws:update-feature-draft` | Preview rebasing a feature branch onto main |
                | `ws:update-feature-publish` | Rebase a feature branch onto main |

                ## Feature Branching

                | Goal | Description |
                |------|-------------|
                | `ws:feature-start-draft` | Preview feature branch |
                | `ws:feature-start-publish` | Create feature branch across components |
                | `ws:feature-finish-merge-draft` | Preview no-ff merge |
                | `ws:feature-finish-merge-publish` | No-ff merge (preserves history) |
                | `ws:feature-finish-squash-draft` | Preview squash merge |
                | `ws:feature-finish-squash-publish` | Squash merge (single commit) |
                | `ws:feature-abandon-draft` | Preview abandoning a feature branch |
                | `ws:feature-abandon-publish` | Delete feature branch across components |

                ## Release & Checkpoint

                | Goal | Description |
                |------|-------------|
                | `ws:release-draft` | Preview what would be released |
                | `ws:release-publish` | Execute workspace release |
                | `ws:release-status` | Diagnose state of any in-flight workspace release |
                | `ws:checkpoint-draft` | Preview checkpoint (tag all subprojects) |
                | `ws:checkpoint-publish` | Execute checkpoint |
                | `ws:post-release` | Bump to next development version |
                | `ws:release-notes` | Generate release notes from GitHub milestone |

                ## VCS Bridge (Syncthing multi-machine)

                | Goal | Description |
                |------|-------------|
                | `ws:sync` | Pull then push across the workspace (the daily sync op) |
                | `ws:commit` | Commit across repos (stages all by default; `-DstagedOnly` to opt out) |
                | `ws:pull` | Git pull --rebase across all subprojects |
                | `ws:push` | Push all subprojects (warns about uncommitted changes) |
                | `ws:report` | Aggregate ws:* goal reports into a single document |

                ## Branch Cleanup

                | Goal | Description |
                |------|-------------|
                | `ws:cleanup-draft` | List merged/stale feature branches |
                | `ws:cleanup-publish` | Delete merged feature branches |

                ## Build Goals (ike:)

                | Goal | Description |
                |------|-------------|
                | `ike:release-draft` | Preview single-repo release |
                | `ike:release-publish` | Execute single-repo release |
                | `ike:generate-bom` | Generate BOM with resolved versions |
                | `ike:deploy-site-draft` | Preview site deployment |
                | `ike:deploy-site-publish` | Deploy project site |
                | `ike:register-site-draft` | Preview org site registration |
                | `ike:register-site-publish` | Register project on org site |
                | `ike:help` | List all ike: goals with descriptions |

                ---
                *Generated by `ws:scaffold-init`. See `ws:help` and `ike:help` for full details.*
                """;
    }

    private void writeWorkspaceReference(Path wsRoot) {
        Path refFile = wsRoot.resolve("WS-REFERENCE.md");
        try {
            Files.writeString(refFile, generateWorkspaceReference(),
                    StandardCharsets.UTF_8);
            log.info("  Updated WS-REFERENCE.md");
        } catch (IOException e) {
            log.debug("Could not write WS-REFERENCE.md: " + e.getMessage());
        }
    }

    /**
     * Generate the long-form workspace goal reference
     * ({@code WS-REFERENCE.md}). Static for testability.
     *
     * @return the markdown content
     */
    public static String generateWorkspaceReference() {
        return """
                # Workspace Goals Reference

                Complete reference for `ws:` goals. Quick overview: [GOALS.md](GOALS.md).

                ## Convention: -draft / -publish

                Most mutating goals come in pairs:
                - **-draft** (default) — preview mode, no changes made
                - **-publish** — executes the operation

                Example: `mvn ws:feature-start-draft -Dfeature=X` previews,
                `mvn ws:feature-start-publish -Dfeature=X` executes.

                ---

                ## Feature Branching

                ### Start: `ws:feature-start-draft` / `ws:feature-start-publish`

                Create a feature branch, qualify versions (e.g., `1.0.0-SNAPSHOT` becomes
                `1.0.0-CssUtils-SNAPSHOT`), cascade through BOMs and properties.

                | Parameter | Default | Description |
                |-----------|---------|-------------|
                | `feature` | prompted | Feature name (branch: `feature/<name>`) |
                | `targetBranch` | `main` | Source branch |
                | `skipVersion` | `false` | Skip version qualification |

                Fails if any subproject is on a different feature branch.
                Branches stay local (no auto-push).

                ### Finish: Three Strategies

                **`ws:feature-finish-squash-publish`** (recommended) — single commit on target.
                **`ws:feature-finish-merge-publish`** — no-ff merge, preserves history.
                **`ws:feature-finish-rebase-publish`** — linear history, no merge commit.

                All strategies:
                - Auto-generate commit message from per-subproject commit history
                - Fail-fast if any subproject has uncommitted changes
                - Strip branch-qualified versions back to base SNAPSHOT
                - Accept optional `-Dmessage="summary"` prepended to auto-generated message

                | Parameter | Default | Description |
                |-----------|---------|-------------|
                | `feature` | prompted | Feature name |
                | `targetBranch` | `main` | Merge target |
                | `keepBranch` | varies | Keep branch after merge |
                | `message` | auto | Optional human summary |

                ### Abandon: `ws:feature-abandon-draft`

                Delete a feature branch without merging.

                ---

                ## Workspace Lifecycle

                | Goal | Description |
                |------|-------------|
                | `ws:scaffold-init` | Bootstrap a new workspace, or clone declared subprojects when workspace.yaml already exists. Safe to re-run. |
                | `ws:scaffold-draft` | Check manifest, BOM cascade, VCS state (folds verify per #393) |
                | `ws:verify-convergence` | Transitive dependency convergence (slow) |
                | `ws:overview` | Dashboard: manifest, graph, status, cascade |
                | `ws:scaffold-publish` | Apply workspace-level reconciliation (denormalized YAML field sync, scaffold conventions, etc.) |
                | `ws:pull` | Git pull --rebase across components |

                ---

                ## Release & Checkpoint

                | Goal | Description |
                |------|-------------|
                | `ws:release-draft` / `-publish` | Release release-pending components in topo order |
                | `ws:checkpoint-draft` / `-publish` | Tag all subprojects, record SHAs |
                | `ws:post-release` | Bump to next SNAPSHOT |
                | `ws:align-draft` / `-publish` | Align inter-subproject versions |
                | `ws:release-notes` | Generate notes from GitHub milestone |

                ---

                ## VCS Bridge (Syncthing)

                | Goal | Description |
                |------|-------------|
                | `ws:commit` | Commit across repos (`-Dpush=true -Dmessage="..."`) |
                | `ws:push` | Push all subprojects (warns about uncommitted changes) |
                | `ws:sync` | Pull then push across the workspace |
                | `ws:cleanup-draft` / `-publish` | List/delete merged feature branches |

                ---

                ## Preflight Validation

                Multi-repo goals validate that all subproject working trees are clean
                before starting. If any subproject has uncommitted changes, the goal
                fails immediately with a list of affected repos and files — no partial
                modifications occur.

                **Goals with hard preflight (publish mode):**
                `release`, `align`, `set-parent`, `checkpoint`, `pull`, `switch`,
                `feature-start`, `feature-finish-*`, `feature-abandon`, `update-feature`

                **Draft goals:** warn about uncommitted changes that would block the
                corresponding `-publish` goal, but still run the preview.

                **`ws:commit`:** skips VCS bridge catch-up when there are pending
                changes to commit, preventing branch-switch conflicts.

                **`ws:push`:** warns about uncommitted changes after pushing, and
                automatically sets upstream tracking for new branches.

                ## Troubleshooting

                **"Cannot X — uncommitted changes in:"** — Run `mvn ws:commit -Dmessage="..."` to commit all pending changes, then retry.

                **Maven discovers `.teamcity/pom.xml`** — Add `-pl !.teamcity` to `.mvn/maven.config`.

                **Feature finish: "uncommitted changes"** — Run `mvn ws:commit -Dmessage="..."` first.

                **Feature start: "already on feature branch"** — Finish/abandon the current feature first.

                **Plugin version mismatch** — After upgrading `ike-parent`, run `mvn ws:scaffold-init`.

                **Stale clones on CI** — `ws:scaffold-init` now fetches and rebases existing clones. Delete subproject directories manually only if rebase conflicts occur.

                ---
                *Generated by `ws:scaffold-init`. Regenerated when workspace plugin version changes.*
                """;
    }

    // ── Report ──────────────────────────────────────────────────

    private String buildInitMarkdownReport(Result result) {
        StringBuilder sb = new StringBuilder();
        sb.append(result.cloned()).append(" cloned, ")
                .append(result.syncthing()).append(" Syncthing-initialized, ")
                .append(result.updated()).append(" updated, ")
                .append(result.skipped()).append(" skipped");
        if (result.wrappers() > 0) {
            sb.append(", ").append(result.wrappers()).append(" Maven wrappers updated");
        }
        sb.append(".\n\n");
        sb.append("| Subproject | Action | URL | Status |\n");
        sb.append("|-----------|--------|-----|--------|\n");
        for (String[] row : result.rows()) {
            sb.append("| ").append(row[0])
                    .append(" | ").append(row[1])
                    .append(" | ").append(row[2])
                    .append(" | ").append(row[3])
                    .append(" |\n");
        }
        return sb.toString();
    }
}