Skip to content

Commit 75a87a4

Browse files
xesrevinuGit Agent
andcommitted
feat(services): enhance vcs for git ignore support
- Added ScanState interface for managing scan parameters. - Implemented ignoredPaths function to handle path filtering. - Refactored listTopLevelDirs to integrate ignore rules. This commit enhances the vcs service by supporting git ignore rules. A new ScanState interface is introduced to manage scanning configurations and the logic for filtering ignored paths has been implemented. Additionally, the listTopLevelDirs function is refactored to utilize these improvements. Co-Authored-By: Git Agent <noreply@git-agent.dev>
1 parent 5f968c0 commit 75a87a4

2 files changed

Lines changed: 175 additions & 53 deletions

File tree

src/services/vcs.ts

Lines changed: 159 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ interface IgnoreRules {
1919
readonly relativePaths: ReadonlySet<string>;
2020
}
2121

22+
interface ScanState {
23+
readonly root: string;
24+
readonly ignoreRules: IgnoreRules;
25+
readonly useGitCheckIgnore: boolean;
26+
}
27+
2228
const internalDirectoryNames = new Set([".git", ".jj"]);
2329

2430
const emptyDiff = (): VcsDiff => ({
@@ -109,6 +115,21 @@ const shouldSkipDir = (name: string, relativePath: string, ignoreRules: IgnoreRu
109115
ignoreRules.directoryNames.has(name) ||
110116
ignoreRules.relativePaths.has(toPortablePath(relativePath));
111117

118+
const ignoredPathsByFallback = (
119+
relativePaths: ReadonlyArray<string>,
120+
ignoreRules: IgnoreRules,
121+
): Set<string> => {
122+
const ignored = new Set<string>();
123+
for (const relativePath of relativePaths) {
124+
const normalized = toPortablePath(relativePath);
125+
const name = normalized.split("/").at(-1) ?? normalized;
126+
if (shouldSkipDir(name, normalized, ignoreRules)) {
127+
ignored.add(normalized);
128+
}
129+
}
130+
return ignored;
131+
};
132+
112133
const makeRunProcess =
113134
(spawner: ChildProcessSpawner["Service"]) =>
114135
(options: RunProcessOptions): Effect.Effect<ProcessResult, ProcessExecutionError> =>
@@ -219,22 +240,63 @@ export const GitClientLive = Layer.effect(
219240
} satisfies IgnoreRules;
220241
});
221242

222-
const listTopLevelDirs = Effect.fn(function* (root: string) {
243+
const ignoredPaths = Effect.fn(function* (
244+
state: ScanState,
245+
relativePaths: ReadonlyArray<string>,
246+
) {
247+
if (relativePaths.length === 0) {
248+
return new Set<string>();
249+
}
250+
const fallbackIgnored = ignoredPathsByFallback(relativePaths, state.ignoreRules);
251+
if (!state.useGitCheckIgnore) {
252+
return fallbackIgnored;
253+
}
254+
255+
const input = relativePaths.map(toPortablePath).join("\n");
256+
const result = yield* run({
257+
command: "git",
258+
args: ["check-ignore", "--stdin"],
259+
cwd: state.root,
260+
stdin: input.length > 0 ? `${input}\n` : "",
261+
allowFailure: true,
262+
});
263+
264+
if (result.exitCode === 0 || result.exitCode === 1) {
265+
return new Set([...fallbackIgnored, ...normalizeLines(result.stdout).map(toPortablePath)]);
266+
}
267+
268+
return fallbackIgnored;
269+
});
270+
271+
const buildScanState = Effect.fn(function* (root: string) {
223272
const ignoreRules = yield* loadIgnoreRules(root);
224-
const entries = yield* fs
225-
.readDirectory(root)
226-
.pipe(Effect.mapError((cause) => processError("readDirectory", cause)));
227-
const dirs = yield* Effect.filter(entries, (entry) => {
228-
if (entry.startsWith(".") || shouldSkipDir(entry, entry, ignoreRules)) {
229-
return Effect.succeed(false);
230-
}
231-
return fs.stat(path.join(root, entry)).pipe(
232-
Effect.map((info) => info.type === "Directory"),
233-
Effect.catch(() => Effect.succeed(false)),
273+
return {
274+
root,
275+
ignoreRules,
276+
useGitCheckIgnore: true,
277+
} satisfies ScanState;
278+
});
279+
280+
const listTopLevelDirs: (root: string) => Effect.Effect<Array<string>, ProcessExecutionError> =
281+
Effect.fn(function* (root: string) {
282+
const state = yield* buildScanState(root);
283+
const entries = yield* fs
284+
.readDirectory(root)
285+
.pipe(Effect.mapError((cause) => processError("readDirectory", cause)));
286+
const maybeDirectoryEntries: Array<string | undefined> = yield* Effect.forEach(
287+
entries,
288+
(entry) =>
289+
fs.stat(path.join(root, entry)).pipe(
290+
Effect.map((info) => (info.type === "Directory" ? entry : undefined)),
291+
Effect.catch(() => Effect.succeed(undefined)),
292+
),
234293
);
294+
const directoryEntries = maybeDirectoryEntries.filter(
295+
(entry): entry is string => entry != null,
296+
);
297+
const ignored = yield* ignoredPaths(state, directoryEntries);
298+
return directoryEntries.filter((entry) => !ignored.has(toPortablePath(entry))).sort();
235299
});
236-
return dirs.sort();
237-
});
238300

239301
const readGitLog = Effect.fn(function* (cwd: string, max: number) {
240302
const result = yield* run({
@@ -500,64 +562,108 @@ export const JjClientLive = Layer.effect(
500562
} satisfies IgnoreRules;
501563
});
502564

565+
const ignoredPaths = Effect.fn(function* (
566+
state: ScanState,
567+
relativePaths: ReadonlyArray<string>,
568+
) {
569+
if (relativePaths.length === 0) {
570+
return new Set<string>();
571+
}
572+
const fallbackIgnored = ignoredPathsByFallback(relativePaths, state.ignoreRules);
573+
if (!state.useGitCheckIgnore) {
574+
return fallbackIgnored;
575+
}
576+
577+
const input = relativePaths.map(toPortablePath).join("\n");
578+
const result = yield* run({
579+
command: "git",
580+
args: ["check-ignore", "--stdin"],
581+
cwd: state.root,
582+
stdin: input.length > 0 ? `${input}\n` : "",
583+
allowFailure: true,
584+
});
585+
586+
if (result.exitCode === 0 || result.exitCode === 1) {
587+
return new Set([...fallbackIgnored, ...normalizeLines(result.stdout).map(toPortablePath)]);
588+
}
589+
590+
return fallbackIgnored;
591+
});
592+
593+
const buildScanState = Effect.fn(function* (root: string) {
594+
const ignoreRules = yield* loadIgnoreRules(root);
595+
const useGitCheckIgnore = yield* fs
596+
.exists(path.join(root, ".git"))
597+
.pipe(Effect.mapError((cause) => processError("readIgnoreRules", cause)));
598+
return {
599+
root,
600+
ignoreRules,
601+
useGitCheckIgnore,
602+
} satisfies ScanState;
603+
});
604+
503605
const walkFiles: (
504-
root: string,
505-
ignoreRules: IgnoreRules,
606+
state: ScanState,
506607
cwd?: string,
507608
) => Effect.Effect<Array<string>, ProcessExecutionError> = Effect.fn(function* (
508-
root: string,
509-
ignoreRules: IgnoreRules,
510-
cwd = root,
609+
state: ScanState,
610+
cwd = state.root,
511611
) {
512612
const entries = yield* fs
513613
.readDirectory(cwd)
514614
.pipe(Effect.mapError((cause) => processError("walkFiles", cause)));
515-
const nested: Array<Array<string>> = yield* Effect.forEach(entries, (entry) =>
615+
const scanned = yield* Effect.forEach(entries, (entry) =>
516616
Effect.gen(function* () {
517-
if (entry.startsWith(".") && ![".gitignore", ".env.example", ".envrc"].includes(entry)) {
518-
const hiddenPath = path.join(cwd, entry);
519-
const hiddenInfo = yield* fs
520-
.stat(hiddenPath)
521-
.pipe(Effect.mapError((cause) => processError("walkFiles", cause)));
522-
if (hiddenInfo.type === "Directory") {
523-
return [] as Array<string>;
524-
}
525-
}
526-
527617
const fullPath = path.join(cwd, entry);
528618
const info = yield* fs
529619
.stat(fullPath)
530620
.pipe(Effect.mapError((cause) => processError("walkFiles", cause)));
531-
532-
if (info.type === "Directory") {
533-
if (shouldSkipDir(entry, path.relative(root, fullPath), ignoreRules)) {
534-
return [] as Array<string>;
535-
}
536-
return yield* walkFiles(root, ignoreRules, fullPath);
621+
return {
622+
entry,
623+
fullPath,
624+
relativePath: toPortablePath(path.relative(state.root, fullPath)),
625+
info,
626+
};
627+
}),
628+
);
629+
const ignored = yield* ignoredPaths(
630+
state,
631+
scanned.map((item) => item.relativePath),
632+
);
633+
const nested: Array<Array<string>> = yield* Effect.forEach(scanned, (item) =>
634+
Effect.gen(function* () {
635+
if (ignored.has(item.relativePath)) {
636+
return [] as Array<string>;
537637
}
538-
539-
return [path.relative(root, fullPath)];
638+
if (item.info.type === "Directory") {
639+
return yield* walkFiles(state, item.fullPath);
640+
}
641+
return [item.relativePath];
540642
}),
541643
);
542644
return nested.flat().sort();
543645
});
544646

545-
const listTopLevelDirs = Effect.fn(function* (root: string) {
546-
const ignoreRules = yield* loadIgnoreRules(root);
547-
const entries = yield* fs
548-
.readDirectory(root)
549-
.pipe(Effect.mapError((cause) => processError("readDirectory", cause)));
550-
const dirs = yield* Effect.filter(entries, (entry) => {
551-
if (entry.startsWith(".") || shouldSkipDir(entry, entry, ignoreRules)) {
552-
return Effect.succeed(false);
553-
}
554-
return fs.stat(path.join(root, entry)).pipe(
555-
Effect.map((info) => info.type === "Directory"),
556-
Effect.catch(() => Effect.succeed(false)),
647+
const listTopLevelDirs: (root: string) => Effect.Effect<Array<string>, ProcessExecutionError> =
648+
Effect.fn(function* (root: string) {
649+
const state = yield* buildScanState(root);
650+
const entries = yield* fs
651+
.readDirectory(root)
652+
.pipe(Effect.mapError((cause) => processError("readDirectory", cause)));
653+
const maybeDirectoryEntries: Array<string | undefined> = yield* Effect.forEach(
654+
entries,
655+
(entry) =>
656+
fs.stat(path.join(root, entry)).pipe(
657+
Effect.map((info) => (info.type === "Directory" ? entry : undefined)),
658+
Effect.catch(() => Effect.succeed(undefined)),
659+
),
660+
);
661+
const directoryEntries = maybeDirectoryEntries.filter(
662+
(entry): entry is string => entry != null,
557663
);
664+
const ignored = yield* ignoredPaths(state, directoryEntries);
665+
return directoryEntries.filter((entry) => !ignored.has(toPortablePath(entry))).sort();
558666
});
559-
return dirs.sort();
560-
});
561667

562668
const readGitLog = Effect.fn(function* (cwd: string, max: number) {
563669
const result = yield* run({
@@ -732,8 +838,8 @@ export const JjClientLive = Layer.effect(
732838
topLevelDirs: (cwd) => Effect.flatMap(repoRoot(cwd), listTopLevelDirs),
733839
projectFiles: Effect.fn(function* (cwd: string) {
734840
const root = yield* repoRoot(cwd);
735-
const ignoreRules = yield* loadIgnoreRules(root);
736-
const files = yield* walkFiles(root, ignoreRules);
841+
const state = yield* buildScanState(root);
842+
const files = yield* walkFiles(state);
737843
return files.slice(0, 300);
738844
}),
739845
} satisfies VcsClient;

tests/vcs.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,22 @@ describe.concurrent("Vcs", () => {
3030
expect(files).not.toContain("packages/tmp/generated.ts");
3131
}),
3232
);
33+
34+
it.effect("jj filesystem scan keeps traversing directories with negated descendants", () =>
35+
Effect.gen(function* () {
36+
const repo = yield* createJjRepo();
37+
yield* writeTextFile(repo, ".gitignore", "dist/*\n!dist/keep/\n!dist/keep/**\n");
38+
yield* writeTextFile(repo, "dist/drop/out.js", "export const drop = true;\n");
39+
yield* writeTextFile(repo, "dist/keep/in.js", "export const keep = true;\n");
40+
41+
const vcsService = yield* Vcs;
42+
const { client } = yield* vcsService.resolve(repo, "jj");
43+
const files = yield* client.projectFiles(repo);
44+
45+
expect(files).toContain("dist/keep/in.js");
46+
expect(files).not.toContain("dist/drop/out.js");
47+
}),
48+
);
3349
},
3450
);
3551
});

0 commit comments

Comments
 (0)