Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/branches/branchPairBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
82 changes: 82 additions & 0 deletions src/branches/branchRoundBuilder.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> = [...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}`
}
5 changes: 5 additions & 0 deletions src/branches/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export type BranchComparisonPair = {
rightBranchName: string
}

export type BranchComparisonRound = {
roundIndex: number
pairs: BranchComparisonPair[]
}

export type BranchContext = {
baseBranch: string
name: string
Expand Down
179 changes: 74 additions & 105 deletions src/git/gitMergeSignalCollector.ts
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -13,17 +16,48 @@ export async function collectGitMergeSignal(
branch: BranchContext,
options: GitMergeSignalCollectionOptions
): Promise<GitMergeSignal> {
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<GitMergeSignal> {
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}`
let mergeBaseSha: string | undefined
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,
Expand All @@ -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",
Expand All @@ -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<GitMergeSignal> {
// 실제 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<void> {
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 목록으로 변환
Expand Down
Loading