fix(script): add worktree mode to annotation check (#11842)

* fix(script): add worktree mode to annotation check

* fix(script): limit worktree annotation check to local changes
This commit is contained in:
Mohammad Javad Naderi
2026-07-21 16:31:35 +02:00
committed by GitHub
parent 8465c1d749
commit 232d7f2c61
4 changed files with 183 additions and 13 deletions
@@ -97,10 +97,10 @@ registerKiloFeature(app)
After editing shared files or marker comments, run:
```bash
bun run script/check-opencode-annotations.ts
bun run script/check-opencode-annotations.ts --worktree
```
If the PR uses a non-default comparison base, pass the correct base ref:
If checking committed PR changes against a non-default comparison base, pass the correct base ref without `--worktree`:
```bash
bun run script/check-opencode-annotations.ts --base <base-ref>
+2 -2
View File
@@ -20,7 +20,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
- **Knip** (unused exports): `bun run knip` from `packages/kilo-vscode/`. CI runs this — all exported types/functions must be imported somewhere. Remove or unexport unused exports before pushing.
- **Source links**: After adding or changing URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`, run `bun run script/extract-source-links.ts` from the repo root and commit the updated `packages/kilo-docs/source-links.md`. CI runs this check — the build fails if the file is stale.
- **kilocode_change check**: `bun run check-kilocode-change` from `packages/kilo-vscode/`. CI runs this — `kilocode_change` is a marker for upstream merge conflicts and must not appear in `packages/kilo-vscode/` or `packages/kilo-ui/` (these are entirely Kilo Code additions). Remove the markers before pushing.
- **opencode annotation check**: `bun run script/check-opencode-annotations.ts` from repo root. CI runs this on PRs touching `packages/opencode/` — every Kilo-specific change in shared opencode files must be annotated with `kilocode_change` markers. Exempt paths (no markers needed): `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, and any path containing `kilocode` in the name.
- **opencode annotation check**: `bun run script/check-opencode-annotations.ts --worktree` from repo root when verifying local agent changes. CI runs `bun run script/check-opencode-annotations.ts` on PRs touching `packages/opencode/` — every Kilo-specific change in shared opencode files must be annotated with `kilocode_change` markers. Exempt paths (no markers needed): `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, and any path containing `kilocode` in the name.
- **Effect facade ratchet**: Do not add runtime-backed Promise facades to shared `packages/opencode/src` Effect services; use service dependencies, `AppRuntime`, or Kilo-owned boundaries. Run `bun run script/check-opencode-promise-facades.ts` when touching service adapters.
- **workflow allowlist**: `bun run script/check-workflows.ts` from repo root. CI runs this as part of the annotations workflow — any `.yml` / `.yaml` file added to or removed from `.github/workflows/` must be reflected in the hardcoded list in `script/check-workflows.ts`. Prevents upstream-merged workflows from silently starting to run in our CI.
- **Backend/SDK programmatic testing**: see [TESTING.md](./TESTING.md) for spawning the local main-branch backend (`bun dev serve`) and driving it via `curl` — use this instead of `kilo serve` (prod binary) when testing backend fixes.
@@ -36,7 +36,7 @@ Before saying an implementation is ready, run the smallest relevant checks that
| VS Code extension | From `packages/kilo-vscode/`: `bun run typecheck`, `bun run lint`, `bun run test:unit` or `bun run test` |
| Extension build/package | From `packages/kilo-vscode/`: `bun run compile` or `bun run package` when touching build, packaging, SDK, or webview integration paths |
| JetBrains plugin | From `packages/kilo-jetbrains/`: `./gradlew typecheck`, `./gradlew test`. Requires Java 21; do not run `java -version` as a routine preflight. Check Java only after a Java-version or missing-Java failure. |
| CI-only guards | Run affected guards documented above, such as `bun run knip`, `bun run check-kilocode-change`, `bun run script/check-opencode-annotations.ts`, or source link extraction |
| CI/local guards | Run affected guards documented above, such as `bun run knip`, `bun run check-kilocode-change`, `bun run script/check-opencode-annotations.ts --worktree`, or source link extraction |
Never run root `bun test`; the root script prints `do not run tests from root` and exits with code 1. Use package-level tests instead.
@@ -1,4 +1,7 @@
import { describe, expect, test } from "bun:test"
import { spawnSync } from "node:child_process"
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import os from "node:os"
import path from "node:path"
const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".yml", ".yaml", ".toml", ".sh", ".bash", ".zsh"])
@@ -83,6 +86,137 @@ function coveredLines(text: string): Set<number> {
return covered
}
const SCRIPT = path.resolve(import.meta.dir, "../../../script/check-opencode-annotations.ts")
function exec(root: string, args: string[]) {
const out = spawnSync("git", args, { cwd: root, encoding: "utf8" })
if (out.status === 0) return
throw new Error(out.stderr || out.stdout || `git ${args.join(" ")} failed`)
}
function repo() {
const root = mkdtempSync(path.join(os.tmpdir(), "kilo-annotations-"))
mkdirSync(path.join(root, "script"), { recursive: true })
mkdirSync(path.join(root, "packages/opencode/src"), { recursive: true })
copyFileSync(SCRIPT, path.join(root, "script/check-opencode-annotations.ts"))
writeFileSync(path.join(root, "packages/opencode/src/shared.ts"), "export const value = 1\n")
exec(root, ["init"])
exec(root, ["checkout", "-B", "main"])
exec(root, ["add", "."])
exec(root, ["-c", "user.name=Kilo", "-c", "user.email=kilo@example.com", "commit", "-m", "init"])
exec(root, ["update-ref", "refs/remotes/origin/main", "HEAD"])
return root
}
function check(root: string, args: string[] = []) {
return spawnSync(process.execPath, ["run", "script/check-opencode-annotations.ts", ...args], {
cwd: root,
encoding: "utf8",
})
}
// ─── CLI worktree mode ───────────────────────────────────────────────────────
describe("CLI worktree mode", () => {
test("default mode ignores local edits, worktree mode reports them", () => {
const root = repo()
try {
writeFileSync(path.join(root, "packages/opencode/src/shared.ts"), "export const value = 2\n")
const head = check(root)
expect(head.status).toBe(0)
expect(head.stdout).toContain("No shared upstream source files changed")
const local = check(root, ["--worktree"])
expect(local.status).toBe(1)
expect(local.stderr).toContain("packages/opencode/src/shared.ts:1")
expect(local.stderr).toContain("export const value = 2")
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("worktree mode reports untracked shared source files", () => {
const root = repo()
try {
writeFileSync(path.join(root, "packages/opencode/src/new.ts"), "export const value = 1\n")
const local = check(root, ["--worktree"])
expect(local.status).toBe(1)
expect(local.stderr).toContain("packages/opencode/src/new.ts:1")
expect(local.stderr).toContain("export const value = 1")
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("worktree mode reports staged shared source edits", () => {
const root = repo()
try {
writeFileSync(path.join(root, "packages/opencode/src/shared.ts"), "export const value = 4\n")
exec(root, ["add", "packages/opencode/src/shared.ts"])
const local = check(root, ["--worktree"])
expect(local.status).toBe(1)
expect(local.stderr).toContain("packages/opencode/src/shared.ts:1")
expect(local.stderr).toContain("export const value = 4")
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("worktree mode checks local edits on upstream merge branches", () => {
const root = repo()
try {
exec(root, ["checkout", "-B", "upstream"])
writeFileSync(path.join(root, "packages/opencode/src/shared.ts"), "export const value = 2\n")
exec(root, ["add", "."])
exec(root, ["-c", "user.name=Kilo", "-c", "user.email=kilo@example.com", "commit", "-m", "upstream"])
exec(root, ["checkout", "main"])
exec(root, ["merge", "--no-ff", "-m", "Merge: upstream opencode", "upstream"])
const head = check(root)
expect(head.status).toBe(0)
expect(head.stdout).toContain("Skipping shared upstream annotation check")
const upstream = check(root, ["--worktree"])
expect(upstream.status).toBe(0)
expect(upstream.stdout).toContain("No shared upstream source files changed")
writeFileSync(path.join(root, "packages/opencode/src/shared.ts"), "export const value = 3\n")
const local = check(root, ["--worktree"])
expect(local.status).toBe(1)
expect(local.stderr).toContain("packages/opencode/src/shared.ts:1")
expect(local.stderr).toContain("export const value = 3")
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("worktree mode rejects base refs", () => {
const root = repo()
try {
const local = check(root, ["--worktree", "--base", "origin/main"])
expect(local.status).toBe(1)
expect(local.stderr).toContain("--base cannot be used with --worktree")
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test("unknown arguments fail instead of falling back to default mode", () => {
const root = repo()
try {
const local = check(root, ["--worktre"])
expect(local.status).toBe(1)
expect(local.stderr).toContain("Unknown argument: --worktre")
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
// ─── hasMarker tests ──────────────────────────────────────────────────────────
describe("hasMarker", () => {
+45 -9
View File
@@ -5,8 +5,9 @@
* is annotated with a kilocode_change marker.
*
* Usage:
* bun run script/check-opencode-annotations.ts # diff against origin/main
* bun run script/check-opencode-annotations.ts --base <ref> # diff against <ref>
* bun run script/check-opencode-annotations.ts # diff origin/main...HEAD
* bun run script/check-opencode-annotations.ts --base <ref> # diff <ref>...HEAD
* bun run script/check-opencode-annotations.ts --worktree # diff HEAD..worktree plus untracked files
*
* A line is "covered" if it:
* - contains a kilocode_change marker comment (inline annotation)
@@ -54,8 +55,24 @@ const EXEMPT_SCOPES = [
]
const args = process.argv.slice(2)
const unknown = args.find((arg, i) => arg !== "--base" && arg !== "--worktree" && args[i - 1] !== "--base")
if (unknown) {
console.error(`Unknown argument: ${unknown}`)
process.exit(1)
}
const baseIdx = args.indexOf("--base")
const base = baseIdx !== -1 ? args[baseIdx + 1] : "origin/main"
const worktree = args.includes("--worktree")
if (worktree && baseIdx !== -1) {
console.error("--base cannot be used with --worktree")
process.exit(1)
}
const base = (() => {
if (baseIdx === -1) return "origin/main"
const ref = args[baseIdx + 1]
if (ref && !ref.startsWith("--")) return ref
console.error("Missing value for --base")
process.exit(1)
})()
function run(cmd: string, args: string[]) {
const result = spawnSync(cmd, args, { cwd: ROOT, encoding: "utf8" })
@@ -67,11 +84,23 @@ function run(cmd: string, args: string[]) {
return result.stdout?.trim() ?? ""
}
function changedFiles() {
const out = run("git", ["diff", "--name-only", "--diff-filter=AMRT", `${base}...HEAD`, "--", ...SCOPES])
const ref = worktree ? "HEAD" : `${base}...HEAD`
function lines(out: string) {
return out ? out.split("\n").filter(Boolean) : []
}
function untracked(file?: string) {
if (!worktree) return []
const pathspec = file ? [file] : SCOPES
return lines(run("git", ["ls-files", "--others", "--exclude-standard", "--", ...pathspec]))
}
function changedFiles() {
const out = run("git", ["diff", "--name-only", "--diff-filter=AMRT", ref, "--", ...SCOPES])
return [...new Set([...lines(out), ...untracked()])]
}
function isUpstreamMerge() {
const out = run("git", ["log", "--format=%P%x09%s", `${base}..HEAD`])
return out.split("\n").some((line) => {
@@ -102,8 +131,8 @@ function isSource(file: string) {
return content(file).startsWith("#!") // kilocode_change
}
// Parses the unified=0 diff for `file` against `base` and returns:
// - added: every added line number on HEAD
// Parses the unified=0 diff for `file` against the selected target and returns:
// - added: every added line number on the checked version
// - revert: true when the file's diff removes any kilocode_change marker.
// In that case the changes are reverting Kilo modifications back to the
// upstream baseline, so newly added lines (which are restoring upstream
@@ -112,8 +141,15 @@ function isSource(file: string) {
// hunks than the marker itself, so we use file-level detection rather
// than hunk-level to avoid false positives on legitimate reverts.
function addedLines(file: string): { added: Set<number>; revert: boolean } {
const diff = run("git", ["diff", "--unified=0", "--diff-filter=AMRT", `${base}...HEAD`, "--", file])
const added = new Set<number>()
if (untracked(file).includes(file)) {
const text = content(file)
const count = text.split(/\r?\n/).length
for (const n of Array.from({ length: count }, (_, i) => i + 1)) added.add(n)
return { added, revert: false }
}
const diff = run("git", ["diff", "--unified=0", "--diff-filter=AMRT", ref, "--", file])
let revert = false
const all = diff.split("\n")
@@ -208,7 +244,7 @@ function coveredLines(text: string): { lines: string[]; covered: Set<number> } {
// --- main ---
if (isUpstreamMerge()) {
if (!worktree && isUpstreamMerge()) {
console.log("Skipping shared upstream annotation check — upstream merge detected.")
process.exit(0)
}