Merge branch 'main' into session/agent_a6ecf426-6df9-4de2-832c-c3835090b68b

This commit is contained in:
Mark IJbema
2026-05-06 10:25:08 +02:00
committed by GitHub
264 changed files with 5027 additions and 1626 deletions
+44 -11
View File
@@ -100,17 +100,49 @@ function isSource(file: string) {
return content(file).startsWith("#!") // kilocode_change
}
function addedLines(file: string): Set<number> {
// Parses the unified=0 diff for `file` against `base` and returns:
// - added: every added line number on HEAD
// - revert: true when the file's diff removes any kilocode_change marker.
// In that case the changes are reverting Kilo modifications back to the
// upstream baseline, so newly added lines (which are restoring upstream
// content) should not require a marker. Refs that depended on a removed
// Kilo construct (e.g. `unixSkip(` → `unix(`) often live in different
// hunks than the marker itself, so we use file-level detection rather
// than hunk-level to avoid false positives on legitimate reverts.
function addedLines(file: string): { added: Set<number>; revert: boolean } {
const diff = run("git", ["diff", "--unified=0", "--diff-filter=AMRT", `${base}...HEAD`, "--", file])
const out = new Set<number>()
for (const line of diff.split("\n")) {
const m = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
if (!m) continue
const added = new Set<number>()
let revert = false
const all = diff.split("\n")
let i = 0
while (i < all.length) {
const header = all[i] ?? ""
const m = header.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
if (!m) {
i++
continue
}
const start = Number(m[1])
const count = m[2] !== undefined ? Number(m[2]) : 1
for (let i = 0; i < count; i++) out.add(start + i)
let pos = 0
let j = i + 1
while (j < all.length) {
const hl = all[j] ?? ""
if (hl.startsWith("@@") || hl.startsWith("diff ")) break
if (hl.startsWith("+") && !hl.startsWith("+++")) {
added.add(start + pos)
pos++
} else if (hl.startsWith("-") && !hl.startsWith("---") && hasMarker(hl.slice(1))) {
revert = true
}
j++
}
i = j
}
return out
return { added, revert }
}
// kilocode_change start
@@ -189,13 +221,14 @@ if (files.length === 0) {
const violations: string[] = []
for (const file of files) {
const nums = addedLines(file)
if (nums.size === 0) continue
const { added, revert } = addedLines(file)
if (added.size === 0) continue
if (revert) continue // kilocode_change - file is reverting Kilo modifications back to upstream
const text = content(file) // kilocode_change
const { lines, covered } = coveredLines(text)
for (const n of nums) {
for (const n of added) {
const line = lines[n - 1] ?? ""
const trim = line.trim()
if (!trim) continue
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bun
// kilocode_change - new file
/**
* Guards against accidentally inheriting workflows from upstream opencode.
*
* We regularly merge upstream. When upstream adds a new workflow under
* `.github/workflows/`, it silently starts running in our CI unless we
* explicitly review and accept it. This check makes that decision explicit:
* the list of allowed workflows is hardcoded below, and any drift (added or
* removed file in `.github/workflows/`) fails CI until the list is updated
* deliberately.
*
* Only runnable workflows are checked (`.yml` / `.yaml`). Files under
* `.github/workflows/disabled/` are Kilo-specific and can't run, so they're
* not tracked here.
*
* To accept a new workflow: add its filename to `active`.
* To drop one: remove its filename from the list.
*/
import { readdirSync } from "node:fs"
import path from "node:path"
const ROOT = path.resolve(import.meta.dir, "..")
const DIR = path.join(ROOT, ".github", "workflows")
// Workflows we have deliberately accepted into CI. Sort alphabetically.
const active = new Set([
"auto-docs.yml",
"beta.yml",
"check-md-table-padding.yml",
"check-opencode-annotations.yml",
"check-org-member.yml",
"close-issues.yml",
"close-stale-prs.yml",
"containers.yml",
"docs-build.yml",
"docs-check-links.yml",
"duplicate-issues.yml",
"generate.yml",
"nix-eval.yml",
"nix-hashes.yml",
"publish.yml",
"smoke-test.yml",
"source-check-links.yml",
"test-vscode.yml",
"test.yml",
"triage.yml",
"typecheck.yml",
"visual-regression.yml",
"watch-opencode-releases.yml",
])
// GitHub picks up both .yml and .yaml in .github/workflows/. We accept both so
// an upstream `.yaml` addition also shows up as unexpected drift.
const isWorkflow = (f: string) => f.endsWith(".yml") || f.endsWith(".yaml")
const actualActive = new Set(readdirSync(DIR).filter(isWorkflow))
const missing = [...active].filter((f) => !actualActive.has(f)).sort()
const extra = [...actualActive].filter((f) => !active.has(f)).sort()
const errs: string[] = []
for (const f of extra) {
errs.push(`unexpected workflow: ${f} — if this was added intentionally, add it to script/check-workflows.ts`)
}
for (const f of missing) {
errs.push(
`expected workflow not found: ${f} — if this was removed intentionally, remove it from script/check-workflows.ts`,
)
}
if (errs.length === 0) {
console.log(`check-workflows: ok (${actualActive.size} workflows).`)
process.exit(0)
}
for (const e of errs) console.error(e)
console.error("")
console.error(`Found ${errs.length} workflow drift issue(s).`)
console.error("This guard prevents upstream-merged workflows from silently running in our CI.")
process.exit(1)
+2
View File
@@ -99,7 +99,9 @@ if (Script.release) {
// Use an absolute path for the CHANGELOG because the imported SDK build
// script chdirs into packages/sdk/js, so a relative path would miss the file
// and fall through to the "No notable changes" default.
const kind = Script.preview ? "pre-release" : "release"
const flags = Script.preview ? ["--draft=false", "--prerelease"] : ["--draft=false"]
flags.push("--title", `v${Script.version} (${kind})`)
const changelogPath = fileURLToPath(new URL("../packages/kilo-vscode/CHANGELOG.md", import.meta.url))
const changelog = await Bun.file(changelogPath)
.text()
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bun
// kilocode_change - new file
/**
* Configures repo-local git settings for all contributors.
*
* `merge.conflictStyle=zdiff3` makes conflict markers include the common
* ancestor (|||||||) alongside ours/theirs. That base section is what
* mergiraf's syntax-aware resolution feeds on during upstream opencode
* merges (see script/upstream/merge.ts) and it makes manual resolution
* dramatically easier than the default 2-way `merge` markers.
*
* Runs from `postinstall`. Safe to re-run — `git config` is idempotent.
* Guarded so tarball / docker installs without a `.git` don't fail.
*/
import { $ } from "bun"
const inside = await $`git rev-parse --is-inside-work-tree`.nothrow().quiet()
if (inside.exitCode !== 0) process.exit(0)
await $`git config --local merge.conflictStyle zdiff3`.quiet()
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bun
// kilocode_change - new file
// Sync every Kilo version string across the monorepo to a single target.
//
// Why this exists: upstream opencode stamps its own version into shared files
// during each release (notably `packages/extensions/zed/extension.toml`). When
// we merge upstream, that churn either produces conflicts or silently leaves
// our packages pointing at upstream's version — and upstream's version tag
// doesn't exist on our release pipeline, so the resulting download URLs 404.
//
// Run this in a dedicated commit after resolving an upstream merge (see
// `.kilo/command/upstream-manual-merge.md`). It's also handy mid-merge to
// rebase our version bumps onto any new Kilo main releases.
//
// Usage:
// bun run script/sync-versions.ts # use root package.json version
// bun run script/sync-versions.ts 7.2.41 # explicit target
// bun run script/sync-versions.ts v7.2.41 # leading `v` is stripped
//
// What gets updated:
// - every `package.json` top-level `"version": "..."` field in the repo
// (excluding node_modules and hidden directories)
// - `packages/extensions/zed/extension.toml` top-level `version = "..."`
// - the five Kilo-Org download URLs inside that toml
//
// Intentionally NOT touched:
// - `packages/kilo-jetbrains/**` — the JetBrains plugin has its own release
// cadence and version number.
// - dependency version strings inside `package.json` — internal deps use
// `workspace:*` so they don't need bumping.
import { Glob } from "bun"
import { join, relative } from "node:path"
const root = join(import.meta.dir, "..")
const arg = process.argv[2]
const target = await (async () => {
if (arg) return arg.replace(/^v/, "")
const pkg = await Bun.file(join(root, "package.json")).json()
return pkg.version as string
})()
if (!/^\d+\.\d+\.\d+([-+].+)?$/.test(target)) {
console.error(`error: invalid version "${target}"`)
process.exit(1)
}
console.log(`syncing versions → ${target}\n`)
let updated = 0
const glob = new Glob("**/package.json")
for await (const rel of glob.scan({ cwd: root, onlyFiles: true })) {
if (rel.includes("node_modules/")) continue
if (rel.startsWith(".")) continue
if (rel.includes("/.")) continue
// JetBrains plugin tracks its own version.
if (rel.startsWith("packages/kilo-jetbrains/")) continue
const path = join(root, rel)
const text = await Bun.file(path).text()
// Only rewrite the top-level version field — avoid touching nested
// dependency version fields or versions inside sub-strings. The first
// `"version"` key at 2-space indentation is always the package version in
// this repo's style.
const next = text.replace(/^(\s*)"version":\s*"[^"]+"(,?)/m, (_m, indent, comma) => {
return `${indent}"version": "${target}"${comma}`
})
if (next === text) continue
// Defensive: the replace above runs unconditionally on any match — skip if
// the file had no `"version"` key at all.
if (!/"version"\s*:/.test(text)) continue
await Bun.write(path, next)
console.log(` ${rel}`)
updated++
}
const zed = join(root, "packages/extensions/zed/extension.toml")
if (await Bun.file(zed).exists()) {
const text = await Bun.file(zed).text()
const next = text
.replace(/^version\s*=\s*"[^"]+"/m, `version = "${target}"`)
.replace(
/https:\/\/github\.com\/Kilo-Org\/kilocode\/releases\/download\/v[^/]+\//g,
`https://github.com/Kilo-Org/kilocode/releases/download/v${target}/`,
)
if (next !== text) {
await Bun.write(zed, next)
console.log(` ${relative(root, zed)}`)
updated++
}
}
console.log(`\nupdated ${updated} file(s)`)
+58
View File
@@ -35,6 +35,8 @@ bun run merge.ts --version v1.1.50 --base-branch catrielmuller/kilo-opencode-v1.
| `list-versions.ts` | List available upstream versions |
| `analyze.ts` | Analyze changes without merging |
| `fix-kilocode-markers.ts` | Rebuild `kilocode_change` markers for one file against the last merged upstream |
| `reset-to-upstream.ts` | Reset one file to the transformed last merged upstream version |
| `find-reset-candidates.ts` | Bulk-find files that have drifted insignificantly from upstream and (optionally) reset them |
### Transform Scripts
@@ -243,6 +245,62 @@ Options:
The command finds the newest upstream tag already merged into `HEAD`, reads that upstream version of the file, applies the same branding transforms used by upstream merge automation, strips existing `kilocode_change` markers from the current file, and adds fresh markers around the remaining lines that differ from upstream.
### reset-to-upstream.ts
```
Usage:
bun run script/upstream/reset-to-upstream.ts <repo-relative-file> [--dry-run]
Options:
--dry-run Show what would change without writing the file
```
The command finds the newest upstream tag already merged into `HEAD`, reads that upstream version of the file, applies the same branding transforms used by upstream merge automation for text files, and writes the result to the working tree. Binary files are restored as raw upstream bytes without text transforms. If the file does not exist upstream, the local file is deleted.
### find-reset-candidates.ts
```
Usage:
bun run script/upstream/find-reset-candidates.ts [path] [options]
Arguments:
path Optional repo-relative subdirectory to scope to.
Defaults to all tracked shared paths.
Options:
--review-limit <n> Max non-marker, non-whitespace diff lines that
still auto-resets (default: 5).
--dry-run Classify and report only; do not write any files.
--concurrency <n> Parallel classifications (default: 8).
```
The command pre-filters with `git diff --name-only <last-merged-upstream>..HEAD` and drops:
- Kilo-only paths: anything under `packages/kilo-*/`, any `**/kilocode/**` subdir, `script/upstream/`.
- Non-code assets: SVG, PNG, fonts, archives, lock files, etc. (see `SKIP_EXTENSIONS` / `SKIP_FILENAMES` in the script).
- Files covered by the merge config's `keepOurs` or `skipFiles` lists in `utils/config.ts` — these are intentionally preserved or removed in Kilo and must not be bulk-reset.
It then issues one `git cat-file --batch-check` for all remaining paths to grab upstream blob sizes in a single subprocess. Files absent upstream land in `upstream-missing` immediately; files above 256 KB land in `too-large` (generated manifests, giant snapshots). Only the survivors get fetched via `git show` and classified:
| Bucket | Meaning | Action |
|---|---|---|
| `identical` | Local bytes already match transformed upstream (branding-only drift in raw git diff) | none |
| `markers-only` | Stripping `kilocode_change` markers makes local match upstream | reset |
| `cosmetic-only` | Non-marker diff is only whitespace or reordered lines (the line multiset is identical) | reset |
| `small-diff` | ≤ `--review-limit` non-marker, non-cosmetic diff lines | reset |
| `large-diff` | > `--review-limit` non-marker, non-cosmetic diff lines | skipped |
| `upstream-missing` | File does not exist upstream (kilo-only, intentional) | skipped |
| `local-missing` | File tracked but missing locally (deleted in Kilo) | skipped |
| `binary-diff` | Binary file differs | skipped (use `reset-to-upstream.ts` per file) |
| `binary-identical` | Binary file already matches | none |
| `too-large` | Upstream blob > 256 KB | skipped (use `reset-to-upstream.ts` per file) |
Line counting uses an in-process multiset diff (pure JS, no subprocess) for speed and robustness against concurrent git output stalls on big files. Moved/reordered lines therefore count as zero drift, which is usually what you want for "is this file meaningfully different from upstream".
`markers-only`, `cosmetic-only`, and `small-diff` buckets are auto-reset unless `--dry-run` is passed. A markdown summary is printed to stdout so you can review what happened and spot-check the resulting `git diff`. All resets land as uncommitted working-tree changes; `git diff` / `git checkout` is your safety net.
Tighten the blast radius with `--review-limit 0` (only `markers-only` and `cosmetic-only`) or by scoping with a `path` argument (e.g. `packages/opencode/src/mcp`).
## Using Custom Base Branches
By default, upstream merges start from the `main` branch. However, you can use `--base-branch` to start from a different branch. This is useful for:
+415
View File
@@ -0,0 +1,415 @@
#!/usr/bin/env bun
/**
* Find files whose drift from the last merged upstream is insignificant and
* (optionally) reset them back to upstream.
*
* Starts from `git diff --name-only <upstream-commit>..HEAD` to pre-filter the
* working tree, then classifies each candidate:
*
* - identical : local bytes already match transformed upstream
* - markers-only : only diff is kilocode_change markers wrapping
* identical code (stale markers)
* - whitespace-only : only diff is whitespace
* - small-diff : <= --review-limit non-marker diff lines
* - large-diff : > --review-limit non-marker diff lines (skipped)
* - upstream-missing : file does not exist upstream (kilo-only, skipped)
* - local-missing : file tracked by git but missing locally (skipped)
* - binary-identical : binary file already matches (skipped)
* - binary-diff : binary file differs (skipped; use reset-to-upstream.ts
* per file if you want to reset binaries)
*
* markers-only, whitespace-only, and small-diff buckets are auto-reset unless
* --dry-run is passed. A markdown summary is printed to stdout at the end so
* you can review what happened and spot-check the resulting `git diff`.
*
* Usage:
* bun run script/upstream/find-reset-candidates.ts
* bun run script/upstream/find-reset-candidates.ts packages/opencode/src/agent
* bun run script/upstream/find-reset-candidates.ts --dry-run --review-limit 3
*/
import { $ } from "bun"
import { defaultConfig } from "./utils/config"
import { error, header, info, success, warn } from "./utils/logger"
import { matches } from "./utils/match"
import { classifyDrift, resetFile, type Bucket, type ClassifyResult } from "./utils/reset"
import { last, normalize, root, upstreamSizes } from "./utils/upstream"
interface Args {
scope?: string
reviewLimit: number
dryRun: boolean
concurrency: number
help: boolean
}
interface Entry extends ClassifyResult {
file: string
reset?: boolean
}
const KILO_ONLY_PATHSPECS = [
":(exclude,glob)packages/kilo-*/**",
":(exclude,glob)**/kilocode/**",
":(exclude)script/upstream",
]
// Non-code assets never make sense to bulk-reset. Big binary-ish files (large
// SVG sprites, icons, fonts, archives) also stress concurrent git subprocesses
// and hide real drift in the report. Use reset-to-upstream.ts per file if you
// really want to restore one of these.
const SKIP_EXTENSIONS = new Set([
".svg",
".png",
".jpg",
".jpeg",
".gif",
".webp",
".avif",
".ico",
".bmp",
".woff",
".woff2",
".ttf",
".otf",
".eot",
".zip",
".tar",
".gz",
".br",
".wasm",
".bin",
".db",
".sqlite",
".mp3",
".mp4",
".mov",
".pdf",
])
const SKIP_FILENAMES = new Set(["bun.lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "Cargo.lock"])
// Cap the blob size we'll classify. Generated manifests (models-snapshot.ts,
// openapi.json), media bundled in upstream-only packages (MP4s, PNGs), etc.
// are not meaningful reset candidates and big git show outputs can stall Bun
// subprocesses under concurrency.
const MAX_SIZE = 256 * 1024
const RESET_BUCKETS = new Set<Bucket>(["markers-only", "cosmetic-only", "small-diff"])
const BUCKET_ORDER: Bucket[] = [
"markers-only",
"cosmetic-only",
"small-diff",
"large-diff",
"identical",
"binary-diff",
"binary-identical",
"too-large",
"upstream-missing",
"local-missing",
]
function usage() {
console.log(`Usage: bun run script/upstream/find-reset-candidates.ts [path] [options]
Arguments:
path Optional repo-relative subdirectory to scope to.
Defaults to all tracked shared paths.
Options:
--review-limit <n> Max non-marker diff lines that still auto-resets
(default: 5).
--dry-run Classify and report only; do not write any files.
--concurrency <n> Parallel classifications (default: 8).
--help Show this help message.`)
}
function args(): Args {
const raw = process.argv.slice(2)
const skip = new Set<number>()
const flagValue = (names: string[]) => {
const idx = raw.findIndex((a) => names.includes(a) || names.some((n) => a.startsWith(`${n}=`)))
if (idx === -1) return undefined
skip.add(idx)
const arg = raw[idx]
if (arg.includes("=")) return arg.slice(arg.indexOf("=") + 1)
skip.add(idx + 1)
return raw[idx + 1]
}
const reviewRaw = flagValue(["--review-limit"])
const reviewLimit = reviewRaw === undefined ? 5 : Number(reviewRaw)
if (!Number.isFinite(reviewLimit) || reviewLimit < 0) {
throw new Error("--review-limit requires a non-negative number")
}
const concurrencyRaw = flagValue(["--concurrency"])
const concurrency = concurrencyRaw === undefined ? 8 : Number(concurrencyRaw)
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new Error("--concurrency requires a positive integer")
}
const positional = raw.filter((a, i) => !skip.has(i) && !a.startsWith("--"))
if (positional.length > 1) throw new Error(`Unexpected extra arguments: ${positional.slice(1).join(" ")}`)
return {
scope: positional[0],
reviewLimit,
concurrency,
dryRun: raw.includes("--dry-run"),
help: raw.includes("--help") || raw.includes("-h"),
}
}
interface CandidateSet {
files: string[]
skippedAssets: string[]
skippedPolicy: string[]
}
async function candidates(commit: string, scope: string | undefined, top: string): Promise<CandidateSet> {
const pathspecs = [scope ?? ".", ...KILO_ONLY_PATHSPECS]
const result = await $`git diff --name-only ${commit}..HEAD -- ${pathspecs}`.cwd(top).quiet().nothrow()
if (result.exitCode !== 0) {
throw new Error(`Failed to list candidate files: ${result.stderr.toString()}`)
}
const all = result.stdout
.toString()
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
const files: string[] = []
const skippedAssets: string[] = []
const skippedPolicy: string[] = []
for (const file of all) {
if (asset(file)) {
skippedAssets.push(file)
continue
}
if (policyExempt(file)) {
skippedPolicy.push(file)
continue
}
files.push(file)
}
return { files, skippedAssets, skippedPolicy }
}
/**
* Files the upstream merge config marks as "keep ours" (Kilo-specific preserved
* versions) or "skip" (upstream-only, removed in Kilo) should never be touched
* by the bulk resetter. They show up in `git diff` against raw upstream but
* resetting them would undo deliberate Kilo decisions.
*/
function policyExempt(file: string): boolean {
if (matches(file, defaultConfig.keepOurs)) return true
if (matches(file, defaultConfig.skipFiles)) return true
return false
}
function asset(file: string): boolean {
const base = file.slice(file.lastIndexOf("/") + 1)
if (SKIP_FILENAMES.has(base)) return true
const dot = base.lastIndexOf(".")
if (dot === -1) return false
return SKIP_EXTENSIONS.has(base.slice(dot).toLowerCase())
}
async function concurrent<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]> {
const results: R[] = Array.from({ length: items.length })
let next = 0
const worker = async () => {
while (next < items.length) {
const idx = next++
results[idx] = await fn(items[idx], idx)
}
}
const workers = Array.from({ length: Math.min(limit, Math.max(1, items.length)) }, worker)
await Promise.all(workers)
return results
}
function group(entries: Entry[]): Map<Bucket, Entry[]> {
const out = new Map<Bucket, Entry[]>()
for (const entry of entries) {
const bucket = out.get(entry.bucket) ?? []
bucket.push(entry)
out.set(entry.bucket, bucket)
}
for (const bucket of out.values()) bucket.sort((a, b) => a.file.localeCompare(b.file))
return out
}
function detail(entry: Entry): string {
if (entry.lines === undefined) return ""
if (entry.bucket === "too-large") return ` (${Math.round(entry.lines / 1024)} KB)`
return ` (${entry.lines} line${entry.lines === 1 ? "" : "s"})`
}
function describe(bucket: Bucket, count: number, dryRun: boolean): { label: string; action: string } {
if (bucket === "markers-only") return { label: `markers-only (${count})`, action: dryRun ? "would reset" : "reset" }
if (bucket === "cosmetic-only")
return { label: `cosmetic-only (${count})`, action: dryRun ? "would reset" : "reset" }
if (bucket === "small-diff") return { label: `small-diff (${count})`, action: dryRun ? "would reset" : "reset" }
if (bucket === "large-diff") return { label: `large-diff (${count})`, action: "skipped" }
if (bucket === "identical") return { label: `identical (${count})`, action: "nothing to do" }
if (bucket === "binary-diff") return { label: `binary-diff (${count})`, action: "skipped" }
if (bucket === "binary-identical") return { label: `binary-identical (${count})`, action: "nothing to do" }
if (bucket === "upstream-missing") return { label: `upstream-missing (${count})`, action: "skipped" }
if (bucket === "too-large") return { label: `too-large (${count})`, action: "skipped" }
return { label: `local-missing (${count})`, action: "skipped" }
}
function report(
entries: Entry[],
skippedAssets: string[],
skippedPolicy: string[],
dryRun: boolean,
tag: string,
commit: string,
scope: string,
limit: number,
) {
const grouped = group(entries)
const lines: string[] = []
lines.push(`# Reset-to-upstream candidate report`)
lines.push("")
lines.push(`- Last merged upstream: **${tag}** (\`${commit.slice(0, 8)}\`)`)
lines.push(`- Scope: \`${scope}\``)
lines.push(`- Review limit: ${limit} non-marker diff line(s)`)
lines.push(`- Mode: ${dryRun ? "dry-run (no writes)" : "auto-apply"}`)
lines.push(`- Total candidates: ${entries.length}`)
if (skippedAssets.length > 0) lines.push(`- Non-code assets skipped: ${skippedAssets.length}`)
if (skippedPolicy.length > 0) lines.push(`- Config-protected files skipped: ${skippedPolicy.length}`)
lines.push("")
lines.push(`## Summary`)
lines.push("")
lines.push(`| Bucket | Count | Action |`)
lines.push(`|---|---|---|`)
for (const bucket of BUCKET_ORDER) {
const items = grouped.get(bucket) ?? []
if (items.length === 0) continue
const info = describe(bucket, items.length, dryRun)
lines.push(`| ${bucket} | ${items.length} | ${info.action} |`)
}
if (skippedAssets.length > 0) lines.push(`| non-code-asset | ${skippedAssets.length} | skipped |`)
if (skippedPolicy.length > 0) lines.push(`| config-protected | ${skippedPolicy.length} | skipped |`)
lines.push("")
for (const bucket of BUCKET_ORDER) {
const items = grouped.get(bucket) ?? []
if (items.length === 0) continue
const info = describe(bucket, items.length, dryRun)
lines.push(`## ${info.label} — ${info.action}`)
lines.push("")
for (const entry of items) {
const suffix = detail(entry)
const note = entry.reset === false ? " [reset failed]" : ""
lines.push(`- \`${entry.file}\`${suffix}${note}`)
}
lines.push("")
}
return lines.join("\n")
}
async function main() {
const opts = args()
if (opts.help) {
usage()
return
}
const top = await root()
process.chdir(top)
const scope = opts.scope ? normalize(top, opts.scope) : undefined
header("Find reset-to-upstream candidates")
const version = await last()
success(`Last merged upstream: ${version.tag} (${version.commit.slice(0, 8)})`)
info(`Scope: ${scope ?? "(all shared paths)"}`)
info(`Review limit: ${opts.reviewLimit} non-marker diff line(s)`)
info(`Mode: ${opts.dryRun ? "dry-run" : "auto-apply"}`)
const { files, skippedAssets, skippedPolicy } = await candidates(version.commit, scope, top)
if (skippedAssets.length > 0) info(`Skipping ${skippedAssets.length} non-code asset(s)`)
if (skippedPolicy.length > 0) info(`Skipping ${skippedPolicy.length} file(s) protected by keepOurs/skipFiles config`)
if (files.length === 0) {
success("No code files differ from upstream in scope. Nothing to do.")
return
}
info(`Candidate files: ${files.length}`)
// Batch-check upstream sizes in one subprocess. Pre-bucket absent and oversized
// files so we don't spawn `git show` for a 16 MB .mp4 that would stall under
// concurrency anyway.
info(`Checking upstream blob sizes...`)
const sizes = await upstreamSizes(version.commit, files)
const classifyQueue: string[] = []
const preBucketed: Entry[] = []
for (const file of files) {
const size = sizes.get(file)
if (size === null || size === undefined) {
preBucketed.push({ file, bucket: "upstream-missing" })
continue
}
if (size > MAX_SIZE) {
preBucketed.push({ file, bucket: "too-large", lines: size })
continue
}
classifyQueue.push(file)
}
if (preBucketed.length > 0) info(`Pre-bucketed ${preBucketed.length} (missing or too-large)`)
info(`Classifying ${classifyQueue.length} file(s)...`)
const classified = await concurrent(classifyQueue, opts.concurrency, async (file, i) => {
const result = await classifyDrift({
root: top,
file,
commit: version.commit,
reviewLimit: opts.reviewLimit,
})
if ((i + 1) % 50 === 0 || i === classifyQueue.length - 1) {
info(`Classified ${i + 1}/${classifyQueue.length}`)
}
return { file, ...result } as Entry
})
const entries = [...preBucketed, ...classified]
if (!opts.dryRun) {
const resets = entries.filter((e) => RESET_BUCKETS.has(e.bucket))
if (resets.length > 0) info(`Resetting ${resets.length} file(s) to upstream...`)
await concurrent(resets, opts.concurrency, async (entry) => {
const result = await resetFile({ root: top, file: entry.file, commit: version.commit })
entry.reset = result.action !== "skipped"
if (result.action === "skipped") warn(`Skipped ${entry.file}: ${result.reason ?? "unknown"}`)
})
}
console.log("")
console.log(
report(
entries,
skippedAssets,
skippedPolicy,
opts.dryRun,
version.tag,
version.commit,
scope ?? "(all shared paths)",
opts.reviewLimit,
),
)
}
main().catch((err) => {
error(err instanceof Error ? err.message : String(err))
process.exit(1)
})
+10 -513
View File
@@ -8,19 +8,18 @@
* bun run script/upstream/fix-kilocode-markers.ts packages/opencode/src/file.ts --dry-run
*/
import { $ } from "bun"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { compareVersions, parseVersion, type VersionInfo } from "./utils/version"
import { isAncestor } from "./utils/git"
import { error, header, info, success, warn } from "./utils/logger"
import { transformI18nContent } from "./transforms/transform-i18n"
import { applyBrandingTransforms } from "./transforms/transform-take-theirs"
import { applyScriptTransforms } from "./transforms/transform-scripts"
import { applyExtensionTransforms } from "./transforms/transform-extensions"
import { applyWebTransforms } from "./transforms/transform-web"
import { applyPackageNameTransforms } from "./transforms/package-names"
import {
annotate,
annotates,
changed,
clean,
fresh,
ranges,
supported,
} from "./utils/markers"
import { last, normalize, root, translate, upstream } from "./utils/upstream"
interface Args {
file?: string
@@ -28,68 +27,6 @@ interface Args {
help: boolean
}
interface Text {
lines: string[]
eol: string
final: boolean
}
interface Clean {
text: Text
marks: Marks
}
interface Diff {
lines: Set<number>
deleted: number
}
interface Range {
start: number
end: number
}
interface Block extends Range {
before: string
after: string
}
interface Marks {
inline: Map<number, string>
starts: Map<number, string>
ends: Map<number, string>
blocks: Block[]
file?: string
}
type Style = "slash" | "hash" | "jsx" | "block"
const standalone = [
/^\s*\/\/\s*kilocode_change\b.*$/,
/^\s*#\s*kilocode_change\b.*$/,
/^\s*\{?\s*\/\*\s*kilocode_change\b.*\*\/\}?\s*$/,
]
const start = /\bkilocode_change\s+start\b/
const end = /\bkilocode_change\s+end\b/
const freshmark = /\bkilocode_change\s*-\s*new\s*file\b/
const unsupported = new Set([".json", ".jsonc", ".lock", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"])
const styles = new Map<string, Style>([
[".ts", "slash"],
[".tsx", "slash"],
[".js", "slash"],
[".jsx", "slash"],
[".css", "block"],
[".yml", "hash"],
[".yaml", "hash"],
[".toml", "hash"],
[".sh", "hash"],
[".bash", "hash"],
[".zsh", "hash"],
])
const workflows = [".github/workflows/publish.yml", ".github/workflows/beta.yml"]
const url = "https://github.com/anomalyco/opencode.git"
const exempt = ["script/upstream/"]
function usage() {
console.log(`Usage: bun run script/upstream/fix-kilocode-markers.ts <repo-relative-file> [--dry-run]
@@ -113,446 +50,6 @@ function args(): Args {
}
}
async function root() {
return (await $`git rev-parse --show-toplevel`.text()).trim()
}
function normalize(root: string, file: string) {
if (path.isAbsolute(file)) throw new Error("File must be relative to the repo root")
if (file.includes("\0")) throw new Error("File path contains a null byte")
const abs = path.resolve(root, file)
const rel = path.relative(root, abs).replaceAll(path.sep, "/")
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) throw new Error("File must stay inside the repo")
return rel
}
function ext(file: string) {
return path.extname(file).toLowerCase()
}
function supported(file: string, text: string) {
const kind = ext(file)
if (unsupported.has(kind)) return false
if (styles.has(kind)) return true
return !kind && text.startsWith("#!")
}
function annotates(file: string) {
return !exempt.some((scope) => file.startsWith(scope))
}
async function translate(file: string, text: string) {
const names = applyPackageNameTransforms(text).result
const script = applyScriptTransforms(names).result
const branded = applyBrandingTransforms(script).result
const i18n = transformI18nContent(branded).result
const ext = applyExtensionTransforms(i18n, file).result
const web = applyWebTransforms(ext).result
return workflow(file, web)
}
function workflow(file: string, text: string) {
if (!workflows.includes(file)) return text
return text
.replace(/github\.repository == 'anomalyco\/opencode'/g, "github.repository == 'Kilo-Org/kilocode'")
.replace(/github\.repository == "anomalyco\/opencode"/g, 'github.repository == "Kilo-Org/kilocode"')
.replace(/\bopencode-ai\b/g, "@kilocode/cli")
.replace(
/GH_REPO:\s*\$\{\{ \(github\.ref_name == 'beta' && 'anomalyco\/opencode-beta'\) \|\| github\.repository \}\}/g,
"GH_REPO: ${{ github.repository }}",
)
}
function split(text: string): Text {
const eol = text.includes("\r\n") ? "\r\n" : "\n"
const final = text.endsWith("\n")
const body = final ? text.slice(0, text.endsWith("\r\n") ? -2 : -1) : text
return { lines: body ? body.split(/\r?\n/) : [], eol, final }
}
function join(text: Text) {
return text.lines.join(text.eol) + (text.final ? text.eol : "")
}
function strip(file: string, line: string): { line: string | null; mark?: string } {
if (standalone.some((item) => item.test(line))) return { line: null }
if (style(file) === "hash") return comment(line, [/^#\s*kilocode_change\b/])
return comment(line, [/^\{\/\*\s*kilocode_change\b/, /^\/\*\s*kilocode_change\b/, /^\/\/\s*kilocode_change\b/])
}
function comment(line: string, tokens: RegExp[]) {
let quote = ""
let escape = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (!char) continue
if (quote) {
if (escape) {
escape = false
continue
}
if (char === "\\") {
escape = true
continue
}
if (char === quote) quote = ""
continue
}
if (char === '"' || char === "'" || char === "`") {
quote = char
continue
}
const rest = line.slice(i)
if (tokens.some((item) => item.test(rest))) {
const next = line.slice(0, i).trimEnd()
return { line: next, mark: line.slice(next.length) }
}
}
return { line }
}
function clean(file: string, text: string): Clean {
const parsed = split(text)
const marks: Marks = { inline: new Map(), starts: new Map(), ends: new Map(), blocks: [] }
const lines: string[] = []
const opens: { before: string; start?: number }[] = []
for (const line of parsed.lines) {
if (standalone.some((item) => item.test(line))) {
if (freshmark.test(line)) marks.file = line
if (start.test(line)) {
opens.push({ before: line })
continue
}
if (end.test(line)) {
const open = opens.pop()
const last = lines.length - 1
if (open?.start !== undefined && last >= open.start) {
marks.ends.set(last, line)
marks.blocks.push({ start: open.start, end: last, before: open.before, after: line })
}
if (!open && last >= 0) marks.ends.set(last, line)
continue
}
continue
}
const next = strip(file, line)
if (next.line === null) continue
const index = lines.length
lines.push(next.line)
for (const open of opens) {
if (open.start !== undefined) continue
open.start = index
marks.starts.set(index, open.before)
}
if (next.mark) marks.inline.set(index, next.mark)
}
return { text: { ...parsed, lines }, marks }
}
async function last(): Promise<VersionInfo> {
const source = await remote()
info(`Fetching upstream tags from ${source}...`)
const fetch = await $`git fetch ${source} --tags --force`.quiet().nothrow()
if (fetch.exitCode !== 0) throw new Error(`Failed to fetch upstream: ${fetch.stderr.toString()}`)
const versions = await list(source)
for (const version of versions) {
if (await isAncestor(version.commit, "HEAD")) return version
}
throw new Error("Could not find a merged upstream tag in HEAD")
}
async function remote() {
const result = await $`git remote get-url upstream`.quiet().nothrow()
if (result.exitCode === 0) return "upstream"
warn(`No 'upstream' remote found; using ${url}`)
return url
}
async function list(source: string): Promise<VersionInfo[]> {
const result = await $`git ls-remote --tags ${source}`.quiet().nothrow()
if (result.exitCode !== 0) throw new Error(`Failed to list upstream tags: ${result.stderr.toString()}`)
const found = new Map<string, string>()
for (const line of result.stdout.toString().trim().split("\n")) {
const match = line.match(/^([a-f0-9]+)\s+refs\/tags\/([^^]+)(\^\{\})?$/)
if (!match) continue
const commit = match[1]
const tag = match[2]
const peeled = Boolean(match[3])
if (commit && tag && (peeled || !found.has(tag))) found.set(tag, commit)
}
return [...found]
.flatMap(([tag, commit]) => {
const version = parseVersion(tag)
return version ? [{ version, tag, commit }] : []
})
.sort((a, b) => compareVersions(b.version, a.version))
}
async function upstream(ref: string, file: string) {
const spec = `${ref}:${file}`
const result = await $`git show ${spec}`.quiet().nothrow()
if (result.exitCode === 0) return result.stdout.toString()
const stderr = result.stderr.toString()
if (stderr.includes("exists on disk") || stderr.includes("does not exist") || stderr.includes("Path")) return null
throw new Error(`Failed to read ${file} from ${ref}: ${stderr}`)
}
function style(file: string): Style {
const kind = ext(file)
return styles.get(kind) ?? "hash"
}
function context(file: string, text: Text, range: Range): Style {
const base = style(file)
if (![".tsx", ".jsx"].includes(ext(file))) return base
if (tag(text.lines, range.start)) return "block"
if (child(text.lines, range.start)) return "jsx"
return base
}
function nearby(lines: string[], start: number, step: number) {
for (let i = start; i >= 0 && i < lines.length; i += step) {
const line = lines[i]?.trim()
if (line) return line
}
return ""
}
function tag(lines: string[], start: number) {
const current = lines[start]?.trim() ?? ""
if (!current) return false
if (/^[A-Za-z_$][\w$.:/-]*(=|\s*=)/.test(current)) return true
for (let i = start - 1; i >= Math.max(0, start - 20); i--) {
const line = lines[i]?.trim() ?? ""
if (!line) continue
if (line.includes(">")) return false
if (/^<\/?[A-Za-z]/.test(line)) return true
}
return false
}
function child(lines: string[], start: number) {
const current = lines[start]?.trim() ?? ""
const prev = nearby(lines, start - 1, -1)
const next = nearby(lines, start + 1, 1)
if (prev.endsWith(">") && !prev.endsWith("=>")) return true
if (next.startsWith("</")) return true
if (current.startsWith("</")) return true
if (current.startsWith("<") && prev && !prev.endsWith("(") && !prev.endsWith("return (")) return true
return false
}
function block(mode: Style, pad: string) {
if (mode === "hash") return { start: `${pad}# kilocode_change start`, end: `${pad}# kilocode_change end` }
if (mode === "jsx") return { start: `${pad}{/* kilocode_change start */}`, end: `${pad}{/* kilocode_change end */}` }
if (mode === "block") return { start: `${pad}/* kilocode_change start */`, end: `${pad}/* kilocode_change end */` }
return { start: `${pad}// kilocode_change start`, end: `${pad}// kilocode_change end` }
}
function note(mode: Style) {
if (mode === "hash") return " # kilocode_change"
if (mode === "jsx") return " {/* kilocode_change */}"
if (mode === "block") return " /* kilocode_change */"
return " // kilocode_change"
}
function indent(line: string) {
return line.match(/^\s*/)?.[0] ?? ""
}
function inline(file: string, lines: string[], range: Range, mode: Style) {
if (mode === "hash") return true
if (mode === "block" || mode === "jsx") return false
if (![".tsx", ".jsx"].includes(ext(file))) return true
return true
}
function merge(items: Range[]) {
return [...items]
.sort((a, b) => a.start - b.start)
.reduce<Range[]>((acc, item) => {
const prev = acc.at(-1)
if (prev && item.start <= prev.end + 1) {
prev.end = Math.max(prev.end, item.end)
return acc
}
acc.push({ ...item })
return acc
}, [])
}
function ranges(nums: Set<number>): Range[] {
const sorted = [...nums].sort((a, b) => a - b)
return merge(
sorted.reduce<Range[]>((acc, num) => {
const prev = acc.at(-1)
if (prev && num === prev.end + 1) {
prev.end = num
return acc
}
acc.push({ start: num, end: num })
return acc
}, []),
)
}
function expand(found: Range[], marks: Marks) {
return merge(
found.map((range) => {
const next = { ...range }
for (const block of marks.blocks) {
if (next.end < block.start || next.start > block.end) continue
next.start = Math.min(next.start, block.start)
next.end = Math.max(next.end, block.end)
}
return next
}),
)
}
function boundary(line: string | undefined, kind: RegExp) {
if (!line) return false
return standalone.some((item) => item.test(line)) && kind.test(line)
}
function gap(lines: string[], index: number) {
const next = lines.slice(index).findIndex((line) => line.trim() !== "")
return next === -1 ? -1 : index + next
}
function collapse(lines: string[]): string[] {
const index = lines.findIndex((line, pos) => {
if (!boundary(line, end)) return false
const next = gap(lines, pos + 1)
return next !== -1 && boundary(lines[next], start)
})
if (index === -1) return lines
const next = gap(lines, index + 1)
return collapse(lines.filter((_, pos) => pos !== index && pos !== next))
}
function saved(marks: Marks, range: Range) {
return marks.blocks.find((block) => block.start === range.start && block.end === range.end)
}
function annotate(file: string, clean: Clean, found: Range[]) {
const text = clean.text
const marks = clean.marks
const lines = [...text.lines]
for (const range of expand(found, marks).reverse()) {
const mode = context(file, text, range)
const prior = saved(marks, range)
const before = prior?.before ?? marks.starts.get(range.start)
const after = prior?.after ?? marks.ends.get(range.end)
if (!before && !after && range.start === range.end && inline(file, text.lines, range, mode)) {
lines[range.start] = `${lines[range.start]}${marks.inline.get(range.start) ?? note(mode)}`
continue
}
const pad = indent(text.lines[range.start] ?? "")
const fallback = block(mode, pad)
const pair = {
start: before ?? fallback.start,
end: after ?? fallback.end,
}
lines.splice(range.end + 1, 0, pair.end)
lines.splice(range.start, 0, pair.start)
}
return join({ ...text, lines: collapse(lines) })
}
function fresh(file: string, clean: Clean) {
const lines = [...clean.text.lines]
const mode = style(file)
const line = clean.marks.file ?? (mode === "hash" ? "# kilocode_change - new file" : "// kilocode_change - new file")
const at = lines[0]?.startsWith("#!") ? 1 : 0
lines.splice(at, 0, line)
return join({ ...clean.text, lines })
}
function patch(out: string): Diff {
const lines = new Set<number>()
const state = { next: 0, deleted: 0, added: 0, removed: 0 }
const flush = () => {
if (state.removed > 0 && state.added === 0) state.deleted += state.removed
state.added = 0
state.removed = 0
}
for (const line of out.split("\n")) {
const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
if (hunk) {
flush()
state.next = Number(hunk[1]) - 1
continue
}
if (line.startsWith("+++") || line.startsWith("---")) continue
if (line.startsWith("+")) {
if (line.slice(1).trim()) lines.add(state.next)
state.added++
state.next++
continue
}
if (line.startsWith("-")) {
state.removed++
continue
}
if (line.startsWith(" ")) state.next++
}
flush()
return { lines, deleted: state.deleted }
}
async function changed(base: Text, head: Text): Promise<Diff> {
const dir = await mkdtemp(path.join(tmpdir(), "kilo-markers-"))
const left = path.join(dir, "upstream")
const right = path.join(dir, "current")
try {
await Bun.write(left, join({ ...base, eol: "\n" }))
await Bun.write(right, join({ ...head, eol: "\n" }))
const result = await $`git diff --no-index --no-ext-diff --unified=0 -- ${left} ${right}`.quiet().nothrow()
if (result.exitCode === 0) return { lines: new Set(), deleted: 0 }
if (result.exitCode === 1) return patch(result.stdout.toString())
throw new Error(result.stderr.toString())
} finally {
await rm(dir, { recursive: true, force: true })
}
}
async function main() {
const opts = args()
if (opts.help) {
+3
View File
@@ -11,6 +11,9 @@ export * from "./utils/logger"
export * from "./utils/config"
export * from "./utils/version"
export * from "./utils/report"
export * from "./utils/upstream"
export * from "./utils/markers"
export * from "./utils/reset"
// Transforms
export { transformAll as transformPackageNames, transformFile } from "./transforms/package-names"
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/upstream-merge",
"version": "7.2.36",
"version": "7.2.40",
"private": true,
"type": "module",
"description": "Scripts for automating upstream opencode merges into Kilo",
@@ -14,6 +14,8 @@
"transform:all": "bun run transforms/package-names.ts && bun run codemods/transform-imports.ts && bun run codemods/transform-strings.ts",
"versions": "bun run transforms/preserve-versions.ts",
"fix:markers": "bun run fix-kilocode-markers.ts",
"reset:upstream": "bun run reset-to-upstream.ts",
"find:candidates": "bun run find-reset-candidates.ts",
"keep-ours": "bun run transforms/keep-ours.ts"
},
"dependencies": {
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bun
/**
* Reset one file to the last merged upstream version after applying Kilo merge
* branding transforms.
*
* Usage:
* bun run script/upstream/reset-to-upstream.ts packages/opencode/src/file.ts
* bun run script/upstream/reset-to-upstream.ts packages/opencode/src/file.ts --dry-run
*/
import { error, header, info, success, warn } from "./utils/logger"
import { resetFile } from "./utils/reset"
import { last, normalize, root } from "./utils/upstream"
interface Args {
file?: string
dryRun: boolean
help: boolean
}
function usage() {
console.log(`Usage: bun run script/upstream/reset-to-upstream.ts <repo-relative-file> [--dry-run]
Resets one file by:
1. Finding the newest upstream tag whose commit is already merged into HEAD.
2. Reading that file from upstream at the merged tag.
3. Applying upstream merge branding transforms.
4. Writing the transformed upstream file to the working tree.
If the file does not exist upstream, the local file is deleted. Binary files are
written back as raw upstream bytes without text transforms.
Options:
--dry-run Show what would change without writing the file.
--help Show this help message.`)
}
function args(): Args {
const raw = process.argv.slice(2)
return {
file: raw.find((arg) => !arg.startsWith("--")),
dryRun: raw.includes("--dry-run"),
help: raw.includes("--help") || raw.includes("-h"),
}
}
async function main() {
const opts = args()
if (opts.help) {
usage()
return
}
if (!opts.file) {
usage()
process.exit(1)
}
const top = await root()
process.chdir(top)
const file = normalize(top, opts.file)
header("Reset file to upstream")
const version = await last()
success(`Last merged upstream: ${version.tag} (${version.commit.slice(0, 8)})`)
const result = await resetFile({ root: top, file, commit: version.commit, dryRun: opts.dryRun })
if (result.action === "deleted") {
if (opts.dryRun) {
warn(`${file} does not exist upstream`)
info(`[DRY-RUN] Would delete ${file}`)
return
}
warn(`${file} does not exist upstream`)
success(`Deleted ${file}`)
return
}
if (result.action === "identical") {
success(`${file} already matches transformed upstream ${version.tag}`)
return
}
if (opts.dryRun) {
info(`[DRY-RUN] Would reset ${file} to transformed upstream ${version.tag}`)
return
}
success(`Reset ${file} to transformed upstream ${version.tag}`)
}
main().catch((err) => {
error(err instanceof Error ? err.message : String(err))
process.exit(1)
})
+16 -1
View File
@@ -83,7 +83,6 @@ export const defaultConfig: MergeConfig = {
// GitHub Action - Kilo version is fully ported and complete
"github/action.yml",
"github/README.md",
"github/.gitignore",
"github/script/release",
"github/script/publish",
],
@@ -115,11 +114,26 @@ export const defaultConfig: MergeConfig = {
"README.zht.md",
// Stats file
"STATS.md",
// Team members file (Kilo doesn't maintain this upstream list)
".github/TEAM_MEMBERS",
// Workflows that don't exist in Kilo
".github/workflows/update-nix-hashes.yml",
".github/workflows/deploy.yml",
".github/workflows/docs-update.yml",
".github/workflows/docs-locale-sync.yml",
// Workflows deleted in Kilo (replaced or no longer needed)
".github/workflows/opencode.yml",
".github/workflows/publish-vscode.yml",
// VS Code example configs (Kilo ships real .vscode/* files)
".vscode/launch.example.json",
".vscode/settings.example.json",
// Nix files for packages Kilo has removed / replaced with nix/kilo.nix
"nix/desktop.nix",
"nix/opencode.nix",
// opencode CLI bin (Kilo uses its own build output)
"packages/opencode/bin/opencode",
// Removed prompt file
"packages/opencode/src/session/prompt/build-switch.txt",
// Vouch files (Kilo doesn't use Vouch).
// Upstream currently ships VOUCHED.td (typo extension). The glob covers both
// the current .td file and any future .md rename without another merge breaking.
@@ -148,6 +162,7 @@ export const defaultConfig: MergeConfig = {
"github/tsconfig.json",
"github/bun.lock",
"github/sst-env.d.ts",
"github/.gitignore",
],
// Files that should take upstream version and apply Kilo branding transforms
+5 -5
View File
@@ -126,11 +126,11 @@ export async function commit(message: string): Promise<void> {
}
export async function merge(branch: string): Promise<{ success: boolean; conflicts: string[] }> {
// Use zdiff3 markers so conflicts carry the base version (|||||||) alongside
// ours/theirs. This gives mergiraf the base it needs for structural heuristics
// and makes any remaining manual resolution dramatically easier (you can see
// what both sides changed relative to the common ancestor instead of
// reverse-engineering it from a 2-way marker).
// Force zdiff3 markers even if the contributor's local config has drifted:
// conflicts carry the base version (|||||||) alongside ours/theirs so mergiraf
// has the common ancestor for structural heuristics and any remaining manual
// resolution is dramatically easier. `postinstall` (script/setup-git.ts) sets
// this repo-wide as well; the `-c` override here is belt-and-suspenders.
const result = await $`git -c merge.conflictStyle=zdiff3 merge ${branch}`.nothrow()
if (result.exitCode === 0) {
+448
View File
@@ -0,0 +1,448 @@
#!/usr/bin/env bun
/**
* Shared kilocode_change marker helpers used by both the marker fixer and the
* reset-candidate classifier. The logic here was originally inlined in
* fix-kilocode-markers.ts.
*/
import { $ } from "bun"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
export interface Text {
lines: string[]
eol: string
final: boolean
}
export interface Clean {
text: Text
marks: Marks
}
export interface Diff {
lines: Set<number>
deleted: number
}
export interface Range {
start: number
end: number
}
export interface Block extends Range {
before: string
after: string
}
export interface Marks {
inline: Map<number, string>
starts: Map<number, string>
ends: Map<number, string>
blocks: Block[]
file?: string
}
export type Style = "slash" | "hash" | "jsx" | "block"
export const standalone = [
/^\s*\/\/\s*kilocode_change\b.*$/,
/^\s*#\s*kilocode_change\b.*$/,
/^\s*\{?\s*\/\*\s*kilocode_change\b.*\*\/\}?\s*$/,
]
export const start = /\bkilocode_change\s+start\b/
export const end = /\bkilocode_change\s+end\b/
export const freshmark = /\bkilocode_change\s*-\s*new\s*file\b/
export const unsupported = new Set([".json", ".jsonc", ".lock", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"])
export const styles = new Map<string, Style>([
[".ts", "slash"],
[".tsx", "slash"],
[".js", "slash"],
[".jsx", "slash"],
[".css", "block"],
[".yml", "hash"],
[".yaml", "hash"],
[".toml", "hash"],
[".sh", "hash"],
[".bash", "hash"],
[".zsh", "hash"],
])
export const exempt = ["script/upstream/"]
export function ext(file: string) {
return path.extname(file).toLowerCase()
}
export function supported(file: string, text: string) {
const kind = ext(file)
if (unsupported.has(kind)) return false
if (styles.has(kind)) return true
return !kind && text.startsWith("#!")
}
export function annotates(file: string) {
return !exempt.some((scope) => file.startsWith(scope))
}
export function binary(data: Uint8Array) {
return data.includes(0)
}
export function split(text: string): Text {
const eol = text.includes("\r\n") ? "\r\n" : "\n"
const final = text.endsWith("\n")
const body = final ? text.slice(0, text.endsWith("\r\n") ? -2 : -1) : text
return { lines: body ? body.split(/\r?\n/) : [], eol, final }
}
export function join(text: Text) {
return text.lines.join(text.eol) + (text.final ? text.eol : "")
}
function strip(file: string, line: string): { line: string | null; mark?: string } {
if (standalone.some((item) => item.test(line))) return { line: null }
if (style(file) === "hash") return comment(line, [/^#\s*kilocode_change\b/])
return comment(line, [/^\{\/\*\s*kilocode_change\b/, /^\/\*\s*kilocode_change\b/, /^\/\/\s*kilocode_change\b/])
}
function comment(line: string, tokens: RegExp[]) {
let quote = ""
let escape = false
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (!char) continue
if (quote) {
if (escape) {
escape = false
continue
}
if (char === "\\") {
escape = true
continue
}
if (char === quote) quote = ""
continue
}
if (char === '"' || char === "'" || char === "`") {
quote = char
continue
}
const rest = line.slice(i)
if (tokens.some((item) => item.test(rest))) {
const next = line.slice(0, i).trimEnd()
return { line: next, mark: line.slice(next.length) }
}
}
return { line }
}
export function clean(file: string, text: string): Clean {
const parsed = split(text)
const marks: Marks = { inline: new Map(), starts: new Map(), ends: new Map(), blocks: [] }
const lines: string[] = []
const opens: { before: string; start?: number }[] = []
for (const line of parsed.lines) {
if (standalone.some((item) => item.test(line))) {
if (freshmark.test(line)) marks.file = line
if (start.test(line)) {
opens.push({ before: line })
continue
}
if (end.test(line)) {
const open = opens.pop()
const last = lines.length - 1
if (open?.start !== undefined && last >= open.start) {
marks.ends.set(last, line)
marks.blocks.push({ start: open.start, end: last, before: open.before, after: line })
}
if (!open && last >= 0) marks.ends.set(last, line)
continue
}
continue
}
const next = strip(file, line)
if (next.line === null) continue
const index = lines.length
lines.push(next.line)
for (const open of opens) {
if (open.start !== undefined) continue
open.start = index
marks.starts.set(index, open.before)
}
if (next.mark) marks.inline.set(index, next.mark)
}
return { text: { ...parsed, lines }, marks }
}
export function style(file: string): Style {
const kind = ext(file)
return styles.get(kind) ?? "hash"
}
function context(file: string, text: Text, range: Range): Style {
const base = style(file)
if (![".tsx", ".jsx"].includes(ext(file))) return base
if (tag(text.lines, range.start)) return "block"
if (child(text.lines, range.start)) return "jsx"
return base
}
function nearby(lines: string[], start: number, step: number) {
for (let i = start; i >= 0 && i < lines.length; i += step) {
const line = lines[i]?.trim()
if (line) return line
}
return ""
}
function tag(lines: string[], start: number) {
const current = lines[start]?.trim() ?? ""
if (!current) return false
if (/^[A-Za-z_$][\w$.:/-]*(=|\s*=)/.test(current)) return true
for (let i = start - 1; i >= Math.max(0, start - 20); i--) {
const line = lines[i]?.trim() ?? ""
if (!line) continue
if (line.includes(">")) return false
if (/^<\/?[A-Za-z]/.test(line)) return true
}
return false
}
function child(lines: string[], start: number) {
const current = lines[start]?.trim() ?? ""
const prev = nearby(lines, start - 1, -1)
const next = nearby(lines, start + 1, 1)
if (prev.endsWith(">") && !prev.endsWith("=>")) return true
if (next.startsWith("</")) return true
if (current.startsWith("</")) return true
if (current.startsWith("<") && prev && !prev.endsWith("(") && !prev.endsWith("return (")) return true
return false
}
function block(mode: Style, pad: string) {
if (mode === "hash") return { start: `${pad}# kilocode_change start`, end: `${pad}# kilocode_change end` }
if (mode === "jsx") return { start: `${pad}{/* kilocode_change start */}`, end: `${pad}{/* kilocode_change end */}` }
if (mode === "block") return { start: `${pad}/* kilocode_change start */`, end: `${pad}/* kilocode_change end */` }
return { start: `${pad}// kilocode_change start`, end: `${pad}// kilocode_change end` }
}
function note(mode: Style) {
if (mode === "hash") return " # kilocode_change"
if (mode === "jsx") return " {/* kilocode_change */}"
if (mode === "block") return " /* kilocode_change */"
return " // kilocode_change"
}
function indent(line: string) {
return line.match(/^\s*/)?.[0] ?? ""
}
function inline(file: string, _lines: string[], _range: Range, mode: Style) {
if (mode === "hash") return true
if (mode === "block" || mode === "jsx") return false
if (![".tsx", ".jsx"].includes(ext(file))) return true
return true
}
function merge(items: Range[]) {
return [...items]
.sort((a, b) => a.start - b.start)
.reduce<Range[]>((acc, item) => {
const prev = acc.at(-1)
if (prev && item.start <= prev.end + 1) {
prev.end = Math.max(prev.end, item.end)
return acc
}
acc.push({ ...item })
return acc
}, [])
}
export function ranges(nums: Set<number>): Range[] {
const sorted = [...nums].sort((a, b) => a - b)
return merge(
sorted.reduce<Range[]>((acc, num) => {
const prev = acc.at(-1)
if (prev && num === prev.end + 1) {
prev.end = num
return acc
}
acc.push({ start: num, end: num })
return acc
}, []),
)
}
function expand(found: Range[], marks: Marks) {
return merge(
found.map((range) => {
const next = { ...range }
for (const block of marks.blocks) {
if (next.end < block.start || next.start > block.end) continue
next.start = Math.min(next.start, block.start)
next.end = Math.max(next.end, block.end)
}
return next
}),
)
}
function boundary(line: string | undefined, kind: RegExp) {
if (!line) return false
return standalone.some((item) => item.test(line)) && kind.test(line)
}
function gap(lines: string[], index: number) {
const next = lines.slice(index).findIndex((line) => line.trim() !== "")
return next === -1 ? -1 : index + next
}
function collapse(lines: string[]): string[] {
const index = lines.findIndex((line, pos) => {
if (!boundary(line, end)) return false
const next = gap(lines, pos + 1)
return next !== -1 && boundary(lines[next], start)
})
if (index === -1) return lines
const next = gap(lines, index + 1)
return collapse(lines.filter((_, pos) => pos !== index && pos !== next))
}
function saved(marks: Marks, range: Range) {
return marks.blocks.find((block) => block.start === range.start && block.end === range.end)
}
export function annotate(file: string, clean: Clean, found: Range[]) {
const text = clean.text
const marks = clean.marks
const lines = [...text.lines]
for (const range of expand(found, marks).reverse()) {
const mode = context(file, text, range)
const prior = saved(marks, range)
const before = prior?.before ?? marks.starts.get(range.start)
const after = prior?.after ?? marks.ends.get(range.end)
if (!before && !after && range.start === range.end && inline(file, text.lines, range, mode)) {
lines[range.start] = `${lines[range.start]}${marks.inline.get(range.start) ?? note(mode)}`
continue
}
const pad = indent(text.lines[range.start] ?? "")
const fallback = block(mode, pad)
const pair = {
start: before ?? fallback.start,
end: after ?? fallback.end,
}
lines.splice(range.end + 1, 0, pair.end)
lines.splice(range.start, 0, pair.start)
}
return join({ ...text, lines: collapse(lines) })
}
export function fresh(file: string, clean: Clean) {
const lines = [...clean.text.lines]
const mode = style(file)
const line = clean.marks.file ?? (mode === "hash" ? "# kilocode_change - new file" : "// kilocode_change - new file")
const at = lines[0]?.startsWith("#!") ? 1 : 0
lines.splice(at, 0, line)
return join({ ...clean.text, lines })
}
function patch(out: string): Diff {
const lines = new Set<number>()
const state = { next: 0, deleted: 0, added: 0, removed: 0 }
const flush = () => {
if (state.removed > 0 && state.added === 0) state.deleted += state.removed
state.added = 0
state.removed = 0
}
for (const line of out.split("\n")) {
const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
if (hunk) {
flush()
state.next = Number(hunk[1]) - 1
continue
}
if (line.startsWith("+++") || line.startsWith("---")) continue
if (line.startsWith("+")) {
if (line.slice(1).trim()) lines.add(state.next)
state.added++
state.next++
continue
}
if (line.startsWith("-")) {
state.removed++
continue
}
if (line.startsWith(" ")) state.next++
}
flush()
return { lines, deleted: state.deleted }
}
export async function changed(base: Text, head: Text, opts?: { ignoreWhitespace?: boolean }): Promise<Diff> {
const dir = await mkdtemp(path.join(tmpdir(), "kilo-markers-"))
const left = path.join(dir, "upstream")
const right = path.join(dir, "current")
try {
await Bun.write(left, join({ ...base, eol: "\n" }))
await Bun.write(right, join({ ...head, eol: "\n" }))
const result = opts?.ignoreWhitespace
? await $`git diff --no-index --no-ext-diff -w --unified=0 -- ${left} ${right}`.quiet().nothrow()
: await $`git diff --no-index --no-ext-diff --unified=0 -- ${left} ${right}`.quiet().nothrow()
if (result.exitCode === 0) return { lines: new Set(), deleted: 0 }
if (result.exitCode === 1) return patch(result.stdout.toString())
throw new Error(result.stderr.toString())
} finally {
await rm(dir, { recursive: true, force: true })
}
}
/**
* Pure in-process line-diff used by bulk classifiers. Returns the number of
* non-matching lines between two texts using a multiset approach (moving a line
* around doesn't count as drift). Whitespace can optionally be ignored.
*
* Unlike `changed()`, this spawns no subprocesses so it is safe to run
* concurrently without risking pipe-buffer deadlocks on large inputs.
*/
export function approxDiff(base: string, head: string, opts?: { ignoreWhitespace?: boolean }): number {
if (base === head) return 0
const norm = opts?.ignoreWhitespace ? (line: string) => line.replace(/\s+/g, " ").trim() : (line: string) => line
const counts = new Map<string, number>()
for (const line of base.split(/\r?\n/)) {
const key = norm(line)
counts.set(key, (counts.get(key) ?? 0) + 1)
}
for (const line of head.split(/\r?\n/)) {
const key = norm(line)
counts.set(key, (counts.get(key) ?? 0) - 1)
}
let total = 0
for (const v of counts.values()) total += Math.abs(v)
return total
}
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bun
/**
* Shared helpers for resetting a single file to the last merged upstream
* version and for classifying how far a file has drifted from upstream.
*
* Used by both reset-to-upstream.ts (single-file CLI) and
* find-reset-candidates.ts (bulk finder).
*/
import { rm } from "node:fs/promises"
import path from "node:path"
import { approxDiff, binary, clean, join } from "./markers"
import { translate, upstreamData } from "./upstream"
export type ResetAction = "identical" | "deleted" | "written" | "skipped"
export interface ResetResult {
action: ResetAction
reason?: string
}
export interface ResetOptions {
/** Repo root absolute path. */
root: string
/** Repo-relative file path. */
file: string
/** Upstream commit SHA to read from. */
commit: string
/** Do not write; report the action that would be taken. */
dryRun?: boolean
}
/**
* Reset a file to the transformed last merged upstream version. Binary files
* are restored as raw bytes without text transforms. Files that do not exist
* upstream are deleted from the working tree.
*/
export async function resetFile(opts: ResetOptions): Promise<ResetResult> {
const abs = path.join(opts.root, opts.file)
const data = await upstreamData(opts.commit, opts.file)
if (data === null) {
if (opts.dryRun) return { action: "deleted", reason: "dry-run" }
await rm(abs, { force: true })
return { action: "deleted" }
}
if (binary(data)) {
const current = await Bun.file(abs)
.arrayBuffer()
.then((buffer) => new Uint8Array(buffer))
.catch(() => null)
if (current && same(current, data)) return { action: "identical" }
if (opts.dryRun) return { action: "written", reason: "dry-run" }
await Bun.write(abs, data)
return { action: "written" }
}
const base = new TextDecoder().decode(data)
const next = await translate(opts.file, base)
const current = await Bun.file(abs)
.text()
.catch(() => null)
if (current === next) return { action: "identical" }
if (opts.dryRun) return { action: "written", reason: "dry-run" }
await Bun.write(abs, next)
return { action: "written" }
}
export type Bucket =
| "identical"
| "markers-only"
| "cosmetic-only"
| "small-diff"
| "large-diff"
| "upstream-missing"
| "binary-diff"
| "binary-identical"
| "local-missing"
| "too-large"
export interface ClassifyResult {
bucket: Bucket
/** Non-marker, non-whitespace diff line count for text diffs. */
lines?: number
}
export interface ClassifyOptions {
root: string
file: string
commit: string
/** Threshold for small-diff vs large-diff (inclusive upper bound for small). */
reviewLimit: number
}
/**
* Classify how a file compares to the transformed last merged upstream version.
* Does not touch the working tree.
*/
export async function classifyDrift(opts: ClassifyOptions): Promise<ClassifyResult> {
const abs = path.join(opts.root, opts.file)
const data = await upstreamData(opts.commit, opts.file)
if (data === null) return { bucket: "upstream-missing" }
if (binary(data)) {
const current = await Bun.file(abs)
.arrayBuffer()
.then((buffer) => new Uint8Array(buffer))
.catch(() => null)
if (current === null) return { bucket: "local-missing" }
return same(current, data) ? { bucket: "binary-identical" } : { bucket: "binary-diff" }
}
const upstreamText = new TextDecoder().decode(data)
const translated = await translate(opts.file, upstreamText)
const local = await Bun.file(abs)
.text()
.catch(() => null)
if (local === null) return { bucket: "local-missing" }
if (local === translated) return { bucket: "identical" }
const cleanedLocal = join(clean(opts.file, local).text)
const cleanedUpstream = join(clean(opts.file, translated).text)
if (cleanedLocal === cleanedUpstream) return { bucket: "markers-only" }
const count = approxDiff(cleanedUpstream, cleanedLocal, { ignoreWhitespace: true })
if (count === 0) return { bucket: "cosmetic-only" }
if (count <= opts.reviewLimit) return { bucket: "small-diff", lines: count }
return { bucket: "large-diff", lines: count }
}
function same(left: Uint8Array, right: Uint8Array) {
return left.length === right.length && left.every((byte, index) => byte === right[index])
}
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bun
import { $ } from "bun"
import path from "node:path"
import { applyPackageNameTransforms } from "../transforms/package-names"
import { applyExtensionTransforms } from "../transforms/transform-extensions"
import { transformI18nContent } from "../transforms/transform-i18n"
import { applyScriptTransforms } from "../transforms/transform-scripts"
import { applyBrandingTransforms } from "../transforms/transform-take-theirs"
import { applyWebTransforms } from "../transforms/transform-web"
import { warn, info } from "./logger"
import { compareVersions, parseVersion, type VersionInfo } from "./version"
import { isAncestor } from "./git"
const url = "https://github.com/anomalyco/opencode.git"
const workflows = [".github/workflows/publish.yml", ".github/workflows/beta.yml"]
export async function root() {
return (await $`git rev-parse --show-toplevel`.text()).trim()
}
export function normalize(root: string, file: string) {
if (path.isAbsolute(file)) throw new Error("File must be relative to the repo root")
if (file.includes("\0")) throw new Error("File path contains a null byte")
const abs = path.resolve(root, file)
const rel = path.relative(root, abs).replaceAll(path.sep, "/")
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) throw new Error("File must stay inside the repo")
return rel
}
export async function remote() {
const result = await $`git remote get-url upstream`.quiet().nothrow()
if (result.exitCode === 0) return "upstream"
warn(`No 'upstream' remote found; using ${url}`)
return url
}
export async function last(): Promise<VersionInfo> {
const source = await remote()
info(`Fetching upstream tags from ${source}...`)
const fetch = await $`git fetch ${source} --tags --force`.quiet().nothrow()
if (fetch.exitCode !== 0) throw new Error(`Failed to fetch upstream: ${fetch.stderr.toString()}`)
const items = await versions(source)
for (const version of items) {
if (await isAncestor(version.commit, "HEAD")) return version
}
throw new Error("Could not find a merged upstream tag in HEAD")
}
export async function versions(source: string): Promise<VersionInfo[]> {
const result = await $`git ls-remote --tags ${source}`.quiet().nothrow()
if (result.exitCode !== 0) throw new Error(`Failed to list upstream tags: ${result.stderr.toString()}`)
const found = new Map<string, string>()
for (const line of result.stdout.toString().trim().split("\n")) {
const match = line.match(/^([a-f0-9]+)\s+refs\/tags\/([^^]+)(\^\{\})?$/)
if (!match) continue
const commit = match[1]
const tag = match[2]
const peeled = Boolean(match[3])
if (commit && tag && (peeled || !found.has(tag))) found.set(tag, commit)
}
return [...found]
.flatMap(([tag, commit]) => {
const version = parseVersion(tag)
return version ? [{ version, tag, commit }] : []
})
.sort((a, b) => compareVersions(b.version, a.version))
}
export async function upstream(ref: string, file: string) {
const data = await upstreamData(ref, file)
return data === null ? null : data.toString()
}
export async function upstreamData(ref: string, file: string) {
const spec = `${ref}:${file}`
const result = await $`git show ${spec}`.quiet().nothrow()
if (result.exitCode === 0) return result.stdout
const stderr = result.stderr.toString()
if (stderr.includes("exists on disk") || stderr.includes("does not exist") || stderr.includes("Path")) return null
throw new Error(`Failed to read ${file} from ${ref}: ${stderr}`)
}
/**
* Batch-look up upstream blob sizes for many files in one subprocess. Returns
* a map keyed by the input file path. Missing files map to `null`. Avoids
* per-file `git show` spawns and keeps memory bounded when most candidates are
* missing upstream or above a size threshold.
*/
export async function upstreamSizes(ref: string, files: string[]): Promise<Map<string, number | null>> {
const result = new Map<string, number | null>()
if (files.length === 0) return result
const proc = Bun.spawn(["git", "cat-file", "--batch-check"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
const input = files.map((f) => `${ref}:${f}\n`).join("")
proc.stdin.write(input)
await proc.stdin.end()
const stdout = await new Response(proc.stdout).text()
const lines = stdout.split("\n").filter((line) => line.length > 0)
for (let i = 0; i < files.length; i++) {
const line = lines[i] ?? ""
if (line.includes(" missing")) {
result.set(files[i], null)
continue
}
const parts = line.trim().split(/\s+/)
const size = Number(parts[2] ?? "")
result.set(files[i], Number.isFinite(size) ? size : null)
}
return result
}
export async function translate(file: string, text: string) {
const names = applyPackageNameTransforms(text).result
const script = applyScriptTransforms(names).result
const branded = applyBrandingTransforms(script).result
const i18n = transformI18nContent(branded).result
const ext = applyExtensionTransforms(i18n, file).result
const web = applyWebTransforms(ext).result
return workflow(file, web)
}
function workflow(file: string, text: string) {
if (!workflows.includes(file)) return text
return text
.replace(/github\.repository == 'anomalyco\/opencode'/g, "github.repository == 'Kilo-Org/kilocode'")
.replace(/github\.repository == "anomalyco\/opencode"/g, 'github.repository == "Kilo-Org/kilocode"')
.replace(/\bopencode-ai\b/g, "@kilocode/cli")
.replace(
/GH_REPO:\s*\$\{\{ \(github\.ref_name == 'beta' && 'anomalyco\/opencode-beta'\) \|\| github\.repository \}\}/g,
"GH_REPO: ${{ github.repository }}",
)
}