RepositoryStateService.java
package com.taxonomy.versioning.service;
import com.taxonomy.dsl.storage.DslCommit;
import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
import com.taxonomy.dto.ProjectionState;
import com.taxonomy.dto.RepositoryState;
import com.taxonomy.dto.ViewContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import com.taxonomy.dto.WorkspaceInfo;
import com.taxonomy.workspace.service.SystemRepositoryService;
import com.taxonomy.workspace.service.UserWorkspaceState;
import com.taxonomy.workspace.service.WorkspaceContext;
import com.taxonomy.workspace.service.WorkspaceManager;
/**
* Provides a unified view of the Git repository state, including projection
* and search-index freshness tracking.
*
* <p>This service is the single source of truth for answering: "What version
* of the architecture is the user looking at?" It tracks:
* <ul>
* <li>Git HEAD — the latest commit on a branch</li>
* <li>Projection commit — the commit the DB projection was built from</li>
* <li>Index commit — the commit the search index was built from</li>
* <li>Operation state — whether a multi-step merge/cherry-pick is in progress</li>
* </ul>
*
* <p>All mutable state (projection tracking, operation tracking) is isolated
* per user via {@link WorkspaceManager}. Overloaded methods without a
* {@code username} parameter use {@link WorkspaceManager#DEFAULT_USER}
* for backward compatibility with tests and unauthenticated callers.
*/
@Service
public class RepositoryStateService {
private static final Logger log = LoggerFactory.getLogger(RepositoryStateService.class);
private final DslGitRepositoryFactory repositoryFactory;
private final WorkspaceManager workspaceManager;
private final SystemRepositoryService systemRepositoryService;
public RepositoryStateService(DslGitRepositoryFactory repositoryFactory,
WorkspaceManager workspaceManager,
SystemRepositoryService systemRepositoryService) {
this.repositoryFactory = repositoryFactory;
this.workspaceManager = workspaceManager;
this.systemRepositoryService = systemRepositoryService;
}
/**
* Resolve the Git repository for the given workspace context.
*
* <p>Callers pass an explicit {@link WorkspaceContext} rather than relying
* on {@code resolveCurrentContext()} — this keeps the service free from
* implicit request-scoped state and makes it testable without a
* SecurityContext.
*
* @param ctx the workspace context (use {@link WorkspaceContext#SHARED}
* for the system repository)
* @return the resolved DslGitRepository
*/
private DslGitRepository resolveRepository(WorkspaceContext ctx) {
return repositoryFactory.resolveRepository(ctx);
}
// ── Workspace-aware methods ─────────────────────────────────────
/**
* Build the full repository state snapshot for a user and branch.
*
* <p>Uses the system repository (SHARED context). Use
* {@link #getState(String, String, WorkspaceContext)} for workspace-aware resolution.
*
* @param username the user whose workspace state to include
* @param branch the branch to query (e.g. "draft")
* @return the full repository state
*/
public RepositoryState getState(String username, String branch) {
return getState(username, branch, WorkspaceContext.SHARED);
}
/**
* Build the full repository state snapshot for a user, branch, and workspace context.
*
* @param username the user whose workspace state to include
* @param branch the branch to query (e.g. "draft")
* @param ctx the workspace context for repository resolution
* @return the full repository state
*/
public RepositoryState getState(String username, String branch, WorkspaceContext ctx) {
UserWorkspaceState ws = resolveState(username);
DslGitRepository repo = resolveRepository(ctx);
try {
DslCommit headInfo = repo.getHeadCommitInfo(branch);
List<String> branches = repo.getBranchNames();
int commitCount = repo.getCommitCount(branch);
String headCommit = headInfo != null ? headInfo.commitId() : null;
boolean projStale = isProjectionStaleForCommit(ws, headCommit);
boolean idxStale = isIndexStale(ws, headCommit);
return new RepositoryState(
branch,
headCommit,
headInfo != null ? headInfo.timestamp() : null,
headInfo != null ? headInfo.author() : null,
headInfo != null ? headInfo.message() : null,
branches,
ws.isOperationInProgress(),
ws.getOperationKind(),
ws.getLastProjectionCommit(),
ws.getLastProjectionBranch(),
ws.getLastProjectionTimestamp(),
projStale,
ws.getLastIndexCommit(),
idxStale,
commitCount,
repo.isDatabaseBacked()
);
} catch (IOException e) {
log.error("Failed to build repository state for user '{}', branch '{}'", username, branch, e);
return new RepositoryState(
branch, null, null, null, null, List.of(),
false, null, null, null, null, false, null, false, 0,
repo.isDatabaseBacked()
);
}
}
/**
* Build a {@link ViewContext} for inclusion in API responses.
*
* <p>Uses the system repository (SHARED context). Use
* {@link #getViewContext(String, String, WorkspaceContext)} for workspace-aware resolution.
*
* @param username the user whose workspace state to include
* @param branch the branch the data is based on
* @return the view context metadata
*/
public ViewContext getViewContext(String username, String branch) {
return getViewContext(username, branch, WorkspaceContext.SHARED);
}
/**
* Build a {@link ViewContext} for inclusion in API responses.
*
* @param username the user whose workspace state to include
* @param branch the branch the data is based on
* @param ctx the workspace context for repository resolution
* @return the view context metadata
*/
public ViewContext getViewContext(String username, String branch, WorkspaceContext ctx) {
UserWorkspaceState ws = resolveState(username);
DslGitRepository repo = resolveRepository(ctx);
try {
DslCommit headInfo = repo.getHeadCommitInfo(branch);
String headCommit = headInfo != null ? headInfo.commitId() : null;
return new ViewContext(
headCommit,
branch,
headInfo != null ? headInfo.timestamp() : null,
true,
isProjectionStaleForCommit(ws, headCommit),
isIndexStale(ws, headCommit)
);
} catch (IOException e) {
log.error("Failed to build view context for user '{}', branch '{}'", username, branch, e);
return new ViewContext(null, branch, null, true, false, false);
}
}
/**
* Record that a materialization completed successfully for a user's workspace.
*
* @param username the user whose projection to record
* @param commitId the commit SHA that was materialized
* @param branch the branch that was materialized
*/
public void recordProjection(String username, String commitId, String branch) {
UserWorkspaceState ws = resolveState(username);
ws.recordProjection(commitId, branch);
log.info("User '{}': recorded projection: branch='{}', commit='{}'",
username, branch, abbreviateSha(commitId));
}
/**
* Record that a search index rebuild completed for a user's workspace.
*
* @param username the user whose index to record
* @param commitId the commit SHA the index was built from
*/
public void recordIndexBuild(String username, String commitId) {
UserWorkspaceState ws = resolveState(username);
ws.recordIndexBuild(commitId);
log.info("User '{}': recorded index build: commit='{}'",
username, abbreviateSha(commitId));
}
/**
* Check if the DB projection is stale for a user's workspace.
*
* <p>Uses the system repository (SHARED context). Use
* {@link #isProjectionStale(String, String, WorkspaceContext)} for workspace-aware resolution.
*
* @param username the user to check
* @param branch the branch to check
* @return true if projection is stale
*/
public boolean isProjectionStale(String username, String branch) {
return isProjectionStale(username, branch, WorkspaceContext.SHARED);
}
/**
* Check if the DB projection is stale for a user's workspace.
*
* @param username the user to check
* @param branch the branch to check
* @param ctx the workspace context for repository resolution
* @return true if projection is stale
*/
public boolean isProjectionStale(String username, String branch, WorkspaceContext ctx) {
UserWorkspaceState ws = resolveState(username);
try {
String headCommit = resolveRepository(ctx).getHeadCommit(branch);
return isProjectionStaleForCommit(ws, headCommit);
} catch (IOException e) {
log.error("Failed to check projection staleness for user '{}', branch '{}'",
username, branch, e);
return false;
}
}
/**
* Get the full projection state for a user's workspace.
*
* <p>Uses the system repository (SHARED context). Use
* {@link #getProjectionState(String, String, WorkspaceContext)} for workspace-aware resolution.
*
* @param username the user to check
* @param branch the branch to check against
* @return the projection state
*/
public ProjectionState getProjectionState(String username, String branch) {
return getProjectionState(username, branch, WorkspaceContext.SHARED);
}
/**
* Get the full projection state for a user's workspace.
*
* @param username the user to check
* @param branch the branch to check against
* @param ctx the workspace context for repository resolution
* @return the projection state
*/
public ProjectionState getProjectionState(String username, String branch, WorkspaceContext ctx) {
UserWorkspaceState ws = resolveState(username);
try {
String headCommit = resolveRepository(ctx).getHeadCommit(branch);
return new ProjectionState(
ws.getLastProjectionCommit(),
ws.getLastProjectionBranch(),
ws.getLastProjectionTimestamp(),
ws.getLastIndexCommit(),
ws.getLastIndexTimestamp(),
isProjectionStaleForCommit(ws, headCommit),
isIndexStale(ws, headCommit)
);
} catch (IOException e) {
log.error("Failed to get projection state for user '{}', branch '{}'",
username, branch, e);
return new ProjectionState(
ws.getLastProjectionCommit(), ws.getLastProjectionBranch(),
ws.getLastProjectionTimestamp(),
ws.getLastIndexCommit(), ws.getLastIndexTimestamp(), false, false
);
}
}
/**
* Mark the start of a multi-step operation for a user's workspace.
*
* @param username the user performing the operation
* @param kind the operation kind ("merge", "cherry-pick", "revert")
*/
public void beginOperation(String username, String kind) {
resolveState(username).beginOperation(kind);
log.info("User '{}': operation started: {}", username, kind);
}
/**
* Mark the end of a multi-step operation for a user's workspace.
*
* @param username the user whose operation ended
*/
public void endOperation(String username) {
UserWorkspaceState ws = resolveState(username);
log.info("User '{}': operation ended: {}", username, ws.getOperationKind());
ws.endOperation();
}
// ── Internal helpers ────────────────────────────────────────────
/**
* Resolve the workspace branch for a user. Falls back to the configured
* shared branch (via {@link SystemRepositoryService#getSharedBranch()})
* if the user does not have a specific workspace branch configured.
*
* @param username the username to resolve the branch for
* @return the user's active branch, or the shared branch as default
*/
public String resolveWorkspaceBranch(String username) {
WorkspaceInfo info = workspaceManager.getWorkspaceInfo(username);
if (info != null && info.currentBranch() != null) {
return info.currentBranch();
}
return systemRepositoryService.getSharedBranch();
}
private UserWorkspaceState resolveState(String username) {
return workspaceManager.getOrCreateWorkspace(username);
}
/**
* Ensure that the workspace state for the given user is provisioned.
*
* <p>This triggers lazy workspace creation (via
* {@link com.taxonomy.workspace.service.WorkspaceManager#getOrCreateWorkspace})
* so that subsequent calls to
* {@link com.taxonomy.workspace.service.WorkspaceContextResolver#resolveForUser}
* find the workspace and return a workspace-scoped context instead of
* {@link com.taxonomy.workspace.service.WorkspaceContext#SHARED}.
*
* @param username the authenticated user's username
*/
public void ensureWorkspaceState(String username) {
resolveState(username);
}
private boolean isProjectionStaleForCommit(UserWorkspaceState ws, String headCommit) {
String projCommit = ws.getLastProjectionCommit();
if (headCommit == null || projCommit == null) {
return false;
}
return !headCommit.equals(projCommit);
}
private boolean isIndexStale(UserWorkspaceState ws, String headCommit) {
String idxCommit = ws.getLastIndexCommit();
if (headCommit == null || idxCommit == null) {
return false;
}
return !headCommit.equals(idxCommit);
}
private String abbreviateSha(String commitId) {
if (commitId == null) return "null";
return commitId.substring(0, Math.min(7, commitId.length()));
}
}