FeatureStartSiblingDraftMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;

import network.ike.workspace.Defaults;
import network.ike.workspace.FeatureName;
import network.ike.workspace.Subproject;
import network.ike.workspace.WorkingSet;
import network.ike.workspace.WorkspaceGraph;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;

import java.io.File;
import java.util.ArrayList;
import java.util.List;

/**
 * Preview a sibling-clone feature start — the read-only {@code -draft}
 * counterpart of {@link FeatureStartSiblingPublishMojo}
 * (IKE-Network/ike-issues#770).
 *
 * <p>No cloning, no branching, no mutation. The goal resolves the base
 * branch and applies the <em>same guard</em> the publish mojo does (it
 * refuses to plan a sibling off a non-base branch unless {@code -Dfrom} is
 * given), then writes a {@code ws꞉feature-start-sibling-draft.md} report
 * covering:
 * <ul>
 *   <li>the plan — sibling directory {@code <baseName>-<feature>}, the
 *       feature branch, the base branch, and the members that would be
 *       cloned and version-qualified;</li>
 *   <li>preflight checks, each with copy-pasteable remediation when failing:
 *       the sibling dir must not already exist; the primary root and every
 *       member with a repo URL must be materialized locally — the sibling
 *       chains through its local parent (IKE-Network/ike-issues#992); the
 *       base branch must exist locally in the primary;</li>
 *   <li>a clone-cost note — members clone from the primary's local paths
 *       (origin = the parent member): offline, no transport or credential
 *       machinery, objects hardlinked where the filesystem allows, and the
 *       sibling stays {@code rm -rf}-safe.</li>
 * </ul>
 *
 * <pre>{@code
 * mvn ws:feature-start-sibling-draft   -Dfeature=jira-456
 * mvn ws:feature-start-sibling-publish -Dfeature=jira-456
 * }</pre>
 *
 * @see FeatureStartSiblingPublishMojo for the executing counterpart
 * @see SiblingBaseResolution for the shared base-branch resolution + guard
 */
@Mojo(name = "feature-start-sibling-draft", projectRequired = false, aggregator = true)
public class FeatureStartSiblingDraftMojo extends AbstractWorkspaceMojo {

    /** Feature name. Branch will be {@code feature/<name>}. Prompted if omitted. */
    @Parameter(property = "feature")
    String feature;

    /**
     * Skip POM version qualification in the plan. Mirrors the publish flag —
     * a document workspace whose subprojects have no versioned artifacts.
     */
    @Parameter(property = "skipVersion", defaultValue = "false")
    boolean skipVersion;

    /**
     * Explicit base branch to cut the sibling from. When unset, the sibling
     * is based on the primary's current branch — guarded so a sibling is
     * never silently planned off a non-base branch (see
     * {@link SiblingBaseResolution}).
     */
    @Parameter(property = "from")
    String from;

    /** Creates this goal instance. */
    public FeatureStartSiblingDraftMojo() {}

    /** A single preflight check row: the check, pass/fail, and remediation. */
    private record Preflight(String check, boolean ok, String detail) {}

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        feature = requireParam(feature, "feature",
                "Feature name (without feature/ prefix)");
        FeatureName featureName = validateFeatureName(feature);
        String branchName = "feature/" + feature;

        if (!isWorkspaceMode()) {
            return previewBareMode(featureName, branchName);
        }

        WorkspaceGraph graph = loadGraph();
        File primaryRoot = workspaceRoot();
        Defaults defaults = graph.manifest().defaults();
        String manifestBase = (defaults != null && defaults.branch() != null)
                ? defaults.branch() : "main";

        // Resolve base + apply the SAME guard the publish mojo does. A guard
        // violation aborts the preview with the remediation message.
        String base = SiblingBaseResolution.resolveAndGuard(
                from, gitBranch(primaryRoot), manifestBase);

        String baseName = resolveWorkingSet().baseName();
        File parent = primaryRoot.getParentFile();
        if (parent == null) {
            throw new MojoException(
                    "Cannot resolve the parent of the workspace root " + primaryRoot
                    + "; sibling clones live alongside the primary.");
        }
        String siblingName = featureName.siblingDirectoryName(baseName);
        File siblingRoot = new File(parent, siblingName);

        List<String> sorted = graph.topologicalSort();

        getLog().info("");
        getLog().info(header("Feature Start (sibling) — DRAFT"));
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  Feature: " + feature);
        getLog().info("  Branch:  " + branchName);
        getLog().info("  Base:    " + base);
        getLog().info("  Sibling: " + siblingRoot.getAbsolutePath());
        getLog().info("");

