WsCheckpointDraftMojo.java

package network.ike.plugin.ws;

import network.ike.plugin.ReleaseNotesSupport;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.support.GoalReportBuilder;
import network.ike.plugin.ws.preflight.Preflight;
import network.ike.plugin.ws.preflight.PreflightCondition;
import network.ike.plugin.ws.preflight.PreflightContext;
import network.ike.plugin.ws.preflight.PreflightResult;

import network.ike.workspace.ManifestWriter;
import network.ike.workspace.WorkspaceGraph;
import network.ike.plugin.ws.vcs.VcsOperations;
import network.ike.plugin.ws.vcs.VcsState;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Create a workspace checkpoint — tag every subproject at its current HEAD
 * and record the snapshot in a YAML manifest.
 *
 * <p>A checkpoint records the current state of the workspace for reproduction.
 * It is not a build or a release — no POM version changes, no compilation,
 * no deployment. TeamCity watches for checkpoint tags on the workspace repo
 * and handles CI.
 *
 * <p>Each subproject is tagged in topological order (dependencies before
 * dependents). After all subprojects are tagged, a YAML file recording
 * the SHAs, versions, and branches is committed and tagged in the
 * workspace aggregator repo.
 *
 * <pre>{@code
 * mvn ws:checkpoint                          # auto-derived name
 * mvn ws:checkpoint -Dname=sprint-42         # explicit name
 * mvn ws:checkpoint                          # draft (default)
 * mvn ws:checkpoint-publish                  # execute
 * }</pre>
 *
 * @see CheckpointSupport the per-subproject tagging engine
 */
@Mojo(name = "checkpoint-draft", projectRequired = false, aggregator = true)
public class WsCheckpointDraftMojo extends AbstractWorkspaceMojo {

    private static final DateTimeFormatter ISO_UTC =
            DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
                    .withZone(ZoneOffset.UTC);

    private static final DateTimeFormatter COMPACT_UTC =
            DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")
                    .withZone(ZoneOffset.UTC);

    /**
     * Checkpoint name. Used in the YAML filename and tag names.
     * If omitted, auto-derived from the workspace branch and a compact
     * UTC timestamp ({@code <branch>-<yyyyMMdd>-<HHmmss>}).
     */
    @Parameter(property = "name")
    String name;

    /**
     * Show what the checkpoint would do without creating tags or writing
     * files. Set automatically by {@code ws:checkpoint} (bare goal is
     * draft; use {@code ws:checkpoint-publish} to execute).
     */
    @Parameter(property = "publish", defaultValue = "false")
    boolean publish;

    /**
     * GitHub repository for issue tracking, used to snapshot active
     * issues into the checkpoint's testing context.
     */
    @Parameter(property = "issueRepo", defaultValue = "IKE-Network/ike-issues")
    String issueRepo;

    /**
     * Milestone name to snapshot for testing context. If omitted,
     * looks for an open milestone matching the workspace's primary
     * subproject (first subproject in manifest) in the form
     * {@code <artifactId> v<version>} where version is the current
     * SNAPSHOT stripped of the suffix.
     */
    @Parameter(property = "milestone")
    String milestone;

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

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        WorkspaceGraph graph = loadGraph();
        File root = workspaceRoot();

        boolean draft = !publish;

        // Preflight: all working trees must be clean (#132, #154)
        PreflightResult preflight = Preflight.of(
                List.of(PreflightCondition.WORKING_TREE_CLEAN),
                PreflightContext.of(root, graph, graph.topologicalSort()));
        if (draft) {
            preflight.warnIfFailed(getLog(), WsGoal.CHECKPOINT_PUBLISH);
        } else {
            preflight.requirePassed(WsGoal.CHECKPOINT_PUBLISH);
        }

        if (name == null || name.isBlank()) {
            name = deriveCheckpointName(root);
        }

        // Idempotency guard (#294): in publish mode, exit cleanly when a
        // checkpoint with this name was already created. The checkpoint
        // file in checkpoints/ is the durable success marker; if it
        // exists, re-running would either duplicate-tag (failing at git)
        // or rewrite the file with a fresh timestamp (changing the
        // commit). Either way "running twice = same result" is violated.
        if (publish) {
            Path existingCheckpoint = root.toPath().resolve("checkpoints")
                    .resolve(checkpointFileName(name));
            if (Files.isRegularFile(existingCheckpoint)) {
                getLog().info("");
                getLog().info("  Checkpoint '" + name
                        + "' already exists — nothing to do.");
                getLog().info("    " + existingCheckpoint);
                getLog().info("  To create a new checkpoint, pass a "
                        + "different -Dname=…, or delete the existing");
                getLog().info("  file (and the matching checkpoint/"
                        + name + " tag in each subproject) first.");
                getLog().info("");
                return new WorkspaceReportSpec(WsGoal.CHECKPOINT_PUBLISH,
                        "Idempotent skip — checkpoint **`" + name
                                + "`** already exists at `"
                                + root.toPath().relativize(existingCheckpoint)
                                + "`.\n");
            }
        }

        String wsTagName = "checkpoint/" + name;
        String timestamp = ISO_UTC.format(Instant.now());
        String author = resolveAuthor(root);

        getLog().info("");
        getLog().info(header("Checkpoint"));
        getLog().info("══════════════════════════════════════════════════════════════");
        getLog().info("  Name:   " + name);
        getLog().info("  Tag:    " + wsTagName);
        getLog().info("  Time:   " + timestamp);
        getLog().info("  Author: " + author);
        if (draft) {
            getLog().info("  Mode:   DRAFT — no tags, no files written");
        }
        getLog().info("");

        // ── Tag each subproject in dependency order ────────────────────
        List<SubprojectSnapshot> snapshots = new ArrayList<>();
        List<String> absentComponents = new ArrayList<>();
        // Per-subproject closing-keyword trailer issue refs (#394) —
        // report-only, never mutates issue state.
        Map<String, List<ReleaseNotesSupport.IssueRef>> issuesSinceLastRelease =
                new LinkedHashMap<>();

        List<String> ordered = graph.topologicalSort(
                new LinkedHashSet<>(graph.manifest().subprojects().keySet()));

        for (String subName : ordered) {
            File dir = new File(root, subName);
            File gitDir = new File(dir, ".git");

            if (!gitDir.exists()) {
                absentComponents.add(subName);
                getLog().info("  - " + subName + " [absent — skipped]");
                continue;
            }

            String branch   = gitBranch(dir);
            String sha      = gitFullSha(dir);
            String shortSha = gitShortSha(dir);
            String version  = readVersion(dir);

            // Collect issue refs from closing-keyword trailers in commits
            // since the last release tag. Pure read — does not close
            // issues or remove pending-release labels (#394).
            List<ReleaseNotesSupport.IssueRef> issues =
                    collectClosingTrailerIssuesSinceLastRelease(dir);
            if (!issues.isEmpty()) {
                issuesSinceLastRelease.put(subName, issues);
            }

            if (draft) {
                getLog().info(Ansi.green("  ✓ ") + subName
                        + " [" + shortSha + "] " + branch
                        + " (" + version + ")"
                        + (issues.isEmpty() ? ""
                            : " — " + issues.size() + " issue(s) since last release"));
                CheckpointSupport.preview(dir, wsTagName, getLog());
                snapshots.add(new SubprojectSnapshot(
                        subName, sha, shortSha, branch, version, false));
            } else {
                CheckpointSupport.checkpoint(dir, wsTagName, getLog());
                getLog().info(Ansi.green("  ✓ ") + subName
                        + " [" + shortSha + "] → " + wsTagName
                        + (issues.isEmpty() ? ""
                            : " — " + issues.size() + " issue(s) since last release"));
                snapshots.add(new SubprojectSnapshot(
                        subName, sha, shortSha, branch, version, false));
            }
        }

        // ── Build checkpoint YAML ──────────────────────────────────────
        String yamlContent = buildCheckpointYaml(
                name, timestamp, author,
                graph.manifest().schemaVersion(),
                snapshots, absentComponents, issuesSinceLastRelease);

        // ── Append testing context from milestone ─────────────────────
        ReleaseNotesSupport.TestingContext testingContext = snapshotTestingContext(graph);
        if (testingContext != null) {
            yamlContent = yamlContent + "\n" + testingContext.toYaml("  ");
        }

