Skip to content

Upstream Merge Automation#102

Merged
catrielmuller merged 17 commits intodevfrom
catrielmuller/upstream-scripts
Feb 5, 2026
Merged

Upstream Merge Automation#102
catrielmuller merged 17 commits intodevfrom
catrielmuller/upstream-scripts

Conversation

@catrielmuller
Copy link
Collaborator

Context

Kilo CLI is a fork of opencode, and we regularly merge upstream changes to stay in sync. Previously, this was a painful manual process involving many merge conflicts due to branding differences (OpenCode vs Kilo).

This PR introduces a comprehensive automation toolset in script/upstream/ that transforms upstream code before merging, drastically reducing conflicts and streamlining the sync process.

Implementation

Architecture Overview

The automation follows an 8-step process:

  1. Environment Validation - Checks for upstream remote and clean working directory
  2. Fetch Upstream - Gets latest tags and commits from upstream
  3. Version Selection - Determines target version (specific or latest)
  4. Conflict Analysis - Pre-analyzes which files will conflict and categorizes them
  5. Branch Creation - Creates backup, Kilo merge, and transformed upstream branches
  6. Pre-Merge Transformations - Applies ALL branding transforms to upstream BEFORE merging
  7. Merge & Auto-Resolution - Merges transformed upstream; remaining conflicts are actual code differences
  8. Lock File Regeneration - Regenerates bun.lock, Cargo.lock after merge

Key Insight: Pre-Merge Transformation

The critical innovation is applying branding transforms before the merge:

  • Previously: Git saw "OpenCode" vs "Kilo" as conflicts
  • Now: Upstream is transformed to "Kilo" branding first, so Git sees no conflict for branding-only files

The only remaining conflicts are files with actual code differences (files with kilocode_change markers).

Components

Main Scripts (script/upstream/)

Script Purpose
merge.ts Full orchestration - validates, transforms, merges, reports
analyze.ts Dry-run analysis without merging
list-versions.ts Lists available upstream versions

Transform Pipeline (transforms/)

Transform Handles
package-names.ts opencode-ai -> @kilocode/cli in all files
preserve-versions.ts Keeps Kilo's version numbers in package.json
transform-i18n.ts Branding in translation files (OpenCode -> Kilo)
transform-take-theirs.ts UI components with branding-only differences
transform-tauri.ts Desktop app configs (identifiers, names)
transform-package-json.ts package.json with Kilo dependency injection
transform-scripts.ts GitHub API references (anomalyco -> Kilo-Org)
transform-extensions.ts Zed and other extension files
transform-web.ts Documentation/web files (.mdx)
keep-ours.ts Files to never take from upstream (README, workflows)
skip-files.ts Files to remove (translated READMEs, STATS.md)
lock-files.ts Accept ours & regenerate after merge

AST-Based Codemods (codemods/)

Codemod Purpose
transform-imports.ts Transform import statements using ts-morph
transform-strings.ts Transform string literals with full AST analysis

Utilities (utils/)

Utility Purpose
config.ts Centralized configuration (patterns, mappings, keepOurs list)
git.ts Git operations (checkout, merge, branch management)
logger.ts Consistent logging with progress indicators
report.ts Conflict analysis and markdown report generation
version.ts Version detection and comparison

Configuration Highlights

// Package name mappings
packageMappings: [
  { from: "opencode-ai", to: "@kilocode/cli" },
  { from: "@opencode-ai/cli", to: "@kilocode/cli" },
  { from: "@opencode-ai/sdk", to: "@kilocode/sdk" },
]

// Files that never take upstream changes
keepOurs: [
  "README.md",
  ".github/workflows/publish.yml",
  "AGENTS.md",
  // ...
]

// Files removed during merge
skipFiles: [
  "README.*.md", // Translated READMEs
  "STATS.md",
  // ...
]

// Kilo-specific directories (always preserved)
kiloDirectories: ["packages/opencode/src/kilocode", "packages/kilo-gateway", "script/upstream"]

Tradeoffs & Decisions

  1. Pre-merge vs post-merge transforms: We chose pre-merge to minimize conflicts. The tradeoff is that the upstream branch is modified, but this is isolated to a feature branch.

  2. String-based vs AST-based transforms: Most transforms use regex for speed. AST-based codemods (ts-morph) are reserved for complex cases where context matters.

  3. Lock file handling: We accept "ours" and regenerate rather than attempting 3-way merge. Lock files are not meant to be manually merged.

  4. Manual review for kilocode_change files: Files with actual code differences still require manual review, which is intentional - these contain Kilo-specific logic that needs human judgment.

