From 243dd6468a464e70218b8fb5d4e9537335fc1e06 Mon Sep 17 00:00:00 2001 From: n4mr3g Date: Sun, 11 Oct 2026 13:41:13 +0200 Subject: [PATCH 1/3] fix(server): import agent sessions from a project's linked worktrees Onboarding stopped offering linked worktrees in #10493 because their history belongs to the main checkout, but the import for the main checkout only accepts sessions whose cwd is the project root. Sessions that ran in a worktree were dropped entirely. Claude Code desktop runs every session in a worktree under .claude/worktrees, so most of that history never reached T3 Code. The import now also accepts sessions from linked worktrees whose commondir resolves to the project's .git directory. Imported threads keep the worktree path and branch, so resuming runs the provider in the checkout it started in. T3-managed worktrees stay excluded. Co-Authored-By: Claude Opus 5.5 --- .../src/project/AgentSessionImporter.test.ts | 3 + .../src/project/AgentSessionImporter.ts | 4 +- .../src/project/AgentSessionScanner.test.ts | 75 +++++++++++++++++ .../server/src/project/AgentSessionScanner.ts | 82 ++++++++++++++++--- 4 files changed, 152 insertions(+), 12 deletions(-) diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 3a1ab5d0a7e6..427992c396b7 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -57,6 +57,7 @@ it.effect("imports messages once and preserves the provider native resume bindin { role: "assistant", text: "Fixed", createdAt: "2026-09-01T10:01:00.000Z" }, ], }, + worktree: { path: "/workspace/project-feature", branch: "feature" }, }), }); const layerTest = AgentSessionImporter.layer.pipe( @@ -121,6 +122,8 @@ it.effect("imports messages once and preserves the provider native resume bindin id: threadId, activeProviderThreadId: providerThread?.payload.id, historyOrigin: "v1_import", + branch: "feature", + worktreePath: "/workspace/project-feature", }); expect(providerThread?.payload).toMatchObject({ appThreadId: threadId, diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 2c8d6a49d4c7..1f9ed5753f79 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -283,8 +283,8 @@ const make = Effect.gen(function* () { modelSelection: { instanceId: thread.providerInstanceId, model }, runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - branch: null, - worktreePath: null, + branch: outcome.worktree?.branch ?? null, + worktreePath: outcome.worktree?.path ?? null, linkedPullRequest: null, branchPullRequest: null, activeProviderThreadId: providerThreadId, diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 6be4c661e72a..598bfc87028e 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -2415,6 +2415,81 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("imports sessions from linked worktrees of the project with their branch", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const repo = yield* makeTempDir("t3code-workspace-repo-"); + const otherRepo = yield* makeTempDir("t3code-workspace-other-"); + + const addWorktree = Effect.fn("addWorktree")(function* (input: { + readonly repo: string; + readonly directory: string; + readonly name: string; + }) { + const gitDir = path.join(input.repo, ".git", "worktrees", input.name); + yield* fileSystem.makeDirectory(gitDir, { recursive: true }); + yield* fileSystem.makeDirectory(input.directory, { recursive: true }); + yield* fileSystem.writeFileString(path.join(gitDir, "commondir"), "../..\n"); + yield* fileSystem.writeFileString( + path.join(gitDir, "HEAD"), + `ref: refs/heads/${input.name}\n`, + ); + yield* fileSystem.writeFileString( + path.join(input.directory, ".git"), + `gitdir: ${gitDir}\n`, + ); + }); + const worktree = path.join(repo, ".claude", "worktrees", "feature"); + const managedWorktree = path.join(configBaseDir, "worktrees", "repo", "managed"); + const otherWorktree = path.join(otherRepo, "elsewhere"); + yield* addWorktree({ repo, directory: worktree, name: "feature" }); + yield* addWorktree({ repo, directory: managedWorktree, name: "managed" }); + yield* addWorktree({ repo: otherRepo, directory: otherWorktree, name: "elsewhere" }); + + for (const [index, [sessionId, cwd]] of [ + ["root-session", repo], + ["worktree-session", worktree], + ["managed-session", managedWorktree], + ["other-session", otherWorktree], + ].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, `${sessionId}.jsonl`), + contents: `${JSON.stringify({ + type: "user", + cwd, + sessionId, + message: { role: "user", content: "Fix the project" }, + })}\n`, + mtimeMs: nowMs - (index + 1) * 60 * 1000, + }); + } + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + configBaseDir, + workspaceRoot: repo, + }); + + expect( + outcomes.flatMap((outcome) => + outcome._tag === "Importable" + ? [{ sessionId: outcome.thread.providerSessionId, worktree: outcome.worktree }] + : [], + ), + ).toEqual([ + { sessionId: "root-session", worktree: null }, + { sessionId: "worktree-session", worktree: { path: worktree, branch: "feature" } }, + ]); + }), + ); + it.effect("uses one deterministic provider instance for a shared session home", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 454971a75ab8..02ec20fec0a5 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -168,11 +168,18 @@ export interface AgentSessionThread { readonly messages: ReadonlyArray; } +/** A linked worktree of the project's repository that a session ran in. */ +export interface AgentSessionWorktree { + readonly path: string; + readonly branch: string | null; +} + export type AgentSessionRecentThread = | { readonly _tag: "Importable"; readonly thread: AgentSessionThread; readonly source: AgentSessionImportSource; + readonly worktree: AgentSessionWorktree | null; } | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource } | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource } @@ -690,7 +697,7 @@ export const make = Effect.gen(function* () { * directly instead of spawning git so a scan over hundreds of candidates * stays cheap. A `.git` file is a `gitdir:` pointer. When it points into a * `worktrees/` directory the checkout is a linked worktree, which - * onboarding skips because its history belongs to the main checkout. + * onboarding skips because its history imports with the main checkout. * Submodules use the same pointer shape but live under `modules/`, and * are offered like any other repository. */ @@ -698,7 +705,7 @@ export const make = Effect.gen(function* () { directory: string, ): Effect.fn.Return< | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null } - | { readonly _tag: "Worktree" } + | { readonly _tag: "Worktree"; readonly gitDir: string } | { readonly _tag: "NotGit" } > { const gitPath = path.join(directory, ".git"); @@ -712,7 +719,9 @@ export const make = Effect.gen(function* () { const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const; gitDir = path.resolve(directory, target); - if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) return { _tag: "Worktree" } as const; + if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) { + return { _tag: "Worktree", gitDir } as const; + } } const configText = yield* fileSystem .readFileString(path.join(gitDir, "config")) @@ -727,6 +736,50 @@ export const make = Effect.gen(function* () { } as const; }); + /** + * Whether a session directory is the project root or a linked worktree of + * the root's repository. T3-managed worktrees stay excluded because their + * threads already exist. + */ + const matchProjectCheckout = Effect.fn("AgentSessionScanner.matchProjectCheckout")(function* ( + directory: string, + project: { readonly identity: string; readonly gitDirIdentity: string | null }, + ): Effect.fn.Return< + | { readonly _tag: "Root" } + | { readonly _tag: "Worktree"; readonly worktree: AgentSessionWorktree } + | { readonly _tag: "Unrelated" } + > { + const resolved = path.resolve(directory); + if ((yield* directoryIdentity(resolved)) === project.identity) return { _tag: "Root" } as const; + if (project.gitDirIdentity === null) return { _tag: "Unrelated" } as const; + const gitIdentity = yield* readGitIdentity(resolved); + if (gitIdentity._tag !== "Worktree") return { _tag: "Unrelated" } as const; + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + if (isExcludedProjectPath(resolved) || isExcludedProjectPath(realPath)) { + return { _tag: "Unrelated" } as const; + } + const readGitFile = (name: string) => + fileSystem.readFileString(path.join(gitIdentity.gitDir, name)).pipe( + Effect.map((text) => text.trim()), + Effect.orElseSucceed(() => ""), + ); + const commonDir = yield* readGitFile("commondir"); + if ( + commonDir === "" || + (yield* directoryIdentity(path.resolve(gitIdentity.gitDir, commonDir))) !== + project.gitDirIdentity + ) { + return { _tag: "Unrelated" } as const; + } + const head = yield* readGitFile("HEAD"); + return { + _tag: "Worktree", + worktree: { path: resolved, branch: /^ref: refs\/heads\/(.+)$/.exec(head)?.[1] ?? null }, + } as const; + }); + // A large history snapshot can precede session metadata. Read bounded // chunks until a complete record names its cwd or the safety budget ends. const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( @@ -1331,7 +1384,15 @@ export const make = Effect.gen(function* () { const root = path.resolve(expandHomePath(workspaceRoot, homeDir)); const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty; - const rootIdentity = yield* directoryIdentity(root); + const rootGitDir = path.join(root, ".git"); + const rootGitDirStats = yield* statOption(rootGitDir); + const project = { + identity: yield* directoryIdentity(root), + gitDirIdentity: + Option.isSome(rootGitDirStats) && rootGitDirStats.value.type === "Directory" + ? yield* directoryIdentity(rootGitDir, rootGitDirStats.value) + : null, + }; const nowMs = DateTime.toEpochMillis(yield* DateTime.now); const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS; @@ -1345,8 +1406,7 @@ export const make = Effect.gen(function* () { for (const candidate of candidates) { const expanded = expandHomePath(candidate.cwd.trim(), homeDir); if (!path.isAbsolute(expanded)) continue; - const resolved = path.resolve(expanded); - if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue; + if ((yield* matchProjectCheckout(expanded, project))._tag === "Unrelated") continue; for (const transcript of candidate.transcripts) { if ( @@ -1440,10 +1500,11 @@ export const make = Effect.gen(function* () { return Option.some({ _tag: "Skipped" }); } const expandedCwd = expandHomePath(snapshotCwd.trim(), homeDir); - if ( - !path.isAbsolute(expandedCwd) || - (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity - ) { + if (!path.isAbsolute(expandedCwd)) { + return Option.some({ _tag: "Skipped" }); + } + const checkout = yield* matchProjectCheckout(expandedCwd, project); + if (checkout._tag === "Unrelated") { return Option.some({ _tag: "Skipped" }); } @@ -1475,6 +1536,7 @@ export const make = Effect.gen(function* () { _tag: "Importable", thread: parsedThread, source, + worktree: checkout._tag === "Worktree" ? checkout.worktree : null, }); }).pipe(importReadLock.withPermits(1)), ), From 43174b5688a6c01175b94712e820e6633b0a7d1b Mon Sep 17 00:00:00 2001 From: n4mr3g Date: Sun, 11 Oct 2026 14:01:42 +0200 Subject: [PATCH 2/3] fix(server): match worktrees when the project's .git is a gitdir pointer A submodule or a --separate-git-dir checkout has a .git file, so the root's git dir identity was null and its linked worktrees never matched. readGitIdentity already resolves the pointer; reuse its git dir. Co-Authored-By: Claude Opus 5.5 --- .../src/project/AgentSessionScanner.test.ts | 143 ++++++++++++------ .../server/src/project/AgentSessionScanner.ts | 14 +- 2 files changed, 108 insertions(+), 49 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 598bfc87028e..a26bbc0689d2 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -124,6 +124,55 @@ const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(fu yield* fileSystem.utimes(input.filePath, seconds, seconds); }); +/** Lays out a linked worktree the way `git worktree add` does, without running git. */ +const addLinkedWorktree = Effect.fn("AgentSessionScanner.test.addLinkedWorktree")( + function* (input: { + readonly commonGitDir: string; + readonly directory: string; + readonly branch: string; + }) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const gitDir = path.join(input.commonGitDir, "worktrees", input.branch); + yield* fileSystem.makeDirectory(gitDir, { recursive: true }); + yield* fileSystem.makeDirectory(input.directory, { recursive: true }); + yield* fileSystem.writeFileString(path.join(gitDir, "commondir"), "../..\n"); + yield* fileSystem.writeFileString( + path.join(gitDir, "HEAD"), + `ref: refs/heads/${input.branch}\n`, + ); + yield* fileSystem.writeFileString(path.join(input.directory, ".git"), `gitdir: ${gitDir}\n`); + }, +); + +const writeClaudeSession = Effect.fn("AgentSessionScanner.test.writeClaudeSession")( + function* (input: { + readonly claudeHomePath: string; + readonly sessionId: string; + readonly cwd: string; + readonly mtimeMs: number; + }) { + const path = yield* Path.Path; + yield* writeTranscript({ + filePath: path.join(input.claudeHomePath, "projects", input.sessionId, "session.jsonl"), + contents: `${JSON.stringify({ + type: "user", + cwd: input.cwd, + sessionId: input.sessionId, + message: { role: "user", content: "Fix the project" }, + })}\n`, + mtimeMs: input.mtimeMs, + }); + }, +); + +const importedWorktrees = (outcomes: ReadonlyArray) => + outcomes.flatMap((outcome) => + outcome._tag === "Importable" + ? [{ sessionId: outcome.thread.providerSessionId, worktree: outcome.worktree }] + : [], + ); + /** Claude session line: the first record carries the real `cwd`. */ const claudeSessionLine = (cwd: string) => `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`; @@ -2418,7 +2467,6 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { it.effect("imports sessions from linked worktrees of the project with their branch", () => Effect.gen(function* () { const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); yield* TestClock.setTime(nowMs); const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); @@ -2427,45 +2475,27 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { const repo = yield* makeTempDir("t3code-workspace-repo-"); const otherRepo = yield* makeTempDir("t3code-workspace-other-"); - const addWorktree = Effect.fn("addWorktree")(function* (input: { - readonly repo: string; - readonly directory: string; - readonly name: string; - }) { - const gitDir = path.join(input.repo, ".git", "worktrees", input.name); - yield* fileSystem.makeDirectory(gitDir, { recursive: true }); - yield* fileSystem.makeDirectory(input.directory, { recursive: true }); - yield* fileSystem.writeFileString(path.join(gitDir, "commondir"), "../..\n"); - yield* fileSystem.writeFileString( - path.join(gitDir, "HEAD"), - `ref: refs/heads/${input.name}\n`, - ); - yield* fileSystem.writeFileString( - path.join(input.directory, ".git"), - `gitdir: ${gitDir}\n`, - ); - }); const worktree = path.join(repo, ".claude", "worktrees", "feature"); const managedWorktree = path.join(configBaseDir, "worktrees", "repo", "managed"); const otherWorktree = path.join(otherRepo, "elsewhere"); - yield* addWorktree({ repo, directory: worktree, name: "feature" }); - yield* addWorktree({ repo, directory: managedWorktree, name: "managed" }); - yield* addWorktree({ repo: otherRepo, directory: otherWorktree, name: "elsewhere" }); - - for (const [index, [sessionId, cwd]] of [ - ["root-session", repo], - ["worktree-session", worktree], - ["managed-session", managedWorktree], - ["other-session", otherWorktree], + const commonGitDir = path.join(repo, ".git"); + yield* addLinkedWorktree({ commonGitDir, directory: worktree, branch: "feature" }); + yield* addLinkedWorktree({ commonGitDir, directory: managedWorktree, branch: "managed" }); + yield* addLinkedWorktree({ + commonGitDir: path.join(otherRepo, ".git"), + directory: otherWorktree, + branch: "elsewhere", + }); + + for (const [index, session] of [ + { sessionId: "root-session", cwd: repo }, + { sessionId: "worktree-session", cwd: worktree }, + { sessionId: "managed-session", cwd: managedWorktree }, + { sessionId: "other-session", cwd: otherWorktree }, ].entries()) { - yield* writeTranscript({ - filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, `${sessionId}.jsonl`), - contents: `${JSON.stringify({ - type: "user", - cwd, - sessionId, - message: { role: "user", content: "Fix the project" }, - })}\n`, + yield* writeClaudeSession({ + ...session, + claudeHomePath, mtimeMs: nowMs - (index + 1) * 60 * 1000, }); } @@ -2477,19 +2507,46 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { workspaceRoot: repo, }); - expect( - outcomes.flatMap((outcome) => - outcome._tag === "Importable" - ? [{ sessionId: outcome.thread.providerSessionId, worktree: outcome.worktree }] - : [], - ), - ).toEqual([ + expect(importedWorktrees(outcomes)).toEqual([ { sessionId: "root-session", worktree: null }, { sessionId: "worktree-session", worktree: { path: worktree, branch: "feature" } }, ]); }), ); + it.effect("imports linked worktree sessions when the project's .git is a pointer", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const repo = yield* makeTempDir("t3code-workspace-repo-"); + const commonGitDir = yield* makeTempDir("t3code-separate-git-dir-"); + yield* fileSystem.writeFileString(path.join(repo, ".git"), `gitdir: ${commonGitDir}\n`); + + const worktree = path.join(repo, ".claude", "worktrees", "feature"); + yield* addLinkedWorktree({ commonGitDir, directory: worktree, branch: "feature" }); + yield* writeClaudeSession({ + claudeHomePath, + sessionId: "worktree-session", + cwd: worktree, + mtimeMs: nowMs, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: repo, + }); + + expect(importedWorktrees(outcomes)).toEqual([ + { sessionId: "worktree-session", worktree: { path: worktree, branch: "feature" } }, + ]); + }), + ); + it.effect("uses one deterministic provider instance for a shared session home", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 02ec20fec0a5..40d1d420a19b 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -704,7 +704,11 @@ export const make = Effect.gen(function* () { const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* ( directory: string, ): Effect.fn.Return< - | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null } + | { + readonly _tag: "Repository"; + readonly gitDir: string; + readonly git: AgentSessionProjectGit | null; + } | { readonly _tag: "Worktree"; readonly gitDir: string } | { readonly _tag: "NotGit" } > { @@ -729,6 +733,7 @@ export const make = Effect.gen(function* () { const originUrl = parseOriginUrlFromGitConfig(configText); return { _tag: "Repository", + gitDir, git: { remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl), repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl), @@ -1384,14 +1389,11 @@ export const make = Effect.gen(function* () { const root = path.resolve(expandHomePath(workspaceRoot, homeDir)); const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty; - const rootGitDir = path.join(root, ".git"); - const rootGitDirStats = yield* statOption(rootGitDir); + const rootGit = yield* readGitIdentity(root); const project = { identity: yield* directoryIdentity(root), gitDirIdentity: - Option.isSome(rootGitDirStats) && rootGitDirStats.value.type === "Directory" - ? yield* directoryIdentity(rootGitDir, rootGitDirStats.value) - : null, + rootGit._tag === "Repository" ? yield* directoryIdentity(rootGit.gitDir) : null, }; const nowMs = DateTime.toEpochMillis(yield* DateTime.now); const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS; From f2915da23372976d3925491057e9ae5442dbbbe3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 15:48:11 +0000 Subject: [PATCH 3/3] fix(server): preserve linked checkout sessions during import --- .../src/project/AgentSessionScanner.test.ts | 224 ++++++++++-------- .../server/src/project/AgentSessionScanner.ts | 187 ++++++++------- 2 files changed, 222 insertions(+), 189 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 562f49e8221d..92fe2dcd96cb 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -47,6 +47,8 @@ interface ScannerTestInput { readonly importedWorkspaceRoots?: ReadonlyArray; readonly configBaseDir?: string; readonly providerInstances?: ContractServerSettings["providerInstances"]; + readonly worktreesDirectory?: string; + readonly previousWorktreesDirectories?: ReadonlyArray; } const layerScannerTest = (input: ScannerTestInput) => @@ -54,6 +56,8 @@ const layerScannerTest = (input: ScannerTestInput) => Layer.provide( Layer.mergeAll( ServerSettings.layerTest({ + worktreesDirectory: input.worktreesDirectory ?? "", + previousWorktreesDirectories: input.previousWorktreesDirectories ?? [], providerInstances: { [ProviderInstanceId.make("claudeAgent")]: { driver: ProviderDriverKind.make("claudeAgent"), @@ -136,34 +140,6 @@ const addLinkedWorktree = Effect.fn("AgentSessionScanner.test.addLinkedWorktree" }, ); -const writeClaudeSession = Effect.fn("AgentSessionScanner.test.writeClaudeSession")( - function* (input: { - readonly claudeHomePath: string; - readonly sessionId: string; - readonly cwd: string; - readonly mtimeMs: number; - }) { - const path = yield* Path.Path; - yield* writeTranscript({ - filePath: path.join(input.claudeHomePath, "projects", input.sessionId, "session.jsonl"), - contents: `${JSON.stringify({ - type: "user", - cwd: input.cwd, - sessionId: input.sessionId, - message: { role: "user", content: "Fix the project" }, - })}\n`, - mtimeMs: input.mtimeMs, - }); - }, -); - -const importedWorktrees = (outcomes: ReadonlyArray) => - outcomes.flatMap((outcome) => - outcome._tag === "Importable" - ? [{ sessionId: outcome.thread.providerSessionId, worktree: outcome.worktree }] - : [], - ); - const claudeSessionLine = (cwd: string) => `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`; @@ -2438,87 +2414,125 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); - it.effect("imports sessions from linked worktrees of the project with their branch", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); - const repo = yield* makeTempDir("t3code-workspace-repo-"); - const otherRepo = yield* makeTempDir("t3code-workspace-other-"); - - const worktree = path.join(repo, ".claude", "worktrees", "feature"); - const managedWorktree = path.join(configBaseDir, "worktrees", "repo", "managed"); - const otherWorktree = path.join(otherRepo, "elsewhere"); - const commonGitDir = path.join(repo, ".git"); - yield* addLinkedWorktree({ commonGitDir, directory: worktree, branch: "feature" }); - yield* addLinkedWorktree({ commonGitDir, directory: managedWorktree, branch: "managed" }); - yield* addLinkedWorktree({ - commonGitDir: path.join(otherRepo, ".git"), - directory: otherWorktree, - branch: "elsewhere", - }); - - for (const [index, session] of [ - { sessionId: "root-session", cwd: repo }, - { sessionId: "worktree-session", cwd: worktree }, - { sessionId: "managed-session", cwd: managedWorktree }, - { sessionId: "other-session", cwd: otherWorktree }, - ].entries()) { - yield* writeClaudeSession({ - ...session, - claudeHomePath, - mtimeMs: nowMs - (index + 1) * 60 * 1000, + it.effect.each(["directory", "pointer"] as const)( + "imports only the project's linked worktrees with their branches when .git is a %s", + (layout) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const repo = yield* makeTempDir("t3code-workspace-repo-"); + const otherRepo = yield* makeTempDir("t3code-workspace-other-"); + const commonGitDir = + layout === "directory" + ? path.join(repo, ".git") + : yield* makeTempDir("t3code-separate-git-dir-"); + if (layout === "pointer") { + yield* fileSystem.writeFileString(path.join(repo, ".git"), `gitdir: ${commonGitDir}\n`); + } + + const worktree = path.join(repo, ".claude", "worktrees", "feature"); + const externalWorktree = yield* makeTempDir("t3code-workspace-feature-"); + const managedWorktree = path.join(configBaseDir, "worktrees", "repo", "managed"); + const customDirectory = yield* makeTempDir("t3code-custom-worktrees-"); + const previousDirectory = yield* makeTempDir("t3code-previous-worktrees-"); + const customWorktree = path.join(customDirectory, "repo", "custom"); + const previousWorktree = path.join(previousDirectory, "repo", "previous"); + const otherWorktree = path.join(repo, "unrelated"); + yield* addLinkedWorktree({ commonGitDir, directory: worktree, branch: "feature" }); + yield* addLinkedWorktree({ + commonGitDir, + directory: externalWorktree, + branch: "external", + }); + yield* addLinkedWorktree({ commonGitDir, directory: managedWorktree, branch: "managed" }); + yield* addLinkedWorktree({ commonGitDir, directory: customWorktree, branch: "custom" }); + yield* addLinkedWorktree({ + commonGitDir, + directory: previousWorktree, + branch: "previous", + }); + yield* addLinkedWorktree({ + commonGitDir: path.join(otherRepo, ".git"), + directory: otherWorktree, + branch: "elsewhere", }); - } - - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - configBaseDir, - workspaceRoot: repo, - }); - - expect(importedWorktrees(outcomes)).toEqual([ - { sessionId: "root-session", worktree: null }, - { sessionId: "worktree-session", worktree: { path: worktree, branch: "feature" } }, - ]); - }), - ); - - it.effect("imports linked worktree sessions when the project's .git is a pointer", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); - yield* TestClock.setTime(nowMs); - const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); - const codexHomePath = yield* makeTempDir("t3code-codex-home-"); - const repo = yield* makeTempDir("t3code-workspace-repo-"); - const commonGitDir = yield* makeTempDir("t3code-separate-git-dir-"); - yield* fileSystem.writeFileString(path.join(repo, ".git"), `gitdir: ${commonGitDir}\n`); - const worktree = path.join(repo, ".claude", "worktrees", "feature"); - yield* addLinkedWorktree({ commonGitDir, directory: worktree, branch: "feature" }); - yield* writeClaudeSession({ - claudeHomePath, - sessionId: "worktree-session", - cwd: worktree, - mtimeMs: nowMs, - }); + let worktreesDirectory = customDirectory; + const previousWorktreesDirectories = [previousDirectory, path.parse(repo).root]; + const sessions = [ + { sessionId: "root-session", cwd: repo }, + { sessionId: "worktree-session", cwd: worktree }, + { sessionId: "external-session", cwd: externalWorktree }, + { sessionId: "managed-session", cwd: managedWorktree }, + { sessionId: "custom-session", cwd: customWorktree }, + { sessionId: "previous-session", cwd: previousWorktree }, + { sessionId: "other-session", cwd: otherWorktree }, + ]; + if (symlinksSupported) { + const aliases = yield* makeTempDir("t3code-managed-aliases-"); + for (const [name, directory, branch] of [ + ["default", path.join(configBaseDir, "worktrees"), "managed"], + ["custom", customDirectory, "custom"], + ["previous", previousDirectory, "previous"], + ] as const) { + const alias = path.join(aliases, name); + yield* fileSystem.symlink(directory, alias); + sessions.push({ + sessionId: `${name}-alias-session`, + cwd: path.join(alias, "repo", branch), + }); + if (name === "custom") worktreesDirectory = alias; + if (name === "previous") previousWorktreesDirectories[0] = alias; + } + const rootAlias = path.join(aliases, "root"); + yield* fileSystem.symlink(path.parse(repo).root, rootAlias); + previousWorktreesDirectories.push(rootAlias); + const escapedAlias = path.join(customDirectory, "external"); + yield* fileSystem.symlink(externalWorktree, escapedAlias); + sessions.push({ sessionId: "managed-escape-session", cwd: escapedAlias }); + } + + for (const [index, session] of sessions.entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", session.sessionId, "session.jsonl"), + contents: `${JSON.stringify({ + type: "user", + ...session, + message: { role: "user", content: "Fix the project" }, + })}\n`, + mtimeMs: nowMs - (index + 1) * 60 * 1000, + }); + } - const outcomes = yield* runRecentThreadOutcomes({ - claudeHomePath, - codexHomePath, - workspaceRoot: repo, - }); + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + configBaseDir, + workspaceRoot: repo, + worktreesDirectory, + previousWorktreesDirectories, + }); - expect(importedWorktrees(outcomes)).toEqual([ - { sessionId: "worktree-session", worktree: { path: worktree, branch: "feature" } }, - ]); - }), + expect( + outcomes.flatMap((outcome) => + outcome._tag === "Importable" + ? [{ sessionId: outcome.thread.providerSessionId, worktree: outcome.worktree }] + : [], + ), + ).toEqual([ + { sessionId: "root-session", worktree: null }, + { sessionId: "worktree-session", worktree: { path: worktree, branch: "feature" } }, + { + sessionId: "external-session", + worktree: { path: externalWorktree, branch: "external" }, + }, + ]); + }), ); it.effect("uses one deterministic provider instance for a shared session home", () => diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 7a3d8a5e4e2d..e427e8650a26 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -12,6 +12,7 @@ import { type AgentSessionProjectGit, type AgentSessionScanResult, type ProviderInstanceConfig, + type ServerSettings as ContractServerSettings, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -37,6 +38,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; +import { isFilesystemRoot, managedWorktreesDirectories } from "../worktreesDirectory.ts"; import { createTranscriptJsonReader, createTranscriptJsonSelector, @@ -140,17 +142,12 @@ export interface AgentSessionThread { readonly messages: ReadonlyArray; } -export interface AgentSessionWorktree { - readonly path: string; - readonly branch: string | null; -} - export type AgentSessionRecentThread = | { readonly _tag: "Importable"; readonly thread: AgentSessionThread; readonly source: AgentSessionImportSource; - readonly worktree: AgentSessionWorktree | null; + readonly worktree: { readonly path: string; readonly branch: string | null } | null; } | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource } | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource } @@ -521,13 +518,14 @@ function normalizeForWorktreeMatch(value: string, caseFold: boolean): string { function isT3ManagedWorktree( candidatePath: string, - worktreesDir: string, + worktreesDirectories: ReadonlyArray, caseFold: boolean, ): boolean { const normalized = normalizeForWorktreeMatch(candidatePath, caseFold); return ( - normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) || - normalized.includes("/.t3/worktrees/") + worktreesDirectories.some((directory) => + normalized.startsWith(normalizeForWorktreeMatch(directory, caseFold)), + ) || normalized.includes("/.t3/worktrees/") ); } @@ -591,6 +589,9 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; const serverSettings = yield* ServerSettings.ServerSettingsService; + const readSettings = serverSettings.getSettings.pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), + ); const projectStore = yield* ProjectStore.ProjectStoreV2; const baseDir = path.resolve(serverConfig.baseDir); const worktreesDir = path.resolve(serverConfig.worktreesDir); @@ -608,7 +609,10 @@ export const make = Effect.gen(function* () { path.join(homeDir, "Documents", "Codex"), ]; - const isExcludedProjectPath = (candidatePath: string) => + const isExcludedProjectPath = ( + candidatePath: string, + managedDirectories: ReadonlyArray = [worktreesDir], + ) => excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) || excludedProjectAncestors.some((ancestor) => normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( @@ -618,7 +622,7 @@ export const make = Effect.gen(function* () { normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( normalizeForWorktreeMatch(baseDir, foldWorktreeCase), ) || - isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase); + isT3ManagedWorktree(candidatePath, managedDirectories, foldWorktreeCase); const listDirectory = (directory: string) => fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); @@ -641,89 +645,60 @@ export const make = Effect.gen(function* () { return `path:${normalizeProjectPathForComparison(realPath)}`; }); - /** - * When it points into a `worktrees/` directory the checkout is a linked worktree, which - * onboarding skips because its history imports with the main checkout. Submodules use the same - * pointer shape but live under `modules/`, and are offered like any other repository. - */ - const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* ( + // Submodule pointers are repositories; linked-worktree pointers live under `worktrees/`. + const readGitDirectory = Effect.fn("AgentSessionScanner.readGitDirectory")(function* ( directory: string, - ): Effect.fn.Return< - | { - readonly _tag: "Repository"; - readonly gitDir: string; - readonly git: AgentSessionProjectGit | null; - } - | { readonly _tag: "Worktree"; readonly gitDir: string } - | { readonly _tag: "NotGit" } - > { + ) { const gitPath = path.join(directory, ".git"); const gitStats = yield* statOption(gitPath); if (Option.isNone(gitStats)) return { _tag: "NotGit" } as const; - let gitDir = gitPath; - if (gitStats.value.type !== "Directory") { - const pointer = yield* fileSystem - .readFileString(gitPath) - .pipe(Effect.orElseSucceed(() => "")); - const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); - if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const; - gitDir = path.resolve(directory, target); - if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) { - return { _tag: "Worktree", gitDir } as const; - } + if (gitStats.value.type === "Directory") { + return { _tag: "Repository", gitDir: gitPath } as const; } - const configText = yield* fileSystem - .readFileString(path.join(gitDir, "config")) - .pipe(Effect.orElseSucceed(() => "")); - const originUrl = parseOriginUrlFromGitConfig(configText); - return { - _tag: "Repository", - gitDir, - git: { - remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl), - repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl), - }, - } as const; + const pointer = yield* fileSystem.readFileString(gitPath).pipe(Effect.orElseSucceed(() => "")); + const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); + if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const; + const gitDir = path.resolve(directory, target); + return /[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir) + ? ({ _tag: "Worktree", gitDir } as const) + : ({ _tag: "Repository", gitDir } as const); }); - /** T3-managed worktrees stay excluded: their threads already exist. */ const matchProjectCheckout = Effect.fn("AgentSessionScanner.matchProjectCheckout")(function* ( directory: string, - project: { readonly identity: string; readonly gitDirIdentity: string | null }, - ): Effect.fn.Return< - | { readonly _tag: "Root" } - | { readonly _tag: "Worktree"; readonly worktree: AgentSessionWorktree } - | { readonly _tag: "Unrelated" } - > { + project: { + readonly identity: string; + readonly gitDirIdentity: string | null; + readonly managedDirectories: ReadonlyArray; + }, + ) { const resolved = path.resolve(directory); + if (isExcludedProjectPath(resolved, project.managedDirectories)) { + return { _tag: "Unrelated" } as const; + } if ((yield* directoryIdentity(resolved)) === project.identity) return { _tag: "Root" } as const; - if (project.gitDirIdentity === null) return { _tag: "Unrelated" } as const; - const gitIdentity = yield* readGitIdentity(resolved); - if (gitIdentity._tag !== "Worktree") return { _tag: "Unrelated" } as const; + if (project.gitDirIdentity === null) { + return { _tag: "Unrelated" } as const; + } + const gitDirectory = yield* readGitDirectory(resolved); + if (gitDirectory._tag !== "Worktree") return { _tag: "Unrelated" } as const; const realPath = yield* fileSystem .realPath(resolved) .pipe(Effect.orElseSucceed(() => resolved)); - if (isExcludedProjectPath(resolved) || isExcludedProjectPath(realPath)) { + if (isExcludedProjectPath(realPath, project.managedDirectories)) { return { _tag: "Unrelated" } as const; } - const readGitFile = (name: string) => - fileSystem.readFileString(path.join(gitIdentity.gitDir, name)).pipe( - Effect.map((text) => text.trim()), - Effect.orElseSucceed(() => ""), - ); - const commonDir = yield* readGitFile("commondir"); + const commonDir = yield* fileSystem + .readFileString(path.join(gitDirectory.gitDir, "commondir")) + .pipe(Effect.orElseSucceed(() => "")); if ( - commonDir === "" || - (yield* directoryIdentity(path.resolve(gitIdentity.gitDir, commonDir))) !== + commonDir.trim() === "" || + (yield* directoryIdentity(path.resolve(gitDirectory.gitDir, commonDir.trim()))) !== project.gitDirIdentity ) { return { _tag: "Unrelated" } as const; } - const head = yield* readGitFile("HEAD"); - return { - _tag: "Worktree", - worktree: { path: resolved, branch: /^ref: refs\/heads\/(.+)$/.exec(head)?.[1] ?? null }, - } as const; + return { _tag: "Worktree", path: resolved, gitDir: gitDirectory.gitDir } as const; }); const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( @@ -1066,11 +1041,9 @@ export const make = Effect.gen(function* () { })); }); - const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* () { - const settings = yield* serverSettings.getSettings.pipe( - Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), - ); - + const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* ( + settings: ContractServerSettings, + ) { const raw: Array = []; let truncated = false; @@ -1177,7 +1150,7 @@ export const make = Effect.gen(function* () { let cachedCandidates: ReadonlyArray | null = null; const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { - const { candidates: raw, truncated } = yield* collectCandidates(); + const { candidates: raw, truncated } = yield* collectCandidates(yield* readSettings); cachedCandidates = raw; const merged = new Map< @@ -1213,12 +1186,23 @@ export const make = Effect.gen(function* () { if (isExcludedProjectPath(realPath)) { key = ""; } else { - const gitIdentity = yield* readGitIdentity(resolved); + const gitIdentity = yield* readGitDirectory(resolved); if (gitIdentity._tag === "Worktree") { key = ""; } else { key = yield* directoryIdentity(resolved, stats.value); - gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null); + let git: AgentSessionProjectGit | null = null; + if (gitIdentity._tag === "Repository") { + const configText = yield* fileSystem + .readFileString(path.join(gitIdentity.gitDir, "config")) + .pipe(Effect.orElseSucceed(() => "")); + const originUrl = parseOriginUrlFromGitConfig(configText); + git = { + remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl), + repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl), + }; + } + gitIdentities.set(key, git); } } directoryKeys.set(resolved, key); @@ -1304,8 +1288,31 @@ export const make = Effect.gen(function* () { const root = path.resolve(expandHomePath(workspaceRoot, homeDir)); const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty; - const rootGit = yield* readGitIdentity(root); + const settings = yield* readSettings; + const managedDirectories = (yield* Effect.forEach( + managedWorktreesDirectories(settings, worktreesDir, path, homeDir), + (directory) => { + const resolved = path.resolve(directory); + return fileSystem.realPath(resolved).pipe( + Effect.catchTags({ + PlatformError: (cause) => + Effect.succeed(cause.reason._tag === "NotFound" ? resolved : null), + }), + Effect.map((real) => + real === null || isFilesystemRoot(real, path) ? [] : [resolved, real], + ), + ); + }, + )).flat(); + if ( + isExcludedProjectPath(root, managedDirectories) || + isExcludedProjectPath(realRoot, managedDirectories) + ) { + return Stream.empty; + } + const rootGit = yield* readGitDirectory(root); const project = { + managedDirectories, identity: yield* directoryIdentity(root), gitDirIdentity: rootGit._tag === "Repository" ? yield* directoryIdentity(rootGit.gitDir) : null, @@ -1313,7 +1320,7 @@ export const make = Effect.gen(function* () { const nowMs = DateTime.toEpochMillis(yield* DateTime.now); const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS; - const candidates = cachedCandidates ?? (yield* collectCandidates()).candidates; + const candidates = cachedCandidates ?? (yield* collectCandidates(settings)).candidates; cachedCandidates = candidates; const eligibleTranscripts: Array<{ @@ -1447,11 +1454,23 @@ export const make = Effect.gen(function* () { return Option.some({ _tag: "Duplicate", source }); } importedSessions.add(sessionKey); + const head = + checkout._tag === "Worktree" + ? yield* fileSystem + .readFileString(path.join(checkout.gitDir, "HEAD")) + .pipe(Effect.orElseSucceed(() => "")) + : ""; return Option.some({ _tag: "Importable", thread: parsedThread, source, - worktree: checkout._tag === "Worktree" ? checkout.worktree : null, + worktree: + checkout._tag === "Worktree" + ? { + path: checkout.path, + branch: /^ref: refs\/heads\/(.+)$/.exec(head.trim())?.[1] ?? null, + } + : null, }); }).pipe(importReadLock.withPermits(1)), ),