Skip to content
Open
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
80 changes: 80 additions & 0 deletions apps/server/src/provider/claudeSettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import fsPromises from "node:fs/promises";
import path from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { Effect } from "effect";
import { afterEach, describe, expect, it, vi } from "vitest";

import { extractClaudeRespectGitignore, resolveClaudeRespectGitignore } from "./claudeSettings.ts";

const runWithNodeServices = <A>(effect: Effect.Effect<A, never, NodeServices.NodeServices>) =>
Effect.runPromise(Effect.provide(effect, NodeServices.layer));

describe("extractClaudeRespectGitignore", () => {
it("reads top-level settings.json values", () => {
expect(extractClaudeRespectGitignore({ respectGitignore: false })).toBe(false);
expect(extractClaudeRespectGitignore({ respectGitignore: true })).toBe(true);
});

it("falls back to a nested legacy settings object", () => {
expect(extractClaudeRespectGitignore({ settings: { respectGitignore: false } })).toBe(false);
});

it("returns undefined for unrelated shapes", () => {
expect(extractClaudeRespectGitignore({})).toBeUndefined();
expect(extractClaudeRespectGitignore(null)).toBeUndefined();
expect(extractClaudeRespectGitignore("false")).toBeUndefined();
});
});

describe("resolveClaudeRespectGitignore", () => {
const tempDirectories: string[] = [];

afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(
tempDirectories
.splice(0)
.map((directory) => fsPromises.rm(directory, { force: true, recursive: true })),
);
});

it("defaults to respecting gitignore when no Claude setting is present", async () => {
const cwd = await fsPromises.mkdtemp(
path.join(process.env.TMPDIR ?? "/tmp", "t3code-claude-settings-cwd-"),
);
const homeDir = await fsPromises.mkdtemp(
path.join(process.env.TMPDIR ?? "/tmp", "t3code-claude-settings-home-"),
);
tempDirectories.push(cwd, homeDir);

await expect(
runWithNodeServices(resolveClaudeRespectGitignore(cwd, { homeDirectory: homeDir })),
).resolves.toBe(true);
});

it("applies project-local settings over user settings", async () => {
const cwd = await fsPromises.mkdtemp(
path.join(process.env.TMPDIR ?? "/tmp", "t3code-claude-settings-cwd-"),
);
const homeDir = await fsPromises.mkdtemp(
path.join(process.env.TMPDIR ?? "/tmp", "t3code-claude-settings-home-"),
);
tempDirectories.push(cwd, homeDir);

await fsPromises.mkdir(path.join(homeDir, ".claude"), { recursive: true });
await fsPromises.mkdir(path.join(cwd, ".claude"), { recursive: true });
await fsPromises.writeFile(
path.join(homeDir, ".claude", "settings.json"),
'{"respectGitignore":true}',
);
await fsPromises.writeFile(
path.join(cwd, ".claude", "settings.local.json"),
'{"respectGitignore":false}',
);

await expect(
runWithNodeServices(resolveClaudeRespectGitignore(cwd, { homeDirectory: homeDir })),
).resolves.toBe(false);
});
});
71 changes: 71 additions & 0 deletions apps/server/src/provider/claudeSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import * as OS from "node:os";
import { Effect, FileSystem, Path } from "effect";

function getBooleanProperty(record: Record<string, unknown>, key: string): boolean | undefined {
const value = record[key];
return typeof value === "boolean" ? value : undefined;
}

export function extractClaudeRespectGitignore(value: unknown): boolean | undefined {
if (value === null || typeof value !== "object") {
return undefined;
}

const record = value as Record<string, unknown>;
const topLevel = getBooleanProperty(record, "respectGitignore");
if (topLevel !== undefined) {
return topLevel;
}

const nestedSettings = record.settings;
if (nestedSettings === null || typeof nestedSettings !== "object") {
return undefined;
}

return getBooleanProperty(nestedSettings as Record<string, unknown>, "respectGitignore");
}

function readClaudeRespectGitignoreFromFile(settingsPath: string) {
return Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const contents = yield* fileSystem
.readFileString(settingsPath)
.pipe(Effect.catch(() => Effect.succeed(null)));

if (contents === null) {
return undefined;
}

return yield* Effect.sync(() => {
try {
return extractClaudeRespectGitignore(JSON.parse(contents));
} catch {
return undefined;
}
});
});
}

