mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
fix: include sibling worktree sessions when project ids drift
Resolve all-worktree session lookups by git worktree family instead of a single project row so /sessions, Ctrl+A, and local recall still find sibling worktrees without leaking unrelated directories.
This commit is contained in:
@@ -29,12 +29,11 @@ export function DialogSessionList() {
|
||||
async ({ query, global: all }) => {
|
||||
if (!query && !all) return undefined
|
||||
if (all) {
|
||||
const project = await sdk.client.project.current({}, { throwOnError: true })
|
||||
const result = await sdk.client.experimental.session.list(
|
||||
{
|
||||
projectID: project.data?.id,
|
||||
search: query || undefined,
|
||||
roots: true,
|
||||
worktrees: true,
|
||||
limit: 30,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// kilocode_change - new file
|
||||
import { Instance } from "../project/instance"
|
||||
import { Project } from "../project/project"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { git } from "../util/git"
|
||||
|
||||
export namespace WorktreeFamily {
|
||||
export async function list() {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
return [Filesystem.resolve(Instance.directory)]
|
||||
}
|
||||
|
||||
const listed = await git(["worktree", "list", "--porcelain"], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
|
||||
if (listed.exitCode === 0) {
|
||||
const dirs = listed
|
||||
.text()
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.flatMap((line) => {
|
||||
if (!line.startsWith("worktree ")) return []
|
||||
return [Filesystem.resolve(line.slice("worktree ".length).trim())]
|
||||
})
|
||||
|
||||
if (dirs.length > 0) {
|
||||
return [...new Set(dirs)]
|
||||
}
|
||||
}
|
||||
|
||||
const dirs = [Instance.worktree, ...(await Project.sandboxes(Instance.project.id))]
|
||||
return [...new Set(dirs.map((dir) => Filesystem.resolve(dir)))]
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { lazy } from "../../util/lazy"
|
||||
import { Snapshot } from "../../snapshot" // kilocode_change
|
||||
import { Review } from "../../kilocode/review/review" // kilocode_change
|
||||
import { WorktreeDiff } from "../../kilocode/review/worktree-diff" // kilocode_change
|
||||
import { WorktreeFamily } from "../../kilocode/worktree-family" // kilocode_change
|
||||
import { Log } from "../../util/log" // kilocode_change
|
||||
import { WorkspaceRoutes } from "./workspace"
|
||||
|
||||
@@ -329,6 +330,10 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
// kilocode_change
|
||||
projectID: z.string().optional().meta({ description: "Filter sessions by project ID" }),
|
||||
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
|
||||
worktrees: z.coerce
|
||||
.boolean()
|
||||
.optional()
|
||||
.meta({ description: "Restrict sessions to the current repo worktree family or current directory" }),
|
||||
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
|
||||
start: z.coerce
|
||||
.number()
|
||||
@@ -346,10 +351,16 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const limit = query.limit ?? 100
|
||||
const projectID =
|
||||
query.worktrees && !query.projectID
|
||||
? Instance.project.id // kilocode_change
|
||||
: query.projectID
|
||||
const directories = query.worktrees ? await WorktreeFamily.list() : undefined // kilocode_change
|
||||
const sessions: Session.GlobalInfo[] = []
|
||||
for await (const session of Session.listGlobal({
|
||||
projectID: query.projectID, // kilocode_change
|
||||
projectID, // kilocode_change
|
||||
directory: query.directory,
|
||||
directories, // kilocode_change
|
||||
roots: query.roots,
|
||||
start: query.start,
|
||||
cursor: query.cursor,
|
||||
|
||||
@@ -117,6 +117,25 @@ export namespace Session {
|
||||
return `${title} (fork #1)`
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
function family(id: string) {
|
||||
const row = Database.use((db) =>
|
||||
db.select({ worktree: ProjectTable.worktree }).from(ProjectTable).where(eq(ProjectTable.id, id)).get(),
|
||||
)
|
||||
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]
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
id: Identifier.schema("session"),
|
||||
@@ -624,6 +643,7 @@ export namespace Session {
|
||||
export function* listGlobal(input?: {
|
||||
projectID?: string
|
||||
directory?: string
|
||||
directories?: string[]
|
||||
roots?: boolean
|
||||
start?: number
|
||||
cursor?: number
|
||||
@@ -635,7 +655,12 @@ export namespace Session {
|
||||
|
||||
// kilocode_change start
|
||||
if (input?.projectID) {
|
||||
conditions.push(eq(SessionTable.project_id, input.projectID))
|
||||
const ids = family(input.projectID)
|
||||
if (ids.length === 1 && ids[0] === input.projectID) {
|
||||
conditions.push(eq(SessionTable.project_id, input.projectID))
|
||||
} else {
|
||||
conditions.push(inArray(SessionTable.project_id, ids))
|
||||
}
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
@@ -661,6 +686,7 @@ export namespace Session {
|
||||
}
|
||||
|
||||
const limit = input?.limit ?? 100
|
||||
const dirs = [...new Set((input?.directories ?? []).map((dir) => Filesystem.resolve(dir)))]
|
||||
|
||||
const rows = Database.use((db) => {
|
||||
const query =
|
||||
@@ -670,10 +696,19 @@ export namespace Session {
|
||||
.from(SessionTable)
|
||||
.where(and(...conditions))
|
||||
: db.select().from(SessionTable)
|
||||
return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all()
|
||||
const sorted = query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id))
|
||||
return dirs.length ? sorted.all() : sorted.limit(limit).all()
|
||||
})
|
||||
|
||||
const ids = [...new Set(rows.map((row) => row.project_id))]
|
||||
const list =
|
||||
dirs.length > 0
|
||||
? rows.filter((row) => {
|
||||
const dir = Filesystem.resolve(row.directory)
|
||||
return dirs.some((root) => Filesystem.contains(root, dir))
|
||||
})
|
||||
: rows
|
||||
|
||||
const ids = [...new Set(list.slice(0, limit).map((row) => row.project_id))]
|
||||
const projects = new Map<string, ProjectInfo>()
|
||||
|
||||
if (ids.length > 0) {
|
||||
@@ -693,7 +728,7 @@ export namespace Session {
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
for (const row of list.slice(0, limit)) {
|
||||
const project = projects.get(row.project_id) ?? null
|
||||
yield { ...fromRow(row), project }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Locale } from "../util/locale"
|
||||
import { Filesystem } from "../util/filesystem" // kilocode_change
|
||||
import { WorktreeFamily } from "../kilocode/worktree-family" // kilocode_change
|
||||
import DESCRIPTION from "./recall.txt"
|
||||
|
||||
export const RecallTool = Tool.define("kilo_local_recall", {
|
||||
@@ -37,7 +39,7 @@ async function search(params: { query?: string; limit?: number }, ctx: Tool.Cont
|
||||
})
|
||||
|
||||
const limit = Math.min(params.limit ?? 20, 50)
|
||||
const current = Instance.project.id
|
||||
const dirs = await WorktreeFamily.list() // kilocode_change
|
||||
const { Session } = await import("../session/index") // kilocode_change
|
||||
|
||||
const results: Array<{
|
||||
@@ -48,7 +50,8 @@ async function search(params: { query?: string; limit?: number }, ctx: Tool.Cont
|
||||
}> = []
|
||||
|
||||
for (const session of Session.listGlobal({
|
||||
projectID: current, // kilocode_change
|
||||
projectID: Instance.project.id, // kilocode_change
|
||||
directories: dirs, // kilocode_change
|
||||
search: params.query,
|
||||
roots: true,
|
||||
limit,
|
||||
@@ -87,10 +90,12 @@ async function read(params: { sessionID?: string }, ctx: Tool.Context) {
|
||||
const session = await Session.get(params.sessionID).catch(() => {
|
||||
throw new Error(`Session "${params.sessionID}" not found. Use search mode first to find valid session IDs.`)
|
||||
})
|
||||
const dirs = await WorktreeFamily.list() // kilocode_change
|
||||
// kilocode_change start
|
||||
if (session.projectID !== Instance.project.id) {
|
||||
const dir = Filesystem.resolve(session.directory)
|
||||
if (!dirs.some((root) => Filesystem.contains(root, dir))) {
|
||||
throw new Error(
|
||||
`Session "${params.sessionID}" belongs to a different project and cannot be read from this workspace.`,
|
||||
`Session "${params.sessionID}" belongs to a different workspace and cannot be read from this directory.`,
|
||||
)
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
@@ -25,13 +25,14 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("experimental.session.list", () => {
|
||||
test("filters sessions by project ID across worktrees", async () => {
|
||||
test("filters sessions by repo worktree family even when project IDs drift", async () => {
|
||||
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")
|
||||
|
||||
try {
|
||||
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
|
||||
await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id")
|
||||
|
||||
const share = Config.get
|
||||
Config.get = async () => ({ share: "manual" }) as Awaited<ReturnType<typeof Config.get>>
|
||||
@@ -63,7 +64,7 @@ describe("experimental.session.list", () => {
|
||||
const app = root.app
|
||||
const project = await root.project.json()
|
||||
const response = await app.request(
|
||||
`/experimental/session?projectID=${encodeURIComponent(project.id)}&roots=true`,
|
||||
`/experimental/session?projectID=${encodeURIComponent(project.id)}&roots=true&worktrees=true`,
|
||||
{
|
||||
headers: { "x-kilo-directory": first.path },
|
||||
},
|
||||
@@ -74,6 +75,8 @@ describe("experimental.session.list", () => {
|
||||
const ids = body.map((item: { id: string }) => item.id)
|
||||
const dirs = body.map((item: { directory: string }) => item.directory)
|
||||
|
||||
expect(root.session.projectID).not.toBe(branch.projectID)
|
||||
expect(project.id).toBe(root.session.projectID)
|
||||
expect(ids).toContain(root.session.id)
|
||||
expect(ids).toContain(branch.id)
|
||||
expect(dirs).toContain(worktree)
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("Session.listGlobal", () => {
|
||||
expect(ids).not.toContain(second.id)
|
||||
})
|
||||
|
||||
test("filters by project ID across worktrees", async () => {
|
||||
test("filters by project family across worktrees when project IDs drift", async () => {
|
||||
const { Session } = await import("../../src/session/index")
|
||||
await using first = await tmpdir({ git: true })
|
||||
await using second = await tmpdir({ git: true })
|
||||
@@ -115,6 +115,7 @@ describe("Session.listGlobal", () => {
|
||||
|
||||
try {
|
||||
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
|
||||
await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id")
|
||||
|
||||
const root = await Instance.provide({
|
||||
directory: first.path,
|
||||
@@ -132,6 +133,7 @@ describe("Session.listGlobal", () => {
|
||||
const sessions = [...Session.listGlobal({ projectID: root.projectID, roots: true, limit: 200 })]
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(root.projectID).not.toBe(branch.projectID)
|
||||
expect(ids).toContain(root.id)
|
||||
expect(ids).toContain(branch.id)
|
||||
expect(ids).not.toContain(other.id)
|
||||
|
||||
@@ -42,6 +42,7 @@ describe("tool.recall", () => {
|
||||
|
||||
try {
|
||||
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
|
||||
await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id")
|
||||
|
||||
const share = Config.get
|
||||
Config.get = async () => ({ share: "manual" }) as Awaited<ReturnType<typeof Config.get>>
|
||||
@@ -103,9 +104,44 @@ describe("tool.recall", () => {
|
||||
})
|
||||
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect((err as Error).message).toContain("belongs to a different project")
|
||||
expect((err as Error).message).toContain("belongs to a different workspace")
|
||||
} finally {
|
||||
Config.get = share
|
||||
}
|
||||
})
|
||||
|
||||
test("read allows sessions from sibling worktrees when project IDs drift", async () => {
|
||||
await using first = await tmpdir({ git: true })
|
||||
const worktree = path.join(first.path, "..", path.basename(first.path) + "-worktree")
|
||||
|
||||
try {
|
||||
await $`git worktree add ${worktree} -b test-branch-${Date.now()}`.cwd(first.path).quiet()
|
||||
await Bun.write(path.join(first.path, ".git", "opencode"), "stale-project-id")
|
||||
|
||||
const share = Config.get
|
||||
Config.get = async () => ({ share: "manual" }) as Awaited<ReturnType<typeof Config.get>>
|
||||
|
||||
try {
|
||||
const { Session } = await import("../../src/session/index")
|
||||
const session = await Instance.provide({
|
||||
directory: worktree,
|
||||
fn: async () => Session.create({ title: "worktree readable" }),
|
||||
})
|
||||
|
||||
const result = await Instance.provide({
|
||||
directory: first.path,
|
||||
fn: async () => {
|
||||
const tool = await RecallTool.init()
|
||||
return tool.execute({ mode: "read", sessionID: session.id }, ctx)
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.output).toContain("# Session: worktree readable")
|
||||
} finally {
|
||||
Config.get = share
|
||||
}
|
||||
} finally {
|
||||
await $`git worktree remove ${worktree}`.cwd(first.path).quiet().nothrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1066,6 +1066,7 @@ export class Session extends HeyApiClient {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
projectID?: string
|
||||
worktrees?: boolean
|
||||
roots?: boolean
|
||||
start?: number
|
||||
cursor?: number
|
||||
@@ -1083,6 +1084,7 @@ export class Session extends HeyApiClient {
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "query", key: "projectID" },
|
||||
{ in: "query", key: "worktrees" },
|
||||
{ in: "query", key: "roots" },
|
||||
{ in: "query", key: "start" },
|
||||
{ in: "query", key: "cursor" },
|
||||
|
||||
@@ -2847,6 +2847,10 @@ export type ExperimentalSessionListData = {
|
||||
* Filter sessions by project ID
|
||||
*/
|
||||
projectID?: string
|
||||
/**
|
||||
* Restrict sessions to the current repo worktree family or current directory
|
||||
*/
|
||||
worktrees?: boolean
|
||||
/**
|
||||
* Only return root sessions (no parentID)
|
||||
*/
|
||||
|
||||
+390
-1719
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user