feat(FN-5039): complete Step 3 — add branch attribution helper

Fusion-Task-Id: FN-5039
Fusion-Task-Lineage: 324b7259-e08f-4051-b89c-b94836b73d5d
This commit is contained in:
Fusion (runfusion.ai)
2026-05-18 06:33:06 -07:00
committed by gsxdsm
parent b92266a38a
commit 6e1ce2eb04
2 changed files with 224 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from "vitest";
import { BranchAttributionError, filterFilesToOwnTaskCommits } from "../branch-attribution";
describe("FN-5039 branch-attribution", () => {
it("returns empty attribution for empty range", async () => {
const execMock = vi
.fn()
.mockResolvedValueOnce({ stdout: "" })
.mockResolvedValueOnce({ stdout: "" });
const result = await filterFilesToOwnTaskCommits({
worktreePath: "/tmp/wt",
baseRef: "base",
taskId: "FN-5039",
execAsyncImpl: execMock as never,
});
expect(result).toEqual({
files: [],
foreignCommits: [],
ownCommitCount: 0,
rawDiffFileCount: 0,
});
});
it("collects files from own-attributed commits only", async () => {
const log = [
"sha-own-1\x00own one\x00body\nFusion-Task-Id: FN-5039\n\x1e",
"sha-own-2\x00own two\x00body\nFusion-Task-Id: FN-5039\n\x1e",
].join("");
const execMock = vi
.fn()
.mockResolvedValueOnce({ stdout: "a.ts\nb.ts\n" })
.mockResolvedValueOnce({ stdout: log })
.mockResolvedValueOnce({ stdout: "a.ts\n" })
.mockResolvedValueOnce({ stdout: "b.ts\na.ts\n" });
const result = await filterFilesToOwnTaskCommits({
worktreePath: "/tmp/wt",
baseRef: "base",
taskId: "FN-5039",
execAsyncImpl: execMock as never,
});
expect(result.files).toEqual(["a.ts", "b.ts"]);
expect(result.ownCommitCount).toBe(2);
expect(result.foreignCommits).toEqual([]);
expect(result.rawDiffFileCount).toBe(2);
});
it("treats foreign and untrailered commits as foreign", async () => {
const log = [
"sha-own\x00own\x00notes\nFusion-Task-Id: FN-5039\n\x1e",
"sha-foreign\x00foreign\x00notes\nFusion-Task-Id: FN-1111\n\x1e",
"sha-none\x00none\x00notes without trailer\x1e",
].join("");
const execMock = vi
.fn()
.mockResolvedValueOnce({ stdout: "task.ts\nforeign.ts\nunt.ts\n" })
.mockResolvedValueOnce({ stdout: log })
.mockResolvedValueOnce({ stdout: "task.ts\n" });
const result = await filterFilesToOwnTaskCommits({
worktreePath: "/tmp/wt",
baseRef: "base",
taskId: "FN-5039",
execAsyncImpl: execMock as never,
});
expect(result.files).toEqual(["task.ts"]);
expect(result.foreignCommits).toEqual([
{ sha: "sha-foreign", subject: "foreign", attributedTaskId: "FN-1111" },
{ sha: "sha-none", subject: "none", attributedTaskId: null },
]);
});
it("throws BranchAttributionError on malformed git log output", async () => {
const execMock = vi
.fn()
.mockResolvedValueOnce({ stdout: "x.ts\n" })
.mockResolvedValueOnce({ stdout: "\x00missing sha\x00body\x1e" });
await expect(
filterFilesToOwnTaskCommits({
worktreePath: "/tmp/wt",
baseRef: "base",
taskId: "FN-5039",
execAsyncImpl: execMock as never,
}),
).rejects.toBeInstanceOf(BranchAttributionError);
});
it("throws BranchAttributionError when git command fails", async () => {
const execMock = vi.fn().mockRejectedValueOnce(new Error("fatal: bad revision"));
await expect(
filterFilesToOwnTaskCommits({
worktreePath: "/tmp/wt",
baseRef: "bad",
taskId: "FN-5039",
execAsyncImpl: execMock as never,
}),
).rejects.toBeInstanceOf(BranchAttributionError);
});
});

