ReleaseSupport.java

package network.ike.plugin;

import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.Log;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.nio.file.FileVisitResult;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;

/**
 * Shared utilities for release mojos.
 *
 * <p>All subprocess invocations use {@link ProcessBuilder} — no
 * library dependencies beyond the JDK and maven-plugin-api.
 */
public class ReleaseSupport {

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

    private ReleaseSupport() {}

    /**
     * Check if the current platform is macOS.
     *
     * @return {@code true} if running on macOS or Darwin
     */
    public static boolean isMacOS() {
        String osName = System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT);
        return osName.contains("mac") || osName.contains("darwin");
    }

    /**
     * Run a command, inherit IO so output streams to the Maven console.
     * Throws on non-zero exit code.
     *
     * @param workDir working directory for the subprocess
     * @param log     Maven logger for output routing
     * @param command the command and arguments to execute
     * @throws MojoException if the command exits non-zero or cannot be started
     */
    public static void exec(File workDir, Log log, String... command)
            throws MojoException {
        log.debug("» " + String.join(" ", command));
        try {
            Process proc = new ProcessBuilder(command)
                    .directory(workDir)
                    .redirectErrorStream(true)
                    .start();
            // Route subprocess output through Maven's logger, stripping
            // Maven log prefixes to avoid redundant [INFO] [stdout] [INFO].
            // Maps subprocess [WARNING]/[ERROR] to the correct parent level.
            try (var reader = new java.io.BufferedReader(
                    new java.io.InputStreamReader(proc.getInputStream()))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    routeSubprocessLine(log, line);
                }
            }
            int exit = proc.waitFor();
            if (exit != 0) {
                throw new MojoException(
                        "Command failed (exit " + exit + "): " +
                                String.join(" ", command));
            }
        } catch (IOException | InterruptedException e) {
            throw new MojoException(
                    "Failed to execute: " + String.join(" ", command), e);
        }
    }

    /**
     * Route a subprocess output line through Maven's logger at the
     * correct level. Strips Maven log prefixes ([INFO], [WARNING],
     * [ERROR]) from the line to avoid redundant nesting.
     *
     * @param log  Maven logger
     * @param line raw subprocess output line
     */
    public static void routeSubprocessLine(Log log, String line) {
        routeSubprocessLine(log, line, "");
    }

    /**
     * Route a subprocess output line through Maven's logger with a prefix.
     *
     * <p>Recognized prefixes are stripped and routed to the matching
     * Maven log level. Unrecognized lines are routed to {@code info}
     * so that subprocess activity (especially {@code git} operations
     * during release) is visible in the build log.
     *
     * <p>Specific git error patterns ({@code fatal:}, {@code error:},
     * {@code remote: error:}, {@code ! [rejected]}, {@code ! [remote rejected]})
     * are detected explicitly and routed to {@code error} so they
     * cannot be missed in the log. Earlier behavior routed all
     * unprefixed output to {@code debug}, which silently hid
     * gh-pages push failures (see {@code IKE-Network/ike-issues#329}).
     *
     * @param log    Maven logger
     * @param line   raw subprocess output line
     * @param prefix string prepended to each routed line
     */
    public static void routeSubprocessLine(Log log, String line, String prefix) {
        if (line.startsWith("[ERROR] ")) {
            log.error(prefix + line.substring(8));
        } else if (line.startsWith("[WARNING] ")) {
            log.warn(prefix + line.substring(10));
        } else if (line.startsWith("[INFO] ")) {
            log.info(prefix + line.substring(7));
        } else if (line.startsWith("[DEBUG] ")) {
            log.debug(prefix + line.substring(8));
        } else if (line.startsWith("WARNING: ")) {
            // JVM-style warnings (e.g., sun.misc.Unsafe deprecation)
            log.warn(prefix + line.substring(9));
        } else if (line.startsWith("ERROR: ")) {
            // JVM-style errors
            log.error(prefix + line.substring(7));
        } else if (line.startsWith("fatal: ")
                || line.startsWith("error: ")
                || line.startsWith("remote: error:")
                || line.startsWith("remote: fatal:")
                || line.startsWith("! [rejected]")
                || line.startsWith("! [remote rejected]")) {
            // Git error patterns — must be visible. Without these,
            // a failed `git push` was effectively silent because the
            // exit-code-only signal got swallowed by the catching
            // wrapper that logged only e.getMessage().
            log.error(prefix + line);
        } else {
            // Plain subprocess output (e.g., git push success indicators
            // "remote: ...", "To <url>", "* [new branch] X -> Y").
            // Earlier behavior was log.debug — hid both successes and
            // any unrecognized failures. Route to info for visibility.
            log.info(prefix + line);
        }
    }

    /**
     * A command paired with a display label for parallel execution.
     *
     * @param label   human-readable name shown in log output
     * @param command the command and arguments to execute
     */
    public record LabeledTask(String label, String[] command) {}

    /**
     * Run multiple commands concurrently, prefixing each line of output
     * with the task's label (e.g., {@code [nexus] ...}).
     *
     * <p>Spawns virtual threads to read stdout/stderr from each process.
     * All processes run to completion even if one fails — the exception
     * reports which task(s) failed.
     *
     * @param workDir working directory for each subprocess
     * @param log     Maven logger for output routing
     * @param tasks   the labeled tasks to run concurrently
     * @throws MojoException if any task fails or execution is interrupted
     */
    public static void execParallel(File workDir, Log log, LabeledTask... tasks)
            throws MojoException {
        for (LabeledTask task : tasks) {
            log.debug("» [" + task.label() + "] " + String.join(" ", task.command()));
        }

        List<String> failures = new CopyOnWriteArrayList<>();
        List<Thread> threads = new ArrayList<>();

        for (LabeledTask task : tasks) {
            Thread thread = Thread.ofVirtual()
                    .name("exec-" + task.label())
                    .start(() -> {
                        try {
                            Process process = new ProcessBuilder(task.command())
                                    .directory(workDir)
                                    .redirectErrorStream(true)
                                    .start();

                            try (BufferedReader reader = new BufferedReader(
                                    new InputStreamReader(process.getInputStream(),
                                            StandardCharsets.UTF_8))) {
                                String line;
                                while ((line = reader.readLine()) != null) {
                                    String prefix = "[" + task.label() + "] ";
                                    synchronized (log) {
                                        routeSubprocessLine(log, line, prefix);
                                    }
                                }
                            }

                            int exit = process.waitFor();
                            if (exit != 0) {
                                failures.add(task.label() + " (exit " + exit + ")");
                            }
                        } catch (IOException | InterruptedException e) {
                            failures.add(task.label() + " (" + e.getMessage() + ")");
                        }
                    });
            threads.add(thread);
        }

        try {
            for (Thread thread : threads) {
                thread.join();
            }
        } catch (InterruptedException e) {
            throw new MojoException("Parallel execution interrupted", e);
        }

        if (!failures.isEmpty()) {
            throw new MojoException(
                    "Parallel tasks failed: " + String.join(", ", failures));
        }
    }

    /**
     * Run a command and capture stdout as a trimmed String.
     * Throws on non-zero exit code.
     *
     * @param workDir working directory for the subprocess
     * @param command the command and arguments to execute
     * @return trimmed stdout output
     * @throws MojoException if the command exits non-zero or cannot be started
     */
    public static String execCapture(File workDir, String... command)
            throws MojoException {
        try {
            Process process = new ProcessBuilder(command)
                    .directory(workDir)
                    .redirectErrorStream(false)
                    .start();
            String output;
            try (BufferedReader reader = new BufferedReader(
                    new InputStreamReader(process.getInputStream(),
                            StandardCharsets.UTF_8))) {
                output = reader.lines().collect(Collectors.joining("\n")).trim();
            }
            int exit = process.waitFor();
            if (exit != 0) {
                throw new MojoException(
                        "Command failed (exit " + exit + "): " +
                                String.join(" ", command));
            }
            return output;
        } catch (IOException | InterruptedException e) {
            throw new MojoException(
                    "Failed to execute: " + String.join(" ", command), e);
        }
    }

    /**
     * Run a command, streaming output through Maven's logger AND
     * capturing the full output as a String. Throws on non-zero exit.
     *
     * @param workDir working directory for the subprocess
     * @param log     Maven logger for real-time output
     * @param command the command and arguments to execute
     * @return the complete stdout+stderr output as a trimmed string
     * @throws MojoException if the command exits non-zero
     */
    public static String execCaptureAndLog(File workDir, Log log, String... command)
            throws MojoException {
        log.debug("» " + String.join(" ", command));
        try {
            Process proc = new ProcessBuilder(command)
                    .directory(workDir)
                    .redirectErrorStream(true)
                    .start();
            StringBuilder captured = new StringBuilder();
            try (var reader = new BufferedReader(
                    new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    routeSubprocessLine(log, line);
                    captured.append(line).append('\n');
                }
            }
            int exit = proc.waitFor();
            if (exit != 0) {
                String output = captured.toString().trim();
                String detail = output.isEmpty()
                        ? ""
                        : "\nOutput:\n" + output;
                throw new MojoException(
                        "Command failed (exit " + exit + "): "
                                + String.join(" ", command) + detail);
            }
            return captured.toString().trim();
        } catch (IOException | InterruptedException e) {
            throw new MojoException(
                    "Failed to execute: " + String.join(" ", command), e);
        }
    }

    /**
     * Read the project's own {@code <version>} from a POM file,
     * skipping any {@code <version>} inside the {@code <parent>} block.
     *
     * @param pomFile the POM file to read
     * @return the version string
     * @throws MojoException if the file cannot be read or has no version
     */
    public static String readPomVersion(File pomFile) throws MojoException {
        try {
            String content = Files.readString(pomFile.toPath(), StandardCharsets.UTF_8);

            // Strip the <parent>...</parent> block so we don't match
            // the parent version instead of the project version.
            String stripped = content.replaceFirst(
                    "(?s)<parent>.*?</parent>", "");
            Matcher matcher = VERSION_PATTERN.matcher(stripped);
            if (matcher.find()) {
                return matcher.group(1);
            }
            throw new MojoException(
                    "Could not extract <version> from " + pomFile);
        } catch (IOException e) {
            throw new MojoException("Failed to read " + pomFile, e);
        }
    }

    /**
     * Stamp {@code <project.build.outputTimestamp>} in the root POM to
     * {@code newTimestamp}, enabling reproducible builds for the release.
     *
     * <p>The property must already exist in the POM (inherited from
     * ike-parent). If it is absent this method is a no-op with a warning.
     *
     * @param pomFile      the root POM to update
     * @param newTimestamp ISO-8601 UTC timestamp, e.g. {@code 2026-03-30T12:00:00Z}
     * @param log          Maven log (used for warnings only)
     * @throws MojoException if the file cannot be read or written
     */
    public static void stampOutputTimestamp(File pomFile, String newTimestamp, Log log)
            throws MojoException {
        try {
            String content = Files.readString(pomFile.toPath(), StandardCharsets.UTF_8);
            java.util.regex.Pattern pat = java.util.regex.Pattern.compile(
                    "(<project\\.build\\.outputTimestamp>)[^<]*(</project\\.build\\.outputTimestamp>)");
            java.util.regex.Matcher m = pat.matcher(content);
            if (!m.find()) {
                log.warn("project.build.outputTimestamp not found in " + pomFile
                        + " — reproducible build stamp skipped");
                return;
            }
            String updated = m.replaceFirst("$1" + newTimestamp + "$2");
            Files.writeString(pomFile.toPath(), updated, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new MojoException(
                    "Failed to stamp outputTimestamp in " + pomFile, e);
        }
    }

    /**
     * Replace the project's own {@code <version>old</version>} with
     * {@code <version>new</version>}, skipping any version inside
     * the {@code <parent>} block.
     *
     * @param pomFile    the POM file to update
     * @param oldVersion the current version string to replace
     * @param newVersion the new version string
     * @throws MojoException if the version is not found or the file cannot be updated
     */
    public static void setPomVersion(File pomFile, String oldVersion, String newVersion)
            throws MojoException {
        try {
            String content = Files.readString(pomFile.toPath(), StandardCharsets.UTF_8);
            String oldTag = "<version>" + oldVersion + "</version>";
            String newTag = "<version>" + newVersion + "</version>";

            // Find the end of the <parent> block (if any) so we skip it
            int searchStart = 0;
            Matcher parentEnd = Pattern.compile("</parent>").matcher(content);
            if (parentEnd.find()) {
                searchStart = parentEnd.end();
            }

            int idx = content.indexOf(oldTag, searchStart);
            if (idx < 0) {
                throw new MojoException(
                        "POM does not contain " + oldTag +
                                " (outside <parent> block)");
            }
            String updated = content.substring(0, idx) + newTag +
                    content.substring(idx + oldTag.length());
            Files.writeString(pomFile.toPath(), updated, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new MojoException("Failed to update " + pomFile, e);
        }
    }

    /**
     * Check if the current platform is Windows.
     *
     * @return {@code true} if {@code os.name} contains "win"
     */
    public static boolean isWindows() {
        return System.getProperty("os.name", "")
                .toLowerCase(java.util.Locale.ROOT).contains("win");
    }

    /**
     * Resolve the Maven executable. Prefers the Maven wrapper
     * ({@code mvnw} on Unix, {@code mvnw.cmd} on Windows) at the
     * git root; falls back to system Maven located via {@code which}
     * on Unix or {@code where} on Windows.
     *
     * @param gitRoot the git repository root directory
     * @param log     Maven logger
     * @return the resolved Maven executable
     * @throws MojoException if neither wrapper nor system Maven is found
     */
    public static File resolveMavenWrapper(File gitRoot, Log log) throws MojoException {
        return resolveMavenWrapperFor(gitRoot, log, isWindows());
    }

    /**
     * OS-injected variant of {@link #resolveMavenWrapper(File, Log)} for testing.
     * Production callers should use the two-argument overload.
     *
     * @param gitRoot the git repository root directory
     * @param log     Maven logger
     * @param windows {@code true} to use Windows wrapper/lookup conventions,
     *                {@code false} for Unix conventions
     * @return the resolved Maven executable
     * @throws MojoException if neither wrapper nor system Maven is found
     */
    static File resolveMavenWrapperFor(File gitRoot, Log log, boolean windows)
            throws MojoException {
        String wrapperName = windows ? "mvnw.cmd" : "mvnw";
        File wrapper = new File(gitRoot, wrapperName);
        if (wrapper.exists()) {
            return wrapper;
        }
        // Fall back to system mvn — resolve via PATH
        String systemName = windows ? "mvn.cmd" : "mvn";
        String lookupTool = windows ? "where" : "which";
        try {
            String output = execCapture(gitRoot, lookupTool, systemName);
            String path = firstNonEmptyLine(output);
            log.info("No Maven wrapper found; using system '" + path + "'");
            return new File(path);
        } catch (MojoException _) {
            throw new MojoException(
                    "Neither Maven wrapper (" + wrapper.getAbsolutePath() +
                            ") nor system '" + systemName + "' found on PATH.");
        }
    }

    /**
     * Return the first non-empty line of {@code output}, trimmed.
     * Handles the Windows {@code where} command, which may emit multiple
     * matches separated by newlines (e.g. {@code mvn.cmd} from a wrapper
     * shim and from a system install).
     *
     * @param output multi-line command output
     * @return first non-empty line trimmed, or the trimmed full output
     *         if no non-empty line exists
     */
    static String firstNonEmptyLine(String output) {
        return output.lines()
                .map(String::trim)
                .filter(s -> !s.isEmpty())
                .findFirst()
                .orElse(output.trim());
    }

    /**
     * Get the git repository root directory.
     *
     * @param startDir any directory inside the repository
     * @return the repository root directory
     * @throws MojoException if git rev-parse fails
     */
    public static File gitRoot(File startDir) throws MojoException {
        try {
            String root = execCapture(startDir,
                    "git", "rev-parse", "--show-toplevel");
            return new File(root);
        } catch (MojoException e) {
            String absPath;
            try {
                absPath = startDir.getCanonicalPath();
            } catch (IOException io) {
                absPath = startDir.getAbsolutePath();
            }
            throw new MojoException(
                    "Could not resolve git repository root from "
                            + absPath
                            + ". `git rev-parse --show-toplevel` failed."
                            + " If you invoked this goal with `-pl <module>`"
                            + " from a parent that is itself a git repo and"
                            + " the subproject was newly synced via Syncthing,"
                            + " its `.git/` directory may not yet exist; run"
                            + " the repo's `_git-init.sh` (or `git init &&"
                            + " git remote add origin ...`) first. Or `cd`"
                            + " into the subproject and run the goal directly."
                            + " ike-issues#357.", e);
        }
    }

    /**
     * Assert that the git working tree is clean (no staged or unstaged changes).
     *
     * @param workDir any directory inside the repository
     * @throws MojoException if the working tree has uncommitted changes
     */
    public static void requireCleanWorktree(File workDir) throws MojoException {
        try {
            execCapture(workDir, "git", "diff", "--quiet");
        } catch (MojoException _) {
            throw new MojoException(
                    "Working tree has unstaged changes. Commit or stash before proceeding.");
        }
        try {
            execCapture(workDir, "git", "diff", "--cached", "--quiet");
        } catch (MojoException _) {
            throw new MojoException(
                    "Working tree has staged changes. Commit or stash before proceeding.");
        }
    }

    /**
     * Get the current git branch name.
     *
     * @param workDir any directory inside the repository
     * @return the current branch name
     * @throws MojoException if git rev-parse fails
     */
    public static String currentBranch(File workDir) throws MojoException {
        return execCapture(workDir, "git", "rev-parse", "--abbrev-ref", "HEAD");
    }

    /**
     * Check whether a named git remote exists.
     *
     * @param workDir    any directory inside the repository
     * @param remoteName the remote name to check (e.g., "origin")
     * @return {@code true} if the remote exists
     */
    public static boolean hasRemote(File workDir, String remoteName) {
        try {
            String remotes = execCapture(workDir, "git", "remote");
            return remotes.lines().anyMatch(line -> line.trim().equals(remoteName));
        } catch (MojoException _) {
            return false;
        }
    }

    /**
     * Return the URL of a named git remote, or null if the remote does
     * not exist.
     *
     * @param workDir    any directory inside the repository
     * @param remoteName the remote name (typically {@code "origin"})
     * @return the remote URL, or null if the remote is absent
     */
    public static String getRemoteUrl(File workDir, String remoteName) {
        try {
            String url = execCapture(workDir,
                    "git", "remote", "get-url", remoteName);
            return url.isBlank() ? null : url.trim();
        } catch (MojoException _) {
            return null;
        }
    }

    /**
     * Derive the release version from a SNAPSHOT version.
     * {@code "2-SNAPSHOT"} becomes {@code "2"};
     * {@code "1.1.0-SNAPSHOT"} becomes {@code "1.1.0"}.
     *
     * @param snapshotVersion the SNAPSHOT version string
     * @return the release version without the -SNAPSHOT suffix
     */
    public static String deriveReleaseVersion(String snapshotVersion) {
        return snapshotVersion.replace("-SNAPSHOT", "");
    }

    /**
     * Derive the next SNAPSHOT version by incrementing the last numeric
     * segment. {@code "2"} becomes {@code "3-SNAPSHOT"};
     * {@code "1.1.0"} becomes {@code "1.1.1-SNAPSHOT"}.
     *
     * @param releaseVersion the release version to increment
     * @return the next SNAPSHOT version
     */
    public static String deriveNextSnapshot(String releaseVersion) {
        String base = releaseVersion.replace("-SNAPSHOT", "");
        int lastDot = base.lastIndexOf('.');
        if (lastDot >= 0) {
            String prefix = base.substring(0, lastDot + 1);
            String last = base.substring(lastDot + 1);
            return prefix + (Integer.parseInt(last) + 1) + "-SNAPSHOT";
        }
        // Simple integer version (e.g., "2" -> "3-SNAPSHOT")
        return (Integer.parseInt(base) + 1) + "-SNAPSHOT";
    }

    /**
     * Update a named Maven property in POM content.
     * Replaces {@code <propertyName>oldValue</propertyName>} with
     * {@code <propertyName>newVersion</propertyName>}.
     *
     * @param pomContent   the POM file content as a string
     * @param propertyName the Maven property name (e.g., "ike-bom.version")
     * @param newVersion   the new version value
     * @return the updated POM content (unchanged if property not found)
     */
    public static String updateVersionProperty(String pomContent,
                                         String propertyName,
                                         String newVersion) {
        String propPattern = "<" + java.util.regex.Pattern.quote(propertyName)
                + ">[^<]+</" + java.util.regex.Pattern.quote(propertyName) + ">";
        return pomContent.replaceAll(propPattern,
                "<" + propertyName + ">" + newVersion + "</" + propertyName + ">");
    }

    private static final String PROJECT_VERSION_EXPR = "${project.version}";
    private static final String BACKUP_SUFFIX = ".ike-backup";

    /**
     * Find all {@code pom.xml} files under the git root, excluding
     * {@code target/} directories and the {@code .mvn/} directory.
     *
     * @param gitRoot the git repository root directory
     * @return list of discovered POM files
     * @throws MojoException if the file tree cannot be walked
     */
    public static List<File> findPomFiles(File gitRoot) throws MojoException {
        try (Stream<Path> walk = Files.walk(gitRoot.toPath())) {
            return walk
                    .filter(p -> p.getFileName().toString().equals("pom.xml"))
                    .filter(p -> {
                        String rel = gitRoot.toPath().relativize(p).toString();
                        return !rel.contains("target" + File.separator)
                                && !rel.startsWith(".mvn" + File.separator);
                    })
                    .map(Path::toFile)
                    .collect(Collectors.toList());
        } catch (IOException e) {
            throw new MojoException("Failed to scan for POM files", e);
        }
    }

    /**
     * Replace all occurrences of {@code ${project.version}} with a
     * literal version string in every POM file under the git root.
     * Before replacing, each affected file is saved as
     * {@code pom.xml.ike-backup} so it can be restored later.
     *
     * @param gitRoot the git repository root directory
     * @param version the literal version to substitute
     * @param log     Maven logger
     * @return the list of POM files that were modified
     * @throws MojoException if a file cannot be read or written
     */
    public static List<File> replaceProjectVersionRefs(File gitRoot, String version,
                                                 Log log)
            throws MojoException {
        List<File> pomFiles = findPomFiles(gitRoot);
        List<File> modified = new ArrayList<>();

        for (File pom : pomFiles) {
            try {
                String content = Files.readString(pom.toPath(), StandardCharsets.UTF_8);
                if (!content.contains(PROJECT_VERSION_EXPR)) {
                    continue;
                }
                // Save backup before modifying
                Path backup = pom.toPath().resolveSibling(pom.getName() + BACKUP_SUFFIX);
                Files.copy(pom.toPath(), backup, StandardCopyOption.REPLACE_EXISTING);

                // Replace all occurrences
                String updated = content.replace(PROJECT_VERSION_EXPR, version);
                Files.writeString(pom.toPath(), updated, StandardCharsets.UTF_8);

                String rel = gitRoot.toPath().relativize(pom.toPath()).toString();
                log.info("  Resolved ${project.version} -> " + version +
                        " in " + rel);
                modified.add(pom);
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to process " + pom, e);
            }
        }
        return modified;
    }

    /**
     * Restore all POM files from their {@code .ike-backup} copies and
     * delete the backup files. This reverses
     * {@link #replaceProjectVersionRefs}.
     *
     * @param gitRoot the git repository root directory
     * @param log     Maven logger
     * @return the list of POM files that were restored
     * @throws MojoException if a backup cannot be restored
     */
    public static List<File> restoreBackups(File gitRoot, Log log)
            throws MojoException {
        List<File> pomFiles = findPomFiles(gitRoot);
        List<File> restored = new ArrayList<>();

        for (File pom : pomFiles) {
            Path backup = pom.toPath().resolveSibling(pom.getName() + BACKUP_SUFFIX);
            if (!Files.exists(backup)) {
                continue;
            }
            try {
                Files.copy(backup, pom.toPath(), StandardCopyOption.REPLACE_EXISTING);
                Files.delete(backup);

                String rel = gitRoot.toPath().relativize(pom.toPath()).toString();
                log.info("  Restored ${project.version} in " + rel);
                restored.add(pom);
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to restore backup for " + pom, e);
            }
        }
        return restored;
    }

    /**
     * Stage a list of files with {@code git add}.
     *
     * @param gitRoot the git repository root directory
     * @param log     Maven logger
     * @param files   the files to stage
     * @throws MojoException if the git add command fails
     */
    public static void gitAddFiles(File gitRoot, Log log, List<File> files)
            throws MojoException {
        if (files.isEmpty()) return;
        List<String> command = new ArrayList<>();
        command.add("git");
        command.add("add");
        for (File f : files) {
            command.add(gitRoot.toPath().relativize(f.toPath()).toString());
        }
        exec(gitRoot, log, command.toArray(new String[0]));
    }

    private static final DateTimeFormatter CHECKPOINT_DATE_FMT =
            DateTimeFormatter.ofPattern("yyyyMMdd");

    /**
     * Derive a checkpoint version from the current POM version.
     *
     * <p>Format: {@code {base}-checkpoint.{yyyyMMdd}.{shortSha}} where
     * {@code base} is the POM version minus {@code -SNAPSHOT}, and
     * {@code shortSha} is the abbreviated SHA of the current HEAD commit.
     *
     * <p>This scheme is fully deterministic — the same commit on any
     * machine always produces the same version string. No tag-sequence
     * coordination across machines is required.
     *
     * @param pomVersion current POM version (may include -SNAPSHOT)
     * @param gitRoot    git repository root (for HEAD SHA lookup)
     * @return the checkpoint version string
     * @throws MojoException if the HEAD SHA cannot be resolved
     */
    public static String deriveCheckpointVersion(String pomVersion, File gitRoot)
            throws MojoException {
        String base = pomVersion.replace("-SNAPSHOT", "");
        String date = LocalDate.now().format(CHECKPOINT_DATE_FMT);
        String shortSha = execCapture(gitRoot, "git", "rev-parse", "--short", "HEAD");
        return base + "-checkpoint." + date + "." + shortSha;
    }

    /**
     * Check whether a git tag exists (locally).
     *
     * @param gitRoot the git repository root directory
     * @param tagName the tag name to check
     * @return {@code true} if the tag exists locally
     */
    public static boolean tagExists(File gitRoot, String tagName) {
        try {
            execCapture(gitRoot, "git", "rev-parse", "--verify", "refs/tags/" + tagName);
            return true;
        } catch (MojoException _) {
            return false;
        }
    }

    /** Base path on the site server. */
    public static final String SITE_DISK_BASE = "/srv/ike-site/";

    /** SSH host alias used by wagon-ssh-external. */
    public static final String SITE_SSH_HOST = "proxy";

    /**
     * Remove a directory tree on the site server via SSH.
     *
     * <p>Used to clean up snapshot sites after release or feature-finish.
     *
     * <p>Safety: validates the path starts with {@link #SITE_DISK_BASE}
     * and contains at least two path components after the base to
     * prevent accidental deletion of the entire site root.
     *
     * @param workDir    local directory for process execution
     * @param log        Maven log
     * @param remotePath absolute path on the server (e.g.,
     *                   {@code /srv/ike-site/ike-pipeline/snapshot/main})
     * @throws MojoException if the path is unsafe or SSH fails
     */
    public static void cleanRemoteSiteDir(File workDir, Log log, String remotePath)
            throws MojoException {
        cleanRemoteSiteDir(workDir, log, remotePath, "ssh", SITE_SSH_HOST);
    }

    /**
     * Overload accepting an explicit SSH command prefix — package-private
     * for testing against containers.
     *
     * @param workDir    local directory for process execution
     * @param log        Maven log
     * @param remotePath absolute path on the server to remove
     * @param sshPrefix  the SSH command tokens (e.g., "ssh", "-i", "key",
     *                   "-p", "2222", "user@localhost")
     * @throws MojoException if the path is unsafe or SSH fails
     */
    public static void cleanRemoteSiteDir(File workDir, Log log, String remotePath,
                                    String... sshPrefix)
            throws MojoException {
        validateRemotePath(remotePath);
        log.debug("Cleaning remote site: " + remotePath);
        String[] cmd = new String[sshPrefix.length + 3];
        System.arraycopy(sshPrefix, 0, cmd, 0, sshPrefix.length);
        cmd[sshPrefix.length] = "rm";
        cmd[sshPrefix.length + 1] = "-rf";
        cmd[sshPrefix.length + 2] = remotePath;
        exec(workDir, log, cmd);
    }

    /**
     * Atomically swap a newly deployed site into place on the server.
     *
     * <p>The deployment flow is:
     * <ol>
     *   <li>SCP deploys to a staging path ({@code <target>.staging})</li>
     *   <li>This method renames the old directory to {@code <target>.old}</li>
     *   <li>Renames the staging directory to the final target</li>
     *   <li>Removes the old directory</li>
     * </ol>
     *
     * <p>This avoids a window where the site is missing (rm + deploy)
     * and ensures the site always serves either the old or new version.
     *
     * @param workDir    local directory for process execution
     * @param log        Maven log
     * @param remotePath final target path on the server
     * @throws MojoException if SSH commands fail
     */
    public static void swapRemoteSiteDir(File workDir, Log log, String remotePath)
            throws MojoException {
        swapRemoteSiteDir(workDir, log, remotePath, "ssh", SITE_SSH_HOST);
    }

    /**
     * Overload accepting an explicit SSH command prefix — package-private
     * for testing against containers.
     *
     * @param workDir    local directory for process execution
     * @param log        Maven log
     * @param remotePath final target path on the server
     * @param sshPrefix  the SSH command tokens (e.g., "ssh", "-i", "key",
     *                   "-p", "2222", "user@localhost")
     * @throws MojoException if the path is unsafe or SSH fails
     */
    public static void swapRemoteSiteDir(File workDir, Log log, String remotePath,
                                   String... sshPrefix)
            throws MojoException {
        validateRemotePath(remotePath);
        String staging = remotePath + ".staging";
        String old = remotePath + ".old";

        log.info("Swapping site: " + staging + " → " + remotePath);
        String[] cmd = new String[sshPrefix.length + 1];
        System.arraycopy(sshPrefix, 0, cmd, 0, sshPrefix.length);
        cmd[sshPrefix.length] = "rm -rf " + old
                + " && (mv " + remotePath + " " + old + " 2>/dev/null || true)"
                + " && mv " + staging + " " + remotePath
                + " && rm -rf " + old;
        exec(workDir, log, cmd);
    }

    /**
     * Return the staging path for a site deploy (final path + ".staging").
     *
     * @param diskPath the final on-disk site path
     * @return {@code diskPath} with {@code .staging} appended
     */
    public static String siteStagingPath(String diskPath) {
        return diskPath + ".staging";
    }

    /**
     * Update the {@code latest} symlink alongside a version-prefixed
     * site deploy so that {@code <site-base>/latest/} always points at
     * the most recent release (ike-issues#303).
     *
     * <p>For a release deployed to
     * {@code /srv/ike-site/ike-platform/17/}, this issues
     * {@code cd /srv/ike-site/ike-platform && ln -snf 17 latest} on the
     * site host. Idempotent — the {@code -f} flag replaces any prior
     * symlink target.
     *
     * <p>Uses the same SSH host as {@link #swapRemoteSiteDir}.
     * Best-effort: callers should catch {@link MojoException} and
     * surface as a warning rather than failing the release — the
     * version-prefixed site is reachable at its own URL even if the
     * alias update fails.
     *
     * @param workDir    local directory for process execution
     * @param log        Maven log
     * @param remotePath the version-prefixed final site path
     *                   (e.g. {@code /srv/ike-site/ike-platform/17})
     * @throws MojoException if the path is unsafe or SSH fails
     */
    public static void updateLatestSymlink(File workDir, Log log,
                                           String remotePath)
            throws MojoException {
        updateLatestSymlink(workDir, log, remotePath, "ssh", SITE_SSH_HOST);
    }

    /**
     * Overload accepting an explicit SSH command prefix —
     * package-private for testing against containers.
     *
     * @param workDir    local directory for process execution
     * @param log        Maven log
     * @param remotePath the version-prefixed final site path
     * @param sshPrefix  the SSH command tokens
     * @throws MojoException if the path is unsafe or SSH fails
     */
    public static void updateLatestSymlink(File workDir, Log log,
                                           String remotePath,
                                           String... sshPrefix)
            throws MojoException {
        validateRemotePath(remotePath);
        String parent = parentDir(remotePath);
        String leaf = leafName(remotePath);
        if (parent == null || leaf == null || leaf.isEmpty()) {
            throw new MojoException(
                    "Cannot derive parent/leaf from site path: " + remotePath);
        }

        log.info("Updating latest symlink: " + parent + "/latest -> " + leaf);
        String[] cmd = new String[sshPrefix.length + 1];
        System.arraycopy(sshPrefix, 0, cmd, 0, sshPrefix.length);
        cmd[sshPrefix.length] = "cd " + parent
                + " && ln -snf " + leaf + " latest";
        exec(workDir, log, cmd);
    }

    /**
     * Compute the parent directory of a Unix-style absolute path
     * without crossing the {@link #SITE_DISK_BASE} boundary. Returns
     * {@code null} when the input is at or above the base.
     *
     * <p>Package-private for testing.
     */
    static String parentDir(String absPath) {
        int lastSlash = absPath.lastIndexOf('/');
        if (lastSlash <= 0) return null;
        String parent = absPath.substring(0, lastSlash);
        return parent.startsWith(SITE_DISK_BASE.replaceAll("/$", ""))
                ? parent : null;
    }

    /**
     * Last path segment of a Unix-style absolute path — the basename.
     * Trailing slashes are tolerated.
     *
     * <p>Package-private for testing.
     */
    static String leafName(String absPath) {
        String trimmed = absPath.endsWith("/")
                ? absPath.substring(0, absPath.length() - 1) : absPath;
        int lastSlash = trimmed.lastIndexOf('/');
        return lastSlash < 0 ? trimmed : trimmed.substring(lastSlash + 1);
    }

    /**
     * Return the scpexe URL for the staging directory.
     *
     * @param targetUrl the final site URL
     * @return {@code targetUrl} with {@code .staging} appended
     */
    public static String siteStagingUrl(String targetUrl) {
        return targetUrl + ".staging";
    }

    /**
     * Publish a project's rendered site to its repo's {@code gh-pages}
     * branch using the hybrid structure (ike-issues#312, #332).
     *
     * <p>Layout produced after each release:
     * <ul>
     *   <li>{@code /} — the just-released version's site at the root
     *       (so {@code https://ike.network/<repo>/} serves the current
     *       release, the same as before).</li>
     *   <li>{@code /<version>/} — the just-released version preserved
     *       under a versioned subdirectory for citations and
     *       reproducibility.</li>
     *   <li>{@code /latest/} — a copy of the just-released version
     *       under the canonical "latest" path. Not a git symlink:
     *       GitHub Pages doesn't follow them reliably.</li>
     *   <li>Earlier {@code /<version>/} subdirectories from prior
     *       releases are preserved unchanged.</li>
     * </ul>
     *
     * <p>Mechanics: the existing {@code gh-pages} branch is cloned
     * (preserving full history including all prior {@code <version>/}
     * subdirs); root-level files and non-version root subdirs are
     * wiped (stale assets from the previous release shouldn't linger);
     * staging is copied to root, to {@code <version>/}, and to
     * {@code latest/}; an additive commit is pushed (no
     * {@code --force}). On first-time publish (no {@code gh-pages}
     * branch yet) the bootstrap path uses {@code git checkout
     * --orphan} and force-push.
     *
     * <p>Adds a {@code .nojekyll} marker so GitHub Pages skips Jekyll
     * processing — the content is already rendered HTML and we don't
     * want underscore-prefixed directories to be stripped.
     *
     * <p>Does NOT write a {@code CNAME} file: the org-level
     * {@code IKE-Network.github.io/CNAME} (set to {@code ike.network})
     * extends to all project pages under the org automatically. A
     * per-project CNAME would either be ignored or conflict.
     *
     * <p>Patterned on {@code OrgSiteSupport.publishToGhPages} (in
     * the ike-maven-plugin module) but generalized to any project's
     * staging dir + remote. ike-workspace-model can't {@code @link}
     * directly to ike-maven-plugin classes — it sits below in the
     * dependency stack, so they're not on its javadoc classpath.
     *
     * @param stagingDir directory containing the rendered site
     *                   (typically {@code target/staging/})
     * @param repoUrl    git URL of the project repo to push to
     * @param log        Maven logger
     * @param projectId  project artifact ID, used in the commit message
     * @param version    release version, used in the commit message
     * @throws MojoException if any step fails
     */
    public static void publishProjectSiteToGhPages(Path stagingDir,
                                                    String repoUrl,
                                                    Log log,
                                                    String projectId,
                                                    String version)
            throws MojoException {
        if (!Files.isDirectory(stagingDir)) {
            throw new MojoException(
                    "Staging directory does not exist: " + stagingDir
                            + ". Site build may have failed.");
        }

        // Empty-staging guard (ike-issues#334). Earlier behavior: an
        // empty-but-existing dir passed isDirectory() and shipped a
        // .nojekyll-only tree to gh-pages — silent publication of an
        // empty site. Now we fail loud so the caller (or an upstream
        // fallback to target/site/) gets a clear signal.
        if (isEmptyDirectory(stagingDir)) {
            throw new MojoException(
                    "Staging directory is empty: " + stagingDir
                            + ". Site build produced no content. "
                            + "For single-module projects, mvn site:stage "
                            + "is a no-op (it's designed to aggregate "
                            + "child module sites in a multi-module "
                            + "reactor); the rendered content lives at "
                            + "target/site/. The release flow's empty-"
                            + "staging fallback (ReleaseDraftMojo) should "
                            + "have detected this and substituted "
                            + "target/site/ before reaching this point. "
                            + "If you're calling publishProjectSiteToGhPages "
                            + "directly, pass target/site/ instead of "
                            + "target/staging/ for single-module projects.");
        }

        // Detect version-nested staging (ike-issues#337). When a
        // project's site.deploy.url contains the version segment
        // (e.g., scpexe://...//ike-platform/${project.version}/),
        // mvn site:stage produces target/staging/<version>/<actual
        // content> rather than target/staging/<actual content>.
        // Unwrap whenever the version subdir exists, regardless of
        // whether the staging dir has stale prior-release subdirs
        // (which it usually does on ike-platform because
        // maven-clean-plugin preserves target/staging across clean).
        // Earlier behavior required exactly-one-entry, which failed
        // the second time the same project released (count >1 due
        // to leftovers); the over-narrow check stripped both the
        // current version and the stale ones via the
        // copyDirectoryExcludingTopLevelVersionDirs filter, leaving
        // the gh-pages /<version>/ subdir empty. The fix here is
        // simpler: if the per-version subdir exists, that's
        // unambiguously the source.
        Path effectiveStagingSource = stagingDir;

        // Detect URL-as-path staging (ike-issues#359). When a
        // multi-module reactor declares an https:// <site><url>,
        // maven-site-plugin's site:stage stages content at
        // target/staging/https:/<host>/<projectId>/[<version>/]
        // — scheme, host, and every path segment become literal
        // directory names. Pre-#304 the foundation repos used
        // scpexe:// URLs which produced clean target/staging/
        // <path-segments>/ trees; post-#304 the https:// URLs came
        // with this URL-as-path side effect. Detect the pattern
        // first so the existing version-nested (#337) and
        // parent-artifactId (#342) unwraps run against the
        // already-narrowed effective source.
        Path urlAsPathUnwrap = detectHttpsUrlStaging(stagingDir, projectId);
        if (urlAsPathUnwrap != null) {
            log.info("  Detected https://-URL-as-path staging at "
                    + urlAsPathUnwrap
                    + " — using it as the gh-pages source. (Project's "
                    + "<site><url> is an https:// URL and maven-site-plugin "
                    + "stages content at target/staging/https:/<host>/"
                    + "<projectId>/; ike-issues#359.)");
            effectiveStagingSource = urlAsPathUnwrap;
        }

        // Capture the layer at which sibling submodule sites live
        // BEFORE the version-nested unwrap narrows further. For
        // ike-platform the URL is .../ike-platform/<version>/ so
        // siblings (ike-workspace-maven-plugin/, ike-parent/) live
        // alongside the version dir, one level up from the final
        // narrowed source. For ike-tooling/ike-docs (no version in
        // URL) siblings live at the same layer. ike-issues#363.
        Path siblingSubmoduleLayer = effectiveStagingSource;

        Path nestedVersionDir = effectiveStagingSource.resolve(version);
        if (Files.isDirectory(nestedVersionDir)
                && !isEmptyDirectory(nestedVersionDir)) {
            log.info("  Detected version-nested staging at "
                    + nestedVersionDir
                    + " — using it as the gh-pages source. "
                    + "(Project's site.deploy.url contains the "
                    + "version segment so site:stage nested "
                    + "content under it; ike-issues#337.)");
            effectiveStagingSource = nestedVersionDir;
        }

        // Detect parent-artifactId staging nesting (ike-issues#342).
        // When a project has a <parent> block, maven-site-plugin's
        // site:stage derives the staging directory from the parent
        // artifactId, producing target/staging/<parentArtifactId>/
        // <projectId>/<actual content>. This is orthogonal to the
        // version-nested case above (#337) — they can compound, but
        // the version-nested unwrap runs first; this check is then
        // applied to whatever effective source survived.
        // Detection: effective staging has exactly one entry that's
        // a directory, and that directory contains a non-empty
        // subdirectory whose name matches projectId. Unwrap to
        // <singleEntry>/<projectId>/.
        Path parentArtifactUnwrap =
                detectParentArtifactNesting(effectiveStagingSource, projectId);
        if (parentArtifactUnwrap != null) {
            log.info("  Detected parent-artifactId staging nesting at "
                    + parentArtifactUnwrap
                    + " — using it as the gh-pages source. (Project "
                    + "has a <parent> block so site:stage nested "
                    + "content under <parentArtifactId>/<artifactId>/; "
                    + "ike-issues#342.)");
            effectiveStagingSource = parentArtifactUnwrap;
        }

        // Detect aggregated-reactor staging where the project's own
        // site is one of several top-level entries (ike-issues#351).
        // When a workspace pom inherits a <site> URL via property
        // indirection that produces no common ancestor with its
        // subprojects (e.g. https://ike.network/${project.artifactId}/
        // per project), site:stage flattens each module at its own
        // top-level path under target/staging/. The workspace's own
        // content lives in staging/<projectId>/ and the subprojects
        // are siblings. Without this unwrap, publishProjectSiteToGhPages
        // would copy the whole flattened tree as the gh-pages root —
        // workspace's own pages end up in /<projectId>/, the
        // subprojects' aggregated sites take over the actual root,
        // and the wrong index.html ships. Unwrap to <projectId>/
        // pulls just the workspace's own content to the gh-pages root.
        // Orthogonal to #337 (version-nested) and #342 (parent-artifactId);
        // applies whenever the project's own subdir exists alongside
        // other top-level entries.
        Path aggregatedUnwrap =
                detectAggregatedStaging(effectiveStagingSource, projectId);
        if (aggregatedUnwrap != null) {
            log.info("  Detected aggregated-reactor staging at "
                    + effectiveStagingSource
                    + " — using " + aggregatedUnwrap
                    + " as the gh-pages source. (Reactor site:stage "
                    + "flattened module sites at the staging root; "
                    + "ike-issues#351.)");
            effectiveStagingSource = aggregatedUnwrap;
        }

        log.info("Publishing " + projectId + " site to gh-pages...");

        Path tempDir;
        try {
            tempDir = Files.createTempDirectory("ike-project-gh-pages-");
        } catch (IOException e) {
            throw new MojoException(
                    "Could not create temp directory for gh-pages publish", e);
        }

        try {
            File tempRoot = tempDir.toFile();

            // Try cloning the existing gh-pages branch so we preserve
            // history and any prior <version>/ subdirs. If the branch
            // doesn't yet exist on the remote (first publish), the
            // clone fails and we bootstrap with an orphan branch.
            boolean firstTimeBootstrap = false;
            try {
                exec(tempRoot, log, "git", "clone",
                        "--branch", "gh-pages",
                        "--single-branch",
                        repoUrl, ".");
                log.info("  Cloned existing gh-pages branch (additive publish)");
            } catch (MojoException cloneFailed) {
                // Most likely: the branch doesn't exist yet on this repo.
                // Bootstrap: init + orphan checkout, then force-push at
                // the end.
                log.info("  No existing gh-pages branch — bootstrapping "
                        + "with orphan checkout (first-time publish)");
                firstTimeBootstrap = true;
                // Clear any partial state from the failed clone attempt.
                try (Stream<Path> entries = Files.list(tempDir)) {
                    entries.forEach(p -> {
                        if (Files.isDirectory(p)) {
                            deleteDirectory(p);
                        } else {
                            try { Files.delete(p); } catch (IOException ignore) {
                                // best effort
                            }
                        }
                    });
                } catch (IOException ignore) {
                    // best effort
                }
                exec(tempRoot, log, "git", "init");
                exec(tempRoot, log, "git", "checkout", "--orphan", "gh-pages");
            }

            // Wipe root-level files and non-version subdirs before
            // overlaying the new release. Preserves .git/, the latest/
            // alias (which we'll repopulate), and any directory whose
            // name starts with a digit (a versioned snapshot from a
            // prior release).
            try {
                wipeGhPagesRootForRepublish(tempDir, log);
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to wipe root for republish: " + e.getMessage(), e);
            }

            // (1) Copy staging to root — current release at /<projectId>/.
            //     Filter top-level version-prefixed entries (#337):
            //     they're either pollution from earlier release cycles
            //     preserved by maven-clean's exclude-staging rule, or
            //     the same content already preserved at root by the
            //     wipe step above. In either case we don't want them
            //     coming through staging — version subdirs are managed
            //     explicitly by the caller (steps 2 and 3 below).
            try {
                copyDirectoryExcludingTopLevelVersionDirs(
                        effectiveStagingSource, tempDir);
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to copy staging dir to root: " + e.getMessage(), e);
            }

            // (2) Copy staging to /<version>/ — preserved snapshot.
            //     Same filter: don't recursively nest prior version
            //     subdirs inside this release's versioned snapshot
            //     (the bug surfaced by ike-issues#337).
            Path versionDir = tempDir.resolve(version);
            deleteDirectory(versionDir);
            try {
                Files.createDirectories(versionDir);
                copyDirectoryExcludingTopLevelVersionDirs(
                        effectiveStagingSource, versionDir);
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to copy staging dir to versioned subdir "
                                + versionDir + ": " + e.getMessage(), e);
            }

            // (3) Replace /latest/ with the just-released content.
            //     Directory copy (not symlink) — GitHub Pages does not
            //     follow git symlinks reliably. Same version-dir
            //     filter as above.
            Path latestDir = tempDir.resolve("latest");
            deleteDirectory(latestDir);
            try {
                Files.createDirectories(latestDir);
                copyDirectoryExcludingTopLevelVersionDirs(
                        effectiveStagingSource, latestDir);
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to copy staging dir to latest/: "
                                + e.getMessage(), e);
            }

            // (4) Publish sibling submodule subtrees (ike-issues#363).
            //     In a multi-module reactor with a per-artifactId or
            //     per-version <site> URL, each child module renders
            //     its own site into target/staging/.../<moduleId>/.
            //     Without this step those subtrees never reach
            //     gh-pages — the workspace site links to them but
            //     they 404. We walk siblingSubmoduleLayer for
            //     subdirs that:
            //       (a) aren't the version-nested target we already
            //           published (which IS the workspace's own),
            //       (b) contain an index.html (signal of a rendered
            //           module site, not a CSS/fonts resource dir).
            //     Each matching subtree gets copied to both
            //     <root>/<moduleId>/ and <root>/<version>/<moduleId>/.
            // Walk THREE candidate layers for sibling submodules.
            // Different submodule <site><url> shapes land at
            // different staging depths in the same reactor — we have
            // to cover all of them or some submodules ship with 404.
            //
            //   (a) siblingSubmoduleLayer — the post-URL-unwrap
            //       layer (typically target/staging/https:/<host>/<projectId>/).
            //       Submodules with URL https://ike.network/<projectId>/<sub>/
            //       (same form as reactor top) land as siblings of
            //       the reactor's own site here.
            //
            //   (b) stagingDir — the bare staging root. Submodules
            //       whose <site><url> resolves to a top-level path
            //       (e.g. https://ike.network/<sub>/) stage as
            //       1-level-deep dirs in stagingDir. Pre-#380, this
            //       is where ike-parent landed.
            //
            //   (c) stagingDir/<projectId>/ — when a submodule's URL
            //       AND the reactor's URL share a common middle
            //       segment (https://ike.network/<projectId>/...),
            //       Maven Site can stage the submodule's content
            //       *under* a directory named after the reactor's
            //       projectId. Post-#380, ike-parent's URL is
            //       https://ike.network/ike-platform/ike-parent/ and
            //       its content lands at
            //       target/staging/ike-platform/ike-parent/. The
            //       depth-1 walks in (a) and (b) miss this — the
            //       projectId-named container is rejected by the
            //       moduleId==projectId guard below, dropping its
            //       children with it.
            //
            // ike-issues#380 followup: ike-platform v51 shipped
            // without /ike-platform/ike-parent/ because layer (c)
            // wasn't being walked.
            //
            // Use a LinkedHashMap keyed by directory name to merge
            // hits and dedupe — the same sibling could theoretically
            // appear in multiple layers if a reactor mixes shapes.
            java.util.Map<String, Path> siblingByName =
                    new java.util.LinkedHashMap<>();
            for (Path candidate : findSubmoduleSiteDirs(
                    siblingSubmoduleLayer, effectiveStagingSource)) {
                siblingByName.putIfAbsent(
                        candidate.getFileName().toString(), candidate);
            }
            if (!stagingDir.equals(siblingSubmoduleLayer)) {
                for (Path candidate : findSubmoduleSiteDirs(
                        stagingDir, effectiveStagingSource)) {
                    siblingByName.putIfAbsent(
                            candidate.getFileName().toString(), candidate);
                }
            }
            // Layer (c): stagingDir/<projectId>/ — projectId-named
            // container that holds submodule sites as children.
            Path projectIdContainer = stagingDir.resolve(projectId);
            if (Files.isDirectory(projectIdContainer)
                    && !projectIdContainer.toAbsolutePath().normalize()
                            .equals(siblingSubmoduleLayer
                                    .toAbsolutePath().normalize())) {
                for (Path candidate : findSubmoduleSiteDirs(
                        projectIdContainer, effectiveStagingSource)) {
                    siblingByName.putIfAbsent(
                            candidate.getFileName().toString(), candidate);
                }
            }
            List<Path> siblings = new ArrayList<>(siblingByName.values());
            for (Path siblingSource : siblings) {
                String moduleId = siblingSource.getFileName().toString();
                if (moduleId.equals(projectId)) continue;
                Path siblingRootDest = tempDir.resolve(moduleId);
                Path siblingVersionDest = tempDir.resolve(version)
                        .resolve(moduleId);
                // Submodules also need a latest/ alias so the
                // canonical pattern <site-root>/latest/<sub>/ resolves.
                // Step (3) above populates latest/ from
                // effectiveStagingSource, which catches submodules
                // staging UNDER that source (ike-bom,
                // ike-workspace-maven-plugin) but NOT submodules from
                // the projectId-container shape introduced by #381
                // (ike-parent under stagingDir/<projectId>/). Copying
                // here covers all three layers uniformly. Redundant
                // for siblings already in latest/ from step (3), but
                // the second copy is idempotent (same source).
                Path siblingLatestDest = tempDir.resolve("latest")
                        .resolve(moduleId);
                deleteDirectory(siblingRootDest);
                deleteDirectory(siblingVersionDest);
                deleteDirectory(siblingLatestDest);
                try {
                    Files.createDirectories(siblingRootDest);
                    Files.createDirectories(siblingVersionDest);
                    Files.createDirectories(siblingLatestDest);
                    copyDirectory(siblingSource, siblingRootDest);
                    copyDirectory(siblingSource, siblingVersionDest);
                    copyDirectory(siblingSource, siblingLatestDest);
                } catch (IOException e) {
                    throw new MojoException(
                            "Failed to copy submodule subtree "
                                    + siblingSource + ": "
                                    + e.getMessage(), e);
                }
                log.info("  Published submodule site: " + moduleId);
            }

            // .nojekyll — disable Jekyll preprocessing on rendered HTML.
            Path nojekyll = tempDir.resolve(".nojekyll");
            try {
                Files.writeString(nojekyll, "");
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to write .nojekyll marker: " + e.getMessage(), e);
            }

            // Defensive: never carry a per-repo CNAME — the org CNAME
            // (IKE-Network.github.io -> ike.network) extends down.
            // If a stray CNAME ended up in the staging dir, drop it
            // (root copy only — versioned subdirs are also free of it
            // since their source was the same staging dir).
            Path strayCname = tempDir.resolve("CNAME");
            if (Files.exists(strayCname)) {
                try {
                    Files.delete(strayCname);
                    log.info("  Dropped stray CNAME from staging "
                            + "(per-project CNAMEs conflict with org CNAME)");
                } catch (IOException e) {
                    throw new MojoException(
                            "Could not delete stray CNAME: " + e.getMessage(), e);
                }
            }

            exec(tempRoot, log, "git", "add", "-A");
            exec(tempRoot, log, "git", "commit", "-m",
                    "site: publish " + projectId + " " + version);
            if (firstTimeBootstrap) {
                exec(tempRoot, log, "git", "push", "--force",
                        repoUrl, "gh-pages:gh-pages");
            } else {
                exec(tempRoot, log, "git", "push",
                        repoUrl, "gh-pages:gh-pages");
            }

            log.info("  Published:");
            log.info("    Current:   https://ike.network/" + projectId + "/");
            log.info("    Versioned: https://ike.network/" + projectId
                    + "/" + version + "/");
            log.info("    Latest:    https://ike.network/" + projectId + "/latest/");
        } finally {
            deleteDirectory(tempDir);
        }
    }

    /**
     * Wipe root-level files and non-version subdirs from a freshly
     * cloned (or freshly initialized) gh-pages working tree, in
     * preparation for overlaying a new release.
     *
     * <p>Preserves:
     * <ul>
     *   <li>{@code .git/} — git internals</li>
     *   <li>Any directory whose name starts with a digit — assumed to
     *       be a versioned snapshot from a prior release. Versions in
     *       IKE projects are numeric (single-segment integers, semver,
     *       date-based, etc.) so the digit-prefix heuristic catches all
     *       three. {@code latest/} starts with a letter and is wiped
     *       (will be repopulated by the caller).</li>
     * </ul>
     *
     * <p>Wipes everything else: stale {@code index.html}, {@code css/},
     * {@code js/}, {@code images/}, {@code latest/}, etc. The caller
     * then copies the new staging contents on top.
     *
     * @param repoDir the cloned/initialized gh-pages working tree
     * @param log     Maven logger
     * @throws IOException if directory listing fails
     */
    private static void wipeGhPagesRootForRepublish(Path repoDir, Log log)
            throws IOException {
        try (Stream<Path> entries = Files.list(repoDir)) {
            entries.forEach(entry -> {
                String name = entry.getFileName().toString();
                if (".git".equals(name)) {
                    return;
                }
                if (Files.isDirectory(entry) && isVersionDirName(name)) {
                    log.debug("  Preserving versioned subdir: " + name + "/");
                    return;
                }
                if (Files.isDirectory(entry)) {
                    deleteDirectory(entry);
                } else {
                    try {
                        Files.delete(entry);
                    } catch (IOException e) {
                        log.warn("  Could not delete root file "
                                + name + ": " + e.getMessage());
                    }
                }
            });
        }
    }

    /**
     * Heuristic: does this directory name look like a release version?
     *
     * <p>IKE versions are not necessarily semver — they may be
     * single-segment integers (e.g., {@code 145}), semver
     * ({@code 1.2.3}), or date-based ({@code 2026-04-25}). All three
     * forms start with a digit, while non-version directories at the
     * gh-pages root (e.g., {@code css}, {@code js}, {@code images},
     * {@code latest}) start with a letter.
     *
     * @param name the directory name
     * @return {@code true} if the name looks like a version
     */
    static boolean isVersionDirName(String name) {
        return name != null
                && !name.isEmpty()
                && Character.isDigit(name.charAt(0));
    }

    /**
     * Validate that a remote path is safe for deletion operations.
     *
     * <p>Ensures the path starts with {@link #SITE_DISK_BASE} and has
     * sufficient depth to prevent accidental deletion of the site root.
     *
     * @param remotePath absolute path on the server
     * @throws MojoException if the path is unsafe
     */
    public static void validateRemotePath(String remotePath)
            throws MojoException {
        if (!remotePath.startsWith(SITE_DISK_BASE)) {
            throw new MojoException(
                    "Refusing to delete — path does not start with "
                            + SITE_DISK_BASE + ": " + remotePath);
        }
        String relative = remotePath.substring(SITE_DISK_BASE.length());
        long depth = relative.chars().filter(c -> c == '/').count();
        if (relative.isBlank() || depth < 1) {
            throw new MojoException(
                    "Refusing to delete — path too shallow (need project/type): "
                            + remotePath);
        }
    }

    /**
     * Resolve the on-disk site path for a given project, type, and
     * optional subdirectory.
     *
     * @param projectId  Maven artifact ID (e.g., "ike-pipeline")
     * @param siteType   "release", "snapshot", or "checkpoint"
     * @param subPath    optional subdirectory (branch name, version);
     *                   null or blank to omit
     * @return absolute path on the server
     */
    public static String siteDiskPath(String projectId, String siteType,
                               String subPath) {
        String path = SITE_DISK_BASE + projectId + "/" + siteType;
        if (subPath != null && !subPath.isBlank()) {
            path += "/" + subPath;
        }
        return path;
    }

    /**
     * Convert a git branch name to a safe site path segment.
     * Replaces {@code /} with {@code /} (keeps hierarchy for
     * {@code feature/name} structure).
     *
     * @param branch git branch name
     * @return sanitized path segment safe for use in URLs and file paths
     */
    public static String branchToSitePath(String branch) {
        // Keep forward slashes for directory structure (feature/name → feature/name)
        // but sanitize anything dangerous
        return branch.replaceAll("[^a-zA-Z0-9/_.-]", "-");
    }

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

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

    /**
     * Read the project's own {@code <groupId>} from a POM file,
     * skipping any {@code <groupId>} inside the {@code <parent>}
     * block.
     *
     * <p>Used by cascade self-identification (IKE-Network/ike-issues#402):
     * a reactor-root POM declares its own {@code <groupId>}, which the
     * release goals match against {@code release-cascade.yaml} entries.
     *
     * @param pomFile the POM file to read
     * @return the group ID string
     * @throws MojoException if the file cannot be read or declares no
     *                       own group ID
     */
    public static String readPomGroupId(File pomFile) throws MojoException {
        try {
            String content = Files.readString(
                    pomFile.toPath(), StandardCharsets.UTF_8);
            String stripped = content.replaceFirst(
                    "(?s)<parent>.*?</parent>", "");
            Matcher matcher = GROUP_ID_PATTERN.matcher(stripped);
            if (matcher.find()) {
                return matcher.group(1);
            }
            throw new MojoException(
                    "Could not extract <groupId> from " + pomFile);
        } catch (IOException e) {
            throw new MojoException("Failed to read " + pomFile, e);
        }
    }

    /**
     * Read the project's own {@code <artifactId>} from a POM file,
     * skipping any {@code <artifactId>} inside the {@code <parent>} block.
     *
     * @param pomFile the POM file to read
     * @return the artifact ID string
     * @throws MojoException if the file cannot be read or has no artifact ID
     */
    public static String readPomArtifactId(File pomFile) throws MojoException {
        try {
            String content = Files.readString(pomFile.toPath(), StandardCharsets.UTF_8);
            String stripped = content.replaceFirst(
                    "(?s)<parent>.*?</parent>", "");
            Matcher matcher = ARTIFACT_ID_PATTERN.matcher(stripped);
            if (matcher.find()) {
                return matcher.group(1);
            }
            throw new MojoException(
                    "Could not extract <artifactId> from " + pomFile);
        } catch (IOException e) {
            throw new MojoException("Failed to read " + pomFile, e);
        }
    }

    /**
     * Read a {@code <properties>} value from a POM file by element
     * name.
     *
     * <p>Used by cascade resolution (IKE-Network/ike-issues#404) to
     * read {@code ike-tooling.version} — the version at which a
     * foundation repo resolves the {@code ike-build-standards}
     * {@code cascade} artifact.
     *
     * @param pomFile      the POM file to read
     * @param propertyName the property element name (e.g.
     *                     {@code ike-tooling.version})
     * @return the property value, or {@code null} if the POM declares
     *         no such property
     * @throws MojoException if the file cannot be read
     */
    public static String readPomProperty(File pomFile, String propertyName)
            throws MojoException {
        try {
            String content = Files.readString(
                    pomFile.toPath(), StandardCharsets.UTF_8);
            Matcher matcher = Pattern.compile(
                    "<" + Pattern.quote(propertyName) + ">([^<]+)</"
                    + Pattern.quote(propertyName) + ">").matcher(content);
            return matcher.find() ? matcher.group(1) : null;
        } catch (IOException e) {
            throw new MojoException("Failed to read " + pomFile, e);
        }
    }

    /**
     * Check whether a directory has no entries. Returns {@code true}
     * if the path is a directory with zero entries; {@code false} if
     * it has at least one entry. Throws if the path is not a
     * readable directory.
     *
     * <p>Used by the gh-pages publish flow (ike-issues#334) to
     * distinguish "directory exists but is empty" (e.g., {@code
     * mvn site:stage} produced nothing for a single-module project)
     * from "directory exists and has content" (the normal case).
     *
     * @param dir the directory to inspect
     * @return {@code true} if the directory contains no entries
     * @throws MojoException if the directory cannot be listed
     */
    public static boolean isEmptyDirectory(Path dir) throws MojoException {
        try (Stream<Path> entries = Files.list(dir)) {
            return entries.findAny().isEmpty();
        } catch (IOException e) {
            throw new MojoException(
                    "Could not list directory " + dir + ": " + e.getMessage(),
                    e);
        }
    }

    /**
     * Detect parent-artifactId staging nesting (ike-issues#342).
     *
     * <p>When a Maven project has a {@code <parent>} block,
     * {@code maven-site-plugin}'s {@code site:stage} writes content
     * to {@code target/staging/<parentArtifactId>/<projectId>/}
     * rather than {@code target/staging/}. The hybrid gh-pages
     * publish path treats {@code stagingDir} as the source of
     * truth, so the published tree ends up double-nested at
     * {@code /<repo>/<version>/<parentArtifactId>/<projectId>/}
     * when it should be at {@code /<repo>/<version>/}.
     *
     * <p>This helper detects that wrap by checking:
     * <ol>
     *   <li>{@code source} contains exactly one entry that is itself
     *       a directory, and</li>
     *   <li>that single directory contains a non-empty subdirectory
     *       whose name equals {@code projectId}.</li>
     * </ol>
     *
     * <p>Returns the path to {@code <single-entry>/<projectId>}
     * when both conditions hold; otherwise returns {@code null}
     * to indicate no unwrap is needed.
     *
     * <p>The check is intentionally conservative — it requires a
     * single top-level entry so it does not mis-fire on staging
     * trees that legitimately have multiple top-level dirs (the
     * normal aggregated-reactor case where each module's site
     * lives under its own subdirectory).
     *
     * @param source    the post-#337-unwrap effective staging source
     * @param projectId the project's own artifactId
     * @return the unwrapped path, or {@code null} if no nesting
     *         was detected
     * @throws MojoException if the directory cannot be inspected
     */
    public static Path detectParentArtifactNesting(Path source,
                                                    String projectId)
            throws MojoException {
        if (!Files.isDirectory(source)) {
            return null;
        }
        try (Stream<Path> entries = Files.list(source)) {
            List<Path> topLevel = entries.toList();
            if (topLevel.size() != 1) {
                return null;
            }
            Path single = topLevel.get(0);
            if (!Files.isDirectory(single)) {
                return null;
            }
            Path inner = single.resolve(projectId);
            if (!Files.isDirectory(inner) || isEmptyDirectory(inner)) {
                return null;
            }
            return inner;
        } catch (IOException e) {
            throw new MojoException(
                    "Could not inspect " + source
                            + " for parent-artifactId nesting: "
                            + e.getMessage(), e);
        }
    }

    /**
     * Detect aggregated-reactor staging where the project's own site
     * lives alongside multiple sibling subdirs (ike-issues#351).
     *
     * <p>When a workspace pom inherits a {@code <site>} URL with no
     * common ancestor among its reactor subprojects' URLs (e.g.
     * each module gets {@code https://ike.network/<artifactId>/}),
     * {@code site:stage} flattens each module's site at its own
     * top-level path under {@code target/staging/}. The workspace's
     * own content lives in {@code staging/<projectId>/} and the
     * subprojects are siblings. Without unwrap, the gh-pages root
     * gets the whole flattened tree — wrong index.html, missing
     * the workspace's own pages.
     *
     * <p>Detection criteria (all must hold):
     * <ol>
     *   <li>{@code source} contains at least one top-level entry, AND</li>
     *   <li>a subdirectory named {@code projectId} exists at the
     *       top level, AND</li>
     *   <li>that subdirectory is non-empty.</li>
     * </ol>
     *
     * <p>Single-top-level cases are handled by
     * {@link #detectParentArtifactNesting}; this method's value-add
     * is the multi-top-level case where {@code projectId} is one of
     * several siblings.
     *
     * <p>Returns the path to {@code source/<projectId>} when the
     * detection fires; {@code null} otherwise.
     *
     * @param source    the post-#337/#342-unwrap effective staging source
     * @param projectId the project's own artifactId
     * @return the unwrapped path, or {@code null}
     * @throws MojoException if the directory cannot be inspected
     */
    public static Path detectAggregatedStaging(Path source,
                                                String projectId)
            throws MojoException {
        if (!Files.isDirectory(source)) {
            return null;
        }
        // No multi-entry guard. The earlier "count() < 2 → return null"
        // assumed the single-entry case was always parent-artifactId
        // nesting (handled by detectParentArtifactNesting). After the
        // #358 fix in ike-parent v45+, single-module consumers stage
        // their content at staging/<projectId>/ with NO further
        // nesting — and that pattern has only one top-level entry too.
        // Returning null there caused gh-pages publish to copy the
        // whole staging tree (containing <projectId>/<content>) to
        // gh-pages root, producing /<version>/<projectId>/<content>
        // instead of /<version>/<content> — every consumer URL
        // 404'd. ike-issues#358 followup.
        //
        // The detectParentArtifactNesting check runs FIRST in
        // publishProjectSiteToGhPages and takes the deeper (doubled)
        // path when present, so this method's shallow check only
        // fires in the legitimate "content lives at staging/<projectId>/"
        // case.
        // Check shallow first: source/<projectId>/
        Path shallow = source.resolve(projectId);
        if (Files.isDirectory(shallow) && !isEmptyDirectory(shallow)) {
            return shallow;
        }
        // Check one level deeper: source/<anyDir>/<projectId>/. This
        // catches the compound case where the workspace's own site is
        // nested under parent-artifactId (#342) AND the reactor's
        // sibling subprojects flatten at the staging root (#351 v1
        // detection didn't fire on the shallow check because the
        // projectId subdir lives at staging/<parentArtifactId>/<projectId>/,
        // not staging/<projectId>/). The first matching deeper
        // candidate wins — staging/<parentArtifactId>/<projectId>/
        // is the only configuration that produces this layout in
        // practice, so we don't disambiguate.
        try (Stream<Path> entries = Files.list(source)) {
            for (Path top : entries.toList()) {
                if (!Files.isDirectory(top)) continue;
                Path deeper = top.resolve(projectId);
                if (Files.isDirectory(deeper)
                        && !isEmptyDirectory(deeper)) {
                    return deeper;
                }
            }
        } catch (IOException e) {
            throw new MojoException(
                    "Could not inspect " + source
                            + " for aggregated staging (deep): "
                            + e.getMessage(), e);
        }
        return null;
    }

    /**
     * Detect URL-as-path staging where {@code site:stage} has
     * mapped an {@code https://}-form {@code <site><url>} to a
     * literal directory tree under {@code target/staging/}
     * (ike-issues#359).
     *
     * <p>For a {@code <site><url>https://ike.network/<projectId>/}
     * inside a multi-module reactor, maven-site-plugin produces
     * {@code target/staging/https:/ike.network/<projectId>/<content>}
     * — scheme ({@code https:}), host ({@code ike.network}), and each
     * path segment each become a real subdirectory. The pre-#304
     * scpexe URLs also went through this transformation, but their
     * path segments aligned with where site-deploy actually wrote,
     * so the layout was sensible. Post-#304 the {@code https://}
     * URLs survive only for relative-path computation and the
     * URL-as-path staging is wasted — the actual publish target is
     * gh-pages.
     *
     * <p>Detection: descend into {@code source/https:/} (or
     * {@code source/http:/}), then a single host directory, then
     * the project's own {@code <projectId>/} subdirectory. When
     * all three exist and {@code <projectId>/} is non-empty,
     * return it as the effective staging source. The caller then
     * applies the remaining unwraps (version-nested #337,
     * parent-artifactId #342, aggregated #351) to the narrowed
     * source — e.g. ike-platform's
     * {@code https://ike.network/ike-platform/${project.version}/}
     * resolves to {@code source/https:/ike.network/ike-platform/}
     * here, then #337 descends into {@code 40/}.
     *
     * <p>Returns {@code null} if any layer of the expected
     * structure is missing or the project's own directory is
     * empty — staying conservative so non-matching projects
     * (single-module reactors, scpexe-URL projects, etc.) fall
     * through to the existing unwrap chain unchanged.
     *
     * @param source    the staging directory to inspect
     * @param projectId the project's artifactId
     * @return the unwrap target, or {@code null} if no
     *         URL-as-path nesting was detected
     * @throws MojoException if a directory cannot be listed
     */
    public static Path detectHttpsUrlStaging(Path source, String projectId)
            throws MojoException {
        if (!Files.isDirectory(source)) return null;
        Path scheme = source.resolve("https:");
        if (!Files.isDirectory(scheme)) {
            scheme = source.resolve("http:");
            if (!Files.isDirectory(scheme)) return null;
        }
        Path host;
        try (Stream<Path> hosts = Files.list(scheme)) {
            List<Path> hostDirs = hosts.filter(Files::isDirectory).toList();
            if (hostDirs.size() != 1) return null;
            host = hostDirs.get(0);
        } catch (IOException e) {
            throw new MojoException(
                    "Could not inspect " + scheme
                            + " for URL-as-path staging: "
                            + e.getMessage(), e);
        }
        Path projectDir = host.resolve(projectId);
        if (!Files.isDirectory(projectDir)
                || isEmptyDirectory(projectDir)) {
            return null;
        }
        return projectDir;
    }

    /**
     * Find one-level-deep subdirectories under {@code layer} that
     * look like rendered Maven-site outputs (have an {@code index.html}
     * at the top), excluding {@code exclude} itself (the workspace's
     * own subtree, already published at the gh-pages root).
     *
     * <p>Used by {@link #publishProjectSiteToGhPages} step (4) to
     * surface sibling submodule subtrees in a multi-module reactor.
     * The conservative {@code index.html}-presence check keeps the
     * walk from mis-classifying resource dirs ({@code css/},
     * {@code fonts/}, {@code images/}) or version dirs as submodule
     * sites.
     *
     * <p>Returns an empty list if {@code layer} is not a directory
     * or has no matching entries. Order is unspecified.
     *
     * @param layer   the directory to scan
     * @param exclude a path that, when matched against an entry,
     *                causes the entry to be skipped (typically the
     *                workspace's own narrowed staging source)
     * @return list of submodule site directories
     * @throws MojoException if the directory cannot be listed
     */
    public static List<Path> findSubmoduleSiteDirs(Path layer, Path exclude)
            throws MojoException {
        List<Path> result = new ArrayList<>();
        if (!Files.isDirectory(layer)) return result;
        Path excludeAbs = exclude == null ? null
                : exclude.toAbsolutePath().normalize();
        try (Stream<Path> entries = Files.list(layer)) {
            for (Path entry : entries.toList()) {
                if (!Files.isDirectory(entry)) continue;
                if (excludeAbs != null
                        && entry.toAbsolutePath().normalize()
                                .equals(excludeAbs)) {
                    continue;
                }
                Path indexHtml = entry.resolve("index.html");
                if (Files.isRegularFile(indexHtml)) {
                    result.add(entry);
                }
            }
        } catch (IOException e) {
            throw new MojoException(
                    "Could not scan for submodule sites at " + layer
                            + ": " + e.getMessage(), e);
        }
        return result;
    }

    /**
     * Recursively delete a directory and all its contents.
     * Best-effort — failures are silently ignored.
     *
     * @param dir the directory to delete
     */
    public static void deleteDirectory(Path dir) {
        try {
            Files.walkFileTree(dir, new SimpleFileVisitor<>() {
                @Override
                public FileVisitResult visitFile(Path file,
                        BasicFileAttributes attrs) throws IOException {
                    Files.delete(file);
                    return FileVisitResult.CONTINUE;
                }

                @Override
                public FileVisitResult postVisitDirectory(Path d,
                        IOException exc) throws IOException {
                    Files.delete(d);
                    return FileVisitResult.CONTINUE;
                }
            });
        } catch (IOException e) {
            // Best-effort — log but don't fail
        }
    }

    /**
     * Recursively copy a directory tree.
     *
     * @param source the source directory to copy from
     * @param target the target directory to copy to
     * @throws IOException if a file cannot be copied
     */
    public static void copyDirectory(Path source, Path target) throws IOException {
        try (Stream<Path> stream = Files.walk(source)) {
            stream.forEach(src -> {
                try {
                    Path dest = target.resolve(source.relativize(src));
                    if (Files.isDirectory(src)) {
                        Files.createDirectories(dest);
                    } else {
                        Files.copy(src, dest, StandardCopyOption.REPLACE_EXISTING);
                    }
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            });
        }
    }

    /**
     * Recursively copy a directory tree, excluding top-level
     * subdirectories whose names look like release versions.
     * Files at the top level and non-version subdirectories at the
     * top level are copied normally. Inside any non-filtered
     * subdirectory, all entries are copied without further filtering
     * — the exclusion applies only at depth 0.
     *
     * <p>Used by {@link #publishProjectSiteToGhPages} to keep the
     * gh-pages source clean of staging-pollution. Three causes of
     * top-level version-prefixed entries in the source:
     * <ol>
     *   <li><strong>Stale local mirrors</strong>: when a project's
     *       {@code site.deploy.url} contains the version segment
     *       and {@code maven-clean-plugin} preserves
     *       {@code target/staging/}, prior releases'
     *       {@code <version>/} subdirs accumulate there.</li>
     *   <li><strong>Self-nesting</strong>: copying staging that
     *       already has a {@code <currentVersion>/} subdir into
     *       a version subdir would produce
     *       {@code <currentVersion>/<currentVersion>/...}.</li>
     *   <li><strong>Race with the wipe step</strong>: the wipe
     *       preserved version dirs in the gh-pages clone; if
     *       staging happens to also contain those names, the copy
     *       would clobber the preserved content with potentially
     *       stale mirror copies.</li>
     * </ol>
     *
     * <p>The filter applies the same digit-prefix heuristic as
     * {@link #isVersionDirName}: top-level dirs whose names start
     * with a digit are skipped.
     *
     * @param source the source directory to copy from
     * @param target the target directory to copy to
     * @throws IOException if a file cannot be copied
     * @see #publishProjectSiteToGhPages
     * @see #isVersionDirName
     */
    public static void copyDirectoryExcludingTopLevelVersionDirs(
            Path source, Path target) throws IOException {
        try (Stream<Path> entries = Files.list(source)) {
            for (Path entry : (Iterable<Path>) entries::iterator) {
                String name = entry.getFileName().toString();
                if (Files.isDirectory(entry) && isVersionDirName(name)) {
                    continue;
                }
                Path dest = target.resolve(name);
                if (Files.isDirectory(entry)) {
                    Files.createDirectories(dest);
                    copyDirectory(entry, dest);
                } else {
                    Files.copy(entry, dest,
                            StandardCopyOption.REPLACE_EXISTING);
                }
            }
        }
    }

    /**
     * Read the {@code <name>} element from a POM file.
     *
     * @param pomFile the POM file to read
     * @return the name, or null if not present
     * @throws MojoException if the file cannot be read
     */
    public static String readPomName(File pomFile) throws MojoException {
        return readPomElement(pomFile, "name");
    }

    /**
     * Read the {@code <description>} element from a POM file.
     *
     * @param pomFile the POM file to read
     * @return the description, or null if not present
     * @throws MojoException if the file cannot be read
     */
    public static String readPomDescription(File pomFile) throws MojoException {
        return readPomElement(pomFile, "description");
    }

    private static String readPomElement(File pomFile, String element)
            throws MojoException {
        try {
            String content = Files.readString(pomFile.toPath(), StandardCharsets.UTF_8);
            Pattern pattern = Pattern.compile(
                    "<" + element + ">([^<]+)</" + element + ">");
            Matcher matcher = pattern.matcher(content);
            return matcher.find() ? matcher.group(1).trim() : null;
        } catch (IOException e) {
            throw new MojoException("Failed to read " + pomFile, e);
        }
    }
}