Skip to content

Commit 3c68642

Browse files
fix(server): file links relative to a gitignored git worktree open the file (#34831)
1 parent 7466707 commit 3c68642

2 files changed

Lines changed: 104 additions & 1 deletion

File tree

‎apps/server/src/workspace/WorkspaceEntries.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,35 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => {
510510
expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false);
511511
}),
512512
);
513+
514+
it.effect("finds a path inside a gitignored nested git worktree only by its exact path", () =>
515+
Effect.gen(function* () {
516+
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-worktree-", git: true });
517+
const env = {
518+
...process.env,
519+
GIT_AUTHOR_NAME: "Test",
520+
GIT_AUTHOR_EMAIL: "[email protected]",
521+
GIT_COMMITTER_NAME: "Test",
522+
GIT_COMMITTER_EMAIL: "[email protected]",
523+
};
524+
yield* writeTextFile(cwd, ".gitignore", ".worktrees/\n");
525+
yield* git(cwd, ["add", ".gitignore"]);
526+
yield* git(cwd, ["commit", "-m", "init"], env);
527+
yield* git(cwd, ["worktree", "add", "-b", "x", ".worktrees/x"]);
528+
yield* writeTextFile(cwd, ".worktrees/x/src/foo.py", "print(1)");
529+
530+
const exact = yield* searchWorkspaceEntries({
531+
cwd,
532+
query: "src/foo.py",
533+
limit: 25,
534+
kind: "file",
535+
});
536+
expect(exact.entries).toEqual([{ path: ".worktrees/x/src/foo.py", kind: "file" }]);
537+
538+
const partial = yield* searchWorkspaceEntries({ cwd, query: "src/fo", limit: 25 });
539+
expect(partial.entries.map((entry) => entry.path)).not.toContain(".worktrees/x/src/foo.py");
540+
}),
541+
);
513542
});
514543

515544
describe("searchContents", () => {

‎apps/server/src/workspace/WorkspaceEntries.ts‎

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ export const make = Effect.gen(function* () {
260260
const normalizedQuery = normalizeSearchQuery(input.query, {
261261
trimLeadingPattern: /^[@./]+/,
262262
});
263-
return yield* Effect.gen(function* () {
263+
const result = yield* Effect.gen(function* () {
264264
const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex;
265265
return yield* searchIndex.search(normalizedQuery, input.limit, input.kind, input.imageOnly);
266266
}).pipe(
@@ -270,9 +270,83 @@ export const make = Effect.gen(function* () {
270270
),
271271
),
272272
);
273+
if (input.kind === "directory" || input.imageOnly) return result;
274+
const worktreeFiles = yield* findNestedWorktreeFiles(
275+
normalizedCwd,
276+
input.query,
277+
result.entries,
278+
);
279+
if (worktreeFiles.length === 0) return result;
280+
const entries = [...worktreeFiles, ...result.entries];
281+
return {
282+
entries: entries.slice(0, input.limit),
283+
truncated: result.truncated || entries.length > input.limit,
284+
};
273285
},
274286
);
275287

288+
// The index skips gitignored folders, where agents often put worktrees (`.worktrees/x`).
289+
// A path relative to such a worktree is found only when it names an existing file exactly.
290+
const findNestedWorktreeFiles = Effect.fn("WorkspaceEntries.findNestedWorktreeFiles")(function* (
291+
cwd: string,
292+
query: string,
293+
indexed: ReadonlyArray<ProjectEntry>,
294+
) {
295+
const relativePath = query.trim().replaceAll("\\", "/");
296+
if (
297+
!relativePath.includes("/") ||
298+
relativePath.endsWith("/") ||
299+
path.isAbsolute(relativePath) ||
300+
isWindowsAbsolutePath(relativePath) ||
301+
relativePath.split("/").some((segment) => segment === "..") ||
302+
indexed.some(
303+
(entry) =>
304+
entry.kind === "file" &&
305+
(entry.path === relativePath || entry.path.endsWith(`/${relativePath}`)),
306+
)
307+
) {
308+
return [];
309+
}
310+
const listed = yield* vcsProcess
311+
.run({
312+
operation: "WorkspaceEntries.listWorktrees",
313+
command: "git",
314+
args: ["worktree", "list", "--porcelain"],
315+
cwd,
316+
allowNonZeroExit: true,
317+
timeoutMs: 5_000,
318+
})
319+
.pipe(Effect.orElseSucceed(() => undefined));
320+
if (!listed || listed.exitCode !== 0) return [];
321+
const realCwd = yield* Effect.promise(() => NodeFSP.realpath(cwd).catch(() => cwd));
322+
const worktreeRoots = listed.stdout.split(/\n\s*\n/).flatMap((record) => {
323+
const lines = record.split("\n");
324+
const worktree = lines.find((line) => line.startsWith("worktree "));
325+
// A `prunable` record's directory is gone; another checkout may now sit there.
326+
if (worktree === undefined || lines.some((line) => line.startsWith("prunable"))) return [];
327+
const root = worktree.slice("worktree ".length);
328+
const relativeRoot = path.relative(realCwd, root);
329+
return relativeRoot === "" ||
330+
relativeRoot === ".." ||
331+
relativeRoot.startsWith(`..${path.sep}`) ||
332+
path.isAbsolute(relativeRoot)
333+
? []
334+
: [relativeRoot.split(path.sep).join("/")];
335+
});
336+
const matches = yield* Effect.promise(() =>
337+
Promise.all(
338+
worktreeRoots.map(async (relativeRoot) => {
339+
const candidate = `${relativeRoot}/${relativePath}`;
340+
const stat = await NodeFSP.stat(path.join(realCwd, candidate)).catch(() => null);
341+
return stat?.isFile() ? candidate : null;
342+
}),
343+
),
344+
);
345+
return matches
346+
.filter((candidate) => candidate !== null)
347+
.map((candidate): ProjectEntry => ({ path: candidate, kind: "file" }));
348+
});
349+
276350
const searchContents: WorkspaceEntries["Service"]["searchContents"] = Effect.fn(
277351
"WorkspaceEntries.searchContents",
278352
)(function* (input) {

0 commit comments

Comments
 (0)