diff --git a/script/upstream/merge.ts b/script/upstream/merge.ts index 02933816c1f..24587c9ecec 100644 --- a/script/upstream/merge.ts +++ b/script/upstream/merge.ts @@ -421,6 +421,15 @@ async function main() { await git.createBranch(opencodeBranch) logger.info(`Created opencode branch: ${opencodeBranch}`) + const prior = await git.findLatestCompatCommit(config.baseBranch, targetVersion.commit) + if (prior) { + logger.info( + `Found previous compatibility base: ${prior.message} (${prior.commit.slice(0, 8)}) from upstream ${prior.upstream.slice(0, 8)}`, + ) + } else { + logger.warn("No previous compatibility base found; merge base will remain pristine upstream") + } + // Step 6: Apply ALL transformations to opencode branch (pre-merge) // This reduces conflicts by transforming upstream code to Kilo conventions BEFORE merging logger.step(6, 8, "Applying transformations to opencode branch (pre-merge)...") @@ -508,13 +517,25 @@ async function main() { // Commit all transformations await git.stageAll() - await git.commit(`refactor: kilo compat for ${targetVersion.tag}`) + const compatMessage = `refactor: kilo compat for ${targetVersion.tag}` + if (prior) { + const tree = await git.writeTree() + const commit = await git.createCommit(tree, compatMessage, prior.commit) + await git.updateBranch(opencodeBranch, commit) + await git.checkout(opencodeBranch) + } else { + await git.commit(compatMessage) + } logger.success("Committed pre-merge transformations") // Step 7: Merge into Kilo branch logger.step(7, 8, "Merging into Kilo branch...") await git.checkout(kiloBranch) + if (prior) { + const linked = await git.recordAncestor(targetVersion.commit, `merge: record upstream ${targetVersion.tag}`) + if (linked) logger.info(`Recorded upstream ${targetVersion.tag} as Kilo branch ancestry`) + } const mergeResult = await git.merge(opencodeBranch) if (!mergeResult.success) { diff --git a/script/upstream/utils/git.test.ts b/script/upstream/utils/git.test.ts new file mode 100644 index 00000000000..b51dd35ea4a --- /dev/null +++ b/script/upstream/utils/git.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import { join } from "node:path" +import { tmpdir } from "node:os" +import { $ } from "bun" +import { + createCommit, + findLatestCompatCommit, + getCommitHash, + getCommitParents, + isAncestor, + recordAncestor, + updateBranch, + writeTree, +} from "./git" + +const cwd = process.cwd() +let dir = "" + +async function commit(message: string) { + await $`git add -A`.quiet() + await $`git -c user.name=Test -c user.email=test@example.com commit -m ${message}`.quiet() + return getCommitHash("HEAD") +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "kilo-upstream-git-")) + process.chdir(dir) + await $`git init -b upstream`.quiet() + await $`git config user.name Test`.quiet() + await $`git config user.email test@example.com`.quiet() +}) + +afterEach(async () => { + process.chdir(cwd) + await rm(dir, { recursive: true, force: true }) +}) + +test("finds previous compatibility commit for transformed base", async () => { + await Bun.write("brand.txt", "opencode A\n") + const old = await commit("release: v1.0.0") + + await Bun.write("brand.txt", "opencode B\n") + const target = await commit("release: v1.0.1") + await $`git tag v1.0.1 ${target}`.quiet() + + await $`git checkout -b main ${old}`.quiet() + await $`git tag v1.0.0 ${old}`.quiet() + await Bun.write("brand.txt", "kilo A\n") + const prior = await commit("refactor: kilo compat for v1.0.0") + + const found = await findLatestCompatCommit("main", target) + expect(found?.commit).toBe(prior) + expect(found?.upstream).toBe(old) + + await $`git checkout ${target}`.quiet() + await $`git checkout -b opencode-v1.0.1`.quiet() + await Bun.write("brand.txt", "kilo B\n") + await $`git add -A`.quiet() + const tree = await writeTree() + const next = await createCommit(tree, "refactor: kilo compat for v1.0.1", prior) + await updateBranch("opencode-v1.0.1", next) + const base = (await $`git merge-base main opencode-v1.0.1`.text()).trim() + expect(base).toBe(prior) + expect(await getCommitParents(next)).toEqual([prior]) + + await $`git checkout main`.quiet() + expect(await recordAncestor(target, "merge: record upstream v1.0.1")).toBe(true) + const link = await getCommitHash("HEAD") + expect(await getCommitParents(link)).toEqual([prior, target]) + expect(await isAncestor(target, link)).toBe(true) + + const linked = (await $`git merge-base main opencode-v1.0.1`.text()).trim() + expect(linked).toBe(prior) + + await $`git merge opencode-v1.0.1`.quiet() + const head = await getCommitHash("HEAD") + expect(await getCommitParents(head)).toEqual([link, next]) + expect(await isAncestor(target, head)).toBe(true) +}) + +test("finds previous compatibility commit when upstream tags diverge", async () => { + await Bun.write("brand.txt", "opencode 1.4.9\n") + const old = await commit("release: v1.4.9") + await $`git tag v1.4.9 ${old}`.quiet() + + await $`git checkout -b release-30 ${old}`.quiet() + await Bun.write("brand.txt", "opencode 1.14.30\n") + const side = await commit("release: v1.14.30") + await $`git tag v1.14.30 ${side}`.quiet() + + await $`git checkout -b release-31 ${old}`.quiet() + await Bun.write("brand.txt", "opencode 1.14.31\n") + const target = await commit("release: v1.14.31") + await $`git tag v1.14.31 ${target}`.quiet() + + await $`git checkout -b main ${old}`.quiet() + await Bun.write("brand.txt", "kilo 1.4.9\n") + const ancient = await commit("refactor: kilo compat for v1.4.9") + await Bun.write("brand.txt", "kilo 1.14.30\n") + const prior = await commit("refactor: kilo compat for v1.14.30") + + expect(await isAncestor(side, target)).toBe(false) + expect(await isAncestor(old, target)).toBe(true) + + const found = await findLatestCompatCommit("main", target) + expect(found?.commit).toBe(prior) + expect(found?.upstream).toBe(side) + expect(found?.commit).not.toBe(ancient) +}) diff --git a/script/upstream/utils/git.ts b/script/upstream/utils/git.ts index 461cd712e4f..ab2c5693413 100644 --- a/script/upstream/utils/git.ts +++ b/script/upstream/utils/git.ts @@ -19,6 +19,18 @@ export interface RemoteInfo { url: string } +export interface CompatBase { + commit: string + upstream: string + message: string +} + +type Semver = readonly [number, number, number] + +interface Candidate extends CompatBase { + version: Semver +} + export async function getCurrentBranch(): Promise { const result = await $`git rev-parse --abbrev-ref HEAD`.text() return result.trim() @@ -181,6 +193,136 @@ export async function getCommitHash(ref: string): Promise { return result.trim() } +export async function getCommitParents(ref: string): Promise { + const result = await $`git show --no-patch --format=%P ${ref}`.text() + return result + .trim() + .split(/\s+/) + .filter((parent) => parent.length > 0) +} + +export async function writeTree(): Promise { + const result = await $`git write-tree`.text() + return result.trim() +} + +export async function createCommit(tree: string, message: string, parent: string): Promise { + const result = await $`git commit-tree ${tree} -p ${parent} -m ${message}`.text() + return result.trim() +} + +export async function updateBranch(name: string, commit: string): Promise { + await $`git update-ref refs/heads/${name} ${commit}` +} + +export async function recordAncestor(ref: string, message: string): Promise { + if (await isAncestor(ref, "HEAD")) return false + + const result = await $`git merge -s ours --no-ff ${ref} -m ${message}`.nothrow() + if (result.exitCode !== 0) { + throw new Error(`Failed to record ancestor ${ref}: ${result.stderr.toString()}`) + } + return true +} + +async function compatUpstream(message: string): Promise { + const tag = compatTag(message) + if (!tag) return null + + const ref = `${tag}^{commit}` + const result = await $`git rev-parse ${ref}`.quiet().nothrow() + if (result.exitCode !== 0) return null + return result.stdout.toString().trim() +} + +function compatTag(message: string): string | null { + const prefix = "refactor: kilo compat for " + if (!message.startsWith(prefix)) return null + return message.slice(prefix.length).trim().split(/\s+/)[0] ?? null +} + +function parseSemver(value: string | undefined): Semver | null { + const match = value?.match(/^v?(\d+)\.(\d+)\.(\d+)$/) + if (!match) return null + const major = Number.parseInt(match[1] ?? "0", 10) + const minor = Number.parseInt(match[2] ?? "0", 10) + const patch = Number.parseInt(match[3] ?? "0", 10) + return [major, minor, patch] +} + +function compareSemver(a: Semver, b: Semver): number { + for (const idx of [0, 1, 2] as const) { + if (a[idx] < b[idx]) return -1 + if (a[idx] > b[idx]) return 1 + } + return 0 +} + +function exists(value: T | null): value is T { + return value !== null +} + +async function targetSemver(ref: string): Promise { + const tags = await getTagsForCommit(ref) + const tag = tags.find((item) => parseSemver(item) !== null) + const parsed = parseSemver(tag) + if (parsed) return parsed + + const message = await getCommitMessage(ref) + const match = message.match(/\bv?\d+\.\d+\.\d+\b/) + return parseSemver(match?.[0]) +} + +async function candidate(line: string): Promise { + const [commit, message = ""] = line.split("\0") + if (!commit) return null + + const version = parseSemver(compatTag(message) ?? undefined) + if (!version) return null + + const upstream = (await compatUpstream(message)) ?? (await getCommitParents(commit))[0] ?? commit + return { commit, upstream, message, version } +} + +export async function findLatestCompatCommit(base: string, target: string): Promise { + const grep = "^refactor: kilo compat for " + const result = await $`git log --format=%H%x00%s --grep=${grep} ${base}`.quiet().nothrow() + if (result.exitCode !== 0) { + throw new Error(`Failed to search compatibility commits: ${result.stderr.toString()}`) + } + + const lines = result.stdout + .toString() + .trim() + .split("\n") + .filter((line) => line.length > 0) + + const targetVersion = await targetSemver(target) + if (targetVersion) { + const candidates = (await Promise.all(lines.map(candidate))) + .filter(exists) + .filter((item) => compareSemver(item.version, targetVersion) < 0) + .sort((a, b) => compareSemver(b.version, a.version)) + const latest = candidates[0] + if (latest) return { commit: latest.commit, upstream: latest.upstream, message: latest.message } + } + + for (const line of lines) { + const [commit, message = ""] = line.split("\0") + if (!commit) continue + + const upstream = await compatUpstream(message) + if (upstream && (await isAncestor(upstream, target))) return { commit, upstream, message } + + const parents = await getCommitParents(commit) + for (const parent of parents) { + if (await isAncestor(parent, target)) return { commit, upstream: parent, message } + } + } + + return null +} + /** * Check if `commit` is an ancestor of `ref` (i.e. reachable from `ref`). * Uses `git merge-base --is-ancestor`, which exits 0 for yes, 1 for no.