ExpectedHeadDslCommitter.java
package com.taxonomy.dsl.storage;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.TreeFormatter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/**
* Creates one DSL commit only when the selected branch still has the caller's
* expected head.
*
* <p>The comparison is performed both before object insertion and atomically by
* JGit's ref update. A concurrent change can therefore never be adopted as an
* implicit parent of a relation command. Inserted objects may become unreachable
* after a race, which is normal Git behaviour; the branch remains untouched.</p>
*/
public final class ExpectedHeadDslCommitter {
public CommitResult commit(
DslGitRepository dslRepository,
CommitRequest request) throws IOException {
Objects.requireNonNull(dslRepository, "dslRepository");
Objects.requireNonNull(request, "request");
Repository repository = dslRepository.getGitRepository();
String refName = Constants.R_HEADS + request.branch();
ObjectId expectedHead = request.expectedHeadObjectId();
ObjectId actualHead = currentHead(repository, refName);
if (!actualHead.equals(expectedHead)) {
throw conflict(request.branch(), expectedHead, actualHead, null);
}
PersonIdent actor = personIdent(request.author());
try (ObjectInserter inserter = repository.newObjectInserter()) {
ObjectId blobId = inserter.insert(
Constants.OBJ_BLOB,
request.dslText().getBytes(StandardCharsets.UTF_8));
TreeFormatter tree = new TreeFormatter();
tree.append(DslGitRepository.DSL_FILENAME,
FileMode.REGULAR_FILE, blobId);
ObjectId treeId = inserter.insert(tree);
CommitBuilder commit = new CommitBuilder();
commit.setTreeId(treeId);
commit.setAuthor(actor);
commit.setCommitter(actor);
commit.setMessage(request.message());
if (!ObjectId.zeroId().equals(expectedHead)) {
commit.setParentId(expectedHead);
}
ObjectId commitId = inserter.insert(commit);
inserter.flush();
RefUpdate update = repository.updateRef(refName);
update.setNewObjectId(commitId);
update.setExpectedOldObjectId(expectedHead);
update.setForceUpdate(false);
update.setRefLogIdent(actor);
update.setRefLogMessage("commit: " + request.message(), true);
RefUpdate.Result result = update.update();
switch (result) {
case NEW, FAST_FORWARD, FORCED -> {
return new CommitResult(
commitId.name(),
headName(expectedHead),
result);
}
case LOCK_FAILURE, REJECTED, REJECTED_CURRENT_BRANCH -> {
ObjectId currentHead = currentHead(repository, refName);
throw conflict(
request.branch(), expectedHead, currentHead, result);
}
default -> throw new IOException(
"Ref update failed for " + refName + ": " + result);
}
}
}
/**
* Verifies that a semantic no-op still refers to the exact selected branch
* head and therefore may safely return that commit as the authority token.
*
* <p>For an existing branch the verification uses a no-change ref update
* with an expected-old-object precondition, so a concurrent writer cannot be
* silently accepted. For an expected absent branch JGit has no object ID to
* lock; the method performs a fail-closed exact-ref check and returns
* {@code null} only while the branch remains absent.</p>
*/
public String verifyExpectedHead(
DslGitRepository dslRepository,
String branch,
String expectedHeadCommit) throws IOException {
Objects.requireNonNull(dslRepository, "dslRepository");
String normalizedBranch = normalizeBranch(branch);
ObjectId expectedHead = expectedHeadObjectId(expectedHeadCommit);
Repository repository = dslRepository.getGitRepository();
String refName = Constants.R_HEADS + normalizedBranch;
ObjectId actualHead = currentHead(repository, refName);
if (!actualHead.equals(expectedHead)) {
throw conflict(normalizedBranch, expectedHead, actualHead, null);
}
if (ObjectId.zeroId().equals(expectedHead)) {
return null;
}
RefUpdate update = repository.updateRef(refName);
update.setNewObjectId(expectedHead);
update.setExpectedOldObjectId(expectedHead);
update.setForceUpdate(false);
update.disableRefLog();
RefUpdate.Result result = update.update();
if (result == RefUpdate.Result.NO_CHANGE) {
return expectedHead.name();
}
if (result == RefUpdate.Result.LOCK_FAILURE
|| result == RefUpdate.Result.REJECTED
|| result == RefUpdate.Result.REJECTED_CURRENT_BRANCH) {
throw conflict(
normalizedBranch,
expectedHead,
currentHead(repository, refName),
result);
}
throw new IOException(
"Expected-head verification failed for " + refName + ": " + result);
}
private static ObjectId currentHead(
Repository repository,
String refName) throws IOException {
Ref current = repository.getRefDatabase().exactRef(refName);
return current == null || current.getObjectId() == null
? ObjectId.zeroId()
: current.getObjectId();
}
private static BranchHeadConflictException conflict(
String branch,
ObjectId expected,
ObjectId actual,
RefUpdate.Result result) {
String suffix = result == null ? "" : " (ref update: " + result + ")";
return new BranchHeadConflictException(
branch,
headName(expected),
headName(actual),
"Branch '" + branch + "' moved: expected "
+ headName(expected) + ", actual " + headName(actual)
+ suffix);
}
private static String headName(ObjectId id) {
return id == null || ObjectId.zeroId().equals(id) ? null : id.name();
}
private static PersonIdent personIdent(String author) {
String normalized = author == null || author.isBlank()
? "taxonomy"
: author.strip();
String email = normalized.contains("@")
? normalized
: normalized + "@taxonomy.local";
return new PersonIdent(normalized, email);
}
private static String normalizeBranch(String branch) {
if (branch == null || branch.isBlank()) {
throw new IllegalArgumentException("branch must not be blank");
}
String normalized = branch.strip();
if (!Repository.isValidRefName(Constants.R_HEADS + normalized)) {
throw new IllegalArgumentException(
"invalid branch name: " + normalized);
}
return normalized;
}
private static ObjectId expectedHeadObjectId(String expectedHeadCommit) {
if (expectedHeadCommit == null) {
return ObjectId.zeroId();
}
String normalized = expectedHeadCommit.strip();
if (normalized.isEmpty()) {
throw new IllegalArgumentException(
"expectedHeadCommit must be null or a full commit ID");
}
return ObjectId.fromString(normalized);
}
public record CommitRequest(
String branch,
String expectedHeadCommit,
String dslText,
String author,
String message) {
public CommitRequest {
branch = normalizeBranch(branch);
if (expectedHeadCommit != null) {
expectedHeadCommit = expectedHeadCommit.strip();
ExpectedHeadDslCommitter.expectedHeadObjectId(
expectedHeadCommit);
}
dslText = Objects.requireNonNull(dslText, "dslText");
author = author == null || author.isBlank()
? "taxonomy"
: author.strip();
message = message == null || message.isBlank()
? "DSL update"
: message.strip();
}
ObjectId expectedHeadObjectId() {
return ExpectedHeadDslCommitter.expectedHeadObjectId(
expectedHeadCommit);
}
}
public record CommitResult(
String commitId,
String previousHeadCommit,
RefUpdate.Result refUpdateResult) {
}
public static final class BranchHeadConflictException extends IOException {
private final String branch;
private final String expectedHeadCommit;
private final String actualHeadCommit;
BranchHeadConflictException(
String branch,
String expectedHeadCommit,
String actualHeadCommit,
String message) {
super(message);
this.branch = branch;
this.expectedHeadCommit = expectedHeadCommit;
this.actualHeadCommit = actualHeadCommit;
}
public String getBranch() {
return branch;
}
public String getExpectedHeadCommit() {
return expectedHeadCommit;
}
public String getActualHeadCommit() {
return actualHeadCommit;
}
}
}