DocumentTemplateGitRepository.java
package com.taxonomy.templates;
import io.github.carstenartur.jgit.storage.hibernate.HibernateGitStorage;
import io.github.carstenartur.jgit.storage.hibernate.HibernateRepositoryFactory;
import io.github.carstenartur.jgit.storage.hibernate.RepositoryName;
import jakarta.annotation.PreDestroy;
import org.eclipse.jgit.dircache.DirCache;
import org.eclipse.jgit.dircache.DirCacheBuilder;
import org.eclipse.jgit.dircache.DirCacheEntry;
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.ObjectLoader;
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.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.springframework.stereotype.Component;
import tools.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Pattern;
/**
* Versions canonical, unzipped OOXML template packages in one logical JGit repository.
*
* <p>This facade is intentionally separate from {@code DslGitRepository}, which owns
* the single-file {@code architecture.taxdsl} convention. The branch head is the atomic
* repository update boundary, while each template exposes the most recent commit that
* actually changed its own subtree as its representation version and HTTP ETag.</p>
*/
@Component
public class DocumentTemplateGitRepository implements AutoCloseable {
static final String REPOSITORY_NAME = "taxonomy-document-templates";
static final String BRANCH = "main";
static final String ROOT = "templates/";
static final String MANIFEST_NAME = "template.json";
static final String PACKAGE_DIRECTORY = "package/";
private static final Pattern COMMIT_ID = Pattern.compile("[0-9a-fA-F]{40}");
private static final Pattern SHA256 = Pattern.compile("[0-9a-f]{64}");
private static final ObjectMapper JSON = new ObjectMapper();
private static final int MAX_WRITE_ATTEMPTS = 5;
private final HibernateGitStorage storageHandle;
private final Repository repository;
private final boolean closeRepository;
private final ReentrantLock writeLock = new ReentrantLock();
@org.springframework.beans.factory.annotation.Autowired
public DocumentTemplateGitRepository(HibernateRepositoryFactory storageFactory) {
this(Objects.requireNonNull(storageFactory, "storageFactory")
.open(new RepositoryName(REPOSITORY_NAME)));
}
private DocumentTemplateGitRepository(HibernateGitStorage storageHandle) {
this.storageHandle = storageHandle;
this.repository = storageHandle.repository();
this.closeRepository = false;
}
DocumentTemplateGitRepository(Repository repository) {
this.storageHandle = null;
this.repository = Objects.requireNonNull(repository, "repository");
this.closeRepository = false;
}
/**
* Commit a complete replacement package while preserving all other templates.
*
* <p>A {@code null} expected template version means create-only. A non-null version
* must match the most recent commit that changed this template. The repository-wide
* branch head is intentionally not exposed as the template ETag: unrelated template
* changes can therefore be rebased transparently without causing false conflicts.</p>
*/
public TemplateSnapshot commit(
TemplateManifest manifest,
Map<String, byte[]> packageParts,
String expectedTemplateVersion,
String author,
String message) throws IOException {
Objects.requireNonNull(manifest, "manifest");
Objects.requireNonNull(packageParts, "packageParts");
validateStoredManifest(manifest.templateId(), manifest, packageParts);
String prefix = templatePrefix(manifest.templateId());
String normalizedExpected = normalizeExpectedVersion(expectedTemplateVersion);
writeLock.lock();
try {
for (int attempt = 1; attempt <= MAX_WRITE_ATTEMPTS; attempt++) {
Ref currentRef = repository.getRefDatabase()
.exactRef(Constants.R_HEADS + BRANCH);
ObjectId repositoryHead = currentRef == null
? null : currentRef.getObjectId();
String actualTemplateVersion = currentTemplateVersion(
manifest.templateId(), repositoryHead);
requireTemplatePrecondition(
normalizedExpected, actualTemplateVersion);
TreeMap<String, ObjectId> treeObjects = repositoryHead == null
? new TreeMap<>()
: readTreeObjectIds(repositoryHead);
treeObjects.keySet().removeIf(path -> path.startsWith(prefix));
ObjectId commitId;
PersonIdent identity = identity(author);
try (ObjectInserter inserter = repository.newObjectInserter()) {
byte[] manifestBytes = JSON.writeValueAsBytes(manifest);
treeObjects.put(prefix + MANIFEST_NAME,
inserter.insert(Constants.OBJ_BLOB, manifestBytes));
TreeMap<String, byte[]> sortedParts = new TreeMap<>(packageParts);
for (Map.Entry<String, byte[]> part : sortedParts.entrySet()) {
treeObjects.put(prefix + PACKAGE_DIRECTORY + part.getKey(),
inserter.insert(Constants.OBJ_BLOB, part.getValue()));
}
DirCache cache = DirCache.newInCore();
DirCacheBuilder builder = cache.builder();
for (Map.Entry<String, ObjectId> entry : treeObjects.entrySet()) {
DirCacheEntry cacheEntry = new DirCacheEntry(entry.getKey());
cacheEntry.setFileMode(FileMode.REGULAR_FILE);
cacheEntry.setObjectId(entry.getValue());
builder.add(cacheEntry);
}
builder.finish();
ObjectId treeId = cache.writeTree(inserter);
CommitBuilder commit = new CommitBuilder();
commit.setTreeId(treeId);
commit.setAuthor(identity);
commit.setCommitter(identity);
commit.setMessage(message == null || message.isBlank()
? "Update document template " + manifest.templateId()
: message.strip());
if (repositoryHead != null) {
commit.setParentId(repositoryHead);
}
commitId = inserter.insert(commit);
inserter.flush();
}
RefUpdate update = repository.updateRef(Constants.R_HEADS + BRANCH);
update.setNewObjectId(commitId);
update.setExpectedOldObjectId(repositoryHead == null
? ObjectId.zeroId() : repositoryHead);
update.setForceUpdate(false);
update.setRefLogIdent(identity);
update.setRefLogMessage("template: " + manifest.templateId(), false);
RefUpdate.Result result = update.update();
if (successful(result)) {
return read(manifest.templateId(), commitId.name());
}
if (!concurrent(result)) {
throw new IOException("Template repository ref update failed: " + result);
}
// Another process advanced the shared branch. Re-read it and retry only
// if this template still satisfies the same per-template precondition.
}
String actual = currentTemplateVersion(
manifest.templateId(), headObjectId());
throw new TemplateConflictException(normalizedExpected, actual);
} finally {
writeLock.unlock();
}
}
public List<TemplateDescriptor> list() throws IOException {
ObjectId head = headObjectId();
if (head == null) {
return List.of();
}
Map<String, byte[]> manifests = readManifestFiles(head);
List<TemplateDescriptor> templates = new ArrayList<>();
for (Map.Entry<String, byte[]> file : manifests.entrySet()) {
String pathTemplateId = templateIdFromManifestPath(file.getKey());
if (pathTemplateId == null) {
continue;
}
TemplateManifest manifest = JSON.readValue(
file.getValue(), TemplateManifest.class);
validateStoredManifest(pathTemplateId, manifest, null);
String version = currentTemplateVersion(pathTemplateId, head);
if (version == null) {
continue;
}
templates.add(new TemplateDescriptor(
manifest.templateId(),
manifest.displayName(),
manifest.fileName(),
version,
manifest.updatedAt(),
manifest.updatedBy(),
manifest.uncompressedSize(),
manifest.partCount(),
manifest.packageSha256()));
}
return templates.stream()
.sorted(Comparator.comparing(TemplateDescriptor::displayName,
String.CASE_INSENSITIVE_ORDER))
.toList();
}
public TemplateSnapshot readCurrent(String templateId) throws IOException {
validateTemplateId(templateId);
ObjectId head = headObjectId();
String version = currentTemplateVersion(templateId, head);
if (version == null) {
throw new TemplateNotFoundException(templateId, null);
}
return read(templateId, version);
}
public TemplateSnapshot read(String templateId, String revision) throws IOException {
validateTemplateId(templateId);
ObjectId objectId = resolveRevision(revision);
String prefix = templatePrefix(templateId);
Map<String, byte[]> files = readFiles(objectId, prefix);
byte[] manifestBytes = files.get(prefix + MANIFEST_NAME);
if (manifestBytes == null) {
throw new TemplateNotFoundException(templateId, revision);
}
TemplateManifest manifest = JSON.readValue(
manifestBytes, TemplateManifest.class);
TreeMap<String, byte[]> parts = new TreeMap<>();
String packagePrefix = prefix + PACKAGE_DIRECTORY;
files.forEach((path, content) -> {
if (path.startsWith(packagePrefix)) {
parts.put(path.substring(packagePrefix.length()), content);
}
});
validateStoredManifest(templateId, manifest, parts);
return new TemplateSnapshot(manifest, objectId.name(), parts);
}
public List<TemplateRevision> history(String templateId) throws IOException {
validateTemplateId(templateId);
ObjectId head = headObjectId();
if (head == null) {
return List.of();
}
String path = templateDirectoryPath(templateId);
List<TemplateRevision> history = new ArrayList<>();
try (RevWalk walk = new RevWalk(repository)) {
walk.markStart(walk.parseCommit(head));
for (RevCommit commit : walk) {
ObjectId current = pathObjectId(commit.getTree(), path);
ObjectId parent = null;
if (commit.getParentCount() > 0) {
RevCommit parsedParent = walk.parseCommit(commit.getParent(0));
parent = pathObjectId(parsedParent.getTree(), path);
}
if (current == null || Objects.equals(current, parent)) {
continue;
}
history.add(new TemplateRevision(
commit.name(),
commit.getAuthorIdent().getName(),
Instant.ofEpochSecond(commit.getCommitTime()).toString(),
commit.getFullMessage()));
}
}
return List.copyOf(history);
}
public TemplateDiff diff(String templateId, String fromRevision, String toRevision)
throws IOException {
TemplateSnapshot before = read(templateId, fromRevision);
TemplateSnapshot after = read(templateId, toRevision);
TreeMap<String, byte[]> beforeParts = new TreeMap<>(before.parts());
TreeMap<String, byte[]> afterParts = new TreeMap<>(after.parts());
TreeMap<String, PartChange> changes = new TreeMap<>();
for (String path : beforeParts.keySet()) {
if (!afterParts.containsKey(path)) {
changes.put(path, PartChange.DELETED);
} else if (!Arrays.equals(beforeParts.get(path), afterParts.get(path))) {
changes.put(path, PartChange.MODIFIED);
}
}
for (String path : afterParts.keySet()) {
if (!beforeParts.containsKey(path)) {
changes.put(path, PartChange.ADDED);
}
}
return new TemplateDiff(templateId, before.commitId(), after.commitId(), changes);
}
/** Repository-wide branch head used only for diagnostics and atomic ref updates. */
public String headCommit() throws IOException {
ObjectId head = headObjectId();
return head == null ? null : head.name();
}
private String currentTemplateVersion(String templateId, ObjectId head)
throws IOException {
if (head == null) {
return null;
}
String path = templateDirectoryPath(templateId);
try (RevWalk walk = new RevWalk(repository)) {
RevCommit commit = walk.parseCommit(head);
if (pathObjectId(commit.getTree(), path) == null) {
return null;
}
while (true) {
ObjectId current = pathObjectId(commit.getTree(), path);
RevCommit parentCommit = null;
ObjectId parent = null;
if (commit.getParentCount() > 0) {
parentCommit = walk.parseCommit(commit.getParent(0));
parent = pathObjectId(parentCommit.getTree(), path);
}
if (!Objects.equals(current, parent)) {
return commit.name();
}
if (parentCommit == null) {
return null;
}
commit = parentCommit;
}
}
}
private static void requireTemplatePrecondition(
String expected,
String actual) throws TemplateConflictException {
if (expected == null) {
if (actual != null) {
throw new TemplateConflictException(null, actual);
}
return;
}
if (!expected.equals(actual)) {
throw new TemplateConflictException(expected, actual);
}
}
private static String normalizeExpectedVersion(String expected) {
if (expected == null || expected.isBlank()) {
return null;
}
String normalized = expected.strip();
if (!COMMIT_ID.matcher(normalized).matches()) {
throw new IllegalArgumentException(
"Expected template version must be a full Git commit ID");
}
return normalized.toLowerCase(java.util.Locale.ROOT);
}
private ObjectId resolveRevision(String revision) throws IOException {
if (revision == null || revision.isBlank()) {
ObjectId head = headObjectId();
if (head == null) {
throw new TemplateNotFoundException("", revision);
}
return head;
}
if (!COMMIT_ID.matcher(revision).matches()) {
throw new IllegalArgumentException("Template revision must be a full Git commit ID");
}
ObjectId candidate = ObjectId.fromString(revision);
try (RevWalk walk = new RevWalk(repository)) {
walk.parseCommit(candidate);
return candidate;
} catch (org.eclipse.jgit.errors.MissingObjectException exception) {
throw new TemplateNotFoundException("", revision);
}
}
private ObjectId headObjectId() throws IOException {
Ref ref = repository.getRefDatabase().exactRef(Constants.R_HEADS + BRANCH);
return ref == null ? null : ref.getObjectId();
}
private TreeMap<String, ObjectId> readTreeObjectIds(ObjectId commitId) throws IOException {
TreeMap<String, ObjectId> result = new TreeMap<>();
try (RevWalk walk = new RevWalk(repository);
TreeWalk tree = new TreeWalk(repository)) {
tree.addTree(walk.parseCommit(commitId).getTree());
tree.setRecursive(true);
while (tree.next()) {
result.put(tree.getPathString(), tree.getObjectId(0).copy());
}
}
return result;
}
private Map<String, byte[]> readManifestFiles(ObjectId commitId) throws IOException {
LinkedHashMap<String, byte[]> result = new LinkedHashMap<>();
try (RevWalk walk = new RevWalk(repository);
TreeWalk tree = new TreeWalk(repository)) {
tree.addTree(walk.parseCommit(commitId).getTree());
tree.setRecursive(true);
while (tree.next()) {
String path = tree.getPathString();
if (templateIdFromManifestPath(path) == null) {
continue;
}
ObjectLoader loader = repository.open(
tree.getObjectId(0), Constants.OBJ_BLOB);
result.put(path, loader.getBytes(1_048_576));
}
}
return result;
}
private Map<String, byte[]> readFiles(ObjectId commitId, String prefix) throws IOException {
LinkedHashMap<String, byte[]> result = new LinkedHashMap<>();
try (RevWalk walk = new RevWalk(repository);
TreeWalk tree = new TreeWalk(repository)) {
tree.addTree(walk.parseCommit(commitId).getTree());
tree.setRecursive(true);
while (tree.next()) {
String path = tree.getPathString();
if (!path.startsWith(prefix)) {
continue;
}
ObjectLoader loader = repository.open(
tree.getObjectId(0), Constants.OBJ_BLOB);
result.put(path, loader.getBytes(MAX_STORED_PART_BYTES));
}
}
return result;
}
private ObjectId pathObjectId(RevTree tree, String path) throws IOException {
try (TreeWalk walk = TreeWalk.forPath(repository, path, tree)) {
return walk == null ? null : walk.getObjectId(0).copy();
}
}
private static final int MAX_STORED_PART_BYTES =
OoxmlTemplatePackageCodec.MAX_PART_BYTES + 1_048_576;
private static String templatePrefix(String templateId) {
return templateDirectoryPath(templateId) + "/";
}
private static String templateDirectoryPath(String templateId) {
validateTemplateId(templateId);
return ROOT + templateId;
}
private static String templateIdFromManifestPath(String path) {
if (path == null || !path.startsWith(ROOT)) {
return null;
}
String relative = path.substring(ROOT.length());
int slash = relative.indexOf('/');
if (slash <= 0 || slash != relative.lastIndexOf('/')
|| !MANIFEST_NAME.equals(relative.substring(slash + 1))) {
return null;
}
String templateId = relative.substring(0, slash);
try {
validateTemplateId(templateId);
return templateId;
} catch (IllegalArgumentException invalid) {
return null;
}
}
private static void validateStoredManifest(
String expectedTemplateId,
TemplateManifest manifest,
Map<String, byte[]> parts) throws IOException {
if (manifest.schemaVersion() != 1) {
throw new IOException("Unsupported stored template manifest schema version: "
+ manifest.schemaVersion());
}
if (!expectedTemplateId.equals(manifest.templateId())) {
throw new IOException("Stored template manifest ID does not match its path");
}
String normalizedDisplayName;
try {
normalizedDisplayName = DocumentTemplateService.normalizeDisplayName(
manifest.displayName(), expectedTemplateId);
} catch (IllegalArgumentException invalid) {
throw new IOException("Stored template display name is invalid", invalid);
}
if (!Objects.equals(normalizedDisplayName, manifest.displayName())) {
throw new IOException("Stored template display name is not canonical");
}
if (!Objects.equals(expectedTemplateId + ".dotx", manifest.fileName())) {
throw new IOException("Stored template filename does not match its ID");
}
if (!Objects.equals(
OoxmlTemplatePackageCodec.DOTX_MEDIA_TYPE, manifest.mediaType())) {
throw new IOException("Stored template media type is invalid");
}
try {
Instant.parse(manifest.updatedAt());
} catch (RuntimeException invalid) {
throw new IOException("Stored template update timestamp is invalid", invalid);
}
if (manifest.uncompressedSize() < 0 || manifest.partCount() < 0) {
throw new IOException("Stored template package statistics are invalid");
}
if (manifest.packageSha256() == null
|| !SHA256.matcher(manifest.packageSha256()).matches()) {
throw new IOException("Stored template package checksum is invalid");
}
if (parts == null) {
return;
}
long actualSize = 0;
for (Map.Entry<String, byte[]> part : parts.entrySet()) {
if (part.getKey() == null || part.getValue() == null) {
throw new IOException("Stored OOXML package contains a null part");
}
actualSize += part.getValue().length;
}
if (manifest.partCount() != parts.size()) {
throw new IOException("Stored OOXML part count does not match its manifest");
}
if (manifest.uncompressedSize() != actualSize) {
throw new IOException("Stored OOXML size does not match its manifest");
}
String actualHash = OoxmlTemplatePackageCodec.packageSha256(parts);
if (!actualHash.equals(manifest.packageSha256())) {
throw new IOException("Stored OOXML package checksum does not match its manifest");
}
}
static void validateTemplateId(String templateId) {
if (templateId == null
|| !templateId.matches("[a-z0-9][a-z0-9._-]{0,79}")) {
throw new IllegalArgumentException(
"Template ID must match [a-z0-9][a-z0-9._-]{0,79}");
}
}
private static PersonIdent identity(String author) {
String name = author == null || author.isBlank() ? "taxonomy" : author.strip();
name = name.replaceAll("[\\r\\n<>]", "_");
String local = name.toLowerCase(java.util.Locale.ROOT)
.replaceAll("[^a-z0-9._-]", "_");
if (local.isBlank()) {
local = "taxonomy";
}
return new PersonIdent(name, local + "@taxonomy.local");
}
private static boolean successful(RefUpdate.Result result) {
return result == RefUpdate.Result.NEW
|| result == RefUpdate.Result.FAST_FORWARD
|| result == RefUpdate.Result.NO_CHANGE;
}
private static boolean concurrent(RefUpdate.Result result) {
return result == RefUpdate.Result.REJECTED
|| result == RefUpdate.Result.LOCK_FAILURE;
}
@PreDestroy
@Override
public void close() {
if (storageHandle != null) {
storageHandle.close();
} else if (closeRepository) {
repository.close();
}
}
public record TemplateManifest(
int schemaVersion,
String templateId,
String displayName,
String fileName,
String mediaType,
String updatedAt,
String updatedBy,
long uncompressedSize,
int partCount,
String packageSha256) {
}
/**
* Current per-template representation metadata. Despite the historic field name,
* {@code headCommit} is the last commit that changed this template, not the shared
* repository branch head.
*/
public record TemplateDescriptor(
String templateId,
String displayName,
String fileName,
String headCommit,
String updatedAt,
String updatedBy,
long uncompressedSize,
int partCount,
String packageSha256) {
}
public record TemplateSnapshot(
TemplateManifest manifest,
String commitId,
Map<String, byte[]> parts) {
public TemplateSnapshot {
TreeMap<String, byte[]> copy = new TreeMap<>();
parts.forEach((path, value) ->
copy.put(path, Arrays.copyOf(value, value.length)));
parts = Map.copyOf(copy);
}
}
public record TemplateRevision(
String commitId,
String author,
String committedAt,
String message) {
}
public record TemplateDiff(
String templateId,
String fromRevision,
String toRevision,
Map<String, PartChange> changes) {
}
public enum PartChange {
ADDED,
MODIFIED,
DELETED
}
public static final class TemplateConflictException extends IOException {
private final String expectedHead;
private final String actualHead;
TemplateConflictException(String expectedHead, String actualHead) {
super(expectedHead == null
? "Document template already exists"
+ (actualHead == null ? "" : "; current version is " + actualHead)
: "Document template changed concurrently"
+ (actualHead == null ? "; it no longer exists"
: "; current version is " + actualHead));
this.expectedHead = expectedHead;
this.actualHead = actualHead;
}
public String expectedHead() {
return expectedHead;
}
public String actualHead() {
return actualHead;
}
}
public static final class TemplateNotFoundException extends IOException {
TemplateNotFoundException(String templateId, String revision) {
super("Document template not found"
+ (templateId == null || templateId.isBlank()
? ""
: ": " + templateId)
+ (revision == null || revision.isBlank()
? ""
: " at " + revision));
}
}
}