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
17 changes: 17 additions & 0 deletions apps/server/src/mcp/toolkits/environment/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts";
import { assert, describe, it } from "@effect/vitest";

import { preferences } from "./handlers.ts";

describe("environment handlers", () => {
it("includes worktreesDirectory in environment preferences", () => {
const prefs = preferences(DEFAULT_SERVER_SETTINGS);
assert.equal(prefs.worktreesDirectory, "");

const customPrefs = preferences({
...DEFAULT_SERVER_SETTINGS,
worktreesDirectory: "/Volumes/MySSD/t3-worktrees",
});
assert.equal(customPrefs.worktreesDirectory, "/Volumes/MySSD/t3-worktrees");
});
});
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/environment/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function preferences(settings: ServerSettings) {
const {
defaultThreadEnvMode,
newWorktreesStartFromOrigin,
worktreesDirectory,
enableProviderUpdateChecks,
backgroundActivity,
sourceControlWritingStyle,
Expand All @@ -19,6 +20,7 @@ export function preferences(settings: ServerSettings) {
return {
defaultThreadEnvMode,
newWorktreesStartFromOrigin,
worktreesDirectory,
enableProviderUpdateChecks,
backgroundActivity: { profile: backgroundActivity.profile },
sourceControlWritingStyle: {
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/environment/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";
const PreferenceFields = {
defaultThreadEnvMode: ServerSettings.fields.defaultThreadEnvMode,
newWorktreesStartFromOrigin: ServerSettings.fields.newWorktreesStartFromOrigin,
worktreesDirectory: ServerSettings.fields.worktreesDirectory,
enableProviderUpdateChecks: ServerSettings.fields.enableProviderUpdateChecks,
backgroundActivity: Schema.Struct({ profile: BackgroundActivityProfileSelection }),
sourceControlWritingStyle: Schema.Struct({
Expand Down Expand Up @@ -58,6 +59,7 @@ const EnvironmentPreferencesTool = Tool.make("t3_environment_preferences_update"
parameters: Schema.Struct({
defaultThreadEnvMode: ServerSettingsPatch.fields.defaultThreadEnvMode,
newWorktreesStartFromOrigin: ServerSettingsPatch.fields.newWorktreesStartFromOrigin,
worktreesDirectory: ServerSettingsPatch.fields.worktreesDirectory,
enableProviderUpdateChecks: ServerSettingsPatch.fields.enableProviderUpdateChecks,
backgroundActivity: Schema.optionalKey(Schema.Struct({ profile: BackgroundActivityProfile })),
sourceControlWritingStyle: ServerSettingsPatch.fields.sourceControlWritingStyle,
Expand Down
83 changes: 83 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3017,6 +3017,89 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
)
.pipe(Effect.flip);
assert.match(rootError.detail, /not a drive root/);

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

it.effect("resolves relative worktree paths against the repository directory", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
const driver = yield* GitVcsDriver.GitVcsDriver;
const relativePath = path.join(`relative-${path.basename(cwd)}`, "nested-worktree");
const wrongParent = path.resolve(process.cwd(), path.dirname(relativePath));

yield* Effect.addFinalizer(() =>
fileSystem
.remove(wrongParent, { recursive: true })
.pipe(Effect.orElseSucceed(() => undefined)),
);

const created = yield* driver.createWorktree({
cwd,
path: relativePath,
refName: initialBranch,
newRefName: "feature/relative-path",
});
const expectedPath = path.resolve(cwd, relativePath);

assert.equal(created.worktree.path, expectedPath);
assert.equal(yield* fileSystem.exists(expectedPath), true);
assert.equal(yield* fileSystem.exists(wrongParent), false);
}),
);

it.effect("keeps worktree directory errors bounded and preserves their cause", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
const blocker = path.join(yield* makeTmpDir(), "file-not-directory");
yield* fileSystem.writeFileString(blocker, "file");
const driver = yield* GitVcsDriver.GitVcsDriver;

const configuredError = yield* driver
.createWorktree(
{ cwd, path: null, refName: initialBranch, newRefName: "feature/configured-failure" },
{ worktreesDirectory: path.join(blocker, "worktrees") },
)
.pipe(Effect.flip);
assert.equal(
configuredError.detail,
"Could not create the configured worktree storage directory. Check that the location is accessible and writable, or update Settings → Storage.",
);
assert.isDefined(configuredError.cause);
assert.notInclude(configuredError.detail, blocker);

const explicitError = yield* driver
.createWorktree(
{
cwd,
path: path.join(blocker, "explicit-worktree"),
refName: initialBranch,
newRefName: "feature/explicit-failure",
},
{ worktreesDirectory: path.join(blocker, "worktrees") },
)
.pipe(Effect.flip);
assert.equal(explicitError.detail, "Could not prepare the requested worktree directory.");
assert.isDefined(explicitError.cause);
assert.notInclude(explicitError.detail, blocker);
}),
);

Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3351,7 +3351,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
const targetBranch = input.newRefName ?? input.refName;
const sanitizedBranch = targetBranch.replace(/\//g, "-");
const repoName = path.basename(input.cwd);
let worktreePath = input.path;
let worktreePath = input.path == null ? null : path.resolve(input.cwd, input.path);
if (worktreePath == null) {
const parentDir = resolveWorktreesDirectory(
options?.worktreesDirectory ?? "",
Expand All @@ -3368,6 +3368,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
}
worktreePath = path.join(parentDir, repoName, sanitizedBranch);
}
const customConfigured = input.path == null && Boolean(options?.worktreesDirectory?.trim());
yield* fileSystem.makeDirectory(path.dirname(worktreePath), { recursive: true }).pipe(
Comment thread
RealYusufIsmail marked this conversation as resolved.
Effect.mapError((cause) => {
return new GitCommandError({
operation: "GitVcsDriver.createWorktree",
command: "mkdir -p",
cwd: input.cwd,
detail: customConfigured
? "Could not create the configured worktree storage directory. Check that the location is accessible and writable, or update Settings → Storage."
: "Could not prepare the requested worktree directory.",
cause,
});
}),
);
const args = input.newRefName
? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName]
: ["worktree", "add", worktreePath, input.refName];
Expand Down
112 changes: 112 additions & 0 deletions apps/server/src/worktreesDirectory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Path from "effect/Path";
import * as NodeOS from "node:os";

import { managedWorktreesDirectories, resolveWorktreesDirectory } from "./worktreesDirectory.ts";

describe("worktreesDirectory", () => {
it.effect("resolves default directory when setting is empty or whitespace", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const defaultDir = path.resolve("/Users/test/.t3/worktrees");

assert.equal(resolveWorktreesDirectory("", defaultDir, path), defaultDir);
assert.equal(resolveWorktreesDirectory(" ", defaultDir, path), defaultDir);
assert.equal(resolveWorktreesDirectory("\t\n", defaultDir, path), defaultDir);
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("resolves tilde to user home directory", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const defaultDir = path.resolve("/Users/test/.t3/worktrees");
const home = NodeOS.homedir();

const result = resolveWorktreesDirectory("~/custom-worktrees", defaultDir, path);
assert.equal(result, path.resolve(home, "custom-worktrees"));

const resultWithTrailing = resolveWorktreesDirectory("~/custom-worktrees/", defaultDir, path);
assert.equal(resultWithTrailing, path.resolve(home, "custom-worktrees"));
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("resolves valid absolute custom path and normalizes trailing slashes", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const defaultDir = path.resolve("/Users/test/.t3/worktrees");

const result = resolveWorktreesDirectory("/Volumes/External/worktrees/", defaultDir, path);
assert.equal(result, path.resolve("/Volumes/External/worktrees"));

const resultWithSpaces = resolveWorktreesDirectory(
" /Volumes/External/worktrees ",
defaultDir,
path,
);
assert.equal(resultWithSpaces, path.resolve("/Volumes/External/worktrees"));
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("rejects relative paths", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const defaultDir = path.resolve("/Users/test/.t3/worktrees");

assert.isNull(resolveWorktreesDirectory("relative/path", defaultDir, path));
assert.isNull(resolveWorktreesDirectory("./relative/path", defaultDir, path));
assert.isNull(resolveWorktreesDirectory("../relative/path", defaultDir, path));
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("rejects filesystem root paths", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const defaultDir = path.resolve("/Users/test/.t3/worktrees");

assert.isNull(resolveWorktreesDirectory("/", defaultDir, path));
assert.isNull(resolveWorktreesDirectory("///", defaultDir, path));
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("manages worktrees directories including current and previous directories", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const defaultDir = path.resolve("/Users/test/.t3/worktrees");

const dirs = managedWorktreesDirectories(
{
worktreesDirectory: "/Volumes/External/worktrees",
previousWorktreesDirectories: ["/Volumes/OldDisk/worktrees", "/Users/test/.t3/worktrees"],
},
defaultDir,
path,
);

assert.deepEqual(dirs, [
defaultDir,
path.resolve("/Volumes/External/worktrees"),
path.resolve("/Volumes/OldDisk/worktrees"),
]);
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("filters invalid paths from managed worktrees directories", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const defaultDir = path.resolve("/Users/test/.t3/worktrees");

const dirs = managedWorktreesDirectories(
{
worktreesDirectory: "/", // root -> invalid
previousWorktreesDirectories: ["relative-path", "/Volumes/Valid/worktrees"],
},
defaultDir,
path,
);

assert.deepEqual(dirs, [defaultDir, path.resolve("/Volumes/Valid/worktrees")]);
}).pipe(Effect.provide(NodeServices.layer)),
);
});
5 changes: 3 additions & 2 deletions apps/server/src/worktreesDirectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ export function resolveWorktreesDirectory(
defaultDir: string,
path: Path.Path,
): string | null {
if (setting === "") return defaultDir;
const expanded = expandHomePathWith(setting, path);
const trimmed = setting.trim();
if (trimmed === "") return defaultDir;
const expanded = expandHomePathWith(trimmed, path);
if (!path.isAbsolute(expanded)) return null;
const resolved = path.resolve(expanded);
return isFilesystemRoot(resolved, path) ? null : resolved;
Expand Down
Loading
Loading