FeatureFinishSupport.java

package network.ike.plugin.ws;

import network.ike.plugin.PomRewriter;

import network.ike.plugin.ReleaseSupport;

import network.ike.workspace.Subproject;
import network.ike.workspace.ManifestWriter;
import network.ike.workspace.VersionSupport;
import network.ike.workspace.WorkspaceGraph;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.plugin.ws.vcs.VcsState;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.Log;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;

/**
 * Shared logic for feature-finish goals (squash, merge, rebase).
 *
 * <p>Each strategy goal delegates to this class for validation,
 * version stripping, workspace.yaml updates, branch deletion,
 * and state file writing. The actual merge operation is performed
 * by the strategy goal itself.
 */
class FeatureFinishSupport {

    private FeatureFinishSupport() {}

    /**
     * Detect the feature branch name from subproject branches.
     * If all subprojects on a feature branch agree on the name,
     * returns it. Also checks the workspace root branch.
     *
     * @param root       workspace root directory
     * @param components subproject names to scan
     * @param mojo       the calling mojo (for gitBranch access)
     * @param log        Maven logger
     * @return the detected feature name (without "feature/" prefix)
     * @throws MojoException if no feature branch is detected
     */
    static String detectFeature(File root, List<String> components,
                                 AbstractWorkspaceMojo mojo, Log log)
            throws MojoException {
        Set<String> features = new TreeSet<>();

        // Check workspace root branch
        if (new File(root, ".git").exists()) {
            String wsBranch = mojo.gitBranch(root);
            if (wsBranch.startsWith("feature/")) {
                features.add(wsBranch.substring("feature/".length()));
            }
        }

        // Check subproject branches
        for (String name : components) {
            File dir = new File(root, name);
            if (!new File(dir, ".git").exists()) continue;
            String branch = mojo.gitBranch(dir);
            if (branch.startsWith("feature/")) {
                features.add(branch.substring("feature/".length()));
            }
        }

        if (features.isEmpty()) {
            throw new MojoException(
                    "No components are on a feature branch. "
                    + "Specify -Dfeature=<name> or switch to a feature branch.");
        }

        if (features.size() == 1) {
            String detected = features.iterator().next();
            log.info("  Detected feature: " + detected);
            return detected;
        }

        // Multiple features — list them for the user
        throw new MojoException(
                "Multiple feature branches detected: " + features
                + ". Specify -Dfeature=<name> to disambiguate.");
    }

    /**
     * Validate that a subproject is eligible for feature-finish.
     *
     * <p>Checks three consistency requirements:
     * <ol>
     *   <li>The git working tree must be on the expected feature branch</li>
     *   <li>The workspace.yaml branch field must agree with git</li>
     *   <li>The working tree must have no uncommitted changes</li>
     * </ol>
     *
     * <p>A mismatch between git and workspace.yaml indicates that
     * branches were switched outside the {@code ws:} workflow, which
     * is not supported. The build fails with a diagnostic rather than
     * silently proceeding with inconsistent state.
     *
     * @param root       workspace root directory
     * @param name       subproject name
     * @param branchName expected git branch (e.g., "feature/my-work")
     * @param subproject  the workspace.yaml subproject record
     * @param mojo       the calling mojo (for git operations)
     * @return null if eligible, "MODIFIED" for uncommitted changes,
     *         or a descriptive skip/error reason string
     */
    static String validateComponent(File root, String name, String branchName,
                                     Subproject subproject,
                                     AbstractWorkspaceMojo mojo) {
        File dir = new File(root, name);
        File gitDir = new File(dir, ".git");

        if (!gitDir.exists()) {
            return "not cloned";
        }

        String currentBranch = mojo.gitBranch(dir);
        if (!currentBranch.equals(branchName)) {
            return "on " + currentBranch + ", not " + branchName;
        }

        // Verify workspace.yaml agrees with git — a mismatch means
        // branches were switched outside the ws: workflow.
        String yamlBranch = subproject.branch();
        if (yamlBranch != null && !yamlBranch.equals(currentBranch)) {
            return "INCONSISTENT: git is on " + currentBranch
                    + " but workspace.yaml says " + yamlBranch
                    + " — resolve with ws:feature-start or update workspace.yaml";
        }

        String status = mojo.gitStatus(dir);
        if (!status.isEmpty()) {
            return "MODIFIED";  // Caller should throw
        }

        return null;
    }

