GitConfigAdapter.java
package network.ike.plugin.scaffold;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Model adapter for git config files ({@code ~/.gitconfig} or a
* repository's {@code .git/config}).
*
* <p>Git config is an INI-flavoured format: sections like
* {@code [core]} and {@code [alias "co"]} (a subsection) contain
* {@code key = value} pairs. This adapter parses the format
* line-by-line, preserving comments and unknown sections verbatim,
* and ensures named keys are present under named sections.
*
* <p>Supported {@code ensure} subtree:
* <pre>{@code
* ensure:
* core:
* autocrlf: "false"
* excludesfile: "~/.gitignore_global"
* "alias":
* st: "status -sb"
* }</pre>
*
* <p>Keys under each section are ensured independently. If a key is
* already present with any value, the user's value wins (we don't
* overwrite), but the key is still recorded as managed. Missing keys
* get appended to the end of the matching section, or a new section is
* created at end of file if the section itself is missing.
*/
public final class GitConfigAdapter implements ModelAdapter {
/** Model name matching {@link ManifestEntry#model()}. */
public static final String MODEL_NAME = "git-config";
/**
* Construct a stateless git-config adapter. Instances are safe to
* share across planning calls; all per-invocation state lives on
* method parameters.
*/
public GitConfigAdapter() {
}
@Override
public String modelName() {
return MODEL_NAME;
}
@Override
public ModelPlanResult plan(
ManifestEntry entry,
Path resolvedDest,
byte[] currentContent,
LockfileEntry priorEntry,
String currentStandardsVersion) {
Map<String, Map<String, String>> ensure =
readEnsureSections(entry);
Map<String, ManagedElement> priorByPath =
indexByPath(priorEntry);
boolean fresh = currentContent == null
|| currentContent.length == 0;
GitConfigDoc doc = fresh
? GitConfigDoc.empty()
: GitConfigDoc.parse(
new String(currentContent,
StandardCharsets.UTF_8));
boolean changed = fresh;
List<ManagedElement> managed = new ArrayList<>();
List<String> userOverrides = new ArrayList<>();
for (Map.Entry<String, Map<String, String>> sec
: ensure.entrySet()) {
String sectionName = sec.getKey();
for (Map.Entry<String, String> kv
: sec.getValue().entrySet()) {
String path = configPath(sectionName, kv.getKey());
if (!doc.hasKey(sectionName, kv.getKey())) {
doc.setKey(
sectionName, kv.getKey(), kv.getValue());
changed = true;
managed.add(new ManagedElement(
path, Instant.now(),
currentStandardsVersion));
} else {
String existing = doc.getKey(
sectionName, kv.getKey());
if (!kv.getValue().equals(existing)) {
userOverrides.add(path);
}
ManagedElement prior = priorByPath.get(path);
managed.add(prior != null
? prior
: new ManagedElement(
path, Instant.now(),
currentStandardsVersion));
}
}
}
if (!changed) {
byte[] currentBytes = currentContent;
String sha = Sha256.of(currentBytes);
if (!userOverrides.isEmpty()) {
return new ModelPlanResult(
new TierAction.UserManaged(
entry, resolvedDest, sha, sha,
userOverrideReason(userOverrides)),
managed);
}
return new ModelPlanResult(
new TierAction.UpToDate(
entry, resolvedDest, sha, sha,
"up to date"),
managed);
}
byte[] newBytes = doc.render()
.getBytes(StandardCharsets.UTF_8);
String sha = Sha256.of(newBytes);
TierAction.Write.Kind kind = fresh
? TierAction.Write.Kind.INSTALL
: TierAction.Write.Kind.UPDATE;
int priorCount = priorEntry == null
? 0
: priorEntry.managedElements().size();
int added = managed.size() - priorCount;
String reason = fresh
? "install git config with "
+ managed.size() + " key(s)"
: "ensure " + Math.max(added, 0) + " key(s)";
return new ModelPlanResult(
new TierAction.Write(
entry, resolvedDest, newBytes,
sha, sha, kind, reason),
managed);
}
// ── helpers ────────────────────────────────────────────────────
private static String configPath(String section, String key) {
return "[" + section + "]." + key;
}
private static String userOverrideReason(List<String> paths) {
String first = paths.get(0);
if (paths.size() == 1) {
return "deferred to user value for " + first;
}
return "deferred to user values for " + first
+ " and " + (paths.size() - 1) + " other(s)";
}
private static Map<String, ManagedElement> indexByPath(
LockfileEntry priorEntry) {
if (priorEntry == null
|| priorEntry.managedElements().isEmpty()) {
return Collections.emptyMap();
}
Map<String, ManagedElement> out = new LinkedHashMap<>();
for (ManagedElement e : priorEntry.managedElements()) {
out.put(e.path(), e);
}
return out;
}
private static Map<String, Map<String, String>> readEnsureSections(
ManifestEntry entry) {
Object ensure = entry.extras().get("ensure");
if (ensure == null) {
return Collections.emptyMap();
}
if (!(ensure instanceof Map<?, ?> ensureMap)) {
throw new ScaffoldException(
"git-config entry '" + entry.dest()
+ "': 'ensure' must be a mapping");
}
Map<String, Map<String, String>> out = new LinkedHashMap<>();
for (Map.Entry<?, ?> sec : ensureMap.entrySet()) {
String sectionName = sec.getKey().toString();
if (!(sec.getValue() instanceof Map<?, ?> kvMap)) {
throw new ScaffoldException(
"git-config entry '" + entry.dest()
+ "': 'ensure." + sectionName
+ "' must be a mapping");
}
Map<String, String> kv = new LinkedHashMap<>();
for (Map.Entry<?, ?> pair : kvMap.entrySet()) {
kv.put(pair.getKey().toString(),
pair.getValue() == null
? ""
: pair.getValue().toString());
}
out.put(sectionName, kv);
}
return out;
}
// ── minimal git-config document model ──────────────────────────
/**
* Very small git-config model. Sufficient for round-tripping
* existing files and ensuring keys, not a full implementation of
* git's config parser.
*/
static final class GitConfigDoc {
private final List<Line> lines;
private GitConfigDoc(List<Line> lines) {
this.lines = lines;
}
static GitConfigDoc empty() {
return new GitConfigDoc(new ArrayList<>());
}
static GitConfigDoc parse(String text) {
List<Line> out = new ArrayList<>();
String currentSection = null;
for (String raw : text.split("\n", -1)) {
String trimmed = raw.trim();
if (trimmed.startsWith("[")
&& trimmed.endsWith("]")) {
currentSection = trimmed.substring(
1, trimmed.length() - 1).trim();
out.add(new Line(
Line.Type.SECTION,
currentSection, null, null, raw));
} else if (trimmed.isEmpty()
|| trimmed.startsWith("#")
|| trimmed.startsWith(";")) {
out.add(new Line(
Line.Type.OTHER,
currentSection, null, null, raw));
} else {
int eq = raw.indexOf('=');
if (eq < 0) {
out.add(new Line(
Line.Type.OTHER,
currentSection, null, null, raw));
} else {
String k = raw.substring(0, eq).trim();
String v = raw.substring(eq + 1).trim();
out.add(new Line(
Line.Type.KEY,
currentSection, k, v, raw));
}
}
}
// trailing '\n' split produces an empty OTHER — drop only
// if the original text ended exactly on a newline.
if (text.endsWith("\n")
&& !out.isEmpty()
&& out.get(out.size() - 1).type == Line.Type.OTHER
&& out.get(out.size() - 1).raw.isEmpty()) {
out.remove(out.size() - 1);
}
return new GitConfigDoc(out);
}
boolean hasKey(String section, String key) {
for (Line l : lines) {
if (l.type == Line.Type.KEY
&& section.equals(l.section)
&& key.equals(l.key)) {
return true;
}
}
return false;
}
String getKey(String section, String key) {
for (Line l : lines) {
if (l.type == Line.Type.KEY
&& section.equals(l.section)
&& key.equals(l.key)) {
return l.value;
}
}
return null;
}
void setKey(String section, String key, String value) {
int sectionIdx = -1;
int afterLast = -1;
for (int i = 0; i < lines.size(); i++) {
Line l = lines.get(i);
if (l.type == Line.Type.SECTION
&& section.equals(l.section)) {
sectionIdx = i;
}
if (sectionIdx >= 0
&& section.equals(l.section)) {
afterLast = i;
}
}
String rendered = "\t" + key + " = " + value;
if (sectionIdx < 0) {
// append new section + key at end
lines.add(new Line(
Line.Type.SECTION, section, null, null,
"[" + section + "]"));
lines.add(new Line(
Line.Type.KEY, section, key, value, rendered));
} else {
lines.add(afterLast + 1, new Line(
Line.Type.KEY, section, key, value, rendered));
}
}
String render() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < lines.size(); i++) {
sb.append(lines.get(i).raw);
if (i < lines.size() - 1) {
sb.append('\n');
}
}
if (sb.length() == 0
|| sb.charAt(sb.length() - 1) != '\n') {
sb.append('\n');
}
return sb.toString();
}
static final class Line {
enum Type { SECTION, KEY, OTHER }
final Type type;
final String section;
final String key;
final String value;
final String raw;
Line(Type type, String section, String key,
String value, String raw) {
this.type = type;
this.section = section;
this.key = key;
this.value = value;
this.raw = raw;
}
}
}
}