Screenshots

before after
Manual merge with 50+ conflicts Automated merge with 0-5 conflicts
Hours of conflict resolution Minutes of verification

How to Test

# Navigate to upstream scripts
cd script/upstream
bun install

# List available upstream versions
bun run list-versions.ts

# Analyze changes without merging (safe)
bun run analyze.ts --version v1.1.49

# Dry-run to preview what would happen
bun run merge.ts --version v1.1.49 --dry-run

# Full merge (creates branches, doesn't push by default with --no-push)
bun run merge.ts --version v1.1.49 --no-push

# Full merge with push
bun run merge.ts --version v1.1.49

After running, you should see:

  • A backup branch created: backup/dev-<timestamp>
  • A Kilo merge branch: <author>/kilo-opencode-<version>
  • A transformed upstream branch: <author>/opencode-<version>
  • A detailed report: upstream-merge-report-<version>.md

Rollback

If something goes wrong:

git checkout dev
git reset --hard backup/dev-<timestamp>

* by transforming OpenCode references to Kilo.
*/

import { $ } from "bun"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Unused import {$}

In script/upstream/transforms/transform-web.ts, import { $ } from "bun" is only used by transformWebFile() when options.dryRun is false, but options.dryRun currently returns early before the git checkout --theirs step (so $ is never used in dry-run). However in the non-dry-run path, $ is used; so the import is used.

(If the intent is to allow dry-run without requiring git operations, consider keeping as-is; otherwise, if this script should always do git checkout before transforming, move the dry-run early return to after the checkout stage.)

}
}

export async function commit(message: string): Promise<void> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: commit() only commits tracked changes

commit() uses git commit -am, which does not include newly added files. In this PR, the upstream-merge tool adds new files under script/upstream/, so the transformation commit in merge.ts may miss those additions unless they were already tracked.

Safer pattern for automation is git commit -m after an explicit git add -A (which merge.ts already does via stageAll()).

*/

import { $ } from "bun"
import { getUpstreamTags, getCommitMessage, getTagsForCommit } from "./git"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Unused import getUpstreamTags

getUpstreamTags is imported but not used in this file. Consider removing it to avoid lint/tsc warnings.

@kiloconnect
Copy link
Contributor

kiloconnect bot commented Feb 3, 2026

Code Review Summary

Status: 1 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
script/upstream/transforms/transform-take-theirs.ts 137 Preserve patterns computed but not enforced; .opencode/ and opencode.json may be rewritten

Fix these issues in Kilo Cloud

Files Reviewed (25 files)
  • script/upstream/transforms/transform-take-theirs.ts - 1 issue
  • script/upstream/transforms/transform-web.ts - 0 issues
  • script/upstream/transforms/transform-tauri.ts - 0 issues
  • script/upstream/transforms/transform-package-json.ts - 0 issues
  • script/upstream/transforms/transform-scripts.ts - 0 issues
  • script/upstream/transforms/transform-extensions.ts - 0 issues
  • script/upstream/transforms/transform-i18n.ts - 0 issues
  • script/upstream/transforms/skip-files.ts - 0 issues
  • script/upstream/transforms/keep-ours.ts - 0 issues
  • script/upstream/transforms/lock-files.ts - 0 issues
  • script/upstream/transforms/package-names.ts - 0 issues
  • script/upstream/transforms/preserve-versions.ts - 0 issues
  • script/upstream/utils/config.ts - 0 issues
  • script/upstream/utils/git.ts - 0 issues
  • script/upstream/utils/logger.ts - 0 issues
  • script/upstream/utils/report.ts - 0 issues
  • script/upstream/utils/version.ts - 0 issues
  • script/upstream/analyze.ts - 0 issues
  • script/upstream/merge.ts - 0 issues
  • script/upstream/index.ts - 0 issues
  • script/upstream/list-versions.ts - 0 issues
  • script/upstream/README.md - 0 issues
  • script/upstream/package.json - 0 issues
  • .opencode/tool/github-pr-search.ts - 0 issues
  • .opencode/tool/github-triage.ts - 0 issues

@catrielmuller catrielmuller mentioned this pull request Feb 3, 2026
@marius-kilocode
Copy link
Collaborator

@catrielmuller how fragile do you judge this script? It's a lot to maintain.

@catrielmuller catrielmuller force-pushed the catrielmuller/upstream-scripts branch from 5c4625d to e305491 Compare February 4, 2026 14:35
* by transforming OpenCode references to Kilo.
*/

import { $ } from "bun"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Unused import {$}

