import { Injectable, Logger } from "@nestjs/common"; import { simpleGit, SimpleGit, StatusResult } from "simple-git"; import { ConflictCheckResult, ConflictInfo, ConflictCheckOptions, ConflictDetectionError, } from "./types"; /** * Service for detecting merge conflicts before pushing */ @Injectable() export class ConflictDetectionService { private readonly logger = new Logger(ConflictDetectionService.name); /** * Get a simple-git instance for a local path */ private getGit(localPath: string): SimpleGit { return simpleGit(localPath); } /** * Check for conflicts before pushing * Fetches latest from remote and attempts a test merge/rebase */ async checkForConflicts( localPath: string, options?: ConflictCheckOptions, ): Promise { const remote = options?.remote ?? "origin"; const remoteBranch = options?.remoteBranch ?? "develop"; const strategy = options?.strategy ?? "merge"; try { this.logger.log( `Checking for conflicts in ${localPath} with ${remote}/${remoteBranch} using ${strategy}`, ); // Get current branch const localBranch = await this.getCurrentBranch(localPath); // Fetch latest from remote await this.fetchRemote(localPath, remote, remoteBranch); // Attempt test merge/rebase const hasConflicts = await this.attemptMerge( localPath, remote, remoteBranch, strategy, ); if (!hasConflicts) { this.logger.log("No conflicts detected"); return { hasConflicts: false, conflicts: [], strategy, canRetry: false, remoteBranch, localBranch, }; } // Detect conflicts const conflicts = await this.detectConflicts(localPath); // Cleanup - abort the merge/rebase await this.cleanupMerge(localPath, strategy); this.logger.log(`Detected ${conflicts.length} conflicts`); return { hasConflicts: true, conflicts, strategy, canRetry: true, remoteBranch, localBranch, }; } catch (error) { this.logger.error(`Failed to check for conflicts: ${error}`); throw new ConflictDetectionError( `Failed to check for conflicts in ${localPath}`, "checkForConflicts", error as Error, ); } } /** * Fetch latest from remote */ async fetchRemote( localPath: string, remote: string = "origin", branch?: string, ): Promise { try { this.logger.log(`Fetching from ${remote}${branch ? `/${branch}` : ""}`); const git = this.getGit(localPath); await git.fetch(remote, branch); this.logger.log("Successfully fetched from remote"); } catch (error) { this.logger.error(`Failed to fetch from remote: ${error}`); throw new ConflictDetectionError( `Failed to fetch from ${remote}`, "fetchRemote", error as Error, ); } } /** * Detect conflicts in current state */ async detectConflicts(localPath: string): Promise { try { const git = this.getGit(localPath); const status: StatusResult = await git.status(); const conflicts: ConflictInfo[] = []; // Process conflicted files for (const file of status.conflicted) { // Find the file in status.files to get more details const fileStatus = status.files.find((f) => f.path === file); // Determine conflict type let type: ConflictInfo["type"] = "content"; if (fileStatus) { if (fileStatus.index === "D" || fileStatus.working_dir === "D") { type = "delete"; } else if (fileStatus.index === "A" && fileStatus.working_dir === "A") { type = "add"; } else if (fileStatus.index === "R" || fileStatus.working_dir === "R") { type = "rename"; } } conflicts.push({ file, type, }); } return conflicts; } catch (error) { this.logger.error(`Failed to detect conflicts: ${error}`); throw new ConflictDetectionError( `Failed to detect conflicts in ${localPath}`, "detectConflicts", error as Error, ); } } /** * Get current branch name */ async getCurrentBranch(localPath: string): Promise { try { const git = this.getGit(localPath); const branch = await git.revparse(["--abbrev-ref", "HEAD"]); return branch.trim(); } catch (error) { this.logger.error(`Failed to get current branch: ${error}`); throw new ConflictDetectionError( `Failed to get current branch in ${localPath}`, "getCurrentBranch", error as Error, ); } } /** * Attempt a test merge/rebase to detect conflicts * Returns true if conflicts detected, false if clean */ private async attemptMerge( localPath: string, remote: string, remoteBranch: string, strategy: "merge" | "rebase", ): Promise { const git = this.getGit(localPath); const remoteRef = `${remote}/${remoteBranch}`; try { if (strategy === "merge") { // Attempt test merge with --no-commit and --no-ff await git.raw(["merge", "--no-commit", "--no-ff", remoteRef]); } else { // Attempt test rebase await git.raw(["rebase", remoteRef]); } // If we get here, no conflicts return false; } catch (error) { // Check if error is due to conflicts const errorMessage = (error as Error).message || String(error); if ( errorMessage.includes("CONFLICT") || errorMessage.includes("conflict") ) { // Conflicts detected return true; } // Other error - rethrow throw error; } } /** * Cleanup after test merge/rebase */ private async cleanupMerge( localPath: string, strategy: "merge" | "rebase", ): Promise { try { const git = this.getGit(localPath); if (strategy === "merge") { await git.raw(["merge", "--abort"]); } else { await git.raw(["rebase", "--abort"]); } this.logger.log(`Cleaned up ${strategy} operation`); } catch (error) { // Log warning but don't throw - cleanup is best-effort this.logger.warn(`Failed to cleanup ${strategy}: ${error}`); } } }