WorkspaceBootstrap.java
package network.ike.plugin.ws.bootstrap;
import network.ike.plugin.ReleaseSupport;
import network.ike.plugin.ws.Ansi;
import network.ike.plugin.ws.MavenWrapper;
import network.ike.plugin.ws.WsGoal;
import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
/**
* Generates the on-disk scaffold for a brand-new IKE workspace.
*
* <p>Subsumes the file-generation half of the retired
* {@code WsCreateMojo} (folded into {@code ws:scaffold-init} per
* IKE-Network/ike-issues#393): given a target directory and a small
* parameter bag, writes the standard workspace files
* ({@code pom.xml}, {@code workspace.yaml}, {@code .gitignore},
* {@code .mvn/maven.config}, {@code .mvn/jvm.config},
* {@code README.adoc}) and installs the Maven wrapper via
* {@link MavenWrapper#writeMissingFiles}.
*
* <p>The generated files follow current IKE conventions:
* <ul>
* <li>POM uses Maven 4.1.0 model with {@code root="true"}</li>
* <li>.gitignore uses whitelist strategy (ignore everything,
* whitelist workspace-owned files)</li>
* <li>workspace.yaml has schema-version 1.1 with a typed
* {@code workspace-root:} block holding the workspace's GAV
* (ike-issues#183) and an empty {@code subprojects:} list</li>
* <li>.mvn/maven.config sets {@code -T 1C}</li>
* </ul>
*
* <p>This class is a pure scaffold writer — it does not consult
* {@code workspace.yaml} (none exists yet), does not iterate
* subprojects, and never extends {@code AbstractWorkspaceMojo}.
*
* @see SubprojectInitializer for the "workspace.yaml already exists"
* half of {@code ws:scaffold-init}
*/
public final class WorkspaceBootstrap {
/** Parameters captured from the user-facing mojo. */
public record Params(String name,
String description,
String org,
String group,
String artifactId,
String version,
String mavenVersion,
String defaultBranch,
boolean skipGit,
String parentVersion,
String extensionVersion) {}
private final Params params;
private final Log log;
/**
* Create a workspace bootstrapper bound to a parameter bag and a
* logger. Construction is cheap; the actual filesystem writes
* happen in {@link #createAt(Path)}.
*
* @param params the resolved create-time parameters
* @param log the mojo logger
*/
public WorkspaceBootstrap(Params params, Log log) {
this.params = params;
this.log = log;
}
/**
* Write the full workspace scaffold into {@code wsDir} and
* optionally initialize git. The directory must not already
* contain {@code pom.xml} or {@code workspace.yaml}; the caller
* is responsible for that pre-check.
*
* @param wsDir the workspace directory to populate
* @throws MojoException on file or git failures
*/
public void createAt(Path wsDir) throws MojoException {
try {
Files.createDirectories(wsDir);
Files.createDirectories(wsDir.resolve(".mvn"));
writeFile(wsDir.resolve("pom.xml"), generatePom());
writeFile(wsDir.resolve("workspace.yaml"), generateManifest());
writeFile(wsDir.resolve(".gitignore"), generateGitignore());
writeFile(wsDir.resolve(".mvn/maven.config"), "-T 1C\n");
writeFile(wsDir.resolve(".mvn/extensions.xml"), generateExtensionsXml());
// .mvn/jvm.config is parsed as raw JVM args, one token per line,
// with NO comment syntax. A `#` at column 0 is passed to the JVM
// as a main-class name and fails with ClassNotFoundException: #.
// Seed a single standard flag so downstream hand-edits start
// from a correct baseline. The flag suppresses sun.misc.Unsafe
// deprecation warnings from JRuby/AsciidoctorJ on JDK 24+.
writeFile(wsDir.resolve(".mvn/jvm.config"),
"--sun-misc-unsafe-memory-access=allow\n");
writeFile(wsDir.resolve("README.adoc"), generateReadme());
MavenWrapper.writeMissingFiles(wsDir, params.mavenVersion());
log.info(Ansi.green(" ✓ ") + "pom.xml");
log.info(Ansi.green(" ✓ ") + "workspace.yaml");
log.info(Ansi.green(" ✓ ") + ".gitignore");
log.info(Ansi.green(" ✓ ") + ".mvn/maven.config");
log.info(Ansi.green(" ✓ ") + ".mvn/extensions.xml");
log.info(Ansi.green(" ✓ ") + ".mvn/jvm.config");
log.info(Ansi.green(" ✓ ") + "README.adoc");
log.info(Ansi.green(" ✓ ") + "mvnw (Maven " + params.mavenVersion() + ")");
} catch (IOException e) {
throw new MojoException(
"Failed to create workspace files: " + e.getMessage(), e);
}
if (!params.skipGit()) {
try {
initGit(wsDir);
} catch (Exception e) {
log.warn(" Git init failed (non-fatal): " + e.getMessage());
log.warn(" Initialize git manually in " + wsDir);
}
}
}
// ── File generators (pure, testable) ─────────────────────────
/**
* Generate the workspace root POM content. Package-private so
* tests can invoke it directly.
*
* @return the {@code pom.xml} text
*/
String generatePom() {
// ike-parent version is the same as ike-platform version (reactor sibling).
String parentVersion = params.parentVersion();
StringBuilder xml = new StringBuilder(2048);
xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.append("<!--\n");
xml.append(" ").append(params.description()).append("\n");
xml.append("\n");
xml.append(" Inherits from ike-parent to get plugin version management for\n");
xml.append(" ike-maven-plugin and ike-workspace-maven-plugin automatically.\n");
xml.append("\n");
xml.append(" Subprojects are declared unconditionally at top level.\n");
xml.append(" ike-workspace-extension (registered in .mvn/extensions.xml)\n");
xml.append(" prunes entries whose directory is missing from disk before\n");
xml.append(" Maven's model validator gets to them — so a fresh clone\n");
xml.append(" bootstraps via ").append(WsGoal.SCAFFOLD_INIT.qualified()).append(" without manual edits.\n");
xml.append(" See IKE-Network/ike-issues#460.\n");
xml.append("\n");
xml.append(" Usage:\n");
xml.append(" mvn clean install # All cloned repos\n");
xml.append(" mvn ").append(WsGoal.SCAFFOLD_INIT.qualified())
.append(" # Clone all repos\n");
xml.append(" mvn ").append(WsGoal.OVERVIEW.qualified())
.append(" # Workspace overview\n");
xml.append("-->\n");
xml.append("<project xmlns=\"http://maven.apache.org/POM/4.1.0\"\n");
xml.append(" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n");
xml.append(" xsi:schemaLocation=\"http://maven.apache.org/POM/4.1.0\n");
xml.append(" https://maven.apache.org/xsd/maven-4.1.0.xsd\"\n");
xml.append(" root=\"true\">\n");
xml.append(" <modelVersion>4.1.0</modelVersion>\n\n");
xml.append(" <parent>\n");
xml.append(" <groupId>network.ike.platform</groupId>\n");
xml.append(" <artifactId>ike-parent</artifactId>\n");
xml.append(" <version>").append(parentVersion).append("</version>\n");
xml.append(" <relativePath/>\n");
xml.append(" </parent>\n\n");
xml.append(" <groupId>").append(params.group()).append("</groupId>\n");
xml.append(" <artifactId>").append(params.artifactId()).append("</artifactId>\n");
xml.append(" <version>").append(params.version()).append("</version>\n");
xml.append(" <packaging>pom</packaging>\n\n");
xml.append(" <name>").append(params.description()).append("</name>\n\n");
xml.append(" <build>\n");
xml.append(" <plugins>\n");
xml.append(" <plugin>\n");
xml.append(" <groupId>network.ike.tooling</groupId>\n");
xml.append(" <artifactId>ike-maven-plugin</artifactId>\n");
xml.append(" <!-- version from ike-parent pluginManagement -->\n");
xml.append(" </plugin>\n");
xml.append(" <plugin>\n");
xml.append(" <groupId>network.ike.platform</groupId>\n");
xml.append(" <artifactId>ike-workspace-maven-plugin</artifactId>\n");
xml.append(" <!-- version from ike-parent pluginManagement -->\n");
xml.append(" </plugin>\n");
xml.append(" </plugins>\n");
xml.append(" </build>\n\n");
xml.append(" <!-- Subprojects are added by ").append(WsGoal.ADD.qualified())
.append(" -->\n");
xml.append(" <subprojects>\n");
xml.append(" </subprojects>\n\n");
xml.append("</project>\n");
return xml.toString();
}
// ── .mvn/extensions.xml ─────────────────────────────────────
/**
* Sentinel marking the start of the managed extension block in
* {@code .mvn/extensions.xml}. The block between this marker and
* {@link #EXTENSIONS_MANAGED_END} is regenerated by
* {@code ws:scaffold-init} and {@code ws:scaffold-publish}
* (IKE-Network/ike-issues#460) so the ike-workspace-extension
* version stays in lockstep with the {@code ike-parent} property.
*/
public static final String EXTENSIONS_MANAGED_BEGIN =
" <!-- ── managed by ws:scaffold-publish (ike-issues#460) ── -->";
/** Sentinel marking the end of the managed extension block. */
public static final String EXTENSIONS_MANAGED_END =
" <!-- ── /managed ── -->";
/**
* Generate the workspace {@code .mvn/extensions.xml}. Carries the
* {@code wagon-ssh-external} declaration that ike workspaces have
* shipped since #338, plus the {@code ike-workspace-extension}
* entry that does the {@code <subprojects>} pruning (#460).
*
* <p>The {@code ike-workspace-extension} entry is wrapped in
* sentinel markers ({@link #EXTENSIONS_MANAGED_BEGIN} /
* {@link #EXTENSIONS_MANAGED_END}) so subsequent
* {@code ws:scaffold-publish} runs can refresh the version
* literal in place. Maven 4 does not interpolate POM properties
* inside {@code .mvn/extensions.xml} at extension-load time —
* the version must be a literal — so the literal is sourced
* from the {@code ike-workspace-extension.version} property
* declared in {@code ike-parent}.
*
* @return the {@code .mvn/extensions.xml} text
*/
String generateExtensionsXml() {
StringBuilder xml = new StringBuilder(1024);
xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.append("<!--\n");
xml.append(" IKE-Network — .mvn/extensions.xml\n");
xml.append("\n");
xml.append(" Maven build extensions loaded at Maven startup, before any\n");
xml.append(" goal can run. The block between the managed markers below\n");
xml.append(" is refreshed by ").append(WsGoal.SCAFFOLD_PUBLISH.qualified())
.append(".\n");
xml.append("-->\n");
xml.append("<extensions xmlns=\"http://maven.apache.org/EXTENSIONS/1.2.0\"\n");
xml.append(" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n");
xml.append(" xsi:schemaLocation=\"http://maven.apache.org/EXTENSIONS/1.2.0\n");
xml.append(" https://maven.apache.org/xsd/core-extensions-1.2.0.xsd\">\n");
xml.append("\n");
xml.append(" <!-- scpexe:// transport for site:deploy to the internal\n");
xml.append(" mirror. Required by ike-parent's site flow (#338). -->\n");
xml.append(" <extension>\n");
xml.append(" <groupId>org.apache.maven.wagon</groupId>\n");
xml.append(" <artifactId>wagon-ssh-external</artifactId>\n");
xml.append(" <version>3.5.3</version>\n");
xml.append(" </extension>\n");
xml.append("\n");
xml.append(EXTENSIONS_MANAGED_BEGIN).append("\n");
xml.append(" <extension>\n");
xml.append(" <groupId>network.ike.tooling</groupId>\n");
xml.append(" <artifactId>ike-workspace-extension</artifactId>\n");
xml.append(" <version>").append(params.extensionVersion()).append("</version>\n");
xml.append(" </extension>\n");
xml.append(EXTENSIONS_MANAGED_END).append("\n");
xml.append("\n");
xml.append("</extensions>\n");
return xml.toString();
}
/**
* Refresh the managed ike-workspace-extension block in an
* existing {@code .mvn/extensions.xml}. Replaces the
* sentinel-bounded block with a freshly-generated one carrying
* the current {@code extensionVersion}. When the file predates
* the sentinel convention (or lacks an extension entry
* entirely), one-time migrates by appending the managed block
* before the closing {@code </extensions>} tag.
*
* <p>Idempotent. A no-op write is suppressed.
*
* @param extensionsXmlPath path to the {@code .mvn/extensions.xml}
* to refresh
* @param extensionVersion the literal version to write
* @return {@code true} if the file was rewritten, {@code false}
* when the existing content already matches
* @throws IOException on read/write failure
*/
public static boolean refreshExtensionsManagedBlock(Path extensionsXmlPath,
String extensionVersion)
throws IOException {
String existing = Files.readString(extensionsXmlPath, StandardCharsets.UTF_8);
String freshBlock = EXTENSIONS_MANAGED_BEGIN + "\n"
+ " <extension>\n"
+ " <groupId>network.ike.tooling</groupId>\n"
+ " <artifactId>ike-workspace-extension</artifactId>\n"
+ " <version>" + extensionVersion + "</version>\n"
+ " </extension>\n"
+ EXTENSIONS_MANAGED_END + "\n";
int begin = existing.indexOf(EXTENSIONS_MANAGED_BEGIN);
int end = existing.indexOf(EXTENSIONS_MANAGED_END);
String rebuilt;
if (begin >= 0 && end > begin) {
int after = end + EXTENSIONS_MANAGED_END.length();
if (after < existing.length() && existing.charAt(after) == '\n') after++;
rebuilt = existing.substring(0, begin) + freshBlock + existing.substring(after);
} else {
// Migrate: strip any pre-existing standalone
// ike-workspace-extension <extension> block (operators may
// have added the entry by hand before this goal existed),
// then insert the managed block immediately before
// </extensions>. Maven 4 refuses duplicate <extension>
// entries by groupId:artifactId, so dedup is essential.
String stripped = stripExistingExtensionEntry(existing,
"network.ike.tooling", "ike-workspace-extension");
int close = stripped.lastIndexOf("</extensions>");
if (close < 0) {
throw new IOException("Cannot locate </extensions> in "
+ extensionsXmlPath);
}
rebuilt = stripped.substring(0, close)
+ "\n" + freshBlock + "\n"
+ stripped.substring(close);
}
if (rebuilt.equals(existing)) return false;
Files.writeString(extensionsXmlPath, rebuilt, StandardCharsets.UTF_8);
return true;
}
/**
* Remove every {@code <extension>...</extension>} block in
* {@code xml} whose {@code <groupId>} and {@code <artifactId>}
* children equal the supplied values. Used by the
* managed-block migration path to drop hand-added entries before
* inserting the sentinel-wrapped replacement.
*
* @param xml the original XML content
* @param groupId the groupId to match
* @param artifactId the artifactId to match
* @return the content with matching extension blocks removed
*/
private static String stripExistingExtensionEntry(String xml,
String groupId,
String artifactId) {
// Match <extension>...</extension> non-greedily, then test the
// contents for the GA pair. A regex pass is sufficient here:
// extension entries are always written as self-contained blocks,
// never interleaved.
java.util.regex.Pattern p = java.util.regex.Pattern.compile(
"(?s)[ \\t]*<extension>.*?</extension>\\s*");
java.util.regex.Matcher m = p.matcher(xml);
StringBuilder out = new StringBuilder(xml.length());
int last = 0;
while (m.find()) {
String block = m.group();
boolean matches = block.contains("<groupId>" + groupId + "</groupId>")
&& block.contains("<artifactId>" + artifactId + "</artifactId>");
out.append(xml, last, m.start());
if (!matches) {
out.append(block);
}
last = m.end();
}
out.append(xml, last, xml.length());
return out.toString();
}
/**
* Sentinel marking the start of the managed comment header in
* {@code workspace.yaml}. The block between this marker and
* {@link #MANAGED_END} is regenerated by {@code ws:scaffold-init}
* on every run (IKE-Network/ike-issues#458) so the bootstrap
* instructions stay in lockstep with current goal names.
*/
public static final String MANAGED_BEGIN =
"# ── managed: ws:scaffold-init regenerates this block ──";
/** Sentinel marking the end of the managed comment header. */
public static final String MANAGED_END = "# ── /managed ──";
/**
* Build the managed comment header that leads {@code workspace.yaml}.
* Wrapped in {@link #MANAGED_BEGIN} / {@link #MANAGED_END} sentinels
* so {@link #refreshManagedHeader} can replace it in place on every
* {@code ws:scaffold-init} run (ike-issues#458). Hardcoded goal-name
* strings are forbidden — they are pulled from {@link WsGoal} so a
* goal rename propagates.
*
* @param name the workspace name
* @param description the workspace description; falls back to
* {@code name} when blank
* @param org the GitHub org for the clone URL; rendered as
* {@code <org>} when {@code null} or blank
* @return the sentinel-bounded header, ending in a blank line
*/
public static String managedHeader(String name, String description, String org) {
String desc = (description == null || description.isBlank()) ? name : description;
String orgName = (org == null || org.isBlank()) ? "<org>" : org;
StringBuilder yaml = new StringBuilder(512);
yaml.append(MANAGED_BEGIN).append("\n");
yaml.append("# workspace.yaml — ").append(name).append("\n");
yaml.append("# ").append("═".repeat(name.length() + 22)).append("\n");
yaml.append("#\n");
yaml.append("# ").append(desc).append("\n");
yaml.append("#\n");
yaml.append("# Bootstrap:\n");
yaml.append("# git clone https://github.com/").append(orgName).append("/").append(name).append(".git\n");
yaml.append("# cd ").append(name).append("\n");
yaml.append("# mvn ").append(WsGoal.SCAFFOLD_INIT.qualified()).append("\n");
yaml.append("# mvn clean install\n");
yaml.append(MANAGED_END).append("\n\n");
return yaml.toString();
}
/**
* Refresh the managed comment header in an existing
* {@code workspace.yaml}. Replaces the block between
* {@link #MANAGED_BEGIN} and {@link #MANAGED_END} with freshly-
* generated text. When the file predates the sentinel convention,
* one-time migrates by stripping the legacy leading {@code #}-prefix
* comment block (consecutive comment-or-blank lines from the start
* of file, terminated by the first non-comment non-blank line) and
* prepending the new sentinel-bounded block.
*
* <p>Idempotent. A no-op write is suppressed.
*
* @param yamlPath path to the {@code workspace.yaml} to refresh
* @param name the workspace name (typically the workspace
* directory name)
* @param description the workspace description; falls back to
* {@code name} when blank
* @param org the GitHub org; {@code null} renders as
* {@code <org>} placeholder
* @return {@code true} if the file was rewritten, {@code false} when
* the existing header already matches
* @throws IOException on read/write failure
*/
public static boolean refreshManagedHeader(Path yamlPath, String name,
String description, String org)
throws IOException {
String existing = Files.readString(yamlPath, StandardCharsets.UTF_8);
String fresh = managedHeader(name, description, org);
int begin = existing.indexOf(MANAGED_BEGIN);
int end = existing.indexOf(MANAGED_END);
String rebuilt;
if (begin >= 0 && end > begin) {
// Sentinel-bounded — replace the block (including trailing
// newline after MANAGED_END and one optional blank line).
int after = end + MANAGED_END.length();
if (after < existing.length() && existing.charAt(after) == '\n') after++;
if (after < existing.length() && existing.charAt(after) == '\n') after++;
rebuilt = existing.substring(0, begin) + fresh + existing.substring(after);
} else {
// Legacy file with no sentinels — strip the leading comment
// block (consecutive #/blank lines from start of file).
int i = 0;
while (i < existing.length()) {
int eol = existing.indexOf('\n', i);
if (eol < 0) eol = existing.length();
String line = existing.substring(i, eol);
if (!line.isBlank() && !line.startsWith("#")) break;
i = eol + (eol < existing.length() ? 1 : 0);
}
rebuilt = fresh + existing.substring(i);
}
if (rebuilt.equals(existing)) return false;
Files.writeString(yamlPath, rebuilt, StandardCharsets.UTF_8);
return true;
}
/**
* Generate the {@code workspace.yaml} manifest content. Package-private
* for tests.
*
* @return the YAML text
*/
String generateManifest() {
String today = LocalDate.now().toString();
StringBuilder yaml = new StringBuilder(1024);
yaml.append(managedHeader(params.name(), params.description(), params.org()));
yaml.append("schema-version: \"1.1\"\n");
yaml.append("generated: ").append(today).append("\n\n");
// Workspace root coordinates (#183) — real GAV for ws:release-publish,
// ws:align-publish, and site deploy to address. Single-segment monotonic
// version (not semver — per feedback_no_semver_assumption).
yaml.append("workspace-root:\n");
yaml.append(" groupId: ").append(params.group()).append("\n");
yaml.append(" artifactId: ").append(params.artifactId()).append("\n");
yaml.append(" version: ").append(params.version()).append("\n\n");
yaml.append("defaults:\n");
yaml.append(" branch: ").append(params.defaultBranch()).append("\n");
yaml.append(" maven-version: \"").append(params.mavenVersion()).append("\"\n\n");
yaml.append("subprojects:\n");
yaml.append(" # Add subprojects with: mvn " + WsGoal.ADD.qualified()
+ " -Drepo=<git-url>\n\n");
yaml.append("# Optional: IntelliJ project settings shared across collaborators.\n");
yaml.append("# Uncomment and set to have `" + WsGoal.SCAFFOLD_PUBLISH.qualified()
+ "` enforce these values in\n");
yaml.append("# .idea/misc.xml on every run. Useful when the project uses\n");
yaml.append("# --enable-preview (set language-level to JDK_NN_PREVIEW).\n");
yaml.append("# track-misc-xml commits .idea/misc.xml to git; leave it false\n");
yaml.append("# (the default) when developers toggle Maven profiles locally,\n");
yaml.append("# since those toggles live in misc.xml (ike-issues#571).\n");
yaml.append("# ide:\n");
yaml.append("# language-level: JDK_25_PREVIEW\n");
yaml.append("# jdk-name: \"25\"\n");
yaml.append("# track-misc-xml: false\n");
return yaml.toString();
}
/**
* Generate the workspace {@code .gitignore} using the whitelist
* strategy: ignore everything by default, then allowlist only
* workspace-owned files. Subproject repos are independent git
* repos cloned by {@code ws:scaffold-init}, so they must stay
* ignored at the workspace level.
*
* <p>The generated file includes a curated {@code .idea/} slice so
* that fresh checkouts land at the correct IntelliJ project
* settings (JDK, language level including preview mode, encoding,
* Maven repositories) without per-collaborator manual setup.
* {@code compiler.xml} and {@code vcs.xml} are intentionally not
* allowlisted — they regenerate on every Maven reload or per
* workspace membership and would cause constant diff churn.
* {@code misc.xml} is also excluded by default — it co-mingles
* per-machine Maven profile selection; opt in to tracking it with
* {@code ide.track-misc-xml: true} in {@code workspace.yaml}
* (IKE-Network/ike-issues#571). User-specific state
* ({@code workspace.xml}, {@code shelf/}, {@code httpRequests/}) is
* excluded by IntelliJ's own {@code .idea/.gitignore}.
*
* @return the {@code .gitignore} content
*/
String generateGitignore() {
StringBuilder gi = new StringBuilder(768);
gi.append("# ").append(params.name()).append(" .gitignore\n");
gi.append("# ").append("═".repeat(params.name().length() + 11)).append("\n");
gi.append("#\n");
gi.append("# Ignore everything, whitelist only workspace-owned files.\n");
gi.append("# Subproject repos are independent git repos cloned by "
+ WsGoal.SCAFFOLD_INIT.qualified() + ".\n\n");
gi.append("# ── Ignore everything by default ─────────────────────────────────\n");
gi.append("*\n\n");
gi.append("# ── Whitelist workspace-level files ──────────────────────────────\n");
gi.append("!.gitignore\n");
gi.append("!pom.xml\n");
gi.append("!workspace.yaml\n");
gi.append("!README.adoc\n");
gi.append("!GOALS.md\n");
gi.append("!WS-REFERENCE.md\n");
// Generated workspace guidance + hand-authored notes — negated back
// in alongside the cheatsheets (the file ignores * by default) so
// they can actually be committed (IKE-Network/ike-issues#790).
gi.append("!CLAUDE.md\n");
gi.append("!CLAUDE-*.md\n");
gi.append("!mvnw\n");
gi.append("!mvnw.cmd\n\n");
gi.append("# ── Whitelist workspace-owned directories ────────────────────────\n");
gi.append("!.mvn/\n");
gi.append("!.mvn/**\n");
gi.append("!checkpoints/\n");
gi.append("!checkpoints/**\n");
gi.append("!.run/\n");
gi.append("!.run/**\n\n");
gi.append("# ── IntelliJ project config (curated slice) ──────────────────────\n");
gi.append("# Small, stable project-wide settings shared across collaborators.\n");
gi.append("# compiler.xml and vcs.xml are excluded — they regenerate per\n");
gi.append("# Maven reload or per workspace membership. misc.xml is excluded\n");
gi.append("# by default (per-machine Maven profile selection); opt in with\n");
gi.append("# `ide.track-misc-xml: true` in workspace.yaml (ike-issues#571).\n");
gi.append("!.idea/\n");
gi.append("!.idea/.gitignore\n");
gi.append("!.idea/kotlinc.xml\n");
gi.append("!.idea/encodings.xml\n");
gi.append("!.idea/jarRepositories.xml\n");
return gi.toString();
}
/**
* Generate the boilerplate {@code README.adoc}.
*
* @return the AsciiDoc content
*/
String generateReadme() {
String orgName = params.org() != null ? params.org() : "<org>";
StringBuilder adoc = new StringBuilder(1024);
adoc.append("= ").append(params.description()).append("\n");
adoc.append(":toc:\n");
adoc.append(":toc-placement!:\n\n");
adoc.append(params.description()).append("\n\n");
adoc.append("toc::[]\n\n");
adoc.append("== Bootstrap\n\n");
adoc.append("[source,bash]\n");
adoc.append("----\n");
adoc.append("git clone https://github.com/").append(orgName).append("/").append(params.name()).append(".git\n");
adoc.append("cd ").append(params.name()).append("\n");
adoc.append("mvn " + WsGoal.SCAFFOLD_INIT.qualified() + " # <1>\n");
adoc.append("mvn clean install # <2>\n");
adoc.append("----\n");
adoc.append("<1> Clones all subproject repos in dependency order; installs Maven\n");
adoc.append(" wrapper and JVM config per subproject.\n");
adoc.append("<2> Builds the full stack.\n\n");
adoc.append("== Workspace Commands\n\n");
adoc.append("All `ws:` goals appear in the IntelliJ Maven tool window\n");
adoc.append("(under _Plugins > ws_). Double-click any goal to run it.\n\n");
adoc.append("[source,bash]\n");
adoc.append("----\n");
adoc.append("mvn " + WsGoal.OVERVIEW.qualified()
+ " # Workspace overview\n");
adoc.append("mvn " + WsGoal.ADD.qualified()
+ " -Drepo= # Add a subproject repo\n");
adoc.append("mvn " + WsGoal.SCAFFOLD_DRAFT.qualified()
+ " # Preview scaffold and reconciliation drift\n");
adoc.append("----\n");
return adoc.toString();
}
// ── Git init ────────────────────────────────────────────────
private void initGit(Path wsDir) throws MojoException {
ReleaseSupport.exec(wsDir.toFile(), log, "git", "init");
log.info(Ansi.green(" ✓ ") + "git init");
if (params.org() != null && !params.org().isBlank()) {
String remoteUrl = "https://github.com/" + params.org() + "/"
+ params.name() + ".git";
ReleaseSupport.exec(wsDir.toFile(), log,
"git", "remote", "add", "origin", remoteUrl);
log.info(Ansi.green(" ✓ ") + "remote: " + remoteUrl);
}
// Auto-commit scaffold so ws:add and ws:feature-start have a
// baseline commit to work from.
try {
ReleaseSupport.exec(wsDir.toFile(), log,
"git", "add", ".");
ReleaseSupport.exec(wsDir.toFile(), log,
"git", "commit", "-m", "workspace: initial scaffold");
log.info(Ansi.green(" ✓ ") + "initial commit");
} catch (MojoException e) {
log.warn(" Auto-commit failed (set git user.email/user.name): "
+ e.getMessage());
}
}
private static void writeFile(Path path, String content) throws IOException {
Files.writeString(path, content, StandardCharsets.UTF_8);
}
}