    /**
     * Generate a structured commit message by aggregating per-subproject
     * commit history from the feature branch.
     */
    static String generateFeatureMessage(File root, List<String> components,
                                          String branchName, String targetBranch,
                                          String userMessage, Log log) {
        var sb = new StringBuilder();
        if (userMessage != null && !userMessage.isBlank()) {
            sb.append(userMessage).append("\n\n");
        }
        sb.append(branchName).append("\n");

        for (String name : components) {
            File dir = new File(root, name);
            try {
                List<String> commits = VcsOperations.commitLog(
                        dir, targetBranch, branchName);
                if (commits.isEmpty()) continue;
                sb.append("\n## ").append(name)
                  .append(" (").append(commits.size()).append(" commit")
                  .append(commits.size() == 1 ? "" : "s").append(")\n");
                for (String line : commits) {
                    String msg = line.contains(" ")
                            ? line.substring(line.indexOf(' ') + 1) : line;
                    sb.append("- ").append(msg).append("\n");
                }
            } catch (MojoException e) {
                log.debug("Could not get log for " + name + ": " + e.getMessage());
            }
        }

        // Workspace repo changes
        try {
            List<String> wsCommits = VcsOperations.commitLog(
                    root, targetBranch, branchName);
            if (!wsCommits.isEmpty()) {
                sb.append("\n## workspace (").append(wsCommits.size())
                  .append(" commit").append(wsCommits.size() == 1 ? "" : "s")
                  .append(")\n");
                for (String line : wsCommits) {
                    String msg = line.contains(" ")
                            ? line.substring(line.indexOf(' ') + 1) : line;
                    sb.append("- ").append(msg).append("\n");
                }
            }
        } catch (MojoException e) {
            log.debug("Could not get workspace log: " + e.getMessage());
        }

        return sb.toString().stripTrailing();
    }

    /**
     * Strip branch-qualified version back to base SNAPSHOT.
     * Returns the base version, or null if no stripping was needed.
     */
    static String stripBranchVersion(File dir, Subproject subproject,
                                      String branchName, Log log)
            throws MojoException {
        // Read actual version from POM on disk — workspace.yaml may be stale
        // if the branch update commit failed (#83).
        String currentVersion = readCurrentVersion(dir, log);
        String qualifier = qualifierFromBranch(branchName);
        if (currentVersion == null
                || !containsBranchQualifier(currentVersion, qualifier)) {
            return null;
        }

        String baseVersion = stripQualifier(currentVersion, qualifier);

        log.info("    version: " + currentVersion + " → " + baseVersion);
        setAllVersions(dir, currentVersion, baseVersion, log);

        // Also strip any other branch-qualified versions in the POM tree
        // (BOM imports, version properties, etc. set by cascadeBomProperties
        // and cascadeBomImports during feature-start).
        stripAllBranchQualifiedVersions(dir, qualifier, log);

        ReleaseSupport.exec(dir, log, "git", "add", "-A");
        ReleaseSupport.exec(dir, log, "git", "commit", "-m",
                "merge-prep: strip branch qualifier → " + baseVersion);

        return baseVersion;
    }

    /**
     * Strip branch-qualified version in bare mode.
     */
    static String stripBranchVersionBare(File dir, String branchName, Log log)
            throws MojoException {
        File pom = new File(dir, "pom.xml");
        if (!pom.exists()) return null;

        String currentVersion;
        try {
            currentVersion = ReleaseSupport.readPomVersion(pom);
        } catch (MojoException e) {
            return null;
        }

        String qualifier = qualifierFromBranch(branchName);
        if (currentVersion == null
                || !containsBranchQualifier(currentVersion, qualifier)) {
            return null;
        }

        String baseVersion = stripQualifier(currentVersion, qualifier);

        log.info("  Version: " + currentVersion + " → " + baseVersion);
        setAllVersions(dir, currentVersion, baseVersion, log);
        stripAllBranchQualifiedVersions(dir, qualifier, log);
        ReleaseSupport.exec(dir, log, "git", "add", "-A");
        ReleaseSupport.exec(dir, log, "git", "commit", "-m",
                "merge-prep: strip branch qualifier → " + baseVersion);

        return baseVersion;
    }