export function resolveClaudeRespectGitignore(cwd: string, options?: { homeDirectory?: string }) {
return Effect.gen(function* () {
const path = yield* Path.Path;
const homeDirectory =
options?.homeDirectory ?? process.env.HOME ?? process.env.USERPROFILE ?? OS.homedir();
const candidatePaths = [
path.join(homeDirectory, ".claude.json"),
path.join(homeDirectory, ".claude", "settings.json"),
path.join(cwd, ".claude", "settings.json"),
path.join(cwd, ".claude", "settings.local.json"),
];

let respectGitignore = true;
for (const candidatePath of candidatePaths) {
const nextValue = yield* readClaudeRespectGitignoreFromFile(candidatePath);
if (nextValue !== undefined) {
respectGitignore = nextValue;
}
}

return respectGitignore;
});
}
34 changes: 34 additions & 0 deletions apps/server/src/workspace/Layers/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,40 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
}),
);

it.effect("includes gitignored paths when Claude disables gitignore filtering", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({
prefix: "t3code-workspace-include-gitignored-",
git: true,
});
const homeDir = yield* makeTempDir({ prefix: "t3code-claude-home-" });
const previousHome = process.env.HOME;
process.env.HOME = homeDir;
try {
yield* writeTextFile(homeDir, ".claude/settings.json", '{"respectGitignore":false}');
yield* writeTextFile(cwd, ".gitignore", ".agent/\nignored.txt\n");
yield* writeTextFile(cwd, "src/keep.ts", "export {};");
yield* writeTextFile(cwd, ".agent/local-instructions.md", "# secret");
yield* writeTextFile(cwd, "ignored.txt", "ignore me");

const result = yield* searchWorkspaceEntries({ cwd, query: "", limit: 100 });
const paths = result.entries.map((entry) => entry.path);

expect(paths).toContain("src");
expect(paths).toContain("src/keep.ts");
expect(paths).toContain(".agent");
expect(paths).toContain(".agent/local-instructions.md");
expect(paths).toContain("ignored.txt");
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
}
}),
);

