From d61d5cd4ea1cfca7f644b01bbc705e2b95ca6676 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:01:09 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20branch=20=EB=B9=84=EA=B5=90=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B4=EB=93=9C=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/branches/branchPairBuilder.ts | 2 +- src/branches/branchRoundBuilder.ts | 82 ++++++++++++++++ src/branches/types.ts | 5 + tests/branches/branchRoundBuilder.test.ts | 112 ++++++++++++++++++++++ 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 src/branches/branchRoundBuilder.ts create mode 100644 tests/branches/branchRoundBuilder.test.ts diff --git a/src/branches/branchPairBuilder.ts b/src/branches/branchPairBuilder.ts index 5e0514f..562f7c0 100644 --- a/src/branches/branchPairBuilder.ts +++ b/src/branches/branchPairBuilder.ts @@ -33,7 +33,7 @@ export function build( } // 실행 환경에 무관한 문자열 비교로 branch 이름 순서를 결정 -function compareBranchNames(branchName: string, otherBranchName: string): number { +export function compareBranchNames(branchName: string, otherBranchName: string): number { if (branchName < otherBranchName) { return -1 } diff --git a/src/branches/branchRoundBuilder.ts b/src/branches/branchRoundBuilder.ts new file mode 100644 index 0000000..111af26 --- /dev/null +++ b/src/branches/branchRoundBuilder.ts @@ -0,0 +1,82 @@ +import { compareBranchNames } from "./branchPairBuilder.js" +import type { + BranchComparisonPair, + BranchComparisonRound +} from "./types.js" + +// 중복 없는 branch 비교 조합을 같은 branch가 겹치지 않는 원형 라운드로 구성 +export function build(pairs: BranchComparisonPair[]): BranchComparisonRound[] { + const pairByKey = new Map(pairs.map(pair => { + const normalized = normalize(pair) + + return [keyFor(normalized), normalized] + })) + const branchNames = [...new Set( + [...pairByKey.values()].flatMap(pair => [ + pair.leftBranchName, + pair.rightBranchName + ]) + )].sort(compareBranchNames) + + if (branchNames.length < 2) { + return [] + } + + const positions: Array = [...branchNames] + + if (positions.length % 2 === 1) { + positions.push(undefined) + } + + const rounds: BranchComparisonRound[] = [] + + for (let roundIndex = 0; roundIndex < positions.length - 1; roundIndex += 1) { + const roundPairs: BranchComparisonPair[] = [] + + for (let index = 0; index < positions.length / 2; index += 1) { + const leftBranchName = positions[index] + const rightBranchName = positions[positions.length - index - 1] + + if (leftBranchName === undefined || rightBranchName === undefined) { + continue + } + + const pair = pairByKey.get(keyFor(normalize({ + leftBranchName, + rightBranchName + }))) + + if (pair) { + roundPairs.push(pair) + } + } + + if (roundPairs.length) { + rounds.push({ + roundIndex: rounds.length, + pairs: roundPairs + }) + } + + positions.splice(1, 0, positions.pop()) + } + + return rounds +} + +// 방향이 다른 같은 조합을 동일한 이름순 조합으로 정규화 +function normalize(pair: BranchComparisonPair): BranchComparisonPair { + if (compareBranchNames(pair.leftBranchName, pair.rightBranchName) <= 0) { + return pair + } + + return { + leftBranchName: pair.rightBranchName, + rightBranchName: pair.leftBranchName + } +} + +// branch 이름 조합을 충돌 없는 내부 식별자로 변환 +function keyFor(pair: BranchComparisonPair): string { + return `${pair.leftBranchName}\u0000${pair.rightBranchName}` +} diff --git a/src/branches/types.ts b/src/branches/types.ts index cca6ccc..bf78f29 100644 --- a/src/branches/types.ts +++ b/src/branches/types.ts @@ -47,6 +47,11 @@ export type BranchComparisonPair = { rightBranchName: string } +export type BranchComparisonRound = { + roundIndex: number + pairs: BranchComparisonPair[] +} + export type BranchContext = { baseBranch: string name: string diff --git a/tests/branches/branchRoundBuilder.test.ts b/tests/branches/branchRoundBuilder.test.ts new file mode 100644 index 0000000..a51d17b --- /dev/null +++ b/tests/branches/branchRoundBuilder.test.ts @@ -0,0 +1,112 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { build as buildPairs } from "../../src/branches/branchPairBuilder.js" +import { build } from "../../src/branches/branchRoundBuilder.js" +import type { BranchComparisonPair, BranchContext } from "../../src/branches/types.js" + +// 비교 조합이 없으면 라운드를 만들지 않는지 확인 +test("returns no rounds without pairs", () => { + assert.deepEqual(build([]), []) +}) + +// 홀수 branch에서는 매 라운드마다 한 branch가 쉬면서 모든 조합을 배치하는지 확인 +test("builds circle rounds for odd branch count", () => { + const pairs = buildPairs("main", [ + branch("feature/a"), + branch("feature/b") + ]) + const rounds = build(pairs) + + assert.equal(rounds.length, 3) + assert.deepEqual(rounds.map(round => round.roundIndex), [0, 1, 2]) + assert.deepEqual(rounds.flatMap(round => round.pairs).sort(comparePairs), pairs) + assert.equal(rounds.every(round => round.pairs.length === 1), true) +}) + +// 짝수 branch에서는 휴식 자리 없이 라운드마다 모든 branch를 한 번씩 배치하는지 확인 +test("builds circle rounds for even branch count", () => { + const pairs = buildPairs("main", [ + branch("feature/a"), + branch("feature/b"), + branch("feature/c") + ]) + const rounds = build(pairs) + + assert.equal(rounds.length, 3) + assert.equal(rounds.every(round => round.pairs.length === 2), true) + + for (const round of rounds) { + const names = round.pairs.flatMap(pair => [ + pair.leftBranchName, + pair.rightBranchName + ]) + + assert.equal(new Set(names).size, names.length) + } +}) + +// 입력 순서와 조합 방향이 달라도 같은 라운드를 구성하는지 확인 +test("builds deterministic rounds independent of pair input order", () => { + const pairs = buildPairs("main", [ + branch("feature/a"), + branch("feature/b"), + branch("feature/c") + ]) + const reordered = [...pairs].reverse().map((pair, index) => index % 2 === 0 + ? { + leftBranchName: pair.rightBranchName, + rightBranchName: pair.leftBranchName + } + : pair + ) + + assert.deepEqual(build(reordered), build(pairs)) +}) + +// 한 라운드에서 같은 branch가 두 조합에 포함되지 않는지 확인 +test("does not repeat a branch in the same round", () => { + const rounds = build(buildPairs("main", Array.from({ length: 8 }, (_, index) => + branch(`feature/${index}`) + ))) + + for (const round of rounds) { + const names = round.pairs.flatMap(pair => [ + pair.leftBranchName, + pair.rightBranchName + ]) + + assert.equal(new Set(names).size, names.length) + } +}) + +// 31개 branch의 465개 조합을 31개 라운드에 정확히 한 번씩 배치하는지 확인 +test("builds 31 rounds for 31 branches and 465 pairs", () => { + const pairs = buildPairs("main", Array.from({ length: 30 }, (_, index) => + branch(`feature/${String(index).padStart(2, "0")}`) + )) + const rounds = build(pairs) + const scheduled = rounds.flatMap(round => round.pairs) + + assert.equal(rounds.length, 31) + assert.equal(rounds.every(round => round.pairs.length === 15), true) + assert.equal(scheduled.length, 465) + assert.equal(new Set(scheduled.map(keyFor)).size, 465) + assert.deepEqual([...scheduled].sort(comparePairs), pairs) +}) + +function branch(name: string): BranchContext { + return { + baseBranch: "main", + name, + headSha: `${name}-sha`, + checks: [] + } +} + +function comparePairs(pair: BranchComparisonPair, other: BranchComparisonPair): number { + return keyFor(pair).localeCompare(keyFor(other)) +} + +function keyFor(pair: BranchComparisonPair): string { + return `${pair.leftBranchName}\u0000${pair.rightBranchName}` +} From c656ef6e8622bc2b00ea2e95ce311cf6951f83e0 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:02:47 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20merge-tree=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=ED=99=94=20=EC=B6=9C=EB=A0=A5=20=ED=95=B4=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/git/mergeTreeOutputParser.ts | 215 ++++++++++++++++++++++++ src/git/types.ts | 16 ++ tests/git/mergeTreeOutputParser.test.ts | 207 +++++++++++++++++++++++ 3 files changed, 438 insertions(+) create mode 100644 src/git/mergeTreeOutputParser.ts create mode 100644 tests/git/mergeTreeOutputParser.test.ts diff --git a/src/git/mergeTreeOutputParser.ts b/src/git/mergeTreeOutputParser.ts new file mode 100644 index 0000000..d151ed5 --- /dev/null +++ b/src/git/mergeTreeOutputParser.ts @@ -0,0 +1,215 @@ +import type { BranchComparisonPair } from "../branches/types.js" +import type { + GitMergeSignalStatus, + GitMergeTreeConflict, + GitMergeTreePairResult +} from "./types.js" + +type ParserState = + | "status" + | "merged_tree_oid" + | "conflict_files" + | "message_path_count" + | "message_paths" + | "message_type" + | "message_text" + +// git merge-tree --stdin -z 출력을 chunk 경계와 무관하게 조합별 결과로 해석 +export class MergeTreeOutputParser { + private readonly pairs: BranchComparisonPair[] + private readonly results: GitMergeTreePairResult[] = [] + private pendingChunks: Buffer[] = [] + private pendingLength = 0 + private state: ParserState = "status" + private pairIndex = 0 + private status?: GitMergeSignalStatus + private mergedTreeOid?: string + private conflictFiles: string[] = [] + private conflicts: GitMergeTreeConflict[] = [] + private messagePathCount = 0 + private messagePaths: string[] = [] + private messageType?: string + + constructor(pairs: BranchComparisonPair[]) { + this.pairs = pairs + } + + push(chunk: Buffer): void { + if (!chunk.length) { + return + } + + let start = 0 + let end = chunk.indexOf(0, start) + + while (end !== -1) { + const segment = chunk.subarray(start, end) + const token = this.pendingChunks.length + ? Buffer.concat( + [...this.pendingChunks, segment], + this.pendingLength + segment.length + ) + : segment + + this.consume(token.toString("utf8")) + this.pendingChunks = [] + this.pendingLength = 0 + start = end + 1 + end = chunk.indexOf(0, start) + } + + if (start < chunk.length) { + const segment = chunk.subarray(start) + + this.pendingChunks.push(segment) + this.pendingLength += segment.length + } + } + + finish(): GitMergeTreePairResult[] { + if (this.pendingLength) { + throw this.error(`truncated token while reading ${this.state}`) + } + + if (this.state !== "status") { + throw this.error(`truncated result while reading ${this.state}`) + } + + if (this.pairIndex !== this.pairs.length) { + throw this.error( + `expected ${this.pairs.length} results but received ${this.pairIndex}` + ) + } + + return [...this.results] + } + + private consume(token: string): void { + switch (this.state) { + case "status": + this.consumeStatus(token) + return + case "merged_tree_oid": + this.consumeMergedTreeOid(token) + return + case "conflict_files": + this.consumeConflictFile(token) + return + case "message_path_count": + this.consumeMessagePathCount(token) + return + case "message_paths": + this.messagePaths.push(token) + + if (this.messagePaths.length === this.messagePathCount) { + this.state = "message_type" + } + return + case "message_type": + this.messageType = token + this.state = "message_text" + return + case "message_text": + this.consumeMessageText() + return + } + } + + private consumeStatus(token: string): void { + if (this.pairIndex === this.pairs.length) { + throw this.error("received more results than expected") + } + + if (token === "1") { + this.status = "clean" + } else if (token === "0") { + this.status = "confirmed_conflict" + } else { + throw this.error(`invalid merge status ${JSON.stringify(token)}`) + } + + this.state = "merged_tree_oid" + } + + private consumeMergedTreeOid(token: string): void { + if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(token)) { + throw this.error(`invalid merged tree OID ${JSON.stringify(token)}`) + } + + this.mergedTreeOid = token + this.state = "conflict_files" + } + + private consumeConflictFile(token: string): void { + if (token.length) { + if (this.status === "clean") { + throw this.error(`clean result contained conflict file ${JSON.stringify(token)}`) + } + + this.conflictFiles.push(token) + return + } + + this.state = "message_path_count" + } + + private consumeMessagePathCount(token: string): void { + if (!token.length) { + this.completeResult() + return + } + + if (!/^\d+$/.test(token)) { + throw this.error(`invalid message path count ${JSON.stringify(token)}`) + } + + this.messagePathCount = Number(token) + this.messagePaths = [] + this.state = this.messagePathCount === 0 + ? "message_type" + : "message_paths" + } + + private consumeMessageText(): void { + if (this.messageType?.startsWith("CONFLICT")) { + this.conflicts.push({ + paths: [...this.messagePaths], + type: this.messageType + }) + } + + this.messagePathCount = 0 + this.messagePaths = [] + this.messageType = undefined + this.state = "message_path_count" + } + + private completeResult(): void { + const pair = this.pairs[this.pairIndex] + + if (!pair || !this.status || !this.mergedTreeOid) { + throw this.error("incomplete merge result") + } + + this.results.push({ + pair, + status: this.status, + mergedTreeOid: this.mergedTreeOid, + conflictFiles: [...this.conflictFiles], + conflicts: [...this.conflicts] + }) + this.pairIndex += 1 + this.status = undefined + this.mergedTreeOid = undefined + this.conflictFiles = [] + this.conflicts = [] + this.messagePathCount = 0 + this.messagePaths = [] + this.messageType = undefined + this.state = "status" + } + + private error(message: string): Error { + return new Error(`Invalid merge-tree output at pair ${this.pairIndex}: ${message}`) + } +} diff --git a/src/git/types.ts b/src/git/types.ts index dc7aded..5be7343 100644 --- a/src/git/types.ts +++ b/src/git/types.ts @@ -1,8 +1,24 @@ +import type { BranchComparisonPair } from "../branches/types.js" + export type GitMergeSignalStatus = | "clean" | "confirmed_conflict" | "merge_check_failed" +export type GitMergeTreeConflict = { + paths: string[] + type: string +} + +export type GitMergeTreePairResult = { + pair: BranchComparisonPair + status: GitMergeSignalStatus + mergedTreeOid?: string + conflictFiles: string[] + conflicts: GitMergeTreeConflict[] + errorMessage?: string +} + export type GitMergeSignal = { status: GitMergeSignalStatus baseBranch: string diff --git a/tests/git/mergeTreeOutputParser.test.ts b/tests/git/mergeTreeOutputParser.test.ts new file mode 100644 index 0000000..6320065 --- /dev/null +++ b/tests/git/mergeTreeOutputParser.test.ts @@ -0,0 +1,207 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { MergeTreeOutputParser } from "../../src/git/mergeTreeOutputParser.js" +import type { BranchComparisonPair } from "../../src/branches/types.js" + +const cleanTreeOid = "1".repeat(40) +const conflictTreeOid = "2".repeat(40) + +// 정상 병합 상태와 merged tree OID를 조합 순서대로 해석하는지 확인 +test("parses clean merge results", () => { + const pairs = [pair("main", "feature/clean")] + const parser = new MergeTreeOutputParser(pairs) + + parser.push(output(["1", cleanTreeOid, "", ""])) + + assert.deepEqual(parser.finish(), [{ + pair: pairs[0], + status: "clean", + mergedTreeOid: cleanTreeOid, + conflictFiles: [], + conflicts: [] + }]) +}) + +// conflict file과 여러 path가 연결된 안정된 conflict type을 해석하는지 확인 +test("parses conflict files and conflict types", () => { + const pairs = [pair("feature/a", "feature/b")] + const parser = new MergeTreeOutputParser(pairs) + + parser.push(output([ + "0", + conflictTreeOid, + "renamed.txt", + "removed.txt", + "", + "2", + "before.txt", + "renamed.txt", + "CONFLICT (rename/delete)", + "free form message is ignored", + "1", + "removed.txt", + "CONFLICT (modify/delete)", + "another free form message", + "" + ])) + + assert.deepEqual(parser.finish(), [{ + pair: pairs[0], + status: "confirmed_conflict", + mergedTreeOid: conflictTreeOid, + conflictFiles: ["renamed.txt", "removed.txt"], + conflicts: [{ + paths: ["before.txt", "renamed.txt"], + type: "CONFLICT (rename/delete)" + }, { + paths: ["removed.txt"], + type: "CONFLICT (modify/delete)" + }] + }]) +}) + +// Auto-merging 같은 정보성 message를 conflict 목록에 포함하지 않는지 확인 +test("ignores non-conflict informational messages", () => { + const pairs = [pair("main", "feature/conflict")] + const parser = new MergeTreeOutputParser(pairs) + + parser.push(output([ + "0", + conflictTreeOid, + "공유.txt", + "", + "1", + "공유.txt", + "Auto-merging", + "free form message", + "1", + "공유.txt", + "CONFLICT (content)", + "free form message", + "" + ])) + + assert.deepEqual(parser.finish()[0]?.conflicts, [{ + paths: ["공유.txt"], + type: "CONFLICT (content)" + }]) +}) + +// UTF-8 문자와 NUL 사이에서 stdout chunk가 나뉘어도 같은 결과를 만드는지 확인 +test("parses chunks split across UTF-8 and NUL boundaries", () => { + const pairs = [ + pair("main", "feature/clean"), + pair("main", "feature/conflict") + ] + const parser = new MergeTreeOutputParser(pairs) + const bytes = output([ + "1", + cleanTreeOid, + "", + "", + "0", + conflictTreeOid, + "한글.txt", + "", + "1", + "한글.txt", + "CONFLICT (content)", + "free form message", + "" + ]) + + for (const byte of bytes) { + parser.push(Buffer.from([byte])) + } + + const results = parser.finish() + + assert.deepEqual(results.map(result => result.status), [ + "clean", + "confirmed_conflict" + ]) + assert.deepEqual(results[1]?.conflictFiles, ["한글.txt"]) +}) + +// 잘린 토큰에 현재 pair와 parser 상태를 포함한 진단 오류를 제공하는지 확인 +test("rejects truncated output", () => { + const parser = new MergeTreeOutputParser([pair("main", "feature/a")]) + + parser.push(Buffer.from(`1\0${cleanTreeOid}`, "utf8")) + + assert.throws( + () => parser.finish(), + /pair 0: truncated token while reading merged_tree_oid/ + ) +}) + +// 잘못된 상태와 OID를 진단 오류로 거부하는지 확인 +test("rejects invalid status and merged tree OID", () => { + const invalidStatus = new MergeTreeOutputParser([pair("main", "feature/a")]) + const invalidOid = new MergeTreeOutputParser([pair("main", "feature/a")]) + const abbreviatedOid = new MergeTreeOutputParser([pair("main", "feature/a")]) + + assert.throws( + () => invalidStatus.push(output(["2"])), + /invalid merge status/ + ) + assert.throws( + () => invalidOid.push(output(["1", "not-an-oid"])), + /invalid merged tree OID/ + ) + assert.throws( + () => abbreviatedOid.push(output(["1", "1".repeat(39)])), + /invalid merged tree OID/ + ) +}) + +// 예상한 조합 수보다 결과가 적거나 많으면 거부하는지 확인 +test("rejects mismatched result count", () => { + const missing = new MergeTreeOutputParser([ + pair("main", "feature/a"), + pair("main", "feature/b") + ]) + const extra = new MergeTreeOutputParser([pair("main", "feature/a")]) + + missing.push(output(["1", cleanTreeOid, "", ""])) + extra.push(output(["1", cleanTreeOid, "", ""])) + + assert.throws( + () => missing.finish(), + /expected 2 results but received 1/ + ) + assert.throws( + () => extra.push(output(["1"])), + /received more results than expected/ + ) +}) + +// message path 수가 숫자가 아니면 구조화된 출력 오류로 처리하는지 확인 +test("rejects invalid message path count", () => { + const parser = new MergeTreeOutputParser([pair("main", "feature/a")]) + + assert.throws( + () => parser.push(output([ + "0", + conflictTreeOid, + "shared.txt", + "", + "not-a-count" + ])), + /invalid message path count/ + ) +}) + +function pair( + leftBranchName: string, + rightBranchName: string +): BranchComparisonPair { + return { + leftBranchName, + rightBranchName + } +} + +function output(tokens: string[]): Buffer { + return Buffer.from(`${tokens.join("\0")}\0`, "utf8") +} From c2624a15a3c73d0bc04fa2a9655d3a199f0c6888 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:05:53 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=9D=BC=EC=9A=B4=EB=93=9C?= =?UTF-8?q?=EB=B3=84=20merge-tree=20=EC=8B=A4=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/git/gitMergeTreeRoundCollector.ts | 131 +++++++++++ src/git/types.ts | 16 +- tests/git/gitMergeTreeRoundCollector.test.ts | 226 +++++++++++++++++++ 3 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 src/git/gitMergeTreeRoundCollector.ts create mode 100644 tests/git/gitMergeTreeRoundCollector.test.ts diff --git a/src/git/gitMergeTreeRoundCollector.ts b/src/git/gitMergeTreeRoundCollector.ts new file mode 100644 index 0000000..cd7c0bf --- /dev/null +++ b/src/git/gitMergeTreeRoundCollector.ts @@ -0,0 +1,131 @@ +import { spawn } from "node:child_process" +import type { BranchComparisonRound } from "../branches/types.js" +import { MergeTreeOutputParser } from "./mergeTreeOutputParser.js" +import type { + GitMergeTreePairResult, + GitMergeTreeRoundCollectionOptions +} from "./types.js" + +const DEFAULT_STDERR_LIMIT = 16 * 1024 + +// 한 라운드의 commit OID 조합을 하나의 git merge-tree process에서 수집 +export async function collect( + round: BranchComparisonRound, + options: GitMergeTreeRoundCollectionOptions +): Promise { + if (!round.pairs.length) { + return [] + } + + const input = `${round.pairs.map(pair => [ + oidFor(pair.leftBranchName, options.commitOidByBranch), + oidFor(pair.rightBranchName, options.commitOidByBranch) + ].join(" ")).join("\n")}\n` + const parser = new MergeTreeOutputParser(round.pairs) + const stderrLimit = options.stderrLimit ?? DEFAULT_STDERR_LIMIT + + return new Promise((resolve, reject) => { + const child = spawn("git", [ + "merge-tree", + "--stdin", + "--name-only", + "--messages" + ], { + cwd: options.repositoryPath, + stdio: ["pipe", "pipe", "pipe"] + }) + const stderrChunks: Buffer[] = [] + let stderrLength = 0 + let parserError: unknown + let inputError: unknown + let settled = false + + child.stdout.on("data", (chunk: Buffer) => { + if (parserError) { + return + } + + try { + parser.push(chunk) + } catch (error) { + parserError = error + } + }) + child.stderr.on("data", (chunk: Buffer) => { + const remaining = stderrLimit - stderrLength + + if (remaining <= 0) { + return + } + + const captured = chunk.subarray(0, remaining) + + stderrChunks.push(captured) + stderrLength += captured.length + }) + child.stdin.on("error", error => { + inputError = error + }) + child.on("error", error => { + if (!settled) { + settled = true + reject(error) + } + }) + child.on("close", (code, signal) => { + if (settled) { + return + } + + settled = true + const stderr = Buffer.concat(stderrChunks, stderrLength).toString("utf8").trim() + + if (code !== 0) { + reject(new Error([ + `git merge-tree failed for round ${round.roundIndex}`, + code === null ? `signal ${signal ?? "unknown"}` : `exit code ${code}`, + stderr + ].filter(Boolean).join(": "))) + return + } + + if (inputError) { + reject(errorFor(inputError)) + return + } + + if (parserError) { + reject(errorFor(parserError)) + return + } + + try { + resolve(parser.finish()) + } catch (error) { + reject(errorFor(error)) + } + }) + + child.stdin.end(input) + }) +} + +// branch 이름에 대응하는 고정 commit OID를 반환 +function oidFor(name: string, commitOidByBranch: ReadonlyMap): string { + const oid = commitOidByBranch.get(name) + + if (!oid) { + throw new Error(`Missing commit OID for branch ${name}`) + } + + return oid +} + +// unknown 오류를 호출자가 진단 가능한 Error로 정규화 +function errorFor(error: unknown): Error { + if (error instanceof Error) { + return error + } + + return new Error(String(error)) +} diff --git a/src/git/types.ts b/src/git/types.ts index 5be7343..fcdcd25 100644 --- a/src/git/types.ts +++ b/src/git/types.ts @@ -1,4 +1,7 @@ -import type { BranchComparisonPair } from "../branches/types.js" +import type { + BranchComparisonPair, + BranchComparisonRound +} from "../branches/types.js" export type GitMergeSignalStatus = | "clean" @@ -19,6 +22,17 @@ export type GitMergeTreePairResult = { errorMessage?: string } +export type GitMergeTreeRoundCollectionOptions = { + repositoryPath: string + commitOidByBranch: ReadonlyMap + stderrLimit?: number +} + +export type GitMergeTreeRoundCollector = ( + round: BranchComparisonRound, + options: GitMergeTreeRoundCollectionOptions +) => Promise + export type GitMergeSignal = { status: GitMergeSignalStatus baseBranch: string diff --git a/tests/git/gitMergeTreeRoundCollector.test.ts b/tests/git/gitMergeTreeRoundCollector.test.ts new file mode 100644 index 0000000..a9ccf6f --- /dev/null +++ b/tests/git/gitMergeTreeRoundCollector.test.ts @@ -0,0 +1,226 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { promisify } from "node:util" +import { collect } from "../../src/git/gitMergeTreeRoundCollector.js" +import type { + BranchComparisonPair, + BranchComparisonRound +} from "../../src/branches/types.js" + +const execFileAsync = promisify(execFile) + +// clean과 여러 conflict 조합을 한 process 출력 순서대로 수집하는지 확인 +test("collects mixed merge results for one round", async () => { + const fixture = await createGitFixture() + const round = comparisonRound([ + pair("clean/left", "clean/right"), + pair("content/left", "content/right"), + pair("rename/left", "rename/delete"), + pair("modify/left", "modify/delete") + ]) + + try { + const results = await collect(round, { + repositoryPath: fixture.repositoryPath, + commitOidByBranch: fixture.commitOidByBranch + }) + + assert.deepEqual(results.map(result => result.pair), round.pairs) + assert.deepEqual(results.map(result => result.status), [ + "clean", + "confirmed_conflict", + "confirmed_conflict", + "confirmed_conflict" + ]) + assert.match(results[0]?.mergedTreeOid ?? "", /^[0-9a-f]{40}$/) + assert.deepEqual(results[0]?.conflictFiles, []) + assert.deepEqual(results[1]?.conflictFiles, ["shared.txt"]) + assert.equal(results[1]?.conflicts.some(conflict => + conflict.type === "CONFLICT (contents)" + ), true) + assert.equal(results[2]?.conflicts.some(conflict => + conflict.type === "CONFLICT (rename/delete)" + ), true) + assert.equal(results[3]?.conflicts.some(conflict => + conflict.type === "CONFLICT (modify/delete)" + ), true) + } finally { + await fixture.remove() + } +}) + +// merge-tree 실행 전후 worktree 상태와 index tree가 바뀌지 않는지 확인 +test("keeps worktree and index unchanged", async () => { + const fixture = await createGitFixture() + const round = comparisonRound([ + pair("content/left", "content/right") + ]) + + try { + const statusBefore = await git(fixture.repositoryPath, ["status", "--porcelain=v1"]) + const indexBefore = await git(fixture.repositoryPath, ["write-tree"]) + + await collect(round, { + repositoryPath: fixture.repositoryPath, + commitOidByBranch: fixture.commitOidByBranch + }) + + assert.equal(await git(fixture.repositoryPath, ["status", "--porcelain=v1"]), statusBefore) + assert.equal(await git(fixture.repositoryPath, ["write-tree"]), indexBefore) + } finally { + await fixture.remove() + } +}) + +// Git process가 실패하면 부분 결과 대신 제한된 stderr 진단을 반환하는지 확인 +test("rejects failed git process with bounded stderr", async () => { + const fixture = await createGitFixture() + const round = comparisonRound([ + pair("clean/left", "missing") + ]) + const commitOidByBranch = new Map(fixture.commitOidByBranch) + + commitOidByBranch.set("missing", "f".repeat(40)) + + try { + await assert.rejects( + collect(round, { + repositoryPath: fixture.repositoryPath, + commitOidByBranch, + stderrLimit: 32 + }), + error => { + assert.match(String(error), /git merge-tree failed for round 0: exit code/) + assert.equal(String(error).length < 160, true) + return true + } + ) + } finally { + await fixture.remove() + } +}) + +// branch commit OID가 없으면 Git 실행 전에 해당 branch를 진단하는지 확인 +test("rejects missing branch commit OID", async () => { + await assert.rejects( + collect(comparisonRound([pair("main", "feature/a")]), { + repositoryPath: "/tmp/repository", + commitOidByBranch: new Map([["main", "1".repeat(40)]]) + }), + /Missing commit OID for branch feature\/a/ + ) +}) + +// 빈 라운드에서는 Git process 없이 빈 결과를 반환하는지 확인 +test("returns no results for an empty round", async () => { + assert.deepEqual(await collect(comparisonRound([]), { + repositoryPath: "/missing/repository", + commitOidByBranch: new Map() + }), []) +}) + +async function createGitFixture(): Promise<{ + repositoryPath: string + commitOidByBranch: Map + remove(): Promise +}> { + const root = await mkdtemp(join(tmpdir(), "watcher-merge-tree-round-")) + const repositoryPath = join(root, "repository") + + await git(root, ["init", "--initial-branch=main", repositoryPath]) + await git(repositoryPath, ["config", "user.email", "opfic@example.com"]) + await git(repositoryPath, ["config", "user.name", "opfic"]) + await writeFile(join(repositoryPath, "shared.txt"), "value=base\n") + await writeFile(join(repositoryPath, "rename.txt"), "rename base\n") + await writeFile(join(repositoryPath, "modify-delete.txt"), "modify delete base\n") + await git(repositoryPath, ["add", "."]) + await git(repositoryPath, ["commit", "-m", "initial"]) + const baseOid = await git(repositoryPath, ["rev-parse", "HEAD"]) + const commitOidByBranch = new Map() + + await checkout(repositoryPath, baseOid, "clean/left") + await writeFile(join(repositoryPath, "left.txt"), "left\n") + commitOidByBranch.set("clean/left", await commit(repositoryPath, "clean left")) + + await checkout(repositoryPath, baseOid, "clean/right") + await writeFile(join(repositoryPath, "right.txt"), "right\n") + commitOidByBranch.set("clean/right", await commit(repositoryPath, "clean right")) + + await checkout(repositoryPath, baseOid, "content/left") + await writeFile(join(repositoryPath, "shared.txt"), "value=left\n") + commitOidByBranch.set("content/left", await commit(repositoryPath, "content left")) + + await checkout(repositoryPath, baseOid, "content/right") + await writeFile(join(repositoryPath, "shared.txt"), "value=right\n") + commitOidByBranch.set("content/right", await commit(repositoryPath, "content right")) + + await checkout(repositoryPath, baseOid, "rename/left") + await git(repositoryPath, ["mv", "rename.txt", "renamed.txt"]) + commitOidByBranch.set("rename/left", await commit(repositoryPath, "rename left")) + + await checkout(repositoryPath, baseOid, "rename/delete") + await rm(join(repositoryPath, "rename.txt")) + commitOidByBranch.set("rename/delete", await commit(repositoryPath, "rename delete")) + + await checkout(repositoryPath, baseOid, "modify/left") + await writeFile(join(repositoryPath, "modify-delete.txt"), "modified\n") + commitOidByBranch.set("modify/left", await commit(repositoryPath, "modify left")) + + await checkout(repositoryPath, baseOid, "modify/delete") + await rm(join(repositoryPath, "modify-delete.txt")) + commitOidByBranch.set("modify/delete", await commit(repositoryPath, "modify delete")) + + await git(repositoryPath, ["checkout", "main"]) + + return { + repositoryPath, + commitOidByBranch, + async remove(): Promise { + await rm(root, { recursive: true, force: true }) + } + } +} + +async function checkout( + repositoryPath: string, + oid: string, + name: string +): Promise { + await git(repositoryPath, ["checkout", "-B", name, oid]) +} + +async function commit(repositoryPath: string, message: string): Promise { + await git(repositoryPath, ["add", "-A"]) + await git(repositoryPath, ["commit", "-m", message]) + return git(repositoryPath, ["rev-parse", "HEAD"]) +} + +function comparisonRound(pairs: BranchComparisonPair[]): BranchComparisonRound { + return { + roundIndex: 0, + pairs + } +} + +function pair( + leftBranchName: string, + rightBranchName: string +): BranchComparisonPair { + return { + leftBranchName, + rightBranchName + } +} + +async function git(cwd: string, args: string[]): Promise { + const result = await execFileAsync("git", args, { + cwd, + maxBuffer: 10 * 1024 * 1024 + }) + + return result.stdout.trim() +} From 5ff36f6718c268b389353a730e9fe77316043ee4 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:08:13 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20merge-tree=20=EB=B3=91=EB=A0=AC=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=EA=B3=BC=20=EC=8B=A4=ED=8C=A8=20=EA=B2=A9?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/git/gitMergeTreeCollector.ts | 305 ++++++++++++++++++++++++ src/git/types.ts | 21 ++ tests/git/gitMergeTreeCollector.test.ts | 252 ++++++++++++++++++++ 3 files changed, 578 insertions(+) create mode 100644 src/git/gitMergeTreeCollector.ts create mode 100644 tests/git/gitMergeTreeCollector.test.ts diff --git a/src/git/gitMergeTreeCollector.ts b/src/git/gitMergeTreeCollector.ts new file mode 100644 index 0000000..7411149 --- /dev/null +++ b/src/git/gitMergeTreeCollector.ts @@ -0,0 +1,305 @@ +import { execFile, spawn } from "node:child_process" +import { availableParallelism } from "node:os" +import { promisify } from "node:util" +import { compareBranchNames } from "../branches/branchPairBuilder.js" +import { build as buildBranchComparisonRounds } from "../branches/branchRoundBuilder.js" +import type { + BranchComparisonPair, + BranchComparisonRound +} from "../branches/types.js" +import { collect as collectGitMergeTreeRound } from "./gitMergeTreeRoundCollector.js" +import type { + GitMergeTreeCollectionOptions, + GitMergeTreeCollectorDependencies, + GitMergeTreePairResult +} from "./types.js" + +const execFileAsync = promisify(execFile) +const MAX_PARALLEL_ROUNDS = 4 +const MAX_ERROR_MESSAGE_LENGTH = 16 * 1024 + +const defaultDependencies: GitMergeTreeCollectorDependencies = { + fetchRemoteBranches, + resolveCommitOids, + supportsMergeTreeStdin, + collectRound: collectGitMergeTreeRound, + availableParallelism +} + +// 모든 branch 조합을 제한된 라운드 병렬 실행으로 수집하고 입력 순서로 복원 +export async function collect( + pairs: BranchComparisonPair[], + options: GitMergeTreeCollectionOptions, + dependencyOverrides: Partial = {} +): Promise { + if (!pairs.length) { + return [] + } + + const dependencies = { + ...defaultDependencies, + ...dependencyOverrides + } + const remote = options.remoteName ?? "origin" + + try { + await dependencies.fetchRemoteBranches(options.repositoryPath, remote) + } catch { + return pairs.map(pair => failedResult( + pair, + `git fetch failed for remote ${remote}`, + "preparation" + )) + } + + const branchNames = [...new Set(pairs.flatMap(pair => [ + pair.leftBranchName, + pair.rightBranchName + ]))].sort(compareBranchNames) + let commitOidByBranch: ReadonlyMap + + try { + commitOidByBranch = await dependencies.resolveCommitOids( + options.repositoryPath, + remote, + branchNames + ) + } catch { + return pairs.map(pair => failedResult( + pair, + "git remote ref resolution failed", + "preparation" + )) + } + + const resultByKey = new Map() + const resolvedPairs = pairs.filter(pair => { + const missing = [pair.leftBranchName, pair.rightBranchName] + .filter(name => !commitOidByBranch.has(name)) + + if (!missing.length) { + return true + } + + resultByKey.set(keyFor(pair), failedResult( + pair, + `Missing remote branch commit OID: ${missing.join(", ")}`, + "preparation" + )) + return false + }) + + if (!resolvedPairs.length) { + return resultsInInputOrder(pairs, resultByKey) + } + + let supported: boolean + + try { + supported = await dependencies.supportsMergeTreeStdin(options.repositoryPath) + } catch { + supported = false + } + + if (!supported) { + const message = "git merge-tree --stdin is not supported by the installed Git version" + + for (const pair of resolvedPairs) { + resultByKey.set(keyFor(pair), failedResult(pair, message, "preparation")) + } + + return resultsInInputOrder(pairs, resultByKey) + } + + const rounds = buildBranchComparisonRounds(resolvedPairs) + let nextRoundIndex = 0 + const workerCount = Math.min( + rounds.length, + MAX_PARALLEL_ROUNDS, + Math.max(1, dependencies.availableParallelism()) + ) + + async function runNextRound(): Promise { + while (nextRoundIndex < rounds.length) { + const round = rounds[nextRoundIndex] + + nextRoundIndex += 1 + + if (!round) { + return + } + + const results = await collectWithFailureIsolation(round, { + repositoryPath: options.repositoryPath, + commitOidByBranch, + stderrLimit: options.stderrLimit + }, dependencies.collectRound) + + for (const result of results) { + resultByKey.set(keyFor(result.pair), result) + } + } + } + + await Promise.all(Array.from({ length: workerCount }, runNextRound)) + + return resultsInInputOrder(pairs, resultByKey) +} + +// 실패한 라운드만 절반으로 나눠 단일 조합 오류까지 격리 +async function collectWithFailureIsolation( + round: BranchComparisonRound, + options: { + repositoryPath: string + commitOidByBranch: ReadonlyMap + stderrLimit?: number + }, + collectRound: GitMergeTreeCollectorDependencies["collectRound"] +): Promise { + try { + return await collectRound(round, options) + } catch (error) { + if (round.pairs.length === 1) { + return [failedResult(round.pairs[0]!, errorMessageFor(error), "merge")] + } + + const middle = Math.ceil(round.pairs.length / 2) + const results: GitMergeTreePairResult[] = [] + + for (const pairs of [ + round.pairs.slice(0, middle), + round.pairs.slice(middle) + ]) { + results.push(...await collectWithFailureIsolation({ + roundIndex: round.roundIndex, + pairs + }, options, collectRound)) + } + + return results + } +} + +// 비교할 remote tracking ref를 한 번의 fetch로 갱신 +async function fetchRemoteBranches( + repositoryPath: string, + remote: string +): Promise { + await execFileAsync("git", [ + "fetch", + "--quiet", + "--prune", + remote, + `+refs/heads/*:refs/remotes/${remote}/*` + ], { + cwd: repositoryPath, + maxBuffer: 1024 * 1024 + }) +} + +// remote tracking ref 이름을 해당 실행에서 고정할 commit OID로 변환 +async function resolveCommitOids( + repositoryPath: string, + remote: string, + branchNames: string[] +): Promise> { + const result = await execFileAsync("git", [ + "for-each-ref", + `refs/remotes/${remote}`, + "--format=%(refname)%09%(objectname)" + ], { + cwd: repositoryPath, + maxBuffer: 1024 * 1024 + }) + const prefix = `refs/remotes/${remote}/` + const requested = new Set(branchNames) + const commitOidByBranch = new Map() + + for (const line of result.stdout.trim().split("\n").filter(Boolean)) { + const [ref, oid] = line.split("\t") + + if (!ref?.startsWith(prefix) || !oid) { + continue + } + + const name = ref.slice(prefix.length) + + if (requested.has(name)) { + commitOidByBranch.set(name, oid) + } + } + + return commitOidByBranch +} + +// 빈 표준 입력으로 merge-tree --stdin 지원 여부를 한 번 확인 +async function supportsMergeTreeStdin(repositoryPath: string): Promise { + return new Promise(resolve => { + const child = spawn("git", [ + "merge-tree", + "--stdin", + "--name-only", + "--messages" + ], { + cwd: repositoryPath, + stdio: ["pipe", "ignore", "ignore"] + }) + let settled = false + + child.stdin.on("error", () => {}) + child.on("error", () => { + if (!settled) { + settled = true + resolve(false) + } + }) + child.on("close", code => { + if (!settled) { + settled = true + resolve(code === 0) + } + }) + child.stdin.end() + }) +} + +// 모든 조합 결과를 원래 #42 조합 배열 순서로 복원 +function resultsInInputOrder( + pairs: BranchComparisonPair[], + resultByKey: ReadonlyMap +): GitMergeTreePairResult[] { + return pairs.map(pair => resultByKey.get(keyFor(pair)) ?? failedResult( + pair, + "merge-tree result missing for pair", + "merge" + )) +} + +// 실행하지 못한 조합을 기존 merge 실패 상태와 같은 형태로 구성 +function failedResult( + pair: BranchComparisonPair, + errorMessage: string, + failureStage: "preparation" | "merge" +): GitMergeTreePairResult { + return { + pair, + status: "merge_check_failed", + conflictFiles: [], + conflicts: [], + errorMessage: errorMessage.slice(0, MAX_ERROR_MESSAGE_LENGTH), + failureStage + } +} + +// 조합 방향과 무관한 결과 식별자를 구성 +function keyFor(pair: BranchComparisonPair): string { + const names = [pair.leftBranchName, pair.rightBranchName].sort(compareBranchNames) + + return `${names[0]}\u0000${names[1]}` +} + +// unknown 오류를 제한된 진단 문자열로 변환 +function errorMessageFor(error: unknown): string { + return (error instanceof Error ? error.message : String(error)) + .slice(0, MAX_ERROR_MESSAGE_LENGTH) +} diff --git a/src/git/types.ts b/src/git/types.ts index fcdcd25..bb2383c 100644 --- a/src/git/types.ts +++ b/src/git/types.ts @@ -13,6 +13,8 @@ export type GitMergeTreeConflict = { type: string } +export type GitMergeTreeFailureStage = "preparation" | "merge" + export type GitMergeTreePairResult = { pair: BranchComparisonPair status: GitMergeSignalStatus @@ -20,6 +22,7 @@ export type GitMergeTreePairResult = { conflictFiles: string[] conflicts: GitMergeTreeConflict[] errorMessage?: string + failureStage?: GitMergeTreeFailureStage } export type GitMergeTreeRoundCollectionOptions = { @@ -33,6 +36,24 @@ export type GitMergeTreeRoundCollector = ( options: GitMergeTreeRoundCollectionOptions ) => Promise +export type GitMergeTreeCollectionOptions = { + repositoryPath: string + remoteName?: string + stderrLimit?: number +} + +export type GitMergeTreeCollectorDependencies = { + fetchRemoteBranches(repositoryPath: string, remote: string): Promise + resolveCommitOids( + repositoryPath: string, + remote: string, + branchNames: string[] + ): Promise> + supportsMergeTreeStdin(repositoryPath: string): Promise + collectRound: GitMergeTreeRoundCollector + availableParallelism(): number +} + export type GitMergeSignal = { status: GitMergeSignalStatus baseBranch: string diff --git a/tests/git/gitMergeTreeCollector.test.ts b/tests/git/gitMergeTreeCollector.test.ts new file mode 100644 index 0000000..439c28d --- /dev/null +++ b/tests/git/gitMergeTreeCollector.test.ts @@ -0,0 +1,252 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { build as buildPairs } from "../../src/branches/branchPairBuilder.js" +import { build as buildRounds } from "../../src/branches/branchRoundBuilder.js" +import { collect } from "../../src/git/gitMergeTreeCollector.js" +import type { + BranchComparisonPair, + BranchComparisonRound, + BranchContext +} from "../../src/branches/types.js" +import type { + GitMergeTreeCollectorDependencies, + GitMergeTreePairResult +} from "../../src/git/types.js" + +// availableParallelism과 고정 상한 중 작은 값만큼 라운드를 병렬 실행하는지 확인 +test("limits parallel rounds to available processors and fixed cap", async () => { + for (const scenario of [{ available: 2, expected: 2 }, { available: 8, expected: 4 }]) { + const pairs = buildPairs("main", Array.from({ length: 8 }, (_, index) => + branch(`feature/${index}`) + )) + let active = 0 + let maximum = 0 + let fetchCount = 0 + let resolveCount = 0 + let supportCount = 0 + + const results = await collect(pairs, baseOptions(), { + fetchRemoteBranches: async () => { + fetchCount += 1 + }, + resolveCommitOids: async (_repositoryPath, _remote, names) => { + resolveCount += 1 + return commitOidsFor(names) + }, + supportsMergeTreeStdin: async () => { + supportCount += 1 + return true + }, + collectRound: async round => { + active += 1 + maximum = Math.max(maximum, active) + await delay(10) + active -= 1 + return cleanResults(round) + }, + availableParallelism: () => scenario.available + }) + + assert.equal(maximum, scenario.expected) + assert.equal(fetchCount, 1) + assert.equal(resolveCount, 1) + assert.equal(supportCount, 1) + assert.equal(results.length, pairs.length) + } +}) + +// 라운드 완료 순서와 무관하게 #42 조합 순서로 결과를 반환하는지 확인 +test("restores results to pair input order", async () => { + const pairs = buildPairs("main", Array.from({ length: 6 }, (_, index) => + branch(`feature/${index}`) + )) + const roundCount = buildRounds(pairs).length + + const results = await collect(pairs, baseOptions(), dependencies({ + collectRound: async round => { + await delay((roundCount - round.roundIndex) * 5) + return cleanResults(round) + }, + availableParallelism: () => 4 + })) + + assert.deepEqual(results.map(result => result.pair), pairs) +}) + +// 실패한 라운드만 이분하고 단일 실패 조합을 다른 결과에서 격리하는지 확인 +test("isolates one failed pair by splitting only its round", async () => { + const pairs = buildPairs("main", [ + branch("feature/a"), + branch("feature/b"), + branch("feature/c") + ]) + const target = pairs[0]! + const attempts: BranchComparisonPair[][] = [] + + const results = await collect(pairs, baseOptions(), dependencies({ + collectRound: async round => { + attempts.push(round.pairs) + + if (round.pairs.some(pair => keyFor(pair) === keyFor(target))) { + throw new Error("target merge failed") + } + + return cleanResults(round) + }, + availableParallelism: () => 1 + })) + const failed = results.filter(result => result.status === "merge_check_failed") + + assert.deepEqual(failed.map(result => result.pair), [target]) + assert.equal(failed[0]?.errorMessage, "target merge failed") + assert.equal(failed[0]?.failureStage, "merge") + assert.equal(results.filter(result => result.status === "clean").length, pairs.length - 1) + assert.equal(attempts.some(attempt => attempt.length === 2 && + attempt.some(pair => keyFor(pair) === keyFor(target)) + ), true) + assert.equal(attempts.some(attempt => attempt.length === 1 && + keyFor(attempt[0]!) === keyFor(target) + ), true) +}) + +// 지원하지 않는 Git에서는 모든 조합에 같은 진단을 남기고 라운드를 실행하지 않는지 확인 +test("returns the same diagnostic when merge-tree stdin is unsupported", async () => { + const pairs = buildPairs("main", [branch("feature/a"), branch("feature/b")]) + let roundCount = 0 + + const results = await collect(pairs, baseOptions(), dependencies({ + supportsMergeTreeStdin: async () => false, + collectRound: async round => { + roundCount += 1 + return cleanResults(round) + } + })) + + assert.equal(roundCount, 0) + assert.equal(results.every(result => result.status === "merge_check_failed"), true) + assert.equal(results.every(result => result.failureStage === "preparation"), true) + assert.equal(new Set(results.map(result => result.errorMessage)).size, 1) + assert.match(results[0]?.errorMessage ?? "", /not supported/) +}) + +// OID가 없는 branch 관련 조합만 실패시키고 나머지 조합은 수집하는지 확인 +test("isolates pairs with missing remote branch OID", async () => { + const pairs = buildPairs("main", [branch("feature/a"), branch("feature/missing")]) + + const results = await collect(pairs, baseOptions(), dependencies({ + resolveCommitOids: async (_repositoryPath, _remote, names) => + commitOidsFor(names.filter(name => name !== "feature/missing")) + })) + + assert.equal(results.find(result => + result.pair.leftBranchName === "feature/a" && + result.pair.rightBranchName === "main" + )?.status, "clean") + assert.equal(results.filter(result => + result.pair.leftBranchName === "feature/missing" || + result.pair.rightBranchName === "feature/missing" + ).every(result => + result.status === "merge_check_failed" && + result.failureStage === "preparation" + ), true) +}) + +// fetch 실패를 모든 조합의 preparation 실패로 분류하는지 확인 +test("marks fetch failures as preparation failures", async () => { + const pairs = buildPairs("main", [branch("feature/a"), branch("feature/b")]) + + const results = await collect(pairs, baseOptions(), dependencies({ + fetchRemoteBranches: async () => { + throw new Error("fetch failed") + } + })) + + assert.equal(results.every(result => + result.status === "merge_check_failed" && + result.failureStage === "preparation" + ), true) +}) + +// 빈 조합에서는 fetch, 지원 확인, 라운드 process를 실행하지 않는지 확인 +test("does not run Git operations without pairs", async () => { + let operationCount = 0 + + const results = await collect([], baseOptions(), { + fetchRemoteBranches: async () => { + operationCount += 1 + }, + resolveCommitOids: async () => { + operationCount += 1 + return new Map() + }, + supportsMergeTreeStdin: async () => { + operationCount += 1 + return true + }, + collectRound: async () => { + operationCount += 1 + return [] + }, + availableParallelism: () => 4 + }) + + assert.deepEqual(results, []) + assert.equal(operationCount, 0) +}) + +function dependencies( + overrides: Partial = {} +): GitMergeTreeCollectorDependencies { + return { + fetchRemoteBranches: async () => {}, + resolveCommitOids: async (_repositoryPath, _remote, names) => commitOidsFor(names), + supportsMergeTreeStdin: async () => true, + collectRound: async round => cleanResults(round), + availableParallelism: () => 4, + ...overrides + } +} + +function cleanResults(round: BranchComparisonRound): GitMergeTreePairResult[] { + return round.pairs.map(pair => ({ + pair, + status: "clean", + mergedTreeOid: "1".repeat(40), + conflictFiles: [], + conflicts: [] + })) +} + +function commitOidsFor(names: string[]): ReadonlyMap { + return new Map(names.map((name, index) => [ + name, + String(index + 1).repeat(40).slice(0, 40) + ])) +} + +function baseOptions(): { + repositoryPath: string + remoteName: string +} { + return { + repositoryPath: "/tmp/repository", + remoteName: "origin" + } +} + +function branch(name: string): BranchContext { + return { + baseBranch: "main", + name, + headSha: `${name}-sha`, + checks: [] + } +} + +function keyFor(pair: BranchComparisonPair): string { + return [pair.leftBranchName, pair.rightBranchName].sort().join("\u0000") +} + +function delay(milliseconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, milliseconds)) +} From 84eb2d981cf202660b02988c373d8c09508e05eb Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:10:10 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20=EC=9E=84=EC=8B=9C=20worktree?= =?UTF-8?q?=20=EB=B3=91=ED=95=A9=20=EA=B2=BD=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/git/gitMergeSignalCollector.ts | 179 +++++++++------------- src/workflows/mergeRiskWatch.ts | 24 ++- tests/git/gitMergeSignalCollector.test.ts | 68 ++++++++ tests/workflows/mergeRiskWatch.test.ts | 65 ++++++++ 4 files changed, 229 insertions(+), 107 deletions(-) diff --git a/src/git/gitMergeSignalCollector.ts b/src/git/gitMergeSignalCollector.ts index 7d17afd..b2c5e56 100644 --- a/src/git/gitMergeSignalCollector.ts +++ b/src/git/gitMergeSignalCollector.ts @@ -1,10 +1,13 @@ import { execFile } from "node:child_process" -import { mkdtemp, rm } from "node:fs/promises" -import { tmpdir } from "node:os" -import { join } from "node:path" import { promisify } from "node:util" +import { build as buildBranchComparisonPairs } from "../branches/branchPairBuilder.js" import type { BranchContext } from "../branches/types.js" -import type { GitMergeSignal, GitMergeSignalCollectionOptions } from "./types.js" +import { collect as collectGitMergeTreeResults } from "./gitMergeTreeCollector.js" +import type { + GitMergeSignal, + GitMergeSignalCollectionOptions, + GitMergeTreePairResult +} from "./types.js" const execFileAsync = promisify(execFile) @@ -13,6 +16,41 @@ export async function collectGitMergeSignal( branch: BranchContext, options: GitMergeSignalCollectionOptions ): Promise { + const pair = buildBranchComparisonPairs(branch.baseBranch, [branch])[0] + + if (!pair) { + return failedSignal(branch, "branch comparison pair is missing") + } + + const results = await collectGitMergeTreeResults([pair], { + repositoryPath: options.repositoryPath, + remoteName: options.remoteName + }) + + return collectGitMergeSignalFromPairResult(branch, results[0], options) +} + +// 미리 수집한 base 포함 pair 결과를 기존 branch 단위 GitMergeSignal로 변환 +export async function collectGitMergeSignalFromPairResult( + branch: BranchContext, + pairResult: GitMergeTreePairResult | undefined, + options: GitMergeSignalCollectionOptions +): Promise { + if (!pairResult) { + return failedSignal(branch, "merge-tree result is missing for branch pair") + } + + if (!matches(branch, pairResult)) { + return failedSignal(branch, "merge-tree result does not match branch pair") + } + + if (pairResult.failureStage === "preparation") { + return failedSignal( + branch, + pairResult.errorMessage ?? "merge-tree preparation failed" + ) + } + const remote = options.remoteName ?? "origin" const baseRef = `refs/remotes/${remote}/${branch.baseBranch}` const headRef = `refs/remotes/${remote}/${branch.name}` @@ -20,10 +58,6 @@ export async function collectGitMergeSignal( let changedFiles: string[] = [] try { - // remote tracking ref 기준으로 base/head를 맞춘 뒤 merge signal을 계산 - await fetchBranch(options.repositoryPath, remote, branch.baseBranch) - await fetchBranch(options.repositoryPath, remote, branch.name) - mergeBaseSha = await gitOutput(options.repositoryPath, [ "merge-base", baseRef, @@ -36,14 +70,17 @@ export async function collectGitMergeSignal( headRef ]) - return await runVirtualMerge({ - branch, - options, - baseRef, - headRef, + return { + status: pairResult.status, + baseBranch: branch.baseBranch, + branchName: branch.name, mergeBaseSha, - changedFiles - }) + changedFiles, + conflictFiles: pairResult.conflictFiles, + ...(pairResult.errorMessage + ? { errorMessage: pairResult.errorMessage } + : {}) + } } catch (error) { return { status: "merge_check_failed", @@ -52,103 +89,35 @@ export async function collectGitMergeSignal( mergeBaseSha, changedFiles, conflictFiles: [], - errorMessage: formatGitError(error) + errorMessage: pairResult.status === "merge_check_failed" + ? pairResult.errorMessage ?? formatGitError(error) + : formatGitError(error) } } } -// 임시 worktree에서 merge를 시도해 repository 변경 없이 충돌 여부를 확인 -async function runVirtualMerge(input: { - branch: BranchContext - options: GitMergeSignalCollectionOptions - baseRef: string - headRef: string - mergeBaseSha: string - changedFiles: string[] -}): Promise { - // 실제 repository 상태를 더럽히지 않도록 임시 worktree에서 virtual merge 수행 - const root = await mkdtemp(join(input.options.worktreeRoot ?? tmpdir(), "watcher-merge-")) - const worktree = join(root, "worktree") - - try { - await gitOutput(input.options.repositoryPath, [ - "worktree", - "add", - "--detach", - worktree, - input.baseRef - ]) +// pair 결과가 기존 base와 branch 관계를 나타내는지 확인 +function matches(branch: BranchContext, result: GitMergeTreePairResult): boolean { + const names = new Set([ + result.pair.leftBranchName, + result.pair.rightBranchName + ]) - try { - const merge = await gitResult(worktree, [ - "merge", - "--no-commit", - "--no-ff", - input.headRef - ]) - - if (merge.exitCode === 0) { - return { - status: "clean", - baseBranch: input.branch.baseBranch, - branchName: input.branch.name, - mergeBaseSha: input.mergeBaseSha, - changedFiles: input.changedFiles, - conflictFiles: [] - } - } - - const conflictFiles = await gitLines(worktree, [ - "diff", - "--name-only", - "--diff-filter=U" - ]) - - if (0 < conflictFiles.length) { - return { - status: "confirmed_conflict", - baseBranch: input.branch.baseBranch, - branchName: input.branch.name, - mergeBaseSha: input.mergeBaseSha, - changedFiles: input.changedFiles, - conflictFiles - } - } - - return { - status: "merge_check_failed", - baseBranch: input.branch.baseBranch, - branchName: input.branch.name, - mergeBaseSha: input.mergeBaseSha, - changedFiles: input.changedFiles, - conflictFiles: [], - errorMessage: formatGitError(merge.stderr) - } - } finally { - await gitResult(input.options.repositoryPath, [ - "worktree", - "remove", - "--force", - worktree - ]) - } - } finally { - await rm(root, { recursive: true, force: true }) - } + return names.size === 2 && + names.has(branch.baseBranch) && + names.has(branch.name) } -// 지정 branch의 최신 remote ref를 로컬 remote tracking ref로 갱신 -async function fetchBranch( - repositoryPath: string, - remote: string, - branchName: string -): Promise { - await gitOutput(repositoryPath, [ - "fetch", - "--quiet", - remote, - `+refs/heads/${branchName}:refs/remotes/${remote}/${branchName}` - ]) +// pair를 구성하거나 찾지 못한 branch를 기존 실패 signal로 표현 +function failedSignal(branch: BranchContext, errorMessage: string): GitMergeSignal { + return { + status: "merge_check_failed", + baseBranch: branch.baseBranch, + branchName: branch.name, + changedFiles: [], + conflictFiles: [], + errorMessage + } } // git stdout을 비어 있지 않은 line 목록으로 변환 diff --git a/src/workflows/mergeRiskWatch.ts b/src/workflows/mergeRiskWatch.ts index 395533c..ef1239d 100644 --- a/src/workflows/mergeRiskWatch.ts +++ b/src/workflows/mergeRiskWatch.ts @@ -4,7 +4,8 @@ import { resolve } from "node:path" import { promisify } from "node:util" import { build as buildBranchComparisonPairs } from "../branches/branchPairBuilder.js" import { selectWithReasons as selectBranchesWithReasons } from "../branches/branchSelector.js" -import { collectGitMergeSignal } from "../git/gitMergeSignalCollector.js" +import { collect as collectGitMergeTreeResults } from "../git/gitMergeTreeCollector.js" +import { collectGitMergeSignalFromPairResult } from "../git/gitMergeSignalCollector.js" import { analyze as analyzeBranchMergeRisks } from "../risks/riskAnalyzer.js" import { build as buildAiPredictionEvidencePayload } from "../ai/evidenceBuilder.js" import { createDefaultAiPredictionClient } from "../ai/openAiPredictionClient.js" @@ -127,6 +128,14 @@ export async function run(options: MergeRiskWatchOptions): Promise { }, generatedAt) const branches = branchSelection.selected const pairs = buildBranchComparisonPairs(options.baseBranch, branches) + const pairResults = await collectGitMergeTreeResults(pairs, { + repositoryPath: options.repositoryPath, + remoteName: options.remoteName + }) + const pairResultByKey = new Map(pairResults.map(result => [ + branchPairKey(result.pair.leftBranchName, result.pair.rightBranchName), + result + ])) const inputs: BranchRiskAnalysisInput[] = [] await debugArtifactWriter?.writeJson("branch-selection.json", { @@ -139,7 +148,11 @@ export async function run(options: MergeRiskWatchOptions): Promise { }) for (const branch of branches) { - const gitSignal = await collectGitMergeSignal(branch, { + const pairResult = pairResultByKey.get(branchPairKey( + options.baseBranch, + branch.name + )) + const gitSignal = await collectGitMergeSignalFromPairResult(branch, pairResult, { repositoryPath: options.repositoryPath, remoteName: options.remoteName }) @@ -223,6 +236,13 @@ export async function run(options: MergeRiskWatchOptions): Promise { } } +// 방향과 무관하게 base 포함 pair 결과를 찾을 식별자를 구성 +function branchPairKey(branchName: string, otherBranchName: string): string { + return branchName < otherBranchName + ? `${branchName}\u0000${otherBranchName}` + : `${otherBranchName}\u0000${branchName}` +} + // AI 대상 선택 artifact에 기록할 branch 식별 정보만 추출 function aiDebugTargetFor(payload: AiPredictionEvidencePayload): { branchName: string diff --git a/tests/git/gitMergeSignalCollector.test.ts b/tests/git/gitMergeSignalCollector.test.ts index a5054ec..7a0fe1e 100644 --- a/tests/git/gitMergeSignalCollector.test.ts +++ b/tests/git/gitMergeSignalCollector.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { promisify } from "node:util" import { collectGitMergeSignal, type BranchContext } from "../../src/index.js" +import { collectGitMergeSignalFromPairResult } from "../../src/git/gitMergeSignalCollector.js" const execFileAsync = promisify(execFile) @@ -68,6 +69,73 @@ test("collects merge check failure signal", async () => { } }) +// base branch가 조합의 오른쪽에 있어도 기존 branch signal로 변환하는지 확인 +test("converts a base pair result independent of pair direction", async () => { + const fixture = await createGitFixture() + + try { + const signal = await collectGitMergeSignalFromPairResult( + branch("main", "feature/conflict"), + { + pair: { + leftBranchName: "feature/conflict", + rightBranchName: "main" + }, + status: "confirmed_conflict", + mergedTreeOid: "1".repeat(40), + conflictFiles: ["shared.txt"], + conflicts: [{ + paths: ["shared.txt"], + type: "CONFLICT (contents)" + }] + }, + { + repositoryPath: fixture.repositoryPath, + worktreeRoot: fixture.worktreeRoot + } + ) + + assert.equal(signal.status, "confirmed_conflict") + assert.deepEqual(signal.changedFiles, ["shared.txt"]) + assert.deepEqual(signal.conflictFiles, ["shared.txt"]) + assert.deepEqual(await readdir(fixture.worktreeRoot), []) + } finally { + await fixture.remove() + } +}) + +// fetch와 ref 고정 실패에서는 오래된 ref의 변경 정보를 다시 사용하지 않는지 확인 +test("keeps changed files empty after merge-tree preparation failure", async () => { + const fixture = await createGitFixture() + + try { + const signal = await collectGitMergeSignalFromPairResult( + branch("main", "feature/conflict"), + { + pair: { + leftBranchName: "feature/conflict", + rightBranchName: "main" + }, + status: "merge_check_failed", + conflictFiles: [], + conflicts: [], + errorMessage: "git fetch failed for remote origin", + failureStage: "preparation" + }, + { + repositoryPath: fixture.repositoryPath + } + ) + + assert.equal(signal.status, "merge_check_failed") + assert.equal(signal.mergeBaseSha, undefined) + assert.deepEqual(signal.changedFiles, []) + assert.deepEqual(signal.conflictFiles, []) + } finally { + await fixture.remove() + } +}) + async function createGitFixture(): Promise<{ repositoryPath: string worktreeRoot: string diff --git a/tests/workflows/mergeRiskWatch.test.ts b/tests/workflows/mergeRiskWatch.test.ts index 39e18f3..1ebdb9b 100644 --- a/tests/workflows/mergeRiskWatch.test.ts +++ b/tests/workflows/mergeRiskWatch.test.ts @@ -203,6 +203,14 @@ test("writes merge risk debug artifacts", async () => { leftBranchName: "feature/critical-peer", rightBranchName: "main" }]) + assert.equal(await git(fixture.repositoryPath, ["status", "--porcelain=v1"]), "") + assert.equal( + (await git(fixture.repositoryPath, ["worktree", "list", "--porcelain"])) + .split("\n") + .filter(line => line.startsWith("worktree ")) + .length, + 1 + ) assert.doesNotMatch(combinedArtifact, /openai-secret/) assert.doesNotMatch(combinedArtifact, /webhook-secret/) assert.doesNotMatch(combinedArtifact, /feature critical content/) @@ -215,6 +223,63 @@ test("writes merge risk debug artifacts", async () => { } }) +// fetch 실패 시 오래된 remote ref에서 changed file과 hunk를 다시 만들지 않는지 확인 +test("keeps deterministic evidence empty after pair fetch failure", async () => { + const fixture = await createWorkflowGitFixture() + const originalOpenAiApiKey = process.env.OPENAI_API_KEY + const originalDiscordWebhookUrl = process.env.DISCORD_WEBHOOK_URL + const originalFetch = globalThis.fetch + + process.env.OPENAI_API_KEY = "openai-secret" + process.env.DISCORD_WEBHOOK_URL = "https://discord.test/webhook-secret" + globalThis.fetch = async input => { + assert.equal(new Request(input).url, "https://discord.test/webhook-secret") + return new Response(null, { status: 204 }) + } + + try { + await git(fixture.repositoryPath, [ + "remote", + "set-url", + "origin", + join(fixture.repositoryPath, "missing-remote.git") + ]) + await run({ + repository: "opficdev/Watcher", + repositoryPath: fixture.repositoryPath, + baseBranch: "main", + criticalFilePatterns: [], + remoteName: "origin", + githubApiUrl: "https://api.github.test", + debugArtifactDir: fixture.debugArtifactDir + }) + + const artifact = await readJson<{ + inputs?: Array<{ + gitSignal?: { + status?: string + mergeBaseSha?: string + changedFiles?: string[] + } + changedHunks?: unknown[] + }> + }>(fixture.debugArtifactDir, "deterministic-evidence.json") + + assert.equal(artifact.inputs?.length, 2) + assert.equal(artifact.inputs?.every(input => + input.gitSignal?.status === "merge_check_failed" && + input.gitSignal.mergeBaseSha === undefined && + input.gitSignal.changedFiles?.length === 0 && + input.changedHunks?.length === 0 + ), true) + } finally { + globalThis.fetch = originalFetch + restoreEnv("OPENAI_API_KEY", originalOpenAiApiKey) + restoreEnv("DISCORD_WEBHOOK_URL", originalDiscordWebhookUrl) + await fixture.remove() + } +}) + // 필수 환경 변수가 없으면 실행 전에 명확한 오류로 실패하는지 확인 test("throws when required environment is missing", () => { assert.throws(