feat(FN-3865): complete Step 2 — add already-merged detection helpers
This commit is contained in:
@@ -13,7 +13,7 @@
|
|||||||
* by cleaning oldest idle worktrees when count exceeds 2× maxWorktrees.
|
* by cleaning oldest idle worktrees when count exceeds 2× maxWorktrees.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { exec } from "node:child_process";
|
import { exec, execSync } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
|
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||||
@@ -131,6 +131,21 @@ interface LandedTaskCommit {
|
|||||||
deletions?: number;
|
deletions?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AlreadyMergedDetectionStrategy = "trailer" | "ancestry" | "patch-id";
|
||||||
|
|
||||||
|
interface AlreadyMergedLookupInput {
|
||||||
|
taskId: string;
|
||||||
|
repoDir: string;
|
||||||
|
baseBranch: string;
|
||||||
|
taskBranch?: string;
|
||||||
|
baseCommitSha?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlreadyMergedLookupResult {
|
||||||
|
sha: string;
|
||||||
|
strategy: AlreadyMergedDetectionStrategy;
|
||||||
|
}
|
||||||
|
|
||||||
function commitOwnedByTask(taskId: string, subject: string, body: string): boolean {
|
function commitOwnedByTask(taskId: string, subject: string, body: string): boolean {
|
||||||
return body.includes(`Fusion-Task-Id: ${taskId}`) || subject.includes(taskId);
|
return body.includes(`Fusion-Task-Id: ${taskId}`) || subject.includes(taskId);
|
||||||
}
|
}
|
||||||
@@ -594,6 +609,153 @@ export class SelfHealingManager {
|
|||||||
return commit;
|
return commit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async findAlreadyMergedTaskCommit(
|
||||||
|
input: AlreadyMergedLookupInput,
|
||||||
|
): Promise<AlreadyMergedLookupResult | null> {
|
||||||
|
const { taskId, repoDir, baseBranch, taskBranch, baseCommitSha } = input;
|
||||||
|
|
||||||
|
try {
|
||||||
|
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: true,
|
||||||
|
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: true,
|
||||||
|
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.
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupWorktreeOnly(task: Task): Promise<void> {
|
||||||
|
if (task.worktree && existsSync(task.worktree)) {
|
||||||
|
try {
|
||||||
|
await execAsync(`git worktree remove ${shellQuote(task.worktree)} --force`, {
|
||||||
|
cwd: this.options.rootDir,
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
log.warn(
|
||||||
|
`Failed to remove worktree ${task.worktree} for ${task.id}: ${errorMessage} — non-fatal, cleanup can retry later`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async cleanupInterruptedMergeArtifacts(task: Task): Promise<void> {
|
private async cleanupInterruptedMergeArtifacts(task: Task): Promise<void> {
|
||||||
if (task.worktree && existsSync(task.worktree)) {
|
if (task.worktree && existsSync(task.worktree)) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user