    /**
     * Delete feature branch locally and remotely.
     */
    static void deleteBranch(File dir, Log log, String branchName)
            throws MojoException {
        VcsOperations.deleteBranch(dir, log, branchName);
        log.info("    deleted local branch: " + branchName);

        Optional<String> remoteSha = VcsOperations.remoteSha(dir, "origin", branchName);
        if (remoteSha.isPresent()) {
            VcsOperations.deleteRemoteBranch(dir, log, "origin", branchName);
            log.info("    deleted remote branch: origin/" + branchName);
        } else {
            log.info("    remote branch origin/" + branchName
                    + " does not exist (never pushed) — skipping");
        }
    }

    /**
     * Clean up feature branch snapshot sites.
     */
    static void cleanFeatureSites(File root, List<String> components,
                                    String branchName, Log log) {
        String featurePath = ReleaseSupport.branchToSitePath(branchName);
        for (String name : components) {
            String siteDisk = ReleaseSupport.siteDiskPath(
                    name, "snapshot", featurePath);
            try {
                ReleaseSupport.cleanRemoteSiteDir(
                        new File(root, name), log, siteDisk);
            } catch (MojoException e) {
                log.debug("No snapshot site to clean for " + name
                        + ": " + e.getMessage());
            }
        }
    }

    /**
     * Update workspace.yaml branch fields back to targetBranch and commit.
     */
    static void updateWorkspaceYaml(Path manifestPath, List<String> components,
                                      String targetBranch, String feature,
                                      Log log) {
        try {
            Map<String, String> updates = new LinkedHashMap<>();
            for (String name : components) {
                updates.put(name, targetBranch);
            }
            ManifestWriter.updateBranches(manifestPath, updates);
            log.info("  Updated workspace.yaml branches → " + targetBranch);

            File wsRoot = manifestPath.getParent().toFile();
            File wsGit = new File(wsRoot, ".git");
            if (wsGit.exists()) {
                ReleaseSupport.exec(wsRoot, log, "git", "add", "workspace.yaml");
                if (VcsOperations.hasStagedChanges(wsRoot)) {
                    ReleaseSupport.exec(wsRoot, log, "git", "commit", "-m",
                            "workspace: restore branches to " + targetBranch
                                    + " after feature/" + feature);
                }
            }
        } catch (IOException | MojoException e) {
            log.warn("  Could not update workspace.yaml: " + e.getMessage());
        }
    }

    /**
     * Merge the workspace aggregator repo from the feature branch to the
     * target branch. Mirrors the per-subproject merge: checkout target,
     * no-ff merge, push.
     */
    static void mergeWorkspaceRepo(Path manifestPath, String branchName,
                                     String targetBranch, boolean keepBranch,
                                     boolean push, Log log)
            throws MojoException {
        File wsRoot = manifestPath.getParent().toFile();
        if (!new File(wsRoot, ".git").exists()) return;

        String wsBranch = null;
        try {
            wsBranch = VcsOperations.currentBranch(wsRoot);
        } catch (MojoException e) {
            return;
        }

        if (wsBranch != null && wsBranch.equals(branchName)) {
            log.info("  Merging workspace repo: " + branchName + " → " + targetBranch);
            VcsOperations.checkout(wsRoot, log, targetBranch);
            VcsOperations.mergeNoFf(wsRoot, log, branchName,
                    "Merge " + branchName + " into " + targetBranch);
            if (push) {
                VcsOperations.pushIfRemoteExists(wsRoot, log, "origin", targetBranch);
            }
        }

        if (!keepBranch) {
            try {
                deleteBranch(wsRoot, log, branchName);
            } catch (MojoException e) {
                log.warn("  Could not delete ws branch: " + e.getMessage());
            }
        }

        // Write state file for ws
        if (VcsState.isIkeManaged(wsRoot.toPath())) {
            VcsOperations.writeVcsState(wsRoot, VcsState.Action.FEATURE_FINISH);
        }
    }

