| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729 |
- #!/usr/bin/env bun
- /**
- * Upstream Merge Orchestration Script
- *
- * Automates the process of merging upstream opencode changes into Kilo.
- *
- * Usage:
- * bun run script/upstream/merge.ts [options]
- *
- * Options:
- * --version <version> Target upstream version (e.g., v1.1.49)
- * --commit <hash> Target upstream commit hash
- * --base-branch <name> Base branch to merge into (default: main)
- * --dry-run Preview changes without applying them
- * --no-push Don't push branches to remote
- * --report-only Only generate conflict report, don't merge
- * --verbose Enable verbose logging
- * --author <name> Author name for branch prefix (default: from git config)
- */
- import { $ } from "bun"
- import * as git from "./utils/git"
- import * as logger from "./utils/logger"
- import * as version from "./utils/version"
- import * as report from "./utils/report"
- import { defaultConfig, loadConfig, type MergeConfig } from "./utils/config"
- import { transformAll as transformPackageNames } from "./transforms/package-names"
- import { preserveAllVersions } from "./transforms/preserve-versions"
- import { keepOursFiles, resetToOurs } from "./transforms/keep-ours"
- import { skipFiles, skipSpecificFiles } from "./transforms/skip-files"
- import { transformConflictedI18n, transformAllI18n } from "./transforms/transform-i18n"
- // New transforms for auto-resolving more conflict types
- import {
- transformConflictedTakeTheirs,
- shouldTakeTheirs,
- transformAllTakeTheirs,
- } from "./transforms/transform-take-theirs"
- import { transformConflictedTauri, isTauriFile, transformAllTauri } from "./transforms/transform-tauri"
- import {
- transformConflictedPackageJson,
- isPackageJson,
- transformAllPackageJson,
- } from "./transforms/transform-package-json"
- import { transformConflictedScripts, isScriptFile, transformAllScripts } from "./transforms/transform-scripts"
- import {
- transformConflictedExtensions,
- isExtensionFile,
- transformAllExtensions,
- } from "./transforms/transform-extensions"
- import { transformConflictedWeb, isWebFile, transformAllWeb } from "./transforms/transform-web"
- import { resolveLockFileConflicts, regenerateLockFiles } from "./transforms/lock-files"
- interface MergeOptions {
- version?: string
- commit?: string
- baseBranch?: string
- dryRun: boolean
- push: boolean
- reportOnly: boolean
- verbose: boolean
- author?: string
- }
- function parseArgs(): MergeOptions {
- const args = process.argv.slice(2)
- const options: MergeOptions = {
- dryRun: args.includes("--dry-run"),
- push: !args.includes("--no-push"),
- reportOnly: args.includes("--report-only"),
- verbose: args.includes("--verbose"),
- }
- const versionIdx = args.indexOf("--version")
- if (versionIdx !== -1 && args[versionIdx + 1]) {
- options.version = args[versionIdx + 1]
- }
- const commitIdx = args.indexOf("--commit")
- if (commitIdx !== -1 && args[commitIdx + 1]) {
- options.commit = args[commitIdx + 1]
- }
- const authorIdx = args.indexOf("--author")
- if (authorIdx !== -1 && args[authorIdx + 1]) {
- options.author = args[authorIdx + 1]
- }
- const baseBranchIdx = args.indexOf("--base-branch")
- if (baseBranchIdx !== -1 && args[baseBranchIdx + 1]) {
- options.baseBranch = args[baseBranchIdx + 1]
- }
- return options
- }
- async function getAuthor(): Promise<string> {
- const result = await $`git config user.name`.text()
- return result
- .trim()
- .normalize("NFD")
- .replace(/[\u0300-\u036f]/g, "")
- .toLowerCase()
- .replace(/\s+/g, "")
- }
- async function createBackupBranch(baseBranch: string): Promise<string> {
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
- const backupName = `backup/${baseBranch}-${timestamp}`
- await git.createBranch(backupName, baseBranch)
- await git.checkout(baseBranch)
- return backupName
- }
- async function main() {
- const options = parseArgs()
- const config = loadConfig(options.baseBranch ? { baseBranch: options.baseBranch } : undefined)
- if (options.verbose) {
- logger.setVerbose(true)
- }
- logger.header("Kilo Upstream Merge Tool")
- // Step 1: Validate environment
- logger.step(1, 8, "Validating environment...")
- if (!(await git.hasUpstreamRemote())) {
- logger.error("No 'upstream' remote found. Please add it:")
- logger.info(" git remote add upstream [email protected]:anomalyco/opencode.git")
- process.exit(1)
- }
- if (await git.hasUncommittedChanges()) {
- logger.error("Working directory has uncommitted changes. Please commit or stash them first.")
- process.exit(1)
- }
- const currentBranch = await git.getCurrentBranch()
- logger.info(`Current branch: ${currentBranch}`)
- // Enable git rerere so conflict resolutions are recorded and reused across merges
- if (!options.dryRun) {
- await git.ensureRerere()
- logger.info("git rerere enabled (resolutions will be recorded and reused automatically)")
- // Train rerere from past upstream merge commits so the cache is populated
- // even on a fresh clone. This replays past merges to learn their resolutions.
- logger.info("Training rerere cache from past merge history...")
- const learned = await git.trainRerere("merge: upstream\\|Resolve merge conflict")
- if (learned > 0) {
- logger.success(`Learned ${learned} conflict resolution(s) from history`)
- } else {
- logger.info("No new resolutions to learn from history (cache already up to date)")
- }
- }
- // Step 2: Fetch upstream
- logger.step(2, 8, "Fetching upstream...")
- if (!options.dryRun) {
- await git.fetchUpstream()
- }
- // Step 3: Determine target version/commit
- logger.step(3, 8, "Determining target version...")
- let targetVersion: version.VersionInfo | null = null
- if (options.commit) {
- targetVersion = await version.getVersionForCommit(options.commit)
- if (!targetVersion) {
- targetVersion = {
- version: "unknown",
- tag: "unknown",
- commit: options.commit,
- }
- }
- } else if (options.version) {
- const versions = await version.getAvailableUpstreamVersions()
- targetVersion = versions.find((v) => v.version === options.version || v.tag === options.version) || null
- if (!targetVersion) {
- logger.error(`Version ${options.version} not found in upstream`)
- logger.info("Available versions:")
- for (const v of versions.slice(0, 10)) {
- logger.info(` - ${v.tag} (${v.commit.slice(0, 8)})`)
- }
- process.exit(1)
- }
- } else {
- targetVersion = await version.getLatestUpstreamVersion()
- }
- if (!targetVersion) {
- logger.error("Could not determine target version")
- process.exit(1)
- }
- logger.success(`Target: ${targetVersion.tag} (${targetVersion.commit.slice(0, 8)})`)
- // Step 4: Generate conflict report
- logger.step(4, 8, "Analyzing potential conflicts...")
- // Use the commit hash or tag directly (tags are fetched, not remote refs)
- const upstreamRef = targetVersion.commit || targetVersion.tag
- const conflicts = await report.analyzeConflicts(upstreamRef, config.baseBranch, config.keepOurs, config.skipFiles)
- const conflictReport: report.ConflictReport = {
- timestamp: new Date().toISOString(),
- upstreamVersion: targetVersion.version,
- upstreamCommit: targetVersion.commit,
- baseBranch: config.baseBranch,
- mergeBranch: "", // Will be set later
- totalConflicts: conflicts.length,
- conflicts,
- recommendations: [],
- }
- // Add recommendations
- const skipCount = conflicts.filter((c) => c.recommendation === "skip").length
- const i18nCount = conflicts.filter((c) => c.recommendation === "i18n-transform").length
- const keepOursCount = conflicts.filter((c) => c.recommendation === "keep-ours").length
- const codemodCount = conflicts.filter((c) => c.recommendation === "codemod").length
- const manualCount = conflicts.filter((c) => c.recommendation === "manual").length
- if (skipCount > 0) {
- conflictReport.recommendations.push(`${skipCount} files will be skipped (auto-removed)`)
- }
- if (i18nCount > 0) {
- conflictReport.recommendations.push(`${i18nCount} i18n files will be auto-transformed`)
- }
- if (keepOursCount > 0) {
- conflictReport.recommendations.push(`${keepOursCount} files will keep Kilo's version`)
- }
- if (codemodCount > 0) {
- conflictReport.recommendations.push(`${codemodCount} files will be processed by codemods`)
- }
- if (manualCount > 0) {
- conflictReport.recommendations.push(`${manualCount} files require manual review`)
- }
- logger.info(`Total files changed: ${conflicts.length}`)
- logger.info(` - Skip (auto-remove): ${skipCount}`)
- logger.info(` - i18n transform: ${i18nCount}`)
- logger.info(` - Keep ours: ${keepOursCount}`)
- logger.info(` - Codemod: ${codemodCount}`)
- logger.info(` - Manual review: ${manualCount}`)
- if (options.reportOnly) {
- const reportPath = `upstream-merge-report-${targetVersion.version}.md`
- await report.saveReport(conflictReport, reportPath)
- logger.success(`Report saved to ${reportPath}`)
- process.exit(0)
- }
- if (options.dryRun) {
- logger.info("[DRY-RUN] Would proceed with merge")
- const reportPath = `upstream-merge-report-${targetVersion.version}.md`
- await report.saveReport(conflictReport, reportPath)
- logger.success(`Report saved to ${reportPath}`)
- process.exit(0)
- }
- // Step 5: Create branches
- logger.step(5, 8, "Creating branches...")
- const author = options.author || (await getAuthor())
- const kiloVersion = await version.getCurrentKiloVersion()
- const dirs = ["packages/ui/src/assets/icons/provider", "packages/ui/src/components/provider-icons"]
- logger.info("Resetting generated provider icons before checkout...")
- await git.restoreDirectories(dirs)
- await git.cleanDirectories(dirs)
- // Create backup branch
- await git.checkout(config.baseBranch)
- await git.pull(config.originRemote)
- const backupBranch = await createBackupBranch(config.baseBranch)
- logger.info(`Created backup branch: ${backupBranch}`)
- // Create Kilo merge branch
- const kiloBranch = `${author}/kilo-opencode-${targetVersion.tag}`
- const kiloBackup = await git.backupAndDeleteBranch(kiloBranch)
- if (kiloBackup) {
- logger.info(`Backed up existing branch to: ${kiloBackup}`)
- }
- await git.createBranch(kiloBranch)
- if (options.push) {
- await git.push(config.originRemote, kiloBranch, true)
- }
- logger.info(`Created Kilo branch: ${kiloBranch}`)
- // Create opencode compatibility branch from upstream commit
- const opencodeBranch = `${author}/opencode-${targetVersion.tag}`
- const opencodeBackup = await git.backupAndDeleteBranch(opencodeBranch)
- if (opencodeBackup) {
- logger.info(`Backed up existing branch to: ${opencodeBackup}`)
- }
- await git.checkout(targetVersion.commit)
- await git.createBranch(opencodeBranch)
- logger.info(`Created opencode branch: ${opencodeBranch}`)
- // Step 6: Apply ALL transformations to opencode branch (pre-merge)
- // This reduces conflicts by transforming upstream code to Kilo conventions BEFORE merging
- logger.step(6, 8, "Applying transformations to opencode branch (pre-merge)...")
- // 6a. Transform package names (opencode-ai -> @kilocode/cli)
- logger.info("Transforming package names...")
- const nameResults = await transformPackageNames({ dryRun: false, verbose: options.verbose })
- logger.success(`Transformed ${nameResults.length} files`)
- // 6b. Preserve Kilo versions
- logger.info("Preserving Kilo versions...")
- const versionResults = await preserveAllVersions({
- dryRun: false,
- verbose: options.verbose,
- targetVersion: kiloVersion,
- })
- logger.success(`Preserved versions in ${versionResults.length} files`)
- // 6c. Transform i18n files (OpenCode -> Kilo branding)
- logger.info("Transforming i18n files...")
- const i18nPreResults = await transformAllI18n({ dryRun: false, verbose: options.verbose })
- const i18nPreCount = i18nPreResults.filter((r) => r.replacements > 0).length
- if (i18nPreCount > 0) {
- logger.success(`Transformed ${i18nPreCount} i18n files with Kilo branding`)
- }
- // 6d. Transform branding-only files (take-theirs patterns)
- logger.info("Transforming branding-only files...")
- const brandingResults = await transformAllTakeTheirs({ dryRun: false, verbose: options.verbose })
- const brandingCount = brandingResults.filter((r) => r.action === "transformed" && r.replacements > 0).length
- if (brandingCount > 0) {
- logger.success(`Transformed ${brandingCount} files with Kilo branding`)
- }
- // 6e. Transform Tauri/Desktop config files
- logger.info("Transforming Tauri/Desktop config files...")
- const tauriPreResults = await transformAllTauri({ dryRun: false, verbose: options.verbose })
- const tauriPreCount = tauriPreResults.filter((r) => r.action === "transformed" && r.replacements > 0).length
- if (tauriPreCount > 0) {
- logger.success(`Transformed ${tauriPreCount} Tauri config files`)
- }
- // 6f. Transform package.json files (names, deps, Kilo injections)
- logger.info("Transforming package.json files...")
- const pkgPreResults = await transformAllPackageJson({ dryRun: false, verbose: options.verbose })
- const pkgPreCount = pkgPreResults.filter((r) => r.action === "transformed" && r.changes.length > 0).length
- if (pkgPreCount > 0) {
- logger.success(`Transformed ${pkgPreCount} package.json files`)
- }
- // 6g. Transform script files (GitHub API references)
- logger.info("Transforming script files...")
- const scriptPreResults = await transformAllScripts({ dryRun: false, verbose: options.verbose })
- const scriptPreCount = scriptPreResults.filter((r) => r.action === "transformed" && r.replacements > 0).length
- if (scriptPreCount > 0) {
- logger.success(`Transformed ${scriptPreCount} script files`)
- }
- // 6h. Transform extension files (Zed, etc.)
- logger.info("Transforming extension files...")
- const extPreResults = await transformAllExtensions({ dryRun: false, verbose: options.verbose })
- const extPreCount = extPreResults.filter((r) => r.action === "transformed" && r.replacements > 0).length
- if (extPreCount > 0) {
- logger.success(`Transformed ${extPreCount} extension files`)
- }
- // 6i. Transform web/docs files
- logger.info("Transforming web/docs files...")
- const webPreResults = await transformAllWeb({ dryRun: false, verbose: options.verbose })
- const webPreCount = webPreResults.filter((r) => r.action === "transformed" && r.replacements > 0).length
- if (webPreCount > 0) {
- logger.success(`Transformed ${webPreCount} web/docs files`)
- }
- // 6j. Reset keep-ours files to Kilo's version
- logger.info("Resetting Kilo-specific files...")
- const keepOursResults = await resetToOurs(config.keepOurs, { dryRun: false, verbose: options.verbose })
- logger.success(`Reset ${keepOursResults.length} files to Kilo's version`)
- // Clean untracked build artifacts from Kilo-specific directories.
- // These packages don't exist in upstream, so their .gitignore files are absent
- // on the opencode branch. Artifacts like bin/, out/, .next/ etc. would otherwise
- // be picked up by the git add -A below.
- logger.info("Cleaning Kilo-specific directory artifacts...")
- await git.cleanDirectories(config.kiloDirectories)
- // Commit all transformations
- await git.stageAll()
- await git.commit(`refactor: kilo compat for ${targetVersion.tag}`)
- logger.success("Committed pre-merge transformations")
- // Step 7: Merge into Kilo branch
- logger.step(7, 8, "Merging into Kilo branch...")
- await git.checkout(kiloBranch)
- const mergeResult = await git.merge(opencodeBranch)
- if (!mergeResult.success) {
- logger.warn("Merge has conflicts (these should only be files with actual code differences)")
- logger.info("Conflicted files:")
- logger.list(mergeResult.conflicts)
- // Check if git rerere already auto-resolved any conflicts from recorded history.
- // rerere.autoupdate stages them automatically; we just log how many were handled.
- const rerereResolved = await git.getRerereResolved()
- if (rerereResolved.length > 0) {
- logger.success(`git rerere auto-resolved ${rerereResolved.length} conflict(s) from recorded history:`)
- logger.list(rerereResolved)
- }
- // Since we applied all branding transforms pre-merge, remaining conflicts should be minimal.
- // These are likely files with kilocode_change markers or actual logic differences.
- // Step 7a: Skip files that shouldn't exist in Kilo
- logger.info("Removing files that shouldn't exist in Kilo...")
- const skipResults = await skipFiles({ dryRun: false, verbose: options.verbose })
- const skippedCount = skipResults.filter((r) => r.action === "removed").length
- if (skippedCount > 0) {
- logger.success(`Skipped ${skippedCount} files (removed from merge)`)
- }
- // Step 7b: Auto-resolve keep-ours conflicts
- logger.info("Keeping Kilo-specific files...")
- const resolved = await keepOursFiles({ dryRun: false, verbose: options.verbose })
- const autoResolved = resolved.filter((r) => r.action === "kept")
- if (autoResolved.length > 0) {
- logger.success(`Auto-resolved ${autoResolved.length} conflicts (kept Kilo's version)`)
- }
- // Step 7c: Try to auto-resolve remaining conflicts with post-merge transforms
- // These handle edge cases where pre-merge transforms might have missed something.
- // Files with kilocode_change markers are flagged for manual resolution instead.
- let conflictedFiles = await git.getConflictedFiles()
- const flaggedFiles: string[] = []
- if (conflictedFiles.length > 0) {
- logger.info("Attempting to auto-resolve remaining conflicts...")
- // Transform i18n files
- const i18nResults = await transformConflictedI18n(conflictedFiles, { dryRun: false, verbose: options.verbose })
- const i18nTransformed = i18nResults.filter((r) => r.replacements > 0).length
- if (i18nTransformed > 0) {
- logger.success(`Auto-resolved ${i18nTransformed} i18n conflicts`)
- }
- const i18nFlagged = i18nResults.filter((r) => r.flagged).map((r) => r.file)
- if (i18nFlagged.length > 0) {
- logger.warn(`${i18nFlagged.length} i18n file(s) have kilocode_change markers — flagged for manual resolution`)
- flaggedFiles.push(...i18nFlagged)
- }
- // Transform branding-only files
- conflictedFiles = await git.getConflictedFiles()
- if (conflictedFiles.length > 0) {
- const takeTheirsResults = await transformConflictedTakeTheirs(conflictedFiles, {
- dryRun: false,
- verbose: options.verbose,
- })
- const takeTheirsCount = takeTheirsResults.filter((r) => r.action === "transformed").length
- if (takeTheirsCount > 0) {
- logger.success(`Auto-resolved ${takeTheirsCount} branding conflicts`)
- }
- const takeFlagged = takeTheirsResults.filter((r) => r.action === "flagged").map((r) => r.file)
- if (takeFlagged.length > 0) {
- logger.warn(
- `${takeFlagged.length} branding file(s) have kilocode_change markers — flagged for manual resolution`,
- )
- flaggedFiles.push(...takeFlagged)
- }
- }
- // Transform Tauri files
- conflictedFiles = await git.getConflictedFiles()
- if (conflictedFiles.length > 0) {
- const tauriResults = await transformConflictedTauri(conflictedFiles, {
- dryRun: false,
- verbose: options.verbose,
- })
- const tauriCount = tauriResults.filter((r) => r.action === "transformed").length
- if (tauriCount > 0) {
- logger.success(`Auto-resolved ${tauriCount} Tauri conflicts`)
- }
- const tauriFlagged = tauriResults.filter((r) => r.action === "flagged").map((r) => r.file)
- if (tauriFlagged.length > 0) {
- logger.warn(
- `${tauriFlagged.length} Tauri file(s) have kilocode_change markers — flagged for manual resolution`,
- )
- flaggedFiles.push(...tauriFlagged)
- }
- }
- // Transform package.json files
- conflictedFiles = await git.getConflictedFiles()
- if (conflictedFiles.length > 0) {
- const pkgResults = await transformConflictedPackageJson(conflictedFiles, {
- dryRun: false,
- verbose: options.verbose,
- })
- const pkgCount = pkgResults.filter((r) => r.action === "transformed").length
- if (pkgCount > 0) {
- logger.success(`Auto-resolved ${pkgCount} package.json conflicts`)
- }
- const pkgFlagged = pkgResults.filter((r) => r.action === "flagged").map((r) => r.file)
- if (pkgFlagged.length > 0) {
- logger.warn(
- `${pkgFlagged.length} package.json file(s) have kilocode_change markers — flagged for manual resolution`,
- )
- flaggedFiles.push(...pkgFlagged)
- }
- }
- // Transform script files
- conflictedFiles = await git.getConflictedFiles()
- if (conflictedFiles.length > 0) {
- const scriptResults = await transformConflictedScripts(conflictedFiles, {
- dryRun: false,
- verbose: options.verbose,
- })
- const scriptCount = scriptResults.filter((r) => r.action === "transformed").length
- if (scriptCount > 0) {
- logger.success(`Auto-resolved ${scriptCount} script conflicts`)
- }
- const scriptFlagged = scriptResults.filter((r) => r.action === "flagged").map((r) => r.file)
- if (scriptFlagged.length > 0) {
- logger.warn(
- `${scriptFlagged.length} script file(s) have kilocode_change markers — flagged for manual resolution`,
- )
- flaggedFiles.push(...scriptFlagged)
- }
- }
- // Transform extension files
- conflictedFiles = await git.getConflictedFiles()
- if (conflictedFiles.length > 0) {
- const extResults = await transformConflictedExtensions(conflictedFiles, {
- dryRun: false,
- verbose: options.verbose,
- })
- const extCount = extResults.filter((r) => r.action === "transformed").length
- if (extCount > 0) {
- logger.success(`Auto-resolved ${extCount} extension conflicts`)
- }
- const extFlagged = extResults.filter((r) => r.action === "flagged").map((r) => r.file)
- if (extFlagged.length > 0) {
- logger.warn(
- `${extFlagged.length} extension file(s) have kilocode_change markers — flagged for manual resolution`,
- )
- flaggedFiles.push(...extFlagged)
- }
- }
- // Transform web/docs files
- conflictedFiles = await git.getConflictedFiles()
- if (conflictedFiles.length > 0) {
- const webResults = await transformConflictedWeb(conflictedFiles, {
- dryRun: false,
- verbose: options.verbose,
- })
- const webCount = webResults.filter((r) => r.action === "transformed").length
- if (webCount > 0) {
- logger.success(`Auto-resolved ${webCount} web/docs conflicts`)
- }
- const webFlagged = webResults.filter((r) => r.action === "flagged").map((r) => r.file)
- if (webFlagged.length > 0) {
- logger.warn(
- `${webFlagged.length} web/docs file(s) have kilocode_change markers — flagged for manual resolution`,
- )
- flaggedFiles.push(...webFlagged)
- }
- }
- // Resolve lock file conflicts (accept ours, will regenerate later)
- conflictedFiles = await git.getConflictedFiles()
- if (conflictedFiles.length > 0) {
- const lockResults = await resolveLockFileConflicts({
- dryRun: false,
- verbose: options.verbose,
- })
- const lockCount = lockResults.filter((r) => r.action === "resolved").length
- if (lockCount > 0) {
- logger.success(`Resolved ${lockCount} lock file conflicts (will regenerate)`)
- }
- }
- }
- // Check remaining conflicts
- const remaining = await git.getConflictedFiles()
- // Combine git-reported conflicts with files flagged due to kilocode_change markers
- const allManual = [...new Set([...remaining, ...flaggedFiles])]
- if (allManual.length > 0) {
- if (flaggedFiles.length > 0) {
- logger.warn(`${flaggedFiles.length} file(s) were flagged because they contain kilocode_change markers:`)
- logger.list(flaggedFiles)
- logger.info(" These files have intentional Kilo-specific changes. Keep our version or merge carefully.")
- logger.info("")
- }
- if (remaining.length > 0) {
- logger.warn(`${remaining.length} conflict(s) still require manual resolution:`)
- logger.list(remaining)
- }
- logger.info("")
- logger.info("These conflicts contain kilocode_change markers or actual code differences.")
- logger.info("After resolving conflicts, run:")
- logger.info(" git add -A && git commit -m 'resolve merge conflicts'")
- // Save report before exiting so user has documentation
- conflictReport.mergeBranch = kiloBranch
- const reportPath = `upstream-merge-report-${targetVersion.version}.md`
- await report.saveReport(conflictReport, reportPath)
- logger.success(`Report saved to ${reportPath}`)
- logger.divider()
- logger.info("Next steps:")
- logger.info(" 1. Resolve remaining conflicts manually")
- logger.info(" 2. git add -A && git commit -m 'resolve merge conflicts'")
- logger.info(` 3. git push ${config.originRemote} ${kiloBranch}`)
- logger.info(" 4. Create PR from " + kiloBranch + " to " + config.baseBranch)
- logger.info("")
- logger.info("To rollback:")
- logger.info(` git checkout ${config.baseBranch}`)
- logger.info(` git reset --hard ${backupBranch}`)
- // Exit early - don't continue to finalization steps
- process.exit(1)
- } else {
- await git.stageAll()
- await git.commit(`merge: upstream ${targetVersion.tag}`)
- logger.success("Merge completed - all conflicts auto-resolved!")
- }
- } else {
- logger.success("Merge completed without conflicts!")
- await git.stageAll()
- const hasChanges = await git.hasUncommittedChanges()
- if (hasChanges) {
- await git.commit(`merge: upstream ${targetVersion.tag}`)
- }
- }
- // Step 8: Regenerate lock files and finalize
- logger.step(8, 8, "Regenerating lock files and finalizing...")
- // Regenerate lock files (bun.lock, Cargo.lock, etc.)
- const lockRegenResults = await regenerateLockFiles({ dryRun: false, verbose: options.verbose })
- const regeneratedCount = lockRegenResults.filter((r) => r.action === "regenerated").length
- if (regeneratedCount > 0) {
- logger.success(`Regenerated ${regeneratedCount} lock file(s)`)
- // Stage and commit the regenerated lock files
- await git.stageAll()
- const hasLockChanges = await git.hasUncommittedChanges()
- if (hasLockChanges) {
- await git.commit("chore: regenerate lock files after upstream merge")
- logger.success("Committed regenerated lock files")
- }
- }
- // Regenerate OpenAPI spec and SDK (keeps generated files in sync with merged code)
- logger.info("Regenerating OpenAPI spec and SDK...")
- const regenResult = await $`bun ./script/generate.ts`.quiet().nothrow()
- if (regenResult.exitCode === 0) {
- logger.success("Regenerated OpenAPI spec and SDK")
- await git.stageAll()
- const hasSpecChanges = await git.hasUncommittedChanges()
- if (hasSpecChanges) {
- await git.commit("chore: regenerate openapi spec and sdk after upstream merge")
- logger.success("Committed regenerated OpenAPI spec and SDK")
- }
- } else {
- logger.warn("OpenAPI spec regeneration failed — run ./script/generate.ts manually after resolving any issues")
- logger.warn(regenResult.stderr.toString().trim())
- }
- if (options.push) {
- await git.push(config.originRemote, kiloBranch)
- logger.success(`Pushed ${kiloBranch} to ${config.originRemote}`)
- }
- // Update merge branch in report
- conflictReport.mergeBranch = kiloBranch
- // Save final report
- const reportPath = `upstream-merge-report-${targetVersion.version}.md`
- await report.saveReport(conflictReport, reportPath)
- logger.success(`Report saved to ${reportPath}`)
- // Summary
- logger.divider()
- logger.header("Merge Summary")
- logger.info(`Upstream version: ${targetVersion.tag}`)
- logger.info(`Kilo branch: ${kiloBranch}`)
- logger.info(`Opencode branch: ${opencodeBranch}`)
- logger.info(`Backup branch: ${backupBranch}`)
- logger.info(`Report: ${reportPath}`)
- const remainingConflicts = await git.getConflictedFiles()
- if (remainingConflicts.length > 0) {
- logger.warn(`${remainingConflicts.length} conflicts need manual resolution`)
- } else {
- logger.success("All conflicts resolved")
- }
- logger.divider()
- logger.info("Next steps:")
- if (remainingConflicts.length > 0) {
- logger.info(" 1. Resolve remaining conflicts")
- logger.info(" 2. git add -A && git commit -m 'resolve merge conflicts'")
- logger.info(` 3. git push ${config.originRemote} ${kiloBranch}`)
- logger.info(" 4. Create PR from " + kiloBranch + " to " + config.baseBranch)
- } else {
- logger.info(" 1. Review changes")
- logger.info(" 2. Create PR from " + kiloBranch + " to " + config.baseBranch)
- }
- logger.info("")
- logger.info("To rollback:")
- logger.info(` git checkout ${config.baseBranch}`)
- logger.info(` git reset --hard ${backupBranch}`)
- }
- main().catch((err) => {
- logger.error(`Fatal error: ${err}`)
- process.exit(1)
- })
|