        // --- Plan rows: aggregator + each subproject that would be cloned. ---
        List<String[]> planRows = new ArrayList<>();
        planRows.add(new String[]{baseName + " (aggregator)",
                "clone + branch", currentVersionLabel(primaryRoot, branchName)});

        // --- Preflight checks. ---
        List<Preflight> preflight = new ArrayList<>();
        preflight.add(new Preflight(
                "Sibling dir does not already exist",
                !siblingRoot.exists(),
                siblingRoot.exists()
                        ? "`" + siblingRoot.getAbsolutePath() + "` exists — "
                                + "remove it: `rm -rf " + siblingRoot.getAbsolutePath() + "`"
                        : "`" + siblingRoot.getAbsolutePath() + "`"));

        boolean rootMaterialized = new File(primaryRoot, ".git").exists();
        preflight.add(new Preflight(
                "Primary root is a git repository (the sibling chains to it)",
                rootMaterialized,
                rootMaterialized ? "`" + primaryRoot.getAbsolutePath() + "`"
                        : "materialize it first: `git clone <url> "
                                + primaryRoot.getAbsolutePath() + "`"));

        // base branch exists locally in the primary — the clone source
        // under the local-origin model (ike-issues#992).
        preflight.add(baseBranchPreflight(primaryRoot, base));

        List<String> unmaterialized = new ArrayList<>();
        for (String name : sorted) {
            Subproject sub = graph.manifest().subprojects().get(name);
            File comp = new File(primaryRoot, name);
            String versionLabel = skipVersion
                    ? "—"
                    : currentVersionLabel(comp, branchName);
            // Each member clones from the primary's local path — origin in
            // the sibling is the parent member (ike-issues#992). A member
            // the primary never materialized either skips (deliberately
            // unclonable: no repo URL) or fails the preflight below.
            if (!new File(comp, ".git").exists()) {
                String remote = sub.repo();
                if (remote == null || remote.isEmpty()) {
                    planRows.add(new String[]{name,
                            "skip (no repo URL, no local clone)", "—"});
                    continue;
                }
                unmaterialized.add(name);
            }
            planRows.add(new String[]{name, "clone + branch", versionLabel});
        }
        preflight.add(new Preflight(
                "Every member is materialized in the primary",
                unmaterialized.isEmpty(),
                unmaterialized.isEmpty()
                        ? "all clone sources present"
                        : "missing local clone(s): "
                                + String.join(", ", unmaterialized)
                                + " — run `mvn ws:scaffold-init` in the primary"));

