Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function DialogSessionList() {
if (global()) return all
const root = project.instance.path().worktree
if (!root || root === "/") return all
return all.filter((s) => s.directory === root || s.directory.startsWith(root + path.sep))
return all.filter((s) => s.worktreeDirectory === root)
})
// kilocode_change end

Expand Down
11 changes: 6 additions & 5 deletions packages/opencode/src/kilocode/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,9 @@ export namespace KiloSession {
archived?: boolean
}) {
const conditions: SQL[] = []
const ids = input.projectID ? family(input.projectID) : []

if (input.projectID) {
const ids = family(input.projectID)
if (ids.length === 1 && ids[0] === input.projectID) {
conditions.push(eq(SessionTable.project_id, ProjectID.make(input.projectID)))
} else {
Expand Down Expand Up @@ -252,6 +252,7 @@ export namespace KiloSession {

const limit = input.limit ?? 100
const dirs = [...new Set((input.directories ?? []).map((dir) => Filesystem.resolve(dir)))]
const projectIDs = new Set(ids)

const rows = Database.use((db) => {
const query =
Expand All @@ -269,19 +270,19 @@ export namespace KiloSession {
dirs.length > 0
? rows.filter((row) => {
const dir = Filesystem.resolve(row.directory)
return dirs.some((root) => Filesystem.contains(root, dir))
return dirs.some((root) => Filesystem.contains(root, dir)) || projectIDs.has(row.project_id)
})
: rows

const ids = [...new Set(list.slice(0, limit).map((row) => row.project_id))]
const pids = [...new Set(list.slice(0, limit).map((row) => row.project_id))]
const projects = new Map<string, ProjectInfo>()

if (ids.length > 0) {
if (pids.length > 0) {
const items = Database.use((db) =>
db
.select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree })
.from(ProjectTable)
.where(inArray(ProjectTable.id, ids))
.where(inArray(ProjectTable.id, pids))
.all(),
)
for (const item of items) {
Expand Down
21 changes: 16 additions & 5 deletions packages/opencode/src/kilocode/worktree-family.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
// kilocode_change - new file
import { Instance } from "../project/instance"
import { Project } from "../project/project"
import { ProjectTable } from "../project/project.sql"
import { Database, eq } from "../storage/db"
import { Filesystem } from "../util/filesystem"
import { Git } from "../git"

export namespace WorktreeFamily {
function saved() {
const row = Database.use((db) =>
db
.select({ worktree: ProjectTable.worktree, sandboxes: ProjectTable.sandboxes })
.from(ProjectTable)
.where(eq(ProjectTable.id, Instance.project.id))
.get(),
)
if (!row) return []
return [row.worktree, ...row.sandboxes]
}

export async function list() {
if (Instance.project.vcs !== "git") {
return [Filesystem.resolve(Instance.directory)]
Expand All @@ -24,12 +37,10 @@ export namespace WorktreeFamily {
return [Filesystem.resolve(line.slice("worktree ".length).trim())]
})

if (dirs.length > 0) {
return [...new Set(dirs)]
}
if (dirs.length > 0) return [...new Set([...dirs, ...saved()].map((dir) => Filesystem.resolve(dir)))]
}

const dirs = [Instance.worktree, ...(await Project.sandboxes(Instance.project.id))]
const dirs = [Instance.worktree, ...saved()]
return [...new Set(dirs.map((dir) => Filesystem.resolve(dir)))]
}
}
14 changes: 12 additions & 2 deletions packages/opencode/src/server/routes/instance/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ const ConsoleSwitchBody = z.object({
orgID: z.string(),
})

// kilocode_change start - Agent Manager worktrees live under ignored .kilo folders
function kiloRoot(dir: string) {
return /^(.*(?:\/|\\)\.kilo(?:code)?(?:\/|\\)worktrees(?:\/|\\)[^\/\\]+)/.exec(dir)?.[1]
}
// kilocode_change end

const QueryBoolean = z.union([
z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()),
z.enum(["true", "false"]),
Expand Down Expand Up @@ -522,8 +528,12 @@ export const ExperimentalRoutes = lazy(() =>
})) {
// kilocode_change start - resolve worktree folder name for each session
if (sorted) {
const root = sorted.find((d) => Filesystem.contains(d, session.directory))
sessions.push({ ...session, worktreeName: path.basename(root ?? session.directory) })
const exact = sorted.find((d) => d === session.directory)
const kilo = kiloRoot(session.directory)
const parent = sorted.find((d) => Filesystem.contains(d, session.directory))
const root = exact ?? (kilo && parent ? kilo : parent)
const dir = root ?? session.directory
sessions.push({ ...session, worktreeDirectory: dir, worktreeName: path.basename(dir) })
continue
}
// kilocode_change end
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ export type ProjectInfo = Types.DeepMutable<Schema.Schema.Type<typeof ProjectInf
export const GlobalInfo = Schema.Struct({
...Info.fields,
project: Schema.NullOr(ProjectInfo),
worktreeDirectory: Schema.optional(Schema.String), // kilocode_change - exact worktree root for session list filtering
worktreeName: Schema.optional(Schema.String), // kilocode_change - basename of the specific worktree directory
})
.annotate({ identifier: "GlobalSession" })
Expand Down
166 changes: 166 additions & 0 deletions packages/opencode/test/kilocode/session-worktree-list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { $ } from "bun"
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import * as Config from "../../src/config/config"
import { Instance } from "../../src/project/instance"
import { ProjectTable } from "../../src/project/project.sql"
import * as Log from "@opencode-ai/core/util/log"
import { RemoteSender } from "../../src/kilo-sessions/remote-sender"
import { SessionID } from "../../src/session/schema"
import { SessionTable } from "../../src/session/session.sql"
import { Database, eq } from "../../src/storage/db"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"

Log.init({ print: false })

beforeEach(() => {
spyOn(RemoteSender, "create").mockReturnValue({ handle() {}, dispose() {} })
spyOn(Config, "get").mockImplementation(async () => ({ share: "manual" }) as Awaited<ReturnType<typeof Config.get>>)
})

afterEach(async () => {
mock.restore()
await Instance.disposeAll()
await resetDatabase()
})

type Item = {
id: string
directory: string
title: string
worktreeDirectory?: string
worktreeName?: string
}

describe("Kilo experimental session worktree list", () => {
test("returns exact worktree directories for nested worktree sessions", async () => {
await using repo = await tmpdir({ git: true })
const worktree = path.join(repo.path, ".kilo", "worktrees", "nested")

try {
await fs.mkdir(path.dirname(worktree), { recursive: true })
await Bun.write(path.join(repo.path, ".gitignore"), ".kilo/worktrees/\n")
await $`git worktree add ${worktree} -b nested-${Date.now()}`.cwd(repo.path).quiet()

const { Server } = await import("../../src/server/server")
const { Session } = await import("../../src/session/session")

const branch = await Instance.provide({
directory: worktree,
fn: async () => Session.create({ title: "nested-worktree-session" }),
})
const root = await Instance.provide({
directory: repo.path,
fn: async () => ({
app: Server.Default().app,
session: await Session.create({ title: "root-session" }),
}),
})

const response = await root.app.request("/experimental/session?roots=true&worktrees=true", {
headers: { "x-kilo-directory": repo.path },
})

expect(response.status).toBe(200)
const body = (await response.json()) as Item[]
const base = body.find((item) => item.id === root.session.id)
const nested = body.find((item) => item.id === branch.id)

expect(base?.worktreeDirectory).toBe(repo.path)
expect(base?.worktreeName).toBe(path.basename(repo.path))
expect(nested?.worktreeDirectory).toBe(worktree)
expect(nested?.worktreeName).toBe(path.basename(worktree))
} finally {
await $`git worktree remove ${worktree} --force`.cwd(repo.path).quiet().nothrow()
}
})

test("keeps sessions for removed worktrees visible from the root repo", async () => {
await using repo = await tmpdir({ git: true })
const worktree = path.join(repo.path, "..", path.basename(repo.path) + "-removed")

try {
await $`git worktree add ${worktree} -b removed-${Date.now()}`.cwd(repo.path).quiet()

const { Server } = await import("../../src/server/server")
const { Session } = await import("../../src/session/session")

const branch = await Instance.provide({
directory: worktree,
fn: async () => Session.create({ title: "removed-worktree-session" }),
})
const root = await Instance.provide({
directory: repo.path,
fn: async () => ({
app: Server.Default().app,
session: await Session.create({ title: "root-session" }),
}),
})

await $`git worktree remove ${worktree} --force`.cwd(repo.path).quiet()
await Instance.disposeAll()

const response = await root.app.request("/experimental/session?roots=true&worktrees=true", {
headers: { "x-kilo-directory": repo.path },
})

expect(response.status).toBe(200)
const body = (await response.json()) as Item[]
const item = body.find((session) => session.id === branch.id)

expect(item?.directory).toBe(worktree)
expect(item?.worktreeDirectory).toBe(worktree)
expect(item?.worktreeName).toBe(path.basename(worktree))
} finally {
await $`git worktree remove ${worktree} --force`.cwd(repo.path).quiet().nothrow()
}
})

test("keeps sessions for removed worktrees visible after process restart", async () => {
await using repo = await tmpdir({ git: true })
const worktree = path.join(repo.path, ".kilo", "worktrees", "stale")
const now = Date.now()
const session = SessionID.descending()

await Instance.provide({
directory: repo.path,
fn: async () => {
Database.use((db) => {
db.update(ProjectTable)
.set({ sandboxes: [worktree] })
.where(eq(ProjectTable.id, Instance.project.id))
.run()
db.insert(SessionTable)
.values({
id: session,
project_id: Instance.project.id,
slug: "stale",
directory: worktree,
path: "..",
title: "stale-worktree-session",
version: "test",
time_created: now,
time_updated: now,
})
.run()
})
},
})
await Instance.disposeAll()

const { Server } = await import("../../src/server/server")
const response = await Server.Default().app.request("/experimental/session?roots=true&worktrees=true", {
headers: { "x-kilo-directory": repo.path },
})

expect(response.status).toBe(200)
const body = (await response.json()) as Item[]
const item = body.find((entry) => entry.id === session)

expect(item?.directory).toBe(worktree)
expect(item?.worktreeDirectory).toBe(worktree)
expect(item?.worktreeName).toBe(path.basename(worktree))
})
})
1 change: 1 addition & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2319,6 +2319,7 @@ export type GlobalSession = {
diff?: string
}
project: ProjectSummary | null
worktreeDirectory?: string
worktreeName?: string
}

Expand Down
3 changes: 3 additions & 0 deletions packages/sdk/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -17079,6 +17079,9 @@
}
]
},
"worktreeDirectory": {
"type": "string"
},
"worktreeName": {
"type": "string"
}
Expand Down
Loading