PreferencesGitRepository.java

package com.taxonomy.preferences.storage;

import com.taxonomy.dsl.storage.jgit.HibernateRepository;
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.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.hibernate.SessionFactory;
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 com.taxonomy.dsl.storage.jgit.HibernateObjDatabase;
import com.taxonomy.dsl.storage.jgit.HibernateRefDatabase;

/**
 * Facade around a JGit DFS repository that stores preferences as a JSON file.
 *
 * <p>Uses the {@code sandbox-jgit-storage-hibernate} pattern with a <em>separate</em>
 * project name ({@code "taxonomy-preferences"}) so that preferences history is
 * completely decoupled from the Architecture DSL history ({@code "taxonomy-dsl"}).
 *
 * <pre>
 * preferences.json → JGit commit → HibernateRepository("taxonomy-preferences")
 *                                         ↕
 *                   HibernateObjDatabase / HibernateRefDatabase (git_packs table)
 * </pre>
 *
 * <p>Both repositories share the same {@code git_packs} database table but are
 * logically independent Git repositories with separate commit histories, branches,
 * and refs.
 *
 * <p>For tests, the no-arg constructor creates an {@link InMemoryRepository}.
 */
public class PreferencesGitRepository {

    private static final Logger log = LoggerFactory.getLogger(PreferencesGitRepository.class);

    /** 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;

    /**
     * Create a PreferencesGitRepository 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 — exactly as the
     * {@code sandbox-jgit-storage-hibernate} module does, but in a
     * <strong>separate</strong> project ({@code "taxonomy-preferences"}) from
     * the Architecture DSL repository.
     *
     * @param sessionFactory the Hibernate SessionFactory (shared with the existing app)
     */
    public PreferencesGitRepository(SessionFactory sessionFactory) {
        this.gitRepo = new HibernateRepository(sessionFactory, "taxonomy-preferences");
        log.info("Initialised PreferencesGitRepository (HibernateRepository → database)");
    }

    /**
     * Create a PreferencesGitRepository backed by an in-memory DFS repository.
     *
     * <p>This is suitable for testing only. Data is lost on JVM restart.
     */
    public PreferencesGitRepository() {
        this.gitRepo = new InMemoryRepository(
                new DfsRepositoryDescription("taxonomy-preferences"));
        log.info("Initialised PreferencesGitRepository (InMemoryRepository — volatile)");
    }

    /**
     * Commit preferences JSON to the {@code main} branch.
     *
     * @param jsonContent the preferences JSON string
     * @param author      author identity string (e.g. username)
     * @param message     commit message
     * @return the commit SHA hex string
     * @throws IOException on JGit errors
     */
    public String commit(String jsonContent, 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. JSON → Blob
            ObjectId blobId = inserter.insert(Constants.OBJ_BLOB,
                    jsonContent.getBytes(StandardCharsets.UTF_8));

            // 2. Tree (preferences.json → blobId)
            TreeFormatter tree = new TreeFormatter();
            tree.append(PREFERENCES_FILENAME, FileMode.REGULAR_FILE, blobId);
            ObjectId treeId = inserter.insert(tree);

            // 3. Commit
            CommitBuilder commitBuilder = new CommitBuilder();
            commitBuilder.setTreeId(treeId);
            commitBuilder.setAuthor(personIdent);
            commitBuilder.setCommitter(personIdent);
            commitBuilder.setMessage(message != null ? message : "Preferences update");

            // Parent = current HEAD of main branch
            String refName = Constants.R_HEADS + MAIN_BRANCH;
            Ref branchRef = gitRepo.getRefDatabase().exactRef(refName);
            if (branchRef != null) {
                commitBuilder.setParentId(branchRef.getObjectId());
            }

            ObjectId commitId = inserter.insert(commitBuilder);
            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 preferences to '{}': {} (ref update: {})",
                    MAIN_BRANCH, commitId.name(), result);

            return commitId.name();
        }
    }

    /**
     * Read the preferences JSON at the HEAD of the {@code main} branch.
     *
     * @return the JSON string, or {@code null} if the branch has no commits yet
     * @throws IOException on JGit errors
     */
    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)) {
            RevCommit revCommit = walk.parseCommit(ref.getObjectId());
            return readJsonFromTree(revCommit.getTree());
        }
    }

    /**
     * Get the commit history of the {@code main} branch, newest first.
     *
     * @return list of preference commits (may be empty)
     * @throws IOException on JGit errors
     */
    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 c : walk) {
                PersonIdent authorIdent = c.getAuthorIdent();
                history.add(new PreferencesCommit(
                        c.name(),
                        authorIdent.getName(),
                        Instant.ofEpochSecond(c.getCommitTime()),
                        c.getFullMessage()));
            }
        }
        return history;
    }

    private String readJsonFromTree(org.eclipse.jgit.revwalk.RevTree tree) throws IOException {
        try (TreeWalk tw = TreeWalk.forPath(gitRepo, PREFERENCES_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);
        }
    }

    /**
     * Check if this repository is backed by a database (Hibernate) or in-memory.
     *
     * @return {@code true} if backed by HibernateRepository (persistent)
     */
    public boolean isDatabaseBacked() {
        return gitRepo instanceof HibernateRepository;
    }
}