diff --git a/.gitignore b/.gitignore index 52d2845..1362dea 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ dist/ # macOS .DS_Store + +# AI +superpowers/ diff --git a/src/git/gitCleanCodeContextCollector.ts b/src/git/gitCleanCodeContextCollector.ts new file mode 100644 index 0000000..fe9f368 --- /dev/null +++ b/src/git/gitCleanCodeContextCollector.ts @@ -0,0 +1,178 @@ +import { + collectPreparedEvidence, + type PreparedCodeContextEvidence +} from "./gitCodeContextEvidenceBuilder.js" +import { + changedFiles, + diffHunks, + pairCodeContext +} from "./gitCodeContextReader.js" +import { + boundedContextRange, + functionRangeFor, + newRange, + oldRange, + overlapRegions, + unionRange +} from "./mergeCodeContextParser.js" +import type { + GitDiffHunk, + GitMergeTreePairResult, + MergeCodeContextEvidence, + MergeCodeContextLineRange +} from "./types.js" + +// clean 조합에서 양쪽 exact hunk가 merge-base 좌표로 겹치는 문맥만 수집 +export async function collectCleanOverlapEvidence( + mergeResult: GitMergeTreePairResult, + pairIndex: number, + repositoryPath: string +): Promise { + const context = await pairCodeContext(mergeResult, repositoryPath) + const [leftFiles, rightFiles] = await Promise.all([ + changedFiles(repositoryPath, context.mergeBaseOid, context.leftOid), + changedFiles(repositoryPath, context.mergeBaseOid, context.rightOid) + ]) + const rightFileSet = new Set(rightFiles) + const commonFiles = leftFiles + .filter(filePath => rightFileSet.has(filePath)) + .sort(compareText) + const prepared: PreparedCodeContextEvidence[] = [] + + for (const [fileIndex, filePath] of commonFiles.entries()) { + const [leftHunks, rightHunks] = await Promise.all([ + diffHunks( + repositoryPath, + context.mergeBaseOid, + context.leftOid, + filePath, + "exact" + ), + diffHunks( + repositoryPath, + context.mergeBaseOid, + context.rightOid, + filePath, + "exact" + ) + ]) + const regions = overlapRegions(filePath, leftHunks, rightHunks) + + if (!regions.length) { + continue + } + + const [ + mergedHunks, + leftFunctionHunks, + rightFunctionHunks, + mergedFunctionHunks + ] = await Promise.all([ + diffHunks( + repositoryPath, + context.mergeBaseOid, + context.mergedTreeOid, + filePath, + "exact" + ), + diffHunks( + repositoryPath, + context.mergeBaseOid, + context.leftOid, + filePath, + "function" + ), + diffHunks( + repositoryPath, + context.mergeBaseOid, + context.rightOid, + filePath, + "function" + ), + diffHunks( + repositoryPath, + context.mergeBaseOid, + context.mergedTreeOid, + filePath, + "function" + ) + ]) + + for (const [regionIndex, region] of regions.entries()) { + const mergedTarget = mappedNewRangeFor( + mergedHunks, + region.baseRange + ) ?? unionRange(region.leftRange, region.rightRange) + const key = `${pairIndex}:${fileIndex}:${regionIndex}` + + prepared.push({ + filePath, + baseRange: boundedContextRange( + region.baseRange, + functionRangeFor( + [...leftFunctionHunks, ...rightFunctionHunks], + "old", + region.baseRange + ) + ), + leftRange: boundedContextRange( + region.leftRange, + functionRangeFor(leftFunctionHunks, "new", region.leftRange) + ), + rightRange: boundedContextRange( + region.rightRange, + functionRangeFor(rightFunctionHunks, "new", region.rightRange) + ), + mergedRange: boundedContextRange( + mergedTarget, + functionRangeFor(mergedFunctionHunks, "new", mergedTarget) + ), + baseKey: `${key}:base`, + leftKey: `${key}:left`, + rightKey: `${key}:right`, + mergedKey: `${key}:merged` + }) + } + } + + return collectPreparedEvidence( + mergeResult, + "clean_hunk_overlap", + context, + prepared, + repositoryPath + ) +} + +// base 좌표와 겹치는 merged hunk의 변경 후 좌표를 선택 +function mappedNewRangeFor( + hunks: GitDiffHunk[], + baseRange: MergeCodeContextLineRange +): MergeCodeContextLineRange | undefined { + return hunks + .map(hunk => ({ + oldRange: oldRange(hunk), + newRange: newRange(hunk) + })) + .filter(item => + item.oldRange !== undefined && + item.newRange !== undefined && + Math.max(item.oldRange.startLine, baseRange.startLine) <= + Math.min(item.oldRange.endLine, baseRange.endLine) + ) + .sort((item, other) => { + const range = item.oldRange! + const otherRange = other.oldRange! + + return range.endLine - range.startLine - + (otherRange.endLine - otherRange.startLine) + })[0]?.newRange +} + +function compareText(text: string, other: string): number { + if (text === other) { + return 0 + } + + return text < other ? -1 : 1 +} diff --git a/src/git/gitCodeContextEvidenceBuilder.ts b/src/git/gitCodeContextEvidenceBuilder.ts new file mode 100644 index 0000000..8decb42 --- /dev/null +++ b/src/git/gitCodeContextEvidenceBuilder.ts @@ -0,0 +1,156 @@ +import { readGitObjectSnippets } from "./gitObjectBatchReader.js" +import type { PairCodeContext } from "./gitCodeContextReader.js" +import type { + GitMergeTreePairResult, + GitObjectSnippetRequest, + MergeCodeContextEvidence, + MergeCodeContextKind, + MergeCodeContextLineRange, + MergeCodeContextSnippet +} from "./types.js" + +export type PreparedCodeContextEvidence = { + filePath: string + baseRange: MergeCodeContextLineRange + leftRange: MergeCodeContextLineRange + rightRange: MergeCodeContextLineRange + mergedRange: MergeCodeContextLineRange + baseKey: string + leftKey: string + rightKey: string + mergedKey: string +} + +// 준비된 네 version 요청을 evidence model로 변환 +export async function collectPreparedEvidence( + mergeResult: GitMergeTreePairResult, + kind: MergeCodeContextKind, + context: PairCodeContext, + prepared: PreparedCodeContextEvidence[], + repositoryPath: string +): Promise { + const snippets = await readGitObjectSnippets( + snippetRequests( + prepared, + context.mergeBaseOid, + context.leftOid, + context.rightOid, + context.mergedTreeOid + ), + { repositoryPath } + ) + + return prepared.map(item => { + const baseSnippet = requiredSnippet(snippets, item.baseKey) + const leftSnippet = deletedVersionSnippet( + baseSnippet, + requiredSnippet(snippets, item.leftKey) + ) + const rightSnippet = deletedVersionSnippet( + baseSnippet, + requiredSnippet(snippets, item.rightKey) + ) + const mergedSnippet = deletedMergedSnippet( + baseSnippet, + leftSnippet, + rightSnippet, + requiredSnippet(snippets, item.mergedKey) + ) + + return { + pair: mergeResult.pair, + kind, + filePath: item.filePath, + mergeBaseOid: context.mergeBaseOid, + mergedTreeOid: context.mergedTreeOid, + baseCommit: context.baseCommit, + leftCommit: context.leftCommit, + rightCommit: context.rightCommit, + baseSnippet, + leftSnippet, + rightSnippet, + mergedSnippet + } + }) +} + +// 준비된 evidence를 Git object batch 요청 네 개로 변환 +function snippetRequests( + prepared: PreparedCodeContextEvidence[], + mergeBaseOid: string, + leftOid: string, + rightOid: string, + mergedTreeOid: string +): GitObjectSnippetRequest[] { + return prepared.flatMap(item => [{ + key: item.baseKey, + objectOid: mergeBaseOid, + filePath: item.filePath, + range: item.baseRange + }, { + key: item.leftKey, + objectOid: leftOid, + filePath: item.filePath, + range: item.leftRange + }, { + key: item.rightKey, + objectOid: rightOid, + filePath: item.filePath, + range: item.rightRange + }, { + key: item.mergedKey, + objectOid: mergedTreeOid, + filePath: item.filePath, + range: item.mergedRange + }]) +} + +function requiredSnippet( + snippets: ReadonlyMap, + key: string +): MergeCodeContextSnippet { + const snippet = snippets.get(key) + + if (!snippet) { + throw new Error(`Missing Git object snippet ${key}`) + } + + return snippet +} + +// base에 존재하던 file이 side object에 없으면 deleted로 분류 +function deletedVersionSnippet( + baseSnippet: MergeCodeContextSnippet, + snippet: MergeCodeContextSnippet +): MergeCodeContextSnippet { + if (snippet.status !== "missing" || baseSnippet.status === "missing") { + return snippet + } + + return { + ...snippet, + status: "deleted" + } +} + +// 어느 version에든 존재한 file이 merge tree에서 없으면 deleted로 분류 +function deletedMergedSnippet( + baseSnippet: MergeCodeContextSnippet, + leftSnippet: MergeCodeContextSnippet, + rightSnippet: MergeCodeContextSnippet, + mergedSnippet: MergeCodeContextSnippet +): MergeCodeContextSnippet { + if ( + mergedSnippet.status !== "missing" || + [baseSnippet, leftSnippet, rightSnippet].every( + snippet => snippet.status === "missing" + ) + ) { + return mergedSnippet + } + + return { + ...mergedSnippet, + status: "deleted" + } +} diff --git a/src/git/gitCodeContextReader.ts b/src/git/gitCodeContextReader.ts new file mode 100644 index 0000000..e796128 --- /dev/null +++ b/src/git/gitCodeContextReader.ts @@ -0,0 +1,356 @@ +import { execFile, spawn } from "node:child_process" +import { parseDiffHunks } from "./mergeCodeContextParser.js" +import type { + GitDiffHunk, + GitMergeTreePairResult, + MergeCodeContextCommitMetadata +} from "./types.js" + +const STDERR_LIMIT = 16 * 1024 +const SMALL_GIT_OUTPUT_LIMIT = 64 * 1024 +const PATH_GIT_OUTPUT_LIMIT = 16 * 1024 * 1024 +const DIFF_LINE_PREFIX_LIMIT = 4 * 1024 + +export type PairCodeContext = { + leftOid: string + rightOid: string + mergedTreeOid: string + mergeBaseOid: string + baseCommit: MergeCodeContextCommitMetadata + leftCommit: MergeCodeContextCommitMetadata + rightCommit: MergeCodeContextCommitMetadata +} + +// 조합 OID와 commit metadata를 한 번만 고정 +export async function pairCodeContext( + mergeResult: GitMergeTreePairResult, + repositoryPath: string +): Promise { + const leftOid = requiredOid(mergeResult.leftCommitOid, "left commit") + const rightOid = requiredOid(mergeResult.rightCommitOid, "right commit") + const mergedTreeOid = requiredOid(mergeResult.mergedTreeOid, "merged tree") + const mergeBaseOid = requiredOid( + await executeGitText( + repositoryPath, + ["merge-base", leftOid, rightOid], + "merge-base" + ), + "merge base" + ) + const [baseCommit, leftCommit, rightCommit] = await Promise.all([ + commitMetadata(repositoryPath, mergeBaseOid, "base"), + commitMetadata( + repositoryPath, + leftOid, + "left", + mergeResult.pair.leftBranchName + ), + commitMetadata( + repositoryPath, + rightOid, + "right", + mergeResult.pair.rightBranchName + ) + ]) + + return { + leftOid, + rightOid, + mergedTreeOid, + mergeBaseOid, + baseCommit, + leftCommit, + rightCommit + } +} + +// base와 한쪽 commit 사이에서 변경된 file path를 NUL 구분으로 조회 +export async function changedFiles( + repositoryPath: string, + baseOid: string, + targetOid: string +): Promise { + const output = await executeGitBuffer(repositoryPath, [ + "diff", + "--name-only", + "-z", + "--no-ext-diff", + "--no-textconv", + "--no-renames", + "--no-color", + baseOid, + targetOid + ], "diff names") + + if (!output.length) { + return [] + } + + if (output[output.length - 1] !== 0) { + throw new Error("Incomplete Git changed file response") + } + + return output.subarray(0, -1).toString("utf8").split("\u0000") +} + +// raw diff 본문은 버리고 unified hunk header만 제한된 memory로 수집 +export async function diffHunks( + repositoryPath: string, + baseOid: string, + targetOid: string, + filePath: string, + context: "exact" | "function" +): Promise { + return new Promise((resolve, reject) => { + const child = spawn("git", [ + "diff", + "--no-ext-diff", + "--no-textconv", + "--no-color", + "--unified=0", + ...(context === "function" ? ["--function-context"] : []), + baseOid, + targetOid, + "--", + filePath + ], { + cwd: repositoryPath, + env: readOnlyGitEnvironment(), + stdio: ["ignore", "pipe", "pipe"] + }) + const collector = new GitDiffHunkHeaderCollector() + const stderrChunks: Buffer[] = [] + let stderrLength = 0 + let outputError: unknown + let settled = false + + child.stdout.on("data", (chunk: Buffer) => { + if (outputError) { + return + } + + try { + collector.push(chunk) + } catch (error) { + outputError = error + } + }) + child.stderr.on("data", (chunk: Buffer) => { + const remaining = STDERR_LIMIT - stderrLength + + if (remaining <= 0) { + return + } + + const captured = chunk.subarray(0, remaining) + + stderrChunks.push(captured) + stderrLength += captured.length + }) + child.on("error", error => { + if (!settled) { + settled = true + reject(error) + } + }) + child.on("close", (code, signal) => { + if (settled) { + return + } + + settled = true + + if (code !== 0) { + reject(gitFailure( + "diff", + code, + signal, + Buffer.concat(stderrChunks, stderrLength).toString("utf8").trim() + )) + return + } + + if (outputError) { + reject(errorFor(outputError)) + return + } + + resolve(collector.finish()) + }) + }) +} + +// commit 한 건의 고정 metadata를 object OID 기준으로 조회 +async function commitMetadata( + repositoryPath: string, + oid: string, + role: "base" | "left" | "right", + branchName?: string +): Promise { + const output = await executeGitText(repositoryPath, [ + "show", + "-s", + "--format=%H%x00%an <%ae>%x00%cI%x00%s", + oid + ], "show metadata") + const [resolvedOid, author, committedAt, subject, ...remaining] = output.split("\u0000") + + if (!resolvedOid || !author || !committedAt || subject === undefined || remaining.length) { + throw new Error("Invalid Git commit metadata response") + } + + return { + role, + ...(branchName ? { branchName } : {}), + oid: resolvedOid, + author, + committedAt, + subject + } +} + +// 긴 변경 line을 보관하지 않고 hunk header 접두사만 해석 +class GitDiffHunkHeaderCollector { + private readonly hunks: GitDiffHunk[] = [] + private lineChunks: Buffer[] = [] + private lineLength = 0 + + push(chunk: Buffer): void { + let offset = 0 + let newline = chunk.indexOf(10, offset) + + while (newline >= 0) { + this.pushLineSegment(chunk.subarray(offset, newline)) + this.finishLine() + offset = newline + 1 + newline = chunk.indexOf(10, offset) + } + + if (offset < chunk.length) { + this.pushLineSegment(chunk.subarray(offset)) + } + } + + finish(): GitDiffHunk[] { + if (this.lineLength) { + this.finishLine() + } + + return [...this.hunks] + } + + private pushLineSegment(segment: Buffer): void { + const remaining = DIFF_LINE_PREFIX_LIMIT - this.lineLength + + if (remaining <= 0) { + return + } + + const captured = segment.subarray(0, remaining) + + this.lineChunks.push(captured) + this.lineLength += captured.length + } + + private finishLine(): void { + if (this.lineLength) { + this.hunks.push(...parseDiffHunks( + Buffer.concat(this.lineChunks, this.lineLength).toString("utf8") + )) + } + + this.lineChunks = [] + this.lineLength = 0 + } +} + +// 작은 Git text 응답을 제한된 buffer로 조회 +async function executeGitText( + repositoryPath: string, + args: string[], + operation: string +): Promise { + return new Promise((resolve, reject) => { + execFile("git", args, { + cwd: repositoryPath, + env: readOnlyGitEnvironment(), + maxBuffer: SMALL_GIT_OUTPUT_LIMIT + }, (error, stdout, stderr) => { + if (error) { + reject(gitFailure( + operation, + typeof error.code === "number" ? error.code : null, + error.signal ?? null, + stderr.toString().slice(0, STDERR_LIMIT).trim() + )) + return + } + + resolve(stdout.toString().trimEnd()) + }) + }) +} + +// path와 같은 NUL 포함 Git 응답을 제한된 buffer로 조회 +async function executeGitBuffer( + repositoryPath: string, + args: string[], + operation: string +): Promise { + return new Promise((resolve, reject) => { + execFile("git", args, { + cwd: repositoryPath, + encoding: "buffer", + env: readOnlyGitEnvironment(), + maxBuffer: PATH_GIT_OUTPUT_LIMIT + }, (error, stdout, stderr) => { + if (error) { + reject(gitFailure( + operation, + typeof error.code === "number" ? error.code : null, + error.signal ?? null, + stderr.toString().slice(0, STDERR_LIMIT).trim() + )) + return + } + + resolve(stdout) + }) + }) +} + +function readOnlyGitEnvironment(): NodeJS.ProcessEnv { + return { + ...process.env, + GIT_OPTIONAL_LOCKS: "0" + } +} + +function requiredOid(oid: string | undefined, role: string): string { + if (!oid || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(oid)) { + throw new Error(`Invalid ${role} OID`) + } + + return oid +} + +function gitFailure( + operation: string, + code: number | null, + signal: NodeJS.Signals | null, + stderr: string +): Error { + return new Error([ + `git ${operation} failed`, + code === null ? `signal ${signal ?? "unknown"}` : `exit code ${code}`, + stderr + ].filter(Boolean).join(": ")) +} + +function errorFor(error: unknown): Error { + if (error instanceof Error) { + return error + } + + return new Error(String(error)) +} diff --git a/src/git/gitConflictCodeContextCollector.ts b/src/git/gitConflictCodeContextCollector.ts new file mode 100644 index 0000000..5b303f3 --- /dev/null +++ b/src/git/gitConflictCodeContextCollector.ts @@ -0,0 +1,165 @@ +import { + collectPreparedEvidence, + type PreparedCodeContextEvidence +} from "./gitCodeContextEvidenceBuilder.js" +import { + diffHunks, + pairCodeContext +} from "./gitCodeContextReader.js" +import { readGitObjectConflictMarkerRanges } from "./gitObjectBatchReader.js" +import { + boundedContextRange, + functionRangeFor, + newRange, + oldRange, + overlapRegions, + unionRange +} from "./mergeCodeContextParser.js" +import type { + GitDiffHunk, + GitMergeTreePairResult, + MergeCodeContextEvidence, + MergeCodeContextRegion +} from "./types.js" + +// 한 conflict 조합의 merge base, metadata, hunk, 네 version을 조립 +export async function collectConflictEvidence( + mergeResult: GitMergeTreePairResult, + pairIndex: number, + repositoryPath: string +): Promise { + const context = await pairCodeContext(mergeResult, repositoryPath) + const prepared: PreparedCodeContextEvidence[] = [] + const markerKeys = mergeResult.conflictFiles.map((_, fileIndex) => + `${pairIndex}:${fileIndex}:markers` + ) + const markerRanges = await readGitObjectConflictMarkerRanges( + mergeResult.conflictFiles.map((filePath, fileIndex) => ({ + key: markerKeys[fileIndex]!, + objectOid: context.mergedTreeOid, + filePath, + range: { startLine: 1, endLine: 1 } + })), + { repositoryPath } + ) + + for (const [fileIndex, filePath] of mergeResult.conflictFiles.entries()) { + const [ + leftHunks, + rightHunks, + leftFunctionHunks, + rightFunctionHunks, + mergedFunctionHunks + ] = await Promise.all([ + diffHunks( + repositoryPath, + context.mergeBaseOid, + context.leftOid, + filePath, + "exact" + ), + diffHunks( + repositoryPath, + context.mergeBaseOid, + context.rightOid, + filePath, + "exact" + ), + diffHunks( + repositoryPath, + context.mergeBaseOid, + context.leftOid, + filePath, + "function" + ), + diffHunks( + repositoryPath, + context.mergeBaseOid, + context.rightOid, + filePath, + "function" + ), + diffHunks( + repositoryPath, + context.mergeBaseOid, + context.mergedTreeOid, + filePath, + "function" + ) + ]) + const regions = conflictRegions(filePath, leftHunks, rightHunks) + const markers = markerRanges.get(markerKeys[fileIndex]!) ?? [] + const evidenceCount = Math.max(regions.length, markers.length) + + for (let regionIndex = 0; regionIndex < evidenceCount; regionIndex += 1) { + const region = regions[Math.min(regionIndex, regions.length - 1)]! + const mergedTarget = markers[regionIndex] ?? unionRange( + region.leftRange, + region.rightRange + ) + const key = `${pairIndex}:${fileIndex}:${regionIndex}` + + prepared.push({ + filePath, + baseRange: boundedContextRange( + region.baseRange, + functionRangeFor( + [...leftFunctionHunks, ...rightFunctionHunks], + "old", + region.baseRange + ) + ), + leftRange: boundedContextRange( + region.leftRange, + functionRangeFor(leftFunctionHunks, "new", region.leftRange) + ), + rightRange: boundedContextRange( + region.rightRange, + functionRangeFor(rightFunctionHunks, "new", region.rightRange) + ), + mergedRange: boundedContextRange( + mergedTarget, + functionRangeFor(mergedFunctionHunks, "new", mergedTarget) + ), + baseKey: `${key}:base`, + leftKey: `${key}:left`, + rightKey: `${key}:right`, + mergedKey: `${key}:merged` + }) + } + } + + return collectPreparedEvidence( + mergeResult, + "confirmed_conflict", + context, + prepared, + repositoryPath + ) +} + +// merge-base 좌표가 겹치면 각 overlap을, 아니면 file별 fallback을 사용 +function conflictRegions( + filePath: string, + leftHunks: GitDiffHunk[], + rightHunks: GitDiffHunk[] +): MergeCodeContextRegion[] { + const overlaps = overlapRegions(filePath, leftHunks, rightHunks) + + if (overlaps.length) { + return overlaps + } + + const left = leftHunks[0] + const right = rightHunks[0] + + return [{ + filePath, + baseRange: unionRange( + oldRange(left) ?? { startLine: 1, endLine: 1 }, + oldRange(right) ?? { startLine: 1, endLine: 1 } + ), + leftRange: newRange(left) ?? { startLine: 1, endLine: 1 }, + rightRange: newRange(right) ?? { startLine: 1, endLine: 1 } + }] +} diff --git a/src/git/gitMergeCodeContextCollector.ts b/src/git/gitMergeCodeContextCollector.ts new file mode 100644 index 0000000..60d325b --- /dev/null +++ b/src/git/gitMergeCodeContextCollector.ts @@ -0,0 +1,84 @@ +import { collectCleanOverlapEvidence } from "./gitCleanCodeContextCollector.js" +import { collectConflictEvidence } from "./gitConflictCodeContextCollector.js" +import type { + GitMergeCodeContextPairResult, + GitMergeTreePairResult +} from "./types.js" + +const STDERR_LIMIT = 16 * 1024 + +// merge-tree 결과 순서를 보존하며 conflict와 clean overlap 코드 문맥을 수집 +export async function collect( + mergeResults: GitMergeTreePairResult[], + options: { repositoryPath: string } +): Promise { + const results: GitMergeCodeContextPairResult[] = [] + + for (const [pairIndex, mergeResult] of mergeResults.entries()) { + if (mergeResult.status === "merge_check_failed") { + results.push({ + pair: mergeResult.pair, + evidence: [] + }) + continue + } + + try { + results.push({ + pair: mergeResult.pair, + evidence: mergeResult.status === "confirmed_conflict" + ? await collectConflictEvidence( + mergeResult, + pairIndex, + options.repositoryPath + ) + : await collectCleanOverlapEvidence( + mergeResult, + pairIndex, + options.repositoryPath + ) + }) + } catch (error) { + results.push({ + pair: mergeResult.pair, + evidence: [], + errorMessage: diagnosticFor(error) + }) + } + } + + return results +} + +function diagnosticFor(error: unknown): string { + return utf8Prefix( + `Git code context collection failed: ${errorFor(error).message}`, + STDERR_LIMIT + ) +} + +function utf8Prefix(content: string, maximumBytes: number): string { + let byteLength = 0 + let endIndex = 0 + + for (const character of content) { + const characterLength = Buffer.byteLength(character) + + if (maximumBytes < byteLength + characterLength) { + break + } + + byteLength += characterLength + endIndex += character.length + } + + return content.slice(0, endIndex) +} + +function errorFor(error: unknown): Error { + if (error instanceof Error) { + return error + } + + return new Error(String(error)) +} diff --git a/src/git/gitMergeTreeRoundCollector.ts b/src/git/gitMergeTreeRoundCollector.ts index cd7c0bf..2e09c79 100644 --- a/src/git/gitMergeTreeRoundCollector.ts +++ b/src/git/gitMergeTreeRoundCollector.ts @@ -17,9 +17,13 @@ export async function collect( return [] } - const input = `${round.pairs.map(pair => [ - oidFor(pair.leftBranchName, options.commitOidByBranch), - oidFor(pair.rightBranchName, options.commitOidByBranch) + const oidsByPair = round.pairs.map(pair => ({ + left: oidFor(pair.leftBranchName, options.commitOidByBranch), + right: oidFor(pair.rightBranchName, options.commitOidByBranch) + })) + const input = `${oidsByPair.map(oids => [ + oids.left, + oids.right ].join(" ")).join("\n")}\n` const parser = new MergeTreeOutputParser(round.pairs) const stderrLimit = options.stderrLimit ?? DEFAULT_STDERR_LIMIT @@ -100,7 +104,19 @@ export async function collect( } try { - resolve(parser.finish()) + resolve(parser.finish().map((result, index) => { + const oids = oidsByPair[index] + + if (!oids) { + throw new Error(`Missing commit OID snapshot for pair ${index}`) + } + + return { + ...result, + leftCommitOid: oids.left, + rightCommitOid: oids.right + } + })) } catch (error) { reject(errorFor(error)) } diff --git a/src/git/gitObjectBatchProcess.ts b/src/git/gitObjectBatchProcess.ts new file mode 100644 index 0000000..320ef57 --- /dev/null +++ b/src/git/gitObjectBatchProcess.ts @@ -0,0 +1,352 @@ +import { spawn } from "node:child_process" +import type { GitObjectSnippetRequest } from "./types.js" + +const STDERR_LIMIT = 16 * 1024 +const CHECK_OUTPUT_LIMIT = 16 * 1024 * 1024 + +export type GitObjectRequestGroup = { + objectSpec: string + requests: GitObjectSnippetRequest[] +} + +export type GitObjectCheck = { + group: GitObjectRequestGroup + objectType?: string + byteLength?: number +} + +export type GitObjectContentConsumer = { + start(check: GitObjectCheck): void + push(content: Buffer): void + finish(): void +} + +type GitProcessOutputConsumer = { + push(content: Buffer): void + finish(): Result +} + +// object 존재 여부와 종류, 크기를 작은 응답으로 먼저 확인 +export async function checkObjects( + groups: GitObjectRequestGroup[], + repositoryPath: string +): Promise { + const output = await collectGitOutput( + ["cat-file", "--batch-check", "-Z"], + batchInput(groups), + repositoryPath + ) + const records = nulRecords(output) + + if (records.length !== groups.length) { + throw new Error("Unexpected git cat-file batch-check record count") + } + + return records.map((record, index) => { + const group = groups[index] + + if (!group) { + throw new Error(`Missing Git object request group ${index}`) + } + + if (record.endsWith(" missing")) { + return { group } + } + + const match = record.match(/^([0-9a-f]+) ([^ ]+) (\d+)$/) + + if (!match) { + throw new Error(`Unexpected git cat-file batch-check record ${index}`) + } + + return { + group, + objectType: match[2], + byteLength: Number(match[3]) + } + }) +} + +// 확인된 blob을 한 process에서 읽고 object별로 즉시 소비 +export async function readObjects( + checks: GitObjectCheck[], + repositoryPath: string, + consumer: GitObjectContentConsumer +): Promise { + if (!checks.length) { + return + } + + await runGitProcess( + ["cat-file", "--batch", "-Z"], + "batch", + batchInput(checks.map(check => check.group)), + repositoryPath, + new GitBatchContentParser(checks, consumer) + ) +} + +// NUL header와 고정 크기 content가 번갈아 오는 batch 응답을 해석 +class GitBatchContentParser { + private checkIndex = 0 + private headerChunks: Buffer[] = [] + private headerLength = 0 + private contentLength = 0 + private expectedContentLength: number | undefined + private expectsTerminator = false + + constructor( + private readonly checks: GitObjectCheck[], + private readonly consumer: GitObjectContentConsumer + ) {} + + push(chunk: Buffer): void { + let offset = 0 + + while (offset < chunk.length) { + if (this.expectsTerminator) { + if (chunk[offset] !== 0) { + throw new Error(`Missing git cat-file content terminator ${this.checkIndex}`) + } + + offset += 1 + this.finishContent() + continue + } + + if (this.expectedContentLength !== undefined) { + const remaining = this.expectedContentLength - this.contentLength + const length = Math.min(remaining, chunk.length - offset) + + if (length) { + this.consumer.push(chunk.subarray(offset, offset + length)) + this.contentLength += length + offset += length + } + + if (this.contentLength === this.expectedContentLength) { + this.expectsTerminator = true + } + + continue + } + + const terminator = chunk.indexOf(0, offset) + + if (terminator < 0) { + this.headerChunks.push(chunk.subarray(offset)) + this.headerLength += chunk.length - offset + return + } + + this.headerChunks.push(chunk.subarray(offset, terminator)) + this.headerLength += terminator - offset + offset = terminator + 1 + this.finishHeader() + } + } + + finish(): void { + if ( + this.checkIndex !== this.checks.length || + this.headerLength || + this.expectedContentLength !== undefined || + this.expectsTerminator + ) { + throw new Error("Incomplete git cat-file batch response") + } + } + + private finishHeader(): void { + const check = this.checks[this.checkIndex] + const header = Buffer.concat(this.headerChunks, this.headerLength) + .toString("utf8") + const match = header.match(/^([0-9a-f]+) ([^ ]+) (\d+)$/) + + this.headerChunks = [] + this.headerLength = 0 + + if (!check || !match || match[2] !== "blob") { + throw new Error(`Unexpected git cat-file batch header ${this.checkIndex}`) + } + + const byteLength = Number(match[3]) + + if (byteLength !== check.byteLength) { + throw new Error(`Changed git cat-file object size ${this.checkIndex}`) + } + + this.expectedContentLength = byteLength + this.consumer.start(check) + + if (!byteLength) { + this.expectsTerminator = true + } + } + + private finishContent(): void { + if (!this.checks[this.checkIndex]) { + throw new Error(`Missing Git object check ${this.checkIndex}`) + } + + this.consumer.finish() + this.checkIndex += 1 + this.contentLength = 0 + this.expectedContentLength = undefined + this.expectsTerminator = false + } +} + +// NUL 구분 입력을 만들어 경로의 개행과 공백을 보존 +function batchInput(groups: GitObjectRequestGroup[]): Buffer { + return Buffer.from(`${groups.map(group => group.objectSpec).join("\u0000")}\u0000`) +} + +// Git process 생명주기와 공통 오류 우선순위를 관리 +async function runGitProcess( + args: string[], + operation: string, + input: Buffer, + repositoryPath: string, + outputConsumer: GitProcessOutputConsumer +): Promise { + return new Promise((resolve, reject) => { + const child = spawn("git", args, { + cwd: repositoryPath, + stdio: ["pipe", "pipe", "pipe"] + }) + const stderrChunks: Buffer[] = [] + let stderrLength = 0 + let outputError: unknown + let inputError: unknown + let settled = false + + child.stdout.on("data", (chunk: Buffer) => { + if (outputError) { + return + } + + try { + outputConsumer.push(chunk) + } catch (error) { + outputError = error + } + }) + child.stderr.on("data", (chunk: Buffer) => { + const remaining = STDERR_LIMIT - 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(gitError(operation, code, signal, stderr)) + return + } + + if (inputError) { + reject(errorFor(inputError)) + return + } + + if (outputError) { + reject(errorFor(outputError)) + return + } + + try { + resolve(outputConsumer.finish()) + } catch (error) { + reject(errorFor(error)) + } + }) + + child.stdin.end(input) + }) +} + +// batch-check의 작은 응답을 크기 제한 안에서 수집 +async function collectGitOutput( + args: string[], + input: Buffer, + repositoryPath: string +): Promise { + const stdoutChunks: Buffer[] = [] + let stdoutLength = 0 + + return runGitProcess(args, "batch-check", input, repositoryPath, { + push(chunk) { + const remaining = CHECK_OUTPUT_LIMIT - stdoutLength + + if (remaining <= 0) { + throw new Error("git cat-file batch-check output limit exceeded") + } + + const captured = chunk.subarray(0, remaining) + + stdoutChunks.push(captured) + stdoutLength += captured.length + + if (captured.length !== chunk.length) { + throw new Error("git cat-file batch-check output limit exceeded") + } + }, + finish() { + return Buffer.concat(stdoutChunks, stdoutLength) + } + }) +} + +// NUL로 끝나는 batch-check record를 순서대로 분리 +function nulRecords(output: Buffer): string[] { + if (!output.length || output[output.length - 1] !== 0) { + throw new Error("Incomplete git cat-file batch-check response") + } + + return output.subarray(0, -1).toString("utf8").split("\u0000") +} + +function gitError( + operation: string, + code: number | null, + signal: NodeJS.Signals | null, + stderr: string +): Error { + return new Error([ + `git cat-file ${operation} failed`, + code === null ? `signal ${signal ?? "unknown"}` : `exit code ${code}`, + stderr + ].filter(Boolean).join(": ")) +} + +function errorFor(error: unknown): Error { + if (error instanceof Error) { + return error + } + + return new Error(String(error)) +} diff --git a/src/git/gitObjectBatchReader.ts b/src/git/gitObjectBatchReader.ts new file mode 100644 index 0000000..3479f23 --- /dev/null +++ b/src/git/gitObjectBatchReader.ts @@ -0,0 +1,136 @@ +import { GitObjectConflictMarkerStream } from "./gitObjectConflictMarkerCollector.js" +import { + checkObjects, + readObjects +} from "./gitObjectBatchProcess.js" +import type { GitObjectRequestGroup } from "./gitObjectBatchProcess.js" +import { + GitObjectSnippetStream, + SNIPPET_MAX_LINES, + unavailableSnippet +} from "./gitObjectSnippetCollector.js" +import type { + GitObjectSnippetRequest, + MergeCodeContextLineRange, + MergeCodeContextSnippet +} from "./types.js" + +// 여러 Git object의 file 내용을 checkout 없이 묶음 조회 +export async function readGitObjectSnippets( + requests: GitObjectSnippetRequest[], + options: { repositoryPath: string } +): Promise> { + if (!requests.length) { + return new Map() + } + + validateRequests(requests) + const groups = groupRequests(requests) + const checks = await checkObjects(groups, options.repositoryPath) + const snippets = new Map() + const readable = checks.filter(check => check.objectType === "blob") + + for (const check of checks) { + if (check.objectType === "blob") { + continue + } + + for (const request of check.group.requests) { + snippets.set(request.key, unavailableSnippet(request)) + } + } + + await readObjects( + readable, + options.repositoryPath, + new GitObjectSnippetStream(snippets) + ) + + return snippets +} + +// merged object를 흘려 읽으며 conflict marker block의 line range만 수집 +export async function readGitObjectConflictMarkerRanges( + requests: GitObjectSnippetRequest[], + options: { repositoryPath: string } +): Promise> { + if (!requests.length) { + return new Map() + } + + validateRequests(requests) + const groups = groupRequests(requests) + const checks = await checkObjects(groups, options.repositoryPath) + const ranges = new Map() + const readable = checks.filter(check => check.objectType === "blob") + + for (const check of checks) { + if (check.objectType === "blob") { + continue + } + + for (const request of check.group.requests) { + ranges.set(request.key, []) + } + } + + await readObjects( + readable, + options.repositoryPath, + new GitObjectConflictMarkerStream(ranges) + ) + + return ranges +} + +// 요청 key, OID, line range가 묶음 protocol에 안전한지 확인 +function validateRequests(requests: GitObjectSnippetRequest[]): void { + const keys = new Set() + + for (const request of requests) { + if (keys.has(request.key)) { + throw new Error(`Duplicate Git object snippet key: ${request.key}`) + } + + if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(request.objectOid)) { + throw new Error(`Invalid Git object OID for key ${request.key}`) + } + + if (request.filePath.includes("\u0000")) { + throw new Error(`Invalid Git object path for key ${request.key}`) + } + + if ( + request.range.startLine < 1 || + request.range.endLine < request.range.startLine || + SNIPPET_MAX_LINES < request.range.endLine - request.range.startLine + 1 + ) { + throw new Error(`Invalid Git object line range for key ${request.key}`) + } + + keys.add(request.key) + } +} + +// 같은 object와 file 조합을 한 번만 읽도록 요청을 묶음 +function groupRequests( + requests: GitObjectSnippetRequest[] +): GitObjectRequestGroup[] { + const groupsBySpec = new Map() + + for (const request of requests) { + const objectSpec = `${request.objectOid}:${request.filePath}` + const group = groupsBySpec.get(objectSpec) + + if (group) { + group.requests.push(request) + } else { + groupsBySpec.set(objectSpec, { + objectSpec, + requests: [request] + }) + } + } + + return [...groupsBySpec.values()] +} diff --git a/src/git/gitObjectConflictMarkerCollector.ts b/src/git/gitObjectConflictMarkerCollector.ts new file mode 100644 index 0000000..4c03c43 --- /dev/null +++ b/src/git/gitObjectConflictMarkerCollector.ts @@ -0,0 +1,126 @@ +import type { + GitObjectCheck, + GitObjectContentConsumer +} from "./gitObjectBatchProcess.js" +import type { MergeCodeContextLineRange } from "./types.js" + +// object별 conflict marker 수집기를 전환하며 결과 map을 구성 +export class GitObjectConflictMarkerStream implements GitObjectContentConsumer { + private check: GitObjectCheck | undefined + private collector: GitObjectConflictMarkerCollector | undefined + + constructor( + private readonly ranges: Map + ) {} + + start(check: GitObjectCheck): void { + if (this.collector) { + throw new Error("Overlapping git cat-file conflict marker content") + } + + this.check = check + this.collector = new GitObjectConflictMarkerCollector() + } + + push(content: Buffer): void { + if (!this.collector) { + throw new Error("Missing git conflict marker collector") + } + + this.collector.push(content) + } + + finish(): void { + if (!this.check || !this.collector) { + throw new Error("Missing git conflict marker collector") + } + + const ranges = this.collector.finish() + + for (const request of this.check.group.requests) { + this.ranges.set(request.key, ranges) + } + + this.check = undefined + this.collector = undefined + } +} + +// raw blob에서 line 접두사만 보관해 완성된 conflict marker 범위를 계산 +class GitObjectConflictMarkerCollector { + private readonly ranges: MergeCodeContextLineRange[] = [] + private lineNumber = 1 + private linePrefix = Buffer.alloc(0) + private startLine: number | undefined + private binary = false + + push(content: Buffer): void { + if (this.binary) { + return + } + + if (content.includes(0)) { + this.binary = true + return + } + + let offset = 0 + let newline = content.indexOf(10, offset) + + while (newline >= 0) { + this.pushLinePrefix(content.subarray(offset, newline)) + this.finishLine() + offset = newline + 1 + newline = content.indexOf(10, offset) + } + + if (offset < content.length) { + this.pushLinePrefix(content.subarray(offset)) + } + } + + finish(): MergeCodeContextLineRange[] { + if (this.binary) { + return [] + } + + if (this.linePrefix.length) { + this.finishLine() + } + + return [...this.ranges] + } + + private pushLinePrefix(content: Buffer): void { + const remaining = 16 - this.linePrefix.length + + if (remaining <= 0) { + return + } + + this.linePrefix = Buffer.concat([ + this.linePrefix, + content.subarray(0, remaining) + ]) + } + + private finishLine(): void { + const prefix = this.linePrefix.toString("ascii") + + if (prefix.startsWith("<<<<<<< ")) { + this.startLine = this.lineNumber + } else if ( + this.startLine !== undefined && + prefix.startsWith(">>>>>>> ") + ) { + this.ranges.push({ + startLine: this.startLine, + endLine: this.lineNumber + }) + this.startLine = undefined + } + + this.lineNumber += 1 + this.linePrefix = Buffer.alloc(0) + } +} diff --git a/src/git/gitObjectSnippetCollector.ts b/src/git/gitObjectSnippetCollector.ts new file mode 100644 index 0000000..2fa4b21 --- /dev/null +++ b/src/git/gitObjectSnippetCollector.ts @@ -0,0 +1,371 @@ +import { TextDecoder } from "node:util" +import type { + GitObjectCheck, + GitObjectContentConsumer +} from "./gitObjectBatchProcess.js" +import type { + GitObjectSnippetRequest, + MergeCodeContextSnippet +} from "./types.js" + +const SNIPPET_MAX_BYTES = 32 * 1024 +export const SNIPPET_MAX_LINES = 400 + +// object별 streaming 수집기를 전환하며 결과 map을 구성 +export class GitObjectSnippetStream implements GitObjectContentConsumer { + private collector: GitObjectContentCollector | undefined + + constructor( + private readonly snippets: Map + ) {} + + start(check: GitObjectCheck): void { + if (this.collector) { + throw new Error("Overlapping git cat-file object content") + } + + this.collector = new GitObjectContentCollector(check) + } + + push(content: Buffer): void { + if (!this.collector) { + throw new Error("Missing git cat-file object collector") + } + + this.collector.push(content) + } + + finish(): void { + if (!this.collector) { + throw new Error("Missing git cat-file object collector") + } + + for (const [key, snippet] of this.collector.finish()) { + this.snippets.set(key, snippet) + } + + this.collector = undefined + } +} + +// 작은 object만 전체 보관하고 큰 object는 선택 line만 streaming 보관 +class GitObjectContentCollector { + private readonly byteLength: number + private readonly storesWholeContent: boolean + private readonly contentChunks: Buffer[] = [] + private readonly decoder = new TextDecoder("utf-8", { fatal: true }) + private readonly lineCollectors: GitObjectLineRangeCollector[] + private contentLength = 0 + private currentLine = 1 + private endedWithNewline = false + private binary = false + + constructor(private readonly check: GitObjectCheck) { + const byteLength = check.byteLength + + if (byteLength === undefined) { + throw new Error("Missing Git object byte length") + } + + this.byteLength = byteLength + this.storesWholeContent = byteLength <= SNIPPET_MAX_BYTES + this.lineCollectors = check.group.requests.map(request => + new GitObjectLineRangeCollector(request) + ) + } + + push(content: Buffer): void { + if (this.storesWholeContent) { + this.contentChunks.push(content) + this.contentLength += content.length + return + } + + if (this.binary) { + return + } + + if (content.includes(0)) { + this.binary = true + return + } + + try { + this.pushDecoded(this.decoder.decode(content, { stream: true })) + } catch { + this.binary = true + } + } + + finish(): Array<[string, MergeCodeContextSnippet]> { + if (this.storesWholeContent) { + return this.finishWholeContent() + } + + if (!this.binary) { + try { + this.pushDecoded(this.decoder.decode()) + } catch { + this.binary = true + } + } + + if (this.binary) { + return this.check.group.requests.map(request => [ + request.key, + binarySnippet(request) + ]) + } + + const lineCount = this.byteLength === 0 + ? 1 + : this.endedWithNewline + ? Math.max(1, this.currentLine - 1) + : this.currentLine + + return this.lineCollectors.map(collector => [ + collector.request.key, + collector.snippet(lineCount) + ]) + } + + private finishWholeContent(): Array<[string, MergeCodeContextSnippet]> { + const content = Buffer.concat(this.contentChunks, this.contentLength) + const decoded = decodeText(content) + + if (decoded === undefined) { + return this.check.group.requests.map(request => [ + request.key, + binarySnippet(request) + ]) + } + + const text = splitText(decoded) + + return this.check.group.requests.map(request => [ + request.key, + wholeTextSnippet(request, decoded, text) + ]) + } + + private pushDecoded(content: string): void { + let offset = 0 + + while (offset < content.length) { + const newline = content.indexOf("\n", offset) + + if (newline < 0) { + this.pushLineContent(content.slice(offset)) + this.endedWithNewline = false + return + } + + this.pushLineContent(content.slice(offset, newline)) + this.currentLine += 1 + this.endedWithNewline = true + offset = newline + 1 + } + } + + private pushLineContent(content: string): void { + for (const collector of this.lineCollectors) { + collector.push(this.currentLine, content) + } + } +} + +// 한 요청의 line range만 UTF-8 byte 상한 안에서 누적 +class GitObjectLineRangeCollector { + readonly request: GitObjectSnippetRequest + private readonly contentChunks: string[] = [] + private contentLength = 0 + private firstLine: number | undefined + private lastLine: number | undefined + private activeLine: number | undefined + private byteTruncated = false + + constructor(request: GitObjectSnippetRequest) { + this.request = request + } + + push(lineNumber: number, content: string): void { + if ( + lineNumber < this.request.range.startLine || + this.request.range.endLine < lineNumber + ) { + return + } + + if (this.activeLine !== lineNumber) { + if (this.firstLine !== undefined && !this.append("\n")) { + return + } + + this.firstLine ??= lineNumber + this.lastLine = lineNumber + this.activeLine = lineNumber + } + + this.append(content) + } + + snippet(lineCount: number): MergeCodeContextSnippet { + const fallbackLine = Math.max( + 1, + Math.min(this.request.range.startLine, lineCount) + ) + + return { + status: "text", + filePath: this.request.filePath, + content: this.contentChunks.join(""), + startLine: this.firstLine ?? fallbackLine, + endLine: this.lastLine ?? fallbackLine, + truncated: + this.byteTruncated || + this.firstLine !== 1 || + this.lastLine !== lineCount + } + } + + private append(content: string): boolean { + if (this.byteTruncated) { + return false + } + + if (!content.length) { + return true + } + + const remaining = SNIPPET_MAX_BYTES - this.contentLength + + if (remaining <= 0) { + this.byteTruncated = true + return false + } + + const byteLength = Buffer.byteLength(content) + + if (byteLength <= remaining) { + this.contentChunks.push(content) + this.contentLength += byteLength + return true + } + + const prefix = utf8Prefix(content, remaining) + + if (prefix) { + this.contentChunks.push(prefix) + this.contentLength += Buffer.byteLength(prefix) + } + + this.byteTruncated = true + return false + } +} + +type SplitText = { + lines: string[] + endsWithNewline: boolean +} + +// NUL이나 잘못된 UTF-8을 binary로 분류 +function decodeText(content: Buffer): string | undefined { + if (content.includes(0)) { + return undefined + } + + try { + return new TextDecoder("utf-8", { fatal: true }).decode(content) + } catch { + return undefined + } +} + +// 마지막 개행을 가상 line으로 세지 않고 text를 분리 +function splitText(content: string): SplitText { + const endsWithNewline = content.endsWith("\n") + const body = endsWithNewline ? content.slice(0, -1) : content + + return { + lines: body.length ? body.split("\n") : [""], + endsWithNewline + } +} + +// 작은 file은 전체를, 400줄 초과 file은 요청 범위만 반환 +function wholeTextSnippet( + request: GitObjectSnippetRequest, + content: string, + text: SplitText +): MergeCodeContextSnippet { + if (text.lines.length <= SNIPPET_MAX_LINES) { + return { + status: "text", + filePath: request.filePath, + content, + startLine: 1, + endLine: text.lines.length, + truncated: false + } + } + + const startLine = Math.min(request.range.startLine, text.lines.length) + const endLine = Math.max( + startLine, + Math.min(request.range.endLine, text.lines.length) + ) + let selected = text.lines.slice(startLine - 1, endLine).join("\n") + + if (text.endsWithNewline && endLine === text.lines.length) { + selected += "\n" + } + + return { + status: "text", + filePath: request.filePath, + content: selected, + startLine, + endLine, + truncated: startLine !== 1 || endLine !== text.lines.length + } +} + +// UTF-8 문자를 자르지 않고 byte 상한 안의 접두사 반환 +function utf8Prefix(content: string, maximumBytes: number): string { + let byteLength = 0 + let endIndex = 0 + + for (const character of content) { + const characterLength = Buffer.byteLength(character) + + if (maximumBytes < byteLength + characterLength) { + break + } + + byteLength += characterLength + endIndex += character.length + } + + return content.slice(0, endIndex) +} + +function binarySnippet( + request: GitObjectSnippetRequest +): MergeCodeContextSnippet { + return { + status: "binary", + filePath: request.filePath, + truncated: false + } +} + +export function unavailableSnippet( + request: GitObjectSnippetRequest +): MergeCodeContextSnippet { + return { + status: "missing", + filePath: request.filePath, + truncated: false + } +} diff --git a/src/git/mergeCodeContextParser.ts b/src/git/mergeCodeContextParser.ts new file mode 100644 index 0000000..6d26897 --- /dev/null +++ b/src/git/mergeCodeContextParser.ts @@ -0,0 +1,232 @@ +import type { + GitDiffHunk, + MergeCodeContextLineRange, + MergeCodeContextRegion +} from "./types.js" + +const SMALL_FILE_MAX_BYTES = 32 * 1024 +const SMALL_FILE_MAX_LINES = 400 +const FALLBACK_CONTEXT_LINES = 40 + +// unified diff hunk header에서 base와 변경 후 line range를 추출 +export function parseDiffHunks(output: string): GitDiffHunk[] { + const hunks: GitDiffHunk[] = [] + + for (const line of output.split("\n")) { + const match = line.match( + /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/ + ) + + if (!match) { + continue + } + + hunks.push({ + oldStartLine: Number(match[1]), + oldLineCount: Number(match[2] ?? "1"), + newStartLine: Number(match[3]), + newLineCount: Number(match[4] ?? "1") + }) + } + + return hunks +} + +// 양쪽 hunk를 merge-base 좌표에서 비교해 겹치는 코드 문맥 범위를 구성 +export function overlapRegions( + filePath: string, + leftHunks: GitDiffHunk[], + rightHunks: GitDiffHunk[] +): MergeCodeContextRegion[] { + const regions: MergeCodeContextRegion[] = [] + + for (const left of leftHunks) { + const leftBase = rangeFor(left.oldStartLine, left.oldLineCount) + + for (const right of rightHunks) { + const rightBase = rangeFor(right.oldStartLine, right.oldLineCount) + const startLine = Math.max(leftBase.startLine, rightBase.startLine) + const endLine = Math.min(leftBase.endLine, rightBase.endLine) + + if (endLine < startLine) { + continue + } + + regions.push({ + filePath, + baseRange: { startLine, endLine }, + leftRange: rangeFor(left.newStartLine, left.newLineCount), + rightRange: rangeFor(right.newStartLine, right.newLineCount) + }) + } + } + + return regions.sort(compareRegions) +} + +// merged text에서 완성된 conflict marker block의 line range를 추출 +export function conflictMarkerRanges( + content: string +): MergeCodeContextLineRange[] { + const ranges: MergeCodeContextLineRange[] = [] + let startLine: number | undefined + + for (const [index, line] of content.split("\n").entries()) { + const lineNumber = index + 1 + + if (line.startsWith("<<<<<<< ")) { + startLine = lineNumber + continue + } + + if (startLine !== undefined && line.startsWith(">>>>>>> ")) { + ranges.push({ + startLine, + endLine: lineNumber + }) + startLine = undefined + } + } + + return ranges +} + +// file 크기와 대상 범위에 따라 전체 file 또는 제한된 문맥 범위를 선택 +export function snippetRangeFor(input: { + byteLength: number + lineCount: number + targetRange: MergeCodeContextLineRange + functionRange?: MergeCodeContextLineRange +}): MergeCodeContextLineRange & { truncated: boolean } { + const lineCount = Math.max(1, input.lineCount) + + if ( + input.byteLength <= SMALL_FILE_MAX_BYTES && + input.lineCount <= SMALL_FILE_MAX_LINES + ) { + return { + startLine: 1, + endLine: lineCount, + truncated: false + } + } + + const selected = input.functionRange ?? { + startLine: input.targetRange.startLine - FALLBACK_CONTEXT_LINES, + endLine: input.targetRange.endLine + FALLBACK_CONTEXT_LINES + } + let startLine = Math.max(1, selected.startLine) + let endLine = Math.max(startLine, Math.min(lineCount, selected.endLine)) + + if (SMALL_FILE_MAX_LINES < endLine - startLine + 1) { + const center = Math.floor( + (input.targetRange.startLine + input.targetRange.endLine) / 2 + ) + const lowerBound = input.functionRange?.startLine ?? 1 + const upperBound = Math.min( + lineCount, + input.functionRange?.endLine ?? lineCount + ) + + startLine = Math.max( + lowerBound, + center - Math.floor(SMALL_FILE_MAX_LINES / 2) + ) + endLine = Math.min(upperBound, startLine + SMALL_FILE_MAX_LINES - 1) + startLine = Math.max(lowerBound, endLine - SMALL_FILE_MAX_LINES + 1) + } + + return { + startLine, + endLine, + truncated: true + } +} + +// 큰 file fallback 규칙으로 최대 400줄 요청 범위를 구성 +export function boundedContextRange( + targetRange: MergeCodeContextLineRange, + functionRange?: MergeCodeContextLineRange +): MergeCodeContextLineRange { + const selected = snippetRangeFor({ + byteLength: Number.MAX_SAFE_INTEGER, + lineCount: Number.MAX_SAFE_INTEGER, + targetRange, + functionRange + }) + + return { + startLine: selected.startLine, + endLine: selected.endLine + } +} + +// target을 포함하는 가장 좁은 function hunk range를 선택 +export function functionRangeFor( + hunks: GitDiffHunk[], + side: "old" | "new", + targetRange: MergeCodeContextLineRange +): MergeCodeContextLineRange | undefined { + return hunks + .map(hunk => side === "old" ? oldRange(hunk) : newRange(hunk)) + .filter((range): range is MergeCodeContextLineRange => + range !== undefined && + range.startLine <= targetRange.startLine && + targetRange.endLine <= range.endLine + ) + .sort((range, other) => + range.endLine - range.startLine - (other.endLine - other.startLine) + )[0] +} + +export function oldRange( + hunk: GitDiffHunk | undefined +): MergeCodeContextLineRange | undefined { + if (!hunk) { + return undefined + } + + return rangeFor(hunk.oldStartLine, hunk.oldLineCount) +} + +export function newRange( + hunk: GitDiffHunk | undefined +): MergeCodeContextLineRange | undefined { + if (!hunk) { + return undefined + } + + return rangeFor(hunk.newStartLine, hunk.newLineCount) +} + +export function unionRange( + range: MergeCodeContextLineRange, + other: MergeCodeContextLineRange +): MergeCodeContextLineRange { + return { + startLine: Math.min(range.startLine, other.startLine), + endLine: Math.max(range.endLine, other.endLine) + } +} + +function rangeFor(startLine: number, lineCount: number): MergeCodeContextLineRange { + return { + startLine, + endLine: startLine + Math.max(1, lineCount) - 1 + } +} + +function compareRegions( + region: MergeCodeContextRegion, + other: MergeCodeContextRegion +): number { + if (region.filePath !== other.filePath) { + return region.filePath < other.filePath ? -1 : 1 + } + + if (region.baseRange.startLine !== other.baseRange.startLine) { + return region.baseRange.startLine - other.baseRange.startLine + } + + return region.baseRange.endLine - other.baseRange.endLine +} diff --git a/src/git/types.ts b/src/git/types.ts index bb2383c..c75b28c 100644 --- a/src/git/types.ts +++ b/src/git/types.ts @@ -18,6 +18,8 @@ export type GitMergeTreeFailureStage = "preparation" | "merge" export type GitMergeTreePairResult = { pair: BranchComparisonPair status: GitMergeSignalStatus + leftCommitOid?: string + rightCommitOid?: string mergedTreeOid?: string conflictFiles: string[] conflicts: GitMergeTreeConflict[] @@ -25,6 +27,81 @@ export type GitMergeTreePairResult = { failureStage?: GitMergeTreeFailureStage } +export type MergeCodeContextKind = + | "confirmed_conflict" + | "clean_hunk_overlap" + +export type MergeCodeContextFileStatus = + | "text" + | "binary" + | "deleted" + | "missing" + +export type MergeCodeContextLineRange = { + startLine: number + endLine: number +} + +export type GitObjectSnippetRequest = { + key: string + objectOid: string + filePath: string + range: MergeCodeContextLineRange +} + +export type MergeCodeContextSnippet = { + status: MergeCodeContextFileStatus + filePath: string + content?: string + startLine?: number + endLine?: number + truncated: boolean +} + +export type MergeCodeContextCommitMetadata = { + role: "base" | "left" | "right" + branchName?: string + oid: string + author: string + committedAt: string + subject: string +} + +export type MergeCodeContextEvidence = { + pair: BranchComparisonPair + kind: MergeCodeContextKind + filePath: string + mergeBaseOid: string + mergedTreeOid: string + baseCommit: MergeCodeContextCommitMetadata + leftCommit: MergeCodeContextCommitMetadata + rightCommit: MergeCodeContextCommitMetadata + baseSnippet: MergeCodeContextSnippet + leftSnippet: MergeCodeContextSnippet + rightSnippet: MergeCodeContextSnippet + mergedSnippet: MergeCodeContextSnippet +} + +export type GitMergeCodeContextPairResult = { + pair: BranchComparisonPair + evidence: MergeCodeContextEvidence[] + errorMessage?: string +} + +export type GitDiffHunk = { + oldStartLine: number + oldLineCount: number + newStartLine: number + newLineCount: number +} + +export type MergeCodeContextRegion = { + filePath: string + baseRange: MergeCodeContextLineRange + leftRange: MergeCodeContextLineRange + rightRange: MergeCodeContextLineRange +} + export type GitMergeTreeRoundCollectionOptions = { repositoryPath: string commitOidByBranch: ReadonlyMap diff --git a/tests/git/gitMergeCodeContextCollector.test.ts b/tests/git/gitMergeCodeContextCollector.test.ts new file mode 100644 index 0000000..569be2f --- /dev/null +++ b/tests/git/gitMergeCodeContextCollector.test.ts @@ -0,0 +1,623 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { dirname, join } from "node:path" +import { promisify } from "node:util" +import { collect as collectCodeContext } from "../../src/git/gitMergeCodeContextCollector.js" +import { collect as collectMergeTreeRound } from "../../src/git/gitMergeTreeRoundCollector.js" +import type { BranchComparisonRound } from "../../src/branches/types.js" +import type { GitMergeTreePairResult } from "../../src/git/types.js" + +const execFileAsync = promisify(execFile) + +test("collects four conflict versions and commit metadata without repository mutation", async () => { + const fixture = await createGitFixture() + const round: BranchComparisonRound = { + roundIndex: 0, + pairs: [{ + leftBranchName: "feature/left", + rightBranchName: "feature/right" + }] + } + + try { + const [mergeResult] = await collectMergeTreeRound(round, { + repositoryPath: fixture.repositoryPath, + commitOidByBranch: fixture.commitOidByBranch + }) + + assert.ok(mergeResult) + assert.equal(mergeResult.status, "confirmed_conflict") + + const before = await repositorySnapshot(fixture.repositoryPath) + const [result] = await collectCodeContext([mergeResult], { + repositoryPath: fixture.repositoryPath + }) + const after = await repositorySnapshot(fixture.repositoryPath) + const evidence = result?.evidence[0] + + assert.deepEqual(after, before) + assert.equal(result?.errorMessage, undefined) + assert.equal(result?.evidence.length, 1) + assert.equal(evidence?.kind, "confirmed_conflict") + assert.equal(evidence?.filePath, "src/value.ts") + assert.equal(evidence?.mergeBaseOid, fixture.baseOid) + assert.equal(evidence?.mergedTreeOid, mergeResult.mergedTreeOid) + assert.deepEqual(evidence?.baseCommit, { + role: "base", + oid: fixture.baseOid, + author: "opfic ", + committedAt: "2026-07-16T00:00:00Z", + subject: "base" + }) + assert.equal(evidence?.leftCommit.role, "left") + assert.equal(evidence?.leftCommit.branchName, "feature/left") + assert.equal(evidence?.leftCommit.oid, fixture.commitOidByBranch.get("feature/left")) + assert.equal(evidence?.leftCommit.subject, "left change") + assert.equal(evidence?.rightCommit.role, "right") + assert.equal(evidence?.rightCommit.branchName, "feature/right") + assert.equal(evidence?.rightCommit.oid, fixture.commitOidByBranch.get("feature/right")) + assert.equal(evidence?.rightCommit.subject, "right change") + assert.match(evidence?.baseSnippet.content ?? "", /base/) + assert.match(evidence?.leftSnippet.content ?? "", /left/) + assert.match(evidence?.rightSnippet.content ?? "", /right/) + assert.match(evidence?.mergedSnippet.content ?? "", /<<<<<<< /) + assert.match(evidence?.mergedSnippet.content ?? "", /=======/) + assert.match(evidence?.mergedSnippet.content ?? "", />>>>>>> /) + } finally { + await fixture.remove() + } +}) + +test("uses actual merged marker ranges after earlier conflicts shift line numbers", async () => { + const conflictLines = [ + 20, 60, 100, 140, 180, 220, 260, + 300, 340, 380, 420, 460, 600 + ] + const baseLines = Array.from({ length: 700 }, (_, index) => + `line ${index + 1}` + ) + const leftLines = [...baseLines] + const rightLines = [...baseLines] + + for (const lineNumber of conflictLines) { + leftLines[lineNumber - 1] = `left ${lineNumber}` + rightLines[lineNumber - 1] = `right ${lineNumber}` + } + + const fixture = await createVersionedGitFixture( + "src/large.txt", + `${baseLines.join("\n")}\n`, + `${leftLines.join("\n")}\n`, + `${rightLines.join("\n")}\n` + ) + + try { + const mergeResult = await collectFixtureMergeResult(fixture) + const [result] = await collectCodeContext([mergeResult], { + repositoryPath: fixture.repositoryPath + }) + + assert.equal(result?.evidence.length, conflictLines.length) + assert.equal(result?.evidence.every(evidence => + evidence.mergedSnippet.content?.includes("<<<<<<< ") && + evidence.mergedSnippet.content.includes("=======") && + evidence.mergedSnippet.content.includes(">>>>>>> ") + ), true) + } finally { + await fixture.remove() + } +}) + +test("prefers function context over the forty line fallback", async () => { + const prelude = Array.from({ length: 450 }, (_, index) => + `/* prelude ${index + 1} */` + ) + const functionLines = [ + "int value(void) {", + ...Array.from({ length: 120 }, (_, index) => + ` int local_${index + 1} = ${index + 1};` + ), + " return local_60;", + "}" + ] + const baseLines = [...prelude, ...functionLines] + const selectedLine = prelude.length + 61 + const leftLines = [...baseLines] + const rightLines = [...baseLines] + + leftLines[selectedLine - 1] = " int local_60 = 600;" + rightLines[selectedLine - 1] = " int local_60 = 601;" + + const fixture = await createVersionedGitFixture( + "src/value.c", + `${baseLines.join("\n")}\n`, + `${leftLines.join("\n")}\n`, + `${rightLines.join("\n")}\n` + ) + + try { + const mergeResult = await collectFixtureMergeResult(fixture) + const [result] = await collectCodeContext([mergeResult], { + repositoryPath: fixture.repositoryPath + }) + const evidence = result?.evidence[0] + + assert.match(evidence?.baseSnippet.content ?? "", /int value\(void\) \{/) + assert.match(evidence?.leftSnippet.content ?? "", /int value\(void\) \{/) + assert.match(evidence?.rightSnippet.content ?? "", /int value\(void\) \{/) + assert.match(evidence?.mergedSnippet.content ?? "", /int value\(void\) \{/) + assert.match(evidence?.mergedSnippet.content ?? "", /<<<<<<< /) + } finally { + await fixture.remove() + } +}) + +test("bounds pair diagnostics and rejects non-object identifiers", async () => { + const invalidResult = conflictResult({ + leftCommitOid: "a".repeat(41) + }) + const [invalid] = await collectCodeContext([invalidResult], { + repositoryPath: "/tmp/repository" + }) + + assert.match(invalid?.errorMessage ?? "", /Invalid left commit OID/) + + const root = await mkdtemp(join(tmpdir(), "watcher-fake-git-")) + const executablePath = join(root, "git") + const scriptPath = join(root, "fake-git.js") + const originalPath = process.env.PATH + + try { + await writeFile(scriptPath, [ + `#!${process.execPath}`, + "process.stderr.write(\"x\".repeat(20 * 1024))", + "process.exit(1)", + "" + ].join("\n")) + await writeFile(executablePath, [ + "#!/bin/sh", + `exec "${process.execPath}" "${scriptPath}" "$@"`, + "" + ].join("\n")) + await chmod(executablePath, 0o755) + process.env.PATH = `${root}:${originalPath ?? ""}` + + const [failed] = await collectCodeContext([conflictResult()], { + repositoryPath: root + }) + + assert.equal(Buffer.byteLength(failed?.errorMessage ?? "") <= 16 * 1024, true) + } finally { + process.env.PATH = originalPath + await rm(root, { recursive: true, force: true }) + } +}) + +test("collects only overlapping clean hunks without repository mutation", async () => { + const baseLines = Array.from({ length: 100 }, (_, index) => + `line ${index + 1}` + ) + const overlappingLines = [...baseLines] + + overlappingLines[49] = "shared change" + + const overlapFixture = await createVersionedGitFixture( + "src/clean.txt", + `${baseLines.join("\n")}\n`, + `${overlappingLines.join("\n")}\n`, + `${overlappingLines.join("\n")}\n` + ) + const leftLines = [...baseLines] + const rightLines = [...baseLines] + + leftLines[9] = "left change" + rightLines[89] = "right change" + + const disjointFixture = await createVersionedGitFixture( + "src/disjoint.txt", + `${baseLines.join("\n")}\n`, + `${leftLines.join("\n")}\n`, + `${rightLines.join("\n")}\n` + ) + + try { + const overlapMergeResult = await collectFixtureMergeResult( + overlapFixture, + "clean" + ) + const disjointMergeResult = await collectFixtureMergeResult( + disjointFixture, + "clean" + ) + const overlapBefore = await repositorySnapshot(overlapFixture.repositoryPath) + const disjointBefore = await repositorySnapshot(disjointFixture.repositoryPath) + const [overlap] = await collectCodeContext([overlapMergeResult], { + repositoryPath: overlapFixture.repositoryPath + }) + const [disjoint] = await collectCodeContext([disjointMergeResult], { + repositoryPath: disjointFixture.repositoryPath + }) + const overlapAfter = await repositorySnapshot(overlapFixture.repositoryPath) + const disjointAfter = await repositorySnapshot(disjointFixture.repositoryPath) + + assert.deepEqual(overlapAfter, overlapBefore) + assert.deepEqual(disjointAfter, disjointBefore) + assert.equal(overlap?.evidence.length, 1) + assert.equal(overlap?.evidence[0]?.kind, "clean_hunk_overlap") + assert.match(overlap?.evidence[0]?.mergedSnippet.content ?? "", /shared change/) + assert.equal(disjoint?.errorMessage, undefined) + assert.deepEqual(disjoint?.evidence, []) + } finally { + await overlapFixture.remove() + await disjointFixture.remove() + } +}) + +test("maps a clean overlap through the merged tree after a large insertion", async () => { + const baseLines = Array.from({ length: 100 }, (_, index) => + `line ${index + 1}` + ) + const rightLines = [...baseLines] + const leftLines = [ + ...Array.from({ length: 450 }, (_, index) => `inserted ${index + 1}`), + ...baseLines + ] + + rightLines[79] = "shared change" + leftLines[450 + 79] = "shared change" + + const fixture = await createVersionedGitFixture( + "src/shifted.txt", + `${baseLines.join("\n")}\n`, + `${leftLines.join("\n")}\n`, + `${rightLines.join("\n")}\n` + ) + + try { + const mergeResult = await collectFixtureMergeResult(fixture, "clean") + const [result] = await collectCodeContext([mergeResult], { + repositoryPath: fixture.repositoryPath + }) + + assert.equal(result?.evidence.length, 1) + assert.match(result?.evidence[0]?.mergedSnippet.content ?? "", /shared change/) + } finally { + await fixture.remove() + } +}) + +test("classifies every deleted clean overlap version without repository mutation", async () => { + const fixture = await createVersionedGitFixture( + "src/deleted.txt", + "base\n", + undefined, + undefined + ) + + try { + const mergeResult = await collectFixtureMergeResult(fixture, "clean") + const before = await repositorySnapshot(fixture.repositoryPath) + const [result] = await collectCodeContext([mergeResult], { + repositoryPath: fixture.repositoryPath + }) + const after = await repositorySnapshot(fixture.repositoryPath) + const evidence = result?.evidence[0] + + assert.deepEqual(after, before) + assert.equal(result?.evidence.length, 1) + assert.equal(evidence?.baseSnippet.status, "text") + assert.equal(evidence?.leftSnippet.status, "deleted") + assert.equal(evidence?.rightSnippet.status, "deleted") + assert.equal(evidence?.mergedSnippet.status, "deleted") + assert.equal(evidence?.leftSnippet.content, undefined) + assert.equal(evidence?.rightSnippet.content, undefined) + assert.equal(evidence?.mergedSnippet.content, undefined) + } finally { + await fixture.remove() + } +}) + +test("classifies binary, deleted, and missing conflict versions without repository mutation", async () => { + const fixture = await createEdgeCaseGitFixture() + + try { + const mergeResult = await collectFixtureMergeResult(fixture) + const before = await repositorySnapshot(fixture.repositoryPath) + const [result] = await collectCodeContext([mergeResult], { + repositoryPath: fixture.repositoryPath + }) + const after = await repositorySnapshot(fixture.repositoryPath) + const evidenceByFile = new Map(result?.evidence.map(evidence => [ + evidence.filePath, + evidence + ])) + const binary = evidenceByFile.get("binary.bin") + const deleted = evidenceByFile.get("delete.txt") + const added = evidenceByFile.get("added.txt") + + assert.deepEqual(after, before) + assert.equal(binary?.baseSnippet.status, "binary") + assert.equal(binary?.leftSnippet.status, "binary") + assert.equal(binary?.rightSnippet.status, "binary") + assert.equal(binary?.mergedSnippet.status, "binary") + assert.equal(binary?.baseSnippet.content, undefined) + assert.equal(binary?.leftSnippet.content, undefined) + assert.equal(binary?.rightSnippet.content, undefined) + assert.equal(binary?.mergedSnippet.content, undefined) + assert.equal(deleted?.baseSnippet.status, "text") + assert.equal(deleted?.leftSnippet.status, "text") + assert.equal(deleted?.rightSnippet.status, "deleted") + assert.equal(deleted?.rightSnippet.content, undefined) + assert.equal(added?.baseSnippet.status, "missing") + assert.equal(added?.leftSnippet.status, "text") + assert.equal(added?.rightSnippet.status, "text") + } finally { + await fixture.remove() + } +}) + +test("isolates one pair failure and preserves input order", async () => { + const fixture = await createGitFixture() + + try { + const valid = await collectFixtureMergeResult(fixture) + const invalid = { + ...valid, + pair: { + leftBranchName: "invalid/left", + rightBranchName: "invalid/right" + }, + leftCommitOid: "f".repeat(41) + } + const repeated = { + ...valid, + pair: { + leftBranchName: "repeated/left", + rightBranchName: "repeated/right" + } + } + const inputs = [valid, invalid, repeated] + const before = await repositorySnapshot(fixture.repositoryPath) + const results = await collectCodeContext(inputs, { + repositoryPath: fixture.repositoryPath + }) + const after = await repositorySnapshot(fixture.repositoryPath) + + assert.deepEqual(after, before) + assert.deepEqual(results.map(result => result.pair), inputs.map(result => result.pair)) + assert.equal(results[0]?.errorMessage, undefined) + assert.match(results[1]?.errorMessage ?? "", /Invalid left commit OID/) + assert.equal(results[2]?.errorMessage, undefined) + assert.equal(results[0]?.evidence.length, results[2]?.evidence.length) + } finally { + await fixture.remove() + } +}) + +async function createGitFixture(): Promise<{ + repositoryPath: string + baseOid: string + commitOidByBranch: Map + remove(): Promise +}> { + const root = await mkdtemp(join(tmpdir(), "watcher-code-context-")) + const repositoryPath = join(root, "repository") + const sourcePath = join(repositoryPath, "src") + + 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 mkdir(sourcePath) + await writeValueFile(repositoryPath, "base") + await git(repositoryPath, ["add", "."]) + await git(repositoryPath, ["commit", "-m", "base"], "2026-07-16T00:00:00Z") + const baseOid = await git(repositoryPath, ["rev-parse", "HEAD"]) + const commitOidByBranch = new Map() + + await git(repositoryPath, ["checkout", "-b", "feature/left", baseOid]) + await writeValueFile(repositoryPath, "left") + await git(repositoryPath, ["commit", "-am", "left change"], "2026-07-16T01:00:00Z") + commitOidByBranch.set("feature/left", await git(repositoryPath, ["rev-parse", "HEAD"])) + + await git(repositoryPath, ["checkout", "-b", "feature/right", baseOid]) + await writeValueFile(repositoryPath, "right") + await git(repositoryPath, ["commit", "-am", "right change"], "2026-07-16T02:00:00Z") + commitOidByBranch.set("feature/right", await git(repositoryPath, ["rev-parse", "HEAD"])) + await git(repositoryPath, ["checkout", "main"]) + + return { + repositoryPath, + baseOid, + commitOidByBranch, + async remove(): Promise { + await rm(root, { recursive: true, force: true }) + } + } +} + +async function writeValueFile( + repositoryPath: string, + value: string +): Promise { + await writeFile(join(repositoryPath, "src/value.ts"), [ + "export function value() {", + ` return "${value}"`, + "}", + "" + ].join("\n")) +} + +async function createVersionedGitFixture( + filePath: string, + baseContent: string, + leftContent: string | undefined, + rightContent: string | undefined +): Promise<{ + repositoryPath: string + baseOid: string + commitOidByBranch: Map + remove(): Promise +}> { + const root = await mkdtemp(join(tmpdir(), "watcher-code-context-versioned-")) + const repositoryPath = join(root, "repository") + const absoluteFilePath = join(repositoryPath, filePath) + + 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 mkdir(dirname(absoluteFilePath), { recursive: true }) + await writeFile(absoluteFilePath, baseContent) + await git(repositoryPath, ["add", "."]) + await git(repositoryPath, ["commit", "-m", "base"], "2026-07-16T00:00:00Z") + const baseOid = await git(repositoryPath, ["rev-parse", "HEAD"]) + const commitOidByBranch = new Map() + + await git(repositoryPath, ["checkout", "-b", "feature/left", baseOid]) + if (leftContent === undefined) { + await rm(absoluteFilePath) + } else { + await writeFile(absoluteFilePath, leftContent) + } + await git(repositoryPath, ["add", "-A"]) + await git(repositoryPath, ["commit", "-m", "left change"], "2026-07-16T01:00:00Z") + commitOidByBranch.set("feature/left", await git(repositoryPath, ["rev-parse", "HEAD"])) + + await git(repositoryPath, ["checkout", "-b", "feature/right", baseOid]) + if (rightContent === undefined) { + await rm(absoluteFilePath) + } else { + await writeFile(absoluteFilePath, rightContent) + } + await git(repositoryPath, ["add", "-A"]) + await git(repositoryPath, ["commit", "-m", "right change"], "2026-07-16T02:00:00Z") + commitOidByBranch.set("feature/right", await git(repositoryPath, ["rev-parse", "HEAD"])) + await git(repositoryPath, ["checkout", "main"]) + + return { + repositoryPath, + baseOid, + commitOidByBranch, + async remove(): Promise { + await rm(root, { recursive: true, force: true }) + } + } +} + +async function createEdgeCaseGitFixture(): Promise<{ + repositoryPath: string + baseOid: string + commitOidByBranch: Map + remove(): Promise +}> { + const root = await mkdtemp(join(tmpdir(), "watcher-code-context-edge-")) + 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, "binary.bin"), Buffer.from([0, 1, 2])) + await writeFile(join(repositoryPath, "delete.txt"), "base\n") + await git(repositoryPath, ["add", "."]) + await git(repositoryPath, ["commit", "-m", "base"], "2026-07-16T00:00:00Z") + const baseOid = await git(repositoryPath, ["rev-parse", "HEAD"]) + const commitOidByBranch = new Map() + + await git(repositoryPath, ["checkout", "-b", "feature/left", baseOid]) + await writeFile(join(repositoryPath, "binary.bin"), Buffer.from([0, 3, 2])) + await writeFile(join(repositoryPath, "delete.txt"), "left\n") + await writeFile(join(repositoryPath, "added.txt"), "left added\n") + await git(repositoryPath, ["add", "-A"]) + await git(repositoryPath, ["commit", "-m", "left change"], "2026-07-16T01:00:00Z") + commitOidByBranch.set("feature/left", await git(repositoryPath, ["rev-parse", "HEAD"])) + + await git(repositoryPath, ["checkout", "-b", "feature/right", baseOid]) + await writeFile(join(repositoryPath, "binary.bin"), Buffer.from([0, 4, 2])) + await rm(join(repositoryPath, "delete.txt")) + await writeFile(join(repositoryPath, "added.txt"), "right added\n") + await git(repositoryPath, ["add", "-A"]) + await git(repositoryPath, ["commit", "-m", "right change"], "2026-07-16T02:00:00Z") + commitOidByBranch.set("feature/right", await git(repositoryPath, ["rev-parse", "HEAD"])) + await git(repositoryPath, ["checkout", "main"]) + + return { + repositoryPath, + baseOid, + commitOidByBranch, + async remove(): Promise { + await rm(root, { recursive: true, force: true }) + } + } +} + +async function collectFixtureMergeResult( + fixture: { + repositoryPath: string + commitOidByBranch: Map + }, + expectedStatus: "clean" | "confirmed_conflict" = "confirmed_conflict" +): Promise { + const round: BranchComparisonRound = { + roundIndex: 0, + pairs: [{ + leftBranchName: "feature/left", + rightBranchName: "feature/right" + }] + } + const [result] = await collectMergeTreeRound(round, { + repositoryPath: fixture.repositoryPath, + commitOidByBranch: fixture.commitOidByBranch + }) + + assert.ok(result) + assert.equal(result.status, expectedStatus) + return result +} + +function conflictResult( + overrides: Partial = {} +): GitMergeTreePairResult { + return { + pair: { + leftBranchName: "feature/left", + rightBranchName: "feature/right" + }, + status: "confirmed_conflict", + leftCommitOid: "1".repeat(40), + rightCommitOid: "2".repeat(40), + mergedTreeOid: "3".repeat(40), + conflictFiles: ["value.txt"], + conflicts: [], + ...overrides + } +} + +async function repositorySnapshot(repositoryPath: string): Promise { + const head = await git(repositoryPath, ["rev-parse", "HEAD"]) + const branch = await git(repositoryPath, ["branch", "--show-current"]) + const status = await git(repositoryPath, ["status", "--porcelain=v1"]) + const index = await git(repositoryPath, ["write-tree"]) + const refs = await git(repositoryPath, ["show-ref", "--head"]) + + return [head, branch, status, index, refs] +} + +async function git( + cwd: string, + args: string[], + date?: string +): Promise { + const result = await execFileAsync("git", args, { + cwd, + env: date ? { + ...process.env, + GIT_AUTHOR_DATE: date, + GIT_COMMITTER_DATE: date + } : process.env, + maxBuffer: 10 * 1024 * 1024 + }) + + return result.stdout.trim() +} diff --git a/tests/git/gitMergeTreeRoundCollector.test.ts b/tests/git/gitMergeTreeRoundCollector.test.ts index a9ccf6f..566f3a7 100644 --- a/tests/git/gitMergeTreeRoundCollector.test.ts +++ b/tests/git/gitMergeTreeRoundCollector.test.ts @@ -36,6 +36,22 @@ test("collects mixed merge results for one round", async () => { "confirmed_conflict", "confirmed_conflict" ]) + assert.equal( + results[0]?.leftCommitOid, + fixture.commitOidByBranch.get("clean/left") + ) + assert.equal( + results[0]?.rightCommitOid, + fixture.commitOidByBranch.get("clean/right") + ) + assert.equal( + results[1]?.leftCommitOid, + fixture.commitOidByBranch.get("content/left") + ) + assert.equal( + results[1]?.rightCommitOid, + fixture.commitOidByBranch.get("content/right") + ) assert.match(results[0]?.mergedTreeOid ?? "", /^[0-9a-f]{40}$/) assert.deepEqual(results[0]?.conflictFiles, []) assert.deepEqual(results[1]?.conflictFiles, ["shared.txt"]) @@ -53,6 +69,34 @@ test("collects mixed merge results for one round", async () => { } }) +// merge-tree 입력 이후 원본 map이 바뀌어도 실행에 사용한 OID를 보존하는지 확인 +test("keeps commit OID snapshot after collection starts", async () => { + const fixture = await createGitFixture() + const round = comparisonRound([ + pair("clean/left", "clean/right") + ]) + const commitOidByBranch = new Map(fixture.commitOidByBranch) + const leftOid = commitOidByBranch.get("clean/left") + const rightOid = commitOidByBranch.get("clean/right") + + try { + const resultPromise = collect(round, { + repositoryPath: fixture.repositoryPath, + commitOidByBranch + }) + + commitOidByBranch.set("clean/left", "f".repeat(40)) + commitOidByBranch.set("clean/right", "e".repeat(40)) + + const [result] = await resultPromise + + assert.equal(result?.leftCommitOid, leftOid) + assert.equal(result?.rightCommitOid, rightOid) + } finally { + await fixture.remove() + } +}) + // merge-tree 실행 전후 worktree 상태와 index tree가 바뀌지 않는지 확인 test("keeps worktree and index unchanged", async () => { const fixture = await createGitFixture() diff --git a/tests/git/gitObjectBatchReader.test.ts b/tests/git/gitObjectBatchReader.test.ts new file mode 100644 index 0000000..8c84fde --- /dev/null +++ b/tests/git/gitObjectBatchReader.test.ts @@ -0,0 +1,178 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { execFile } from "node:child_process" +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { promisify } from "node:util" +import { readGitObjectSnippets } from "../../src/git/gitObjectBatchReader.js" + +const execFileAsync = promisify(execFile) + +test("reads text, binary, missing, and bounded large snippets in one batch", async () => { + const fixture = await createGitFixture() + + try { + const snippets = await readGitObjectSnippets([{ + key: "small", + objectOid: fixture.commitOid, + filePath: "소스/값.txt", + range: { startLine: 2, endLine: 2 } + }, { + key: "newline", + objectOid: fixture.commitOid, + filePath: "소스/줄\n바꿈.txt", + range: { startLine: 1, endLine: 1 } + }, { + key: "binary", + objectOid: fixture.commitOid, + filePath: "소스/data.bin", + range: { startLine: 1, endLine: 1 } + }, { + key: "invalid-utf8", + objectOid: fixture.commitOid, + filePath: "소스/invalid.bin", + range: { startLine: 1, endLine: 1 } + }, { + key: "missing", + objectOid: fixture.commitOid, + filePath: "소스/missing.txt", + range: { startLine: 1, endLine: 1 } + }, { + key: "large", + objectOid: fixture.commitOid, + filePath: "소스/large.txt", + range: { startLine: 460, endLine: 540 } + }, { + key: "long-line", + objectOid: fixture.commitOid, + filePath: "소스/long.txt", + range: { startLine: 1, endLine: 1 } + }], { + repositoryPath: fixture.repositoryPath + }) + + assert.deepEqual(snippets.get("small"), { + status: "text", + filePath: "소스/값.txt", + content: "첫째\n둘째\n셋째\n", + startLine: 1, + endLine: 3, + truncated: false + }) + assert.equal(snippets.get("newline")?.content, "줄바꿈 경로\n") + assert.deepEqual(snippets.get("binary"), { + status: "binary", + filePath: "소스/data.bin", + truncated: false + }) + assert.equal(snippets.get("invalid-utf8")?.status, "binary") + assert.deepEqual(snippets.get("missing"), { + status: "missing", + filePath: "소스/missing.txt", + truncated: false + }) + assert.deepEqual(snippets.get("large"), { + status: "text", + filePath: "소스/large.txt", + content: Array.from({ length: 81 }, (_, index) => + `line ${index + 460}` + ).join("\n"), + startLine: 460, + endLine: 540, + truncated: true + }) + assert.equal(snippets.get("long-line")?.status, "text") + assert.equal(snippets.get("long-line")?.startLine, 1) + assert.equal(snippets.get("long-line")?.endLine, 1) + assert.equal(snippets.get("long-line")?.truncated, true) + assert.equal( + Buffer.byteLength(snippets.get("long-line")?.content ?? "") <= 32 * 1024, + true + ) + } finally { + await fixture.remove() + } +}) + +test("reuses one object lookup for requests with different ranges", async () => { + const fixture = await createGitFixture() + + try { + const snippets = await readGitObjectSnippets([{ + key: "first", + objectOid: fixture.commitOid, + filePath: "소스/large.txt", + range: { startLine: 60, endLine: 140 } + }, { + key: "second", + objectOid: fixture.commitOid, + filePath: "소스/large.txt", + range: { startLine: 860, endLine: 940 } + }], { + repositoryPath: fixture.repositoryPath + }) + + assert.equal(snippets.get("first")?.startLine, 60) + assert.equal(snippets.get("first")?.endLine, 140) + assert.equal(snippets.get("second")?.startLine, 860) + assert.equal(snippets.get("second")?.endLine, 940) + } finally { + await fixture.remove() + } +}) + +test("rejects an OID whose length is neither 40 nor 64", async () => { + await assert.rejects(readGitObjectSnippets([{ + key: "invalid", + objectOid: "a".repeat(41), + filePath: "value.txt", + range: { startLine: 1, endLine: 1 } + }], { + repositoryPath: "/tmp/repository" + }), /Invalid Git object OID/) +}) + +async function createGitFixture(): Promise<{ + repositoryPath: string + commitOid: string + remove(): Promise +}> { + const root = await mkdtemp(join(tmpdir(), "watcher-object-batch-")) + const repositoryPath = join(root, "repository") + const sourcePath = join(repositoryPath, "소스") + + 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 mkdir(sourcePath) + await writeFile(join(sourcePath, "값.txt"), "첫째\n둘째\n셋째\n") + await writeFile(join(sourcePath, "줄\n바꿈.txt"), "줄바꿈 경로\n") + await writeFile(join(sourcePath, "data.bin"), Buffer.from([0, 1, 2, 3])) + await writeFile(join(sourcePath, "invalid.bin"), Buffer.from([0xc3, 0x28])) + await writeFile(join(sourcePath, "large.txt"), [ + ...Array.from({ length: 1_000 }, (_, index) => `line ${index + 1}`), + "" + ].join("\n")) + await writeFile(join(sourcePath, "long.txt"), `${"한".repeat(40_000)}\n`) + await git(repositoryPath, ["add", "."]) + await git(repositoryPath, ["commit", "-m", "fixture"]) + const commitOid = await git(repositoryPath, ["rev-parse", "HEAD"]) + + return { + repositoryPath, + commitOid, + async remove(): Promise { + await rm(root, { recursive: true, force: true }) + } + } +} + +async function git(cwd: string, args: string[]): Promise { + const result = await execFileAsync("git", args, { + cwd, + maxBuffer: 10 * 1024 * 1024 + }) + + return result.stdout.trim() +} diff --git a/tests/git/mergeCodeContextParser.test.ts b/tests/git/mergeCodeContextParser.test.ts new file mode 100644 index 0000000..2e082e6 --- /dev/null +++ b/tests/git/mergeCodeContextParser.test.ts @@ -0,0 +1,109 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { + conflictMarkerRanges, + overlapRegions, + parseDiffHunks, + snippetRangeFor +} from "../../src/git/mergeCodeContextParser.js" + +test("parses old and new hunk ranges", () => { + assert.deepEqual(parseDiffHunks([ + "@@ -10,3 +12,4 @@ function value()", + "@@ -30 +32 @@" + ].join("\n")), [{ + oldStartLine: 10, + oldLineCount: 3, + newStartLine: 12, + newLineCount: 4 + }, { + oldStartLine: 30, + oldLineCount: 1, + newStartLine: 32, + newLineCount: 1 + }]) +}) + +test("finds overlap in merge-base coordinates", () => { + const left = parseDiffHunks("@@ -10,3 +10,4 @@") + const right = parseDiffHunks("@@ -12,2 +12,3 @@") + + assert.deepEqual(overlapRegions("src/value.ts", left, right), [{ + filePath: "src/value.ts", + baseRange: { startLine: 12, endLine: 12 }, + leftRange: { startLine: 10, endLine: 13 }, + rightRange: { startLine: 12, endLine: 14 } + }]) +}) + +test("uses insertion lines as overlap anchors", () => { + const left = parseDiffHunks("@@ -5,0 +6,2 @@") + const right = parseDiffHunks("@@ -5,0 +6,1 @@") + + assert.deepEqual(overlapRegions("src/value.ts", left, right)[0]?.baseRange, { + startLine: 5, + endLine: 5 + }) +}) + +test("finds merged conflict marker ranges", () => { + const content = [ + "before", + "<<<<<<< left", + "left", + "=======", + "right", + ">>>>>>> right", + "after" + ].join("\n") + + assert.deepEqual(conflictMarkerRanges(content), [{ + startLine: 2, + endLine: 6 + }]) +}) + +test("ignores an unclosed conflict marker", () => { + assert.deepEqual(conflictMarkerRanges("<<<<<<< left\nvalue"), []) +}) + +test("uses whole small file and bounded fallback for a large file", () => { + assert.deepEqual(snippetRangeFor({ + byteLength: 100, + lineCount: 20, + targetRange: { startLine: 10, endLine: 10 } + }), { startLine: 1, endLine: 20, truncated: false }) + + assert.deepEqual(snippetRangeFor({ + byteLength: 64 * 1024, + lineCount: 1_000, + targetRange: { startLine: 500, endLine: 510 } + }), { startLine: 460, endLine: 550, truncated: true }) +}) + +test("prefers a bounded function range for a large file", () => { + assert.deepEqual(snippetRangeFor({ + byteLength: 64 * 1024, + lineCount: 1_000, + targetRange: { startLine: 500, endLine: 510 }, + functionRange: { startLine: 450, endLine: 520 } + }), { startLine: 450, endLine: 520, truncated: true }) +}) + +test("keeps a long bounded function range inside the function", () => { + assert.deepEqual(snippetRangeFor({ + byteLength: 128 * 1024, + lineCount: 2_000, + targetRange: { startLine: 1_480, endLine: 1_490 }, + functionRange: { startLine: 1_000, endLine: 1_500 } + }), { startLine: 1_101, endLine: 1_500, truncated: true }) +}) + +test("normalizes a zero line Git insertion range", () => { + assert.deepEqual(snippetRangeFor({ + byteLength: 64 * 1024, + lineCount: 1_000, + targetRange: { startLine: 0, endLine: 0 }, + functionRange: { startLine: 0, endLine: 0 } + }), { startLine: 1, endLine: 1, truncated: true }) +})