    /**
     * Scan for stale feature branches across all subprojects and offer
     * interactive cleanup after a successful feature-finish.
     *
     * <p>Stale branches are feature branches that are fully merged into
     * the target branch and are not the branch just finished. The
     * interactive prompt defaults to "no" so unattended/batch runs
     * leave stale branches in place rather than deleting them
     * silently.
     *
     * @param root           workspace root directory
     * @param components     subproject names to scan
     * @param finishedBranch the branch that was just finished (excluded
     *                       from the stale list)
     * @param targetBranch   the merge target (e.g., "main")
     * @param prompter       prompter for the cleanup confirmation
     * @param log            Maven logger
     */
    static void promptStaleBranchCleanup(File root, List<String> components,
                                           String finishedBranch, String targetBranch,
                                           network.ike.plugin.support.IkePrompter prompter,
                                           Log log) {
        // Collect stale branches across all subprojects
        Map<String, List<String>> staleBranches = new LinkedHashMap<>();
        for (String name : components) {
            File dir = new File(root, name);
            if (!new File(dir, ".git").exists()) continue;

            List<String> merged = VcsOperations.mergedBranches(
                    dir, targetBranch, "feature/");
            List<String> stale = merged.stream()
                    .filter(b -> !b.equals(finishedBranch))
                    .toList();
            if (!stale.isEmpty()) {
                staleBranches.put(name, stale);
            }
        }

        if (staleBranches.isEmpty()) return;

        // Collect unique branch names with last-commit dates
        Set<String> uniqueBranches = new TreeSet<>();
        staleBranches.values().forEach(uniqueBranches::addAll);

        log.info("");
        log.info("  Stale feature branches (merged into " + targetBranch + "):");
        for (String branch : uniqueBranches) {
            // Get date from first subproject that has it
            String date = "unknown";
            for (var entry : staleBranches.entrySet()) {
                if (entry.getValue().contains(branch)) {
                    date = VcsOperations.branchLastCommitDate(
                            new File(root, entry.getKey()), branch);
                    break;
                }
            }
            int subprojectCount = (int) staleBranches.values().stream()
                    .filter(list -> list.contains(branch))
                    .count();
            log.info("    " + branch + " (" + subprojectCount
                    + " subproject" + (subprojectCount == 1 ? "" : "s")
                    + ", last commit: " + date + ")");
        }

        // Prompt for deletion. Default "no" so non-interactive runs
        // (batch mode) leave the branches in place.
        log.info("");
        String prompt = "Delete " + uniqueBranches.size() + " stale branch"
                + (uniqueBranches.size() == 1 ? "" : "es") + "?";

        boolean delete = prompter != null && prompter.confirm(prompt, false);

        if (delete) {
            for (var entry : staleBranches.entrySet()) {
                File dir = new File(root, entry.getKey());
                for (String branch : entry.getValue()) {
                    try {
                        VcsOperations.deleteBranch(dir, log, branch);
                        log.info("    deleted: " + entry.getKey() + "/" + branch);
                    } catch (MojoException e) {
                        log.warn("    could not delete " + entry.getKey()
                                + "/" + branch + ": " + e.getMessage());
                    }
                }
            }
            log.info("  Stale branches cleaned up.");
        } else {
            log.info("  Skipping stale branch cleanup.");
        }
    }

    // ── Post-merge qualifier guard ────────────────────────────────

    /**
     * Verify that no branch-qualified versions remain in the subproject's
     * POM tree after merging to the target branch. If any are found, they
     * are auto-stripped and committed as a fixup.
     *
     * <p>This guards against contamination when the merge-prep strip was
     * incomplete (e.g., some POMs were missed) or when commits were
     * cherry-picked outside the {@code ws:} workflow.
     *
     * @param dir        the subproject directory (now on the target branch)
     * @param branchName the feature branch that was just merged
     * @param log        Maven logger
     * @throws MojoException if POM files cannot be scanned or committed
     */
    static void verifyAndFixQualifiers(File dir, String branchName, Log log)
            throws MojoException {
        String qualifier = qualifierFromBranch(branchName);
        if (qualifier == null) return;

        List<File> allPoms = ReleaseSupport.findPomFiles(dir);
        List<String> contaminated = new ArrayList<>();

        // Match only Maven version coordinates that still carry the
        // branch qualifier — i.e. <digits>(.<digits>)*-<qualifier>-SNAPSHOT.
        // The previous content.contains("-" + qualifier + "-") check
        // also flagged legitimate artifactIds, groupIds, and property
        // names that happened to share the qualifier substring
        // (ike-issues#292).
        Pattern qualifiedVersion = Pattern.compile(
                "\\b\\d+(?:\\.\\d+)*-" + Pattern.quote(qualifier) + "-SNAPSHOT\\b");

        for (File pom : allPoms) {
            try {
                String content = Files.readString(pom.toPath(), StandardCharsets.UTF_8);
                if (qualifiedVersion.matcher(content).find()) {
                    contaminated.add(dir.toPath().relativize(pom.toPath()).toString());
                }
            } catch (IOException e) {
                log.warn("    Could not read " + pom + ": " + e.getMessage());
            }
        }

        if (contaminated.isEmpty()) return;

        log.warn("    Post-merge guard: " + contaminated.size()
                + " POM(s) still contain branch qualifier '" + qualifier + "'");
        for (String path : contaminated) {
            log.warn("      " + path);
        }

        // Auto-strip and commit
        stripAllBranchQualifiedVersions(dir, qualifier, log);

        // Also strip the artifact version if still qualified
        String currentVersion = readCurrentVersion(dir, log);
        if (currentVersion != null
                && containsBranchQualifier(currentVersion, qualifier)) {
            String baseVersion = stripQualifier(currentVersion, qualifier);
            setAllVersions(dir, currentVersion, baseVersion, log);
            log.info("    Auto-fixed: " + currentVersion + " → " + baseVersion);
        }

        ReleaseSupport.exec(dir, log, "git", "add", "-A");
        String status = ReleaseSupport.execCapture(dir,
                "git", "status", "--porcelain");
        if (!status.isEmpty()) {
            ReleaseSupport.exec(dir, log, "git", "commit", "-m",
                    "fixup: strip residual branch qualifier '"
                            + qualifier + "' after merge");
            log.info("    Auto-fixed and committed qualifier cleanup");
        }
    }

