StignoreWorkspaceMojo.java

package network.ike.plugin.ws;

import network.ike.workspace.Subproject;
import network.ike.workspace.WorkspaceGraph;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

/**
 * Generate Syncthing {@code .stignore} files for the workspace.
 *
 * <p>Syncthing should sync source files across a developer's own machines
 * but must NOT sync build artifacts or git metadata (those are
 * machine-specific). This goal generates two files:
 *
 * <ul>
 *   <li><b>Workspace-level {@code .stignore}</b> — ignores
 *       {@code target/}, {@code .git/}, IDE files, and OS metadata
 *       within each subproject directory.</li>
 *   <li><b>Per-subproject {@code .stignore}</b> — same patterns, placed
 *       in each cloned subproject for standalone Syncthing folders.</li>
 * </ul>
 *
 * <p>The generated file is deterministic (sorted, no timestamps) so
 * running the goal twice produces identical output — no Syncthing
 * churn.
 *
 * <pre>{@code mvn ike:stignore}</pre>
 */
@Mojo(name = "stignore", projectRequired = false, aggregator = true)
public class StignoreWorkspaceMojo extends AbstractWorkspaceMojo {

    /** Standard patterns that should never be synced. */
    static final List<String> COMMON_IGNORES = List.of(
            "// IKE Workspace .stignore",
            "// Generated by: mvn ike:stignore",
            "// Re-run after adding new components to workspace.yaml",
            "",
            "// ── Build artifacts ────────────────────────────────────",
            "**/target",
            "",
            "// ── Git metadata (each machine has its own clone) ─────",
            "**/.git",
            "",
            "// ── IDE project files ─────────────────────────────────",
            "**/.idea",
            "**/.settings",
            "**/.project",
            "**/.classpath",
            "**/*.iml",
            "**/.vscode",
            "",
            "// ── OS metadata ───────────────────────────────────────",
            ".DS_Store",
            "**/.DS_Store",
            "Thumbs.db",
            "**/Thumbs.db",
            "",
            "// ── Maven local repo + wrapper downloads ──────────────",
            "**/.mvn/local-repo",
            "**/.mvn/wrapper/maven-wrapper.jar",
            "",
            "// ── Claude Code worktrees ─────────────────────────────",
            "**/.claude/worktrees",
            "",
            "// ── Node / frontend build artifacts ──────────────────",
            "**/node_modules"
    );

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

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

        getLog().info("");
        getLog().info(header("Generate .stignore"));
        getLog().info("══════════════════════════════════════════════════════════════");

        // Build the workspace-level .stignore
        List<String> lines = new ArrayList<>(workspaceIgnorePatterns());

        // Write workspace .stignore
        Path workspaceStignore = root.toPath().resolve(".stignore");
        writeStignore(workspaceStignore, lines);
        getLog().info(Ansi.green("  ✓ ") + workspaceStignore);

        // Write per-subproject .stignore for components that are cloned
        int perComponent = 0;
        for (Subproject subproject : graph.manifest().subprojects().values()) {
            File dir = new File(root, subproject.name());
            if (dir.exists()) {
                Path componentStignore = dir.toPath().resolve(".stignore");
                writeStignore(componentStignore, COMMON_IGNORES);
                perComponent++;
                getLog().info(Ansi.green("  ✓ ") + subproject.name() + "/.stignore");
            }
        }

        getLog().info("");
        getLog().info("  Generated: 1 workspace + " + perComponent
                + " subproject .stignore files");
        getLog().info("");

        return new WorkspaceReportSpec(WsGoal.STIGNORE, "Generated **1** workspace + **"
                + perComponent + "** subproject .stignore files.\n");
    }

    private void writeStignore(Path path, List<String> lines)
            throws MojoException {
        try {
            Files.writeString(path,
                    buildStignoreContent(lines),
                    StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new MojoException(
                    "Failed to write " + path, e);
        }
    }

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

    /**
     * Return the standard list of ignore patterns common to all
     * IKE workspace subprojects.
     *
     * @return unmodifiable list of patterns (includes comments and blanks)
     */
    public static List<String> commonIgnorePatterns() {
        return COMMON_IGNORES;
    }

    /**
     * Build the workspace-level .stignore content, which includes the
     * common patterns plus workspace-specific entries.
     *
     * @return workspace .stignore content lines
     */
    public static List<String> workspaceIgnorePatterns() {
        List<String> lines = new ArrayList<>(COMMON_IGNORES);
        lines.add("");
        lines.add("// ── Workspace checkpoint files (git-tracked) ──────────");
        lines.add("checkpoints");
        return List.copyOf(lines);
    }

    /**
     * Join a list of patterns into .stignore file content.
     *
     * <p>Each pattern becomes a line, terminated by a trailing newline.
     *
     * @param patterns the lines to include
     * @return file content ready to write
     */
    public static String buildStignoreContent(List<String> patterns) {
        return String.join("\n", patterns) + "\n";
    }
}