Merge remote-tracking branch 'origin/main' into mark/upstream-compat-base

This commit is contained in:
Mark IJbema
2026-05-07 16:12:34 +02:00
689 changed files with 30913 additions and 11434 deletions
+1
View File
@@ -48,6 +48,7 @@ const EXCLUDE_PATTERNS = [
/^https?:\/\/api\.openai\.com/,
/^https?:\/\/api\.github\.com/,
/^https?:\/\/api\.githubcopilot\.com/,
/^https?:\/\/[^/]+\.openai\.azure\.com\/openai/, // kilocode_change
/^https?:\/\/api\.cloudflare\.com/,
/^https?:\/\/api\.releases\.hashicorp\.com/,
/^https?:\/\/auth\.openai\.com/,
+3 -1
View File
@@ -204,7 +204,7 @@ The only remaining conflicts are files with **actual code differences** - files
Options:
--version <version> Target upstream version (e.g., v1.1.49)
--commit <hash> Target upstream commit hash
--base-branch <name> Base branch to merge into (default: main)
--base-branch <name> Base branch to merge into; use HEAD for current branch (default: main)
--dry-run Preview changes without applying them
--no-push Don't push branches to remote
--no-worktrees Don't create reference worktrees
@@ -305,6 +305,8 @@ Tighten the blast radius with `--review-limit 0` (only `markers-only` and `cosme
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:
Passing `--base-branch HEAD` targets the currently checked-out branch without typing its full name.
### Incremental Merges
When working on multiple upstream versions, you can create a chain of merge PRs:
+1 -2
View File
@@ -251,8 +251,7 @@ function detail(entry: Entry): string {
function describe(bucket: Bucket, count: number, dryRun: boolean): { label: string; action: string } {
if (bucket === "markers-only") return { label: `markers-only (${count})`, action: dryRun ? "would reset" : "reset" }
if (bucket === "cosmetic-only")
return { label: `cosmetic-only (${count})`, action: dryRun ? "would reset" : "reset" }
if (bucket === "cosmetic-only") return { label: `cosmetic-only (${count})`, action: dryRun ? "would reset" : "reset" }
if (bucket === "small-diff") return { label: `small-diff (${count})`, action: dryRun ? "would reset" : "reset" }
if (bucket === "large-diff") return { label: `large-diff (${count})`, action: "skipped" }
if (bucket === "identical") return { label: `identical (${count})`, action: "nothing to do" }
+1 -9
View File
@@ -10,15 +10,7 @@
import path from "node:path"
import { error, header, info, success, warn } from "./utils/logger"
import {
annotate,
annotates,
changed,
clean,
fresh,
ranges,
supported,
} from "./utils/markers"
import { annotate, annotates, changed, clean, fresh, ranges, supported } from "./utils/markers"
import { last, normalize, root, translate, upstream } from "./utils/upstream"
interface Args {
+8 -3
View File
@@ -10,7 +10,7 @@
* Options:
* --version <version> Target upstream version (e.g., v1.1.49)
* --commit <hash> Target upstream commit hash
* --base-branch <name> Base branch to merge into (default: main)
* --base-branch <name> Base branch to merge into, or HEAD for current branch (default: main)
* --dry-run Preview changes without applying them
* --no-push Don't push branches to remote
* --no-worktrees Don't create reference worktrees for manual resolution
@@ -25,7 +25,7 @@ import * as logger from "./utils/logger"
import * as version from "./utils/version"
import * as report from "./utils/report"
import * as worktree from "./utils/worktree"
import { loadConfig } from "./utils/config"
import { loadConfig, resolveBaseBranch } from "./utils/config"
import { transformAll as transformPackageNames } from "./transforms/package-names"
import { preserveAllVersions } from "./transforms/preserve-versions"
import { keepOursFiles, resetToOurs } from "./transforms/keep-ours"
@@ -219,7 +219,6 @@ async function main() {
process.chdir((await $`git rev-parse --show-toplevel`.text()).trim())
const options = parseArgs()
const config = loadConfig(options.baseBranch ? { baseBranch: options.baseBranch } : undefined)
if (options.verbose) {
logger.setVerbose(true)
@@ -248,6 +247,12 @@ async function main() {
const currentBranch = await git.getCurrentBranch()
logger.info(`Current branch: ${currentBranch}`)
const base = resolveBaseBranch(options.baseBranch, currentBranch)
const config = loadConfig(base ? { baseBranch: base } : undefined)
if (options.baseBranch === "HEAD") {
logger.info(`Resolved --base-branch HEAD to current branch: ${config.baseBranch}`)
}
// Enable git rerere so conflict resolutions are recorded and reused across merges
if (!options.dryRun) {
await git.ensureRerere()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/upstream-merge",
"version": "7.2.42",
"version": "7.2.44",
"private": true,
"type": "module",
"description": "Scripts for automating upstream opencode merges into Kilo",
+15
View File
@@ -0,0 +1,15 @@
import { expect, test } from "bun:test"
import { resolveBaseBranch } from "./config"
test("resolves HEAD to the current branch", () => {
expect(resolveBaseBranch("HEAD", "session/agent-123")).toBe("session/agent-123")
})
test("keeps explicit base branch names", () => {
expect(resolveBaseBranch("main", "session/agent-123")).toBe("main")
expect(resolveBaseBranch(undefined, "session/agent-123")).toBeUndefined()
})
test("rejects HEAD when detached", () => {
expect(() => resolveBaseBranch("HEAD", "HEAD")).toThrow("--base-branch HEAD requires a named branch")
})
+6
View File
@@ -231,3 +231,9 @@ export const defaultConfig: MergeConfig = {
export function loadConfig(overrides?: Partial<MergeConfig>): MergeConfig {
return { ...defaultConfig, ...overrides }
}
export function resolveBaseBranch(base: string | undefined, current: string): string | undefined {
if (base !== "HEAD") return base
if (current === "HEAD") throw new Error("--base-branch HEAD requires a named branch, but git is in detached HEAD")
return current
}
+29 -20
View File
@@ -4,6 +4,10 @@
*/
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { randomUUID } from "node:crypto"
import { tmpdir } from "node:os"
import { join } from "node:path"
export interface BranchInfo {
current: string
@@ -409,6 +413,11 @@ export async function ensureRerere(): Promise<void> {
await $`git config rerere.autoupdate true`.quiet()
}
async function reset(dir: string): Promise<void> {
await $`git -C ${dir} reset -q --hard`.quiet().nothrow()
await $`git -C ${dir} clean -fdx`.quiet().nothrow()
}
/**
* Train the rerere cache from past merge commits in the repo history.
* Implements the same logic as git's contrib/rerere-train.sh:
@@ -419,14 +428,14 @@ export async function ensureRerere(): Promise<void> {
* Returns the number of resolutions learned.
*/
export async function trainRerere(grep: string): Promise<number> {
// Save the current HEAD so we can restore it afterwards
const headResult = await $`git symbolic-ref -q HEAD`.quiet().nothrow()
const branch = headResult.exitCode === 0 ? headResult.stdout.toString().trim() : null
const originalHead = branch ?? (await $`git rev-parse --verify HEAD`.text()).trim()
const head = (await $`git rev-parse --verify HEAD`.text()).trim()
const dir = join(tmpdir(), `kilo-rerere-train-${randomUUID()}`)
let learned = 0
try {
await $`git worktree add --detach ${dir} ${head}`.quiet()
// Find all merge commits matching the grep pattern (merges have multiple parents)
const revList = await $`git rev-list --parents --all --grep=${grep}`.quiet().nothrow()
if (revList.exitCode !== 0 || !revList.stdout.toString().trim()) return 0
@@ -443,45 +452,45 @@ export async function trainRerere(grep: string): Promise<number> {
const [commit, parent1, ...otherParents] = parts
await reset(dir)
// Checkout the first parent
const coResult = await $`git checkout -q ${parent1}`.quiet().nothrow()
const coResult = await $`git -C ${dir} checkout -q ${parent1}`.quiet().nothrow()
if (coResult.exitCode !== 0) continue
// Attempt the merge - we expect it to fail with conflicts
const mergeResult = await $`git merge --no-gpg-sign ${otherParents}`.quiet().nothrow()
const mergeResult = await $`git -C ${dir} merge --no-gpg-sign ${otherParents}`.quiet().nothrow()
if (mergeResult.exitCode === 0) {
// Cleanly merged — no conflicts to learn from, reset and skip
await $`git reset -q --hard`.quiet().nothrow()
await reset(dir)
continue
}
// Check if rerere recorded a pre-image (MERGE_RR exists and is non-empty)
const mergeRR = Bun.file(`${process.env.GIT_DIR || ".git"}/MERGE_RR`)
const hasMergeRR = await mergeRR.exists().catch(() => false)
const rr = await $`git -C ${dir} rev-parse --git-path MERGE_RR`.text()
const hasMergeRR = await Bun.file(rr.trim())
.exists()
.catch(() => false)
if (!hasMergeRR) {
await $`git reset -q --hard`.quiet().nothrow()
await reset(dir)
continue
}
// Record the conflict pre-image
await $`git rerere`.quiet().nothrow()
await $`git -C ${dir} rerere`.quiet().nothrow()
// Apply the actual resolution by checking out the merge commit's tree
await $`git checkout -q ${commit} -- .`.quiet().nothrow()
await $`git -C ${dir} checkout -q ${commit} -- .`.quiet().nothrow()
// Record the resolution post-image
await $`git rerere`.quiet().nothrow()
await $`git -C ${dir} rerere`.quiet().nothrow()
learned++
await $`git reset -q --hard`.quiet().nothrow()
await reset(dir)
}
} finally {
// Always restore original branch
if (branch) {
await $`git checkout ${branch.replace("refs/heads/", "")}`.quiet().nothrow()
} else {
await $`git checkout ${originalHead}`.quiet().nothrow()
}
await $`git worktree remove --force ${dir}`.quiet().nothrow()
await rm(dir, { recursive: true, force: true })
}
return learned