From 2aaa33ce6b815e28e18f842d651ae596bac10b7d Mon Sep 17 00:00:00 2001 From: JunkaiWang-TheoPhy <1181100960@qq.com> Date: Sun, 27 Sep 2026 17:37:06 +0800 Subject: [PATCH] Keep multi-project discovery from delaying project selection A non-Git parent containing several immediate Git projects is used as a catalogue. Limit its discovery to immediate instruction files while retaining recursive discovery in selected projects and existing Git checkouts. Constraint: Preserve selected-project instruction discovery and existing filesystem authorization Confidence: high Scope-risk: narrow Tested: Regression fails with original implementation; all 43 server/workspace tests; TypeScript typecheck Not-tested: Live ChatGPT conversation and Windows runtime Related: https://github.com/Waishnav/devspace/issues/372 --- docs/chatgpt-coding-workflow.md | 6 ++++ src/server.ts | 2 +- src/workspaces.test.ts | 58 +++++++++++++++++++++++++++++++++ src/workspaces.ts | 38 ++++++++++++++++++--- 4 files changed, 99 insertions(+), 5 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 4e001e46c..96406c8f7 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -48,6 +48,12 @@ Do not call `open_workspace` again for the same checkout folder unless: - work switches between checkout and worktree mode - the user asks for a new isolated worktree +When a non-Git parent contains several immediate Git projects, its instruction +discovery stays at the parent and immediate-project level. Use that workspace +to list projects, then open the selected project before operating in its +contents. Opening a concrete Git checkout retains recursive nested instruction +discovery. + ## Checkout Mode Checkout mode is the default. DevSpace opens the actual directory: diff --git a/src/server.ts b/src/server.ts index 0a2792f3f..8bc68956c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -134,7 +134,7 @@ function serverInstructions( ? `When ${toolNames.openWorkspace} returns available skills and a task matches one, use ${toolNames.read} with the returned skill path before proceeding. ` : ""; const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in available_agents_files, use ${toolNames.read} to inspect that instruction file and follow it. `; - const common = `Call ${toolNames.openWorkspace} when starting work in a project folder or isolated worktree without a usable workspace_id, then reuse the returned workspace_id for subsequent operations in that workspace.`; + const common = `Call ${toolNames.openWorkspace} when starting work in a project folder or isolated worktree without a usable workspace_id, then reuse the returned workspace_id for subsequent operations in that workspace. When browsing a parent directory containing several Git projects, open the selected project as its own workspace before operating in its contents so nested instructions are discovered.`; return `${common} ${toolSurface.instructions({ agents, skills })}${artifactInstruction}${showChangesInstruction}`; } diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 914837502..08439d932 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -48,6 +48,64 @@ test("a checkout exposes initial and nested instruction context", async (t) => { }); +test("a multi-project parent exposes immediate instructions and selected projects keep nested context", async (t) => { + const context = await fixture(t); + for (const name of ["one", "two"]) { + const project = join(context.root, name); + await mkdir(join(project, "src"), { recursive: true }); + await execFileAsync("git", ["init", "-q", project]); + await writeFile(join(project, "AGENTS.md"), `${name} instructions\n`); + await writeFile(join(project, "src", "AGENTS.md"), "nested project instructions\n"); + } + + const parent = await context.registry.openWorkspace(context.root); + assert.deepEqual(parent.availableAgentsFiles.map(({ path }) => path), [ + join(context.root, "nested", "AGENTS.md"), + join(context.root, "one", "AGENTS.md"), + join(context.root, "two", "AGENTS.md"), + ]); + assert.deepEqual(parent.agentsFiles.map(({ content }) => content), [ + "global instructions\n", "root instructions\n", + ]); + + const selected = await context.registry.openWorkspace(join(context.root, "one")); + assert.deepEqual(selected.availableAgentsFiles.map(({ path }) => path), [ + join(context.root, "one", "src", "AGENTS.md"), + ]); +}); + +test("a Git checkout with nested repositories retains recursive instruction discovery", async (t) => { + const context = await fixture(t); + await execFileAsync("git", ["init", "-q", context.root]); + for (const name of ["one", "two"]) { + const project = join(context.root, name); + await mkdir(join(project, "src"), { recursive: true }); + await execFileAsync("git", ["init", "-q", project]); + await writeFile(join(project, "src", "AGENTS.md"), "nested instructions\n"); + } + + const opened = await context.registry.openWorkspace(context.root); + assert.deepEqual(opened.availableAgentsFiles.map(({ path }) => path), [ + join(context.root, "nested", "AGENTS.md"), + join(context.root, "one", "src", "AGENTS.md"), + join(context.root, "two", "src", "AGENTS.md"), + ]); +}); + +test("a non-Git directory with only one Git child retains recursive discovery", async (t) => { + const context = await fixture(t); + const project = join(context.root, "one"); + await mkdir(join(project, "src"), { recursive: true }); + await execFileAsync("git", ["init", "-q", project]); + await writeFile(join(project, "src", "CLAUDE.md"), "nested instructions\n"); + + const opened = await context.registry.openWorkspace(context.root); + assert.deepEqual(opened.availableAgentsFiles.map(({ path }) => path), [ + join(context.root, "nested", "AGENTS.md"), + join(context.root, "one", "src", "CLAUDE.md"), + ]); +}); + test("global instruction symlinks may target user-managed files outside agentDir", { skip: platform() === "win32", }, async (t) => { diff --git a/src/workspaces.ts b/src/workspaces.ts index 385e47cf2..b516ffad6 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -7,7 +7,7 @@ import type { WorkspaceSession, WorkspaceStore, } from "./workspace-store.js"; -import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises"; +import { mkdir, opendir, readFile, readdir, realpath, stat } from "node:fs/promises"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent"; import type { ServerConfig } from "./config.js"; @@ -627,7 +627,7 @@ export class WorkspaceRegistry { if (realPath && loadedRealPaths.has(realPath)) return; discovered.push({ path }); - }); + }, (await isMultiProjectParent(root)) ? 1 : Infinity); return discovered.sort((a, b) => a.path.localeCompare(b.path)); } @@ -723,9 +723,39 @@ async function tryRealpath(path: string): Promise { } } +// A non-Git parent with several direct Git children is a project catalogue. +// Selected checkouts still discover all their nested instruction files. +async function isMultiProjectParent(root: string): Promise { + try { + await stat(join(root, ".git")); + return false; + } catch (error) { + if (!isErrnoException(error) || error.code !== "ENOENT") return false; + } + + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch { + return false; + } + let projects = 0; + for (const entry of entries) { + if (!entry.isDirectory() || SKIPPED_CONTEXT_DIRS.has(entry.name)) continue; + try { + await stat(join(root, entry.name, ".git")); + if (++projects >= 2) return true; + } catch { + // No accessible Git marker for this immediate child. + } + } + return false; +} + async function walkWorkspace( directory: string, visit: (path: string, entry: { name: string; isFile(): boolean; isDirectory(): boolean }) => Promise | void, + remainingDepth = Infinity, ): Promise { let entries; try { @@ -737,8 +767,8 @@ async function walkWorkspace( for await (const entry of entries) { const path = join(directory, entry.name); if (entry.isDirectory()) { - if (!SKIPPED_CONTEXT_DIRS.has(entry.name)) { - await walkWorkspace(path, visit); + if (remainingDepth > 0 && !SKIPPED_CONTEXT_DIRS.has(entry.name)) { + await walkWorkspace(path, visit, remainingDepth - 1); } continue; }