mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user