Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/server/src/project/AgentSessionScanner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ interface ScannerTestInput {
readonly importedWorkspaceRoots?: ReadonlyArray<string>;
/** Base dir for the test ServerConfig; worktreesDir derives from it. */
readonly configBaseDir?: string;
readonly worktreesDirectory?: string;
readonly providerInstances?: ContractServerSettings["providerInstances"];
}

Expand All @@ -88,6 +89,9 @@ const makeScannerTestLayer = (input: ScannerTestInput) =>
claudeAgent: { homePath: input.claudeHomePath },
codex: { homePath: input.codexHomePath },
},
...(input.worktreesDirectory === undefined
? {}
: { worktreesDirectory: input.worktreesDirectory }),
...(input.providerInstances === undefined
? {}
: { providerInstances: input.providerInstances }),
Expand Down Expand Up @@ -999,6 +1003,40 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
}),
);

it.effect("excludes sandboxes under a custom worktrees directory without .t3 in the path", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
const codexHomePath = yield* makeTempDir("t3code-codex-home-");
const configBaseDir = yield* makeTempDir("t3code-scanner-base-");
const worktreesDirectory = yield* makeTempDir("t3code-custom-worktrees-");
const keep = yield* makeTempDir("t3code-workspace-keep-");
const fileSystem = yield* FileSystem.FileSystem;

const worktreeCwd = path.join(worktreesDirectory, "t3code", "wt-custom");
yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true });
yield* writeTranscript({
filePath: path.join(claudeHomePath, "projects", "-slug-0", "a.jsonl"),
contents: claudeSessionLine(worktreeCwd),
mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
});
yield* writeTranscript({
filePath: path.join(claudeHomePath, "projects", "-slug-1", "b.jsonl"),
contents: claudeSessionLine(keep),
mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
});

const result = yield* runScan({
claudeHomePath,
codexHomePath,
configBaseDir,
worktreesDirectory,
});

expect(result.candidates.map((candidate) => candidate.path)).toEqual([keep]);
}),
);

