feat(FN-4559): complete Step 1 — shared detector and finalize classification
Fusion-Task-Id: FN-4559 Fusion-Task-Lineage: 9276f683-657c-4604-aaef-143da488b287
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { commitOrAmendMergeWithFixes } from "../merger.js";
|
||||
import { DEFAULT_SETTINGS } from "@fusion/core";
|
||||
|
||||
function git(dir: string, cmd: string): string {
|
||||
return execSync(cmd, { cwd: dir, stdio: "pipe" }).toString().trim();
|
||||
}
|
||||
|
||||
const created = new Set<string>();
|
||||
afterEach(() => {
|
||||
for (const dir of created) rmSync(dir, { recursive: true, force: true });
|
||||
created.clear();
|
||||
});
|
||||
|
||||
function mkRepo(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "fusion-test-merge-already-on-main-"));
|
||||
created.add(dir);
|
||||
git(dir, "git init -b main");
|
||||
git(dir, 'git config user.email "test@example.com"');
|
||||
git(dir, 'git config user.name "Test"');
|
||||
writeFileSync(join(dir, "README.md"), "seed\n");
|
||||
git(dir, "git add README.md");
|
||||
git(dir, 'git commit -m "chore: init"');
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("commitOrAmendMergeWithFixes already-on-main recovery", () => {
|
||||
it("returns branch-already-merged-on-main when task trailer exists on main but branch tip is misbound", async () => {
|
||||
const dir = mkRepo();
|
||||
|
||||
writeFileSync(join(dir, "README.md"), "other\n");
|
||||
git(dir, "git add README.md");
|
||||
git(dir, 'git commit -m "feat(FN-4545): unrelated"');
|
||||
const unrelatedSha = git(dir, "git rev-parse HEAD");
|
||||
|
||||
writeFileSync(join(dir, "task-file.txt"), "task content\n");
|
||||
git(dir, "git add task-file.txt");
|
||||
git(
|
||||
dir,
|
||||
'git commit -m "feat(FN-4553): landed task" -m "Fusion-Task-Id: FN-4553" -m "Fusion-Task-Lineage: lineage-4553"',
|
||||
);
|
||||
const landedSha = git(dir, "git rev-parse HEAD");
|
||||
|
||||
writeFileSync(join(dir, "post.txt"), "post\n");
|
||||
git(dir, "git add post.txt");
|
||||
git(dir, 'git commit -m "chore: post-landing commit"');
|
||||
const preAttemptHeadSha = git(dir, "git rev-parse HEAD");
|
||||
|
||||
git(dir, `git branch fusion/fn-4553 ${unrelatedSha}`);
|
||||
|
||||
const result = await commitOrAmendMergeWithFixes(
|
||||
dir,
|
||||
"FN-4553",
|
||||
"fusion/fn-4553",
|
||||
"feat(FN-4553): finalize",
|
||||
true,
|
||||
preAttemptHeadSha,
|
||||
"",
|
||||
undefined,
|
||||
{ ...DEFAULT_SETTINGS, commitAuthorEnabled: false },
|
||||
undefined,
|
||||
null,
|
||||
null,
|
||||
new Set(),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
reason: "branch-already-merged-on-main",
|
||||
mergeSha: landedSha,
|
||||
strategy: "trailer",
|
||||
});
|
||||
expect(git(dir, "git rev-parse HEAD")).toBe(preAttemptHeadSha);
|
||||
});
|
||||
});
|
||||
234
packages/engine/src/already-merged-detector.ts
Normal file
234
packages/engine/src/already-merged-detector.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { exec, execSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export type AlreadyMergedDetectionStrategy = "trailer" | "ancestry" | "patch-id" | "tree-equal";
|
||||
|
||||
export interface AlreadyMergedLookupInput {
|
||||
taskId: string;
|
||||
lineageId?: string;
|
||||
repoDir: string;
|
||||
baseBranch: string;
|
||||
taskBranch?: string;
|
||||
baseCommitSha?: string;
|
||||
}
|
||||
|
||||
export interface AlreadyMergedLookupResult {
|
||||
sha: string;
|
||||
strategy: AlreadyMergedDetectionStrategy;
|
||||
}
|
||||
|
||||
interface DetectAlreadyLandedInput {
|
||||
rootDir: string;
|
||||
taskId: string;
|
||||
lineageId?: string;
|
||||
baseBranch: string;
|
||||
taskBranch?: string;
|
||||
baseCommitSha?: string;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
export async function findAlreadyMergedTaskCommit(
|
||||
input: AlreadyMergedLookupInput,
|
||||
): Promise<AlreadyMergedLookupResult | null> {
|
||||
const { taskId, lineageId, repoDir, baseBranch, taskBranch, baseCommitSha } = input;
|
||||
|
||||
try {
|
||||
if (lineageId) {
|
||||
const lineagePattern = `^Fusion-Task-Lineage: ${lineageId}$`;
|
||||
const lineageCommand = [
|
||||
"git log",
|
||||
`--grep=${shellQuote(lineagePattern)}`,
|
||||
"-E",
|
||||
"--max-count=1",
|
||||
"--format=%H",
|
||||
shellQuote(baseBranch),
|
||||
].join(" ");
|
||||
const lineage = await execAsync(lineageCommand, {
|
||||
cwd: repoDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const lineageSha = lineage.stdout.trim();
|
||||
if (lineageSha) {
|
||||
return { sha: lineageSha, strategy: "trailer" };
|
||||
}
|
||||
}
|
||||
|
||||
const trailerPattern = `^Fusion-Task-Id: ${taskId}$`;
|
||||
const trailerCommand = [
|
||||
"git log",
|
||||
`--grep=${shellQuote(trailerPattern)}`,
|
||||
"-E",
|
||||
"--max-count=1",
|
||||
"--format=%H",
|
||||
shellQuote(baseBranch),
|
||||
].join(" ");
|
||||
const { stdout } = await execAsync(trailerCommand, {
|
||||
cwd: repoDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const sha = stdout.trim();
|
||||
if (sha) {
|
||||
return { sha, strategy: "trailer" };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to ancestry/patch-id checks.
|
||||
}
|
||||
|
||||
let branchTip: string | null = null;
|
||||
const branchName = taskBranch || `fusion/${taskId.toLowerCase()}`;
|
||||
try {
|
||||
branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, {
|
||||
cwd: repoDir,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
|
||||
execSync(`git merge-base --is-ancestor ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`, {
|
||||
cwd: repoDir,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const ancestryCommand = [
|
||||
"git log",
|
||||
"--first-parent",
|
||||
"--format=%H",
|
||||
`--grep=${shellQuote(taskId)}`,
|
||||
"--max-count=1",
|
||||
shellQuote(baseBranch),
|
||||
].join(" ");
|
||||
const { stdout } = await execAsync(ancestryCommand, {
|
||||
cwd: repoDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const sha = stdout.trim();
|
||||
if (sha) {
|
||||
return { sha, strategy: "ancestry" };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to patch-id checks.
|
||||
}
|
||||
|
||||
try {
|
||||
if (!branchTip) {
|
||||
branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, {
|
||||
cwd: repoDir,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
let branchBase = baseCommitSha?.trim();
|
||||
if (!branchBase) {
|
||||
const { stdout: mergeBaseStdout } = await execAsync(
|
||||
`git merge-base ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`,
|
||||
{
|
||||
cwd: repoDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
);
|
||||
branchBase = mergeBaseStdout.trim();
|
||||
}
|
||||
|
||||
if (!branchBase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const branchPatchIdCommand = `git diff ${shellQuote(branchBase)}..${shellQuote(branchTip)} | git patch-id`;
|
||||
const { stdout: branchPatchIdOut } = await execAsync(branchPatchIdCommand, {
|
||||
cwd: repoDir,
|
||||
shell: "/bin/sh",
|
||||
timeout: 60_000,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
const branchPatchIdLine = branchPatchIdOut
|
||||
.trim()
|
||||
.split("\n")
|
||||
.find((line) => line.trim().length > 0);
|
||||
const branchPatchId = branchPatchIdLine?.trim().split(/\s+/)[0];
|
||||
if (!branchPatchId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePatchMapCommand = `git log -n 200 -p --format='%H' ${shellQuote(baseBranch)} | git patch-id`;
|
||||
const { stdout: basePatchIdsOut } = await execAsync(basePatchMapCommand, {
|
||||
cwd: repoDir,
|
||||
shell: "/bin/sh",
|
||||
timeout: 60_000,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const basePatchMap = new Map<string, string>();
|
||||
for (const line of basePatchIdsOut.split("\n")) {
|
||||
const [patchId, sha] = line.trim().split(/\s+/);
|
||||
if (!patchId || !sha) continue;
|
||||
basePatchMap.set(patchId, sha);
|
||||
}
|
||||
|
||||
const matchedSha = basePatchMap.get(branchPatchId);
|
||||
if (matchedSha) {
|
||||
return { sha: matchedSha, strategy: "patch-id" };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to null when patch-id detection fails.
|
||||
}
|
||||
|
||||
try {
|
||||
const treeBranchName = taskBranch || `fusion/${taskId.toLowerCase()}`;
|
||||
execSync(`git rev-parse --verify ${shellQuote(treeBranchName)}`, {
|
||||
cwd: repoDir,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
|
||||
const { stdout: baseTreeStdout } = await execAsync(`git rev-parse ${shellQuote(baseBranch)}^{tree}`, {
|
||||
cwd: repoDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const { stdout: branchTreeStdout } = await execAsync(`git rev-parse ${shellQuote(treeBranchName)}^{tree}`, {
|
||||
cwd: repoDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
const baseTree = baseTreeStdout.trim();
|
||||
const branchTree = branchTreeStdout.trim();
|
||||
if (baseTree && branchTree && baseTree === branchTree) {
|
||||
const { stdout: baseHeadStdout } = await execAsync(`git rev-parse ${shellQuote(baseBranch)}`, {
|
||||
cwd: repoDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const baseHead = baseHeadStdout.trim();
|
||||
if (baseHead) {
|
||||
return { sha: baseHead, strategy: "tree-equal" };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to null when tree-equality detection fails.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function detectAlreadyLandedOnMain(
|
||||
input: DetectAlreadyLandedInput,
|
||||
): Promise<AlreadyMergedLookupResult | null> {
|
||||
return findAlreadyMergedTaskCommit({
|
||||
taskId: input.taskId,
|
||||
lineageId: input.lineageId,
|
||||
repoDir: input.rootDir,
|
||||
baseBranch: input.baseBranch,
|
||||
taskBranch: input.taskBranch,
|
||||
baseCommitSha: input.baseCommitSha,
|
||||
});
|
||||
}
|
||||
@@ -79,6 +79,7 @@ import { auditSquashMerge, MERGER_MAIN_OVERLAP_LOOKBACK_COMMITS, type PostMergeA
|
||||
import { detectMergeOverlap, restoreBranchWinsFiles } from "./merger-overlap-guard.js";
|
||||
import { checkDiffVolume, DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
|
||||
import { ReadonlyViolationError, filterCustomToolsForReadonly } from "./workflow-step-tool-policy.js";
|
||||
import { detectAlreadyLandedOnMain, type AlreadyMergedDetectionStrategy } from "./already-merged-detector.js";
|
||||
|
||||
export { DiffVolumeRegressionError } from "./merger-diff-volume-gate.js";
|
||||
|
||||
@@ -3038,7 +3039,12 @@ async function buildDeterministicMergeMessage(params: {
|
||||
* @internal Exported for integration tests only — not part of the public API.
|
||||
*/
|
||||
type MergeFinalizeResult =
|
||||
| { ok: true; reason: "completed" | "head-task-trailer" | "branch-already-merged" }
|
||||
| {
|
||||
ok: true;
|
||||
reason: "committed" | "head-task-trailer" | "branch-already-merged" | "branch-already-merged-on-main";
|
||||
mergeSha?: string;
|
||||
strategy?: AlreadyMergedDetectionStrategy;
|
||||
}
|
||||
| { ok: false; reason: "fix-produced-no-content" | "unknown-phantom" };
|
||||
|
||||
async function persistFinalizeResetLeftovers(rootDir: string, taskId: string, store?: TaskStore): Promise<void> {
|
||||
@@ -3467,6 +3473,40 @@ export async function commitOrAmendMergeWithFixes(
|
||||
mergerLog.log(`${taskId}: squash-restore reported already up to date; treating as branch-already-merged`);
|
||||
return { ok: true, reason: "branch-already-merged" };
|
||||
}
|
||||
|
||||
if (currentHead === preAttemptHeadSha) {
|
||||
let lineageId: string | undefined;
|
||||
if (store) {
|
||||
const existingTask = await store.getTask(taskId);
|
||||
lineageId = existingTask?.lineageId;
|
||||
}
|
||||
const landed = await detectAlreadyLandedOnMain({
|
||||
rootDir,
|
||||
taskId,
|
||||
lineageId,
|
||||
baseBranch: preAttemptHeadSha,
|
||||
taskBranch: branch,
|
||||
baseCommitSha: preAttemptHeadSha,
|
||||
});
|
||||
if (landed) {
|
||||
mergerLog.log(
|
||||
`${taskId}: recovered finalize no-content as already-landed branch=${branch} tip=${branchTip.slice(0, 8)} integrationTarget=${preAttemptHeadSha.slice(0, 8)} via=${landed.strategy}`,
|
||||
);
|
||||
await auditor?.database({
|
||||
type: "task:auto-recover-finalize-already-on-main",
|
||||
taskId,
|
||||
metadata: {
|
||||
mergeSha: landed.sha,
|
||||
mergeStrategy: landed.strategy,
|
||||
baseBranch: preAttemptHeadSha,
|
||||
branch,
|
||||
branchTip,
|
||||
},
|
||||
});
|
||||
return { ok: true, reason: "branch-already-merged-on-main", mergeSha: landed.sha, strategy: landed.strategy };
|
||||
}
|
||||
}
|
||||
|
||||
mergerLog.warn(
|
||||
`${taskId}: refusing to record merge — no commit was created and no changes are staged after squash-restore.`,
|
||||
);
|
||||
@@ -3549,7 +3589,7 @@ export async function commitOrAmendMergeWithFixes(
|
||||
});
|
||||
}
|
||||
mergerLog.log(`${taskId}: created fresh merge commit after verification fix (no prior commit to amend)`);
|
||||
return { ok: true, reason: "completed" };
|
||||
return { ok: true, reason: "committed" };
|
||||
}
|
||||
|
||||
// HEAD moved — AI agent committed already. Amend with deterministic
|
||||
@@ -3592,7 +3632,7 @@ export async function commitOrAmendMergeWithFixes(
|
||||
});
|
||||
}
|
||||
mergerLog.log(`${taskId}: amended merge commit with verification fixes (deterministic message)`);
|
||||
return { ok: true, reason: "completed" };
|
||||
return { ok: true, reason: "committed" };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DiffVolumeRegressionError || err instanceof FileScopeViolationError) {
|
||||
throw err;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { classifyError, extractMissingModulePath, isOperatorActionableAgentError
|
||||
import { deriveTaskIdFromFusionBranch, inspectBranchConflict, listUniqueBranchCommits } from "./branch-conflicts.js";
|
||||
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
|
||||
import { AutoRecoveryDispatcher } from "./auto-recovery.js";
|
||||
import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js";
|
||||
|
||||
const log = createLogger("self-healing");
|
||||
const execAsync = promisify(exec);
|
||||
@@ -243,22 +244,6 @@ interface LandedTaskCommit {
|
||||
rebaseBaseSha?: string;
|
||||
}
|
||||
|
||||
type AlreadyMergedDetectionStrategy = "trailer" | "ancestry" | "patch-id" | "tree-equal";
|
||||
|
||||
interface AlreadyMergedLookupInput {
|
||||
taskId: string;
|
||||
lineageId?: string;
|
||||
repoDir: string;
|
||||
baseBranch: string;
|
||||
taskBranch?: string;
|
||||
baseCommitSha?: string;
|
||||
}
|
||||
|
||||
interface AlreadyMergedLookupResult {
|
||||
sha: string;
|
||||
strategy: AlreadyMergedDetectionStrategy;
|
||||
}
|
||||
|
||||
function commitOwnedByTask(taskId: string, lineageId: string | undefined, subject: string, body: string): boolean {
|
||||
if (lineageId && body.includes(`Fusion-Task-Lineage: ${lineageId}`)) {
|
||||
return true;
|
||||
@@ -845,203 +830,6 @@ export class SelfHealingManager {
|
||||
return commit;
|
||||
}
|
||||
|
||||
private async findAlreadyMergedTaskCommit(
|
||||
input: AlreadyMergedLookupInput,
|
||||
): Promise<AlreadyMergedLookupResult | null> {
|
||||
const { taskId, lineageId, repoDir, baseBranch, taskBranch, baseCommitSha } = input;
|
||||
|
||||
try {
|
||||
if (lineageId) {
|
||||
const lineagePattern = `^Fusion-Task-Lineage: ${lineageId}$`;
|
||||
const lineageCommand = [
|
||||
"git log",
|
||||
`--grep=${shellQuote(lineagePattern)}`,
|
||||
"-E",
|
||||
"--max-count=1",
|
||||
"--format=%H",
|
||||
shellQuote(baseBranch),
|
||||
].join(" ");
|
||||
const lineage = await execAsync(lineageCommand, {
|
||||
cwd: repoDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const lineageSha = lineage.stdout.trim();
|
||||
if (lineageSha) {
|
||||
return { sha: lineageSha, strategy: "trailer" };
|
||||
}
|
||||
}
|
||||
|
||||
const trailerPattern = `^Fusion-Task-Id: ${taskId}$`;
|
||||
const trailerCommand = [
|
||||
"git log",
|
||||
`--grep=${shellQuote(trailerPattern)}`,
|
||||
"-E",
|
||||
"--max-count=1",
|
||||
"--format=%H",
|
||||
shellQuote(baseBranch),
|
||||
].join(" ");
|
||||
const { stdout } = await execAsync(trailerCommand, {
|
||||
cwd: repoDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const sha = stdout.trim();
|
||||
if (sha) {
|
||||
return { sha, strategy: "trailer" };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to ancestry/patch-id checks.
|
||||
}
|
||||
|
||||
let branchTip: string | null = null;
|
||||
const branchName = taskBranch || `fusion/${taskId.toLowerCase()}`;
|
||||
try {
|
||||
branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, {
|
||||
cwd: repoDir,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
|
||||
execSync(`git merge-base --is-ancestor ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`, {
|
||||
cwd: repoDir,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const ancestryCommand = [
|
||||
"git log",
|
||||
"--first-parent",
|
||||
"--format=%H",
|
||||
`--grep=${shellQuote(taskId)}`,
|
||||
"--max-count=1",
|
||||
shellQuote(baseBranch),
|
||||
].join(" ");
|
||||
const { stdout } = await execAsync(ancestryCommand, {
|
||||
cwd: repoDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const sha = stdout.trim();
|
||||
if (sha) {
|
||||
return { sha, strategy: "ancestry" };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to patch-id checks.
|
||||
}
|
||||
|
||||
try {
|
||||
if (!branchTip) {
|
||||
branchTip = execSync(`git rev-parse --verify ${shellQuote(branchName)}`, {
|
||||
cwd: repoDir,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
let branchBase = baseCommitSha?.trim();
|
||||
if (!branchBase) {
|
||||
const { stdout: mergeBaseStdout } = await execAsync(
|
||||
`git merge-base ${shellQuote(branchTip)} ${shellQuote(baseBranch)}`,
|
||||
{
|
||||
cwd: repoDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
);
|
||||
branchBase = mergeBaseStdout.trim();
|
||||
}
|
||||
|
||||
if (!branchBase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const branchPatchIdCommand = `git diff ${shellQuote(branchBase)}..${shellQuote(branchTip)} | git patch-id`;
|
||||
const { stdout: branchPatchIdOut } = await execAsync(branchPatchIdCommand, {
|
||||
cwd: repoDir,
|
||||
shell: "/bin/sh",
|
||||
timeout: 60_000,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
const branchPatchIdLine = branchPatchIdOut
|
||||
.trim()
|
||||
.split("\n")
|
||||
.find((line) => line.trim().length > 0);
|
||||
const branchPatchId = branchPatchIdLine?.trim().split(/\s+/)[0];
|
||||
if (!branchPatchId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const basePatchMapCommand = `git log -n 200 -p --format='%H' ${shellQuote(baseBranch)} | git patch-id`;
|
||||
const { stdout: basePatchIdsOut } = await execAsync(basePatchMapCommand, {
|
||||
cwd: repoDir,
|
||||
shell: "/bin/sh",
|
||||
timeout: 60_000,
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const basePatchMap = new Map<string, string>();
|
||||
for (const line of basePatchIdsOut.split("\n")) {
|
||||
const [patchId, sha] = line.trim().split(/\s+/);
|
||||
if (!patchId || !sha) continue;
|
||||
basePatchMap.set(patchId, sha);
|
||||
}
|
||||
|
||||
const matchedSha = basePatchMap.get(branchPatchId);
|
||||
if (matchedSha) {
|
||||
return { sha: matchedSha, strategy: "patch-id" };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to null when patch-id detection fails.
|
||||
}
|
||||
|
||||
// Last-resort fallback: if branch and base resolve to identical trees, content is already landed
|
||||
// but attribution is weak (we cannot identify the exact landing commit), so prefer stronger
|
||||
// trailer/ancestry/patch-id matches first and use this only at the end.
|
||||
try {
|
||||
const treeBranchName = taskBranch || `fusion/${taskId.toLowerCase()}`;
|
||||
execSync(`git rev-parse --verify ${shellQuote(treeBranchName)}`, {
|
||||
cwd: repoDir,
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim();
|
||||
|
||||
const { stdout: baseTreeStdout } = await execAsync(
|
||||
`git rev-parse ${shellQuote(baseBranch)}^{tree}`,
|
||||
{
|
||||
cwd: repoDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const { stdout: branchTreeStdout } = await execAsync(
|
||||
`git rev-parse ${shellQuote(treeBranchName)}^{tree}`,
|
||||
{
|
||||
cwd: repoDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
);
|
||||
|
||||
const baseTree = baseTreeStdout.trim();
|
||||
const branchTree = branchTreeStdout.trim();
|
||||
if (baseTree && branchTree && baseTree === branchTree) {
|
||||
const { stdout: baseHeadStdout } = await execAsync(`git rev-parse ${shellQuote(baseBranch)}`, {
|
||||
cwd: repoDir,
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
const baseHead = baseHeadStdout.trim();
|
||||
if (baseHead) {
|
||||
return { sha: baseHead, strategy: "tree-equal" };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to null when tree-equality detection fails.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async cleanupWorktreeOnly(task: Task): Promise<void> {
|
||||
if (task.worktree && existsSync(task.worktree)) {
|
||||
try {
|
||||
@@ -3275,7 +3063,7 @@ export class SelfHealingManager {
|
||||
if (hasDeclaredOverlap) continue;
|
||||
|
||||
const baseBranch = task.baseBranch || task.executionStartBranch || "main";
|
||||
const landed = await this.findAlreadyMergedTaskCommit({
|
||||
const landed = await findAlreadyMergedTaskCommit({
|
||||
taskId: task.id,
|
||||
lineageId: task.lineageId,
|
||||
repoDir: this.options.rootDir,
|
||||
@@ -3385,7 +3173,7 @@ export class SelfHealingManager {
|
||||
const baseBranch = task.baseBranch || task.executionStartBranch || "main";
|
||||
if (!baseBranch) continue;
|
||||
|
||||
const landed = await this.findAlreadyMergedTaskCommit({
|
||||
const landed = await findAlreadyMergedTaskCommit({
|
||||
taskId: task.id,
|
||||
lineageId: task.lineageId,
|
||||
repoDir: this.options.rootDir,
|
||||
|
||||
Reference in New Issue
Block a user