Files
stack/apps/orchestrator/src/git/conflict-detection.service.ts
Jason Woltje fc87494137
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix(orchestrator): resolve all M6 remediation issues (#260-#269)
Addresses all 10 quality remediation issues for the orchestrator module:

TypeScript & Type Safety:
- #260: Fix TypeScript compilation errors in tests
- #261: Replace explicit 'any' types with proper typed mocks

Error Handling & Reliability:
- #262: Fix silent cleanup failures - return structured results
- #263: Fix silent Valkey event parsing failures with proper error handling
- #266: Improve error context in Docker operations
- #267: Fix secret scanner false negatives on file read errors
- #268: Fix worktree cleanup error swallowing

Testing & Quality:
- #264: Add queue integration tests (coverage 15% → 85%)
- #265: Fix Prettier formatting violations
- #269: Update outdated TODO comments

All tests passing (406/406), TypeScript compiles cleanly, ESLint clean.

Fixes #260, Fixes #261, Fixes #262, Fixes #263, Fixes #264
Fixes #265, Fixes #266, Fixes #267, Fixes #268, Fixes #269

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 12:44:04 -06:00

233 lines
6.4 KiB
TypeScript

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<ConflictCheckResult> {
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.toString()} conflicts`);
return {
hasConflicts: true,
conflicts,
strategy,
canRetry: true,
remoteBranch,
localBranch,
};
} catch (error) {
this.logger.error(`Failed to check for conflicts: ${String(error)}`);
throw new ConflictDetectionError(
`Failed to check for conflicts in ${localPath}`,
"checkForConflicts",
error as Error
);
}
}
/**
* Fetch latest from remote
*/
async fetchRemote(localPath: string, remote = "origin", branch?: string): Promise<void> {
try {
this.logger.log(`Fetching from ${remote}${branch ? `/${branch}` : ""}`);
const git = this.getGit(localPath);
// Call fetch with appropriate overload based on branch parameter
if (branch) {
await git.fetch(remote, branch);
} else {
await git.fetch(remote);
}
this.logger.log("Successfully fetched from remote");
} catch (error) {
this.logger.error(`Failed to fetch from remote: ${String(error)}`);
throw new ConflictDetectionError(
`Failed to fetch from ${remote}`,
"fetchRemote",
error as Error
);
}
}
/**
* Detect conflicts in current state
*/
async detectConflicts(localPath: string): Promise<ConflictInfo[]> {
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: ${String(error)}`);
throw new ConflictDetectionError(
`Failed to detect conflicts in ${localPath}`,
"detectConflicts",
error as Error
);
}
}
/**
* Get current branch name
*/
async getCurrentBranch(localPath: string): Promise<string> {
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: ${String(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<boolean> {
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<void> {
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}: ${String(error)}`);
}
}
}