DslGitRepository.java
package com.taxonomy.dsl.storage;
import com.taxonomy.dsl.diff.ModelDiff;
import com.taxonomy.dsl.diff.ModelDiffer;
import com.taxonomy.dsl.mapper.AstToModelMapper;
import com.taxonomy.dsl.model.CanonicalArchitectureModel;
import com.taxonomy.dsl.parser.TaxDslParser;
import com.taxonomy.dsl.storage.jgit.HibernateRepository;
import org.eclipse.jgit.diff.DiffEntry;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
import org.eclipse.jgit.lib.*;
import org.eclipse.jgit.merge.MergeStrategy;
import org.eclipse.jgit.merge.Merger;
import org.eclipse.jgit.merge.ThreeWayMerger;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.AbstractTreeIterator;
import org.eclipse.jgit.treewalk.CanonicalTreeParser;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.eclipse.jgit.util.io.DisabledOutputStream;
import org.hibernate.SessionFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import com.taxonomy.dsl.storage.jgit.HibernateObjDatabase;
import com.taxonomy.dsl.storage.jgit.HibernateRefDatabase;
/**
* Facade around a JGit DFS repository that stores DSL documents as Git objects.
*
* <p>Implements the {@code sandbox-jgit-storage-hibernate} pattern: DSL text is
* stored as Git blobs, organised in trees, and committed with full history — all
* inside the database (via {@link HibernateRepository}) rather than the filesystem.
*
* <pre>
* DSL Text → JGit commit → HibernateRepository → HSQLDB (git_packs table)
* ↕
* HibernateObjDatabase (blobs, trees, commits as pack data)
* HibernateRefDatabase (reftable stored as pack extension)
* </pre>
*
* <p>All Git objects (blobs, trees, commits) and refs are persisted in the
* {@code git_packs} and {@code git_reflog} database tables. No filesystem is
* used; everything lives in the existing HSQLDB instance.
*
* <p>For tests, a no-arg constructor creates an {@link InMemoryRepository} as
* a volatile fallback.
*/
public class DslGitRepository {
private static final Logger log = LoggerFactory.getLogger(DslGitRepository.class);
/** The single DSL file stored in every commit tree. */
static final String DSL_FILENAME = "architecture.taxdsl";
private final Repository gitRepo;
private final TaxDslParser parser = new TaxDslParser();
private final AstToModelMapper astMapper = new AstToModelMapper();
private final ModelDiffer differ = new ModelDiffer();
/**
* Create a DslGitRepository backed by a database via Hibernate.
*
* <p>This is the primary constructor for production use. All Git objects
* are stored in the {@code git_packs} table, refs in reftable format
* persisted as pack extensions — exactly as the
* {@code sandbox-jgit-storage-hibernate} module does.
*
* @param sessionFactory the Hibernate SessionFactory (shared with the
* existing HSQLDB in the Spring Boot app)
*/
public DslGitRepository(SessionFactory sessionFactory) {
this.gitRepo = new HibernateRepository(sessionFactory, "taxonomy-dsl");
log.info("Initialised DslGitRepository (HibernateRepository → database)");
}
/**
* Create a DslGitRepository with a custom repository name.
*
* <p>Different names create logically separate Git repositories in the same
* database. This is used by {@link DslGitRepositoryFactory} to create
* per-workspace repositories that share the same underlying database but
* have independent Git object namespaces.
*
* @param sessionFactory the Hibernate SessionFactory
* @param repositoryName logical name to partition data in the database
*/
public DslGitRepository(SessionFactory sessionFactory, String repositoryName) {
this.gitRepo = new HibernateRepository(sessionFactory, repositoryName);
log.info("Initialised DslGitRepository (HibernateRepository '{}' → database)", repositoryName);
}
/**
* Create a DslGitRepository backed by an in-memory DFS repository.
*
* <p>This is suitable for testing only. Data is lost on JVM restart.
*/
public DslGitRepository() {
this.gitRepo = new InMemoryRepository(new DfsRepositoryDescription("taxonomy-dsl"));
log.info("Initialised DslGitRepository (InMemoryRepository — volatile)");
}
/**
* Create a DslGitRepository backed by a provided JGit repository.
*
* @param gitRepo the JGit repository to use (any DFS implementation)
*/
public DslGitRepository(Repository gitRepo) {
this.gitRepo = gitRepo;
log.info("Initialised DslGitRepository ({})", gitRepo.getClass().getSimpleName());
}
// ── Commit operations ───────────────────────────────────────────
/**
* Commit DSL text to the specified branch.
*
* <p>This creates a Git blob (DSL text), a tree ({@value DSL_FILENAME} → blob),
* and a commit object with the given author and message. The branch ref
* is updated to point at the new commit.
*
* @param branch branch name (e.g. "draft", "review", "accepted")
* @param dslText the DSL document content
* @param author author identity string (e.g. "user@example.com")
* @param message commit message
* @return the commit SHA hex string
* @throws IOException on JGit errors
*/
public String commitDsl(String branch, String dslText, String author, String message)
throws IOException {
PersonIdent personIdent = new PersonIdent(
author != null ? author : "taxonomy",
author != null ? author : "taxonomy@system");
try (ObjectInserter inserter = gitRepo.newObjectInserter()) {
// 1. DSL text → Blob
ObjectId blobId = inserter.insert(Constants.OBJ_BLOB,
dslText.getBytes(StandardCharsets.UTF_8));
// 2. Tree (architecture.taxdsl → blobId)
TreeFormatter tree = new TreeFormatter();
tree.append(DSL_FILENAME, FileMode.REGULAR_FILE, blobId);
ObjectId treeId = inserter.insert(tree);
// 3. Commit
CommitBuilder commit = new CommitBuilder();
commit.setTreeId(treeId);
commit.setAuthor(personIdent);
commit.setCommitter(personIdent);
commit.setMessage(message != null ? message : "DSL update");
// Parent = current HEAD of the branch
String refName = Constants.R_HEADS + branch;
Ref branchRef = gitRepo.getRefDatabase().exactRef(refName);
if (branchRef != null) {
commit.setParentId(branchRef.getObjectId());
}
ObjectId commitId = inserter.insert(commit);
inserter.flush();
// 4. Update branch ref
RefUpdate ru = gitRepo.updateRef(refName);
ru.setNewObjectId(commitId);
if (branchRef == null) {
ru.setExpectedOldObjectId(ObjectId.zeroId());
}
ru.setForceUpdate(true);
RefUpdate.Result result = ru.update();
log.info("Committed DSL to branch '{}': {} (ref update: {})",
branch, commitId.name(), result);
return commitId.name();
}
}
// ── Read operations ─────────────────────────────────────────────
/**
* Read the DSL text at a specific commit.
*
* @param commitId the commit SHA hex string
* @return the DSL text, or {@code null} if the file is not found
* @throws IOException on JGit errors
*/
public String getDslAtCommit(String commitId) throws IOException {
ObjectId oid = ObjectId.fromString(commitId);
try (RevWalk walk = new RevWalk(gitRepo)) {
RevCommit revCommit = walk.parseCommit(oid);
return readDslFromTree(revCommit.getTree());
}
}
/**
* Read the DSL text at the HEAD of a branch.
*
* @param branch the branch name
* @return the DSL text, or {@code null} if the branch has no commits
* @throws IOException on JGit errors
*/
public String getDslAtHead(String branch) throws IOException {
String refName = Constants.R_HEADS + branch;
Ref ref = gitRepo.getRefDatabase().exactRef(refName);
if (ref == null) return null;
return getDslAtCommit(ref.getObjectId().name());
}
/**
* Get the commit history of a branch, newest first.
*
* @param branch the branch name
* @return the list of commits (may be empty)
* @throws IOException on JGit errors
*/
public List<DslCommit> getDslHistory(String branch) throws IOException {
String refName = Constants.R_HEADS + branch;
Ref ref = gitRepo.getRefDatabase().exactRef(refName);
if (ref == null) return List.of();
List<DslCommit> history = new ArrayList<>();
try (RevWalk walk = new RevWalk(gitRepo)) {
walk.markStart(walk.parseCommit(ref.getObjectId()));
for (RevCommit c : walk) {
PersonIdent authorIdent = c.getAuthorIdent();
history.add(new DslCommit(
c.name(),
authorIdent.getName(),
Instant.ofEpochSecond(c.getCommitTime()),
c.getFullMessage()));
}
}
return history;
}
// ── Branch operations ───────────────────────────────────────────
/**
* List all branches.
*
* @return list of branch DTOs
* @throws IOException on JGit errors
*/
public List<DslBranch> listBranches() throws IOException {
List<DslBranch> branches = new ArrayList<>();
for (Ref ref : gitRepo.getRefDatabase().getRefsByPrefix(Constants.R_HEADS)) {
String name = ref.getName().substring(Constants.R_HEADS.length());
String headCommitId = ref.getObjectId() != null ? ref.getObjectId().name() : null;
Instant created = null;
if (headCommitId != null) {
try (RevWalk walk = new RevWalk(gitRepo)) {
walk.markStart(walk.parseCommit(ref.getObjectId()));
RevCommit oldest = null;
for (RevCommit c : walk) { oldest = c; }
if (oldest != null) {
created = Instant.ofEpochSecond(oldest.getCommitTime());
}
}
}
branches.add(new DslBranch(name, headCommitId, created));
}
return branches;
}
/**
* Create a new branch pointing at the HEAD of an existing branch.
*
* @param newBranch the new branch name
* @param fromBranch the source branch to fork from
* @return the head commit ID of the new branch, or {@code null} if source is empty
* @throws IOException on JGit errors
*/
public String createBranch(String newBranch, String fromBranch) throws IOException {
String sourceRef = Constants.R_HEADS + fromBranch;
Ref ref = gitRepo.getRefDatabase().exactRef(sourceRef);
if (ref == null) return null;
String targetRef = Constants.R_HEADS + newBranch;
RefUpdate ru = gitRepo.updateRef(targetRef);
ru.setNewObjectId(ref.getObjectId());
ru.setExpectedOldObjectId(ObjectId.zeroId());
ru.setForceUpdate(true);
ru.update();
log.info("Created branch '{}' from '{}' at {}", newBranch, fromBranch,
ref.getObjectId().name());
return ref.getObjectId().name();
}
// ── Diff operations ─────────────────────────────────────────────
/**
* Compute a semantic diff between two commits using {@link ModelDiffer}.
*
* <p>Reads the DSL text from both commits, parses them into
* {@link CanonicalArchitectureModel} instances, and computes the delta.
*
* @param fromCommitId the "before" commit SHA
* @param toCommitId the "after" commit SHA
* @return the model diff
* @throws IOException on JGit errors
*/
public ModelDiff diffBetween(String fromCommitId, String toCommitId) throws IOException {
String beforeDsl = getDslAtCommit(fromCommitId);
String afterDsl = getDslAtCommit(toCommitId);
CanonicalArchitectureModel beforeModel = beforeDsl != null
? astMapper.map(parser.parse(beforeDsl)) : null;
CanonicalArchitectureModel afterModel = afterDsl != null
? astMapper.map(parser.parse(afterDsl)) : null;
return differ.diff(beforeModel, afterModel);
}
/**
* Compute a semantic diff between the HEAD of two branches.
*
* @param fromBranch the "before" branch
* @param toBranch the "after" branch
* @return the model diff
* @throws IOException on JGit errors
*/
public ModelDiff diffBranches(String fromBranch, String toBranch) throws IOException {
String beforeDsl = getDslAtHead(fromBranch);
String afterDsl = getDslAtHead(toBranch);
CanonicalArchitectureModel beforeModel = beforeDsl != null
? astMapper.map(parser.parse(beforeDsl)) : null;
CanonicalArchitectureModel afterModel = afterDsl != null
? astMapper.map(parser.parse(afterDsl)) : null;
return differ.diff(beforeModel, afterModel);
}
/**
* Compute a JGit-native text diff between two commits.
*
* <p>Uses JGit's {@link DiffFormatter} to produce a unified-diff patch
* of the DSL file changes, plus a list of {@link DiffEntry} metadata.
* This is what a real Git diff would produce.
*
* @param fromCommitId the "before" commit SHA
* @param toCommitId the "after" commit SHA
* @return the unified diff as a string
* @throws IOException on JGit errors
*/
public String textDiff(String fromCommitId, String toCommitId) throws IOException {
ObjectId fromOid = ObjectId.fromString(fromCommitId);
ObjectId toOid = ObjectId.fromString(toCommitId);
try (RevWalk walk = new RevWalk(gitRepo)) {
RevCommit fromCommit = walk.parseCommit(fromOid);
RevCommit toCommit = walk.parseCommit(toOid);
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (DiffFormatter df = new DiffFormatter(out)) {
df.setRepository(gitRepo);
df.format(fromCommit.getTree(), toCommit.getTree());
df.flush();
}
return out.toString(StandardCharsets.UTF_8);
}
}
/**
* Get the list of changed files between two commits using JGit DiffFormatter.
*
* @param fromCommitId the "before" commit SHA
* @param toCommitId the "after" commit SHA
* @return list of DiffEntry objects describing what changed
* @throws IOException on JGit errors
*/
public List<DiffEntry> jgitDiffEntries(String fromCommitId, String toCommitId) throws IOException {
ObjectId fromOid = ObjectId.fromString(fromCommitId);
ObjectId toOid = ObjectId.fromString(toCommitId);
try (RevWalk walk = new RevWalk(gitRepo)) {
RevCommit fromCommit = walk.parseCommit(fromOid);
RevCommit toCommit = walk.parseCommit(toOid);
try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
df.setRepository(gitRepo);
return df.scan(fromCommit.getTree(), toCommit.getTree());
}
}
}
// ── Cherry-pick & merge operations ──────────────────────────────
/**
* Cherry-pick a commit onto a target branch.
*
* <p>Applies the changes from the specified commit to the HEAD of the
* target branch, creating a new commit.
*
* @param commitId the commit to cherry-pick
* @param targetBranch the branch to apply the commit to
* @return the new commit SHA, or {@code null} if cherry-pick failed
* @throws IOException on JGit errors
*/
public String cherryPick(String commitId, String targetBranch) throws IOException {
ObjectId pickOid = ObjectId.fromString(commitId);
try (RevWalk walk = new RevWalk(gitRepo)) {
RevCommit pickCommit = walk.parseCommit(pickOid);
String targetRefName = Constants.R_HEADS + targetBranch;
Ref targetRef = gitRepo.getRefDatabase().exactRef(targetRefName);
if (targetRef == null) {
log.warn("Target branch '{}' not found for cherry-pick", targetBranch);
return null;
}
RevCommit targetHead = walk.parseCommit(targetRef.getObjectId());
// Three-way merge: base=parent (or auto-detected for initial commit),
// ours=targetHead, theirs=pickCommit
ThreeWayMerger merger = MergeStrategy.RECURSIVE.newMerger(gitRepo, true);
boolean success = merger.merge(targetHead, pickCommit);
if (!success) {
log.warn("Cherry-pick merge conflict for {} onto '{}'", commitId, targetBranch);
return null;
}
// Create the new commit on the target branch
try (ObjectInserter inserter = gitRepo.newObjectInserter()) {
CommitBuilder newCommit = new CommitBuilder();
newCommit.setTreeId(merger.getResultTreeId());
newCommit.setParentId(targetHead);
newCommit.setAuthor(pickCommit.getAuthorIdent());
newCommit.setCommitter(new PersonIdent("taxonomy", "taxonomy@system"));
newCommit.setMessage("Cherry-pick: " + pickCommit.getShortMessage());
ObjectId newCommitId = inserter.insert(newCommit);
inserter.flush();
// Update target branch ref
RefUpdate ru = gitRepo.updateRef(targetRefName);
ru.setNewObjectId(newCommitId);
ru.setForceUpdate(true);
ru.update();
log.info("Cherry-picked {} onto '{}': {}", commitId, targetBranch, newCommitId.name());
return newCommitId.name();
}
}
}
/**
* Merge one branch into another.
*
* <p>Performs a three-way merge of {@code fromBranch} into {@code intoBranch}.
* On success, creates a merge commit on the target branch with two parents.
*
* @param fromBranch the branch to merge from (source)
* @param intoBranch the branch to merge into (target)
* @return the merge commit SHA, or {@code null} if merge failed
* @throws IOException on JGit errors
*/
public String merge(String fromBranch, String intoBranch) throws IOException {
String fromRefName = Constants.R_HEADS + fromBranch;
String intoRefName = Constants.R_HEADS + intoBranch;
Ref fromRef = gitRepo.getRefDatabase().exactRef(fromRefName);
Ref intoRef = gitRepo.getRefDatabase().exactRef(intoRefName);
if (fromRef == null || intoRef == null) {
log.warn("Cannot merge: branch not found (from={}, into={})", fromBranch, intoBranch);
return null;
}
try (RevWalk walk = new RevWalk(gitRepo)) {
RevCommit fromCommit = walk.parseCommit(fromRef.getObjectId());
RevCommit intoCommit = walk.parseCommit(intoRef.getObjectId());
// Fast-forward check
if (walk.isMergedInto(fromCommit, intoCommit)) {
log.info("Already merged: '{}' is ancestor of '{}'", fromBranch, intoBranch);
return intoCommit.name();
}
// Can we fast-forward?
if (walk.isMergedInto(intoCommit, fromCommit)) {
// Fast-forward: just move the target ref
RefUpdate ru = gitRepo.updateRef(intoRefName);
ru.setNewObjectId(fromCommit);
ru.setForceUpdate(true);
ru.update();
log.info("Fast-forward merge '{}' into '{}': {}", fromBranch, intoBranch, fromCommit.name());
return fromCommit.name();
}
// Three-way merge
ThreeWayMerger merger = MergeStrategy.RECURSIVE.newMerger(gitRepo, true);
boolean success = merger.merge(intoCommit, fromCommit);
if (!success) {
log.warn("Merge conflict: '{}' into '{}'", fromBranch, intoBranch);
return null;
}
// Create merge commit with two parents
try (ObjectInserter inserter = gitRepo.newObjectInserter()) {
CommitBuilder mergeCommit = new CommitBuilder();
mergeCommit.setTreeId(merger.getResultTreeId());
mergeCommit.setParentIds(intoCommit, fromCommit);
PersonIdent ident = new PersonIdent("taxonomy", "taxonomy@system");
mergeCommit.setAuthor(ident);
mergeCommit.setCommitter(ident);
mergeCommit.setMessage("Merge branch '" + fromBranch + "' into " + intoBranch);
ObjectId mergeCommitId = inserter.insert(mergeCommit);
inserter.flush();
RefUpdate ru = gitRepo.updateRef(intoRefName);
ru.setNewObjectId(mergeCommitId);
ru.setForceUpdate(true);
ru.update();
log.info("Merged '{}' into '{}': {}", fromBranch, intoBranch, mergeCommitId.name());
return mergeCommitId.name();
}
}
}
// ── Revert & undo operations ───────────────────────────────────
/**
* Revert a specific commit on a branch.
*
* <p>Creates a new commit that undoes the changes introduced by the
* specified commit. Uses a three-way merge where the base is the commit
* to revert, ours is the branch HEAD, and theirs is the parent of the
* commit to revert (i.e. the state before the commit).
*
* @param commitId the commit SHA to revert
* @param branch the branch on which to create the revert commit
* @return the new revert commit SHA, or {@code null} if the revert failed
* @throws IOException on JGit errors
*/
public String revert(String commitId, String branch) throws IOException {
ObjectId revertOid = ObjectId.fromString(commitId);
try (RevWalk walk = new RevWalk(gitRepo)) {
RevCommit revertCommit = walk.parseCommit(revertOid);
if (revertCommit.getParentCount() == 0) {
log.warn("Cannot revert initial commit {}", commitId);
return null;
}
String refName = Constants.R_HEADS + branch;
Ref branchRef = gitRepo.getRefDatabase().exactRef(refName);
if (branchRef == null) {
log.warn("Branch '{}' not found for revert", branch);
return null;
}
RevCommit branchHead = walk.parseCommit(branchRef.getObjectId());
RevCommit parentCommit = walk.parseCommit(revertCommit.getParent(0));
// Three-way merge: base=revertCommit, ours=branchHead, theirs=parentCommit
// This effectively applies the inverse of revertCommit's changes
ThreeWayMerger merger = MergeStrategy.RECURSIVE.newMerger(gitRepo, true);
merger.setBase(revertCommit);
boolean success = merger.merge(branchHead, parentCommit);
if (!success) {
log.warn("Revert conflict for {} on '{}'", commitId, branch);
return null;
}
try (ObjectInserter inserter = gitRepo.newObjectInserter()) {
CommitBuilder newCommit = new CommitBuilder();
newCommit.setTreeId(merger.getResultTreeId());
newCommit.setParentId(branchHead);
PersonIdent ident = new PersonIdent("taxonomy", "taxonomy@system");
newCommit.setAuthor(ident);
newCommit.setCommitter(ident);
newCommit.setMessage("Revert: " + revertCommit.getShortMessage());
ObjectId newCommitId = inserter.insert(newCommit);
inserter.flush();
RefUpdate ru = gitRepo.updateRef(refName);
ru.setNewObjectId(newCommitId);
ru.setForceUpdate(true);
ru.update();
log.info("Reverted {} on '{}': {}", commitId, branch, newCommitId.name());
return newCommitId.name();
}
}
}
/**
* Undo the last commit on a branch by resetting to its parent.
*
* <p>This is a simple "soft reset" — the branch ref is moved to the parent
* commit, effectively removing the last commit from the branch history.
* The commit object still exists in the repository but is no longer
* reachable from the branch.
*
* @param branch the branch name
* @return the new HEAD SHA (the parent), or {@code null} if no parent exists
* @throws IOException on JGit errors
*/
public String undoLast(String branch) throws IOException {
String refName = Constants.R_HEADS + branch;
Ref branchRef = gitRepo.getRefDatabase().exactRef(refName);
if (branchRef == null) {
log.warn("Branch '{}' not found for undo", branch);
return null;
}
try (RevWalk walk = new RevWalk(gitRepo)) {
RevCommit headCommit = walk.parseCommit(branchRef.getObjectId());
if (headCommit.getParentCount() == 0) {
log.warn("Cannot undo: branch '{}' has only the initial commit", branch);
return null;
}
RevCommit parentCommit = walk.parseCommit(headCommit.getParent(0));
RefUpdate ru = gitRepo.updateRef(refName);
ru.setNewObjectId(parentCommit);
ru.setForceUpdate(true);
ru.update();
log.info("Undo last on '{}': {} → {}", branch, headCommit.name(), parentCommit.name());
return parentCommit.name();
}
}
/**
* Restore the DSL content from a specific commit as a new commit on the branch.
*
* <p>Reads the DSL text from the given commit and creates a new commit on
* the specified branch with that content. This is a "restore to version"
* operation — the branch moves forward with a new commit whose tree
* matches the old commit.
*
* @param commitId the source commit SHA to restore from
* @param branch the branch to create the new commit on
* @return the new commit SHA, or {@code null} if the source commit was not found
* @throws IOException on JGit errors
*/
public String restore(String commitId, String branch) throws IOException {
String dslText;
try {
dslText = getDslAtCommit(commitId);
} catch (org.eclipse.jgit.errors.MissingObjectException e) {
log.warn("Cannot restore: commit {} not found", commitId);
return null;
}
if (dslText == null) {
log.warn("Cannot restore: commit {} has no DSL content", commitId);
return null;
}
return commitDsl(branch, dslText, "taxonomy",
"Restored from version " + commitId.substring(0, 7));
}
// ── State query helpers ────────────────────────────────────────
/**
* Get the HEAD commit SHA for a branch.
*
* @param branch the branch name
* @return the 40-char commit SHA, or {@code null} if the branch doesn't exist
* @throws IOException on JGit errors
*/
public String getHeadCommit(String branch) throws IOException {
String refName = Constants.R_HEADS + branch;
Ref ref = gitRepo.getRefDatabase().exactRef(refName);
return ref != null && ref.getObjectId() != null ? ref.getObjectId().name() : null;
}
/**
* Get all branch names (without the {@code refs/heads/} prefix).
*
* @return list of branch names (may be empty)
* @throws IOException on JGit errors
*/
public List<String> getBranchNames() throws IOException {
List<String> names = new ArrayList<>();
for (Ref ref : gitRepo.getRefDatabase().getRefsByPrefix(Constants.R_HEADS)) {
names.add(ref.getName().substring(Constants.R_HEADS.length()));
}
return names;
}
/**
* Count the number of commits on a branch.
*
* @param branch the branch name
* @return the commit count (0 if branch doesn't exist)
* @throws IOException on JGit errors
*/
public int getCommitCount(String branch) throws IOException {
String refName = Constants.R_HEADS + branch;
Ref ref = gitRepo.getRefDatabase().exactRef(refName);
if (ref == null) return 0;
int count = 0;
try (RevWalk walk = new RevWalk(gitRepo)) {
walk.markStart(walk.parseCommit(ref.getObjectId()));
for (RevCommit ignored : walk) {
count++;
}
}
return count;
}
/**
* Compute ahead/behind counts between two branches using merge-base.
*
* <p>Returns a two-element array: {@code [ahead, behind]} where:
* <ul>
* <li>{@code ahead} = number of commits in {@code branch} not in {@code baseBranch}</li>
* <li>{@code behind} = number of commits in {@code baseBranch} not in {@code branch}</li>
* </ul>
*
* <p>This uses JGit's {@code RevWalk.isMergedInto()} and commit walking
* relative to the merge-base to produce accurate counts, unlike the simple
* total-commit-count heuristic.
*
* @param branch the branch to compare (e.g. user's branch)
* @param baseBranch the reference branch (e.g. shared "draft")
* @return {@code int[]{ahead, behind}}, or {@code int[]{0, 0}} if either branch is missing
* @throws IOException on JGit errors
*/
public int[] getAheadBehindCounts(String branch, String baseBranch) throws IOException {
Ref branchRef = gitRepo.getRefDatabase().exactRef(Constants.R_HEADS + branch);
Ref baseRef = gitRepo.getRefDatabase().exactRef(Constants.R_HEADS + baseBranch);
if (branchRef == null || baseRef == null) {
return new int[]{0, 0};
}
try (RevWalk walk = new RevWalk(gitRepo)) {
RevCommit branchHead = walk.parseCommit(branchRef.getObjectId());
RevCommit baseHead = walk.parseCommit(baseRef.getObjectId());
// If same commit, no divergence
if (branchHead.equals(baseHead)) {
return new int[]{0, 0};
}
// Count commits reachable from branch but not from base (ahead)
int ahead = countCommitsNotIn(walk, branchHead, baseHead);
// Reset walk for the reverse count
walk.reset();
// Count commits reachable from base but not from branch (behind)
int behind = countCommitsNotIn(walk, baseHead, branchHead);
return new int[]{ahead, behind};
}
}
/**
* Count commits reachable from {@code tip} but not from {@code exclude}.
*/
private int countCommitsNotIn(RevWalk walk, RevCommit tip, RevCommit exclude)
throws IOException {
walk.reset();
walk.markStart(tip);
walk.markUninteresting(exclude);
int count = 0;
for (RevCommit ignored : walk) {
count++;
}
return count;
}
/**
* Get the {@link DslCommit} metadata for the HEAD of a branch.
*
* @param branch the branch name
* @return the HEAD commit info, or {@code null} if the branch has no commits
* @throws IOException on JGit errors
*/
public DslCommit getHeadCommitInfo(String branch) throws IOException {
String refName = Constants.R_HEADS + branch;
Ref ref = gitRepo.getRefDatabase().exactRef(refName);
if (ref == null) return null;
try (RevWalk walk = new RevWalk(gitRepo)) {
RevCommit c = walk.parseCommit(ref.getObjectId());
PersonIdent authorIdent = c.getAuthorIdent();
return new DslCommit(
c.name(),
authorIdent.getName(),
Instant.ofEpochSecond(c.getCommitTime()),
c.getFullMessage());
}
}
// ── Internal helpers ────────────────────────────────────────────
private String readDslFromTree(RevTree tree) throws IOException {
try (TreeWalk tw = TreeWalk.forPath(gitRepo, DSL_FILENAME, tree)) {
if (tw == null) return null;
ObjectId blobId = tw.getObjectId(0);
ObjectLoader loader = gitRepo.open(blobId, Constants.OBJ_BLOB);
return new String(loader.getBytes(), StandardCharsets.UTF_8);
}
}
/** Expose the underlying JGit repository (for advanced operations). */
public Repository getGitRepository() {
return gitRepo;
}
/**
* Check if this repository is backed by a database (Hibernate) or in-memory.
*
* @return true if backed by HibernateRepository (persistent)
*/
public boolean isDatabaseBacked() {
return gitRepo instanceof HibernateRepository;
}
/**
* Delete a branch from the repository.
*
* <p>Removes the branch reference. The underlying Git objects (commits,
* trees, blobs) are not garbage-collected — they remain reachable via
* other branches or the reflog.
*
* @param branchName the branch to delete (must not be a protected branch)
* @return true if the branch was deleted, false if it did not exist
* @throws IOException on JGit errors
* @throws IllegalArgumentException if the branch is protected
*/
public boolean deleteBranch(String branchName) throws IOException {
if ("draft".equals(branchName) || "accepted".equals(branchName) || "main".equals(branchName)) {
throw new IllegalArgumentException("Cannot delete protected branch: " + branchName);
}
String refName = Constants.R_HEADS + branchName;
Ref ref = gitRepo.getRefDatabase().exactRef(refName);
if (ref == null) {
return false;
}
RefUpdate ru = gitRepo.updateRef(refName);
ru.setForceUpdate(true);
RefUpdate.Result result = ru.delete();
boolean deleted = (result == RefUpdate.Result.FORCED || result == RefUpdate.Result.FAST_FORWARD);
if (deleted) {
log.info("Deleted branch '{}'", branchName);
} else {
log.warn("Failed to delete branch '{}': {}", branchName, result);
}
return deleted;
}
}