GoalCommitLedger.java
package network.ike.plugin.ws;
import network.ike.plugin.ws.vcs.VcsOperations;
import org.apache.maven.api.plugin.MojoException;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* The committed-work ledger of a publish goal (IKE-Network/ike-issues#954).
*
* <p>A publish goal that owns its commits (#919, #780) ends by showing the
* operator exactly what it committed — not a working-tree status check,
* which is empty by design once every goal-authored change is committed.
* The ledger derives that inventory from git truth: the caller records each
* repository's {@code HEAD} immediately after the goal's preflight proved
* the tree free of uncommitted changes ({@link #baselineSha baselineSha}),
* lets the goal run, then {@link #collect collect}s
* {@code git log --name-status baseline..HEAD} per repository. Because the
* preflight guaranteed a committed baseline, every commit in the range is
* provably goal-authored — including commits made by delegated subprocesses
* (the per-subproject {@code ike:scaffold-publish} fan-out) that the
* workspace mojo cannot otherwise observe.
*/
public final class GoalCommitLedger {
private GoalCommitLedger() {}
/**
* One goal-authored commit.
*
* @param sha the 8-character short SHA
* @param subject the commit subject line
* @param files the changed files as {@code "<status> <path>"} entries
* (e.g. {@code "M pom.xml"}); renames and copies as
* {@code "<status> <old> → <new>"}
*/
public record Commit(String sha, String subject, List<String> files) {}
/**
* One repository's ledger entry.
*
* @param label the repository label shown in reports
* @param commits the goal-authored commits, newest first ({@code git log}
* order)
* @param residue stripped porcelain status lines left uncommitted after
* the run — empty on a healthy publish (#919)
*/
public record RepoChanges(String label, List<Commit> commits,
List<String> residue) {
/**
* Whether the goal authored at least one commit in this repository.
*
* @return {@code true} when {@link #commits} is non-empty
*/
public boolean hasCommits() {
return !commits.isEmpty();
}
/**
* Whether files were left uncommitted in this repository.
*
* @return {@code true} when {@link #residue} is non-empty
*/
public boolean hasResidue() {
return !residue.isEmpty();
}
}
/**
* The repository's current {@code HEAD} short SHA, for use as the
* ledger baseline — or {@code null} for a repository with no commits
* yet (an unborn {@code HEAD}), in which case the whole history at
* collect time is goal-authored.
*
* @param dir the repository root directory
* @return the baseline short SHA, or {@code null} when the repository
* has no commits yet
*/
public static String baselineSha(File dir) {
try {
return VcsOperations.headSha(dir);
} catch (MojoException e) {
return null;
}
}
/**
* Collect one repository's ledger entry: the goal-authored commits in
* {@code baseline..HEAD} (each with subject, short SHA, and changed
* files) plus any uncommitted residue. Collection is best-effort — a
* repository whose log cannot be read (e.g. {@code HEAD} still unborn)
* reports no commits rather than failing the goal's report.
*
* @param label the repository label shown in reports
* @param dir the repository root directory
* @param baselineSha the {@code HEAD} recorded by {@link #baselineSha}
* before the goal ran; {@code null} means the
* repository had no commits then, so the whole
* history is goal-authored
* @return the repository's ledger entry
*/
public static RepoChanges collect(String label, File dir,
String baselineSha) {
String range = baselineSha == null ? "HEAD" : baselineSha + "..HEAD";
List<Commit> commits;
try {
commits = parseNameStatusLog(
VcsOperations.nameStatusLog(dir, range));
} catch (MojoException e) {
commits = List.of();
}
List<String> residue = VcsOperations.uncommittedStatus(dir).lines()
.map(String::strip)
.filter(line -> !line.isEmpty())
.toList();
return new RepoChanges(label, commits, residue);
}
/**
* The commit-header marker of
* {@link VcsOperations#nameStatusLog VcsOperations.nameStatusLog}:
* {@code commit} followed by {@code U+0001}. No name-status line can
* collide with it (they open with a status letter and a tab), and —
* unlike a bare leading {@code U+0001} — it survives the trimming the
* subprocess capture applies to git's output.
*/
static final String COMMIT_MARKER = "commit" + (char) 1;
/**
* Parse the output of
* {@link VcsOperations#nameStatusLog VcsOperations.nameStatusLog}: each
* commit opens with a {@link #COMMIT_MARKER}-prefixed
* {@code <short-sha><TAB><subject>} header line, followed by its
* name-status entries ({@code M<TAB>path}, {@code R100<TAB>old<TAB>new},
* …). Blank lines are ignored. Pure — unit-testable without git.
*
* @param raw the raw log output
* @return the parsed commits, in log order (newest first)
*/
static List<Commit> parseNameStatusLog(String raw) {
List<Commit> commits = new ArrayList<>();
String sha = null;
String subject = null;
List<String> files = new ArrayList<>();
for (String line : raw.split("\n", -1)) {
if (line.startsWith(COMMIT_MARKER)) {
if (sha != null) {
commits.add(new Commit(sha, subject, List.copyOf(files)));
}
String[] header = line.substring(COMMIT_MARKER.length()).split("\t", 2);
sha = header[0];
subject = header.length > 1 ? header[1] : "";
files = new ArrayList<>();
} else if (sha != null && !line.isBlank()) {
files.add(formatNameStatus(line));
}
}
if (sha != null) {
commits.add(new Commit(sha, subject, List.copyOf(files)));
}
return List.copyOf(commits);
}
/**
* Render one {@code git log --name-status} entry for display: the
* status letter (similarity scores stripped) and the path, renames and
* copies as {@code old → new}.
*
* @param line one raw name-status line
* @return the display form, e.g. {@code "M pom.xml"} or
* {@code "R a.txt → b.txt"}
*/
private static String formatNameStatus(String line) {
String[] parts = line.split("\t");
if (parts.length >= 3) {
return parts[0].substring(0, 1) + " " + parts[1] + " → "
+ parts[2];
}
if (parts.length == 2) {
return parts[0].substring(0, 1) + " " + parts[1];
}
return line.strip();
}
/**
* Render the repositories' goal-authored commits as nested Markdown
* bullets — repository, then each commit's short SHA and subject, then
* its changed files. Repositories without commits are omitted.
*
* @param repos the collected ledger entries
* @return Markdown bullet lines, empty when no repository has commits
*/
static String commitsToMarkdown(List<RepoChanges> repos) {
StringBuilder md = new StringBuilder();
for (RepoChanges repo : repos) {
if (!repo.hasCommits()) {
continue;
}
md.append("- **").append(repo.label()).append("**\n");
for (Commit commit : repo.commits()) {
md.append(" - `").append(commit.sha()).append("` ")
.append(commit.subject()).append("\n");
for (String file : commit.files()) {
md.append(" - `").append(file).append("`\n");
}
}
}
return md.toString();
}
/**
* Render the repositories' uncommitted residue as nested Markdown
* bullets. Repositories without residue are omitted.
*
* @param repos the collected ledger entries
* @return Markdown bullet lines, empty when no repository has residue
*/
static String residueToMarkdown(List<RepoChanges> repos) {
StringBuilder md = new StringBuilder();
for (RepoChanges repo : repos) {
if (!repo.hasResidue()) {
continue;
}
md.append("- **").append(repo.label()).append("** — ")
.append(repo.residue().size()).append(" file(s)\n");
for (String line : repo.residue()) {
md.append(" - `").append(line).append("`\n");
}
}
return md.toString();
}
}