feat: add reset-to-upstream script

This commit is contained in:
kiloconnect[bot]
2026-05-05 08:19:17 +00:00
parent 1271422bd4
commit 6e3ddaad60
6 changed files with 229 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, 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:
+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": {
+103
View File
@@ -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 <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.
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)
})
+110
View File
@@ -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<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 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 }}",
)
}