Merge pull request #12314 from Kilo-Org/johnnyeric/fix-issue-12225

fix: keep CLI sidebar branch label in sync with git
This commit is contained in:
Johnny Eric Amancio
2026-07-20 18:36:26 +02:00
committed by GitHub
9 changed files with 162 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep the CLI sidebar branch label in sync when Git branches change outside Kilo.
+1 -1
View File
@@ -160,7 +160,7 @@ jobs:
if: matrix.settings.run
run: bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='@kilocode/cli'
env:
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true" # kilocode_change - was Windows-only; the CLI now starts a watcher per instance, too heavy/racy for unit tests. Watcher tests opt back in.
KILO_TEST_PROFILE: ${{ matrix.settings.os == 'macos' && 'darwin' || '' }}
KILO_TEST_SHARD: ${{ format('{0}/{1}', matrix.settings.index, matrix.settings.total) }}
# kilocode_change end
@@ -19,6 +19,7 @@ import { MemoryEvents } from "@/kilocode/memory/events"
import { installMemoryRuntime } from "@/kilocode/memory/runtime"
import { KiloToolRegistry } from "@/kilocode/tool/registry"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { KilocodeWatcher } from "@/kilocode/watcher"
const log = Log.create({ service: "kilocode-bootstrap" })
@@ -40,8 +41,10 @@ export namespace KilocodeBootstrap {
const summary = yield* SessionSummary.Service
const provider = yield* Provider.Service
const memory = yield* MemoryService.Service
const watcher = yield* KilocodeWatcher.Service
const init = Effect.fn("KilocodeBootstrap.init")(function* () {
yield* watcher.init()
yield* kilo.init()
yield* MemoryLifecycle.subscribe({ bus, sessions, summary, provider, memory })
// Invalidate enabled cache on every memory state mutation (properties.directory holds the memory root).
@@ -99,10 +102,12 @@ export namespace KilocodeBootstrap {
Provider.defaultLayer,
MemoryService.layer,
Bus.defaultLayer,
KilocodeWatcher.defaultLayer,
]),
)
const memory = LayerNode.make(MemoryService.layer, [])
const watcher = LayerNode.make(KilocodeWatcher.defaultLayer, [])
export const node = LayerNode.make(layer, [
KiloSessions.node,
Session.node,
@@ -110,5 +115,6 @@ export namespace KilocodeBootstrap {
Provider.node,
memory,
Bus.node,
watcher,
])
}
+62
View File
@@ -0,0 +1,62 @@
import { InstanceState } from "@/effect/instance-state"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Cause, Context, Effect, Layer, Scope } from "effect"
const log = Log.create({ service: "kilocode-watcher" })
export namespace KilocodeWatcher {
export interface Interface {
readonly init: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@kilocode/Watcher") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const locations = yield* LocationServiceMap
const scope = yield* Scope.Scope
const state = yield* InstanceState.make(
Effect.fn("KilocodeWatcher.state")(function* (ctx) {
if (ctx.project.vcs !== "git") return
// Warm the v2 location stack for this instance and hold it for the
// instance lifetime. Its Watcher subscribes to .git so Vcs sees HEAD
// changes and publishes vcs.branch.updated in the CLI, where no v2
// route would otherwise build the stack. The ref must be built the
// same way the file/pty handlers build theirs (Location.Ref.make) so
// the LayerMap shares a single build per directory.
const ref = Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })
yield* locations.contextEffect(ref)
// Tear the stack down with the instance instead of letting it idle
// in the LayerMap; same pattern as the pty handlers' disposer.
yield* Effect.addFinalizer(() => locations.invalidate(ref).pipe(Effect.ignore))
}),
)
return Service.of({
init: Effect.fn("KilocodeWatcher.init")(function* () {
yield* InstanceState.get(state).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => log.warn("instance watcher init failed", { err: Cause.squash(cause) })),
),
Effect.forkIn(scope),
)
}),
})
}),
)
// Gate the whole layer so LocationServiceMap's dependency graph is never built when the watcher is disabled.
export const defaultLayer = Layer.unwrap(
Effect.gen(function* () {
if (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER.pipe(Effect.orElseSucceed(() => false)))
return Layer.succeed(Service, Service.of({ init: () => Effect.void }))
return layer.pipe(Layer.provide(LocationServiceMap.layer))
}),
)
}
@@ -10,6 +10,10 @@ export const load = (input: LoadInput) => AppRuntime.runPromise(InstanceStore.Se
export const disposeInstance = (ctx: InstanceContext) =>
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx)))
export const disposeAllInstances = () => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll()))
// kilocode_change start - test fixtures dispose a directory's instance before deleting the directory
export const disposeDirectory = (directory: string) =>
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeDirectory(directory)))
// kilocode_change end
export const reloadInstance = (input: LoadInput) =>
AppRuntime.runPromise(InstanceStore.Service.use((store) => store.reload(input)))
+11
View File
@@ -68,6 +68,15 @@ export async function disposeAllInstances() {
await Promise.all([InstanceRuntime.disposeAllInstances(), runTestInstanceStore((store) => store.disposeAll())])
}
// kilocode_change start - dispose a directory's instance (and its watchers) before the directory is deleted
async function disposeInstancesFor(directory: string) {
await Promise.allSettled([
InstanceRuntime.disposeDirectory(directory),
runTestInstanceStore((store) => store.disposeDirectory(directory)),
])
}
// kilocode_change end
// Strip null bytes from paths (defensive fix for CI environment issues)
function sanitizePath(p: string): string {
return p.replace(/\0/g, "")
@@ -122,6 +131,7 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
try {
await options?.dispose?.(realpath)
} finally {
await disposeInstancesFor(realpath) // kilocode_change - see disposeInstancesFor
if (options?.git) await stop(realpath).catch(() => undefined)
await clean(realpath).catch(() => undefined)
}
@@ -146,6 +156,7 @@ export function tmpdirScoped<E = never, R = never>(options?: {
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
await disposeInstancesFor(dir) // kilocode_change - see disposeInstancesFor
if (options?.git) await stop(dir).catch(() => undefined)
await clean(dir).catch(() => undefined)
}),
@@ -0,0 +1,69 @@
import { afterAll, beforeAll, expect } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { Git } from "../../src/git"
import { InstanceLayer } from "../../src/project/instance-layer"
import { InstanceStore } from "../../src/project/instance-store"
import { tmpdirScoped } from "../fixture/fixture"
import { awaitWithTimeout, testEffect } from "../lib/effect"
const layer = Layer.mergeAll(InstanceLayer.layer, Git.defaultLayer, CrossSpawnSpawner.defaultLayer)
const it = testEffect(layer)
// The suite disables the file watcher (see test/preload.ts); this file tests it, so opt back in.
const disableFilewatcher = process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER
beforeAll(() => {
delete process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER
})
afterAll(() => {
if (disableFilewatcher !== undefined) process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER = disableFilewatcher
})
// The watcher is unreliable on Windows CI, so this test only runs on unix.
const live = process.platform === "win32" ? it.live.skip : it.live
live("instances publish branch updates after git switch", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const git = yield* Git.Service
const store = yield* InstanceStore.Service
const current = yield* git.branch(dir)
if (!current) return yield* Effect.die("missing initial branch")
const branch = `watch-${Math.random().toString(36).slice(2)}`
const created = yield* git.run(["branch", branch], { cwd: dir })
expect(created.exitCode).toBe(0)
yield* store.load({ directory: dir })
const pending = yield* Deferred.make<string | undefined>()
const handler = (event: GlobalEvent) => {
if (event.directory !== dir || event.payload.type !== "vcs.branch.updated") return
if (event.payload.properties.branch !== branch) return
Deferred.doneUnsafe(pending, Effect.succeed(event.payload.properties.branch))
}
GlobalBus.on("event", handler)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", handler)))
// The watcher exposes no readiness signal (its .git subscription is forked
// during instance warm-up), so keep generating HEAD churn in the background
// and synchronize on the event itself with the full test budget.
const churn = yield* Effect.gen(function* () {
while (true) {
yield* git.run(["switch", current], { cwd: dir })
yield* Effect.sleep("50 millis")
yield* git.run(["switch", branch], { cwd: dir })
yield* Effect.sleep("100 millis")
}
}).pipe(Effect.forkScoped)
const updated = yield* awaitWithTimeout(
Deferred.await(pending),
"timed out waiting for vcs.branch.updated",
"15 seconds",
)
yield* Fiber.interrupt(churn)
expect(updated).toBe(branch)
}),
20_000,
)
@@ -5,6 +5,7 @@ import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { KiloIndexing } from "../../src/kilocode/indexing"
import { KilocodeBootstrap } from "../../src/kilocode/bootstrap"
import { KilocodeWatcher } from "../../src/kilocode/watcher"
import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions"
import { KiloMemory } from "@kilocode/kilo-memory/effect"
import { MemoryService } from "@kilocode/kilo-memory/effect/service"
@@ -471,6 +472,7 @@ describe("kilocode tool registry indexing", () => {
const session = Layer.succeed(Session.Service, {} as Session.Interface)
const summary = Layer.succeed(SessionSummary.Service, {} as SessionSummary.Interface)
const provider = Layer.succeed(Provider.Service, {} as Provider.Interface)
const watcher = Layer.succeed(KilocodeWatcher.Service, KilocodeWatcher.Service.of({ init: () => Effect.void }))
const indexing = spyOn(KiloIndexing, "init").mockRejectedValue(err)
const warn = spyOn(logger, "warn").mockImplementation(() => {})
@@ -478,7 +480,7 @@ describe("kilocode tool registry indexing", () => {
await Effect.runPromise(
KilocodeBootstrap.Service.use((svc) => svc.init()).pipe(
Effect.provide(
KilocodeBootstrap.layer.pipe(Layer.provide([sessions, bus, memory, session, summary, provider])),
KilocodeBootstrap.layer.pipe(Layer.provide([sessions, bus, memory, session, summary, provider, watcher])),
),
Effect.scoped,
),
+1
View File
@@ -40,6 +40,7 @@ process.env["XDG_STATE_HOME"] = path.join(dir, "state")
process.env["KILO_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
process.env["KILO_EXPERIMENTAL_EVENT_SYSTEM"] = "true"
process.env["KILO_EXPERIMENTAL_WORKSPACES"] = "true"
process.env["KILO_EXPERIMENTAL_DISABLE_FILEWATCHER"] ??= "true" // kilocode_change - see test.yml: per-instance watchers are too heavy/racy for unit tests; watcher tests opt back in
// Set test home directory to isolate tests from user's actual home directory
// This prevents tests from picking up real user configs/skills from ~/.claude/skills