$ is imported but never referenced in this file. Keeping unused imports can hide intended-but-missing behavior (e.g., forgetting to stage via git).

for (const line of output.trim().split("\n")) {
// Match lines like: abc123... refs/tags/v1.1.49
// Skip annotated tag references (those ending with ^{})
const match = line.match(/^([a-f0-9]+)\s+refs\/tags\/([^\^]+)$/)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Annotated upstream tags may resolve to the tag object hash, not the commit

git ls-remote --tags returns both refs/tags/<tag> (often the tag object for annotated tags) and refs/tags/<tag>^{} (the peeled commit). The current regex explicitly ignores the ^{} line (per comment) but then uses the remaining hash as commit, which can be incorrect for annotated tags and break downstream checkout/analysis. Prefer collecting both and using the peeled ^{} hash when present (or at least skipping non-peeled hashes when a peeled one exists).


try {
// Take upstream's version first
await $`git checkout --theirs ${file}`.quiet().nothrow()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: git checkout --theirs / git add failures can be silently ignored

These commands use .nothrow() and their exit codes aren’t checked. If git checkout --theirs fails (e.g., file not in conflict / path issues), the script will continue and report success, potentially transforming/staging the wrong content. Consider checking exitCode and throwing on non-zero (like fetchUpstream() does) so failures are surfaced reliably.

@@ -1,5 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@kilocode/plugin" // kilocode_change
import { tool } from "@kilocode/plugin"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Preserve kilocode_change markers for fork-maintained files

This file is not in a kilocode/ path, so removing the // kilocode_change marker makes future upstream merges harder to audit and increases conflict risk. Consider restoring the marker on Kilo-specific deltas (per AGENTS.md).

/// <reference path="../env.d.ts" />
// import { Octokit } from "@octokit/rest"
import { tool } from "@kilocode/plugin" // kilocode_change
import { tool } from "@kilocode/plugin"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Preserve kilocode_change markers for fork-maintained files

Same as above: if this is intended to be a Kilo-only deviation from upstream, keeping the marker helps keep merge diffs minimal and reviewable.

export async function transformWebFile(file: string, options: WebTransformOptions = {}): Promise<WebTransformResult> {
if (options.dryRun) {
info(`[DRY-RUN] Would transform web file: ${file}`)
return { file, action: "transformed", replacements: 0, dryRun: true }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: --dry-run currently reports action: "transformed"

In dry-run mode, transformWebFile() returns action: "transformed" and will be counted as transformed by the CLI summary, even though no changes are applied. This can mislead automation/logs.

Suggested change
return { file, action: "transformed", replacements: 0, dryRun: true }
return { file, action: "skipped", replacements: 0, dryRun: true }

*/
export async function getCurrentKiloVersion(): Promise<string> {
// Resolve path relative to repo root (script is in script/upstream/)
const path = new URL("../../../packages/opencode/package.json", import.meta.url).pathname
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: new URL(...).pathname can break on Windows paths

Using .pathname on a file: URL can produce POSIX-style paths (e.g. /C:/...) on Windows. If these scripts are expected to run cross-platform, prefer passing the URL directly to Bun.file() (if supported) or use fileURLToPath() from node:url to convert safely.

@catrielmuller catrielmuller force-pushed the catrielmuller/upstream-scripts branch 2 times, most recently from a5798ae to 294685f Compare February 4, 2026 16:36
info("Analyzing changes...")

// Use commit hash directly since we may not have the tag fetched locally
const conflicts = await report.analyzeConflicts(target.commit, config.baseBranch, config.keepOurs)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: analyzeConflicts() isn’t passed skipFiles, so skip-only upstream artifacts won’t be flagged

merge.ts passes config.skipFiles, but script/upstream/analyze.ts currently doesn’t. This makes the analysis report less accurate (it may recommend manual work for files that the merge flow would auto-remove).

Suggested change
const conflicts = await report.analyzeConflicts(target.commit, config.baseBranch, config.keepOurs)
const conflicts = await report.analyzeConflicts(target.commit, config.baseBranch, config.keepOurs, config.skipFiles)

let modified = false

for (const { pattern, replacement } of STRING_REPLACEMENTS) {
if (pattern.test(value)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Global RegExp + test() without resetting lastIndex can make replacements flaky

In transformStrings(), STRING_REPLACEMENTS uses /g regexes. Calling pattern.test(value) repeatedly across many literals without pattern.lastIndex = 0 can cause false negatives after the first match, leading to skipped transformations depending on iteration order. Consider resetting lastIndex before each test() (or avoid test() and rely on replace() + comparison).

}

if (headModified) {
// Template head replacement is complex, skip for now
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: TemplateExpression path increments changes without applying any edit

At script/upstream/codemods/transform-strings.ts, headModified results in changes++, but the AST/text is intentionally left unchanged. That can over-report transformations and may cause no-op saves/commits to be treated as real edits. If template-head handling is intentionally unsupported, consider not incrementing changes here (or implement the replacement).

@catrielmuller catrielmuller force-pushed the catrielmuller/upstream-scripts branch from 1886e3f to a2e1a37 Compare February 4, 2026 19:40
// Step 2: Fetch upstream
logger.step(2, 8, "Fetching upstream...")

if (!options.dryRun) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: --dry-run skips fetching upstream, but later analysis diffs against an upstream commit

In script/upstream/merge.ts, dry-run avoids git fetch upstream, but the script still calls report.analyzeConflicts() using an upstream commit hash from git ls-remote. Without fetching that commit object locally, git diff ${base}...${commit} can fail, breaking dry-run/report generation. Consider fetching upstream even in dry-run (network-only, no working tree changes) or adjusting analysis to use available refs.

}

// Check if file should always keep ours
if (keepOurs.some((pattern) => path.includes(pattern) || path === pattern)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: keepOurs matching via path.includes(...) can unintentionally match unrelated files

In getRecommendation(), keepOurs.some((pattern) => path.includes(pattern) || path === pattern) will treat any path containing e.g. README.md as keep-ours (like packages/app/README.md). That can incorrectly discard upstream changes. Prefer exact path matching or proper glob/minimatch semantics.

* Check if a file should be skipped (not added from upstream)
*/
function shouldSkipFile(path: string, skipPatterns: string[]): boolean {
return skipPatterns.some((pattern) => path === pattern || path.includes(pattern))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: skipFiles matching via substring (includes) may skip unintended paths

shouldSkipFile() returns true when path.includes(pattern). With filename-like patterns (e.g. README.de.md), this can match files in unexpected directories and remove them during merge. Prefer exact match or glob matching, consistent with how other file patterns are treated.

@catrielmuller catrielmuller force-pushed the catrielmuller/upstream-scripts branch from a2e1a37 to a05a66b Compare February 5, 2026 12:44
return regex.test(filePath)
}
// Contains match
return filePath.includes(pattern)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: shouldKeepOurs() uses substring matching for non-glob patterns

In shouldKeepOurs(), non-glob patterns fall back to filePath.includes(pattern), which can over-match (e.g. a pattern like README.md will also match packages/app/README.md). In an automated merge/conflict resolver, this can silently discard upstream changes for unrelated files.

Consider restricting non-glob patterns to exact path matches (or using a consistent glob matcher) to avoid accidental matches.


try {
// Take upstream's version
await $`git checkout --theirs ${file}`.quiet().nothrow()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: git checkout --theirs / git add failures are ignored

$git checkout --theirs ....quiet().nothrow() will not throw on failure, and the code doesn’t check exitCode before continuing. If checkout/add fails (path missing, not in conflict, git error), the transform may proceed using the wrong content and still report action: "transformed", which can leave the repo in a misleading state.

Recommend checking exitCode for these commands (and returning failed / throwing) so callers can reliably detect and stop on git failures.

@catrielmuller catrielmuller force-pushed the catrielmuller/upstream-scripts branch from a05a66b to 45a4b24 Compare February 5, 2026 14:42
}

// If line has preserve patterns, skip transformation
if (hasPreserve) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: PRESERVE_PATTERNS skips the entire line, which can leave other OpenCode references untransformed

In applyWebTransforms(), any line containing e.g. .opencode/ or opencode.json is pushed unchanged and the loop continues. That means other replacements on the same line (like opencode.aikilo.ai or OpenCodeKilo) won't run, leaving partially-branded docs.

Consider preserving only the matched substrings (mask/restore) instead of skipping the full line.

@catrielmuller catrielmuller force-pushed the catrielmuller/upstream-scripts branch from 45a4b24 to e5f92bc Compare February 5, 2026 17:02
continue
}

// Check if line has preserve patterns
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Preserve patterns detected but not enforced

In applyBrandingTransforms(), hasPreserve is computed but never used to skip transformations, so PRESERVE_PATTERNS (e.g. .opencode/, opencode.json) won’t actually be preserved and may be rewritten. Consider short-circuiting when a line matches a preserve pattern (similar to applyWebTransforms()).

@catrielmuller catrielmuller merged commit 6eb9796 into dev Feb 5, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants