FeatureFinishSquashPublishMojo.java

package network.ike.plugin.ws;

import network.ike.workspace.FeatureName;
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.nio.file.Path;
import java.util.Optional;

/**
 * Execute a squash-merge of a feature branch.
 *
 * <p>This is the {@code -publish} counterpart of
 * {@code ws:feature-finish-squash} (which defaults to a draft preview).
 *
 * <p>Usage: {@code mvn ws:feature-finish-squash-publish -Dfeature=done -Dmessage="Ship it"}
 *
 * <p>{@code -DdeleteSibling=true} (IKE-Network/ike-issues#992, settled
 * 2026-08-22) removes the sibling working set after a successful landing:
 * at that moment everything is landed by construction, so the removal
 * preflight (IKE-Network/ike-issues#600) is re-run as a cheap guard and
 * anything it still finds — a stash, an unrelated branch, a bare tree —
 * keeps the sibling in place with a warning rather than deleting it.
 * There is deliberately no force here; deliberate discard belongs to
 * {@code ws:sibling-remove-publish -Dforce=true}. The lease held by
 * {@link #confirmWorkingSetLease()} covers the deletion, and the finish
 * receipt is persisted to the <em>parent</em> first, because its own home
 * vanishes with the sibling.
 *
 * @see FeatureFinishSquashDraftMojo
 * @see SiblingRemovePublishMojo
 */
@Mojo(name = "feature-finish-squash-publish", projectRequired = false, aggregator = true)
public class FeatureFinishSquashPublishMojo extends FeatureFinishSquashDraftMojo {

    /**
     * Remove the sibling working set after a successful landing. Ignored
     * with a warning when the working set is not a local-origin sibling;
     * skipped with a warning when the post-landing preflight still finds
     * anything that would be lost.
     */
    @Parameter(property = "deleteSibling", defaultValue = "false")
    boolean deleteSibling;

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

    @Override
    protected WorkspaceReportSpec runGoal() throws MojoException {
        confirmWorkingSetLease();
        publish = true;
        WorkspaceReportSpec spec;
        // The landing absorbs into the parent, which makes this goal a
        // writer of the parent too — so its lease is short-held across
        // the landing (#992's parent leasing, #1005's ws: half). A hold
        // acquired fresh is given back on close, success or failure.
        try (ParentLeaseHold hold =
                ParentLeaseHold.acquire(workspaceRoot(), getLog())) {
            spec = super.runGoal();
            PostMutationSync.refresh(workspaceRoot(), getLog());
        }
        if (deleteSibling) {
            spec = deleteSiblingAfterLanding(spec);
        }
        return spec;
    }

    /**
     * The delete-on-finish tail: re-assess, then remove the sibling or
     * explain why it stays.
     *
     * <p>When the sibling is deleted, the framework's own receipt write
     * (into the now-gone sibling root) fails silently by design; the
     * receipt written here into the parent is the durable record.
     *
     * @param spec the finish's report so far
     * @return the report, extended with the removal (or kept) section
     */
    private WorkspaceReportSpec deleteSiblingAfterLanding(
            WorkspaceReportSpec spec) {
        File root = workspaceRoot();
        Optional<File> parent = SiblingFinish.localParent(root);
        boolean named = root.getName()
                .contains(FeatureName.SIBLING_SEPARATOR);
        if (!named || parent.isEmpty()) {
            getLog().warn("  -DdeleteSibling: '" + root.getName()
                    + "' is not a local-origin sibling; nothing is deleted.");
            return spec;
        }

        SiblingRemoval.Target target = SiblingRemoval.assessKnown(root, true);
        if (!SiblingRemoval.clean(target)) {
            getLog().warn("  -DdeleteSibling: the sibling still holds "
                    + "something the landing did not carry — kept in place.");
            for (String[] row : SiblingRemoval.preflightRows(target)) {
                if ("✗".equals(row[0])) {
                    getLog().warn("    " + row[1] + ": " + row[2]);
                }
            }
            return new WorkspaceReportSpec(spec.goal(), spec.content()
                    + "\n## Sibling kept\n\nThe post-landing check still "
                    + "found work that would die with the tree (see the "
                    + "warnings above). Review with `"
                    + WsGoal.SIBLING_REMOVE_DRAFT.qualified()
                    + " -Dfeature=" + target.sibling().feature()
                    + "`; discard deliberately with `"
                    + WsGoal.SIBLING_REMOVE_PUBLISH.qualified()
                    + " -Dforce=true`.\n");
        }

        String removedNote = "\n## Sibling removed\n\n`"
                + root.getAbsolutePath() + "` was deleted after the landing "
                + "(-DdeleteSibling=true): every member was clean and "
                + "landed. The sync layer propagates the deletion; "
                + "staggered file versioning is the net. This receipt "
                + "lives in the parent because the sibling is gone.\n";
        WorkspaceReportSpec extended = new WorkspaceReportSpec(spec.goal(),
                spec.content() + removedNote);
        // The parent copy first: after the deletion there is nowhere else
        // for this record to live.
        WorkspaceReport.write(parent.get().toPath(),
                extended.goal().qualified(), extended.content(), getLog());
        Path gcRecord = SiblingRemoval.delete(target);
        getLog().info("  Sibling removed: " + root.getAbsolutePath());
        if (gcRecord != null) {
            getLog().info("  Lease record GC'd: " + gcRecord.getFileName());
        }
        return extended;
    }
}