        File wsGitDir = new File(root, ".git");
        boolean workspaceHasGit = wsGitDir.exists();

        Path checkpointFile = null;
        boolean manifestUpdated = false;
        boolean tagPushed = false;

        if (draft) {
            getLog().info("");
            getLog().info("[DRAFT] Checkpoint file would be written to:");
            getLog().info("[DRAFT]   checkpoints/" + checkpointFileName(name));
            getLog().info("");
            getLog().info("[DRAFT] Contents:");
            yamlContent.lines().forEach(line ->
                    getLog().info("[DRAFT]   " + line));
            getLog().info("");
        } else {
            // ── Write subproject SHAs into workspace.yaml ────────────────
            try {
                java.util.Map<String, String> shaUpdates = new java.util.LinkedHashMap<>();
                for (SubprojectSnapshot snap : snapshots) {
                    shaUpdates.put(snap.name(), snap.sha());
                }
                ManifestWriter.updateShas(resolveManifest(), shaUpdates);
                manifestUpdated = true;
                getLog().info("  Updated workspace.yaml with subproject SHAs");
            } catch (IOException e) {
                getLog().warn("  Could not update workspace.yaml SHAs: " + e.getMessage());
            }

            // ── Write checkpoint file ──────────────────────────────────────
            Path checkpointsDir = root.toPath().resolve("checkpoints");
            try {
                Files.createDirectories(checkpointsDir);
            } catch (IOException e) {
                throw new MojoException(
                        "Cannot create checkpoints directory", e);
            }
            checkpointFile = checkpointsDir.resolve(checkpointFileName(name));
            try {
                Files.writeString(checkpointFile, yamlContent, StandardCharsets.UTF_8);
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to write " + checkpointFile, e);
            }

            // ── Tag and push workspace aggregator repo ──────────────────
            if (workspaceHasGit) {
                ReleaseSupport.exec(root, getLog(),
                        "git", "add", "workspace.yaml",
                        "checkpoints/" + checkpointFileName(name));
                ReleaseSupport.exec(root, getLog(),
                        "git", "commit", "-m",
                        "checkpoint: " + name);
                ReleaseSupport.exec(root, getLog(),
                        "git", "tag", "-a", wsTagName,
                        "-m", "Workspace checkpoint " + name);

                boolean hasOrigin = ReleaseSupport.hasRemote(root, "origin");
                if (hasOrigin) {
                    ReleaseSupport.exec(root, getLog(),
                            "git", "push", "origin", wsTagName);
                    ReleaseSupport.exec(root, getLog(),
                            "git", "push", "origin",
                            ReleaseSupport.currentBranch(root));
                    tagPushed = true;
                    getLog().info("  Workspace tag pushed: " + wsTagName);
                }
            }

            // VCS bridge: write state file after checkpoint
            for (var entry : graph.manifest().subprojects().entrySet()) {
                File subDir = new File(root, entry.getKey());
                if (new File(subDir, ".git").exists()
                        && VcsState.isIkeManaged(subDir.toPath())) {
                    VcsOperations.writeVcsState(subDir, VcsState.Action.CHECKPOINT);
                }
            }
            if (VcsState.isIkeManaged(root.toPath())) {
                VcsOperations.writeVcsState(root, VcsState.Action.CHECKPOINT);
            }

            getLog().info("");
            getLog().info("  Checkpoint: " + checkpointFile);
            getLog().info("  Components: " + snapshots.size()
                    + " | Absent: " + absentComponents.size());
            getLog().info("");
        }

