1import * as HostProcess from "@t3tools/shared/HostProcess";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5
6import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
7
8import * as ServerConfig from "./config.ts";
9import * as ServerSettings from "./serverSettings.ts";
10
11/** Null for filesystem roots or settings not absolute on this host, including synced foreign-platform paths. */
12export function resolveWorktreesDirectory(
13 setting: string,
14 defaultDir: string,
15 path: Path.Path,
16 home: string,
17): string | null {
18 if (setting === "") return defaultDir;
19 const expanded = expandHomePath(setting, home);
20 if (!path.isAbsolute(expanded)) return null;
21 const resolved = path.resolve(expanded);
22 return isFilesystemRoot(resolved, path) ? null : resolved;
23}
24
25/** Callers re-check after resolving symlinks: a link can point at a root. */
26export function isFilesystemRoot(directory: string, path: Path.Path): boolean {
27 return path.dirname(directory) === directory;
28}
29
30export function managedWorktreesDirectories(
31 settings: {
32 readonly worktreesDirectory: string;
33 readonly previousWorktreesDirectories: ReadonlyArray<string>;
34 },
35 defaultDir: string,
36 path: Path.Path,
37 home: string,
38): ReadonlyArray<string> {
39 const directories = new Set([defaultDir]);
40 for (const setting of [settings.worktreesDirectory, ...settings.previousWorktreesDirectories]) {
41 const directory = resolveWorktreesDirectory(setting, defaultDir, path, home);
42 if (directory !== null) directories.add(directory);
43 }
44 return [...directories];
45}
46
47// Client paths/branches are untrusted; recreate only real paths matching managed-folder/repo/branch.
48export const makeIsManagedWorktreePath = Effect.gen(function* () {
49 const fileSystem = yield* FileSystem.FileSystem;
50 const path = yield* Path.Path;
51 const serverConfig = yield* ServerConfig.ServerConfig;
52 const serverSettings = yield* ServerSettings.ServerSettingsService;
53
54 // Match Git's symlink-following location: nearest real ancestor plus missing path components.
55 const realLocation = (target: string): Effect.Effect<string> =>
56 fileSystem.realPath(target).pipe(
57 Effect.catch(() => {
58 const parent = path.dirname(target);
59 return parent === target
60 ? Effect.succeed(target)
61 : Effect.map(realLocation(parent), (real) => path.join(real, path.basename(target)));
62 }),
63 );
64
65 return Effect.fn("worktreesDirectory.isManagedWorktreePath")(function* (worktreePath: string) {
66 // git resolves a relative path against the project, not the server cwd.
67 if (!path.isAbsolute(worktreePath)) return false;
68 // Require normalized paths; Git and realPath disagree on .. after symlink traversal.
69 if (path.resolve(worktreePath) !== worktreePath) return false;
70 const settings = yield* serverSettings.getSettings.pipe(
71 Effect.orElseSucceed(() => ({
72 worktreesDirectory: "",
73 previousWorktreesDirectories: [],
74 })),
75 );
76 const roots = yield* Effect.forEach(
77 managedWorktreesDirectories(
78 settings,
79 serverConfig.worktreesDir,
80 path,
81 yield* HostProcess.HomeDirectory,
82 ),
83 realLocation,
84 );
85 const candidate = yield* realLocation(worktreePath);
86 return roots.some((root) => {
87 if (isFilesystemRoot(root, path)) return false;
88 const relative = path.relative(root, candidate);
89 const segments = relative.split(path.sep);
90 return (
91 !path.isAbsolute(relative) &&
92 segments.length === 2 &&
93 segments.every((segment) => segment !== "" && segment !== "..")
94 );
95 });
96 });
97});