it.effect("excludes tracked paths that match ignore rules", () =>
Effect.gen(function* () {
const cwd = yield* makeTempDir({
Expand Down
74 changes: 63 additions & 11 deletions apps/server/src/workspace/Layers/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import fsPromises from "node:fs/promises";
import type { Dirent } from "node:fs";

import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect";
import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Option, Path } from "effect";

import { type ProjectEntry } from "@t3tools/contracts";

import { GitCore } from "../../git/Services/GitCore.ts";
import { resolveClaudeRespectGitignore } from "../../provider/claudeSettings.ts";
import {
WorkspaceEntries,
WorkspaceEntriesError,
Expand All @@ -17,6 +18,7 @@ const WORKSPACE_CACHE_TTL_MS = 15_000;
const WORKSPACE_CACHE_MAX_KEYS = 4;
const WORKSPACE_INDEX_MAX_ENTRIES = 25_000;
const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32;
const CLAUDE_SETTINGS_CACHE_TTL_MS = 5_000;
const IGNORED_DIRECTORY_NAMES = new Set([
".git",
".convex",
Expand Down Expand Up @@ -45,6 +47,11 @@ interface RankedWorkspaceEntry {
score: number;
}

class WorkspaceIndexCacheKey extends Data.Class<{
cwd: string;
respectGitignore: boolean;
}> {}

function toPosixPath(input: string): string {
return input.replaceAll("\\", "/");
}
Expand Down Expand Up @@ -219,8 +226,17 @@ const processErrorDetail = (cause: unknown): string =>

export const makeWorkspaceEntries = Effect.gen(function* () {
const path = yield* Path.Path;
const fileSystem = yield* FileSystem.FileSystem;
const gitOption = yield* Effect.serviceOption(GitCore);
const workspacePaths = yield* WorkspacePaths;
const resolveClaudeRespectGitignoreWithServices = (
cwd: string,
options?: { homeDirectory?: string },
) =>
resolveClaudeRespectGitignore(cwd, options).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
);

const isInsideGitWorkTree = (cwd: string): Effect.Effect<boolean> =>
Option.match(gitOption, {
Expand All @@ -242,10 +258,13 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
});

const buildWorkspaceIndexFromGit = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromGit")(
function* (cwd: string) {
function* (cwd: string, respectGitignore: boolean) {
if (Option.isNone(gitOption)) {
return null;
}
if (!respectGitignore) {
return null;
}
if (!(yield* isInsideGitWorkTree(cwd))) {
return null;
}
Expand Down Expand Up @@ -332,8 +351,11 @@ export const makeWorkspaceEntries = Effect.gen(function* () {

const buildWorkspaceIndexFromFilesystem = Effect.fn(
"WorkspaceEntries.buildWorkspaceIndexFromFilesystem",
)(function* (cwd: string): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> {
const shouldFilterWithGitIgnore = yield* isInsideGitWorkTree(cwd);
)(function* (
cwd: string,
respectGitignore: boolean,
): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> {
const shouldFilterWithGitIgnore = respectGitignore && (yield* isInsideGitWorkTree(cwd));

let pendingDirectories: string[] = [""];
const entries: SearchableWorkspaceEntry[] = [];
Expand Down Expand Up @@ -421,16 +443,22 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
});

const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* (
cwd: string,
key: WorkspaceIndexCacheKey,
): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> {
const gitIndexed = yield* buildWorkspaceIndexFromGit(cwd);
const gitIndexed = yield* buildWorkspaceIndexFromGit(key.cwd, key.respectGitignore);
if (gitIndexed) {
return gitIndexed;
}
return yield* buildWorkspaceIndexFromFilesystem(cwd);
// `git ls-files --exclude-standard` cannot produce the mixed tracked/untracked/ignored
// set the picker needs when Claude disables `.gitignore` filtering.
return yield* buildWorkspaceIndexFromFilesystem(key.cwd, key.respectGitignore);
});

const workspaceIndexCache = yield* Cache.makeWith<string, WorkspaceIndex, WorkspaceEntriesError>({
const workspaceIndexCache = yield* Cache.makeWith<
WorkspaceIndexCacheKey,
WorkspaceIndex,
WorkspaceEntriesError
>({
capacity: WORKSPACE_CACHE_MAX_KEYS,
lookup: buildWorkspaceIndex,
timeToLive: (exit) =>
Expand Down Expand Up @@ -458,17 +486,41 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe(
Effect.catch(() => Effect.succeed(cwd)),
);
yield* Cache.invalidate(workspaceIndexCache, cwd);
yield* Cache.invalidate(
workspaceIndexCache,
new WorkspaceIndexCacheKey({ cwd, respectGitignore: true }),
);
yield* Cache.invalidate(
workspaceIndexCache,
new WorkspaceIndexCacheKey({ cwd, respectGitignore: false }),
);
if (normalizedCwd !== cwd) {
yield* Cache.invalidate(workspaceIndexCache, normalizedCwd);
yield* Cache.invalidate(
workspaceIndexCache,
new WorkspaceIndexCacheKey({ cwd: normalizedCwd, respectGitignore: true }),
);
yield* Cache.invalidate(
workspaceIndexCache,
new WorkspaceIndexCacheKey({ cwd: normalizedCwd, respectGitignore: false }),
);
}
},
);

const claudeRespectGitignoreCache = yield* Cache.makeWith<string, boolean, never>({
capacity: WORKSPACE_CACHE_MAX_KEYS,
lookup: resolveClaudeRespectGitignoreWithServices,
timeToLive: () => Duration.millis(CLAUDE_SETTINGS_CACHE_TTL_MS),
});

const search: WorkspaceEntriesShape["search"] = Effect.fn("WorkspaceEntries.search")(
function* (input) {
const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe(
const respectGitignore = yield* Cache.get(claudeRespectGitignoreCache, normalizedCwd);
return yield* Cache.get(
workspaceIndexCache,
new WorkspaceIndexCacheKey({ cwd: normalizedCwd, respectGitignore }),
).pipe(
Effect.map((index) => {
const normalizedQuery = normalizeQuery(input.query);
const limit = Math.max(0, Math.floor(input.limit));
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
description: `${providerLabel} · ${slug}`,
}));
}, [composerTrigger, searchableModelOptions, workspaceEntries]);
const workspaceEntriesPartialResultsHint =
!workspaceEntriesQuery.isLoading &&
composerTrigger?.kind === "path" &&
workspaceEntriesQuery.data?.truncated
? workspaceEntries.length === 0
? "Workspace results are partial. Refine your query to search more precisely."
: "Showing partial results. Refine your query to narrow the file search."
: null;
const composerMenuOpen = Boolean(composerTrigger);
const activeComposerMenuItem = useMemo(
() =>
Expand Down Expand Up @@ -3919,6 +3927,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
resolvedTheme={resolvedTheme}
isLoading={isComposerMenuLoading}
triggerKind={composerTriggerKind}
partialResultsHint={workspaceEntriesPartialResultsHint}
activeItemId={activeComposerMenuItem?.id ?? null}
onHighlightedItemChange={onComposerMenuItemHighlighted}
onSelect={onSelectComposerItem}
Expand Down
Loading
Loading