TrackedBlockTierHandler.java
package network.ike.plugin.scaffold;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Tier handler for {@link ScaffoldTier#TRACKED_BLOCK}.
*
* <p>Policy: same checksum-guarded refresh as
* {@link ScaffoldTier#TRACKED}, but only a bounded region inside the
* destination file is managed. The region is delimited by the
* {@code block-begin} and {@code block-end} line markers declared on
* the manifest entry. Content outside the markers belongs to the user
* and is never touched.
*
* <p>The {@code source} file in the scaffold zip contains only the
* block content (no markers). When publish installs a brand-new file,
* the markers are added around the template content.
*
* <p>The {@code appliedSha} / {@code templateSha} recorded in the
* lockfile for this tier are hashes of the block <em>content</em>, not
* the whole file. That way edits to unmanaged regions do not look like
* drift on the managed block.
*
* <h2>Whitelist-style {@code .gitignore} awareness</h2>
*
* <p>Workspaces that ignore everything by default and whitelist a
* curated set of files (signalled by a bare {@code *} or {@code **}
* line in the destination) need a different block payload — the
* blacklist patterns shipped in {@code source} would either silently
* have no effect or, worse, cause newly tracked scaffold files to be
* ignored. When the manifest entry sets
* {@code whitelist-block-content} (an inline string in extras) the
* handler substitutes that content for the {@code source} bytes when
* whitelist mode is detected on disk. When whitelist mode is detected
* but no {@code whitelist-block-content} is supplied, the entry is
* reported as user-managed (no write) so the publish run does not
* pollute the user's whitelist.
*/
public final class TrackedBlockTierHandler implements TierHandler {
/**
* Construct a stateless tracked-block tier handler. Instances are
* safe to share across planning calls; all per-invocation state
* lives on method parameters.
*/
public TrackedBlockTierHandler() {
}
@Override
public ScaffoldTier tier() {
return ScaffoldTier.TRACKED_BLOCK;
}
@Override
public TierAction plan(
ManifestEntry entry,
Path resolvedDest,
byte[] currentContent,
byte[] templateContent,
LockfileEntry priorEntry) {
if (templateContent == null) {
throw new ScaffoldException(
"tracked-block entry '" + entry.dest()
+ "' has no template content");
}
String beginMarker = stringExtra(entry, "block-begin");
String endMarker = stringExtra(entry, "block-end");
if (beginMarker == null || beginMarker.isBlank()) {
throw new ScaffoldException(
"tracked-block entry '" + entry.dest()
+ "' missing 'block-begin'");
}
if (endMarker == null || endMarker.isBlank()) {
throw new ScaffoldException(
"tracked-block entry '" + entry.dest()
+ "' missing 'block-end'");
}
String currentStr = str(currentContent);
// Whitelist-mode awareness: when the destination is a
// whitelist-style ignore file (catch-all `*`/`**` line
// present), the blacklist patterns shipped in `source` would
// either be no-ops or silently mask scaffold-tracked files.
// Substitute `whitelist-block-content` from extras when
// supplied; otherwise leave the file alone and report as
// user-managed.
boolean whitelistMode = currentContent != null
&& isWhitelistIgnoreFile(currentStr);
String whitelistContent =
stringExtra(entry, "whitelist-block-content");
if (whitelistMode && (whitelistContent == null
|| whitelistContent.isBlank())) {
return new TierAction.Skip(
entry, resolvedDest,
"whitelist-style ignore file; no "
+ "'whitelist-block-content' supplied — "
+ "leaving file alone",
"");
}
String templateBlock = whitelistMode
? whitelistContent
: str(templateContent);
String templateBlockSha = Sha256.of(templateBlock);
// Case 1: file does not exist yet — create it with just the
// managed block.
if (currentContent == null) {
String newFile = renderBlock(
beginMarker, templateBlock, endMarker);
byte[] out = newFile.getBytes(StandardCharsets.UTF_8);
return new TierAction.Write(
entry, resolvedDest, out,
templateBlockSha, templateBlockSha,
TierAction.Write.Kind.INSTALL,
"install new file with managed block");
}
BlockSlice slice =
locate(currentStr, beginMarker, endMarker, entry);
// Case 2: block absent in an existing file — append one.
if (slice == null) {
String newFile = currentStr
+ (currentStr.isEmpty() || currentStr.endsWith("\n")
? ""
: "\n")
+ renderBlock(
beginMarker, templateBlock, endMarker);
byte[] out = newFile.getBytes(StandardCharsets.UTF_8);
return new TierAction.Write(
entry, resolvedDest, out,
templateBlockSha, templateBlockSha,
TierAction.Write.Kind.INSTALL,
"append managed block");
}
String currentBlock = slice.content();
String currentBlockSha = Sha256.of(currentBlock);
// Case 3: block already matches the template.
if (currentBlock.equals(templateBlock)) {
return new TierAction.UpToDate(
entry, resolvedDest,
templateBlockSha, currentBlockSha,
"up to date");
}
// Case 4: safe refresh — block still matches what we last wrote.
if (priorEntry != null
&& priorEntry.appliedSha() != null
&& priorEntry.appliedSha().equals(currentBlockSha)) {
String newFile = slice.before()
+ renderBlock(
beginMarker, templateBlock, endMarker)
+ slice.after();
byte[] out = newFile.getBytes(StandardCharsets.UTF_8);
LineDiff.Counts c = LineDiff.counts(
currentBlock, templateBlock);
return new TierAction.Write(
entry, resolvedDest, out,
templateBlockSha, templateBlockSha,
TierAction.Write.Kind.UPDATE,
"refresh block (" + c.shortForm() + ")");
}
// Case 5: user edited the block — skip with a block-level diff.
LineDiff.Counts c = LineDiff.counts(
currentBlock, templateBlock);
String diff = LineDiff.unified(currentBlock, templateBlock);
return new TierAction.Skip(
entry, resolvedDest,
(priorEntry == null
? "no prior lockfile entry"
: "user-edited block")
+ "; " + c.shortForm(),
diff);
}
// ── helpers ────────────────────────────────────────────────────
/**
* Detect whether an ignore-style file uses the whitelist
* convention: a top-level catch-all (a bare {@code *} or
* {@code **} line) that ignores everything by default, with
* {@code !pattern} entries to selectively whitelist files.
*
* <p>Comment and blank lines are ignored. The check is intentionally
* narrow — only a bare {@code *}/{@code **} (no surrounding text)
* counts. Files with patterns like {@code *.iml} or {@code build/*}
* are blacklist-mode and remain unaffected.
*
* @param content the file content as text; may be empty but not null
* @return {@code true} if the file matches the whitelist convention
*/
static boolean isWhitelistIgnoreFile(String content) {
if (content == null || content.isEmpty()) {
return false;
}
for (String raw : content.split("\n")) {
String t = stripLineEnding(raw).trim();
if (t.isEmpty() || t.startsWith("#")) {
continue;
}
if (t.equals("*") || t.equals("**")) {
return true;
}
}
return false;
}
/** A parsed managed-block slice within a larger file. */
private record BlockSlice(
String before, String content, String after) {
}
private static BlockSlice locate(
String text, String begin, String end, ManifestEntry entry) {
List<String> lines = splitPreservingLineEndings(text);
int beginIdx = -1;
int endIdx = -1;
for (int i = 0; i < lines.size(); i++) {
String rawLine = lines.get(i);
String trimmedLine = stripLineEnding(rawLine);
if (beginIdx < 0 && trimmedLine.equals(begin)) {
beginIdx = i;
} else if (beginIdx >= 0 && trimmedLine.equals(end)) {
endIdx = i;
break;
}
}
if (beginIdx < 0 && endIdx < 0) {
return null;
}
if (beginIdx >= 0 && endIdx < 0) {
throw new ScaffoldException(
"tracked-block entry '" + entry.dest()
+ "': found '" + begin
+ "' but no matching '" + end + "'");
}
// Check for duplicate begin markers after the first block.
for (int i = endIdx + 1; i < lines.size(); i++) {
if (stripLineEnding(lines.get(i)).equals(begin)) {
throw new ScaffoldException(
"tracked-block entry '" + entry.dest()
+ "': multiple '" + begin
+ "' markers found");
}
}
// `before` and `after` exclude the marker lines themselves —
// the caller will re-emit markers via renderBlock().
StringBuilder before = new StringBuilder();
for (int i = 0; i < beginIdx; i++) {
before.append(lines.get(i));
}
StringBuilder content = new StringBuilder();
for (int i = beginIdx + 1; i < endIdx; i++) {
content.append(lines.get(i));
}
StringBuilder after = new StringBuilder();
for (int i = endIdx + 1; i < lines.size(); i++) {
after.append(lines.get(i));
}
return new BlockSlice(
before.toString(),
content.toString(),
after.toString());
}
private static String renderBlock(
String begin, String content, String end) {
StringBuilder sb = new StringBuilder();
sb.append(begin).append('\n');
sb.append(content);
if (!content.isEmpty() && !content.endsWith("\n")) {
sb.append('\n');
}
sb.append(end).append('\n');
return sb.toString();
}
/**
* Split like {@code text.lines()} but preserve the newline at the
* end of each produced line, so joining the pieces reproduces the
* original string byte-for-byte.
*/
private static List<String> splitPreservingLineEndings(String s) {
List<String> out = new ArrayList<>();
if (s == null || s.isEmpty()) {
return out;
}
int start = 0;
for (int k = 0; k < s.length(); k++) {
if (s.charAt(k) == '\n') {
out.add(s.substring(start, k + 1));
start = k + 1;
}
}
if (start < s.length()) {
out.add(s.substring(start));
}
return out;
}
private static String stripLineEnding(String s) {
if (s.endsWith("\r\n")) {
return s.substring(0, s.length() - 2);
}
if (s.endsWith("\n")) {
return s.substring(0, s.length() - 1);
}
return s;
}
private static String stringExtra(
ManifestEntry entry, String key) {
Object v = entry.extras().get(key);
return v == null ? null : v.toString();
}
private static String str(byte[] bytes) {
return bytes == null
? ""
: new String(bytes, StandardCharsets.UTF_8);
}
}