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:
committed by
gsxdsm
parent
b92266a38a
commit
6e1ce2eb04
107
packages/engine/src/__tests__/branch-attribution.test.ts
Normal file
107
packages/engine/src/__tests__/branch-attribution.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
117
packages/engine/src/branch-attribution.ts
Normal file
117
packages/engine/src/branch-attribution.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user