From b19ad25a492aec91583fbfe8036b91dcda9785c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 11:01:48 +0000 Subject: [PATCH 1/6] test(cli): reproduce #30776 thread commands not resolving a project from its git worktree resolveProject only matches a project id or a path under a project's workspaceRoot, so running t3 thread start/list from a T3-managed git worktree of the project's repository fails with ThreadProjectNotFoundError. --- apps/server/src/cli/thread.test.ts | 57 ++++++++++++++++++++++++++++-- apps/server/src/cli/thread.ts | 2 +- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index 1fab7881bcbb5..f146b771ae56a 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -1,8 +1,19 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { RunId, ThreadId } from "@t3tools/contracts"; +import { ProjectId, RunId, ThreadId } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; - -import { readLatestMessages, type ThreadTools, titleFromMessage, waitForRun } from "./thread.ts"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +import { + readLatestMessages, + resolveProject, + type ThreadTools, + titleFromMessage, + waitForRun, +} from "./thread.ts"; const threadId = ThreadId.make("thread-1"); const runId = RunId.make("run-1"); @@ -165,3 +176,43 @@ it("titles from a long first line without splitting an emoji", () => { const title = titleFromMessage(`${"a".repeat(78)}😀 and the rest of a long first line`); assert.strictEqual(title, `${"a".repeat(78)}…`); }); + +const git = Effect.fn("test.git")(function* (cwd: string, args: ReadonlyArray) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const exitCode = yield* spawner.exitCode(ChildProcess.make("git", args, { cwd })); + assert.strictEqual(Number(exitCode), 0, `git ${args.join(" ")}`); +}); + +it.live("resolves a path inside a git worktree of the project's repository to that project", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-thread-worktree-" }); + const repo = path.join(root, "sample"); + const worktree = path.join(root, "worktrees", "sample", "t3-8ff64c24"); + yield* fileSystem.makeDirectory(path.join(repo, "pkg"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(repo, "pkg", "index.ts"), "export {};\n"); + yield* git(repo, ["init", "--quiet", "-b", "main"]); + yield* git(repo, ["add", "."]); + yield* git(repo, [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "commit", + "--quiet", + "-m", + "initial", + ]); + yield* git(repo, ["worktree", "add", "--quiet", "-b", "t3/8ff64c24", worktree]); + const project = { id: ProjectId.make("project-sample"), title: "sample", workspaceRoot: repo }; + const resolveFrom = (workingDirectory: string, identifier: string) => + resolveProject( + scriptedTools({ t3_project_list: [{ projects: [project], nextCursor: null }] }).tools, + identifier, + ).pipe(Effect.provideService(HostProcess.WorkingDirectory, workingDirectory)); + + assert.deepStrictEqual(yield* resolveFrom(worktree, "."), project); + assert.deepStrictEqual(yield* resolveFrom(path.join(worktree, "pkg"), "."), project); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 7bfddda4723a2..aa1e949defe48 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -273,7 +273,7 @@ const listAllProjects = Effect.fn("cli.thread.listAllProjects")(function* (tools }); /** A project id, or a path inside a project's folder (deepest project wins). */ -const resolveProject = Effect.fn("cli.thread.resolveProject")(function* ( +export const resolveProject = Effect.fn("cli.thread.resolveProject")(function* ( tools: ThreadTools, identifier: string, ) { From f53342f05d2f9f834e4395142facda3e8a18025a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 11:06:02 +0000 Subject: [PATCH 2/6] fix(cli): resolve thread projects from a path inside a git worktree `t3 thread start` and `t3 thread list` matched a path only against project workspace roots, so running them from a T3-managed worktree (which lives under the T3 home, outside the project folder) failed with "No T3 Code project matches '.'". When no root contains the path, ask git for the worktree's top level and common git dir and match the same path in the repository's main checkout instead. Paths that already match make no git call; anything git cannot answer still reports the project as not found. Fixes pingdotgg/t3code#30776 AI-assisted; verified with the tests and tools listed above. --- apps/server/src/cli/thread.ts | 58 +++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index aa1e949defe48..3e1896e88bd15 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -29,6 +29,7 @@ import { Argument, Command, Flag, GlobalFlag } from "effect/cli"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerConfig from "../config.ts"; import { mcpToolSession, type McpToolSession } from "../mcp/AcpMcpStdioBridge.ts"; +import * as ProcessRunner from "../processRunner.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; @@ -272,7 +273,34 @@ const listAllProjects = Effect.fn("cli.thread.listAllProjects")(function* (tools return projects; }); -/** A project id, or a path inside a project's folder (deepest project wins). */ +/** + * The same path in the repository's main checkout, when `target` is inside a + * linked git worktree of a non-bare repository. + */ +const mainCheckoutPath = Effect.fn("cli.thread.mainCheckoutPath")(function* (target: string) { + const path = yield* Path.Path; + const processRunner = yield* ProcessRunner.ProcessRunner; + const result = yield* processRunner + .run({ + command: "git", + args: ["rev-parse", "--show-toplevel", "--git-common-dir"], + cwd: target, + }) + .pipe(Effect.option); + if (Option.isNone(result) || result.value.code !== 0) return undefined; + // A newline inside either path breaks the line framing, so that repository never matches. + const lines = result.value.stdout.split(/\r?\n/u); + const [topLevel = "", commonDir = ""] = lines; + if (lines.length !== 3 || lines[2] !== "") return undefined; + const gitDir = path.resolve(target, commonDir); + if (path.basename(gitDir) !== ".git") return undefined; + return path.join(path.dirname(gitDir), path.relative(topLevel, target)); +}); + +/** + * A project id, or a path inside a project's folder (deepest project wins). + * A path inside a git worktree resolves as the same path in the main checkout. + */ export const resolveProject = Effect.fn("cli.thread.resolveProject")(function* ( tools: ThreadTools, identifier: string, @@ -291,19 +319,25 @@ export const resolveProject = Effect.fn("cli.thread.resolveProject")(function* ( const roots = yield* Effect.forEach(projects, (project) => Effect.map(realPath(project.workspaceRoot), (root) => ({ project, root })), ); - const containing = roots - .filter(({ root }) => { - const relative = path.relative(root, target); - return ( - relative === "" || - (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) - ); - }) - .toSorted((left, right) => right.root.length - left.root.length)[0]; - if (containing === undefined) { + const deepestContaining = (candidate: string) => + roots + .filter(({ root }) => { + const relative = path.relative(root, candidate); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); + }) + .toSorted((left, right) => right.root.length - left.root.length)[0]?.project; + + const containing = deepestContaining(target); + if (containing !== undefined) return containing; + const inMainCheckout = yield* mainCheckoutPath(target).pipe(Effect.provide(ProcessRunner.layer)); + const fromWorktree = inMainCheckout === undefined ? undefined : deepestContaining(inMainCheckout); + if (fromWorktree === undefined) { return yield* new ThreadProjectNotFoundError({ identifier }); } - return containing.project; + return fromWorktree; }); /** Every item on a timeline view, oldest first. */ From b38715e660ceaa4fb1c88e918c3f5130bdcc4b32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 11:23:33 +0000 Subject: [PATCH 3/6] fix(cli): resolve worktree projects through every checkout of the repository Mapping a worktree path to the repository's main checkout picked the wrong project when a project is rooted at a linked checkout, and rejected repositories created with --separate-git-dir. When no project contains the path, list the repository's checkouts with `git worktree list --porcelain -z` and look for a project at the same relative path in each other checkout. One match resolves; several matches fail with a usage error that names them and asks for a project id. A project root whose .git file points at the listed main entry stands in for a --separate-git-dir main checkout, which Git lists as its git dir. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/cli/thread.test.ts | 74 ++++++++++++++++++------ apps/server/src/cli/thread.ts | 92 ++++++++++++++++++++---------- docs/user/outside-agents.md | 4 +- 3 files changed, 121 insertions(+), 49 deletions(-) diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index f146b771ae56a..6119887775acb 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -183,6 +183,21 @@ const git = Effect.fn("test.git")(function* (cwd: string, args: ReadonlyArray + Effect.andThen( + git(cwd, ["add", "."]), + git(cwd, [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "commit", + "--quiet", + "-m", + "initial", + ]), + ); + it.live("resolves a path inside a git worktree of the project's repository to that project", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -190,29 +205,56 @@ it.live("resolves a path inside a git worktree of the project's repository to th const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-thread-worktree-" }); const repo = path.join(root, "sample"); const worktree = path.join(root, "worktrees", "sample", "t3-8ff64c24"); + const feature = path.join(root, "wt", "feature"); + const featureWorktree = path.join(root, "worktrees", "feature", "t3-1a2b3c4d"); + const separate = path.join(root, "separate"); + const separateWorktree = path.join(root, "worktrees", "separate", "t3-5e6f7a8b"); yield* fileSystem.makeDirectory(path.join(repo, "pkg"), { recursive: true }); yield* fileSystem.writeFileString(path.join(repo, "pkg", "index.ts"), "export {};\n"); yield* git(repo, ["init", "--quiet", "-b", "main"]); - yield* git(repo, ["add", "."]); - yield* git(repo, [ - "-c", - "user.name=Test", - "-c", - "user.email=test@example.test", - "commit", + yield* commitAll(repo); + yield* git(repo, ["worktree", "add", "--quiet", "-b", "t3/8ff64c24", worktree]); + yield* git(repo, ["worktree", "add", "--quiet", "-b", "feature", feature]); + yield* git(feature, ["worktree", "add", "--quiet", "-b", "t3/1a2b3c4d", featureWorktree]); + yield* fileSystem.makeDirectory(separate); + yield* fileSystem.writeFileString(path.join(separate, "README.md"), "separate\n"); + yield* git(separate, [ + "init", "--quiet", - "-m", - "initial", + "-b", + "main", + `--separate-git-dir=${path.join(root, "separate.git")}`, ]); - yield* git(repo, ["worktree", "add", "--quiet", "-b", "t3/8ff64c24", worktree]); - const project = { id: ProjectId.make("project-sample"), title: "sample", workspaceRoot: repo }; - const resolveFrom = (workingDirectory: string, identifier: string) => + yield* commitAll(separate); + yield* git(separate, ["worktree", "add", "--quiet", "-b", "t3/5e6f7a8b", separateWorktree]); + + const project = (id: string, workspaceRoot: string) => ({ + id: ProjectId.make(id), + title: path.basename(workspaceRoot), + workspaceRoot, + }); + const sample = project("project-sample", repo); + const featureProject = project("project-feature", feature); + const separateProject = project("project-separate", separate); + const resolveFrom = ( + projects: ReadonlyArray>, + workingDirectory: string, + ) => resolveProject( - scriptedTools({ t3_project_list: [{ projects: [project], nextCursor: null }] }).tools, - identifier, + scriptedTools({ t3_project_list: [{ projects, nextCursor: null }] }).tools, + ".", ).pipe(Effect.provideService(HostProcess.WorkingDirectory, workingDirectory)); - assert.deepStrictEqual(yield* resolveFrom(worktree, "."), project); - assert.deepStrictEqual(yield* resolveFrom(path.join(worktree, "pkg"), "."), project); + assert.deepStrictEqual(yield* resolveFrom([sample], worktree), sample); + assert.deepStrictEqual(yield* resolveFrom([sample], path.join(worktree, "pkg")), sample); + assert.deepStrictEqual(yield* resolveFrom([featureProject], featureWorktree), featureProject); + assert.deepStrictEqual( + yield* resolveFrom([separateProject], separateWorktree), + separateProject, + ); + const ambiguous = yield* Effect.flip(resolveFrom([sample, featureProject], featureWorktree)); + assert.strictEqual(ambiguous._tag, "ThreadUsageError"); + assert.include(ambiguous.message, "sample (project-sample)"); + assert.include(ambiguous.message, "feature (project-feature)"); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 3e1896e88bd15..919fd57d05982 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -273,33 +273,25 @@ const listAllProjects = Effect.fn("cli.thread.listAllProjects")(function* (tools return projects; }); -/** - * The same path in the repository's main checkout, when `target` is inside a - * linked git worktree of a non-bare repository. - */ -const mainCheckoutPath = Effect.fn("cli.thread.mainCheckoutPath")(function* (target: string) { - const path = yield* Path.Path; +/** The checkouts of the git repository at `directory`, or none outside a repository. */ +const listGitCheckouts = Effect.fn("cli.thread.listGitCheckouts")(function* (directory: string) { const processRunner = yield* ProcessRunner.ProcessRunner; const result = yield* processRunner - .run({ - command: "git", - args: ["rev-parse", "--show-toplevel", "--git-common-dir"], - cwd: target, - }) + .run({ command: "git", args: ["worktree", "list", "--porcelain", "-z"], cwd: directory }) .pipe(Effect.option); - if (Option.isNone(result) || result.value.code !== 0) return undefined; - // A newline inside either path breaks the line framing, so that repository never matches. - const lines = result.value.stdout.split(/\r?\n/u); - const [topLevel = "", commonDir = ""] = lines; - if (lines.length !== 3 || lines[2] !== "") return undefined; - const gitDir = path.resolve(target, commonDir); - if (path.basename(gitDir) !== ".git") return undefined; - return path.join(path.dirname(gitDir), path.relative(topLevel, target)); + if (Option.isNone(result) || result.value.code !== 0) return []; + return result.value.stdout.split("\0\0").flatMap((record) => { + const [first = "", ...attributes] = record.split("\0"); + return first.startsWith("worktree ") && !attributes.includes("bare") + ? [first.slice("worktree ".length)] + : []; + }); }); /** * A project id, or a path inside a project's folder (deepest project wins). - * A path inside a git worktree resolves as the same path in the main checkout. + * A path inside a git worktree resolves as the same path in the repository's + * other checkouts. */ export const resolveProject = Effect.fn("cli.thread.resolveProject")(function* ( tools: ThreadTools, @@ -319,25 +311,63 @@ export const resolveProject = Effect.fn("cli.thread.resolveProject")(function* ( const roots = yield* Effect.forEach(projects, (project) => Effect.map(realPath(project.workspaceRoot), (root) => ({ project, root })), ); + const contains = (root: string, candidate: string) => { + const relative = path.relative(root, candidate); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); + }; const deepestContaining = (candidate: string) => roots - .filter(({ root }) => { - const relative = path.relative(root, candidate); - return ( - relative === "" || - (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) - ); - }) + .filter(({ root }) => contains(root, candidate)) .toSorted((left, right) => right.root.length - left.root.length)[0]?.project; const containing = deepestContaining(target); if (containing !== undefined) return containing; - const inMainCheckout = yield* mainCheckoutPath(target).pipe(Effect.provide(ProcessRunner.layer)); - const fromWorktree = inMainCheckout === undefined ? undefined : deepestContaining(inMainCheckout); - if (fromWorktree === undefined) { + const listed = yield* listGitCheckouts(target).pipe( + Effect.flatMap((paths) => Effect.forEach(paths, realPath)), + Effect.provide(ProcessRunner.layer), + ); + // Git lists a `--separate-git-dir` main checkout as its git dir; only the + // checkout's own `.git` file says where it is. + const separateMainCheckouts = yield* Effect.forEach(roots, ({ root }) => + fileSystem.readFileString(path.join(root, ".git")).pipe( + Effect.flatMap((gitFile) => + gitFile.startsWith("gitdir: ") + ? realPath(path.resolve(root, gitFile.slice("gitdir: ".length).trim())) + : Effect.succeed(""), + ), + Effect.map((gitDir) => (gitDir === listed[0] ? [root] : [])), + Effect.orElseSucceed(() => []), + ), + ); + const separateMain = separateMainCheckouts.flat(); + const checkouts = separateMain.length > 0 ? [...separateMain, ...listed.slice(1)] : listed; + const ownCheckout = checkouts + .filter((checkout) => contains(checkout, target)) + .toSorted((left, right) => right.length - left.length)[0]; + if (ownCheckout === undefined) { return yield* new ThreadProjectNotFoundError({ identifier }); } - return fromWorktree; + const relative = path.relative(ownCheckout, target); + const matches = new Map(); + for (const checkout of checkouts) { + if (checkout === ownCheckout) continue; + const project = deepestContaining(path.join(checkout, relative)); + if (project !== undefined) matches.set(project.id, project); + } + const [match, ...others] = matches.values(); + if (match === undefined) { + return yield* new ThreadProjectNotFoundError({ identifier }); + } + if (others.length > 0) { + const names = [match, ...others].map((project) => `${project.title} (${project.id})`); + return yield* new ThreadUsageError({ + detail: `'${identifier}' is in a git worktree shared by several projects: ${names.join(", ")}. Pass a project id instead.`, + }); + } + return match; }); /** Every item on a timeline view, oldest first. */ diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index f040f2218058d..fda3b8f54b7aa 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -104,8 +104,8 @@ t3 thread show "$id" t3 thread list ``` -`.` means the project that contains the current folder; a project id also -works. A new thread uses the project's default model and permission mode, and +`.` means the project that contains the current folder, or a git worktree of +its repository; a project id also works. A new thread uses the project's default model and permission mode, and runs in the project folder unless you pass `--worktree `. `send` never interrupts: a busy thread gets the message as its next turn. Pass `-` as the message to read it from stdin. From 89ae1ae656d85eeff4c637bf8bf527129951e6a3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 11:25:20 +0000 Subject: [PATCH 4/6] fix(cli): drop the separate-git-dir main checkout lookup --- apps/server/src/cli/thread.test.ts | 18 ------------------ apps/server/src/cli/thread.ts | 19 ++----------------- docs/user/outside-agents.md | 8 ++++---- 3 files changed, 6 insertions(+), 39 deletions(-) diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index 6119887775acb..0a04a89f9f35c 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -207,8 +207,6 @@ it.live("resolves a path inside a git worktree of the project's repository to th const worktree = path.join(root, "worktrees", "sample", "t3-8ff64c24"); const feature = path.join(root, "wt", "feature"); const featureWorktree = path.join(root, "worktrees", "feature", "t3-1a2b3c4d"); - const separate = path.join(root, "separate"); - const separateWorktree = path.join(root, "worktrees", "separate", "t3-5e6f7a8b"); yield* fileSystem.makeDirectory(path.join(repo, "pkg"), { recursive: true }); yield* fileSystem.writeFileString(path.join(repo, "pkg", "index.ts"), "export {};\n"); yield* git(repo, ["init", "--quiet", "-b", "main"]); @@ -216,17 +214,6 @@ it.live("resolves a path inside a git worktree of the project's repository to th yield* git(repo, ["worktree", "add", "--quiet", "-b", "t3/8ff64c24", worktree]); yield* git(repo, ["worktree", "add", "--quiet", "-b", "feature", feature]); yield* git(feature, ["worktree", "add", "--quiet", "-b", "t3/1a2b3c4d", featureWorktree]); - yield* fileSystem.makeDirectory(separate); - yield* fileSystem.writeFileString(path.join(separate, "README.md"), "separate\n"); - yield* git(separate, [ - "init", - "--quiet", - "-b", - "main", - `--separate-git-dir=${path.join(root, "separate.git")}`, - ]); - yield* commitAll(separate); - yield* git(separate, ["worktree", "add", "--quiet", "-b", "t3/5e6f7a8b", separateWorktree]); const project = (id: string, workspaceRoot: string) => ({ id: ProjectId.make(id), @@ -235,7 +222,6 @@ it.live("resolves a path inside a git worktree of the project's repository to th }); const sample = project("project-sample", repo); const featureProject = project("project-feature", feature); - const separateProject = project("project-separate", separate); const resolveFrom = ( projects: ReadonlyArray>, workingDirectory: string, @@ -248,10 +234,6 @@ it.live("resolves a path inside a git worktree of the project's repository to th assert.deepStrictEqual(yield* resolveFrom([sample], worktree), sample); assert.deepStrictEqual(yield* resolveFrom([sample], path.join(worktree, "pkg")), sample); assert.deepStrictEqual(yield* resolveFrom([featureProject], featureWorktree), featureProject); - assert.deepStrictEqual( - yield* resolveFrom([separateProject], separateWorktree), - separateProject, - ); const ambiguous = yield* Effect.flip(resolveFrom([sample, featureProject], featureWorktree)); assert.strictEqual(ambiguous._tag, "ThreadUsageError"); assert.include(ambiguous.message, "sample (project-sample)"); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 919fd57d05982..1efa66a9e2e5d 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -329,22 +329,7 @@ export const resolveProject = Effect.fn("cli.thread.resolveProject")(function* ( Effect.flatMap((paths) => Effect.forEach(paths, realPath)), Effect.provide(ProcessRunner.layer), ); - // Git lists a `--separate-git-dir` main checkout as its git dir; only the - // checkout's own `.git` file says where it is. - const separateMainCheckouts = yield* Effect.forEach(roots, ({ root }) => - fileSystem.readFileString(path.join(root, ".git")).pipe( - Effect.flatMap((gitFile) => - gitFile.startsWith("gitdir: ") - ? realPath(path.resolve(root, gitFile.slice("gitdir: ".length).trim())) - : Effect.succeed(""), - ), - Effect.map((gitDir) => (gitDir === listed[0] ? [root] : [])), - Effect.orElseSucceed(() => []), - ), - ); - const separateMain = separateMainCheckouts.flat(); - const checkouts = separateMain.length > 0 ? [...separateMain, ...listed.slice(1)] : listed; - const ownCheckout = checkouts + const ownCheckout = listed .filter((checkout) => contains(checkout, target)) .toSorted((left, right) => right.length - left.length)[0]; if (ownCheckout === undefined) { @@ -352,7 +337,7 @@ export const resolveProject = Effect.fn("cli.thread.resolveProject")(function* ( } const relative = path.relative(ownCheckout, target); const matches = new Map(); - for (const checkout of checkouts) { + for (const checkout of listed) { if (checkout === ownCheckout) continue; const project = deepestContaining(path.join(checkout, relative)); if (project !== undefined) matches.set(project.id, project); diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index fda3b8f54b7aa..5a4d274794435 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -105,10 +105,10 @@ t3 thread list ``` `.` means the project that contains the current folder, or a git worktree of -its repository; a project id also works. A new thread uses the project's default model and permission mode, and -runs in the project folder unless you pass `--worktree `. `send` -never interrupts: a busy thread gets the message as its next turn. Pass `-` as -the message to read it from stdin. +its repository; a project id also works. A new thread uses the project's +default model and permission mode, and runs in the project folder unless you +pass `--worktree `. `send` never interrupts: a busy thread gets +the message as its next turn. Pass `-` as the message to read it from stdin. `--wait` prints the agent's reply. It exits 1 when the run fails or stops, and 2 when the agent waits for an answer you give in the app. Add `--json` for a From 2caf72ee148987301d3b521967c00ff29870bf60 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 11:32:46 +0000 Subject: [PATCH 5/6] fix(cli): fall back to line output when git rejects worktree list -z --- apps/server/src/cli/thread.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 1efa66a9e2e5d..37e4f828f488c 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -276,11 +276,18 @@ const listAllProjects = Effect.fn("cli.thread.listAllProjects")(function* (tools /** The checkouts of the git repository at `directory`, or none outside a repository. */ const listGitCheckouts = Effect.fn("cli.thread.listGitCheckouts")(function* (directory: string) { const processRunner = yield* ProcessRunner.ProcessRunner; - const result = yield* processRunner - .run({ command: "git", args: ["worktree", "list", "--porcelain", "-z"], cwd: directory }) - .pipe(Effect.option); - if (Option.isNone(result) || result.value.code !== 0) return []; - return result.value.stdout.split("\0\0").flatMap((record) => { + const list = (args: ReadonlyArray) => + processRunner + .run({ command: "git", args: ["worktree", "list", "--porcelain", ...args], cwd: directory }) + .pipe( + Effect.option, + Effect.map((result) => + Option.isSome(result) && result.value.code === 0 ? result.value.stdout : undefined, + ), + ); + // Pre-2.36 Git rejects worktree list -z; normalize its line output to NUL records. + const stdout = (yield* list(["-z"])) ?? (yield* list([]))?.replaceAll("\n", "\0") ?? ""; + return stdout.split("\0\0").flatMap((record) => { const [first = "", ...attributes] = record.split("\0"); return first.startsWith("worktree ") && !attributes.includes("bare") ? [first.slice("worktree ".length)] From 94a0539e3a0ef5e5b2741c591a49008f4b23b723 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 11:35:09 +0000 Subject: [PATCH 6/6] fix(cli): resolve a file path inside a git worktree --- apps/server/src/cli/thread.test.ts | 4 +++- apps/server/src/cli/thread.ts | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index 0a04a89f9f35c..a55eafec87598 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -225,14 +225,16 @@ it.live("resolves a path inside a git worktree of the project's repository to th const resolveFrom = ( projects: ReadonlyArray>, workingDirectory: string, + identifier = ".", ) => resolveProject( scriptedTools({ t3_project_list: [{ projects, nextCursor: null }] }).tools, - ".", + identifier, ).pipe(Effect.provideService(HostProcess.WorkingDirectory, workingDirectory)); assert.deepStrictEqual(yield* resolveFrom([sample], worktree), sample); assert.deepStrictEqual(yield* resolveFrom([sample], path.join(worktree, "pkg")), sample); + assert.deepStrictEqual(yield* resolveFrom([sample], worktree, "pkg/index.ts"), sample); assert.deepStrictEqual(yield* resolveFrom([featureProject], featureWorktree), featureProject); const ambiguous = yield* Effect.flip(resolveFrom([sample, featureProject], featureWorktree)); assert.strictEqual(ambiguous._tag, "ThreadUsageError"); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 37e4f828f488c..0a1d67b8fa44f 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -332,7 +332,10 @@ export const resolveProject = Effect.fn("cli.thread.resolveProject")(function* ( const containing = deepestContaining(target); if (containing !== undefined) return containing; - const listed = yield* listGitCheckouts(target).pipe( + const targetInfo = yield* fileSystem.stat(target).pipe(Effect.option); + const gitDirectory = + Option.isSome(targetInfo) && targetInfo.value.type === "File" ? path.dirname(target) : target; + const listed = yield* listGitCheckouts(gitDirectory).pipe( Effect.flatMap((paths) => Effect.forEach(paths, realPath)), Effect.provide(ProcessRunner.layer), );