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),