    /**
     * Scan a subproject directory for any version strings containing a
     * branch qualifier. Returns the list of POM-relative paths that
     * are contaminated, or an empty list if clean.
     *
     * <p>This is a read-only check suitable for use in verification
     * goals or draft modes.
     *
     * @param dir        the subproject directory
     * @param qualifier  the branch qualifier to search for
     * @return list of relative POM paths containing the qualifier
     */
    static List<String> findQualifierContamination(File dir, String qualifier) {
        List<String> contaminated = new ArrayList<>();
        if (qualifier == null) return contaminated;

        List<File> allPoms;
        try {
            allPoms = ReleaseSupport.findPomFiles(dir);
        } catch (MojoException e) {
            return contaminated;
        }

        for (File pom : allPoms) {
            try {
                String content = Files.readString(pom.toPath(), StandardCharsets.UTF_8);
                if (content.contains("-" + qualifier + "-")) {
                    contaminated.add(dir.toPath().relativize(pom.toPath()).toString());
                }
            } catch (IOException ignored) {
            }
        }

        return contaminated;
    }

    // ── Internal helpers ─────────────────────────────────────────

    private static String readCurrentVersion(File dir, Log log) {
        try {
            return ReleaseSupport.readPomVersion(new File(dir, "pom.xml"));
        } catch (MojoException e) {
            log.warn("    Could not read version from " + dir.getName()
                    + "/pom.xml: " + e.getMessage());
            return null;
        }
    }

    /**
     * Derive the version qualifier from a feature branch name.
     * For example, {@code "feature/search-provider-diagnostics"}
     * yields {@code "search-provider-diagnostics"} via
     * {@link VersionSupport#safeBranchName(String)}.
     *
     * <p>This is intentionally derived from the branch name rather
     * than parsed from the version string, because version strings
     * may use non-semver schemes (date-based, single-segment, etc.)
     * where structural parsing of the numeric/qualifier boundary
     * is ambiguous.
     *
     * @param branchName the full branch name (e.g., "feature/my-work")
     * @return the qualifier as it appears in version strings
     */
    private static String qualifierFromBranch(String branchName) {
        return VersionSupport.safeBranchName(branchName);
    }

    /**
     * Check whether a version string contains the given branch qualifier.
     * Matches versions of any numeric depth (single-segment, semver,
     * or otherwise) — never assumes a specific version scheme.
     *
     * @param version   version string to test
     * @param qualifier the branch qualifier to look for
     * @return true if the version is a SNAPSHOT containing the qualifier
     */
    private static boolean containsBranchQualifier(String version, String qualifier) {
        return version != null
                && version.endsWith("-SNAPSHOT")
                && version.contains("-" + qualifier + "-");
    }

    /**
     * Strip the branch qualifier from a version, returning the base SNAPSHOT.
     *
     * @param version   branch-qualified version
     * @param qualifier the qualifier to strip
     * @return base SNAPSHOT version
     */
    private static String stripQualifier(String version, String qualifier) {
        return version.replace("-" + qualifier + "-SNAPSHOT", "-SNAPSHOT");
    }

