fix(upstream-merge): always reconcile package.json post-merge

git rerere learns conflict resolutions from past upstream merges and
replays them on the next merge. When past resolutions used the buggy
mergeWithNewestVersions ordering, rerere auto-resolves package.json
files with stale content before transformConflictedPackageJson ever
gets a chance to run — so the fixed merge logic never reaches the
file.

Add reconcileAllPackageJson, which runs after every merge (clean,
auto-resolved, or partially conflicted) and rewrites every package.json
that the merge touched from the kilo branch's pre-merge HEAD and the
opencode compat branch using the same merge logic. Files that are
still conflicted are skipped so manual resolution isn't silently
overwritten.

This makes our merge logic the source of truth for package.json
content, regardless of what rerere or git's textual merge produced.
This commit is contained in:
Mark IJbema
2026-05-07 17:41:58 +02:00
parent d79664f084
commit ee3ee5b06b
2 changed files with 274 additions and 1 deletions
+34 -1
View File
@@ -33,7 +33,11 @@ import { skipFiles } from "./transforms/skip-files"
import { transformConflictedI18n, transformAllI18n } from "./transforms/transform-i18n"
// New transforms for auto-resolving more conflict types
import { transformConflictedTakeTheirs, transformAllTakeTheirs } from "./transforms/transform-take-theirs"
import { transformConflictedPackageJson, transformAllPackageJson } from "./transforms/transform-package-json"
import {
transformConflictedPackageJson,
transformAllPackageJson,
reconcileAllPackageJson,
} from "./transforms/transform-package-json"
import { transformConflictedScripts, transformAllScripts } from "./transforms/transform-scripts"
import { transformConflictedExtensions, transformAllExtensions } from "./transforms/transform-extensions"
import { transformConflictedWeb, transformAllWeb } from "./transforms/transform-web"
@@ -707,6 +711,24 @@ async function main() {
}
}
// Reconcile every package.json that the merge touched, regardless of
// whether it was conflicted, auto-resolved by rerere, or merged textually.
// rerere can replay stale resolutions that bypass our package.json
// transform entirely, so always run our merge logic as the final word for
// package.json content. Skip files that are still conflicted so the user
// can resolve them manually instead of silently overwriting markers.
const stillConflicted = new Set(await git.getConflictedFiles())
const reconcileResults = await reconcileAllPackageJson({
oursRef: baseSha,
theirsRef: opencodeBranch,
verbose: options.verbose,
skip: stillConflicted,
})
const reconcileCount = reconcileResults.filter((r) => r.action === "transformed" && r.changes.length > 0).length
if (reconcileCount > 0) {
logger.success(`Reconciled ${reconcileCount} package.json file(s) post-merge`)
}
// Check remaining conflicts
const remaining = await git.getConflictedFiles()
// Combine git-reported conflicts with files flagged due to kilocode_change markers
@@ -764,6 +786,17 @@ async function main() {
}
} else {
logger.success("Merge completed without conflicts!")
// Same reconcile pass as the conflict path: ensure rerere or git's textual
// merge can't slip stale package.json resolutions through.
const reconcileResults = await reconcileAllPackageJson({
oursRef: baseSha,
theirsRef: opencodeBranch,
verbose: options.verbose,
})
const reconcileCount = reconcileResults.filter((r) => r.action === "transformed" && r.changes.length > 0).length
if (reconcileCount > 0) {
logger.success(`Reconciled ${reconcileCount} package.json file(s) post-merge`)
}
await git.stageAll()
const hasChanges = await git.hasUncommittedChanges()
if (hasChanges) {
@@ -175,6 +175,17 @@ export interface PackageJsonOptions {
preserveVersion?: boolean
}
export interface ReconcileOptions extends PackageJsonOptions {
oursRef: string
theirsRef: string
/**
* Files to skip (e.g. still-conflicted files where the user is going to
* resolve manually). The reconciler would otherwise overwrite the conflict
* markers and silently auto-resolve.
*/
skip?: Set<string>
}
// Package name mappings
const PACKAGE_NAME_MAP: Record<string, string> = {
"opencode-ai": "@kilocode/cli",
@@ -729,6 +740,235 @@ export async function transformAllPackageJson(options: PackageJsonOptions = {}):
return results
}
/**
* Reconcile a single package.json after a merge has finished, regardless of
* whether it was conflicted or auto-resolved (by rerere or git's textual
* merge). Reads ours from `oursRef` and theirs from `theirsRef`, then applies
* the same merge logic used for conflict resolution and writes the result to
* the working tree. Stages the file.
*
* This is needed because rerere can replay stale resolutions for files like
* `package.json` that include cosmetic reordering — those resolutions bypass
* `transformConflictedPackageJson` entirely. Running this reconciler after
* the merge guarantees our merge logic always wins.
*
* Returns "skipped" if neither side touched the file (or both sides match) so
* callers can avoid unnecessary churn. Returns "flagged" if ours has
* kilocode_change markers (manual review needed).
*/
export async function reconcilePackageJsonFromRefs(
file: string,
options: ReconcileOptions,
): Promise<PackageJsonResult> {
const changes: string[] = []
const dryRun = options.dryRun ?? false
if (await oursHasKilocodeChanges(file)) {
warn(`${file} has kilocode_change markers — skipping reconcile, needs manual resolution`)
return { file, action: "flagged", changes: [], dryRun }
}
let ourPkg: Record<string, unknown> | null = null
try {
const ourContent = await $`git show ${options.oursRef}:${file}`.text()
ourPkg = JSON.parse(ourContent)
} catch {
// file didn't exist in ours - that's fine
}
let pkg: Record<string, unknown> | null = null
try {
const theirContent = await $`git show ${options.theirsRef}:${file}`.text()
pkg = JSON.parse(theirContent)
} catch {
// file didn't exist in theirs either - nothing to reconcile
}
if (!pkg) {
if (!ourPkg) return { file, action: "skipped", changes: [], dryRun }
pkg = JSON.parse(JSON.stringify(ourPkg))
}
const relativePath = file.replace(process.cwd() + "/", "")
const newName = TRANSFORM_PACKAGE_NAMES[relativePath]
if (newName && pkg.name !== newName) {
changes.push(`name: ${pkg.name} -> ${newName}`)
pkg.name = newName
}
if (options.preserveVersion !== false) {
const kiloVersion = await getCurrentVersion()
if (pkg.version !== kiloVersion) {
changes.push(`version: ${pkg.version} -> ${kiloVersion}`)
pkg.version = kiloVersion
}
}
if (ourPkg) {
pkg.dependencies = mergeWithNewestVersions(
ourPkg.dependencies as Record<string, string> | undefined,
pkg.dependencies as Record<string, string> | undefined,
changes,
"dependencies",
)
pkg.devDependencies = mergeWithNewestVersions(
ourPkg.devDependencies as Record<string, string> | undefined,
pkg.devDependencies as Record<string, string> | undefined,
changes,
"devDependencies",
)
pkg.peerDependencies = mergeWithNewestVersions(
ourPkg.peerDependencies as Record<string, string> | undefined,
pkg.peerDependencies as Record<string, string> | undefined,
changes,
"peerDependencies",
)
const ourOverrides = ourPkg.overrides as Record<string, string> | undefined
if (ourOverrides || pkg.overrides) {
pkg.overrides = mergeWithNewestVersions(
ourOverrides,
pkg.overrides as Record<string, string> | undefined,
changes,
"overrides",
)
}
const ourPatched = ourPkg.patchedDependencies as Record<string, string> | undefined
if (ourPatched) {
pkg.patchedDependencies = (pkg.patchedDependencies as Record<string, string>) || {}
const patched = pkg.patchedDependencies as Record<string, string>
for (const [name, patch] of Object.entries(ourPatched)) {
if (!patched[name]) {
patched[name] = patch
changes.push(`patchedDependencies: preserved ${name}`)
}
}
}
const ourRepo = ourPkg.repository
if (ourRepo && JSON.stringify(pkg.repository) !== JSON.stringify(ourRepo)) {
pkg.repository = ourRepo
changes.push(`repository: preserved Kilo's repository configuration`)
}
const ourWs = ourPkg.workspaces as { packages?: string[]; catalog?: Record<string, string> } | undefined
const theirWs = pkg.workspaces as { packages?: string[]; catalog?: Record<string, string> } | undefined
if (relativePath === "package.json" && ourWs?.packages) {
pkg.workspaces = (pkg.workspaces as Record<string, unknown>) || {}
;(pkg.workspaces as { packages: string[] }).packages = ourWs.packages
changes.push(`workspaces.packages: preserved Kilo's workspace configuration`)
}
fixScripts(pkg, relativePath, ourPkg, changes)
if (ourWs?.catalog || theirWs?.catalog) {
pkg.workspaces = (pkg.workspaces as Record<string, unknown>) || {}
;(pkg.workspaces as { catalog: Record<string, string> }).catalog = mergeWithNewestVersions(
ourWs?.catalog,
theirWs?.catalog,
changes,
"workspaces.catalog",
)
}
fixCatalog(pkg, relativePath, changes)
}
if (pkg.dependencies) {
const { result, changes: depChanges } = transformDependencies(pkg.dependencies as Record<string, string>)
pkg.dependencies = result
changes.push(...depChanges.map((c) => `dependencies: ${c}`))
}
if (pkg.devDependencies) {
const { result, changes: devChanges } = transformDependencies(pkg.devDependencies as Record<string, string>)
if (devChanges.length > 0) {
pkg.devDependencies = result
changes.push(...devChanges.map((c) => `devDependencies: ${c}`))
}
}
if (pkg.peerDependencies) {
const { result, changes: peerChanges } = transformDependencies(pkg.peerDependencies as Record<string, string>)
if (peerChanges.length > 0) {
pkg.peerDependencies = result
changes.push(...peerChanges.map((c) => `peerDependencies: ${c}`))
}
}
const kiloDeps = KILO_DEPENDENCIES[relativePath]
if (kiloDeps) {
pkg.dependencies = (pkg.dependencies as Record<string, string>) || {}
const deps = pkg.dependencies as Record<string, string>
for (const [name, version] of Object.entries(kiloDeps)) {
if (!deps[name]) {
deps[name] = version
changes.push(`injected: ${name}`)
}
}
}
const kiloBin = KILO_BIN[relativePath]
if (kiloBin) {
pkg.bin = kiloBin
changes.push(`bin: set Kilo bin entries`)
}
if (dryRun) {
info(`[DRY-RUN] Would reconcile ${file}: ${changes.length} changes`)
return { file, action: "transformed", changes, dryRun: true }
}
const newContent = JSON.stringify(pkg, null, 2) + "\n"
await Bun.write(file, newContent)
await $`git add ${file}`.quiet().nothrow()
if (changes.length > 0) {
success(`Reconciled ${file}: ${changes.length} changes`)
if (options.verbose) {
for (const change of changes) debug(` - ${change}`)
}
}
return { file, action: "transformed", changes, dryRun: false }
}
/**
* Reconcile every package.json that differs between `oursRef` and `theirsRef`
* after a merge. This is meant to run after `git merge` (whether the merge
* was clean, conflict-resolved, or rerere-replayed) to ensure our merge logic
* is the source of truth for package.json content.
*/
export async function reconcileAllPackageJson(options: ReconcileOptions): Promise<PackageJsonResult[]> {
// Collect every package.json that differs in either direction so we cover
// upstream-only and kilo-only files alike.
const diffOurs = await $`git diff --name-only ${options.oursRef} -- '*package.json'`.text()
const diffTheirs = await $`git diff --name-only ${options.theirsRef} -- '*package.json'`.text()
const candidates = new Set<string>()
for (const line of [...diffOurs.split("\n"), ...diffTheirs.split("\n")]) {
const path = line.trim()
if (!path) continue
if (path.includes("node_modules")) continue
if (!path.endsWith("package.json")) continue
candidates.add(path)
}
const results: PackageJsonResult[] = []
for (const file of candidates) {
if (options.skip?.has(file)) {
results.push({ file, action: "skipped", changes: [], dryRun: options.dryRun ?? false })
continue
}
const f = Bun.file(file)
if (!(await f.exists())) {
// file was removed by the merge - nothing to reconcile
continue
}
results.push(await reconcilePackageJsonFromRefs(file, options))
}
return results
}
// CLI entry point
if (import.meta.main) {
const args = process.argv.slice(2)