Merge pull request #9889 from Kilo-Org/mark/reset-file-to-upstream

Add reset-to-upstream helper
This commit is contained in:
Mark IJbema
2026-05-05 11:29:48 +02:00
committed by GitHub
6 changed files with 264 additions and 104 deletions
+13
View File
@@ -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 <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.
## 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:
+1 -104
View File
@@ -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<string, Style>([
[".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<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"
+1
View File
@@ -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"
+1
View File
@@ -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": {
+133
View File
@@ -0,0 +1,133 @@
#!/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, upstreamData } 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 binary(data: Uint8Array) {
return data.includes(0)
}
function same(left: Uint8Array, right: Uint8Array) {
return left.length === right.length && left.every((byte, index) => byte === right[index])
}
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 data = await upstreamData(version.commit, file)
if (data === 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
}
if (binary(data)) {
const current = await Bun.file(abs)
.arrayBuffer()
.then((buffer) => new Uint8Array(buffer))
.catch(() => null)
if (current && same(current, data)) {
success(`${file} already matches upstream ${version.tag}`)
return
}
if (opts.dryRun) {
info(`[DRY-RUN] Would reset binary ${file} to upstream ${version.tag}`)
return
}
await Bun.write(abs, data)
success(`Reset binary ${file} to upstream ${version.tag}`)
return
}
const base = new TextDecoder().decode(data)
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)
})
+115
View File
@@ -0,0 +1,115 @@
#!/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}`)
}
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 }}",
)
}