    /**
     * Scan all POM files in a subproject for version strings containing
     * the given branch qualifier and strip them back to base SNAPSHOT.
     * This reverses the cascade done by feature-start (BOM properties,
     * BOM imports, version properties).
     *
     * <p>Uses {@link PomModel} (Maven 4 model API) to identify
     * qualified versions in properties, dependencies, and parent
     * blocks, then applies corrections via {@link PomRewriter}
     * (OpenRewrite LST) for lossless edits. Only versions containing
     * the specific branch qualifier are modified — other non-numeric
     * suffixes (e.g., {@code rc1}, {@code beta}) are left untouched.
     *
     * <p>No assumption is made about the numeric version scheme:
     * single-segment ({@code 92}), two-segment ({@code 1.0}), semver
     * ({@code 3.0.7}), and deeper schemes all work identically.
     *
     * @param dir       the subproject directory containing POM files
     * @param qualifier the branch qualifier to strip (e.g., "search-provider-diagnostics")
     * @param log       Maven logger
     * @throws MojoException if POM files cannot be located
     */
    private static void stripAllBranchQualifiedVersions(File dir,
                                                         String qualifier,
                                                         Log log)
            throws MojoException {
        if (qualifier == null) return;

        List<File> allPoms = ReleaseSupport.findPomFiles(dir);

        for (File pom : allPoms) {
            try {
                PomModel model = PomModel.parse(pom.toPath());
                String content = model.content();
                String updated = content;

                // Strip qualified properties
                for (var entry : model.properties().entrySet()) {
                    String value = entry.getValue();
                    if (containsBranchQualifier(value, qualifier)) {
                        String base = stripQualifier(value, qualifier);
                        updated = PomModel.updateProperty(
                                updated, entry.getKey(), base);
                        log.debug("    property " + entry.getKey()
                                + ": " + value + " → " + base
                                + " in " + pom.getName());
                    }
                }

                // Strip qualified dependencies (including BOM imports)
                for (var dep : model.allDependencies()) {
                    String version = dep.getVersion();
                    if (containsBranchQualifier(version, qualifier)) {
                        String base = stripQualifier(version, qualifier);
                        updated = PomModel.updateDependencyVersion(
                                updated, dep.getGroupId(),
                                dep.getArtifactId(), base);
                        log.debug("    dependency " + dep.getGroupId()
                                + ":" + dep.getArtifactId()
                                + ": " + version + " → " + base
                                + " in " + pom.getName());
                    }
                }

                // Strip qualified parent version (#241: match full GA)
                var parent = model.parent();
                if (parent != null
                        && containsBranchQualifier(parent.getVersion(), qualifier)) {
                    String base = stripQualifier(parent.getVersion(), qualifier);
                    updated = PomModel.updateParentVersion(
                            updated, parent.getGroupId(),
                            parent.getArtifactId(), base);
                    log.debug("    parent " + parent.getGroupId() + ":"
                            + parent.getArtifactId()
                            + ": " + parent.getVersion() + " → " + base
                            + " in " + pom.getName());
                }

                if (!updated.equals(content)) {
                    Files.writeString(pom.toPath(), updated, StandardCharsets.UTF_8);
                }
            } catch (IOException e) {
                log.warn("    Could not strip versions in " + pom + ": "
                        + e.getMessage());
            }
        }
    }

    static void setAllVersions(File dir, String oldVersion, String newVersion,
                                 Log log) throws MojoException {
        File pom = new File(dir, "pom.xml");
        ReleaseSupport.setPomVersion(pom, oldVersion, newVersion);

        List<File> allPoms = ReleaseSupport.findPomFiles(dir);
        for (File subPom : allPoms) {
            if (subPom.equals(pom)) continue;
            try {
                String content = Files.readString(subPom.toPath(), StandardCharsets.UTF_8);
                if (content.contains("<version>" + oldVersion + "</version>")) {
                    String updated = content.replace(
                            "<version>" + oldVersion + "</version>",
                            "<version>" + newVersion + "</version>");
                    Files.writeString(subPom.toPath(), updated, StandardCharsets.UTF_8);
                }
            } catch (IOException e) {
                log.warn("    Could not update " + subPom + ": " + e.getMessage());
            }
        }
    }
}