From 6e3ddaad607b755fd1769a4781bc2c2fa0cf0bb4 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 08:19:17 +0000 Subject: [PATCH] feat: add reset-to-upstream script --- script/upstream/README.md | 13 +++ script/upstream/fix-kilocode-markers.ts | 105 +--------------------- script/upstream/index.ts | 1 + script/upstream/package.json | 1 + script/upstream/reset-to-upstream.ts | 103 ++++++++++++++++++++++ script/upstream/utils/upstream.ts | 110 ++++++++++++++++++++++++ 6 files changed, 229 insertions(+), 104 deletions(-) create mode 100644 script/upstream/reset-to-upstream.ts create mode 100644 script/upstream/utils/upstream.ts diff --git a/script/upstream/README.md b/script/upstream/README.md index 075171cdb72..9d7ce2ac03a 100644 --- a/script/upstream/README.md +++ b/script/upstream/README.md @@ -35,6 +35,7 @@ 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 | ### Transform Scripts @@ -243,6 +244,18 @@ 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 [--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, and writes the result to the working tree. If the file does not exist upstream, the local file is deleted. + ## 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: diff --git a/script/upstream/fix-kilocode-markers.ts b/script/upstream/fix-kilocode-markers.ts index 7299bedd0c0..5545e1c9ac6 100644 --- a/script/upstream/fix-kilocode-markers.ts +++ b/script/upstream/fix-kilocode-markers.ts @@ -12,15 +12,8 @@ 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 { last, normalize, root, translate, upstream } from "./utils/upstream" interface Args { file?: string @@ -86,8 +79,6 @@ const styles = new Map([ [".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() { @@ -113,21 +104,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() } @@ -143,29 +119,6 @@ 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") @@ -263,62 +216,6 @@ function clean(file: string, text: string): Clean { return { text: { ...parsed, lines }, marks } } -async function last(): Promise { - 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 { - 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() - 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" diff --git a/script/upstream/index.ts b/script/upstream/index.ts index e823b69c7ea..639f8cc7af8 100644 --- a/script/upstream/index.ts +++ b/script/upstream/index.ts @@ -11,6 +11,7 @@ export * from "./utils/logger" export * from "./utils/config" export * from "./utils/version" export * from "./utils/report" +export * from "./utils/upstream" // Transforms export { transformAll as transformPackageNames, transformFile } from "./transforms/package-names" diff --git a/script/upstream/package.json b/script/upstream/package.json index 0722d2f2e67..150311f7f8b 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -14,6 +14,7 @@ "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", "keep-ours": "bun run transforms/keep-ours.ts" }, "dependencies": { diff --git a/script/upstream/reset-to-upstream.ts b/script/upstream/reset-to-upstream.ts new file mode 100644 index 00000000000..0e93d6387b4 --- /dev/null +++ b/script/upstream/reset-to-upstream.ts @@ -0,0 +1,103 @@ +#!/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 { rm } from "node:fs/promises" +import path from "node:path" +import { error, header, info, success, warn } from "./utils/logger" +import { last, normalize, root, translate, upstream } from "./utils/upstream" + +interface Args { + file?: string + dryRun: boolean + help: boolean +} + +function usage() { + console.log(`Usage: bun run script/upstream/reset-to-upstream.ts [--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. + +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) + const abs = path.join(top, file) + + header("Reset file to upstream") + + const version = await last() + success(`Last merged upstream: ${version.tag} (${version.commit.slice(0, 8)})`) + + const base = await upstream(version.commit, file) + if (base === null) { + warn(`${file} does not exist upstream`) + if (opts.dryRun) { + info(`[DRY-RUN] Would delete ${file}`) + return + } + + await rm(abs, { force: true }) + success(`Deleted ${file}`) + return + } + + const next = await translate(file, base) + const current = await Bun.file(abs) + .text() + .catch(() => null) + if (current === next) { + success(`${file} already matches transformed upstream ${version.tag}`) + return + } + + if (opts.dryRun) { + info(`[DRY-RUN] Would reset ${file} to transformed upstream ${version.tag}`) + return + } + + await Bun.write(abs, next) + success(`Reset ${file} to transformed upstream ${version.tag}`) +} + +main().catch((err) => { + error(err instanceof Error ? err.message : String(err)) + process.exit(1) +}) diff --git a/script/upstream/utils/upstream.ts b/script/upstream/utils/upstream.ts new file mode 100644 index 00000000000..d80c7e84a3a --- /dev/null +++ b/script/upstream/utils/upstream.ts @@ -0,0 +1,110 @@ +#!/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 { + 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 { + 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() + 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 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}`) +} + +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 }}", + ) +}