WsAddMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.workspace.Subproject;
import network.ike.workspace.Dependency;
import network.ike.workspace.Manifest;
import network.ike.workspace.ManifestException;
import network.ike.workspace.ManifestReader;
import network.ike.workspace.PublishedArtifactSet;

import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;

import org.w3c.dom.Document;
import org.w3c.dom.Element;

import javax.xml.parsers.DocumentBuilder;
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;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
 * Add a subproject repository to an existing workspace.
 *
 * <p>Given a git URL, this goal:
 * <ol>
 *   <li>Clones the repository into the workspace</li>
 *   <li>Derives the subproject name from the URL (or accepts
 *       {@code -Dsubproject=<name>})</li>
 *   <li>Scans the POM to derive groupId and inter-subproject
 *       dependencies (matching dependency/parent groupIds against
 *       already-registered workspace subprojects)</li>
 *   <li>Appends a subproject entry to workspace.yaml</li>
 *   <li>Adds a file-activated profile to the reactor POM</li>
 *   <li>Re-scans existing subprojects to discover any that depend
 *       on the newly added subproject (backward resolution)</li>
 * </ol>
 *
 * <p>The subproject name is derived from the last path segment of the
 * URL with {@code .git} stripped. For example,
 * {@code https://github.com/ikmdev/tinkar-core.git} becomes
 * {@code tinkar-core}.
 *
 * <pre>{@code
 * mvn ws:add -Drepo=https://github.com/ikmdev/tinkar-core.git
 * mvn ws:add -Drepo=https://github.com/ikmdev/rocks-kb.git
 * mvn ws:add -Drepo=https://github.com/ikmdev/komet.git
 * }</pre>
 *
 * @see WsScaffoldInitMojo for creating a new workspace or cloning all subprojects
 */
@Mojo(name = "add", projectRequired = false, aggregator = true)
public class WsAddMojo extends AbstractWorkspaceMojo {

    /**
     * Git repository URL. Prompted interactively if omitted.
     */
    @Parameter(property = "repo")
    private String repo;

    /**
     * Subproject name override. If omitted, derived from the repo URL
     * (last path segment minus {@code .git}).
     */
    @Parameter(property = "subproject")
    private String subproject;

    /**
     * Short description of the subproject.
     */
    @Parameter(property = "description")
    private String description;

    /**
     * Branch to track. If omitted, the new subproject is placed on the
     * workspace repo's current git branch (the workspace's active branch).
     * If the workspace repo has no git state, falls back to the manifest's
     * {@code defaults.branch}.
     *
     * <p>Passing an explicit {@code -Dbranch=} value that disagrees with
     * the workspace repo's current branch is rejected — heterogeneous
     * branch state across a workspace is not a supported configuration
     * (see ike-issues#286).
     */
    @Parameter(property = "branch")
    private String branch;

    /**
     * Maven groupId for the subproject. If omitted, left as
     * a placeholder in workspace.yaml.
     */
    @Parameter(property = "groupId")
    private String groupId;

    /**
     * Maven version for the subproject. If omitted, derived from
     * the subproject's root POM. Written to workspace.yaml so that
     * {@code ws:feature-start} can branch-qualify it.
     */
    @Parameter(property = "version")
    private String version;

    /**
     * Skip cloning — register the subproject in workspace.yaml without
     * cloning. Dependencies cannot be derived without a POM to scan,
     * so they will be empty. Use {@code ws:scaffold-init} to clone later.
     */
    @Parameter(property = "skipClone", defaultValue = "false")
    private boolean skipClone;

    /** Derived dependency with optional version-property name. */
    record DerivedDep(String subproject, String versionProperty) {}

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

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        repo = requireParam(repo, "repo", "Git repository URL");

        // Resolve workspace root
        Path wsDir = findWorkspaceRoot();
        Path manifestPath = wsDir.resolve("workspace.yaml");
        Path pomPath = wsDir.resolve("pom.xml");

        if (!Files.exists(manifestPath)) {
            throw new MojoException(
                    "No workspace.yaml found in " + wsDir
                    + ". Run ws:scaffold-init first.");
        }

        // Resolve the target branch up front: workspace repo HEAD is
        // authoritative (ike-issues#286). The new subproject must land
        // on the same branch as the rest of the workspace.
        branch = resolveBranch(wsDir, manifestPath);

        // Derive subproject name from URL if not specified
        if (subproject == null || subproject.isBlank()) {
            subproject = deriveSubprojectName(repo);
        }
        // Validate via SubprojectName (#295). The derived name comes
        // from a git URL's last path segment which usually conforms,
        // but explicit -Dsubproject= and odd repo URLs can fail.
        validateSubprojectName(subproject);

        // Check if already registered — if so, re-derive and update
        // rather than appending a duplicate (idempotent behavior)
        boolean alreadyRegistered = false;
        try {
            Manifest existing = ManifestReader.read(manifestPath);
            alreadyRegistered = existing.subprojects().containsKey(subproject);
        } catch (ManifestException e) {
            // Manifest may be empty/malformed on first add — continue
        }

        if (description == null || description.isBlank()) {
            description = subproject + " subproject.";
        }

        // Clone so we can scan the POM for groupId and dependencies
        Path subprojectDir = wsDir.resolve(subproject);
        boolean cloned = false;
        List<DerivedDep> derivedDeps = null;

        if (!skipClone && !Files.exists(subprojectDir)) {
            cloneSubproject(wsDir);
            cloned = true;
        }

        String detectedParent = null;

        if (Files.exists(subprojectDir.resolve("pom.xml"))) {
            // Derive groupId from POM if not explicitly specified
            if (groupId == null || groupId.isBlank()) {
                groupId = deriveGroupId(subprojectDir);
            }

            // Derive version from POM if not explicitly specified
            if (version == null || version.isBlank()) {
                try {
                    version = ReleaseSupport.readPomVersion(
                            subprojectDir.resolve("pom.xml").toFile());
                } catch (MojoException e) {
                    // Non-fatal — version will be null in manifest
                }
            }

            // Detect parent POM — match against workspace subprojects
            PomParentSupport.ParentInfo parentInfo = null;
            try {
                parentInfo = PomParentSupport.readParent(
                        subprojectDir.resolve("pom.xml"));
                if (parentInfo != null) {
                    Manifest existing = ManifestReader.read(manifestPath);
                    for (Map.Entry<String, Subproject> candidate :
                            existing.subprojects().entrySet()) {
                        if (candidate.getValue().groupId() != null
                                && candidate.getValue().groupId().equals(parentInfo.groupId())) {
                            detectedParent = candidate.getKey();
                            break;
                        }
                    }
                }
            } catch (Exception e) {
                // Non-fatal — parent detection is best-effort
            }

            // #324 parent coherence: if the new subproject's
            // <parent> GA matches the workspace aggregator's own
            // <parent> GA, enforce two rules:
            //   1. Version coherence — same version as workspace
            //   2. Cycle prevention — empty <relativePath/>
            // Warn (not fail) at add time so an operator who hits
            // either violation gets the heads-up before running
            // mvn install. The matching ws:scaffold-draft check
            // (which folds verify per #393) is authoritative —
            // failure mode there is what blocks a release.
            if (parentInfo != null) {
                checkParentCoherenceAtAdd(wsDir, subprojectDir,
                        subproject, parentInfo);
            }

            // Derive dependencies by matching POM groupIds against
            // already-registered workspace subprojects
            try {
                derivedDeps = deriveDependencies(wsDir, manifestPath,
                        subprojectDir, subproject);
            } catch (IOException e) {
                getLog().warn("  Could not derive dependencies from POM: "
                        + e.getMessage());
            }
        } else if (!skipClone) {
            getLog().warn("  No pom.xml found — dependencies not derived");
        }

        getLog().info("");
        String wsName = readWorkspaceName(wsDir);
        getLog().info(wsName + " — Add Subproject");
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  Subproject: " + subproject);
        getLog().info("  Repo:       " + repo);
        if (branch != null) {
            getLog().info("  Branch:     " + branch);
        }
        if (version != null && !version.isBlank()) {
            getLog().info("  Version:    " + version);
        }
        if (groupId != null && !groupId.isBlank()) {
            getLog().info("  GroupId:    " + groupId);
        }
        if (detectedParent != null) {
            getLog().info("  Parent:     " + detectedParent + " (detected from POM)");
        }
        if (derivedDeps != null && !derivedDeps.isEmpty()) {
            String depNames = derivedDeps.stream()
                    .map(DerivedDep::subproject)
                    .collect(Collectors.joining(", "));
            getLog().info("  Depends:    " + depNames + " (derived from POM)");
        } else {
            getLog().info("  Depends:    (none)");
        }
        if (alreadyRegistered) {
            getLog().info("  (already registered — re-validating dependencies)");
        }
        if (cloned) {
            getLog().info(Ansi.green("  ✓ ") + "Cloned " + subproject);
        }
        getLog().info("");

        try {
            if (alreadyRegistered) {
                // Update existing entry's depends-on in workspace.yaml
                updateSubprojectDependencies(manifestPath, subproject, derivedDeps);
                getLog().info(Ansi.green("  ✓ ") + "workspace.yaml updated (dependencies re-derived)");
            } else {
                // Append new subproject to workspace.yaml
                appendSubprojectToManifest(manifestPath, derivedDeps, detectedParent);
                getLog().info(Ansi.green("  ✓ ") + "workspace.yaml updated");
            }

            // Profile is idempotent — addProfileToPom already checks for existence
            addProfileToPom(pomPath);
            getLog().info(Ansi.green("  ✓ ") + "pom.xml updated (profile: with-" + subproject + ")");

        } catch (IOException e) {
            throw new MojoException(
                    "Failed to update workspace files: " + e.getMessage(), e);
        }

        // Backward resolution: check if any existing subprojects
        // depend on the newly added subproject's groupId
        if (Files.exists(subprojectDir.resolve("pom.xml"))) {
            try {
                int backfilled = backfillDependencies(
                        wsDir, manifestPath, subproject, subprojectDir);
                if (backfilled > 0) {
                    getLog().info(Ansi.green("  ✓ ") + "Updated " + backfilled
                            + " existing subproject(s) with dependency on "
                            + subproject);
                }
            } catch (IOException e) {
                getLog().warn("  Could not backfill dependencies: "
                        + e.getMessage());
            }
        }

        // Auto-commit workspace.yaml + pom.xml changes
        try {
            ReleaseSupport.exec(wsDir.toFile(), getLog(), "git", "add", "workspace.yaml", "pom.xml");
            ReleaseSupport.exec(wsDir.toFile(), getLog(), "git", "commit", "-m", "workspace: add " + subproject);
            getLog().info(Ansi.green("  ✓ ") + "committed workspace.yaml + pom.xml");
        } catch (Exception e) {
            getLog().warn("  Auto-commit failed (non-fatal): " + e.getMessage());
        }

        // Version alignment: update dependency versions in the newly
        // added subproject (and any backfilled subprojects) to match
        // workspace SNAPSHOT versions. Changes are left uncommitted
        // so the developer can review and fold them into a feature branch.
        if (Files.exists(subprojectDir.resolve("pom.xml"))) {
            try {
                Manifest updatedManifest = ManifestReader.read(manifestPath);
                int aligned = alignVersions(wsDir, subprojectDir, subproject,
                        updatedManifest);
                if (aligned > 0) {
                    getLog().info("");
                    getLog().info(Ansi.yellow("  ⚠ ") + aligned + " file(s) modified for version "
                            + "alignment (uncommitted)");
                    getLog().info("    Review with 'git diff' in " + subproject);
                }
            } catch (IOException e) {
                getLog().warn("  Could not align versions: " + e.getMessage());
            }
        }

        getLog().info("");
        if (cloned) {
            getLog().info("  Subproject added and cloned.");
        } else {
            getLog().info("  Subproject added. Run 'mvn ws:scaffold-init' to clone.");
        }
        getLog().info("");
        GoalReportBuilder report = new GoalReportBuilder();
        report.paragraph("Added subproject **" + subproject + "**");
        report.table(List.of("Field", "Value"), List.of(
                new String[]{"Repo", repo},
                new String[]{"Cloned",
                        cloned ? "yes" : "no — run ws:scaffold-init"}));
        WorkspaceReportSpec spec = new WorkspaceReportSpec(WsGoal.ADD,
                report.build());

        PostMutationSync.refresh(workspaceRoot(), getLog());
        return spec;
    }

    // ── YAML generation ──────────────────────────────────────────

    void appendSubprojectToManifest(Path manifestPath, List<DerivedDep> derivedDeps,
                                     String detectedParent)
            throws IOException {
        String yaml = Files.readString(manifestPath, StandardCharsets.UTF_8);

        StringBuilder entry = new StringBuilder();
        entry.append("\n  ").append(subproject).append(":\n");
        entry.append("    description: >\n");
        entry.append("      ").append(description).append("\n");
        entry.append("    repo: ").append(repo).append("\n");
        if (branch != null && !branch.isBlank()) {
            entry.append("    branch: ").append(branch).append("\n");
        }
        if (version != null && !version.isBlank()) {
            entry.append("    version: \"").append(version).append("\"\n");
        }
        if (groupId != null && !groupId.isBlank()) {
            entry.append("    groupId: ").append(groupId).append("\n");
        }
        if (detectedParent != null) {
            entry.append("    parent: ").append(detectedParent).append("\n");
        }
        if (derivedDeps != null && !derivedDeps.isEmpty()) {
            entry.append("    depends-on:\n");
            for (DerivedDep dep : derivedDeps) {
                entry.append("      - subproject: ").append(dep.subproject()).append("\n");
                entry.append("        relationship: build\n");
                if (dep.versionProperty() != null) {
                    entry.append("        version-property: ").append(dep.versionProperty()).append("\n");
                }
            }
        } else {
            entry.append("    depends-on: []\n");
        }

        // Insert at the end of the subprojects: block, before any
        // trailing top-level constructs (e.g. the `# ide:` template
        // comment, or a future `ide:` key). #240
        int insertAt = findSubprojectsBlockInsertionPoint(yaml);
        if (insertAt < 0) {
            // No subprojects: key found — append at EOF as fallback.
            yaml = yaml + entry;
        } else {
            yaml = yaml.substring(0, insertAt) + entry + yaml.substring(insertAt);
        }

        Files.writeString(manifestPath, yaml, StandardCharsets.UTF_8);
    }

    /**
     * Locate the position inside {@code workspace.yaml} where a new
     * subproject entry should be inserted. The entry belongs at the
     * end of the {@code subprojects:} block — after any existing
     * subproject entries (and the placeholder comment) — and before
     * any subsequent top-level construct such as a comment block or
     * a sibling top-level key (e.g. {@code ide:}).
     *
     * <p>Operationally: find the {@code subprojects:} line, scan
     * forward through indented and blank lines (which belong to the
     * block), and stop at the first column-0 non-blank line. The
     * insertion point is the byte offset right after the last
     * non-blank line of the block.
     *
     * @param yaml the current manifest text
     * @return offset to insert before, or {@code -1} if no
     *         {@code subprojects:} key was found
     */
    static int findSubprojectsBlockInsertionPoint(String yaml) {
        // Locate the subprojects: line.
        int subprojectsLineStart = -1;
        int pos = 0;
        while (pos < yaml.length()) {
            int eol = yaml.indexOf('\n', pos);
            if (eol < 0) eol = yaml.length();
            String line = yaml.substring(pos, eol);
            if (line.startsWith("subprojects:")) {
                subprojectsLineStart = pos;
                pos = (eol < yaml.length()) ? eol + 1 : eol;
                break;
            }
            pos = (eol < yaml.length()) ? eol + 1 : eol;
        }
        if (subprojectsLineStart < 0) return -1;

        // Walk forward: indented and blank lines belong to the block;
        // a column-0 non-blank line ends it. Track the end-offset of
        // the last non-blank line so the insertion point is just after it.
        int lastNonBlankEnd = pos;
        while (pos < yaml.length()) {
            int eol = yaml.indexOf('\n', pos);
            boolean hasNewline = eol >= 0;
            if (eol < 0) eol = yaml.length();
            String line = yaml.substring(pos, eol);
            int nextStart = hasNewline ? eol + 1 : eol;

            if (line.isBlank()) {
                // Could be inside the block or trailing — keep scanning.
            } else if (!Character.isWhitespace(line.charAt(0))) {
                // Reached the next top-level construct — stop.
                return lastNonBlankEnd;
            } else {
                // Indented content line — extend the block.
                lastNonBlankEnd = nextStart;
            }
            pos = nextStart;
            if (!hasNewline) break;
        }

        // Hit EOF before another top-level construct.
        return lastNonBlankEnd;
    }

    /**
     * Update the depends-on section for an existing subproject in
     * workspace.yaml. Replaces the current depends-on block with
     * the newly derived dependencies.
     */
    static void updateSubprojectDependencies(Path manifestPath, String subprojectName,
                                             List<DerivedDep> derivedDeps) throws IOException {
        String yaml = Files.readString(manifestPath, StandardCharsets.UTF_8);
        String updated = rewriteDependsOnBlock(yaml, subprojectName, derivedDeps);
        if (!updated.equals(yaml)) {
            Files.writeString(manifestPath, updated, StandardCharsets.UTF_8);
        }
    }

    /**
     * Pure-string variant of {@link #updateSubprojectDependencies}: takes
     * the current YAML and returns a new string with the named
     * subproject's {@code depends-on} block replaced. Returns the input
     * unchanged when the subproject is not present in the YAML.
     */
    static String rewriteDependsOnBlock(String yaml, String subprojectName,
                                        List<DerivedDep> derivedDeps) {
        // Build the new depends-on block
        StringBuilder newDeps = new StringBuilder();
        if (derivedDeps != null && !derivedDeps.isEmpty()) {
            newDeps.append("    depends-on:\n");
            for (DerivedDep dep : derivedDeps) {
                newDeps.append("      - subproject: ").append(dep.subproject()).append("\n");
                newDeps.append("        relationship: build\n");
                if (dep.versionProperty() != null) {
                    newDeps.append("        version-property: ").append(dep.versionProperty()).append("\n");
                }
            }
        } else {
            newDeps.append("    depends-on: []\n");
        }

        // Replace the existing depends-on block for this subproject.
        // Match: "    depends-on: []\n" or "    depends-on:\n      - ...\n"
        // within this subproject's section.
        String escaped = Pattern.quote(subprojectName);
        Pattern depsPattern = Pattern.compile(
                "(" + escaped + ":[\\s\\S]*?)(    depends-on:.*(?:\\n      .*)*\\n)",
                Pattern.MULTILINE);
        Matcher m = depsPattern.matcher(yaml);
        if (m.find()) {
            return yaml.substring(0, m.start(2))
                    + newDeps
                    + yaml.substring(m.end(2));
        }
        return yaml;
    }

    // ── POM generation ───────────────────────────────────────────

    void addProfileToPom(Path pomPath) throws IOException {
        String pom = Files.readString(pomPath, StandardCharsets.UTF_8);

        // Check if profile already exists
        if (pom.contains("with-" + subproject)) {
            getLog().info("  Profile with-" + subproject + " already exists");
            return;
        }

        String profile = "\n"
                + "        <profile>\n"
                + "            <id>with-" + subproject + "</id>\n"
                + "            <activation>\n"
                + "                <file>\n"
                + "                    <exists>${project.basedir}/" + subproject + "/pom.xml</exists>\n"
                + "                </file>\n"
                + "            </activation>\n"
                + "            <subprojects>\n"
                + "                <subproject>" + subproject + "</subproject>\n"
                + "            </subprojects>\n"
                + "        </profile>\n";

        // Insert before closing </profiles>
        int closingIdx = pom.lastIndexOf("</profiles>");
        if (closingIdx >= 0) {
            pom = pom.substring(0, closingIdx) + profile + "\n    " + pom.substring(closingIdx);
        } else {
            getLog().warn("  No </profiles> tag found in pom.xml — add profile manually");
        }

        Files.writeString(pomPath, pom, StandardCharsets.UTF_8);
    }

    // ── Branch resolution ────────────────────────────────────────

    /**
     * Resolve the branch that the new subproject should land on.
     *
     * <p>Precedence (ike-issues#286):
     * <ol>
     *   <li>The workspace repo's current git branch is authoritative.
     *       If {@code -Dbranch=} was given and disagrees, fail — that's
     *       a request for heterogeneous branch state, which is not
     *       supported.</li>
     *   <li>If the workspace dir is not a git repo (no {@code .git}),
     *       use {@code -Dbranch=} if provided.</li>
     *   <li>Otherwise, fall back to the manifest's
     *       {@code defaults.branch}.</li>
     * </ol>
     *
     * @param wsDir        the workspace root directory
     * @param manifestPath path to workspace.yaml
     * @return the resolved branch name; never null or blank
     * @throws MojoException if {@code -Dbranch} disagrees with the
     *                       workspace repo's current branch, or if no
     *                       branch can be resolved at all
     */
    private String resolveBranch(Path wsDir, Path manifestPath) throws MojoException {
        String requested = (branch != null && !branch.isBlank()) ? branch : null;
        String wsBranch = workspaceHeadBranch(wsDir);

        if (wsBranch != null) {
            if (requested != null && !requested.equals(wsBranch)) {
                throw new MojoException(
                        "Requested branch '" + requested + "' disagrees with the "
                                + "workspace repo's current branch '" + wsBranch + "'. "
                                + "All subprojects in a workspace must track the same "
                                + "branch (ike-issues#286). Either run on the matching "
                                + "branch in the workspace repo, or omit -Dbranch= to "
                                + "use the workspace's current branch.");
            }
            return wsBranch;
        }

        if (requested != null) return requested;

        // No workspace git state, no -Dbranch — fall back to defaults.branch.
        try {
            String def = ManifestReader.read(manifestPath).defaults().branch();
            if (def != null && !def.isBlank()) return def;
        } catch (ManifestException e) {
            // fall through to error below
        }
        throw new MojoException(
                "Cannot resolve a branch for the new subproject: workspace repo "
                        + "has no git state, no -Dbranch was given, and "
                        + "defaults.branch is unset in workspace.yaml.");
    }

    /**
     * Read the workspace repo's current branch, or null if the workspace
     * directory is not a git repository.
     */
    private static String workspaceHeadBranch(Path wsDir) {
        if (!Files.isDirectory(wsDir.resolve(".git"))) return null;
        try {
            String b = VcsOperations.currentBranch(wsDir.toFile());
            return (b == null || b.isBlank()) ? null : b;
        } catch (MojoException e) {
            return null;
        }
    }

    // ── Clone ────────────────────────────────────────────────────

    /**
     * Clone the subproject onto the resolved workspace branch.
     *
     * <p>If the remote already has the branch, clones with {@code -b}.
     * If the branch is absent on the remote, clones the remote's default
     * branch and creates the workspace branch locally — mirroring what
     * {@code ws:feature-start-publish} would have done if this subproject
     * had been a workspace member at the time. The new branch is left
     * unpushed; a subsequent {@code ws:push} promotes it to origin
     * alongside the workspace.yaml change.
     */
    private void cloneSubproject(Path wsDir) throws MojoException {
        boolean remoteHasBranch = remoteHasBranch(wsDir, repo, branch);

        List<String> cmd = new ArrayList<>();
        cmd.add("git");
        cmd.add("clone");
        cmd.add("--depth");
        cmd.add("1");
        if (remoteHasBranch) {
            cmd.add("-b");
            cmd.add(branch);
        }
        cmd.add(repo);
        cmd.add(subproject);
        ReleaseSupport.exec(wsDir.toFile(), getLog(), cmd.toArray(new String[0]));

        if (!remoteHasBranch) {
            getLog().info("  Remote has no branch '" + branch
                    + "' — creating it locally from the remote's default.");
            ReleaseSupport.exec(wsDir.resolve(subproject).toFile(), getLog(),
                    "git", "checkout", "-b", branch);
        }
    }

    /**
     * Probe whether {@code refs/heads/<branch>} exists on the given remote
     * URL via {@code git ls-remote --heads}. Returns false on any failure
     * (offline, auth, unknown repo) — the caller will surface the real
     * error from the subsequent {@code git clone} attempt.
     */
    private static boolean remoteHasBranch(Path wsDir, String repoUrl, String branch) {
        try {
            ProcessBuilder pb = new ProcessBuilder(
                    "git", "ls-remote", "--heads", repoUrl, branch)
                    .directory(wsDir.toFile())
                    .redirectErrorStream(false);
            Process proc = pb.start();
            String stdout = new String(proc.getInputStream().readAllBytes(),
                    StandardCharsets.UTF_8).trim();
            int exit = proc.waitFor();
            return exit == 0 && !stdout.isEmpty();
        } catch (IOException | InterruptedException e) {
            if (e instanceof InterruptedException) {
                Thread.currentThread().interrupt();
            }
            return false;
        }
    }

    // ── POM-based dependency derivation ────────────────────────

    /**
     * Derive dependencies by scanning the new subproject's POMs for
     * referenced {@code groupId:artifactId} pairs and matching them
     * against the published artifact sets of already-registered
     * workspace subprojects.
     *
     * <p>This is artifact-level matching, not groupId-level — it
     * correctly handles subprojects that share a groupId (e.g.,
     * tinkar-core and tinkar-composer both use {@code dev.ikm.tinkar}).
     */
    static List<DerivedDep> deriveDependencies(Path wsDir, Path manifestPath,
                                                Path subprojectDir, String subprojectName)
            throws IOException {
        // Collect all groupId:artifactId pairs referenced by this subproject
        Set<String> referencedArtifacts = extractReferencedArtifacts(
                subprojectDir.resolve("pom.xml"));
        if (referencedArtifacts.isEmpty()) return null;

        // Read the new subproject's <properties> for version-property detection
        Map<String, String> newSubProperties;
        try {
            DocumentBuilder db = DBF.newDocumentBuilder();
            Document doc = db.parse(subprojectDir.resolve("pom.xml").toFile());
            newSubProperties = readProperties(doc.getDocumentElement());
        } catch (Exception e) {
            newSubProperties = Map.of();
        }

        Manifest manifest = ManifestReader.read(manifestPath);

        List<DerivedDep> matched = new ArrayList<>();
        for (Map.Entry<String, Subproject> entry : manifest.subprojects().entrySet()) {
            String existingName = entry.getKey();
            Subproject existingSub = entry.getValue();

            // Never depend on yourself
            if (existingName.equals(subprojectName)) continue;

            Path existingDir = wsDir.resolve(existingName);
            if (!Files.exists(existingDir.resolve("pom.xml"))) continue;

            // Build the published artifact set for the existing subproject
            Set<PublishedArtifactSet.Artifact> published =
                    PublishedArtifactSet.scan(existingDir);

            // Check if any referenced artifact is published by this subproject
            for (PublishedArtifactSet.Artifact artifact : published) {
                String key = artifact.groupId() + ":" + artifact.artifactId();
                if (referencedArtifacts.contains(key)) {
                    // Try to find a property whose value matches the upstream version
                    String versionProperty = null;
                    String upstreamVersion = existingSub.version();
                    if (upstreamVersion != null && !newSubProperties.isEmpty()) {
                        for (Map.Entry<String, String> prop : newSubProperties.entrySet()) {
                            if (upstreamVersion.equals(prop.getValue())) {
                                versionProperty = prop.getKey();
                                break;
                            }
                        }
                    }
                    matched.add(new DerivedDep(existingName, versionProperty));
                    break;
                }
            }
        }

        return matched.isEmpty() ? null : matched;
    }

    /**
     * Backward resolution: for each existing cloned subproject, check
     * whether its POMs reference any artifact published by the newly
     * added subproject. Uses artifact-level matching via
     * {@link PublishedArtifactSet} to avoid false positives from
     * shared groupIds.
     */
    private int backfillDependencies(Path wsDir, Path manifestPath,
                                     String newSubproject, Path newSubprojectDir)
            throws IOException {
        // Build the published artifact set for the new subproject
        Set<PublishedArtifactSet.Artifact> newPublished =
                PublishedArtifactSet.scan(newSubprojectDir);
        if (newPublished.isEmpty()) return 0;

        // Build a lookup set of "groupId:artifactId" strings
        Set<String> newArtifactKeys = new LinkedHashSet<>();
        for (PublishedArtifactSet.Artifact a : newPublished) {
            newArtifactKeys.add(a.groupId() + ":" + a.artifactId());
        }

        String yaml = Files.readString(manifestPath, StandardCharsets.UTF_8);
        Manifest manifest = ManifestReader.read(manifestPath);
        int updated = 0;

        for (Map.Entry<String, Subproject> entry : manifest.subprojects().entrySet()) {
            String existingName = entry.getKey();
            Subproject existing = entry.getValue();

            // Skip the newly added subproject itself
            if (existingName.equals(newSubproject)) continue;

            // Skip if already depends on the new subproject
            if (existing.dependsOn() != null
                    && existing.dependsOn().stream()
                    .anyMatch(d -> newSubproject.equals(d.subproject()))) {
                continue;
            }

            // Check if this existing subproject references any artifact
            // published by the new subproject
            Path existingPom = wsDir.resolve(existingName).resolve("pom.xml");
            if (!Files.exists(existingPom)) continue;

            Set<String> referenced = extractReferencedArtifacts(existingPom);
            boolean dependsOnNew = referenced.stream()
                    .anyMatch(newArtifactKeys::contains);

            if (!dependsOnNew) continue;

            yaml = addDependencyEdge(yaml, existingName, newSubproject, null);
            updated++;
            getLog().info(Ansi.cyan("  → ") + existingName + " depends on " + newSubproject);
        }

        if (updated > 0) {
            Files.writeString(manifestPath, yaml, StandardCharsets.UTF_8);
        }

        return updated;
    }

    /**
     * Add a depends-on edge for an existing subproject in workspace.yaml.
     * Converts {@code depends-on: []} to a populated list, or appends
     * to an existing list.
     */
    static String addDependencyEdge(String yaml, String subprojectName,
                                    String dependsOnName, String versionProperty) {
        String versionPropertyLine = (versionProperty != null)
                ? "        version-property: " + versionProperty + "\n" : "";

        // Case 1: depends-on: [] — replace with populated entry
        String emptyDeps = "(" + subprojectName + ":[\\s\\S]*?)(depends-on:\\s*\\[])";
        Pattern emptyPattern = Pattern.compile(emptyDeps);
        Matcher emptyMatcher = emptyPattern.matcher(yaml);
        if (emptyMatcher.find()) {
            String replacement = emptyMatcher.group(1)
                    + "depends-on:\n"
                    + "      - subproject: " + dependsOnName + "\n"
                    + "        relationship: build\n"
                    + versionPropertyLine;
            return emptyMatcher.replaceFirst(Matcher.quoteReplacement(replacement));
        }

        // Case 2: existing depends-on list — append before next subproject
        // or section. Find the subproject's depends-on block and add an entry.
        String existingDeps = "(" + subprojectName
                + ":[\\s\\S]*?depends-on:\\n)((?:\\s+- subproject:.*\\n(?:\\s+relationship:.*\\n)(?:\\s+version-property:.*\\n)?)*)";
        Pattern existingPattern = Pattern.compile(existingDeps);
        Matcher existingMatcher = existingPattern.matcher(yaml);
        if (existingMatcher.find()) {
            String replacement = existingMatcher.group(1)
                    + existingMatcher.group(2)
                    + "      - subproject: " + dependsOnName + "\n"
                    + "        relationship: build\n"
                    + versionPropertyLine;
            return existingMatcher.replaceFirst(Matcher.quoteReplacement(replacement));
        }

        return yaml;
    }

    /**
     * Extract all {@code groupId:artifactId} pairs referenced as
     * build dependencies across the entire subproject (root POM +
     * all submodules/subprojects).
     *
     * <p>Uses DOM parsing to correctly read XML structure and
     * resolves Maven property references ({@code ${property.name}})
     * from the POM's {@code <properties>} section.
     *
     * <p>Scans {@code <parent>}, {@code <dependencies>}, BOM imports
     * inside {@code <dependencyManagement>} (entries with both
     * {@code <scope>import</scope>} and {@code <type>pom</type>}), and
     * build {@code <plugins>} (including {@code <pluginManagement>}).
     * Profile-scoped versions of these are scanned as well, since any
     * profile activation makes those dependencies real. Plain
     * version-constraint entries inside {@code <dependencyManagement>}
     * are excluded — they aren't build edges by themselves. (#239)
     *
     * @return set of "groupId:artifactId" strings
     */
    static Set<String> extractReferencedArtifacts(Path pomFile) throws IOException {
        Set<String> artifacts = new LinkedHashSet<>();
        scanPomForArtifacts(pomFile, artifacts);
        return artifacts;
    }

    /**
     * Recursively scan a POM and its submodules for referenced
     * groupId:artifactId pairs using DOM parsing with property
     * resolution.
     */
    static void scanPomForArtifacts(Path pomFile, Set<String> artifacts)
            throws IOException {
        if (!Files.exists(pomFile)) return;

        Document doc;
        try {
            DocumentBuilder db = DBF.newDocumentBuilder();
            doc = db.parse(pomFile.toFile());
        } catch (Exception e) {
            // If we can't parse, skip this POM
            return;
        }

        Element project = doc.getDocumentElement();

        // Read <properties> for ${...} resolution
        Map<String, String> properties = readProperties(project);

        // Scan the project root for parent/deps/BOMs/plugins.
        collectArtifactsFromContainer(project, properties, artifacts);

        // Scan each profile body — a profile's deps/plugins become real
        // when it activates, so they're legitimate build edges.
        Element profilesEl = firstChild(project, "profiles");
        if (profilesEl != null) {
            for (Element profile : children(profilesEl, "profile")) {
                collectArtifactsFromContainer(profile, properties, artifacts);
            }
        }

        // Recurse into subprojects (Maven 4.1.0) and modules (Maven 4.0.0)
        Path pomDir = pomFile.getParent();

        Element subprojects = firstChild(project, "subprojects");
        if (subprojects != null) {
            for (Element sub : children(subprojects, "subproject")) {
                String name = sub.getTextContent().trim();
                scanPomForArtifacts(pomDir.resolve(name).resolve("pom.xml"), artifacts);
            }
        }

        Element modules = firstChild(project, "modules");
        if (modules != null) {
            for (Element mod : children(modules, "module")) {
                String name = mod.getTextContent().trim();
                scanPomForArtifacts(pomDir.resolve(name).resolve("pom.xml"), artifacts);
            }
        }
    }

    /**
     * Pull groupId:artifactId pairs out of a container element — either
     * a {@code <project>} root or a {@code <profile>} body. Reads:
     * <ul>
     *   <li>{@code <parent>}</li>
     *   <li>{@code <dependencies><dependency>}</li>
     *   <li>{@code <dependencyManagement><dependencies><dependency>} —
     *       BOM imports only ({@code <scope>import</scope>} with
     *       {@code <type>pom</type>})</li>
     *   <li>{@code <build><plugins><plugin>}</li>
     *   <li>{@code <build><pluginManagement><plugins><plugin>}</li>
     * </ul>
     *
     * @param container  the {@code <project>} or {@code <profile>} element
     * @param properties resolved {@code <properties>} for {@code ${...}} references
     * @param artifacts  accumulator for discovered "groupId:artifactId" strings
     */
    static void collectArtifactsFromContainer(
            Element container,
            Map<String, String> properties,
            Set<String> artifacts) {

        // <parent> — present on project root; profiles don't allow it.
        addArtifactCoords(firstChild(container, "parent"), properties, artifacts);

        // Direct build dependencies.
        Element depsEl = firstChild(container, "dependencies");
        if (depsEl != null) {
            for (Element dep : children(depsEl, "dependency")) {
                addArtifactCoords(dep, properties, artifacts);
            }
        }

        // BOM imports — entries declared with scope=import + type=pom.
        // Plain version-constraint entries are skipped (they don't bind
        // anything until something else declares the dep).
        Element depMgmt = firstChild(container, "dependencyManagement");
        if (depMgmt != null) {
            Element dmDeps = firstChild(depMgmt, "dependencies");
            if (dmDeps != null) {
                for (Element dep : children(dmDeps, "dependency")) {
                    String scope = resolve(childText(dep, "scope"), properties);
                    String type = resolve(childText(dep, "type"), properties);
                    if ("import".equals(scope) && "pom".equals(type)) {
                        addArtifactCoords(dep, properties, artifacts);
                    }
                }
            }
        }

        // Build plugins — both top-level <plugins> and <pluginManagement>.
        Element build = firstChild(container, "build");
        if (build != null) {
            addPluginCoords(firstChild(build, "plugins"), properties, artifacts);
            Element pluginMgmt = firstChild(build, "pluginManagement");
            if (pluginMgmt != null) {
                addPluginCoords(firstChild(pluginMgmt, "plugins"),
                        properties, artifacts);
            }
        }
    }

    /**
     * Add every {@code <plugin>} child of the given {@code <plugins>}
     * element to the artifact accumulator. No-op if {@code pluginsEl}
     * is null.
     *
     * @param pluginsEl  the {@code <plugins>} element, or null
     * @param properties resolved {@code <properties>} for property substitution
     * @param artifacts  accumulator
     */
    private static void addPluginCoords(Element pluginsEl,
                                        Map<String, String> properties,
                                        Set<String> artifacts) {
        if (pluginsEl == null) return;
        for (Element plugin : children(pluginsEl, "plugin")) {
            addArtifactCoords(plugin, properties, artifacts);
        }
    }

    /**
     * Resolve an element's {@code <groupId>}/{@code <artifactId>} pair
     * (with property substitution) and add it to the accumulator. No-op
     * if {@code el} is null or either coord is missing.
     *
     * @param el         the element with groupId/artifactId children
     * @param properties resolved {@code <properties>} for property substitution
     * @param artifacts  accumulator
     */
    private static void addArtifactCoords(Element el,
                                          Map<String, String> properties,
                                          Set<String> artifacts) {
        if (el == null) return;
        String gid = resolve(childText(el, "groupId"), properties);
        String aid = resolve(childText(el, "artifactId"), properties);
        if (gid != null && aid != null) {
            artifacts.add(gid + ":" + aid);
        }
    }

    // ── DOM helpers ─────────────────────────────────────────────

    /**
     * Extract the first match of a regex pattern's first group.
     * Used by the version alignment code which operates on raw POM text.
     */
    private static String extractFirst(Pattern pattern, String text) {
        Matcher m = pattern.matcher(text);
        return m.find() ? m.group(1).trim() : null;
    }

    private static final javax.xml.parsers.DocumentBuilderFactory DBF;
    static {
        DBF = javax.xml.parsers.DocumentBuilderFactory.newInstance();
        try {
            DBF.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
            DBF.setFeature("http://xml.org/sax/features/external-general-entities", false);
            DBF.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
        } catch (javax.xml.parsers.ParserConfigurationException e) {
            // Non-fatal
        }
    }

    /**
     * Read {@code <properties>} from a POM's project element into
     * a map for {@code ${...}} resolution.
     */
    private static Map<String, String> readProperties(Element project) {
        Map<String, String> props = new LinkedHashMap<>();
        Element propsEl = firstChild(project, "properties");
        if (propsEl != null) {
            org.w3c.dom.NodeList children = propsEl.getChildNodes();
            for (int i = 0; i < children.getLength(); i++) {
                org.w3c.dom.Node node = children.item(i);
                if (node.getNodeType() == org.w3c.dom.Node.ELEMENT_NODE) {
                    String value = node.getTextContent().trim();
                    if (!value.isEmpty()) {
                        props.put(node.getNodeName(), value);
                    }
                }
            }
        }
        return props;
    }

    /**
     * Resolve {@code ${property.name}} references in a string using
     * the given property map. Returns the input unchanged if no
     * property reference is present or if the property is not found.
     */
    private static String resolve(String value, Map<String, String> properties) {
        if (value == null || !value.contains("${")) return value;
        for (Map.Entry<String, String> entry : properties.entrySet()) {
            value = value.replace("${" + entry.getKey() + "}", entry.getValue());
        }
        // If still contains unresolved references, return as-is
        return value;
    }

    /**
     * Get the text content of a direct child element, or null.
     */
    private static String childText(Element parent, String tagName) {
        Element child = firstChild(parent, tagName);
        if (child == null) return null;
        String text = child.getTextContent().trim();
        return text.isEmpty() ? null : text;
    }

    /**
     * Get the first direct child element with the given tag name.
     */
    private static Element firstChild(Element parent, String tagName) {
        org.w3c.dom.NodeList children = parent.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            org.w3c.dom.Node node = children.item(i);
            if (node.getNodeType() == org.w3c.dom.Node.ELEMENT_NODE
                    && tagName.equals(node.getNodeName())) {
                return (Element) node;
            }
        }
        return null;
    }

    /**
     * Get all direct child elements with the given tag name.
     */
    private static List<Element> children(Element parent, String tagName) {
        List<Element> result = new ArrayList<>();
        org.w3c.dom.NodeList children = parent.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            org.w3c.dom.Node node = children.item(i);
            if (node.getNodeType() == org.w3c.dom.Node.ELEMENT_NODE
                    && tagName.equals(node.getNodeName())) {
                result.add((Element) node);
            }
        }
        return result;
    }

    // ── Version alignment ───────────────────────────────────────

    /**
     * Align dependency versions in the newly added subproject's POMs
     * to match workspace SNAPSHOT versions. For each workspace subproject
     * that this subproject depends on, find explicit version declarations
     * and update them.
     *
     * @return the number of POM files modified
     */
    private int alignVersions(Path wsDir, Path subprojectDir,
                               String subprojectName, Manifest manifest)
            throws IOException {
        // Build a map: groupId:artifactId → workspace version
        // for all workspace subprojects (except the one being added)
        Map<String, String> artifactVersions = new LinkedHashMap<>();
        for (Map.Entry<String, Subproject> entry : manifest.subprojects().entrySet()) {
            if (entry.getKey().equals(subprojectName)) continue;
            Subproject sub = entry.getValue();
            if (sub.version() == null) continue;

            Path subDir = wsDir.resolve(entry.getKey());
            if (!Files.exists(subDir.resolve("pom.xml"))) continue;

            Set<PublishedArtifactSet.Artifact> published =
                    PublishedArtifactSet.scan(subDir);
            for (PublishedArtifactSet.Artifact artifact : published) {
                artifactVersions.put(
                        artifact.groupId() + ":" + artifact.artifactId(),
                        sub.version());
            }
        }

        if (artifactVersions.isEmpty()) return 0;

        // Walk all POM files in the new subproject and update versions
        int filesModified = 0;
        List<Path> pomFiles = findAllPomFiles(subprojectDir);

        for (Path pomFile : pomFiles) {
            String original = Files.readString(pomFile, StandardCharsets.UTF_8);
            String updated = alignDependencyVersions(original, artifactVersions);

            if (!updated.equals(original)) {
                Files.writeString(pomFile, updated, StandardCharsets.UTF_8);
                filesModified++;
                // Log each change
                Path relative = subprojectDir.getParent().relativize(pomFile);
                getLog().info("  Version alignment: " + relative);
            }
        }

        return filesModified;
    }

    /**
     * In a POM content string, find {@code <dependency>} blocks that
     * reference a known workspace artifact and update their
     * {@code <version>} to the workspace version. Skips dependencies
     * inside {@code <dependencyManagement>}.
     */
    static String alignDependencyVersions(String pom,
                                            Map<String, String> artifactVersions) {
        // Strip dependencyManagement to avoid modifying BOM imports
        // We'll process only dependencies outside of dependencyManagement
        StringBuilder result = new StringBuilder();
        Matcher dmMatcher = DEP_MGMT_BLOCK.matcher(pom);
        int lastEnd = 0;

        while (dmMatcher.find()) {
            // Process the segment before this dependencyManagement block
            String segment = pom.substring(lastEnd, dmMatcher.start());
            result.append(alignDepsInSegment(segment, artifactVersions));
            // Append the dependencyManagement block unchanged
            result.append(dmMatcher.group());
            lastEnd = dmMatcher.end();
        }
        // Process remaining content after last dependencyManagement
        result.append(alignDepsInSegment(pom.substring(lastEnd), artifactVersions));

        return result.toString();
    }

    private static String alignDepsInSegment(String segment,
                                              Map<String, String> artifactVersions) {
        Matcher depMatcher = DEPENDENCY_BLOCK.matcher(segment);
        StringBuilder sb = new StringBuilder();
        int lastEnd = 0;

        while (depMatcher.find()) {
            sb.append(segment, lastEnd, depMatcher.start());

            String depBlock = depMatcher.group();
            String gid = extractFirst(GROUP_ID_PATTERN, depBlock);
            String aid = extractFirst(ARTIFACT_ID_PATTERN, depBlock);
            String key = gid + ":" + aid;

            String targetVersion = artifactVersions.get(key);
            if (targetVersion != null) {
                // Update the version in this dependency block
                String currentVersion = extractFirst(VERSION_PATTERN, depBlock);
                if (currentVersion != null && !currentVersion.equals(targetVersion)) {
                    depBlock = depBlock.replaceFirst(
                            "<version>" + Pattern.quote(currentVersion) + "</version>",
                            "<version>" + targetVersion + "</version>");
                }
            }

            sb.append(depBlock);
            lastEnd = depMatcher.end();
        }

        sb.append(segment.substring(lastEnd));
        return sb.toString();
    }

    /**
     * Find all pom.xml files in a subproject directory (root + submodules).
     */
    private List<Path> findAllPomFiles(Path subprojectDir) throws IOException {
        try (var stream = Files.walk(subprojectDir)) {
            return stream
                    .filter(p -> p.getFileName().toString().equals("pom.xml"))
                    .filter(p -> !p.toString().contains("/target/"))
                    .toList();
        }
    }

    private static final Pattern VERSION_PATTERN =
            Pattern.compile("<version>([^<]+)</version>");

    /**
     * Derive the Maven groupId from the subproject's root POM.
     * Strips the parent block first; if no groupId is declared
     * outside parent, falls back to the parent's groupId.
     */
    private String deriveGroupId(Path subprojectDir) {
        Path pomFile = subprojectDir.resolve("pom.xml");
        if (!Files.exists(pomFile)) return null;

        try {
            String content = Files.readString(pomFile, StandardCharsets.UTF_8);

            // Try groupId outside parent block first
            String stripped = PARENT_BLOCK.matcher(content).replaceFirst("");
            Matcher gm = GROUP_ID_PATTERN.matcher(stripped);
            if (gm.find()) return gm.group(1).trim();

            // Fall back to parent groupId
            Matcher parentBlock = PARENT_BLOCK.matcher(content);
            if (parentBlock.find()) {
                gm = GROUP_ID_PATTERN.matcher(parentBlock.group());
                if (gm.find()) return gm.group(1).trim();
            }
        } catch (IOException e) {
            // Non-fatal — groupId will be null in manifest
        }
        return null;
    }

    private static final Pattern PARENT_BLOCK =
            Pattern.compile("(?s)<parent>.*?</parent>");
    private static final Pattern GROUP_ID_PATTERN =
            Pattern.compile("<groupId>([^<]+)</groupId>");
    private static final Pattern ARTIFACT_ID_PATTERN =
            Pattern.compile("<artifactId>([^<]+)</artifactId>");
    private static final Pattern DEPENDENCY_BLOCK =
            Pattern.compile("(?s)<dependency>.*?</dependency>");
    private static final Pattern DEP_MGMT_BLOCK =
            Pattern.compile("(?s)<dependencyManagement>.*?</dependencyManagement>");
    private static final Pattern SUBPROJECT_PATTERN =
            Pattern.compile("<subproject>([^<]+)</subproject>");
    private static final Pattern MODULE_PATTERN =
            Pattern.compile("<module>([^<]+)</module>");

    // ── Helpers ──────────────────────────────────────────────────

    /**
     * Derive a subproject name from a git URL.
     * {@code https://github.com/ikmdev/tinkar-core.git} → {@code tinkar-core}
     */
    static String deriveSubprojectName(String repoUrl) {
        String name = repoUrl;
        // Strip trailing .git
        if (name.endsWith(".git")) {
            name = name.substring(0, name.length() - 4);
        }
        // Strip trailing slash
        if (name.endsWith("/")) {
            name = name.substring(0, name.length() - 1);
        }
        // Take last path segment
        int lastSlash = name.lastIndexOf('/');
        if (lastSlash >= 0) {
            name = name.substring(lastSlash + 1);
        }
        return name;
    }

    private String readWorkspaceName(Path wsDir) {
        try {
            return ReleaseSupport.readPomArtifactId(wsDir.resolve("pom.xml").toFile());
        } catch (MojoException e) {
            return "Workspace";
        }
    }

    /**
     * Apply ike-issues#324 parent-coherence rules to a freshly-added
     * subproject. Warning-only at add time — the matching check in
     * {@code ws:scaffold-draft} (which folds verify per #393) is the
     * authoritative gate.
     *
     * @param wsDir          workspace root directory
     * @param subprojectDir  subproject's checked-out directory
     * @param subprojectName subproject name (workspace.yaml key)
     * @param parentInfo     subproject's declared parent
     */
    private void checkParentCoherenceAtAdd(Path wsDir,
                                            Path subprojectDir,
                                            String subprojectName,
                                            PomParentSupport.ParentInfo parentInfo) {
        PomParentSupport.ParentInfo wsParent;
        try {
            wsParent = PomParentSupport.readParent(wsDir.resolve("pom.xml"));
        } catch (Exception e) {
            // Workspace pom unreadable or no parent — nothing to enforce.
            return;
        }
        if (wsParent == null
                || wsParent.groupId() == null
                || wsParent.artifactId() == null) {
            return;
        }

        // Decision matrix gate: same GA as workspace's parent?
        if (!java.util.Objects.equals(wsParent.groupId(), parentInfo.groupId())
                || !java.util.Objects.equals(wsParent.artifactId(),
                        parentInfo.artifactId())) {
            return;
        }

        String coords = parentInfo.groupId() + ":" + parentInfo.artifactId();

        // Rule 2: version coherence
        if (!java.util.Objects.equals(wsParent.version(), parentInfo.version())) {
            getLog().warn("  WARN: " + subprojectName + " parent "
                    + coords + ":" + parentInfo.version()
                    + " != workspace " + coords + ":" + wsParent.version()
                    + " (#324 coherence violation — fix before "
                    + "ws:release-publish or expect ws:scaffold-draft "
                    + "to flag it)");
            return;
        }

        // Rule 1: cycle prevention
        boolean hasEmptyRelativePath;
        try {
            hasEmptyRelativePath = PomParentSupport.hasEmptyRelativePath(
                    subprojectDir.resolve("pom.xml"));
        } catch (Exception e) {
            return;
        }
        if (!hasEmptyRelativePath) {
            getLog().warn("  WARN: " + subprojectName + " parent "
                    + coords + ":" + parentInfo.version()
                    + " matches workspace parent but is missing empty "
                    + "<relativePath/> (#324 cycle prevention — Maven 4 "
                    + "will fail with \"parents form a cycle\" once the "
                    + "subproject participates in the reactor; add "
                    + "<relativePath/> inside the <parent> block)");
        }
    }

    private Path findWorkspaceRoot() throws MojoException {
        Path dir = Path.of(System.getProperty("user.dir"));
        while (dir != null) {
            if (Files.exists(dir.resolve("workspace.yaml"))) {
                return dir;
            }
            dir = dir.getParent();
        }
        throw new MojoException(
                "Cannot find workspace.yaml. Run from within a workspace "
                + "directory or use ws:scaffold-init first.");
    }
}