View File

@@ -0,0 +1,117 @@
import { exec } from "node:child_process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
const GIT_TIMEOUT_MS = 30_000;
const GIT_MAX_BUFFER = 10 * 1024 * 1024;
export interface AttributionResult {
files: string[];
foreignCommits: { sha: string; subject: string; attributedTaskId: string | null }[];
ownCommitCount: number;
rawDiffFileCount: number;
}
export class BranchAttributionError extends Error {
readonly cause?: unknown;
constructor(message: string, cause?: unknown) {
super(message);
this.name = "BranchAttributionError";
this.cause = cause;
}
}
export interface BranchAttributionOptions {
worktreePath: string;
baseRef: string;
taskId: string;
execAsyncImpl?: typeof execAsync;
}
function quoteShellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function extractAttributedTaskId(body: string): string | null {
const trailerPattern = /(?:^|\n)Fusion-Task-Id:\s*(\S+)\s*(?:\n|$)/gim;
let match: RegExpExecArray | null = null;
let last: RegExpExecArray | null = null;
while (true) {
match = trailerPattern.exec(body);
if (!match) break;
last = match;
}
return last?.[1] ?? null;
}
export async function filterFilesToOwnTaskCommits(opts: BranchAttributionOptions): Promise<AttributionResult> {
const execImpl = opts.execAsyncImpl ?? execAsync;
const runGit = async (command: string): Promise<string> => {
try {
const { stdout } = await execImpl(command, {
cwd: opts.worktreePath,
encoding: "utf-8",
timeout: GIT_TIMEOUT_MS,
maxBuffer: GIT_MAX_BUFFER,
});
return stdout;
} catch (error) {
const stderr =
typeof error === "object" && error && "stderr" in error && typeof error.stderr === "string"
? error.stderr.trim()
: String(error);
throw new BranchAttributionError(`git command failed: ${command} (${stderr || "no stderr"})`, error);
}
};
const rawDiffOutput = await runGit(`git diff --name-only ${quoteShellArg(opts.baseRef)}..HEAD`);
const rawDiffFileCount = rawDiffOutput
.split("\n")
.map((line) => line.trim())
.filter(Boolean).length;
const logOutput = await runGit(
`git log --format=%H%x00%s%x00%B%x1e ${quoteShellArg(`${opts.baseRef}..HEAD`)}`,
);
if (!logOutput.trim()) {
return { files: [], foreignCommits: [], ownCommitCount: 0, rawDiffFileCount };
}
const fileSet = new Set<string>();
const foreignCommits: { sha: string; subject: string; attributedTaskId: string | null }[] = [];
const ownCommitShas: string[] = [];
const records = logOutput.split("\x1e").map((record) => record.trim()).filter(Boolean);
for (const record of records) {
const [sha = "", subject = "", ...bodyParts] = record.split("\x00");
if (!sha) {
throw new BranchAttributionError("malformed git log output: missing commit sha");
}
const body = bodyParts.join("\x00");
const attributedTaskId = extractAttributedTaskId(body);
if (attributedTaskId === opts.taskId) {
ownCommitShas.push(sha);
continue;
}
foreignCommits.push({ sha, subject, attributedTaskId });
}
for (const sha of ownCommitShas) {
const diffTreeOutput = await runGit(`git diff-tree --no-commit-id --name-only -r ${quoteShellArg(sha)}`);
for (const file of diffTreeOutput
.split("\n")
.map((line) => line.trim())
.filter(Boolean)) {
fileSet.add(file);
}
}
return {
files: [...fileSet].sort((a, b) => a.localeCompare(b)),
foreignCommits,
ownCommitCount: ownCommitShas.length,
rawDiffFileCount,
};
}