feat(KB-023): add smart automatic merge conflict resolution
- Add conflict classification API: classifyConflict(), getConflictedFiles(), resolveWithOurs(), resolveWithTheirs(), resolveTrivialWhitespace() - Auto-resolve lock files (ours), generated files (theirs), and trivial whitespace conflicts - Add smartConflictResolution setting (alias for autoResolveConflicts) - Track resolution path via resolutionMethod and autoResolvedCount in MergeResult - Implement 3-attempt retry logic with escalating strategies in aiMergeTask - Export pattern constants LOCKFILE_PATTERNS and GENERATED_PATTERNS
This commit is contained in:
@@ -20,6 +20,14 @@ import {
|
||||
detectResolvableConflicts,
|
||||
autoResolveFile,
|
||||
resolveConflicts,
|
||||
classifyConflict,
|
||||
getConflictedFiles,
|
||||
isTrivialWhitespaceConflict,
|
||||
resolveWithOurs,
|
||||
resolveWithTheirs,
|
||||
resolveTrivialWhitespace,
|
||||
LOCKFILE_PATTERNS,
|
||||
GENERATED_PATTERNS,
|
||||
type ConflictCategory,
|
||||
} from "./merger.js";
|
||||
import { createKbAgent } from "./pi.js";
|
||||
@@ -751,35 +759,35 @@ describe("detectResolvableConflicts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("detects .gen.ts files as generated files with 'ours' strategy", () => {
|
||||
it("detects .gen.ts files as generated files with 'theirs' strategy", () => {
|
||||
mockedExecSync.mockReturnValue("src/types.gen.ts\n");
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
strategy: "theirs",
|
||||
reason: "generated-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects dist/ paths as generated files", () => {
|
||||
it("detects dist/ paths as generated files with 'theirs' strategy", () => {
|
||||
mockedExecSync.mockReturnValue("dist/index.js\n");
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
strategy: "theirs",
|
||||
reason: "generated-file",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects coverage/ paths as generated files", () => {
|
||||
it("detects coverage/ paths as generated files with 'theirs' strategy", () => {
|
||||
mockedExecSync.mockReturnValue("coverage/lcov-report/index.html\n");
|
||||
|
||||
const result = detectResolvableConflicts("/tmp/root");
|
||||
expect(result[0]).toMatchObject({
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
strategy: "theirs",
|
||||
reason: "generated-file",
|
||||
});
|
||||
});
|
||||
@@ -1378,3 +1386,282 @@ describe("aiMergeTask — retry logic with escalating strategies", () => {
|
||||
expect(result.attemptsMade).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── New Smart Conflict Resolution API Tests ────────────────────────────
|
||||
|
||||
describe("classifyConflict", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("classifies package-lock.json as 'lockfile-ours'", () => {
|
||||
const result = classifyConflict("package-lock.json", "/tmp/root");
|
||||
expect(result).toBe("lockfile-ours");
|
||||
});
|
||||
|
||||
it("classifies pnpm-lock.yaml as 'lockfile-ours'", () => {
|
||||
const result = classifyConflict("pnpm-lock.yaml", "/tmp/root");
|
||||
expect(result).toBe("lockfile-ours");
|
||||
});
|
||||
|
||||
it("classifies yarn.lock as 'lockfile-ours'", () => {
|
||||
const result = classifyConflict("yarn.lock", "/tmp/root");
|
||||
expect(result).toBe("lockfile-ours");
|
||||
});
|
||||
|
||||
it("classifies Gemfile.lock as 'lockfile-ours'", () => {
|
||||
const result = classifyConflict("Gemfile.lock", "/tmp/root");
|
||||
expect(result).toBe("lockfile-ours");
|
||||
});
|
||||
|
||||
it("classifies bun.lockb as 'lockfile-ours'", () => {
|
||||
const result = classifyConflict("bun.lockb", "/tmp/root");
|
||||
expect(result).toBe("lockfile-ours");
|
||||
});
|
||||
|
||||
it("classifies go.sum as 'lockfile-ours'", () => {
|
||||
const result = classifyConflict("go.sum", "/tmp/root");
|
||||
expect(result).toBe("lockfile-ours");
|
||||
});
|
||||
|
||||
it("classifies *.gen.ts files as 'generated-theirs'", () => {
|
||||
const result = classifyConflict("src/types.gen.ts", "/tmp/root");
|
||||
expect(result).toBe("generated-theirs");
|
||||
});
|
||||
|
||||
it("classifies dist/* files as 'generated-theirs'", () => {
|
||||
const result = classifyConflict("dist/bundle.js", "/tmp/root");
|
||||
expect(result).toBe("generated-theirs");
|
||||
});
|
||||
|
||||
it("classifies build/* files as 'generated-theirs'", () => {
|
||||
const result = classifyConflict("build/index.html", "/tmp/root");
|
||||
expect(result).toBe("generated-theirs");
|
||||
});
|
||||
|
||||
it("classifies *.min.js files as 'generated-theirs'", () => {
|
||||
const result = classifyConflict("app.min.js", "/tmp/root");
|
||||
expect(result).toBe("generated-theirs");
|
||||
});
|
||||
|
||||
it("classifies regular source files as 'complex'", () => {
|
||||
mockedReadFileSync.mockReturnValue("const x = 1;");
|
||||
const result = classifyConflict("src/components/App.tsx", "/tmp/root");
|
||||
expect(result).toBe("complex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConflictedFiles", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns array of conflicted file paths", () => {
|
||||
mockedExecSync.mockReturnValue("package-lock.json\nsrc/index.ts\n");
|
||||
|
||||
const result = getConflictedFiles("/tmp/root");
|
||||
expect(result).toEqual(["package-lock.json", "src/index.ts"]);
|
||||
});
|
||||
|
||||
it("returns empty array when no conflicts", () => {
|
||||
mockedExecSync.mockReturnValue("");
|
||||
|
||||
const result = getConflictedFiles("/tmp/root");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array on git error", () => {
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("git error");
|
||||
});
|
||||
|
||||
const result = getConflictedFiles("/tmp/root");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWithOurs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||
});
|
||||
|
||||
it("calls git checkout --ours and git add", () => {
|
||||
resolveWithOurs("package-lock.json", "/tmp/root");
|
||||
|
||||
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("checkout --ours"),
|
||||
);
|
||||
const addCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("git add"),
|
||||
);
|
||||
|
||||
expect(checkoutCall).toBeDefined();
|
||||
expect(addCall).toBeDefined();
|
||||
expect(String(checkoutCall![0])).toContain("package-lock.json");
|
||||
});
|
||||
|
||||
it("throws on git error", () => {
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("checkout failed");
|
||||
});
|
||||
|
||||
expect(() => resolveWithOurs("file.ts", "/tmp/root")).toThrow(
|
||||
"Failed to auto-resolve",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWithTheirs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||
});
|
||||
|
||||
it("calls git checkout --theirs and git add", () => {
|
||||
resolveWithTheirs("dist/bundle.js", "/tmp/root");
|
||||
|
||||
const checkoutCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("checkout --theirs"),
|
||||
);
|
||||
const addCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("git add"),
|
||||
);
|
||||
|
||||
expect(checkoutCall).toBeDefined();
|
||||
expect(addCall).toBeDefined();
|
||||
expect(String(checkoutCall![0])).toContain("dist/bundle.js");
|
||||
});
|
||||
|
||||
it("throws on git error", () => {
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("checkout failed");
|
||||
});
|
||||
|
||||
expect(() => resolveWithTheirs("file.ts", "/tmp/root")).toThrow(
|
||||
"Failed to auto-resolve",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTrivialWhitespace", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedExecSync.mockReturnValue(Buffer.from(""));
|
||||
});
|
||||
|
||||
it("calls git add to resolve trivial whitespace conflict", () => {
|
||||
resolveTrivialWhitespace("src/utils.ts", "/tmp/root");
|
||||
|
||||
const addCall = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("git add"),
|
||||
);
|
||||
|
||||
expect(addCall).toBeDefined();
|
||||
expect(String(addCall![0])).toContain("src/utils.ts");
|
||||
});
|
||||
|
||||
it("throws on git error", () => {
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("add failed");
|
||||
});
|
||||
|
||||
expect(() => resolveTrivialWhitespace("file.ts", "/tmp/root")).toThrow(
|
||||
"Failed to auto-resolve",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LOCKFILE_PATTERNS and GENERATED_PATTERNS", () => {
|
||||
it("LOCKFILE_PATTERNS contains expected lock file patterns", () => {
|
||||
expect(LOCKFILE_PATTERNS).toContain("package-lock.json");
|
||||
expect(LOCKFILE_PATTERNS).toContain("pnpm-lock.yaml");
|
||||
expect(LOCKFILE_PATTERNS).toContain("yarn.lock");
|
||||
expect(LOCKFILE_PATTERNS).toContain("Gemfile.lock");
|
||||
expect(LOCKFILE_PATTERNS).toContain("bun.lockb");
|
||||
expect(LOCKFILE_PATTERNS).toContain("go.sum");
|
||||
expect(LOCKFILE_PATTERNS).toContain("composer.lock");
|
||||
expect(LOCKFILE_PATTERNS).toContain("poetry.lock");
|
||||
expect(LOCKFILE_PATTERNS).not.toContain("Cargo.lock"); // Not in task spec
|
||||
});
|
||||
|
||||
it("GENERATED_PATTERNS contains expected generated file patterns", () => {
|
||||
expect(GENERATED_PATTERNS).toContain("*.gen.ts");
|
||||
expect(GENERATED_PATTERNS).toContain("*.gen.js");
|
||||
expect(GENERATED_PATTERNS).toContain("*.min.js");
|
||||
expect(GENERATED_PATTERNS).toContain("*.min.css");
|
||||
expect(GENERATED_PATTERNS).toContain("dist/*");
|
||||
expect(GENERATED_PATTERNS).toContain("build/*");
|
||||
expect(GENERATED_PATTERNS).toContain("coverage/*");
|
||||
expect(GENERATED_PATTERNS).toContain("out/*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTrivialWhitespaceConflict", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns true when diff contains only whitespace changes", () => {
|
||||
// Mock git diff-tree to return empty diff (no content changes)
|
||||
mockedExecSync.mockReturnValue(
|
||||
"diff --git a/file.ts b/file.ts\nindex 123..456 100644\n--- a/file.ts\n+++ b/file.ts\n"
|
||||
);
|
||||
|
||||
const result = isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when diff contains content changes", () => {
|
||||
// Mock git diff-tree to return diff with actual content changes
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
const error = new Error("exit code 1") as any;
|
||||
error.stdout = `diff --git a/file.ts b/file.ts
|
||||
--- a/file.ts
|
||||
+++ b/file.ts
|
||||
@@ -1 +1 @@
|
||||
-const x = 1;
|
||||
+const x = 2;`;
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when only line endings differ (CRLF vs LF)", () => {
|
||||
// Mock git diff-tree -w to show no content changes (whitespace ignored)
|
||||
mockedExecSync.mockReturnValue(
|
||||
"diff --git a/file.ts b/file.ts\nindex 123..456 100644\n--- a/file.ts\n+++ b/file.ts\n"
|
||||
);
|
||||
|
||||
const result = isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when git diff-tree fails unexpectedly", () => {
|
||||
mockedExecSync.mockImplementation(() => {
|
||||
throw new Error("fatal: not a git repository");
|
||||
});
|
||||
// Mock readFileSync for the fallback
|
||||
mockedReadFileSync.mockReturnValue("content without conflict markers");
|
||||
|
||||
const result = isTrivialWhitespaceConflict("src/file.ts", "/tmp/root");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("calls git diff-tree with correct index references (:2: and :3:)", () => {
|
||||
mockedExecSync.mockReturnValue("");
|
||||
|
||||
isTrivialWhitespaceConflict("src/utils.ts", "/tmp/root");
|
||||
|
||||
const call = mockedExecSync.mock.calls.find((call) =>
|
||||
String(call[0]).includes("git diff-tree")
|
||||
);
|
||||
expect(call).toBeDefined();
|
||||
const cmdStr = String(call![0]);
|
||||
expect(cmdStr).toContain("-w"); // whitespace ignored
|
||||
expect(cmdStr).toContain(':2:"src/utils.ts"');
|
||||
expect(cmdStr).toContain(':3:"src/utils.ts"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,216 @@ import { AgentLogger } from "./agent-logger.js";
|
||||
import { mergerLog } from "./logger.js";
|
||||
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
|
||||
|
||||
/** Conflict category for a file with merge conflicts */
|
||||
/** Conflict type classification for merge conflict resolution */
|
||||
export type ConflictType =
|
||||
| "lockfile-ours"
|
||||
| "generated-theirs"
|
||||
| "trivial-whitespace"
|
||||
| "complex";
|
||||
|
||||
/** Lock file patterns that should auto-resolve using "ours" (keep current branch's version) */
|
||||
export const LOCKFILE_PATTERNS = [
|
||||
"package-lock.json",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"Gemfile.lock",
|
||||
"composer.lock",
|
||||
"poetry.lock",
|
||||
"bun.lockb",
|
||||
"go.sum",
|
||||
];
|
||||
|
||||
/** Generated file patterns that should auto-resolve using "theirs" (keep branch's fresh generation) */
|
||||
export const GENERATED_PATTERNS = [
|
||||
"*.gen.ts",
|
||||
"*.gen.js",
|
||||
"*.min.js",
|
||||
"*.min.css",
|
||||
"dist/*",
|
||||
"build/*",
|
||||
"coverage/*",
|
||||
".next/*",
|
||||
".nuxt/*",
|
||||
".output/*",
|
||||
".cache/*",
|
||||
"out/*",
|
||||
"__generated__/*",
|
||||
"generated/*",
|
||||
];
|
||||
|
||||
/** Check if a path matches a glob pattern (simple glob support: * and **) */
|
||||
function matchGlob(path: string, pattern: string): boolean {
|
||||
// Handle ** which matches across directory boundaries (must do before single *)
|
||||
if (pattern.includes("**")) {
|
||||
// Convert ** to match any characters including /
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, "\\.")
|
||||
.replace(/\*\*/g, "<<<DOUBLESTAR>>>")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/<<<DOUBLESTAR>>>/g, ".*");
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
return regex.test(path);
|
||||
}
|
||||
|
||||
// Handle patterns with single directory wildcards (e.g., "src/*.ts")
|
||||
const lastSlash = pattern.lastIndexOf("/");
|
||||
if (lastSlash !== -1) {
|
||||
const patternDir = pattern.slice(0, lastSlash);
|
||||
const patternFile = pattern.slice(lastSlash + 1);
|
||||
const pathDir = path.lastIndexOf("/") !== -1 ? path.slice(0, path.lastIndexOf("/")) : "";
|
||||
const pathFile = path.lastIndexOf("/") !== -1 ? path.slice(path.lastIndexOf("/")) : path;
|
||||
|
||||
// Check if directories match
|
||||
if (patternDir.includes("*")) {
|
||||
const dirRegex = new RegExp(`^${patternDir.replace(/\./g, "\\.").replace(/\*/g, "[^/]*")}$`);
|
||||
if (!dirRegex.test(pathDir)) return false;
|
||||
} else if (!pathDir.endsWith(patternDir) && patternDir !== pathDir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match filename pattern
|
||||
return matchGlob(pathFile, patternFile);
|
||||
}
|
||||
|
||||
// Simple pattern without directory - match against filename only or full path
|
||||
const fileName = path.lastIndexOf("/") !== -1 ? path.slice(path.lastIndexOf("/") + 1) : path;
|
||||
|
||||
// Convert glob to regex
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, "\\.")
|
||||
.replace(/\*/g, "[^/]*");
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
return regex.test(fileName) || regex.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of conflicted files from git.
|
||||
* Runs `git diff --name-only --diff-filter=U` and returns array of file paths.
|
||||
*/
|
||||
export function getConflictedFiles(cwd: string): string[] {
|
||||
try {
|
||||
const output = execSync("git diff --name-only --diff-filter=U", {
|
||||
cwd,
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
|
||||
if (!output) return [];
|
||||
return output.split("\n").filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file has only trivial whitespace conflicts using git.
|
||||
* Compares ours (:2) and theirs (:3) versions with whitespace ignored.
|
||||
*/
|
||||
export function isTrivialWhitespaceConflict(filePath: string, cwd: string): boolean {
|
||||
try {
|
||||
// Use git diff-tree to compare index entries with whitespace ignored
|
||||
// :2 = ours (current branch), :3 = theirs (incoming branch)
|
||||
// -w flag ignores whitespace
|
||||
const result = execSync(
|
||||
`git diff-tree -p -w -- :2:"${filePath}" :3:"${filePath}"`,
|
||||
{ cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }
|
||||
);
|
||||
|
||||
// If the diff output is empty or contains no actual changes, it's trivial
|
||||
// The diff output will have headers but no +/- content lines for whitespace-only changes
|
||||
const lines = result.split("\n");
|
||||
const contentChanges = lines.filter(
|
||||
(line: string) => (line.startsWith("+") || line.startsWith("-")) &&
|
||||
!line.startsWith("+++") && !line.startsWith("---")
|
||||
);
|
||||
return contentChanges.length === 0;
|
||||
} catch (error: any) {
|
||||
// git diff-tree may exit with code 1 when there are differences
|
||||
// Check if the error output indicates substantive changes
|
||||
if (error.stdout && typeof error.stdout === "string") {
|
||||
const lines = error.stdout.split("\n");
|
||||
const contentChanges = lines.filter(
|
||||
(line: string) => (line.startsWith("+") || line.startsWith("-")) &&
|
||||
!line.startsWith("+++") && !line.startsWith("---")
|
||||
);
|
||||
return contentChanges.length === 0;
|
||||
}
|
||||
// On other errors, assume complex conflict (don't fallback to isTrivialConflict
|
||||
// which reads working directory files with conflict markers)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a single conflicted file for auto-resolution.
|
||||
* Returns one of: 'lockfile-ours', 'generated-theirs', 'trivial-whitespace', 'complex'
|
||||
*/
|
||||
export function classifyConflict(filePath: string, cwd: string): ConflictType {
|
||||
// Check for lock files - always take "ours" (current branch's version)
|
||||
if (LOCKFILE_PATTERNS.some((pattern) => matchGlob(filePath, pattern))) {
|
||||
return "lockfile-ours";
|
||||
}
|
||||
|
||||
// Check for generated files - take "theirs" (keep branch's fresh generation)
|
||||
if (GENERATED_PATTERNS.some((pattern) => matchGlob(filePath, pattern))) {
|
||||
return "generated-theirs";
|
||||
}
|
||||
|
||||
// Check for trivial conflicts (whitespace-only)
|
||||
if (isTrivialWhitespaceConflict(filePath, cwd)) {
|
||||
return "trivial-whitespace";
|
||||
}
|
||||
|
||||
// Complex conflicts require AI intervention
|
||||
return "complex";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a conflicted file using "ours" (current branch's version).
|
||||
* Runs `git checkout --ours` and `git add`.
|
||||
*/
|
||||
export function resolveWithOurs(filePath: string, cwd: string): void {
|
||||
try {
|
||||
execSync(`git checkout --ours "${filePath}"`, { cwd, stdio: "pipe" });
|
||||
execSync(`git add "${filePath}"`, { cwd, stdio: "pipe" });
|
||||
mergerLog.log(`Auto-resolved ${filePath} using --ours`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to auto-resolve ${filePath} with ours: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a conflicted file using "theirs" (incoming branch's version).
|
||||
* Runs `git checkout --theirs` and `git add`.
|
||||
*/
|
||||
export function resolveWithTheirs(filePath: string, cwd: string): void {
|
||||
try {
|
||||
execSync(`git checkout --theirs "${filePath}"`, { cwd, stdio: "pipe" });
|
||||
execSync(`git add "${filePath}"`, { cwd, stdio: "pipe" });
|
||||
mergerLog.log(`Auto-resolved ${filePath} using --theirs`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to auto-resolve ${filePath} with theirs: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a trivial whitespace conflict.
|
||||
* For trivial conflicts, we can just stage the file (git considers it resolved).
|
||||
*/
|
||||
export function resolveTrivialWhitespace(filePath: string, cwd: string): void {
|
||||
try {
|
||||
execSync(`git add "${filePath}"`, { cwd, stdio: "pipe" });
|
||||
mergerLog.log(`Auto-resolved ${filePath} (trivial whitespace)`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to auto-resolve ${filePath} trivial conflict: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(KB-023 Step 4): Consolidate with new API above. The following legacy API
|
||||
// (ConflictCategory, detectResolvableConflicts, isTrivialConflict, autoResolveFile,
|
||||
// resolveConflicts) duplicates functionality with the new Step 2 API. Migrate
|
||||
// callers to use classifyConflict, resolveWithOurs, resolveWithTheirs, etc.
|
||||
|
||||
/** Conflict category for a file with merge conflicts - LEGACY API, see above */
|
||||
export type ConflictResolution = "ours" | "theirs";
|
||||
|
||||
export interface ConflictCategory {
|
||||
@@ -29,17 +238,22 @@ const LOCK_FILE_PATTERNS = [
|
||||
/Cargo\.lock$/,
|
||||
/composer\.lock$/,
|
||||
/poetry\.lock$/,
|
||||
/bun\.lockb$/,
|
||||
/go\.sum$/,
|
||||
];
|
||||
|
||||
/** Generated file patterns that should auto-resolve using "ours" */
|
||||
/** Generated file patterns that should auto-resolve using "theirs" (keep branch's fresh generation) */
|
||||
const GENERATED_FILE_PATTERNS = [
|
||||
/\.gen\.(ts|js|tsx|jsx|mjs|cjs)$/,
|
||||
/\.min\.(js|css)$/,
|
||||
/dist\//,
|
||||
/build\//,
|
||||
/coverage\//,
|
||||
/\.next\//,
|
||||
/\.nuxt\//,
|
||||
/\.output\//,
|
||||
/\.cache\//,
|
||||
/out\//,
|
||||
/__generated__\//,
|
||||
/generated\//,
|
||||
];
|
||||
@@ -73,12 +287,12 @@ export function detectResolvableConflicts(rootDir: string): ConflictCategory[] {
|
||||
};
|
||||
}
|
||||
|
||||
// Check for generated files - take "ours" (regenerate after merge)
|
||||
// Check for generated files - take "theirs" (keep branch's fresh generation)
|
||||
if (GENERATED_FILE_PATTERNS.some((pattern) => pattern.test(filePath))) {
|
||||
return {
|
||||
filePath,
|
||||
autoResolvable: true,
|
||||
strategy: "ours",
|
||||
strategy: "theirs",
|
||||
reason: "generated-file",
|
||||
};
|
||||
}
|
||||
@@ -341,7 +555,8 @@ export async function aiMergeTask(
|
||||
// 2. Read settings
|
||||
const settings = await store.getSettings();
|
||||
const includeTaskId = settings.includeTaskIdInCommit !== false;
|
||||
const autoResolveConflicts = settings.autoResolveConflicts !== false;
|
||||
// Support both setting names: smartConflictResolution (new) and autoResolveConflicts (legacy)
|
||||
const smartConflictResolution = (settings.smartConflictResolution ?? settings.autoResolveConflicts) !== false;
|
||||
|
||||
// 3. Check branch exists
|
||||
try {
|
||||
@@ -391,14 +606,16 @@ export async function aiMergeTask(
|
||||
commitLog,
|
||||
diffStat,
|
||||
includeTaskId,
|
||||
autoResolveConflicts,
|
||||
smartConflictResolution,
|
||||
attemptNum,
|
||||
options,
|
||||
});
|
||||
result,
|
||||
}, aiTracker);
|
||||
|
||||
if (success) {
|
||||
result.attemptsMade = attemptNum;
|
||||
result.resolutionStrategy = getResolutionStrategy(attemptNum, autoResolveConflicts);
|
||||
result.resolutionStrategy = getResolutionStrategy(attemptNum, smartConflictResolution);
|
||||
result.resolutionMethod = getResolutionMethod(result.resolutionStrategy, result.autoResolvedCount, aiTracker.aiWasInvoked);
|
||||
result.merged = true;
|
||||
return true;
|
||||
}
|
||||
@@ -414,7 +631,7 @@ export async function aiMergeTask(
|
||||
return false;
|
||||
} catch (error: any) {
|
||||
// Clean up on error before potentially rethrowing or retrying
|
||||
if (attemptNum < 3 && autoResolveConflicts) {
|
||||
if (attemptNum < 3 && smartConflictResolution) {
|
||||
mergerLog.log(`${taskId}: attempt ${attemptNum} error, cleaning up for retry...`);
|
||||
try {
|
||||
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
|
||||
@@ -425,6 +642,9 @@ export async function aiMergeTask(
|
||||
}
|
||||
};
|
||||
|
||||
// Track AI agent invocation for resolutionMethod calculation
|
||||
const aiTracker: AiInvocationTracker = { aiWasInvoked: false };
|
||||
|
||||
// Execute attempts with escalation
|
||||
let merged = false;
|
||||
|
||||
@@ -432,12 +652,12 @@ export async function aiMergeTask(
|
||||
merged = await mergeAttempt(1);
|
||||
|
||||
// Attempt 2: Auto-resolve lock/generated files, then AI (if enabled)
|
||||
if (!merged && autoResolveConflicts) {
|
||||
if (!merged && smartConflictResolution) {
|
||||
merged = await mergeAttempt(2);
|
||||
}
|
||||
|
||||
// Attempt 3: Use -X theirs merge strategy (if enabled)
|
||||
if (!merged && autoResolveConflicts) {
|
||||
if (!merged && smartConflictResolution) {
|
||||
merged = await mergeAttempt(3);
|
||||
}
|
||||
|
||||
@@ -489,9 +709,9 @@ export async function aiMergeTask(
|
||||
/** Get the resolution strategy based on attempt number and settings */
|
||||
function getResolutionStrategy(
|
||||
attemptNum: 1 | 2 | 3,
|
||||
autoResolveConflicts: boolean,
|
||||
smartConflictResolution: boolean,
|
||||
): MergeResult["resolutionStrategy"] {
|
||||
if (!autoResolveConflicts || attemptNum === 1) {
|
||||
if (!smartConflictResolution || attemptNum === 1) {
|
||||
return "ai";
|
||||
}
|
||||
if (attemptNum === 2) {
|
||||
@@ -500,6 +720,25 @@ function getResolutionStrategy(
|
||||
return "theirs";
|
||||
}
|
||||
|
||||
/** Map resolutionStrategy and autoResolvedCount to resolutionMethod for metrics/debugging */
|
||||
function getResolutionMethod(
|
||||
strategy: MergeResult["resolutionStrategy"],
|
||||
autoResolvedCount?: number,
|
||||
aiWasUsed?: boolean,
|
||||
): MergeResult["resolutionMethod"] {
|
||||
if (strategy === "ai") return "ai";
|
||||
if (strategy === "theirs") return "theirs";
|
||||
if (strategy === "auto-resolve") {
|
||||
// auto-resolve strategy: determine if pure auto or mixed with AI
|
||||
if (autoResolvedCount && autoResolvedCount > 0) {
|
||||
// If AI was actually invoked during auto-resolve attempt, it's mixed
|
||||
return aiWasUsed ? "mixed" : "auto";
|
||||
}
|
||||
return "auto";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface MergeAttemptParams {
|
||||
store: TaskStore;
|
||||
rootDir: string;
|
||||
@@ -508,9 +747,15 @@ interface MergeAttemptParams {
|
||||
commitLog: string;
|
||||
diffStat: string;
|
||||
includeTaskId: boolean;
|
||||
autoResolveConflicts: boolean;
|
||||
smartConflictResolution: boolean;
|
||||
attemptNum: 1 | 2 | 3;
|
||||
options: MergerOptions;
|
||||
result: MergeResult;
|
||||
}
|
||||
|
||||
/** Mutable flag to track AI agent invocation */
|
||||
interface AiInvocationTracker {
|
||||
aiWasInvoked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -518,7 +763,10 @@ interface MergeAttemptParams {
|
||||
* Returns true if merge succeeded, false if should retry (for attempts 1-2).
|
||||
* Throws on unrecoverable errors.
|
||||
*/
|
||||
async function executeMergeAttempt(params: MergeAttemptParams): Promise<boolean> {
|
||||
async function executeMergeAttempt(
|
||||
params: MergeAttemptParams,
|
||||
aiTracker: AiInvocationTracker,
|
||||
): Promise<boolean> {
|
||||
const {
|
||||
store,
|
||||
rootDir,
|
||||
@@ -527,9 +775,10 @@ async function executeMergeAttempt(params: MergeAttemptParams): Promise<boolean>
|
||||
commitLog,
|
||||
diffStat,
|
||||
includeTaskId,
|
||||
autoResolveConflicts,
|
||||
smartConflictResolution,
|
||||
attemptNum,
|
||||
options,
|
||||
result,
|
||||
} = params;
|
||||
|
||||
// Attempt 3: Use -X theirs strategy
|
||||
@@ -540,8 +789,8 @@ async function executeMergeAttempt(params: MergeAttemptParams): Promise<boolean>
|
||||
// Attempt 1 & 2: Standard squash merge
|
||||
let hasConflicts = false;
|
||||
try {
|
||||
// For attempt 2, try with auto-resolution first
|
||||
if (attemptNum === 2 && autoResolveConflicts) {
|
||||
// For attempt 2, try with smart auto-resolution first
|
||||
if (attemptNum === 2 && smartConflictResolution) {
|
||||
// First, do a standard merge to get conflicts
|
||||
// Note: git merge --squash exits with code 1 when conflicts exist
|
||||
// This is expected - we catch it and proceed with auto-resolution
|
||||
@@ -556,20 +805,46 @@ async function executeMergeAttempt(params: MergeAttemptParams): Promise<boolean>
|
||||
mergeExitedWithConflicts = true;
|
||||
}
|
||||
|
||||
// Check if we have conflicts (either from merge throwing or detected conflicts)
|
||||
const conflictCategories = detectResolvableConflicts(rootDir);
|
||||
if (conflictCategories.length > 0 || mergeExitedWithConflicts) {
|
||||
const autoResolvable = conflictCategories.filter((c) => c.autoResolvable);
|
||||
const complex = conflictCategories.filter((c) => !c.autoResolvable);
|
||||
// Use new API: get conflicted files and classify them
|
||||
const conflictedFiles = getConflictedFiles(rootDir);
|
||||
if (conflictedFiles.length > 0 || mergeExitedWithConflicts) {
|
||||
// Classify each conflicted file
|
||||
const classified = conflictedFiles.map((file) => ({
|
||||
file,
|
||||
type: classifyConflict(file, rootDir),
|
||||
}));
|
||||
|
||||
const autoResolvable = classified.filter(
|
||||
(c) => c.type !== "complex",
|
||||
);
|
||||
const complex = classified.filter(
|
||||
(c) => c.type === "complex",
|
||||
);
|
||||
|
||||
// Auto-resolve each file based on its classification
|
||||
if (autoResolvable.length > 0) {
|
||||
mergerLog.log(
|
||||
`${taskId}: auto-resolving ${autoResolvable.length} lock/generated file(s) before AI retry`,
|
||||
`${taskId}: auto-resolving ${autoResolvable.length} lock/generated/trivial file(s) before AI retry`,
|
||||
);
|
||||
resolveConflicts(conflictCategories, rootDir);
|
||||
for (const { file, type } of autoResolvable) {
|
||||
try {
|
||||
if (type === "lockfile-ours") {
|
||||
resolveWithOurs(file, rootDir);
|
||||
} else if (type === "generated-theirs") {
|
||||
resolveWithTheirs(file, rootDir);
|
||||
} else if (type === "trivial-whitespace") {
|
||||
resolveTrivialWhitespace(file, rootDir);
|
||||
}
|
||||
result.autoResolvedCount = (result.autoResolvedCount || 0) + 1;
|
||||
} catch (error) {
|
||||
// If auto-resolution fails, treat as complex conflict
|
||||
mergerLog.warn(`${taskId}: auto-resolution failed for ${file}: ${error}`);
|
||||
complex.push({ file, type: "complex" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If only auto-resolvable conflicts, commit them directly
|
||||
// If only auto-resolvable conflicts (or all were resolved), commit directly
|
||||
if (complex.length === 0) {
|
||||
// All conflicts auto-resolved, commit with fallback message
|
||||
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
|
||||
@@ -584,12 +859,12 @@ async function executeMergeAttempt(params: MergeAttemptParams): Promise<boolean>
|
||||
`git commit -m "${fallbackPrefix}: merge ${branch}" -m "${escapedLog}"`,
|
||||
{ cwd: rootDir, stdio: "pipe" },
|
||||
);
|
||||
mergerLog.log(`${taskId}: committed after auto-resolving conflicts`);
|
||||
mergerLog.log(`${taskId}: committed after auto-resolving all conflicts`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Has complex conflicts - continue to AI agent with simplified context
|
||||
// Has complex conflicts - continue to AI agent
|
||||
hasConflicts = true;
|
||||
} else {
|
||||
// No conflicts - check if squash is empty
|
||||
@@ -629,10 +904,10 @@ async function executeMergeAttempt(params: MergeAttemptParams): Promise<boolean>
|
||||
}).trim();
|
||||
hasConflicts = conflictedOutput.length > 0;
|
||||
|
||||
if (hasConflicts && !autoResolveConflicts) {
|
||||
if (hasConflicts && !smartConflictResolution) {
|
||||
// No auto-resolve - AI will handle all conflicts
|
||||
mergerLog.log(`${taskId}: conflicts detected, AI will resolve`);
|
||||
} else if (hasConflicts && autoResolveConflicts) {
|
||||
} else if (hasConflicts && smartConflictResolution) {
|
||||
// Has conflicts and auto-resolve enabled - should be handled in attempt 2
|
||||
// Reset and return false to trigger attempt 2
|
||||
mergerLog.log(`${taskId}: conflicts detected, will retry with auto-resolution`);
|
||||
@@ -644,6 +919,7 @@ async function executeMergeAttempt(params: MergeAttemptParams): Promise<boolean>
|
||||
// - No conflicts (attempt 1) - AI writes commit message
|
||||
// - Complex conflicts remain after attempt 2 auto-resolution - AI resolves them
|
||||
// Spawn AI agent
|
||||
aiTracker.aiWasInvoked = true; // Track that AI was invoked
|
||||
return await runAiAgentForCommit({
|
||||
store,
|
||||
rootDir,
|
||||
@@ -663,7 +939,7 @@ async function executeMergeAttempt(params: MergeAttemptParams): Promise<boolean>
|
||||
}
|
||||
|
||||
// For attempt 1, return false to trigger attempt 2
|
||||
if (attemptNum === 1 && autoResolveConflicts) {
|
||||
if (attemptNum === 1 && smartConflictResolution) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user