mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
Merge pull request #9852 from Kilo-Org/mark/upstream-compat-base
fix: preserve upstream compat merge bases
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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<string> {
|
||||
const result = await $`git rev-parse --abbrev-ref HEAD`.text()
|
||||
return result.trim()
|
||||
@@ -181,6 +193,136 @@ export async function getCommitHash(ref: string): Promise<string> {
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
export async function getCommitParents(ref: string): Promise<string[]> {
|
||||
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<string> {
|
||||
const result = await $`git write-tree`.text()
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
export async function createCommit(tree: string, message: string, parent: string): Promise<string> {
|
||||
const result = await $`git commit-tree ${tree} -p ${parent} -m ${message}`.text()
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
export async function updateBranch(name: string, commit: string): Promise<void> {
|
||||
await $`git update-ref refs/heads/${name} ${commit}`
|
||||
}
|
||||
|
||||
export async function recordAncestor(ref: string, message: string): Promise<boolean> {
|
||||
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<string | null> {
|
||||
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<T>(value: T | null): value is T {
|
||||
return value !== null
|
||||
}
|
||||
|
||||
async function targetSemver(ref: string): Promise<Semver | null> {
|
||||
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<Candidate | null> {
|
||||
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<CompatBase | null> {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user