merge.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729
  1. #!/usr/bin/env bun
  2. /**
  3. * Upstream Merge Orchestration Script
  4. *
  5. * Automates the process of merging upstream opencode changes into Kilo.
  6. *
  7. * Usage:
  8. * bun run script/upstream/merge.ts [options]
  9. *
  10. * Options:
  11. * --version <version> Target upstream version (e.g., v1.1.49)
  12. * --commit <hash> Target upstream commit hash
  13. * --base-branch <name> Base branch to merge into (default: main)
  14. * --dry-run Preview changes without applying them
  15. * --no-push Don't push branches to remote
  16. * --report-only Only generate conflict report, don't merge
  17. * --verbose Enable verbose logging
  18. * --author <name> Author name for branch prefix (default: from git config)
  19. */
  20. import { $ } from "bun"
  21. import * as git from "./utils/git"
  22. import * as logger from "./utils/logger"
  23. import * as version from "./utils/version"
  24. import * as report from "./utils/report"
  25. import { defaultConfig, loadConfig, type MergeConfig } from "./utils/config"
  26. import { transformAll as transformPackageNames } from "./transforms/package-names"
  27. import { preserveAllVersions } from "./transforms/preserve-versions"
  28. import { keepOursFiles, resetToOurs } from "./transforms/keep-ours"
  29. import { skipFiles, skipSpecificFiles } from "./transforms/skip-files"
  30. import { transformConflictedI18n, transformAllI18n } from "./transforms/transform-i18n"
  31. // New transforms for auto-resolving more conflict types
  32. import {
  33. transformConflictedTakeTheirs,
  34. shouldTakeTheirs,
  35. transformAllTakeTheirs,
  36. } from "./transforms/transform-take-theirs"
  37. import { transformConflictedTauri, isTauriFile, transformAllTauri } from "./transforms/transform-tauri"
  38. import {
  39. transformConflictedPackageJson,
  40. isPackageJson,
  41. transformAllPackageJson,
  42. } from "./transforms/transform-package-json"
  43. import { transformConflictedScripts, isScriptFile, transformAllScripts } from "./transforms/transform-scripts"
  44. import {
  45. transformConflictedExtensions,
  46. isExtensionFile,
  47. transformAllExtensions,
  48. } from "./transforms/transform-extensions"
  49. import { transformConflictedWeb, isWebFile, transformAllWeb } from "./transforms/transform-web"
  50. import { resolveLockFileConflicts, regenerateLockFiles } from "./transforms/lock-files"
  51. interface MergeOptions {
  52. version?: string
  53. commit?: string
  54. baseBranch?: string
  55. dryRun: boolean
  56. push: boolean
  57. reportOnly: boolean
  58. verbose: boolean
  59. author?: string
  60. }
  61. function parseArgs(): MergeOptions {
  62. const args = process.argv.slice(2)
  63. const options: MergeOptions = {
  64. dryRun: args.includes("--dry-run"),
  65. push: !args.includes("--no-push"),
  66. reportOnly: args.includes("--report-only"),
  67. verbose: args.includes("--verbose"),
  68. }
  69. const versionIdx = args.indexOf("--version")
  70. if (versionIdx !== -1 && args[versionIdx + 1]) {
  71. options.version = args[versionIdx + 1]
  72. }
  73. const commitIdx = args.indexOf("--commit")
  74. if (commitIdx !== -1 && args[commitIdx + 1]) {
  75. options.commit = args[commitIdx + 1]
  76. }
  77. const authorIdx = args.indexOf("--author")
  78. if (authorIdx !== -1 && args[authorIdx + 1]) {
  79. options.author = args[authorIdx + 1]
  80. }
  81. const baseBranchIdx = args.indexOf("--base-branch")
  82. if (baseBranchIdx !== -1 && args[baseBranchIdx + 1]) {
  83. options.baseBranch = args[baseBranchIdx + 1]
  84. }
  85. return options
  86. }
  87. async function getAuthor(): Promise<string> {
  88. const result = await $`git config user.name`.text()
  89. return result
  90. .trim()
  91. .normalize("NFD")
  92. .replace(/[\u0300-\u036f]/g, "")
  93. .toLowerCase()
  94. .replace(/\s+/g, "")
  95. }
  96. async function createBackupBranch(baseBranch: string): Promise<string> {
  97. const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
  98. const backupName = `backup/${baseBranch}-${timestamp}`
  99. await git.createBranch(backupName, baseBranch)
  100. await git.checkout(baseBranch)
  101. return backupName
  102. }
  103. async function main() {
  104. const options = parseArgs()
  105. const config = loadConfig(options.baseBranch ? { baseBranch: options.baseBranch } : undefined)
  106. if (options.verbose) {
  107. logger.setVerbose(true)
  108. }
  109. logger.header("Kilo Upstream Merge Tool")
  110. // Step 1: Validate environment
  111. logger.step(1, 8, "Validating environment...")
  112. if (!(await git.hasUpstreamRemote())) {
  113. logger.error("No 'upstream' remote found. Please add it:")
  114. logger.info(" git remote add upstream [email protected]:anomalyco/opencode.git")
  115. process.exit(1)
  116. }
  117. if (await git.hasUncommittedChanges()) {
  118. logger.error("Working directory has uncommitted changes. Please commit or stash them first.")
  119. process.exit(1)
  120. }
  121. const currentBranch = await git.getCurrentBranch()
  122. logger.info(`Current branch: ${currentBranch}`)
  123. // Enable git rerere so conflict resolutions are recorded and reused across merges
  124. if (!options.dryRun) {
  125. await git.ensureRerere()
  126. logger.info("git rerere enabled (resolutions will be recorded and reused automatically)")
  127. // Train rerere from past upstream merge commits so the cache is populated
  128. // even on a fresh clone. This replays past merges to learn their resolutions.
  129. logger.info("Training rerere cache from past merge history...")
  130. const learned = await git.trainRerere("merge: upstream\\|Resolve merge conflict")
  131. if (learned > 0) {
  132. logger.success(`Learned ${learned} conflict resolution(s) from history`)
  133. } else {
  134. logger.info("No new resolutions to learn from history (cache already up to date)")
  135. }
  136. }
  137. // Step 2: Fetch upstream
  138. logger.step(2, 8, "Fetching upstream...")
  139. if (!options.dryRun) {
  140. await git.fetchUpstream()
  141. }
  142. // Step 3: Determine target version/commit
  143. logger.step(3, 8, "Determining target version...")
  144. let targetVersion: version.VersionInfo | null = null
  145. if (options.commit) {
  146. targetVersion = await version.getVersionForCommit(options.commit)
  147. if (!targetVersion) {
  148. targetVersion = {
  149. version: "unknown",
  150. tag: "unknown",
  151. commit: options.commit,
  152. }
  153. }
  154. } else if (options.version) {
  155. const versions = await version.getAvailableUpstreamVersions()
  156. targetVersion = versions.find((v) => v.version === options.version || v.tag === options.version) || null
  157. if (!targetVersion) {
  158. logger.error(`Version ${options.version} not found in upstream`)
  159. logger.info("Available versions:")
  160. for (const v of versions.slice(0, 10)) {
  161. logger.info(` - ${v.tag} (${v.commit.slice(0, 8)})`)
  162. }
  163. process.exit(1)
  164. }
  165. } else {
  166. targetVersion = await version.getLatestUpstreamVersion()
  167. }
  168. if (!targetVersion) {
  169. logger.error("Could not determine target version")
  170. process.exit(1)
  171. }
  172. logger.success(`Target: ${targetVersion.tag} (${targetVersion.commit.slice(0, 8)})`)
  173. // Step 4: Generate conflict report
  174. logger.step(4, 8, "Analyzing potential conflicts...")
  175. // Use the commit hash or tag directly (tags are fetched, not remote refs)
  176. const upstreamRef = targetVersion.commit || targetVersion.tag
  177. const conflicts = await report.analyzeConflicts(upstreamRef, config.baseBranch, config.keepOurs, config.skipFiles)
  178. const conflictReport: report.ConflictReport = {
  179. timestamp: new Date().toISOString(),
  180. upstreamVersion: targetVersion.version,
  181. upstreamCommit: targetVersion.commit,
  182. baseBranch: config.baseBranch,
  183. mergeBranch: "", // Will be set later
  184. totalConflicts: conflicts.length,
  185. conflicts,
  186. recommendations: [],
  187. }
  188. // Add recommendations
  189. const skipCount = conflicts.filter((c) => c.recommendation === "skip").length
  190. const i18nCount = conflicts.filter((c) => c.recommendation === "i18n-transform").length
  191. const keepOursCount = conflicts.filter((c) => c.recommendation === "keep-ours").length
  192. const codemodCount = conflicts.filter((c) => c.recommendation === "codemod").length
  193. const manualCount = conflicts.filter((c) => c.recommendation === "manual").length
  194. if (skipCount > 0) {
  195. conflictReport.recommendations.push(`${skipCount} files will be skipped (auto-removed)`)
  196. }
  197. if (i18nCount > 0) {
  198. conflictReport.recommendations.push(`${i18nCount} i18n files will be auto-transformed`)
  199. }
  200. if (keepOursCount > 0) {
  201. conflictReport.recommendations.push(`${keepOursCount} files will keep Kilo's version`)
  202. }
  203. if (codemodCount > 0) {
  204. conflictReport.recommendations.push(`${codemodCount} files will be processed by codemods`)
  205. }
  206. if (manualCount > 0) {
  207. conflictReport.recommendations.push(`${manualCount} files require manual review`)
  208. }
  209. logger.info(`Total files changed: ${conflicts.length}`)
  210. logger.info(` - Skip (auto-remove): ${skipCount}`)
  211. logger.info(` - i18n transform: ${i18nCount}`)
  212. logger.info(` - Keep ours: ${keepOursCount}`)
  213. logger.info(` - Codemod: ${codemodCount}`)
  214. logger.info(` - Manual review: ${manualCount}`)
  215. if (options.reportOnly) {
  216. const reportPath = `upstream-merge-report-${targetVersion.version}.md`
  217. await report.saveReport(conflictReport, reportPath)
  218. logger.success(`Report saved to ${reportPath}`)
  219. process.exit(0)
  220. }
  221. if (options.dryRun) {
  222. logger.info("[DRY-RUN] Would proceed with merge")
  223. const reportPath = `upstream-merge-report-${targetVersion.version}.md`
  224. await report.saveReport(conflictReport, reportPath)
  225. logger.success(`Report saved to ${reportPath}`)
  226. process.exit(0)
  227. }
  228. // Step 5: Create branches
  229. logger.step(5, 8, "Creating branches...")
  230. const author = options.author || (await getAuthor())
  231. const kiloVersion = await version.getCurrentKiloVersion()
  232. const dirs = ["packages/ui/src/assets/icons/provider", "packages/ui/src/components/provider-icons"]
  233. logger.info("Resetting generated provider icons before checkout...")
  234. await git.restoreDirectories(dirs)
  235. await git.cleanDirectories(dirs)
  236. // Create backup branch
  237. await git.checkout(config.baseBranch)
  238. await git.pull(config.originRemote)
  239. const backupBranch = await createBackupBranch(config.baseBranch)
  240. logger.info(`Created backup branch: ${backupBranch}`)
  241. // Create Kilo merge branch
  242. const kiloBranch = `${author}/kilo-opencode-${targetVersion.tag}`
  243. const kiloBackup = await git.backupAndDeleteBranch(kiloBranch)
  244. if (kiloBackup) {
  245. logger.info(`Backed up existing branch to: ${kiloBackup}`)
  246. }
  247. await git.createBranch(kiloBranch)
  248. if (options.push) {
  249. await git.push(config.originRemote, kiloBranch, true)
  250. }
  251. logger.info(`Created Kilo branch: ${kiloBranch}`)
  252. // Create opencode compatibility branch from upstream commit
  253. const opencodeBranch = `${author}/opencode-${targetVersion.tag}`
  254. const opencodeBackup = await git.backupAndDeleteBranch(opencodeBranch)
  255. if (opencodeBackup) {
  256. logger.info(`Backed up existing branch to: ${opencodeBackup}`)
  257. }
  258. await git.checkout(targetVersion.commit)
  259. await git.createBranch(opencodeBranch)
  260. logger.info(`Created opencode branch: ${opencodeBranch}`)
  261. // Step 6: Apply ALL transformations to opencode branch (pre-merge)
  262. // This reduces conflicts by transforming upstream code to Kilo conventions BEFORE merging
  263. logger.step(6, 8, "Applying transformations to opencode branch (pre-merge)...")
  264. // 6a. Transform package names (opencode-ai -> @kilocode/cli)
  265. logger.info("Transforming package names...")
  266. const nameResults = await transformPackageNames({ dryRun: false, verbose: options.verbose })
  267. logger.success(`Transformed ${nameResults.length} files`)
  268. // 6b. Preserve Kilo versions
  269. logger.info("Preserving Kilo versions...")
  270. const versionResults = await preserveAllVersions({
  271. dryRun: false,
  272. verbose: options.verbose,
  273. targetVersion: kiloVersion,
  274. })
  275. logger.success(`Preserved versions in ${versionResults.length} files`)
  276. // 6c. Transform i18n files (OpenCode -> Kilo branding)
  277. logger.info("Transforming i18n files...")
  278. const i18nPreResults = await transformAllI18n({ dryRun: false, verbose: options.verbose })
  279. const i18nPreCount = i18nPreResults.filter((r) => r.replacements > 0).length
  280. if (i18nPreCount > 0) {
  281. logger.success(`Transformed ${i18nPreCount} i18n files with Kilo branding`)
  282. }
  283. // 6d. Transform branding-only files (take-theirs patterns)
  284. logger.info("Transforming branding-only files...")
  285. const brandingResults = await transformAllTakeTheirs({ dryRun: false, verbose: options.verbose })
  286. const brandingCount = brandingResults.filter((r) => r.action === "transformed" && r.replacements > 0).length
  287. if (brandingCount > 0) {
  288. logger.success(`Transformed ${brandingCount} files with Kilo branding`)
  289. }
  290. // 6e. Transform Tauri/Desktop config files
  291. logger.info("Transforming Tauri/Desktop config files...")
  292. const tauriPreResults = await transformAllTauri({ dryRun: false, verbose: options.verbose })
  293. const tauriPreCount = tauriPreResults.filter((r) => r.action === "transformed" && r.replacements > 0).length
  294. if (tauriPreCount > 0) {
  295. logger.success(`Transformed ${tauriPreCount} Tauri config files`)
  296. }
  297. // 6f. Transform package.json files (names, deps, Kilo injections)
  298. logger.info("Transforming package.json files...")
  299. const pkgPreResults = await transformAllPackageJson({ dryRun: false, verbose: options.verbose })
  300. const pkgPreCount = pkgPreResults.filter((r) => r.action === "transformed" && r.changes.length > 0).length
  301. if (pkgPreCount > 0) {
  302. logger.success(`Transformed ${pkgPreCount} package.json files`)
  303. }
  304. // 6g. Transform script files (GitHub API references)
  305. logger.info("Transforming script files...")
  306. const scriptPreResults = await transformAllScripts({ dryRun: false, verbose: options.verbose })
  307. const scriptPreCount = scriptPreResults.filter((r) => r.action === "transformed" && r.replacements > 0).length
  308. if (scriptPreCount > 0) {
  309. logger.success(`Transformed ${scriptPreCount} script files`)
  310. }
  311. // 6h. Transform extension files (Zed, etc.)
  312. logger.info("Transforming extension files...")
  313. const extPreResults = await transformAllExtensions({ dryRun: false, verbose: options.verbose })
  314. const extPreCount = extPreResults.filter((r) => r.action === "transformed" && r.replacements > 0).length
  315. if (extPreCount > 0) {
  316. logger.success(`Transformed ${extPreCount} extension files`)
  317. }
  318. // 6i. Transform web/docs files
  319. logger.info("Transforming web/docs files...")
  320. const webPreResults = await transformAllWeb({ dryRun: false, verbose: options.verbose })
  321. const webPreCount = webPreResults.filter((r) => r.action === "transformed" && r.replacements > 0).length
  322. if (webPreCount > 0) {
  323. logger.success(`Transformed ${webPreCount} web/docs files`)
  324. }
  325. // 6j. Reset keep-ours files to Kilo's version
  326. logger.info("Resetting Kilo-specific files...")
  327. const keepOursResults = await resetToOurs(config.keepOurs, { dryRun: false, verbose: options.verbose })
  328. logger.success(`Reset ${keepOursResults.length} files to Kilo's version`)
  329. // Clean untracked build artifacts from Kilo-specific directories.
  330. // These packages don't exist in upstream, so their .gitignore files are absent
  331. // on the opencode branch. Artifacts like bin/, out/, .next/ etc. would otherwise
  332. // be picked up by the git add -A below.
  333. logger.info("Cleaning Kilo-specific directory artifacts...")
  334. await git.cleanDirectories(config.kiloDirectories)
  335. // Commit all transformations
  336. await git.stageAll()
  337. await git.commit(`refactor: kilo compat for ${targetVersion.tag}`)
  338. logger.success("Committed pre-merge transformations")
  339. // Step 7: Merge into Kilo branch
  340. logger.step(7, 8, "Merging into Kilo branch...")
  341. await git.checkout(kiloBranch)
  342. const mergeResult = await git.merge(opencodeBranch)
  343. if (!mergeResult.success) {
  344. logger.warn("Merge has conflicts (these should only be files with actual code differences)")
  345. logger.info("Conflicted files:")
  346. logger.list(mergeResult.conflicts)
  347. // Check if git rerere already auto-resolved any conflicts from recorded history.
  348. // rerere.autoupdate stages them automatically; we just log how many were handled.
  349. const rerereResolved = await git.getRerereResolved()
  350. if (rerereResolved.length > 0) {
  351. logger.success(`git rerere auto-resolved ${rerereResolved.length} conflict(s) from recorded history:`)
  352. logger.list(rerereResolved)
  353. }
  354. // Since we applied all branding transforms pre-merge, remaining conflicts should be minimal.
  355. // These are likely files with kilocode_change markers or actual logic differences.
  356. // Step 7a: Skip files that shouldn't exist in Kilo
  357. logger.info("Removing files that shouldn't exist in Kilo...")
  358. const skipResults = await skipFiles({ dryRun: false, verbose: options.verbose })
  359. const skippedCount = skipResults.filter((r) => r.action === "removed").length
  360. if (skippedCount > 0) {
  361. logger.success(`Skipped ${skippedCount} files (removed from merge)`)
  362. }
  363. // Step 7b: Auto-resolve keep-ours conflicts
  364. logger.info("Keeping Kilo-specific files...")
  365. const resolved = await keepOursFiles({ dryRun: false, verbose: options.verbose })
  366. const autoResolved = resolved.filter((r) => r.action === "kept")
  367. if (autoResolved.length > 0) {
  368. logger.success(`Auto-resolved ${autoResolved.length} conflicts (kept Kilo's version)`)
  369. }
  370. // Step 7c: Try to auto-resolve remaining conflicts with post-merge transforms
  371. // These handle edge cases where pre-merge transforms might have missed something.
  372. // Files with kilocode_change markers are flagged for manual resolution instead.
  373. let conflictedFiles = await git.getConflictedFiles()
  374. const flaggedFiles: string[] = []
  375. if (conflictedFiles.length > 0) {
  376. logger.info("Attempting to auto-resolve remaining conflicts...")
  377. // Transform i18n files
  378. const i18nResults = await transformConflictedI18n(conflictedFiles, { dryRun: false, verbose: options.verbose })
  379. const i18nTransformed = i18nResults.filter((r) => r.replacements > 0).length
  380. if (i18nTransformed > 0) {
  381. logger.success(`Auto-resolved ${i18nTransformed} i18n conflicts`)
  382. }
  383. const i18nFlagged = i18nResults.filter((r) => r.flagged).map((r) => r.file)
  384. if (i18nFlagged.length > 0) {
  385. logger.warn(`${i18nFlagged.length} i18n file(s) have kilocode_change markers — flagged for manual resolution`)
  386. flaggedFiles.push(...i18nFlagged)
  387. }
  388. // Transform branding-only files
  389. conflictedFiles = await git.getConflictedFiles()
  390. if (conflictedFiles.length > 0) {
  391. const takeTheirsResults = await transformConflictedTakeTheirs(conflictedFiles, {
  392. dryRun: false,
  393. verbose: options.verbose,
  394. })
  395. const takeTheirsCount = takeTheirsResults.filter((r) => r.action === "transformed").length
  396. if (takeTheirsCount > 0) {
  397. logger.success(`Auto-resolved ${takeTheirsCount} branding conflicts`)
  398. }
  399. const takeFlagged = takeTheirsResults.filter((r) => r.action === "flagged").map((r) => r.file)
  400. if (takeFlagged.length > 0) {
  401. logger.warn(
  402. `${takeFlagged.length} branding file(s) have kilocode_change markers — flagged for manual resolution`,
  403. )
  404. flaggedFiles.push(...takeFlagged)
  405. }
  406. }
  407. // Transform Tauri files
  408. conflictedFiles = await git.getConflictedFiles()
  409. if (conflictedFiles.length > 0) {
  410. const tauriResults = await transformConflictedTauri(conflictedFiles, {
  411. dryRun: false,
  412. verbose: options.verbose,
  413. })
  414. const tauriCount = tauriResults.filter((r) => r.action === "transformed").length
  415. if (tauriCount > 0) {
  416. logger.success(`Auto-resolved ${tauriCount} Tauri conflicts`)
  417. }
  418. const tauriFlagged = tauriResults.filter((r) => r.action === "flagged").map((r) => r.file)
  419. if (tauriFlagged.length > 0) {
  420. logger.warn(
  421. `${tauriFlagged.length} Tauri file(s) have kilocode_change markers — flagged for manual resolution`,
  422. )
  423. flaggedFiles.push(...tauriFlagged)
  424. }
  425. }
  426. // Transform package.json files
  427. conflictedFiles = await git.getConflictedFiles()
  428. if (conflictedFiles.length > 0) {
  429. const pkgResults = await transformConflictedPackageJson(conflictedFiles, {
  430. dryRun: false,
  431. verbose: options.verbose,
  432. })
  433. const pkgCount = pkgResults.filter((r) => r.action === "transformed").length
  434. if (pkgCount > 0) {
  435. logger.success(`Auto-resolved ${pkgCount} package.json conflicts`)
  436. }
  437. const pkgFlagged = pkgResults.filter((r) => r.action === "flagged").map((r) => r.file)
  438. if (pkgFlagged.length > 0) {
  439. logger.warn(
  440. `${pkgFlagged.length} package.json file(s) have kilocode_change markers — flagged for manual resolution`,
  441. )
  442. flaggedFiles.push(...pkgFlagged)
  443. }
  444. }
  445. // Transform script files
  446. conflictedFiles = await git.getConflictedFiles()
  447. if (conflictedFiles.length > 0) {
  448. const scriptResults = await transformConflictedScripts(conflictedFiles, {
  449. dryRun: false,
  450. verbose: options.verbose,
  451. })
  452. const scriptCount = scriptResults.filter((r) => r.action === "transformed").length
  453. if (scriptCount > 0) {
  454. logger.success(`Auto-resolved ${scriptCount} script conflicts`)
  455. }
  456. const scriptFlagged = scriptResults.filter((r) => r.action === "flagged").map((r) => r.file)
  457. if (scriptFlagged.length > 0) {
  458. logger.warn(
  459. `${scriptFlagged.length} script file(s) have kilocode_change markers — flagged for manual resolution`,
  460. )
  461. flaggedFiles.push(...scriptFlagged)
  462. }
  463. }
  464. // Transform extension files
  465. conflictedFiles = await git.getConflictedFiles()
  466. if (conflictedFiles.length > 0) {
  467. const extResults = await transformConflictedExtensions(conflictedFiles, {
  468. dryRun: false,
  469. verbose: options.verbose,
  470. })
  471. const extCount = extResults.filter((r) => r.action === "transformed").length
  472. if (extCount > 0) {
  473. logger.success(`Auto-resolved ${extCount} extension conflicts`)
  474. }
  475. const extFlagged = extResults.filter((r) => r.action === "flagged").map((r) => r.file)
  476. if (extFlagged.length > 0) {
  477. logger.warn(
  478. `${extFlagged.length} extension file(s) have kilocode_change markers — flagged for manual resolution`,
  479. )
  480. flaggedFiles.push(...extFlagged)
  481. }
  482. }
  483. // Transform web/docs files
  484. conflictedFiles = await git.getConflictedFiles()
  485. if (conflictedFiles.length > 0) {
  486. const webResults = await transformConflictedWeb(conflictedFiles, {
  487. dryRun: false,
  488. verbose: options.verbose,
  489. })
  490. const webCount = webResults.filter((r) => r.action === "transformed").length
  491. if (webCount > 0) {
  492. logger.success(`Auto-resolved ${webCount} web/docs conflicts`)
  493. }
  494. const webFlagged = webResults.filter((r) => r.action === "flagged").map((r) => r.file)
  495. if (webFlagged.length > 0) {
  496. logger.warn(
  497. `${webFlagged.length} web/docs file(s) have kilocode_change markers — flagged for manual resolution`,
  498. )
  499. flaggedFiles.push(...webFlagged)
  500. }
  501. }
  502. // Resolve lock file conflicts (accept ours, will regenerate later)
  503. conflictedFiles = await git.getConflictedFiles()
  504. if (conflictedFiles.length > 0) {
  505. const lockResults = await resolveLockFileConflicts({
  506. dryRun: false,
  507. verbose: options.verbose,
  508. })
  509. const lockCount = lockResults.filter((r) => r.action === "resolved").length
  510. if (lockCount > 0) {
  511. logger.success(`Resolved ${lockCount} lock file conflicts (will regenerate)`)
  512. }
  513. }
  514. }
  515. // Check remaining conflicts
  516. const remaining = await git.getConflictedFiles()
  517. // Combine git-reported conflicts with files flagged due to kilocode_change markers
  518. const allManual = [...new Set([...remaining, ...flaggedFiles])]
  519. if (allManual.length > 0) {
  520. if (flaggedFiles.length > 0) {
  521. logger.warn(`${flaggedFiles.length} file(s) were flagged because they contain kilocode_change markers:`)
  522. logger.list(flaggedFiles)
  523. logger.info(" These files have intentional Kilo-specific changes. Keep our version or merge carefully.")
  524. logger.info("")
  525. }
  526. if (remaining.length > 0) {
  527. logger.warn(`${remaining.length} conflict(s) still require manual resolution:`)
  528. logger.list(remaining)
  529. }
  530. logger.info("")
  531. logger.info("These conflicts contain kilocode_change markers or actual code differences.")
  532. logger.info("After resolving conflicts, run:")
  533. logger.info(" git add -A && git commit -m 'resolve merge conflicts'")
  534. // Save report before exiting so user has documentation
  535. conflictReport.mergeBranch = kiloBranch
  536. const reportPath = `upstream-merge-report-${targetVersion.version}.md`
  537. await report.saveReport(conflictReport, reportPath)
  538. logger.success(`Report saved to ${reportPath}`)
  539. logger.divider()
  540. logger.info("Next steps:")
  541. logger.info(" 1. Resolve remaining conflicts manually")
  542. logger.info(" 2. git add -A && git commit -m 'resolve merge conflicts'")
  543. logger.info(` 3. git push ${config.originRemote} ${kiloBranch}`)
  544. logger.info(" 4. Create PR from " + kiloBranch + " to " + config.baseBranch)
  545. logger.info("")
  546. logger.info("To rollback:")
  547. logger.info(` git checkout ${config.baseBranch}`)
  548. logger.info(` git reset --hard ${backupBranch}`)
  549. // Exit early - don't continue to finalization steps
  550. process.exit(1)
  551. } else {
  552. await git.stageAll()
  553. await git.commit(`merge: upstream ${targetVersion.tag}`)
  554. logger.success("Merge completed - all conflicts auto-resolved!")
  555. }
  556. } else {
  557. logger.success("Merge completed without conflicts!")
  558. await git.stageAll()
  559. const hasChanges = await git.hasUncommittedChanges()
  560. if (hasChanges) {
  561. await git.commit(`merge: upstream ${targetVersion.tag}`)
  562. }
  563. }
  564. // Step 8: Regenerate lock files and finalize
  565. logger.step(8, 8, "Regenerating lock files and finalizing...")
  566. // Regenerate lock files (bun.lock, Cargo.lock, etc.)
  567. const lockRegenResults = await regenerateLockFiles({ dryRun: false, verbose: options.verbose })
  568. const regeneratedCount = lockRegenResults.filter((r) => r.action === "regenerated").length
  569. if (regeneratedCount > 0) {
  570. logger.success(`Regenerated ${regeneratedCount} lock file(s)`)
  571. // Stage and commit the regenerated lock files
  572. await git.stageAll()
  573. const hasLockChanges = await git.hasUncommittedChanges()
  574. if (hasLockChanges) {
  575. await git.commit("chore: regenerate lock files after upstream merge")
  576. logger.success("Committed regenerated lock files")
  577. }
  578. }
  579. // Regenerate OpenAPI spec and SDK (keeps generated files in sync with merged code)
  580. logger.info("Regenerating OpenAPI spec and SDK...")
  581. const regenResult = await $`bun ./script/generate.ts`.quiet().nothrow()
  582. if (regenResult.exitCode === 0) {
  583. logger.success("Regenerated OpenAPI spec and SDK")
  584. await git.stageAll()
  585. const hasSpecChanges = await git.hasUncommittedChanges()
  586. if (hasSpecChanges) {
  587. await git.commit("chore: regenerate openapi spec and sdk after upstream merge")
  588. logger.success("Committed regenerated OpenAPI spec and SDK")
  589. }
  590. } else {
  591. logger.warn("OpenAPI spec regeneration failed — run ./script/generate.ts manually after resolving any issues")
  592. logger.warn(regenResult.stderr.toString().trim())
  593. }
  594. if (options.push) {
  595. await git.push(config.originRemote, kiloBranch)
  596. logger.success(`Pushed ${kiloBranch} to ${config.originRemote}`)
  597. }
  598. // Update merge branch in report
  599. conflictReport.mergeBranch = kiloBranch
  600. // Save final report
  601. const reportPath = `upstream-merge-report-${targetVersion.version}.md`
  602. await report.saveReport(conflictReport, reportPath)
  603. logger.success(`Report saved to ${reportPath}`)
  604. // Summary
  605. logger.divider()
  606. logger.header("Merge Summary")
  607. logger.info(`Upstream version: ${targetVersion.tag}`)
  608. logger.info(`Kilo branch: ${kiloBranch}`)
  609. logger.info(`Opencode branch: ${opencodeBranch}`)
  610. logger.info(`Backup branch: ${backupBranch}`)
  611. logger.info(`Report: ${reportPath}`)
  612. const remainingConflicts = await git.getConflictedFiles()
  613. if (remainingConflicts.length > 0) {
  614. logger.warn(`${remainingConflicts.length} conflicts need manual resolution`)
  615. } else {
  616. logger.success("All conflicts resolved")
  617. }
  618. logger.divider()
  619. logger.info("Next steps:")
  620. if (remainingConflicts.length > 0) {
  621. logger.info(" 1. Resolve remaining conflicts")
  622. logger.info(" 2. git add -A && git commit -m 'resolve merge conflicts'")
  623. logger.info(` 3. git push ${config.originRemote} ${kiloBranch}`)
  624. logger.info(" 4. Create PR from " + kiloBranch + " to " + config.baseBranch)
  625. } else {
  626. logger.info(" 1. Review changes")
  627. logger.info(" 2. Create PR from " + kiloBranch + " to " + config.baseBranch)
  628. }
  629. logger.info("")
  630. logger.info("To rollback:")
  631. logger.info(` git checkout ${config.baseBranch}`)
  632. logger.info(` git reset --hard ${backupBranch}`)
  633. }
  634. main().catch((err) => {
  635. logger.error(`Fatal error: ${err}`)
  636. process.exit(1)
  637. })