SyncIntegrationService.java
package com.taxonomy.workspace.service;
import com.taxonomy.dsl.storage.DslGitRepository;
import com.taxonomy.dsl.storage.DslGitRepositoryFactory;
import com.taxonomy.workspace.model.SyncState;
import com.taxonomy.workspace.model.UserWorkspace;
import com.taxonomy.workspace.repository.SyncStateRepository;
import com.taxonomy.workspace.repository.UserWorkspaceRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.time.Instant;
import java.util.UUID;
/**
* Manages synchronization between user workspaces and the shared integration
* repository.
*
* <p>The shared branch (called {@code "draft"} by default) represents the
* canonical team-wide state. Users work on their own branches and periodically
* sync (pull) from the shared branch and publish (push) their changes back.
*
* <p>This service tracks the synchronization state via the {@link SyncState}
* entity and delegates actual Git merge operations to {@link DslGitRepository}.
*/
@Service
public class SyncIntegrationService {
private static final Logger log = LoggerFactory.getLogger(SyncIntegrationService.class);
private final SyncStateRepository syncStateRepository;
private final DslGitRepository gitRepository;
private final UserWorkspaceRepository workspaceRepository;
private final SystemRepositoryService systemRepositoryService;
private final DslGitRepositoryFactory repositoryFactory;
@Autowired
public SyncIntegrationService(SyncStateRepository syncStateRepository,
UserWorkspaceRepository workspaceRepository,
SystemRepositoryService systemRepositoryService,
DslGitRepositoryFactory repositoryFactory) {
this.syncStateRepository = syncStateRepository;
this.gitRepository = repositoryFactory.getSystemRepository();
this.workspaceRepository = workspaceRepository;
this.systemRepositoryService = systemRepositoryService;
this.repositoryFactory = repositoryFactory;
}
/**
* Legacy constructor for backward compatibility (used in tests).
*/
public SyncIntegrationService(SyncStateRepository syncStateRepository,
DslGitRepository gitRepository,
UserWorkspaceRepository workspaceRepository,
SystemRepositoryService systemRepositoryService) {
this.syncStateRepository = syncStateRepository;
this.gitRepository = gitRepository;
this.workspaceRepository = workspaceRepository;
this.systemRepositoryService = systemRepositoryService;
this.repositoryFactory = null;
}
private String getSharedBranch() {
return systemRepositoryService.getSharedBranch();
}
/**
* Get or create the sync state for a user.
*
* <p>If no sync state record exists, a new one is created with default
* values and an {@code UP_TO_DATE} status. If persistence fails, the
* error is logged and an unsaved instance is returned as a best-effort
* fallback.
*
* @param username the authenticated user's username
* @return the sync state entity (never null)
*/
public SyncState getSyncState(String username) {
return syncStateRepository.findByUsername(username)
.orElseGet(() -> createSyncState(username));
}
private SyncState createSyncState(String username) {
log.info("Creating sync state for user '{}'", username);
SyncState state = new SyncState();
state.setUsername(username);
// Link to existing UserWorkspace if available; otherwise generate a new ID
String wsId = workspaceRepository.findByUsernameAndSharedFalse(username)
.map(UserWorkspace::getWorkspaceId)
.orElseGet(() -> UUID.randomUUID().toString());
state.setWorkspaceId(wsId);
state.setSyncStatus("UP_TO_DATE");
state.setUnpublishedCommitCount(0);
state.setCreatedAt(Instant.now());
try {
return syncStateRepository.save(state);
} catch (Exception e) {
// Non-fatal: sync state can be recreated; subsequent calls
// will retry persistence via the orElseGet path.
log.warn("Could not persist sync state for user '{}': {}",
username, e.getMessage());
return state;
}
}
/**
* Merge the shared branch into the user's branch (pull).
*
* <p>This brings the user's branch up to date with the latest shared
* state. After a successful merge, the sync state is updated to record
* the shared branch HEAD as the last synced commit.
*
* @param username the authenticated user's username
* @param userBranch the user's branch to merge into
* @return the merge commit SHA
* @throws IOException if the Git merge operation fails
*/
public String syncFromShared(String username, String userBranch) throws IOException {
log.info("User '{}': syncing from shared branch '{}' into '{}'",
username, getSharedBranch(), userBranch);
String mergeCommit = gitRepository.merge(getSharedBranch(), userBranch);
if (mergeCommit == null) {
log.warn("User '{}': merge from '{}' into '{}' returned null (branch missing or conflict)",
username, getSharedBranch(), userBranch);
throw new IOException("Sync failed: merge returned null (branch missing or conflict)");
}
// Update sync state
try {
SyncState state = getSyncState(username);
String sharedHead = gitRepository.getHeadCommit(getSharedBranch());
state.setLastSyncedCommitId(sharedHead);
state.setLastSyncTimestamp(Instant.now());
state.setSyncStatus("UP_TO_DATE");
state.setUpdatedAt(Instant.now());
syncStateRepository.save(state);
} catch (Exception e) {
log.warn("Could not update sync state after pull for user '{}': {}",
username, e.getMessage());
}
log.info("User '{}': sync from shared complete, merge commit='{}'",
username, abbreviateSha(mergeCommit));
return mergeCommit;
}
/**
* Merge the user's branch into the shared branch (publish).
*
* <p>This publishes the user's changes to the shared integration
* repository. After a successful merge, the sync state is updated to
* record the published commit and reset the unpublished count.
*
* @param username the authenticated user's username
* @param userBranch the user's branch to merge from
* @return the merge commit SHA on the shared branch
* @throws IOException if the Git merge operation fails
*/
public String publishToShared(String username, String userBranch) throws IOException {
log.info("User '{}': publishing branch '{}' to shared branch '{}'",
username, userBranch, getSharedBranch());
String mergeCommit = gitRepository.merge(userBranch, getSharedBranch());
if (mergeCommit == null) {
log.warn("User '{}': merge from '{}' into '{}' returned null (branch missing or conflict)",
username, userBranch, getSharedBranch());
throw new IOException("Publish failed: merge returned null (branch missing or conflict)");
}
// Update sync state
try {
SyncState state = getSyncState(username);
state.setLastPublishedCommitId(mergeCommit);
state.setLastPublishTimestamp(Instant.now());
state.setSyncStatus("UP_TO_DATE");
state.setUnpublishedCommitCount(0);
state.setUpdatedAt(Instant.now());
syncStateRepository.save(state);
} catch (Exception e) {
log.warn("Could not update sync state after publish for user '{}': {}",
username, e.getMessage());
}
log.info("User '{}': publish to shared complete, merge commit='{}'",
username, abbreviateSha(mergeCommit));
return mergeCommit;
}
// ── Cross-repository sync (per-workspace isolation) ─────────────
/**
* Sync from the shared system repository into a workspace repository.
*
* <p>When a {@link DslGitRepositoryFactory} is configured, this method
* reads the DSL from the system repository's shared branch and copies it
* into the workspace repository's "main" branch. If both sides have
* content and they differ, the workspace content is overwritten with the
* shared content (fast-forward semantics).
*
* @param username the authenticated user's username
* @param workspaceId the workspace identifier
* @return the commit ID (SHA) of the workspace commit created by the sync
* @throws IOException if Git operations fail
*/
public String syncFromSharedToWorkspace(String username, String workspaceId) throws IOException {
if (repositoryFactory == null) {
throw new IllegalStateException("Repository factory not configured for cross-repo sync");
}
DslGitRepository sysRepo = repositoryFactory.getSystemRepository();
DslGitRepository wsRepo = repositoryFactory.getWorkspaceRepository(workspaceId);
String sharedDsl = sysRepo.getDslAtHead(getSharedBranch());
if (sharedDsl == null) {
throw new IOException("Shared branch has no content");
}
String wsDsl = wsRepo.getDslAtHead("main");
String commitId;
if (wsDsl == null) {
// Workspace branch is empty: create initial sync commit from shared
commitId = wsRepo.commitDsl("main", sharedDsl, username, "Synced from shared");
} else if (sharedDsl.equals(wsDsl)) {
// No-op: content already identical, return existing HEAD commit ID
commitId = wsRepo.getHeadCommit("main");
if (commitId == null) {
commitId = wsRepo.commitDsl("main", sharedDsl, username, "Synced from shared");
}
} else {
commitId = wsRepo.commitDsl("main", sharedDsl, username, "Merged shared into workspace");
}
// Update sync state
try {
SyncState state = getSyncState(username);
state.setLastSyncedCommitId(commitId);
state.setLastSyncTimestamp(Instant.now());
state.setSyncStatus("UP_TO_DATE");
state.setUpdatedAt(Instant.now());
syncStateRepository.save(state);
} catch (Exception e) {
log.warn("Could not update sync state after cross-repo sync for user '{}': {}",
username, e.getMessage());
}
log.info("User '{}': cross-repo sync from shared to workspace '{}' complete",
username, workspaceId);
return commitId;
}
/**
* Publish from a workspace repository to the shared system repository.
*
* <p>When a {@link DslGitRepositoryFactory} is configured, this method
* reads the DSL from the workspace repository's "main" branch and commits
* it to the system repository's shared branch.
*
* @param username the authenticated user's username
* @param workspaceId the workspace identifier
* @return the commit SHA on the shared branch
* @throws IOException if Git operations fail
*/
public String publishFromWorkspaceToShared(String username, String workspaceId) throws IOException {
if (repositoryFactory == null) {
throw new IllegalStateException("Repository factory not configured for cross-repo publish");
}
DslGitRepository sysRepo = repositoryFactory.getSystemRepository();
DslGitRepository wsRepo = repositoryFactory.getWorkspaceRepository(workspaceId);
String wsDsl = wsRepo.getDslAtHead("main");
if (wsDsl == null) {
throw new IOException("Workspace has no content");
}
// Check if shared DSL already matches workspace content to avoid redundant commits
String sharedDsl = sysRepo.getDslAtHead(getSharedBranch());
String commitId;
if (sharedDsl != null && sharedDsl.equals(wsDsl)) {
// No-op: content already identical, return existing shared HEAD commit ID
commitId = sysRepo.getHeadCommit(getSharedBranch());
log.info("User '{}': cross-repo publish from workspace '{}' to shared skipped (no changes)",
username, workspaceId);
} else {
commitId = sysRepo.commitDsl(getSharedBranch(), wsDsl, username,
"Published from workspace " + workspaceId);
}
// Update sync state
try {
SyncState state = getSyncState(username);
state.setLastPublishedCommitId(commitId);
state.setLastPublishTimestamp(Instant.now());
state.setSyncStatus("UP_TO_DATE");
state.setUnpublishedCommitCount(0);
state.setUpdatedAt(Instant.now());
syncStateRepository.save(state);
} catch (Exception e) {
log.warn("Could not update sync state after cross-repo publish for user '{}': {}",
username, e.getMessage());
}
log.info("User '{}': cross-repo publish from workspace '{}' to shared complete",
username, workspaceId);
return commitId;
}
/**
* Count unpublished commits on the user's branch relative to the shared branch.
*
* <p>Uses merge-base computation to accurately determine ahead/behind counts.
* The sync status is set to:
* <ul>
* <li>{@code UP_TO_DATE} — both branches are at the same commit</li>
* <li>{@code AHEAD} — user has commits not in the shared branch</li>
* <li>{@code BEHIND} — shared branch has commits the user hasn't pulled</li>
* <li>{@code DIVERGED} — both sides have unique commits</li>
* </ul>
*
* @param username the authenticated user's username
* @param userBranch the user's branch to check
* @return the number of unpublished commits (ahead count)
* @throws IOException if Git operations fail
*/
public int getLocalChanges(String username, String userBranch) throws IOException {
int[] aheadBehind = gitRepository.getAheadBehindCounts(userBranch, getSharedBranch());
int ahead = aheadBehind[0];
int behind = aheadBehind[1];
String status;
if (ahead == 0 && behind == 0) {
status = "UP_TO_DATE";
} else if (ahead > 0 && behind == 0) {
status = "AHEAD";
} else if (ahead == 0 && behind > 0) {
status = "BEHIND";
} else {
status = "DIVERGED";
}
updateUnpublishedCount(username, ahead, status);
return ahead;
}
private void updateUnpublishedCount(String username, int count, String status) {
try {
SyncState state = getSyncState(username);
state.setUnpublishedCommitCount(count);
state.setSyncStatus(status);
state.setUpdatedAt(Instant.now());
syncStateRepository.save(state);
} catch (Exception e) {
log.warn("Could not update unpublished count for user '{}': {}",
username, e.getMessage());
}
}
/**
* Check if the workspace has unpublished changes.
*
* <p>Reads the persistent sync state to determine if the user has
* local commits that have not been published to the shared branch.
*
* @param username the authenticated user's username
* @return true if the workspace has unpublished changes
*/
public boolean isDirty(String username) {
try {
SyncState state = getSyncState(username);
return state.getUnpublishedCommitCount() > 0
|| "AHEAD".equals(state.getSyncStatus())
|| "DIVERGED".equals(state.getSyncStatus());
} catch (Exception e) {
log.warn("Could not check dirty state for user '{}': {}", username, e.getMessage());
return false;
}
}
// ── Internal helpers ────────────────────────────────────────────
/**
* Resolution strategy for a diverged sync state.
*/
public enum DivergedStrategy {
/** Merge the shared branch into the user's branch. */
MERGE,
/** Keep the user's branch, discard shared changes. */
KEEP_MINE,
/** Replace the user's branch with the shared branch. */
TAKE_SHARED
}
/**
* Resolve a diverged state between user's branch and the shared branch.
*
* <p>Three strategies are available:
* <ul>
* <li>{@code MERGE} — merge shared into user branch (may fail if conflicts persist)</li>
* <li>{@code KEEP_MINE} — force-update shared from user (publish forcefully)</li>
* <li>{@code TAKE_SHARED} — force-update user from shared (overwrite local changes)</li>
* </ul>
*
* @param username the authenticated user's username
* @param userBranch the user's branch
* @param strategy the resolution strategy
* @return a description of what was done
* @throws IOException if a Git operation fails
*/
public String resolveDiverged(String username, String userBranch, DivergedStrategy strategy)
throws IOException {
log.info("User '{}': resolving DIVERGED state with strategy {} on branch '{}'",
username, strategy, userBranch);
switch (strategy) {
case MERGE:
String mergeCommit = gitRepository.merge(getSharedBranch(), userBranch);
if (mergeCommit == null) {
throw new IOException("Merge failed — conflict could not be auto-resolved. " +
"Try KEEP_MINE or TAKE_SHARED strategy instead.");
}
updateSyncStateAfterResolve(username, "UP_TO_DATE",
gitRepository.getHeadCommit(getSharedBranch()), null);
return "Merged shared into your branch: " + abbreviateSha(mergeCommit);
case KEEP_MINE:
// Force publish: merge user → shared
String publishCommit = gitRepository.merge(userBranch, getSharedBranch());
if (publishCommit == null) {
// If merge fails, restore from user's HEAD
String userHead = gitRepository.getHeadCommit(userBranch);
publishCommit = gitRepository.restore(userHead, getSharedBranch());
if (publishCommit == null) {
throw new IOException("Could not force-publish user branch to shared");
}
}
updateSyncStateAfterResolve(username, "UP_TO_DATE",
null, publishCommit);
return "Published your changes to shared: " + abbreviateSha(publishCommit);
case TAKE_SHARED:
// Force sync: restore user branch from shared HEAD
String sharedHead = gitRepository.getHeadCommit(getSharedBranch());
if (sharedHead == null) {
throw new IOException("Shared branch has no commits");
}
String restoreCommit = gitRepository.restore(sharedHead, userBranch);
if (restoreCommit == null) {
throw new IOException("Could not restore user branch from shared");
}
updateSyncStateAfterResolve(username, "UP_TO_DATE",
sharedHead, null);
return "Replaced your branch with shared content: " + abbreviateSha(restoreCommit);
default:
throw new IllegalArgumentException("Unknown strategy: " + strategy);
}
}
private void updateSyncStateAfterResolve(String username, String status,
String syncedCommitId, String publishedCommitId) {
try {
SyncState state = getSyncState(username);
state.setSyncStatus(status);
state.setUnpublishedCommitCount(0);
state.setLastSyncTimestamp(Instant.now());
state.setUpdatedAt(Instant.now());
if (syncedCommitId != null) {
state.setLastSyncedCommitId(syncedCommitId);
}
if (publishedCommitId != null) {
state.setLastPublishedCommitId(publishedCommitId);
state.setLastPublishTimestamp(Instant.now());
}
syncStateRepository.save(state);
} catch (Exception e) {
log.warn("Could not update sync state after resolve for user '{}': {}",
username, e.getMessage());
}
}
// ── Internal helpers (private) ──────────────────────────────────
private String abbreviateSha(String commitId) {
if (commitId == null) return "null";
return commitId.substring(0, Math.min(7, commitId.length()));
}
}