        return new WorkspaceReportSpec(WsGoal.FEATURE_START_SIBLING_DRAFT,
                buildReport(siblingName, siblingRoot, branchName, base,
                        planRows, preflight));
    }

    /**
     * Bare-mode preview: a single-repo working set, base resolved + guarded
     * with {@code main} as the manifest base (there is no manifest).
     */
    private WorkspaceReportSpec previewBareMode(FeatureName featureName,
                                                String branchName)
            throws MojoException {
        WorkingSet workingSet = resolveWorkingSet();
        File repo = workingSet.members().getFirst().directory().toFile();
        String base = SiblingBaseResolution.resolveAndGuard(
                from, gitBranch(repo), "main");
        File parent = repo.getParentFile();
        String siblingName = featureName.siblingDirectoryName(workingSet.baseName());
        File siblingRoot = (parent != null)
                ? new File(parent, siblingName)
                : new File(siblingName);

        getLog().info("");
        getLog().info(header("Feature Start (sibling) — DRAFT"));
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  Feature: " + feature);
        getLog().info("  Branch:  " + branchName);
        getLog().info("  Base:    " + base);
        getLog().info("  Sibling: " + siblingRoot.getAbsolutePath());
        getLog().info("  Mode:    single repo (no workspace.yaml)");
        getLog().info("");

        List<String[]> planRows = new ArrayList<>();
        planRows.add(new String[]{workingSet.baseName() + " (aggregator)",
                "clone + branch",
                skipVersion ? "—" : currentVersionLabel(repo, branchName)});

        List<Preflight> preflight = new ArrayList<>();
        preflight.add(new Preflight(
                "Sibling dir does not already exist",
                !siblingRoot.exists(),
                siblingRoot.exists()
                        ? "`" + siblingRoot.getAbsolutePath() + "` exists — "
                                + "remove it: `rm -rf " + siblingRoot.getAbsolutePath() + "`"
                        : "`" + siblingRoot.getAbsolutePath() + "`"));
        boolean repoMaterialized = new File(repo, ".git").exists();
        preflight.add(new Preflight(
                "Repo is a git repository (the sibling chains to it)",
                repoMaterialized,
                repoMaterialized ? "`" + repo.getAbsolutePath() + "`"
                        : "not a git repository — nothing to fork"));
        preflight.add(baseBranchPreflight(repo, base));

        return new WorkspaceReportSpec(WsGoal.FEATURE_START_SIBLING_DRAFT,
                buildReport(siblingName, siblingRoot, branchName, base,
                        planRows, preflight));
    }

    /**
     * Preflight that the base branch exists <em>locally</em> in {@code dir}
     * — the clone source under the local-origin model
     * (IKE-Network/ike-issues#992); no remote is consulted.
     */
    private Preflight baseBranchPreflight(File dir, String base) {
        boolean exists;
        try {
            String out = ReleaseSupport.execCapture(dir, "git", "rev-parse",
                    "--verify", "--quiet", "refs/heads/" + base);
            exists = out != null && !out.isBlank();
        } catch (Exception e) {
            exists = false;
        }
        String detail = exists
                ? "local branch `" + base + "` resolves in the clone source"
                : "no local branch `" + base + "` there — check it out once,"
                        + " or pass `-Dfrom=<existing-branch>`";
        return new Preflight(
                "Base branch `" + base + "` exists in the clone source",
                exists, detail);
    }

    /**
     * Label a member's current POM version and the version it would be
     * qualified to on {@code branchName}, for the plan table.
     */
    private String currentVersionLabel(File dir, String branchName) {
        File pom = new File(dir, "pom.xml");
        if (!pom.exists()) {
            return "—";
        }
        try {
            String version = ReleaseSupport.readPomVersion(pom);
            if (version == null || version.isEmpty()) {
                return "—";
            }
            String qualified = network.ike.workspace.VersionSupport
                    .branchQualifiedVersion(version, branchName);
            return qualified.equals(version)
                    ? version
                    : version + " → " + qualified;
        } catch (MojoException e) {
            getLog().debug("Could not read POM version for " + dir + ": "
                    + e.getMessage());
            return "—";
        }
    }

    /**
     * Render the preview report: the plan, the preflight table, the
     * clone-cost note, and the next-step commands.
     */
    private String buildReport(String siblingName, File siblingRoot,
                               String branchName, String base,
                               List<String[]> planRows,
                               List<Preflight> preflight) {
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph("**Preview — no clone is made.**")
                .paragraph("**Sibling:** `" + siblingName + "`")
                .paragraph("**Branch:** `" + branchName + "`")
                .paragraph("**Base:** `" + base + "`")
                .paragraph("**Location:** `" + siblingRoot.getAbsolutePath() + "`");

        report.section("Plan")
                .table(List.of("Member", "Action", "Version"), planRows);

        boolean allOk = true;
        List<String[]> checkRows = new ArrayList<>();
        for (Preflight p : preflight) {
            allOk = allOk && p.ok();
            checkRows.add(new String[]{
                    p.ok() ? "✓" : "✗", p.check(), p.detail()});
        }
        report.section("Preflight")
                .table(List.of("", "Check", "Detail"), checkRows);
        report.paragraph(allOk
                ? "All preflight checks pass — `ws:feature-start-sibling-publish"
                        + " -Dfeature=" + feature + "` should proceed."
                : "**Resolve the ✗ checks above, then run** "
                        + "`ws:feature-start-sibling-publish -Dfeature="
                        + feature + "`.");

        report.section("Clone cost")
                .paragraph("Each component is cloned from the primary's local "
                        + "member path, which becomes the sibling's `origin` — "
                        + "the sibling chains sibling → parent → GitHub "
                        + "(ike-issues#992). The clone is offline (no "
                        + "transport, no credentials), objects are hardlinked "
                        + "where the filesystem allows, so creation is fast "
                        + "and disk cost stays far below a full object DB "
                        + "until histories diverge. Deleting the sibling "
                        + "(`rm -rf`) never affects the primary.");

        report.section("Next")
                .paragraph("```bash\nmvn ws:feature-start-sibling-publish"
                        + " -Dfeature=" + feature
                        + (from != null && !from.isBlank()
                                ? " -Dfrom=" + from : "")
                        + "\n```");
        return report.build();
    }
}