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
110 changes: 110 additions & 0 deletions script/upstream/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,89 @@ interface MergeOptions {
author?: string
}

async function hasMergiraf(): Promise<boolean> {
const result = await $`mergiraf --version`.quiet().nothrow()
return result.exitCode === 0
}

function abortMissingMergiraf(): never {
logger.error("mergiraf is required but not installed.")
logger.info(" It provides syntax-aware resolution for imports, JSON/YAML/TOML,")
logger.info(" and other structural conflicts during upstream merges.")
logger.info(" Install via one of:")
logger.info(" brew install mergiraf # macOS / Linuxbrew")
logger.info(" cargo install mergiraf # any platform with rustup")
logger.info(" nix profile install nixpkgs#mergiraf # nix")
logger.info(" See https://mergiraf.org/installation.html for more options.")
process.exit(1)
}

/**
* Attempt syntax-aware resolution of conflicted files via mergiraf.
* Assumes `git merge` was invoked with `merge.conflictStyle=zdiff3`, so the
* working tree already contains base-aware markers that mergiraf can feed
* into its structural heuristics.
*
* Only runs on files whose working-tree content actually contains text
* conflict markers. Delete/modify (UD/DU) and similar non-textual conflicts
* have no markers — running `mergiraf solve` + `git add` on them would
* silently stage the file with our side of the conflict, losing the signal
* that upstream deleted (or that we deleted what upstream modified). Those
* are left untouched for manual review.
*
* Only stages files mergiraf resolves completely (no conflict markers
* remain). Partial resolutions are left unstaged so the remaining markers
* show up for manual review — we never auto-commit a partially-resolved
* file. Per-file failures are logged at debug level and skipped so the
* overall merge continues to the next transform pass.
*/
async function runMergiraf(files: string[]): Promise<{ solved: number; partial: number; skipped: number }> {
let solved = 0
let partial = 0
let skipped = 0
for (const file of files) {
const before = await Bun.file(file)
.text()
.catch(() => "")
if (!before) {
logger.debug(`skipping ${file}: file missing from working tree (likely delete/modify conflict)`)
skipped++
continue
}
if (!before.includes("<<<<<<< ")) {
// No text conflict markers — this is a non-textual conflict (UD/DU,
// add/add with identical content, submodule, binary, etc.). Running
// mergiraf + git add here would silently stage our side as resolved.
logger.debug(`skipping ${file}: no conflict markers (non-textual conflict, needs manual review)`)
skipped++
continue
}
const mg = await $`mergiraf solve --keep-backup=false ${file}`.quiet().nothrow()
const after = await Bun.file(file)
.text()
.catch(() => "")
if (!after) {
logger.debug(`skipping ${file}: empty after mergiraf (exit ${mg.exitCode})`)
continue
}
if (after.includes("<<<<<<< ")) {
// exit 2 = mergiraf reduced but didn't fully resolve; exit 1 = no change.
// Either way the working tree still has markers, so leave it unstaged
// for manual review rather than silently staging a half-resolved file.
logger.debug(`${file}: mergiraf left conflict markers (exit ${mg.exitCode}) — unstaged for manual review`)
if (mg.exitCode === 2) partial++
continue
}
const add = await $`git add ${file}`.quiet().nothrow()
if (add.exitCode !== 0) {
logger.debug(`${file}: git add failed (exit ${add.exitCode}) — leaving for next transform pass`)
continue
}
solved++
}
return { solved, partial, skipped }
}

function parseArgs(): MergeOptions {
const args = process.argv.slice(2)

Expand Down Expand Up @@ -139,6 +222,10 @@ async function main() {
process.exit(1)
}

if (!(await hasMergiraf())) {
abortMissingMergiraf()
}

if (await git.hasUncommittedChanges()) {
logger.error("Working directory has uncommitted changes. Please commit or stash them first.")
process.exit(1)
Expand Down Expand Up @@ -452,6 +539,29 @@ async function main() {
if (conflictedFiles.length > 0) {
logger.info("Attempting to auto-resolve remaining conflicts...")

// Step 7c-pre: syntax-aware resolution via mergiraf.
// Handles the common pattern of neighbouring import additions around
// kilocode_change markers, plus JSON/YAML/TOML key merges and other
// structural conflicts. Presence is enforced at startup.
logger.info("Running mergiraf on remaining conflicts...")
const mgResult = await runMergiraf(conflictedFiles)
if (mgResult.solved > 0) {
logger.success(`mergiraf auto-resolved ${mgResult.solved} conflict(s)`)
conflictedFiles = await git.getConflictedFiles()
} else {
logger.info("mergiraf did not fully resolve any conflicts")
}
if (mgResult.partial > 0) {
logger.info(
`mergiraf partially resolved ${mgResult.partial} file(s) — remaining markers left unstaged for manual review`,
)
}
if (mgResult.skipped > 0) {
logger.info(
`mergiraf skipped ${mgResult.skipped} file(s) with non-textual conflicts (delete/modify, binary, etc.) — left for manual review`,
)
}

// Transform i18n files
const i18nResults = await transformConflictedI18n(conflictedFiles, { dryRun: false, verbose: options.verbose })
const i18nTransformed = i18nResults.filter((r) => r.replacements > 0).length
Expand Down
7 changes: 6 additions & 1 deletion script/upstream/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,12 @@ export async function commit(message: string): Promise<void> {
}

export async function merge(branch: string): Promise<{ success: boolean; conflicts: string[] }> {
const result = await $`git merge ${branch}`.nothrow()
// Use zdiff3 markers so conflicts carry the base version (|||||||) alongside
// ours/theirs. This gives mergiraf the base it needs for structural heuristics
// and makes any remaining manual resolution dramatically easier (you can see
// what both sides changed relative to the common ancestor instead of
// reverse-engineering it from a 2-way marker).
const result = await $`git -c merge.conflictStyle=zdiff3 merge ${branch}`.nothrow()

if (result.exitCode === 0) {
return { success: true, conflicts: [] }
Expand Down
Loading