From 7aaa25d97ef45246514c3255ef2b22da39374e17 Mon Sep 17 00:00:00 2001 From: Imanol Maiztegui Date: Fri, 19 Jun 2026 18:54:51 +0200 Subject: [PATCH] feat(worktree): resolve primary checkout for config, skill, and session fallback Introduce a primaryWorktree Effect that queries Git plumbing commands to locate the main checkout from any linked worktree. Integrate it into config loading so .kilo directory settings from the primary checkout act as local-scoped fallbacks beneath the active worktree and KILO_CONFIG_DIR overrides. Wire primaryPaths into skill discovery so project skills installed in the primary checkout appear as fallbacks unless overridden locally. Refactor session family matching to consider sandbox directories and filter out nested Git repositories when resolving worktree roots. Convert baselineDirectory in indexing to an Effect that delegates to primaryWorktree instead of reading Instance.project.worktree directly. Restore the content() signal usage in the TUI text part renderer. Provide Git.defaultLayer across all affected test layers and add coverage for linked worktrees, submodules, paths with spaces, sandbox project matching, and primary config directory precedence. --- .../src/cli/cmd/tui/routes/session/index.tsx | 2 +- packages/opencode/src/config/config.ts | 22 ++- packages/opencode/src/kilocode/indexing.ts | 18 +-- .../opencode/src/kilocode/primary-worktree.ts | 38 +++++ .../opencode/src/kilocode/session/index.ts | 46 +++--- packages/opencode/src/skill/index.ts | 18 ++- .../agent/plugin-agent-regression.test.ts | 2 + packages/opencode/test/config/config.test.ts | 3 + .../test/kilocode/config-gitignore.test.ts | 2 + .../test/kilocode/config/config.test.ts | 51 +++++++ .../config/indexing-default-plugin.test.ts | 2 + .../test/kilocode/indexing-worktree.test.ts | 1 + .../test/kilocode/primary-worktree.test.ts | 133 ++++++++++++++---- .../kilocode/project-config-update.test.ts | 2 + .../test/kilocode/session-list.test.ts | 34 +++++ .../kilocode/worktree-project-skills.test.ts | 111 +++++++++++---- packages/opencode/test/plugin/trigger.test.ts | 2 + .../test/plugin/workspace-adapter.test.ts | 2 + .../server/experimental-session-list.test.ts | 8 ++ packages/opencode/test/skill/skill.test.ts | 2 + 20 files changed, 405 insertions(+), 94 deletions(-) create mode 100644 packages/opencode/src/kilocode/primary-worktree.ts diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f7ded860e0e..5a127324636 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1802,7 +1802,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess syntaxStyle={syntax()} streaming={true} internalBlockMode="top-level" - content={props.part.text.trim()} + content={content()} // kilocode_change tableOptions={{ style: "grid" }} conceal={ctx.conceal()} fg={theme.markdownText} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 0540a070b88..b94c717b6cd 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -50,6 +50,8 @@ import z from "zod" // kilocode_change - Kilo config compatibility schemas // kilocode_change start import { ZodOverride } from "@opencode-ai/core/effect-zod" import { KilocodeConfig } from "../kilocode/config/config" +import { primaryPaths } from "../kilocode/primary-worktree" +import { Git } from "@/git" import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins" import { KilocodeGlobalConfigStamp } from "@/kilocode/config/global-stamp" import { @@ -523,6 +525,7 @@ export const layer = Layer.effect( const env = yield* Env.Service const npmSvc = yield* Npm.Service const http = yield* HttpClient.HttpClient + const git = yield* Git.Service // kilocode_change const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) @@ -828,7 +831,7 @@ export const layer = Layer.effect( if (!Flag.KILO_DISABLE_PROJECT_CONFIG) { // kilocode_change start - also discover kilo.json project files for (const name of ["kilo", "opencode"] as const) { - for (const file of yield* ConfigPaths.files(name, ctx.directory, ctx.project.worktree).pipe(Effect.orDie)) { + for (const file of yield* ConfigPaths.files(name, ctx.directory, ctx.worktree).pipe(Effect.orDie)) { yield* merge( file, yield* loadFile(file, authEnv).pipe( @@ -848,7 +851,15 @@ export const layer = Layer.effect( result.mode = result.mode || {} result.plugin = result.plugin || [] - const directories = yield* ConfigPaths.directories(ctx.directory, ctx.project.worktree) // kilocode_change + // kilocode_change start - include config directories from the primary checkout + const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree) + const primary = Flag.KILO_DISABLE_PROJECT_CONFIG + ? [] + : yield* primaryPaths(ctx.worktree, KilocodeConfig.ALL_CONFIG_DIR_SUFFIXES) + // Load primary fallbacks before active-worktree config, then track them as local. + directories.splice(1, 0, ...primary) + const primarySet = new Set(primary) + // kilocode_change end if (Flag.KILO_CONFIG_DIR) { log.debug("loading config from KILO_CONFIG_DIR", { path: Flag.KILO_CONFIG_DIR }) @@ -858,6 +869,7 @@ export const layer = Layer.effect( // kilocode_change start for (const dir of unique(directories)) { + const scope = primarySet.has(dir) ? "local" : undefined if (KilocodeConfig.isConfigDir(dir, Flag.KILO_CONFIG_DIR)) { for (const file of KilocodeConfig.ALL_CONFIG_FILES) { const source = path.join(dir, file) @@ -870,6 +882,7 @@ export const layer = Layer.effect( return Effect.succeed({} as Info) }), ), + scope, ) result.agent ??= {} result.mode ??= {} @@ -914,7 +927,7 @@ export const layer = Layer.effect( // Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load // returns normalized Specs and we only need to attach origin metadata here. const list = yield* Effect.promise(() => ConfigPlugin.load(dir)) - yield* mergePluginOrigins(dir, list) + yield* mergePluginOrigins(dir, list, scope) // kilocode_change } if (process.env.KILO_CONFIG_CONTENT) { @@ -1071,7 +1084,7 @@ export const layer = Layer.effect( const state = yield* InstanceState.make( Effect.fn("Config.state")(function* (ctx) { - return yield* loadInstanceState(ctx).pipe(Effect.orDie) + return yield* loadInstanceState(ctx).pipe(Effect.provideService(Git.Service, git), Effect.orDie) // kilocode_change }), ) @@ -1206,6 +1219,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( + Layer.provide(Git.defaultLayer), // kilocode_change Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), diff --git a/packages/opencode/src/kilocode/indexing.ts b/packages/opencode/src/kilocode/indexing.ts index 12c44b6a9d1..8902636808c 100644 --- a/packages/opencode/src/kilocode/indexing.ts +++ b/packages/opencode/src/kilocode/indexing.ts @@ -1,5 +1,6 @@ import z from "zod" import path from "path" +import { Effect } from "effect" import { type IndexingTelemetryEvent, type VectorStoreSearchResult } from "@kilocode/kilo-indexing/engine" import { toIndexingConfigInput, type IndexingConfig } from "@kilocode/kilo-indexing/config" import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" @@ -22,6 +23,7 @@ import { indexingWarningKey, type IndexingWarning } from "./indexing-warning" import { IndexingWorker } from "./indexing-worker-client" import { LanceDBRuntime } from "./lancedb" // kilocode_change import { indexingWithKiloDefault, resolveKiloIndexingAuth, type KiloIndexingAuth } from "./indexing-auth" // kilocode_change +import { primaryWorktree } from "./primary-worktree" const log = Log.create({ service: "kilocode-indexing" }) const auth = makeRuntime(Auth.Service, Auth.defaultLayer) @@ -29,19 +31,19 @@ const missing = () => disabledIndexingStatus("Indexing plugin is not enabled for const noWorkspace = () => disabledIndexingStatus("Codebase indexing is disabled because no workspace folder is open in VS Code.") -function baselineDirectory(dir: string): string | undefined { - if (Instance.project.vcs !== "git") return +const baselineDirectory = Effect.fn("KiloIndexing.baselineDirectory")(function* (dir: string) { + if (Instance.project.vcs !== "git") return undefined const checkout = path.resolve(Instance.worktree) - const main = path.resolve(Instance.project.worktree) - if (checkout === main) return + const main = yield* primaryWorktree(checkout) + if (!main || checkout === main) return undefined const scope = path.relative(checkout, path.resolve(dir)) - if (scope === ".." || scope.startsWith(`..${path.sep}`) || path.isAbsolute(scope)) return + if (scope === ".." || scope.startsWith(`..${path.sep}`) || path.isAbsolute(scope)) return undefined const baseline = path.resolve(main, scope) - if (baseline === path.resolve(dir)) return + if (baseline === path.resolve(dir)) return undefined return baseline -} +}) function failed(err: unknown): z.infer { const msg = err instanceof Error ? err.message : String(err) @@ -242,7 +244,7 @@ export namespace KiloIndexing { const boot = async (hit: Cache): Promise => { const dir = Instance.directory - const baseline = baselineDirectory(dir) + const baseline = await AppRuntime.runPromise(baselineDirectory(dir)) const cfg = await AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) if (process.env["KILO_DISABLE_CODEBASE_INDEXING"] === "vscode-no-workspace") { return track(hit, await inert(() => noWorkspace())) diff --git a/packages/opencode/src/kilocode/primary-worktree.ts b/packages/opencode/src/kilocode/primary-worktree.ts new file mode 100644 index 00000000000..055a52b50ff --- /dev/null +++ b/packages/opencode/src/kilocode/primary-worktree.ts @@ -0,0 +1,38 @@ +import { existsSync } from "fs" +import path from "path" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect } from "effect" +import { Git } from "../git" + +export const primaryPaths = Effect.fn("PrimaryWorktree.paths")(function* (dir: string, names: readonly string[]) { + const cwd = AppFileSystem.normalizePath(path.resolve(dir)) + const primary = yield* primaryWorktree(cwd) + if (!primary || primary === cwd) return [] + return names.map((name) => path.join(primary, name)).filter(existsSync) +}) + +export const primaryWorktree = Effect.fn("PrimaryWorktree.find")(function* (dir: string) { + const cwd = AppFileSystem.normalizePath(path.resolve(dir)) + const git = yield* Git.Service + const run = Effect.fnUntraced(function* (args: string[]) { + const result = yield* git.run(args, { cwd }) + return result.exitCode === 0 ? result.text() : undefined + }) + const resolve = (value: string) => + AppFileSystem.normalizePath(path.isAbsolute(value) ? path.normalize(value) : path.resolve(cwd, value)) + const line = (value: string | undefined) => value?.replace(/\r?\n$/, "") + + if (line(yield* run(["rev-parse", "--is-inside-work-tree"])) !== "true") return undefined + + const root = line(yield* run(["rev-parse", "--path-format=absolute", "--show-toplevel"])) + const gitdir = line(yield* run(["rev-parse", "--path-format=absolute", "--git-dir"])) + const common = line(yield* run(["rev-parse", "--path-format=absolute", "--git-common-dir"])) + if (!root || !gitdir || !common) return undefined + if (resolve(gitdir) === resolve(common)) return resolve(root) + + const listing = yield* run(["worktree", "list", "--porcelain", "-z"]) + const fields = listing?.split("\0\0", 1)[0]?.split("\0") + const worktree = fields?.find((field) => field.startsWith("worktree ")) + if (!worktree || fields?.includes("bare")) return undefined + return resolve(worktree.slice("worktree ".length)) +}) diff --git a/packages/opencode/src/kilocode/session/index.ts b/packages/opencode/src/kilocode/session/index.ts index 708592fe0dd..1f31beb010d 100644 --- a/packages/opencode/src/kilocode/session/index.ts +++ b/packages/opencode/src/kilocode/session/index.ts @@ -17,6 +17,7 @@ import type { Provider } from "@/provider/provider" import { zod as toZod } from "@opencode-ai/core/effect-zod" import { ENV_FEATURE } from "@kilocode/kilo-gateway" import { fn } from "@/kilocode/fn" +import { existsSync } from "fs" import path from "path" export namespace KiloSession { @@ -117,25 +118,26 @@ export namespace KiloSession { // Project family resolution (worktree-aware) // --------------------------------------------------------------------------- - export function family(id: string): string[] { - const row = Database.use((db) => + export function family(id: string, directories: string[] = []): string[] { + const rows = Database.use((db) => db - .select({ worktree: ProjectTable.worktree }) + .select({ id: ProjectTable.id, worktree: ProjectTable.worktree, sandboxes: ProjectTable.sandboxes }) .from(ProjectTable) - .where(eq(ProjectTable.id, ProjectID.make(id))) - .get(), + .all(), ) - const root = row?.worktree ? Filesystem.resolve(row.worktree) : undefined - if (!root || root === "/") return [id] - const ids = Database.use((db) => - db - .select({ id: ProjectTable.id }) - .from(ProjectTable) - .where(eq(ProjectTable.worktree, root)) - .all() - .map((item) => item.id), - ) - return ids.length ? ids : [id] + const current = rows.find((row) => row.id === id) + const root = current?.worktree ? Filesystem.resolve(current.worktree) : undefined + // Combine the stored root with Git's current sibling worktrees. + const roots = new Set([...(root && root !== "/" ? [root] : []), ...directories.map(Filesystem.resolve)]) + if (roots.size === 0) return [id] + + // Match both each project's recorded root and its saved worktrees. + const ids = rows.flatMap((row) => { + const dirs = [row.worktree, ...row.sandboxes].map(Filesystem.resolve) + return dirs.some((dir) => roots.has(dir)) ? [row.id] : [] + }) + // Always keep the requested ID and remove duplicates. + return [...new Set([id, ...ids])] } export function filters(input: { projectID: ProjectID; directory?: string }): SQL[] { @@ -326,9 +328,10 @@ export namespace KiloSession { archived?: boolean }) { const conditions: SQL[] = [] + const dirs = [...new Set((input.directories ?? []).map((dir) => Filesystem.resolve(dir)))] if (input.projectID) { - const ids = family(input.projectID) + const ids = family(input.projectID, dirs) if (ids.length === 1 && ids[0] === input.projectID) { conditions.push(eq(SessionTable.project_id, ProjectID.make(input.projectID))) } else { @@ -361,11 +364,16 @@ export namespace KiloSession { } const limit = input.limit ?? 100 - const dirs = [...new Set((input.directories ?? []).map((dir) => Filesystem.resolve(dir)))] const sorted = [...dirs].sort((a, b) => b.length - a.length) + const nested = (root: string, dir: string): boolean => { + if (dir === root || !Filesystem.contains(root, dir)) return false + if (existsSync(path.join(dir, ".git"))) return true + const parent = path.dirname(dir) + return parent !== dir && nested(root, parent) + } const worktree = (dir: string) => { for (const root of sorted) { - if (!Filesystem.contains(root, dir)) continue + if (!Filesystem.contains(root, dir) || nested(root, dir)) continue const rel = path.relative(root, dir) const parts = rel.split(path.sep) if ((parts[0] === ".kilo" || parts[0] === ".kilocode") && parts[1] === "worktrees" && parts[2]) { diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 6076a7518d9..af48e680d6a 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -15,6 +15,8 @@ import { Glob } from "@opencode-ai/core/util/glob" import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" import { BUILTIN_SKILLS } from "../kilocode/skills/builtin" // kilocode_change +import { primaryPaths } from "../kilocode/primary-worktree" // kilocode_change +import { Git } from "@/git" // kilocode_change import { isRecord } from "@/util/record" const log = Log.create({ service: "skill" }) @@ -187,9 +189,12 @@ const discoverSkills = Effect.fnUntraced(function* ( yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) } - const upDirs = yield* fsys + // kilocode_change start + const local = yield* fsys .up({ targets: externalDirs, start: directory, stop: worktree }) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) + const upDirs = [...(yield* primaryPaths(worktree, externalDirs)), ...local] + // kilocode_change end for (const root of upDirs) { yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) @@ -238,10 +243,7 @@ const loadSkills = Effect.fnUntraced(function* (state: State, discovered: Discov } // kilocode_change end - yield* Effect.forEach(discovered.matches, (match) => add(state, match, bus), { - concurrency: "unbounded", - discard: true, - }) + for (const match of discovered.matches) yield* add(state, match, bus) // kilocode_change log.info("init", { count: Object.keys(state.skills).length }) }) @@ -257,6 +259,7 @@ export const layer = Layer.effect( const fsys = yield* AppFileSystem.Service const global = yield* Global.Service const flags = yield* RuntimeFlags.Service + const git = yield* Git.Service // kilocode_change const discovered = yield* InstanceState.make( Effect.fn("Skill.discovery")(function* (ctx) { return yield* discoverSkills( @@ -267,8 +270,8 @@ export const layer = Layer.effect( flags.disableExternalSkills, flags.disableClaudeCodeSkills, ctx.directory, - ctx.project.worktree, // kilocode_change - ) + ctx.worktree, // kilocode_change + ).pipe(Effect.provideService(Git.Service, git)) // kilocode_change }), ) const state = yield* InstanceState.make( @@ -312,6 +315,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe( + Layer.provide(Git.defaultLayer), // kilocode_change Layer.provide(Discovery.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Bus.layer), diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 6017af1c035..5871b9dc246 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -8,6 +8,7 @@ import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { Env } from "../../src/env" +import { Git } from "../../src/git" // kilocode_change import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin" import { AccountTest } from "../fake/account" @@ -25,6 +26,7 @@ const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "age const provider = ProviderTest.fake() const configLayer = Config.layer.pipe( + Layer.provide(Git.defaultLayer), // kilocode_change Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index a2994d17179..b298f51a79e 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -14,6 +14,7 @@ import { Account } from "../../src/account/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Env } from "../../src/env" +import { Git } from "../../src/git" // kilocode_change import { provideTmpdirInstance, TestInstance, @@ -95,6 +96,7 @@ const configLayer = ( } = {}, ) => Config.layer.pipe( + Layer.provide(Git.defaultLayer), // kilocode_change Layer.provide(testFlock), Layer.provide(Env.defaultLayer), Layer.provide(options.auth ?? AuthTest.empty), @@ -1682,6 +1684,7 @@ test("remote well-known config can use FetchHttpClient layer", async () => { Effect.scoped, Effect.provide( Config.layer.pipe( + Layer.provide(Git.defaultLayer), // kilocode_change Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), diff --git a/packages/opencode/test/kilocode/config-gitignore.test.ts b/packages/opencode/test/kilocode/config-gitignore.test.ts index 9bec27d63bd..01485733039 100644 --- a/packages/opencode/test/kilocode/config-gitignore.test.ts +++ b/packages/opencode/test/kilocode/config-gitignore.test.ts @@ -16,6 +16,7 @@ import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Npm } from "@opencode-ai/core/npm" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Env } from "../../src/env" +import { Git } from "../../src/git" import { Auth } from "../../src/auth" import { Account } from "../../src/account/account" import { provideTestInstance } from "../fixture/fixture" @@ -48,6 +49,7 @@ const unexpectedHttp = HttpClient.make((request) => ) const testLayer = Config.layer.pipe( + Layer.provide(Git.defaultLayer), Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index b501c54f8c5..86e00123b95 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -1,3 +1,4 @@ +import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" import { Effect, Layer, Option, Schema } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" @@ -14,6 +15,7 @@ import { Config } from "../../../src/config/config" import { ConfigMarkdown } from "../../../src/config/markdown" import { ConfigParse } from "../../../src/config/parse" import { Env } from "../../../src/env" +import { Git } from "../../../src/git" import { KiloIndexing } from "../../../src/kilocode/indexing" import { KilocodeConfig } from "../../../src/kilocode/config/config" import { provideTestInstance } from "../../fixture/fixture" @@ -39,6 +41,7 @@ const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`), ) const layer = Config.layer.pipe( + Layer.provide(Git.defaultLayer), Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), @@ -410,6 +413,54 @@ describe("agent config", () => { }) }) +describe("linked worktree config", () => { + test("uses primary config directories as local fallbacks", async () => { + await using primary = await tmpdir({ git: true }) + const worktree = path.join(path.dirname(primary.path), `${path.basename(primary.path)}-config-feature`) + await Bun.write(path.join(primary.path, "kilo.json"), JSON.stringify({ model: "test/primary" })) + await $`git add kilo.json`.cwd(primary.path).quiet() + await $`git commit -m config`.cwd(primary.path).quiet() + await $`git worktree add -b config-sibling-worktree ${worktree}`.cwd(primary.path).quiet() + + try { + await Bun.write(path.join(worktree, "kilo.json"), JSON.stringify({ model: "test/worktree" })) + await Bun.write( + path.join(primary.path, ".kilo", "kilo.jsonc"), + JSON.stringify({ username: "primary-dir", indexing: { enabled: true } }), + ) + await Bun.write(path.join(worktree, ".kilo", "kilo.jsonc"), JSON.stringify({ username: "worktree-dir" })) + + const config = await provideTestInstance({ directory: worktree, fn: load }) + + expect(config.model).toBe("test/worktree") + expect(config.username).toBe("worktree-dir") + expect(config.indexing?.enabled).toBe(true) + } finally { + await $`git worktree remove --force ${worktree}`.cwd(primary.path).quiet().nothrow() + } + }) + + test("keeps KILO_CONFIG_DIR above the primary fallback", async () => { + await using primary = await tmpdir({ git: true }) + await using explicit = await tmpdir() + const worktree = path.join(path.dirname(primary.path), `${path.basename(primary.path)}-config-explicit`) + await $`git worktree add -b config-explicit-worktree ${worktree}`.cwd(primary.path).quiet() + await Bun.write(path.join(primary.path, ".kilo", "kilo.jsonc"), JSON.stringify({ username: "primary-dir" })) + await Bun.write(path.join(explicit.path, "kilo.jsonc"), JSON.stringify({ username: "explicit-dir" })) + const previous = process.env["KILO_CONFIG_DIR"] + process.env["KILO_CONFIG_DIR"] = explicit.path + + try { + const config = await provideTestInstance({ directory: worktree, fn: load }) + expect(config.username).toBe("explicit-dir") + } finally { + if (previous === undefined) delete process.env["KILO_CONFIG_DIR"] + else process.env["KILO_CONFIG_DIR"] = previous + await $`git worktree remove --force ${worktree}`.cwd(primary.path).quiet().nothrow() + } + }) +}) + describe("bash permission migration", () => { for (const action of ["allow", "ask", "deny"] as const) { test(`preserves string-form ${action} permission in jsonc`, async () => { diff --git a/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts b/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts index b8d2777c9fc..e42e3fc190a 100644 --- a/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts +++ b/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts @@ -12,6 +12,7 @@ import { KilocodeDefaultPlugins } from "../../../src/kilocode/config/default-plu import { INDEXING_PLUGIN } from "../../../src/kilocode/indexing-feature" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { Env } from "../../../src/env" +import { Git } from "../../../src/git" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Filesystem } from "../../../src/util/filesystem" @@ -39,6 +40,7 @@ const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`), ) const layer = Config.layer.pipe( + Layer.provide(Git.defaultLayer), Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), diff --git a/packages/opencode/test/kilocode/indexing-worktree.test.ts b/packages/opencode/test/kilocode/indexing-worktree.test.ts index bde2c5eb76a..69a529af605 100644 --- a/packages/opencode/test/kilocode/indexing-worktree.test.ts +++ b/packages/opencode/test/kilocode/indexing-worktree.test.ts @@ -38,6 +38,7 @@ describe("indexing worktrees", () => { test("shares the primary checkout index with a linked worktree", async () => { await using tmp = await tmpdir({ git: true, config: cfg }) process.env["KILO_CONFIG_DIR"] = tmp.path + await Bun.$`git -C ${tmp.path} add opencode.json && git -C ${tmp.path} commit -m config`.quiet() const worktree = path.join(tmp.path, ".kilo", "worktrees", "feature") await Bun.$`git -C ${tmp.path} worktree add -b feature ${worktree}`.quiet() diff --git a/packages/opencode/test/kilocode/primary-worktree.test.ts b/packages/opencode/test/kilocode/primary-worktree.test.ts index 3e1a6b767fa..63cfb6b1df1 100644 --- a/packages/opencode/test/kilocode/primary-worktree.test.ts +++ b/packages/opencode/test/kilocode/primary-worktree.test.ts @@ -1,44 +1,119 @@ import { $ } from "bun" -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Layer } from "effect" import path from "path" +import { Git } from "../../src/git" import { primaryWorktree } from "../../src/kilocode/primary-worktree" -import { tmpdir } from "../fixture/fixture" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(Git.defaultLayer, CrossSpawnSpawner.defaultLayer)) describe("primaryWorktree", () => { - test("returns the current checkout for a normal repository", async () => { - await using repo = await tmpdir({ git: true }) + it.live("returns the current checkout for a normal repository", () => + Effect.gen(function* () { + const repo = yield* tmpdirScoped({ git: true }) - expect(primaryWorktree(repo.path)).toBe(repo.path) - }) + expect(yield* primaryWorktree(repo)).toBe(repo) + }), + ) - test("returns the primary checkout for a linked worktree", async () => { - await using repo = await tmpdir({ git: true }) - const worktree = path.join(repo.path, ".kilo", "worktrees", "feature") - await $`git worktree add -b primary-worktree-test ${worktree}`.cwd(repo.path).quiet() + it.live("returns the primary checkout for a sibling linked worktree", () => + Effect.gen(function* () { + const repo = yield* tmpdirScoped({ git: true }) + const worktree = path.join(path.dirname(repo), `${path.basename(repo)}-feature`) + yield* Effect.addFinalizer(() => + Effect.promise(() => $`git worktree remove --force ${worktree}`.cwd(repo).quiet().nothrow()).pipe( + Effect.asVoid, + ), + ) + yield* Effect.promise(() => $`git worktree add -b primary-worktree-test ${worktree}`.cwd(repo).quiet()) - expect(primaryWorktree(worktree)).toBe(repo.path) - }) + expect(yield* primaryWorktree(worktree)).toBe(repo) + }), + ) - test("returns undefined outside a Git repository", async () => { - await using dir = await tmpdir() + it.live("returns undefined outside a Git repository", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() - expect(primaryWorktree(dir.path)).toBeUndefined() - }) + expect(yield* primaryWorktree(dir)).toBeUndefined() + }), + ) - test("supports repository paths containing spaces", async () => { - await using dir = await tmpdir() - const repo = path.join(dir.path, "repo with spaces") - await $`git init ${repo}`.quiet() + it.live("rechecks a path after it becomes a Git repository", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + expect(yield* primaryWorktree(dir)).toBeUndefined() - expect(primaryWorktree(repo)).toBe(repo) - }) + yield* Effect.promise(() => $`git init ${dir}`.quiet()) + expect(yield* primaryWorktree(dir)).toBe(dir) + }), + ) - test("returns a submodule checkout instead of its internal git directory", async () => { - await using parent = await tmpdir({ git: true }) - await using child = await tmpdir({ git: true }) - await $`git -c protocol.file.allow=always submodule add ${child.path} sub`.cwd(parent.path).quiet() - const submodule = path.join(parent.path, "sub") + it.live("supports repository paths containing spaces", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const repo = path.join(dir, "repo with spaces") + yield* Effect.promise(() => $`git init ${repo}`.quiet()) - expect(primaryWorktree(submodule)).toBe(submodule) - }) + expect(yield* primaryWorktree(repo)).toBe(repo) + }), + ) + + it.live("supports primary checkout paths containing newlines", () => + Effect.gen(function* () { + if (process.platform === "win32") return + const dir = yield* tmpdirScoped() + const repo = path.join(dir, "primary-checkout\n") + const worktree = path.join(dir, "feature") + yield* Effect.promise(() => $`git init ${repo}`.quiet()) + yield* Effect.promise(() => + $`git -c user.name=Test -c user.email=test@example.com -c commit.gpgsign=false commit --allow-empty -m init` + .cwd(repo) + .quiet(), + ) + yield* Effect.addFinalizer(() => + Effect.promise(() => $`git worktree remove --force ${worktree}`.cwd(repo).quiet().nothrow()).pipe( + Effect.asVoid, + ), + ) + yield* Effect.promise(() => $`git worktree add -b primary-newline-worktree ${worktree}`.cwd(repo).quiet()) + + expect(yield* primaryWorktree(worktree)).toBe(repo) + }), + ) + + it.live("returns a submodule checkout instead of its internal git directory", () => + Effect.gen(function* () { + const parent = yield* tmpdirScoped({ git: true }) + const child = yield* tmpdirScoped({ git: true }) + yield* Effect.promise(() => $`git -c protocol.file.allow=always submodule add ${child} sub`.cwd(parent).quiet()) + const submodule = path.join(parent, "sub") + + expect(yield* primaryWorktree(submodule)).toBe(submodule) + }), + ) + + it.live("supports a separate Git directory", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const repo = path.join(dir, "checkout") + const store = path.join(dir, "git-store") + yield* Effect.promise(() => $`git init --separate-git-dir=${store} ${repo}`.quiet()) + + expect(yield* primaryWorktree(repo)).toBe(repo) + }), + ) + + it.live("returns undefined for a bare repository", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const repo = path.join(dir, "bare.git") + yield* Effect.promise(() => $`git init --bare ${repo}`.quiet()) + + expect(yield* primaryWorktree(repo)).toBeUndefined() + }), + ) }) diff --git a/packages/opencode/test/kilocode/project-config-update.test.ts b/packages/opencode/test/kilocode/project-config-update.test.ts index 2b140f80054..e7c5577aac9 100644 --- a/packages/opencode/test/kilocode/project-config-update.test.ts +++ b/packages/opencode/test/kilocode/project-config-update.test.ts @@ -11,6 +11,7 @@ import { Config } from "../../src/config/config" import { Auth } from "../../src/auth" import { Account } from "../../src/account/account" import { Env } from "../../src/env" +import { Git } from "../../src/git" import { Npm } from "@opencode-ai/core/npm" import { provideTestInstance } from "../fixture/fixture" import { Filesystem } from "../../src/util/filesystem" @@ -42,6 +43,7 @@ const unexpectedHttp = HttpClient.make((request) => ) const layer = Config.layer.pipe( + Layer.provide(Git.defaultLayer), Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), diff --git a/packages/opencode/test/kilocode/session-list.test.ts b/packages/opencode/test/kilocode/session-list.test.ts index 0dc998fde7c..9b255dd819e 100644 --- a/packages/opencode/test/kilocode/session-list.test.ts +++ b/packages/opencode/test/kilocode/session-list.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" +import path from "path" import { provideTestInstance } from "../fixture/fixture" import { ProjectTable } from "../../src/project/project.sql" import { ProjectID } from "../../src/project/schema" @@ -50,4 +51,37 @@ describe("Kilo Session.list", () => { }, }) }) + + test("matches legacy project ids through active sandboxes", async () => { + await using tmp = await tmpdir({ git: true }) + await provideTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const session = await Effect.runPromise( + Session.Service.use((svc) => svc.create({ title: "sandbox-session" })).pipe( + Effect.provide(Session.defaultLayer), + ), + ) + const project = ProjectID.make(`sandbox-project-${Date.now()}`) + Database.use((db) => { + db.insert(ProjectTable) + .values({ + id: project, + worktree: path.join(tmp.path, "removed-worktree"), + vcs: "git", + time_created: Date.now(), + time_updated: Date.now(), + sandboxes: [tmp.path], + }) + .run() + db.update(SessionTable).set({ project_id: project }).where(eq(SessionTable.id, session.id)).run() + }) + + const ids = [...Session.listGlobal({ projectID: ctx.project.id, directories: [tmp.path], roots: true })].map( + (item) => item.id, + ) + expect(ids).toContain(session.id) + }, + }) + }) }) diff --git a/packages/opencode/test/kilocode/worktree-project-skills.test.ts b/packages/opencode/test/kilocode/worktree-project-skills.test.ts index d65041b4f6c..d853d0d214f 100644 --- a/packages/opencode/test/kilocode/worktree-project-skills.test.ts +++ b/packages/opencode/test/kilocode/worktree-project-skills.test.ts @@ -1,49 +1,108 @@ import { $ } from "bun" import { afterEach, describe, expect } from "bun:test" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Layer } from "effect" import path from "path" +import { Bus } from "../../src/bus" +import { Config } from "../../src/config/config" +import { RuntimeFlags } from "../../src/effect/runtime-flags" +import { Git } from "../../src/git" import { Skill } from "../../src/skill" -import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" +import { Discovery } from "../../src/skill/discovery" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Skill.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const layer = Skill.layer.pipe( + Layer.provide(Git.defaultLayer), + Layer.provide(Discovery.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.layer), + Layer.provide(RuntimeFlags.layer({ disableExternalSkills: false, disableClaudeCodeSkills: false })), +) +const it = testEffect(Layer.mergeAll(layer, CrossSpawnSpawner.defaultLayer)) afterEach(() => disposeAllInstances()) describe("worktree project skills", () => { - it.live("discovers skills installed in the main repository", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir({ git: true })), - (tmp) => - Effect.gen(function* () { - const dir = path.join(tmp.path, ".kilo", "worktrees", "feature") - yield* Effect.promise(() => $`git worktree add -b worktree-project-skills ${dir}`.cwd(tmp.path).quiet()) - yield* Effect.promise(() => + it.live("uses primary skills as fallbacks and prefers worktree copies", () => + Effect.gen(function* () { + const primary = yield* tmpdirScoped({ git: true }) + const dir = path.join(path.dirname(primary), `${path.basename(primary)}-feature`) + const skills = [ + [".kilo", "kilo"], + [".agents", "agents"], + [".claude", "claude"], + ] as const + + yield* Effect.promise(() => + Promise.all( + skills.map(([root, name]) => Bun.write( - path.join(tmp.path, ".kilo", "skills", "project-skill", "SKILL.md"), + path.join(primary, root, "skills", `${name}-shared`, "SKILL.md"), `--- -name: project-skill -description: A skill installed in the main repository. +name: ${name}-shared +description: Shared primary skill. --- -# Project Skill +# Primary `, ), - ) + ), + ), + ) + yield* Effect.promise(() => $`git add .kilo .agents .claude`.cwd(primary).quiet()) + yield* Effect.promise(() => $`git commit -m skills`.cwd(primary).quiet()) + yield* Effect.addFinalizer(() => + Effect.promise(() => $`git worktree remove --force ${dir}`.cwd(primary).quiet().nothrow()).pipe(Effect.asVoid), + ) + yield* Effect.promise(() => $`git worktree add -b worktree-project-skills ${dir}`.cwd(primary).quiet()) - const list = yield* provideInstance(dir)( - Effect.gen(function* () { - const skill = yield* Skill.Service - return yield* skill.all() - }), - ) + yield* Effect.promise(() => + Promise.all( + skills.flatMap(([root, name]) => [ + Bun.write( + path.join(primary, root, "skills", `${name}-fallback`, "SKILL.md"), + `--- +name: ${name}-fallback +description: Primary-only fallback. +--- - expect(list.find((item) => item.name === "project-skill")?.location).toBe( - path.join(tmp.path, ".kilo", "skills", "project-skill", "SKILL.md"), - ) +# Fallback +`, + ), + Bun.write( + path.join(dir, root, "skills", `${name}-shared`, "SKILL.md"), + `--- +name: ${name}-shared +description: Worktree override. +--- + +# Worktree +`, + ), + ]), + ), + ) + + const list = yield* provideInstance(dir)( + Effect.gen(function* () { + const skill = yield* Skill.Service + return yield* skill.all() }), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), - ), + ) + + for (const [root, name] of skills) { + expect(list.find((item) => item.name === `${name}-fallback`)?.location).toBe( + path.join(primary, root, "skills", `${name}-fallback`, "SKILL.md"), + ) + expect(list.find((item) => item.name === `${name}-shared`)?.location).toBe( + path.join(dir, root, "skills", `${name}-shared`, "SKILL.md"), + ) + } + }), ) }) diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index 3716bc3aca5..1202fd502f2 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -9,6 +9,7 @@ import { pathToFileURL } from "url" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { Env } from "../../src/env" +import { Git } from "../../src/git" // kilocode_change import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin/index" import { ModelID, ProviderID } from "../../src/provider/schema" @@ -19,6 +20,7 @@ import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" const configLayer = Config.layer.pipe( + Layer.provide(Git.defaultLayer), // kilocode_change Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 79964d3deeb..32b50ae63f7 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -10,6 +10,7 @@ import { Auth } from "../../src/auth" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { Env } from "../../src/env" +import { Git } from "../../src/git" // kilocode_change import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Workspace } from "../../src/control-plane/workspace" import { Plugin } from "../../src/plugin/index" @@ -28,6 +29,7 @@ import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" const configLayer = Config.layer.pipe( + Layer.provide(Git.defaultLayer), // kilocode_change Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), diff --git a/packages/opencode/test/server/experimental-session-list.test.ts b/packages/opencode/test/server/experimental-session-list.test.ts index 2462de36446..98de02df620 100644 --- a/packages/opencode/test/server/experimental-session-list.test.ts +++ b/packages/opencode/test/server/experimental-session-list.test.ts @@ -36,6 +36,8 @@ describe("experimental.session.list", () => { await using first = await tmpdir({ git: true }) await using second = await tmpdir({ git: true }) const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree") + const nested = path.join(first.path, "nested") + await $`git clone --no-local ${first.path} ${nested}`.quiet() try { await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet() @@ -64,6 +66,10 @@ describe("experimental.session.list", () => { }) await Bun.file(path.join(first.path, ".git", "kilo")).delete() + const nestedSession = await withTestInstance({ + directory: nested, + fn: (ctx) => create("nested-project-session", ctx), + }) await withTestInstance({ directory: second.path, fn: (ctx) => create("other-project-session", ctx), @@ -84,9 +90,11 @@ describe("experimental.session.list", () => { const dirs = body.map((item: { directory: string }) => item.directory) expect(root.session.projectID).not.toBe(branch.projectID) + expect(nestedSession.projectID).toBe(branch.projectID) expect(project.id).toBe(root.session.projectID) expect(ids).toContain(root.session.id) expect(ids).toContain(branch.id) + expect(ids).not.toContain(nestedSession.id) expect(dirs).toContain(worktree) expect(body.some((item: { title: string }) => item.title === "other-project-session")).toBe(false) } finally { diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index c66651eab9d..7cae92b8b29 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -5,6 +5,7 @@ import { Discovery } from "../../src/skill/discovery" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" +import { Git } from "../../src/git" // kilocode_change import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Global } from "@opencode-ai/core/global" @@ -17,6 +18,7 @@ const node = CrossSpawnSpawner.defaultLayer const skills = (disableExternalSkills: boolean, disableClaudeCodeSkills: boolean) => Skill.layer.pipe( + Layer.provide(Git.defaultLayer), // kilocode_change Layer.provide(Discovery.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Bus.layer),