        var reportContext = new CheckpointReportContext(
                name, wsTagName, timestamp, author, draft,
                snapshots, absentComponents, yamlContent,
                checkpointFile, workspaceHasGit, manifestUpdated, tagPushed,
                testingContext, issuesSinceLastRelease);
        return new WorkspaceReportSpec(
                publish ? WsGoal.CHECKPOINT_PUBLISH : WsGoal.CHECKPOINT_DRAFT,
                buildCheckpointMarkdownReport(reportContext));
    }

    // ── Per-subproject checkpoint (overridable for tests) ──────────────

    /**
     * Tag a single subproject at its current HEAD. Override in tests
     * to substitute a lighter-weight simulation.
     *
     * @param dir     the subproject directory to checkpoint
     * @param tagName the tag name to apply
     * @throws MojoException if the tagging operation fails
     */
    protected void checkpointComponent(File dir, String tagName)
            throws MojoException {
        CheckpointSupport.checkpoint(dir, tagName, getLog());
    }

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

    /**
     * All inputs needed to render the checkpoint markdown report.
     *
     * @param name                the checkpoint name
     * @param wsTagName           the workspace aggregator tag (e.g. {@code checkpoint/<name>})
     * @param checkpointTimestamp ISO-UTC timestamp recorded in the checkpoint
     * @param author              git user.name (or system user) at execution time
     * @param draft               {@code true} for draft mode, {@code false} for publish
     * @param snapshots           per-subproject HEAD snapshots in topological order
     * @param absentComponents    component names declared in the manifest but not on disk
     * @param yamlContent         the full checkpoint YAML body (for the draft preview)
     * @param checkpointFile      path the YAML was written to (publish only; {@code null} in draft)
     * @param workspaceHasGit     whether the workspace aggregator has its own {@code .git}
     * @param manifestUpdated     whether the workspace.yaml SHA write succeeded (publish only)
     * @param tagPushed           whether the workspace tag was pushed to {@code origin} (publish only)
     * @param testingContext      milestone snapshot for testing context, or {@code null} when unavailable
     */
    private record CheckpointReportContext(
            String name,
            String wsTagName,
            String checkpointTimestamp,
            String author,
            boolean draft,
            List<SubprojectSnapshot> snapshots,
            List<String> absentComponents,
            String yamlContent,
            Path checkpointFile,
            boolean workspaceHasGit,
            boolean manifestUpdated,
            boolean tagPushed,
            ReleaseNotesSupport.TestingContext testingContext,
            Map<String, List<ReleaseNotesSupport.IssueRef>> issuesSinceLastRelease) {}

    private String buildCheckpointMarkdownReport(CheckpointReportContext ctx) {
        GoalReportBuilder report = new GoalReportBuilder();

        StringBuilder lead = new StringBuilder();
        lead.append(ctx.snapshots().size()).append(" subproject(s) checkpointed");
        if (!ctx.absentComponents().isEmpty()) {
            lead.append(", ").append(ctx.absentComponents().size()).append(" absent");
        }
        lead.append(ctx.draft() ? " (draft)" : "").append(".");
        report.paragraph(lead.toString());

        report.section("Checkpoint")
                .bullet("**Name:** " + ctx.name())
                .bullet("**Tag:** `" + ctx.wsTagName() + "`")
                .bullet("**Time:** " + ctx.checkpointTimestamp())
                .bullet("**Author:** " + ctx.author())
                .bullet("**Mode:** " + (ctx.draft()
                        ? "DRAFT — no tags, no files written" : "PUBLISH"));

        List<String[]> subprojectRows = new ArrayList<>();
        for (var snap : ctx.snapshots()) {
            subprojectRows.add(new String[]{
                    snap.name(), snap.version(),
                    "`" + snap.shortSha() + "`", snap.branch(), "✓"});
        }
        for (String absentName : ctx.absentComponents()) {
            subprojectRows.add(new String[]{
                    absentName, "—", "—", "—", "not cloned"});
        }
        report.section("Subprojects")
                .table(List.of("Subproject", "Version", "SHA", "Branch",
                        "Status"), subprojectRows);

        report.section("Outputs");
        String checkpointPath = "checkpoints/" + checkpointFileName(ctx.name());
        if (ctx.draft()) {
            report.bullet("Checkpoint file `" + checkpointPath
                    + "` would be written.");
            if (ctx.workspaceHasGit()) {
                report.bullet("Workspace tag `" + ctx.wsTagName()
                        + "` would be created.");
            } else {
                report.bullet("No `.git` at workspace root; "
                        + "tag/commit/push would be skipped.");
            }
            report.bullet("`workspace.yaml` subproject SHAs would be updated.");
        } else {
            report.bullet("Checkpoint file written: `" + checkpointPath + "`");
            if (ctx.workspaceHasGit()) {
                String tagOutcome = ctx.tagPushed()
                        ? "pushed to `origin`."
                        : "created locally (no `origin` remote — not pushed).";
                report.bullet("Workspace tag `" + ctx.wsTagName()
                        + "` " + tagOutcome);
            } else {
                report.bullet("No `.git` at workspace root; "
                        + "tag/commit/push skipped.");
            }
            report.bullet("`workspace.yaml` subproject SHAs "
                    + (ctx.manifestUpdated()
                            ? "updated" : "**not updated** (write failed)")
                    + ".");
        }

        if (ctx.testingContext() != null) {
            report.raw(ctx.testingContext().toMarkdown().stripTrailing()
                    + "\n\n");
        } else {
            report.section("Testing context")
                    .paragraph("No milestone found — skipping.");
        }

        // #394: report issues referenced by closing trailers in commits
        // since each subproject's last release tag. Report-only — checkpoint
        // never closes issues or removes pending-release labels.
        report.section("Issues since last release");
        if (ctx.issuesSinceLastRelease().isEmpty()) {
            report.paragraph("No closing-trailer issue references found in "
                    + "commits since the last release tag in each subproject.");
        } else {
            report.paragraph("Per-subproject `Fixes`/`Closes`/`Resolves` "
                    + "trailers in commits since the last `v*` tag. **This "
                    + "checkpoint does not close any of these issues** — they "
                    + "remain in their current state until an actual release "
                    + "ships.");
            for (var entry : ctx.issuesSinceLastRelease().entrySet()) {
                report.section(entry.getKey());
                for (ReleaseNotesSupport.IssueRef ref : entry.getValue()) {
                    report.bullet(ref.repo() + "#" + ref.number());
                }
            }
        }

        if (ctx.draft()) {
            report.section("Checkpoint YAML (preview)")
                    .codeBlock("yaml", ctx.yamlContent().stripTrailing());
        }

        return report.build();
    }

    // ── YAML generation (pure, static, testable) ──────────────────────

    /**
     * Build checkpoint YAML content from pre-gathered subproject data.
     *
     * @param name          the checkpoint name
     * @param timestamp     the ISO-UTC creation timestamp
     * @param author        the author who created the checkpoint
     * @param schemaVersion the workspace manifest schema version
     * @param snapshots     the per-subproject snapshot records
     * @param absentNames   names of components not present on disk
     * @return the checkpoint YAML content as a string
     */
    public static String buildCheckpointYaml(String name, String timestamp,
                                              String author, String schemaVersion,
                                              List<SubprojectSnapshot> snapshots,
                                              List<String> absentNames,
                                              Map<String, List<ReleaseNotesSupport.IssueRef>>
                                                      issuesSinceLastRelease) {
        List<String> yaml = new ArrayList<>();
        yaml.add("# IKE Workspace Checkpoint");
        yaml.add("# Generated by: mvn ws:checkpoint-publish");
        yaml.add("#");
        yaml.add("checkpoint:");
        yaml.add("  name: \"" + name + "\"");
        yaml.add("  created: \"" + timestamp + "\"");
        yaml.add("  author: \"" + author + "\"");
        yaml.add("  schema-version: \"" + schemaVersion + "\"");
        yaml.add("");
        yaml.add("  subprojects:");

        for (String absent : absentNames) {
            yaml.add("    " + absent + ":");
            yaml.add("      status: absent");
        }

        for (SubprojectSnapshot snap : snapshots) {
            yaml.add("    " + snap.name() + ":");
            if (snap.version() != null) {
                yaml.add("      version: \"" + snap.version() + "\"");
            }
            yaml.add("      sha: \"" + snap.sha() + "\"");
            yaml.add("      short-sha: \"" + snap.shortSha() + "\"");
            yaml.add("      branch: \"" + snap.branch() + "\"");
            List<ReleaseNotesSupport.IssueRef> refs =
                    issuesSinceLastRelease.get(snap.name());
            if (refs != null && !refs.isEmpty()) {
                yaml.add("      issues-since-last-release:");
                for (ReleaseNotesSupport.IssueRef ref : refs) {
                    yaml.add("        - \"" + ref.repo() + "#"
                            + ref.number() + "\"");
                }
            }
        }

        return String.join("\n", yaml) + "\n";
    }

    /**
     * Derive the checkpoint file name from the checkpoint name.
     *
     * @param checkpointName the checkpoint name
     * @return the filename in the form {@code checkpoint-<name>.yaml}
     */
    public static String checkpointFileName(String checkpointName) {
        return "checkpoint-" + checkpointName + ".yaml";
    }

    // ── Private helpers ────────────────────────────────────────────────

    private String gitFullSha(File dir) {
        try {
            return ReleaseSupport.execCapture(dir, "git", "rev-parse", "HEAD");
        } catch (MojoException e) {
            return "unknown";
        }
    }

    private String readVersion(File dir) throws MojoException {
        return ReleaseSupport.readPomVersion(new File(dir, "pom.xml"));
    }

    /**
     * Collect issue references from closing-keyword trailers
     * ({@code Fixes}, {@code Closes}, {@code Resolves} and grammatical
     * variants) in commits since the given subproject's last release
     * tag (matching {@code v*}). Returns an empty list when no
     * previous release tag is reachable, when no commits are in the
     * range, or when {@code git} or the parser fails.
     *
     * <p>Per IKE-Network/ike-issues#394: this is a <em>report-only</em>
     * collection. The checkpoint never closes any of these issues and
     * never removes {@code pending-release} labels — that's reserved
     * for actual releases ({@link ReleaseNotesSupport#removePendingReleaseLabels}).
     *
     * @param subDir the subproject's git working tree
     * @return ordered set of unique issue references, empty when none
     */
    private List<ReleaseNotesSupport.IssueRef>
            collectClosingTrailerIssuesSinceLastRelease(File subDir) {
        try {
            String previousTag = ReleaseSupport.execCapture(subDir,
                    "git", "describe", "--tags", "--abbrev=0",
                    "--match", "v*", "HEAD");
            if (previousTag == null || previousTag.isBlank()) {
                return List.of();
            }
            String body = ReleaseSupport.execCapture(subDir,
                    "git", "log",
                    "--format=%B%n--end--", previousTag + "..HEAD");
            Set<ReleaseNotesSupport.IssueRef> refs =
                    ReleaseNotesSupport.parseClosingTrailers(body, null);
            return new ArrayList<>(refs);
        } catch (Exception e) {
            // No previous tag, no commits, or git failure — non-fatal
            // for a checkpoint. Empty list signals "nothing to report."
            return List.of();
        }
    }

    private String resolveAuthor(File root) {
        try {
            return ReleaseSupport.execCapture(root, "git", "config", "user.name");
        } catch (MojoException e) {
            return System.getProperty("user.name", "unknown");
        }
    }

    private ReleaseNotesSupport.TestingContext snapshotTestingContext(WorkspaceGraph graph)
            throws MojoException {
        if (issueRepo == null || issueRepo.isBlank()) return null;

        String milestoneName = milestone;

        if (milestoneName == null || milestoneName.isBlank()) {
            var components = graph.manifest().subprojects();
            if (!components.isEmpty()) {
                var first = components.entrySet().iterator().next();
                String subName = first.getKey();
                String version = first.getValue().version();
                if (version != null) {
                    String releaseVersion = version.replace("-SNAPSHOT", "");
                    milestoneName = subName + " v" + releaseVersion;
                }
            }
        }

        if (milestoneName == null || milestoneName.isBlank()) return null;

        getLog().info("  Querying milestone: " + milestoneName);
        var context = ReleaseNotesSupport.snapshotMilestone(
                issueRepo, milestoneName, getLog());

        if (context == null) {
            getLog().info("  No milestone found — skipping testing context");
            return null;
        }

        getLog().info("  Testing context: "
                + context.readyToTest().size() + " ready, "
                + context.inProgress().size() + " in progress");

        return context;
    }

    private String deriveCheckpointName(File root) throws MojoException {
        String branch = gitBranch(root);
        String safeBranch = branch.replace('/', '-');
        String compactTime = COMPACT_UTC.format(Instant.now());
        return safeBranch + "-" + compactTime;
    }
}