Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export const make = Effect.gen(function* () {
threadAutoSettlement: true,
storageCleanup: true,
projectWorktreeCleanup: true,
worktreesDirectory: true,
threadRestartContinuation: true,
projectSettingsOverrides: true,
threadSnooze: true,
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,11 @@ export const make = Effect.gen(function* () {
);
return resolveProjectSettings(settings, projectId).settings;
});
// Best effort: a settings read failure falls back to the default location.
const readWorktreesDirectory = serverSettingsService.getSettings.pipe(
Effect.map((settings) => settings.worktreesDirectory),
Effect.orElseSucceed(() => ""),
);
const createWorktree: GitManager["Service"]["createWorktree"] = Effect.fn(
"GitManager.createWorktree",
)(function* (input, options) {
Expand All @@ -743,7 +748,8 @@ export const make = Effect.gen(function* () {
Effect.map((settings) => settings.worktreeSubmodules),
Effect.orElseSucceed(() => null),
);
return yield* gitCore.createWorktree(input, { ...options, submodules });
const worktreesDirectory = yield* readWorktreesDirectory;
return yield* gitCore.createWorktree(input, { worktreesDirectory, ...options, submodules });
});

const readRepositoryInstructions = (cwd: string, fileName: string) =>
Expand Down Expand Up @@ -2604,6 +2610,7 @@ export const make = Effect.gen(function* () {
path: null,
},
{
worktreesDirectory: yield* readWorktreesDirectory,
// Best effort: a settings read failure falls back to the checkout's t3.json.
submodules: yield* projectSettingsFor(input).pipe(
Effect.map((settings) => settings.worktreeSubmodules),
Expand Down
44 changes: 44 additions & 0 deletions apps/server/src/review/ReviewService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer";
import * as PlatformError from "effect/PlatformError";

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 * as ReviewService from "./ReviewService.ts";
Expand All @@ -14,6 +15,8 @@ function makeLayer(input: {
readonly workspaceRoot: string;
readonly baseDir: string;
readonly detectCalls?: Array<{ readonly cwd: string }>;
readonly worktreesDirectory?: string;
readonly previousWorktreesDirectories?: ReadonlyArray<string>;
}) {
return ReviewService.layer.pipe(
Layer.provide(
Expand All @@ -28,6 +31,12 @@ function makeLayer(input: {
}),
),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
Layer.provide(
ServerSettings.ServerSettingsService.layerTest({
worktreesDirectory: input.worktreesDirectory ?? "",
previousWorktreesDirectories: [...(input.previousWorktreesDirectories ?? [])],
}),
),
Layer.provide(ServerConfig.layerTest(input.workspaceRoot, input.baseDir)),
Layer.provideMerge(NodeServices.layer),
);
Expand Down Expand Up @@ -90,6 +99,41 @@ describe("ReviewService", () => {
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("allows previous custom worktree locations but never a filesystem root", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" });
const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" });
const previous = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-old-worktrees-" });
const outsideRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-outside-" });

const result = yield* Effect.gen(function* () {
const review = yield* ReviewService.ReviewService;
return yield* review.getDiffPreview({ cwd: previous });
}).pipe(
Effect.provide(
makeLayer({
workspaceRoot,
baseDir,
worktreesDirectory: "/",
previousWorktreesDirectories: [previous],
}),
),
);
assert.strictEqual(result.cwd, previous);

const rootLink = `${baseDir}/root-link`;
yield* fs.symlink("/", rootLink);
for (const worktreesDirectory of ["/", rootLink]) {
const error = yield* Effect.gen(function* () {
const review = yield* ReviewService.ReviewService;
return yield* review.getDiffPreview({ cwd: outsideRoot }).pipe(Effect.flip);
}).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, worktreesDirectory })));
assert.strictEqual(error._tag, "VcsRepositoryDetectionError");
}
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("allows diff preview cwd inside the configured workspace root", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
24 changes: 21 additions & 3 deletions apps/server/src/review/ReviewService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
import * as ServerConfig from "../config.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
import * as ServerSettings from "../serverSettings.ts";
import { isFilesystemRoot, managedWorktreesDirectories } from "../worktreesDirectory.ts";

export class ReviewService extends Context.Service<
ReviewService,
Expand All @@ -38,6 +40,7 @@ export const make = Effect.gen(function* () {
const path = yield* Path.Path;
const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry;
const git = yield* GitVcsDriver.GitVcsDriver;
const settings = yield* ServerSettings.ServerSettingsService;

const canonicalizePath = (value: string) => {
const resolvedPath = path.resolve(value);
Expand Down Expand Up @@ -67,13 +70,28 @@ export const make = Effect.gen(function* () {
operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents",
cwd: string,
) {
const [candidate, workspaceRoot, worktreesRoot] = yield* Effect.all([
const worktreesDirectories = yield* settings.getSettings.pipe(
Effect.orElseSucceed(() => ({ worktreesDirectory: "", previousWorktreesDirectories: [] })),
);
const [candidate, workspaceRoot, worktreesRoots] = yield* Effect.all([
canonicalizePath(cwd),
canonicalizePath(config.cwd),
canonicalizePath(config.worktreesDir),
// A managed root that cannot be resolved, or resolves to a filesystem
// root through a symlink, is skipped rather than failing every review.
Effect.forEach(
managedWorktreesDirectories(worktreesDirectories, config.worktreesDir, path),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
(directory) => canonicalizePath(directory).pipe(Effect.orElseSucceed(() => null)),
).pipe(
Effect.map((roots) =>
roots.filter((root): root is string => root !== null && !isFilesystemRoot(root, path)),
),
),
]);

if (isWithinRoot(candidate, workspaceRoot) || isWithinRoot(candidate, worktreesRoot)) {
if (
isWithinRoot(candidate, workspaceRoot) ||
worktreesRoots.some((root) => isWithinRoot(candidate, root))
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
) {
return;
}

Expand Down
26 changes: 22 additions & 4 deletions apps/server/src/storageCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementSer
import { forkParked } from "./serverActivation.ts";
import * as Settings from "./serverSettings.ts";
import * as TerminalManager from "./terminal/Manager.ts";
import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts";
import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
import { withWorkspaceLease } from "./workspace/workspaceLease.ts";

Expand Down Expand Up @@ -176,7 +177,20 @@ export const make = Effect.gen(function* () {
now: number,
) {
if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) return;
if (!(yield* fs.exists(config.worktreesDir))) return;
const roots: Array<string> = [];
for (const directory of managedWorktreesDirectories(
serverSettings,
config.worktreesDir,
path,
)) {
// An unmounted drive only skips its own worktrees.
const root = yield* fs.exists(directory).pipe(
Effect.flatMap((exists) => (exists ? fs.realPath(directory) : Effect.succeed(null))),
Effect.orElseSucceed(() => null),
);
if (root !== null && !isFilesystemRoot(root, path)) roots.push(root);
}
if (roots.length === 0) return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const hasDeleteRule = anyWorktreePolicy(serverSettings, (rules) => rules.worktreeOnDelete);
const deletedRows = hasDeleteRule
? yield* sql<{ payload_json: string; workspaceRoot: string }>`
Expand All @@ -197,7 +211,6 @@ export const make = Effect.gen(function* () {
resolveWorktreeCleanup(serverSettings, thread.projectId).worktreeOnDelete,
);
const snapshot = yield* readThreads();
const root = yield* fs.realPath(config.worktreesDir);
const refreshedDefaultRefs = new Map<string, Set<string>>();
const groups = Map.groupBy(
snapshot.threads.filter((thread) => thread.worktreePath !== null),
Expand All @@ -222,8 +235,13 @@ export const make = Effect.gen(function* () {
)
continue;
yield* Effect.gen(function* () {
if (!inside(root, worktreePath) || !(yield* fs.exists(worktreePath))) return;
if ((yield* fs.realPath(worktreePath)) !== worktreePath) return;
if (!(yield* fs.exists(worktreePath))) return;
// Roots are canonical, so compare canonical paths. A symlinked parent
// (a linked drive) is fine; a symlinked worktree directory is not.
const realPath = yield* fs.realPath(worktreePath);
const realParent = yield* fs.realPath(path.dirname(worktreePath));
if (realPath !== path.join(realParent, path.basename(worktreePath))) return;
if (!roots.some((root) => inside(root, realPath))) return;
if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) return;
// A linked worktree has a .git file. Never remove a main checkout.
if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") return;
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ export interface CreateWorktreeOptions {
* own t3.json.
*/
readonly submodules?: WorktreeSubmodules | null;
/** The `worktreesDirectory` setting, used when the input has no explicit path. */
readonly worktreesDirectory?: string;
}

export interface GitCommitProgress {
Expand Down
39 changes: 39 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2981,6 +2981,45 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
}),
);

it.effect("creates worktrees under the configured worktrees directory", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
const worktreesDirectory = yield* makeTmpDir("custom-worktrees-");
const driver = yield* GitVcsDriver.GitVcsDriver;

const created = yield* driver.createWorktree(
{ cwd, path: null, refName: initialBranch, newRefName: "feature/custom-dir" },
{ worktreesDirectory },
);
const expected = pathService.join(
worktreesDirectory,
pathService.basename(cwd),
"feature-custom-dir",
);
assert.equal(created.worktree.path, expected);
assert.equal(yield* fileSystem.exists(expected), true);

const error = yield* driver
.createWorktree(
{ cwd, path: null, refName: initialBranch, newRefName: "feature/relative-dir" },
{ worktreesDirectory: "relative/worktrees" },
)
.pipe(Effect.flip);
assert.match(error.detail, /must be an absolute folder on this machine/);

const rootError = yield* driver
.createWorktree(
{ cwd, path: null, refName: initialBranch, newRefName: "feature/root-dir" },
{ worktreesDirectory: "/" },
)
.pipe(Effect.flip);
assert.match(rootError.detail, /not a drive root/);
}),
);

it.effect("resolves the submodule mode from the option, then t3.json", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
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 @@ -36,6 +36,7 @@ import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings";
import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";
import * as GitVcsDriver from "./GitVcsDriver.ts";
import { resolveWorktreesDirectory } from "../worktreesDirectory.ts";
import {
parseRemoteNames,
parseRemoteNamesInGitOrder,
Expand Down Expand Up @@ -3345,7 +3346,23 @@ 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 parentDir = resolveWorktreesDirectory(
options?.worktreesDirectory ?? "",
worktreesDir,
path,
);
if (parentDir === null) {
return yield* new GitCommandError({
operation: "GitVcsDriver.createWorktree",
command: "git worktree add",
cwd: input.cwd,
detail: `The worktree location "${options?.worktreesDirectory}" must be an absolute folder on this machine, not a drive root. Change it in Settings → Storage.`,
});
}
worktreePath = path.join(parentDir, repoName, sanitizedBranch);
}
const args = input.newRefName
? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName]
: ["worktree", "add", worktreePath, input.refName];
Expand Down
44 changes: 44 additions & 0 deletions apps/server/src/worktreesDirectory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type * as Path from "effect/Path";

import { expandHomePathWith } from "./pathExpansion.ts";

/**
* Directory new worktrees are created under: the `worktreesDirectory`
* setting, or `defaultDir` (`<T3 home>/worktrees`) when it is empty. Null when
* the setting is not an absolute path on this machine, such as `D:\worktrees`
* configured for a Windows server and synced to a Linux one, or when it is a
* filesystem root, which would make every path on that drive look managed.
*/
export function resolveWorktreesDirectory(
setting: string,
defaultDir: string,
path: Path.Path,
): string | null {
if (setting === "") return defaultDir;
const expanded = expandHomePathWith(setting, path);
if (!path.isAbsolute(expanded)) return null;
const resolved = path.resolve(expanded);
return isFilesystemRoot(resolved, path) ? null : resolved;
}

/** Callers re-check after resolving symlinks: a link can point at a root. */
export function isFilesystemRoot(directory: string, path: Path.Path): boolean {
return path.dirname(directory) === directory;
}

/** Every directory that holds T3-managed worktrees on this machine. */
export function managedWorktreesDirectories(
settings: {
readonly worktreesDirectory: string;
readonly previousWorktreesDirectories: ReadonlyArray<string>;
},
defaultDir: string,
path: Path.Path,
): ReadonlyArray<string> {
const directories = new Set([defaultDir]);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
for (const setting of [settings.worktreesDirectory, ...settings.previousWorktreesDirectories]) {
const directory = resolveWorktreesDirectory(setting, defaultDir, path);
if (directory !== null) directories.add(directory);
}
return [...directories];
}
Loading
Loading