it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
Expand Down
42 changes: 30 additions & 12 deletions apps/server/src/project/AgentSessionScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSn
import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
import { expandHomePath } from "../pathExpansion.ts";
import * as ServerSettings from "../serverSettings.ts";
import { listManagedWorktreesRoots } from "../worktreesDirectory.ts";
import {
createTranscriptJsonReader,
createTranscriptJsonSelector,
Expand Down Expand Up @@ -540,10 +541,12 @@ function shouldRetainDecodedRecord(
* transcripts look exactly like user sessions, but re-importing the app's own
* sandboxes as projects is never right. Matches this server's configured
* worktrees directory plus the conventional `.t3/worktrees` layout, which
* also catches sandboxes from other T3 homes on the same machine. Separators
* are normalized (and, on Windows, case folded) so the prefix match holds
* there too. Callers check both the recorded spelling and its realpath so a
* symlink into the worktrees directory cannot bypass the filter.
* also catches sandboxes from other T3 homes on the same machine. A custom
* worktrees directory is checked too, and the default root stays recognized
* after that setting changes. Separators are normalized (and, on Windows,
* case folded) so the prefix match holds there too. Callers check both the
* recorded spelling and its realpath so a symlink into the worktrees
* directory cannot bypass the filter.
*/
function normalizeForWorktreeMatch(value: string, caseFold: boolean): string {
const normalized = `${value.replaceAll("\\", "/")}/`;
Expand All @@ -552,13 +555,14 @@ function normalizeForWorktreeMatch(value: string, caseFold: boolean): string {

function isT3ManagedWorktree(
candidatePath: string,
worktreesDir: string,
worktreesDirs: ReadonlyArray<string>,
caseFold: boolean,
): boolean {
const normalized = normalizeForWorktreeMatch(candidatePath, caseFold);
return (
normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) ||
normalized.includes("/.t3/worktrees/")
worktreesDirs.some((worktreesDir) =>
normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)),
) || normalized.includes("/.t3/worktrees/")
);
}

Expand Down Expand Up @@ -646,7 +650,14 @@ export const make = Effect.gen(function* () {
path.join(homeDir, "Documents", "Codex"),
];

const isExcludedProjectPath = (candidatePath: string) =>
const resolveManagedWorktreesDirs = serverSettings.getSettings.pipe(
Effect.map((settings) =>
listManagedWorktreesRoots(settings.worktreesDirectory, worktreesDir, path),
),
Effect.orElseSucceed(() => [worktreesDir]),
);

const isExcludedProjectPath = (candidatePath: string, worktreesDirs: ReadonlyArray<string>) =>
excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) ||
excludedProjectAncestors.some((ancestor) =>
normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith(
Expand All @@ -656,7 +667,7 @@ export const make = Effect.gen(function* () {
normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith(
normalizeForWorktreeMatch(baseDir, foldWorktreeCase),
) ||
isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase);
isT3ManagedWorktree(candidatePath, worktreesDirs, foldWorktreeCase);

const listDirectory = (directory: string) =>
fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray<string> => []));
Expand Down Expand Up @@ -1200,6 +1211,7 @@ export const make = Effect.gen(function* () {

const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () {
const { candidates: raw, truncated } = yield* collectCandidates();
const managedWorktreesDirs = yield* resolveManagedWorktreesDirs;
cachedCandidates = raw;

// Filesystem identity merges symlinks and case aliases without collapsing
Expand All @@ -1221,7 +1233,7 @@ export const make = Effect.gen(function* () {
const expanded = expandHomePath(candidate.cwd.trim());
if (!path.isAbsolute(expanded)) continue;
const resolved = path.resolve(expanded);
if (isExcludedProjectPath(resolved)) continue;
if (isExcludedProjectPath(resolved, managedWorktreesDirs)) continue;
let key = directoryKeys.get(resolved);
if (key === undefined) {
const stats = yield* statOption(resolved);
Expand All @@ -1235,7 +1247,7 @@ export const make = Effect.gen(function* () {
.pipe(Effect.orElseSucceed(() => resolved));
// A symlink can point into the worktrees directory even when its own
// spelling doesn't; check again with links resolved.
if (isExcludedProjectPath(realPath)) {
if (isExcludedProjectPath(realPath, managedWorktreesDirs)) {
key = "";
} else {
const gitIdentity = yield* readGitIdentity(resolved);
Expand Down Expand Up @@ -1331,7 +1343,13 @@ export const make = Effect.gen(function* () {
) {
const root = path.resolve(expandHomePath(workspaceRoot));
const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root));
if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty;
const managedWorktreesDirs = yield* resolveManagedWorktreesDirs;
if (
isExcludedProjectPath(root, managedWorktreesDirs) ||
isExcludedProjectPath(realRoot, managedWorktreesDirs)
) {
return Stream.empty;
}
const rootIdentity = yield* directoryIdentity(root);
const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS;
Expand Down
64 changes: 64 additions & 0 deletions apps/server/src/review/ReviewService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";

import { ServerConfig } from "../config.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
import * as ReviewService from "./ReviewService.ts";
Expand All @@ -14,6 +16,7 @@ function makeLayer(input: {
readonly workspaceRoot: string;
readonly baseDir: string;
readonly detectCalls?: Array<{ readonly cwd: string }>;
readonly worktreesDirectory?: string;
}) {
return ReviewService.layer.pipe(
Layer.provide(
Expand All @@ -29,6 +32,13 @@ function makeLayer(input: {
),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)),
Layer.provideMerge(
ServerSettings.layerTest(
input.worktreesDirectory === undefined
? {}
: { worktreesDirectory: input.worktreesDirectory },
),
),
Layer.provideMerge(NodeServices.layer),
);
}
Expand Down Expand Up @@ -130,4 +140,58 @@ describe("ReviewService", () => {
assert.deepStrictEqual(detectCalls, []);
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("allows diff preview cwd inside the default worktrees directory", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" });
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" });
const worktreeCwd = path.join(baseDir, "worktrees", "repo", "branch");
yield* fs.makeDirectory(worktreeCwd, { recursive: true });
const detectCalls: Array<{ readonly cwd: string }> = [];

const result = yield* Effect.gen(function* () {
const review = yield* ReviewService.ReviewService;
return yield* review.getDiffPreview({ cwd: worktreeCwd });
}).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls })));

assert.strictEqual(result.cwd, worktreeCwd);
assert.deepStrictEqual(detectCalls, [{ cwd: worktreeCwd }]);
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("allows diff preview cwd in a configured worktrees directory and the old default", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" });
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" });
const configuredRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-worktrees-" });
const configuredCwd = path.join(configuredRoot, "repo", "branch");
const defaultCwd = path.join(baseDir, "worktrees", "repo", "old-branch");
yield* fs.makeDirectory(configuredCwd, { recursive: true });
yield* fs.makeDirectory(defaultCwd, { recursive: true });
const detectCalls: Array<{ readonly cwd: string }> = [];
const layer = makeLayer({
workspaceRoot,
baseDir,
detectCalls,
worktreesDirectory: configuredRoot,
});

const configuredResult = yield* Effect.gen(function* () {
const review = yield* ReviewService.ReviewService;
return yield* review.getDiffPreview({ cwd: configuredCwd });
}).pipe(Effect.provide(layer));
const defaultResult = yield* Effect.gen(function* () {
const review = yield* ReviewService.ReviewService;
return yield* review.getDiffPreview({ cwd: defaultCwd });
}).pipe(Effect.provide(layer));

assert.strictEqual(configuredResult.cwd, configuredCwd);
assert.strictEqual(defaultResult.cwd, defaultCwd);
assert.deepStrictEqual(detectCalls, [{ cwd: configuredCwd }, { cwd: defaultCwd }]);
}).pipe(Effect.provide(NodeServices.layer)),
);
});
17 changes: 14 additions & 3 deletions apps/server/src/review/ReviewService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import {
} from "@t3tools/contracts";

import * as ServerConfig from "../config.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
import { listManagedWorktreesRoots } from "../worktreesDirectory.ts";

export class ReviewService extends Context.Service<
ReviewService,
Expand Down Expand Up @@ -67,13 +69,22 @@ export const make = Effect.gen(function* () {
operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents",
cwd: string,
) {
const [candidate, workspaceRoot, worktreesRoot] = yield* Effect.all([
const settings = yield* ServerSettings.ServerSettingsService;
const configured = yield* settings.getSettings.pipe(
Effect.map((current) => current.worktreesDirectory),
Effect.orElseSucceed(() => ""),
);
const worktreesRoots = listManagedWorktreesRoots(configured, config.worktreesDir, path);
const [candidate, workspaceRoot, ...canonicalWorktreeRoots] = yield* Effect.all([
canonicalizePath(cwd),
canonicalizePath(config.cwd),
canonicalizePath(config.worktreesDir),
...worktreesRoots.map((root) => canonicalizePath(root)),
]);

if (isWithinRoot(candidate, workspaceRoot) || isWithinRoot(candidate, worktreesRoot)) {
if (
isWithinRoot(candidate, workspaceRoot) ||
canonicalWorktreeRoots.some((root) => isWithinRoot(candidate, root))
) {
return;
}

Expand Down
33 changes: 33 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts";
import { ServerConfig } from "../config.ts";
import * as ServerSettings from "../serverSettings.ts";
import { gitCommandDuration } from "../observability/Metrics.ts";
import {
makeGitVcsDriverCore,
Expand All @@ -35,6 +36,7 @@ const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
});
const TestLayer = GitVcsDriver.layer.pipe(
Layer.provide(ServerConfigLayer),
Layer.provideMerge(ServerSettings.layerTest()),
Layer.provideMerge(NodeServices.layer),
);

Expand Down Expand Up @@ -1698,6 +1700,37 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
});

describe("worktree operations", () => {
it.effect("places default worktrees under the configured directory", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const worktreesRoot = yield* makeTmpDir("git-configured-worktrees-");
const settings = yield* ServerSettings.ServerSettingsService;
yield* settings.updateSettings({ worktreesDirectory: worktreesRoot });
const driver = yield* GitVcsDriver.GitVcsDriver;

yield* driver.createWorktree({
cwd,
path: null,
refName: initialBranch,
newRefName: "feature/configured-root",
});

const worktreePath = path.join(
worktreesRoot,
path.basename(cwd),
"feature-configured-root",
);
assert.equal(
yield* git(worktreePath, ["rev-parse", "--abbrev-ref", "HEAD"]),
"feature/configured-root",
);
assert.isTrue(yield* fs.exists(path.join(worktreePath, "README.md")));
}),
);

it.effect("uses parallel checkout without skipping filters or hooks", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import {
parseRemoteRefWithRemoteNames,
} from "../git/remoteRefs.ts";
import { ServerConfig } from "../config.ts";
import * as ServerSettings from "../serverSettings.ts";
import { resolveWorktreesDirectory } from "../worktreesDirectory.ts";

const DEFAULT_TIMEOUT_MS = 30_000;
const gitProcesses = Semaphore.makeUnsafe(8);
Expand Down Expand Up @@ -3055,7 +3057,22 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
const targetBranch = input.newRefName ?? input.refName;
const sanitizedBranch = targetBranch.replace(/\//g, "-");
const repoName = path.basename(input.cwd);
const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);
let worktreePath = input.path;
if (worktreePath === null) {
const settings = yield* ServerSettings.ServerSettingsService;
const configured = yield* settings.getSettings.pipe(
Effect.map((current) => current.worktreesDirectory),
Effect.orElseSucceed(() => ""),
);
worktreePath = path.join(
resolveWorktreesDirectory(configured, worktreesDir, path),
repoName,
sanitizedBranch,
);
}
yield* fileSystem
.makeDirectory(path.dirname(worktreePath), { recursive: true })
.pipe(Effect.ignore);
const args = input.newRefName
? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName]
: ["worktree", "add", worktreePath, input.refName];
Expand Down
Loading
Loading