PreferencesGitRepository.java

package com.taxonomy.preferences.storage;

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 org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
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.lib.TreeFormatter;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * JGit facade that versions preferences as one JSON document.
 *
 * <p>The Taxonomy-specific file and branch conventions remain here. Persistent
 * object, ref and reflog storage is supplied by {@code jgit-storage-hibernate-core}
 * through its public factory contract.</p>
 */
public class PreferencesGitRepository implements AutoCloseable {

    private static final Logger log = LoggerFactory.getLogger(PreferencesGitRepository.class);
    private static final String REPOSITORY_NAME = "taxonomy-preferences";

    /** The single JSON file stored in every commit tree. */
    static final String PREFERENCES_FILENAME = "preferences.json";

    /** The single branch used for preferences history. */
    static final String MAIN_BRANCH = "main";

    private final Repository gitRepo;
    private final HibernateGitStorage storageHandle;
    private final boolean databaseBacked;
    private final boolean closeRepository;
    private final AtomicBoolean closed = new AtomicBoolean();

    /** Open the persistent preferences repository through the reusable storage library. */
    public PreferencesGitRepository(HibernateRepositoryFactory storageFactory) {
        this(Objects.requireNonNull(storageFactory, "storageFactory")
                .open(new RepositoryName(REPOSITORY_NAME)));
    }

    private PreferencesGitRepository(HibernateGitStorage storageHandle) {
        this.gitRepo = storageHandle.repository();
        this.storageHandle = storageHandle;
        this.databaseBacked = true;
        this.closeRepository = false;
        log.info("Initialised database-backed PreferencesGitRepository");
    }

    /** Create an owned, volatile in-memory repository for unit tests. */
    public PreferencesGitRepository() {
        this(new InMemoryRepository(new DfsRepositoryDescription(REPOSITORY_NAME)), true);
    }

    /** Use a caller-supplied JGit repository without taking ownership of it. */
    public PreferencesGitRepository(Repository gitRepo) {
        this(gitRepo, false);
    }

    PreferencesGitRepository(Repository gitRepo, boolean closeRepository) {
        this.gitRepo = Objects.requireNonNull(gitRepo, "gitRepo");
        this.storageHandle = null;
        this.databaseBacked = false;
        this.closeRepository = closeRepository;
        log.info("Initialised PreferencesGitRepository ({})", gitRepo.getClass().getSimpleName());
    }

    /**
     * Commit preferences JSON to the {@code main} branch.
     *
     * @return the new commit SHA
     * @throws IOException if object insertion or the optimistic ref update fails
     */
    public String commit(String jsonContent, String author, String message) throws IOException {
        PersonIdent personIdent = personIdent(author);
        String commitMessage = message != null ? message : "Preferences update";

        try (ObjectInserter inserter = gitRepo.newObjectInserter()) {
            ObjectId blobId = inserter.insert(Constants.OBJ_BLOB,
                    jsonContent.getBytes(StandardCharsets.UTF_8));

            TreeFormatter tree = new TreeFormatter();
            tree.append(PREFERENCES_FILENAME, FileMode.REGULAR_FILE, blobId);
            ObjectId treeId = inserter.insert(tree);

            String refName = Constants.R_HEADS + MAIN_BRANCH;
            Ref branchRef = gitRepo.getRefDatabase().exactRef(refName);

            CommitBuilder commitBuilder = new CommitBuilder();
            commitBuilder.setTreeId(treeId);
            commitBuilder.setAuthor(personIdent);
            commitBuilder.setCommitter(personIdent);
            commitBuilder.setMessage(commitMessage);
            if (branchRef != null) {
                commitBuilder.setParentId(branchRef.getObjectId());
            }

            ObjectId commitId = inserter.insert(commitBuilder);
            inserter.flush();

            RefUpdate update = gitRepo.updateRef(refName);
            update.setNewObjectId(commitId);
            update.setExpectedOldObjectId(
                    branchRef != null ? branchRef.getObjectId() : ObjectId.zeroId());
            update.setForceUpdate(false);
            update.setRefLogIdent(personIdent);
            update.setRefLogMessage("commit: " + commitMessage, true);
            RefUpdate.Result result = update.update();
            requireSuccessfulUpdate(refName, result);

            log.info("Committed preferences to '{}': {} (ref update: {})",
                    MAIN_BRANCH, commitId.name(), result);
            return commitId.name();
        }
    }

    /** Read the preferences JSON at the HEAD of {@code main}. */
    public String readHead() throws IOException {
        String refName = Constants.R_HEADS + MAIN_BRANCH;
        Ref ref = gitRepo.getRefDatabase().exactRef(refName);
        if (ref == null) {
            return null;
        }

        try (RevWalk walk = new RevWalk(gitRepo)) {
            return readJsonFromTree(walk.parseCommit(ref.getObjectId()).getTree());
        }
    }

    /** Return preference commits newest first. */
    public List<PreferencesCommit> getHistory() throws IOException {
        String refName = Constants.R_HEADS + MAIN_BRANCH;
        Ref ref = gitRepo.getRefDatabase().exactRef(refName);
        if (ref == null) {
            return List.of();
        }

        List<PreferencesCommit> history = new ArrayList<>();
        try (RevWalk walk = new RevWalk(gitRepo)) {
            walk.markStart(walk.parseCommit(ref.getObjectId()));
            for (RevCommit commit : walk) {
                PersonIdent authorIdent = commit.getAuthorIdent();
                history.add(new PreferencesCommit(
                        commit.name(),
                        authorIdent.getName(),
                        Instant.ofEpochSecond(commit.getCommitTime()),
                        commit.getFullMessage()));
            }
        }
        return history;
    }

    private String readJsonFromTree(org.eclipse.jgit.revwalk.RevTree tree) throws IOException {
        try (TreeWalk treeWalk = TreeWalk.forPath(gitRepo, PREFERENCES_FILENAME, tree)) {
            if (treeWalk == null) {
                return null;
            }
            ObjectLoader loader = gitRepo.open(treeWalk.getObjectId(0), Constants.OBJ_BLOB);
            return new String(loader.getBytes(), StandardCharsets.UTF_8);
        }
    }

    private static PersonIdent personIdent(String author) {
        String name = author != null ? author : "taxonomy";
        String email = author != null ? author : "taxonomy@system";
        return new PersonIdent(name, email);
    }

    private static void requireSuccessfulUpdate(String refName, RefUpdate.Result result)
            throws IOException {
        if (result != RefUpdate.Result.NEW
                && result != RefUpdate.Result.FAST_FORWARD
                && result != RefUpdate.Result.FORCED
                && result != RefUpdate.Result.NO_CHANGE) {
            throw new IOException("Ref update failed for " + refName + ": " + result);
        }
    }

    /** Expose the underlying public JGit repository for advanced read operations. */
    public Repository getGitRepository() {
        return gitRepo;
    }

    /** Return whether this instance uses persistent Hibernate-backed storage. */
    public boolean isDatabaseBacked() {
        return databaseBacked;
    }

    /** Close the library handle or an owned in-memory repository exactly once. */
    @Override
    public void close() {
        if (!closed.compareAndSet(false, true)) {
            return;
        }
        if (storageHandle != null) {
            storageHandle.close();
        } else if (closeRepository) {
            gitRepo.close();
        }
    }
}