feat(core): Add .gitignore support to getFolderStructure (#865)

This commit is contained in:
N. Taylor Mullen 2025-06-08 18:42:38 -07:00 committed by GitHub
parent 72fa01f62d
commit ccdd1df039
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 102 additions and 4 deletions

View File

@ -4,11 +4,13 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
import fsPromises from 'fs/promises'; import fsPromises from 'fs/promises';
import { Dirent as FSDirent } from 'fs'; import { Dirent as FSDirent } from 'fs';
import * as nodePath from 'path'; import * as nodePath from 'path';
import { getFolderStructure } from './getFolderStructure.js'; import { getFolderStructure } from './getFolderStructure.js';
import * as gitUtils from './gitUtils.js';
vi.mock('path', async (importOriginal) => { vi.mock('path', async (importOriginal) => {
const original = (await importOriginal()) as typeof nodePath; const original = (await importOriginal()) as typeof nodePath;
@ -20,6 +22,7 @@ vi.mock('path', async (importOriginal) => {
}); });
vi.mock('fs/promises'); vi.mock('fs/promises');
vi.mock('./gitUtils.js');
// Import 'path' again here, it will be the mocked version // Import 'path' again here, it will be the mocked version
import * as path from 'path'; import * as path from 'path';
@ -276,3 +279,61 @@ Showing up to 3 items (files + folders).
expect(structure.trim()).toBe(expected); expect(structure.trim()).toBe(expected);
}); });
}); });
describe('getFolderStructure gitignore', () => {
beforeEach(() => {
vi.resetAllMocks();
(path.resolve as Mock).mockImplementation((str: string) => str);
(fsPromises.readdir as Mock).mockImplementation(async (p) => {
const path = p.toString();
if (path === '/test/project') {
return [
createDirent('file1.txt', 'file'),
createDirent('node_modules', 'dir'),
createDirent('ignored.txt', 'file'),
createDirent('.gemini', 'dir'),
] as any;
}
if (path === '/test/project/node_modules') {
return [createDirent('some-package', 'dir')] as any;
}
if (path === '/test/project/.gemini') {
return [
createDirent('config.yaml', 'file'),
createDirent('logs.json', 'file'),
] as any;
}
return [];
});
(fsPromises.readFile as Mock).mockImplementation(async (p) => {
const path = p.toString();
if (path === '/test/project/.gitignore') {
return 'ignored.txt\nnode_modules/\n.gemini/\n!/.gemini/config.yaml';
}
return '';
});
vi.mocked(gitUtils.isGitRepository).mockReturnValue(true);
});
it('should ignore files and folders specified in .gitignore', async () => {
const structure = await getFolderStructure('/test/project', {
projectRoot: '/test/project',
});
expect(structure).not.toContain('ignored.txt');
expect(structure).toContain('node_modules/...');
expect(structure).not.toContain('logs.json');
});
it('should not ignore files if respectGitIgnore is false', async () => {
const structure = await getFolderStructure('/test/project', {
projectRoot: '/test/project',
respectGitIgnore: false,
});
expect(structure).toContain('ignored.txt');
// node_modules is still ignored by default
expect(structure).toContain('node_modules/...');
});
});

View File

@ -8,6 +8,8 @@ import * as fs from 'fs/promises';
import { Dirent } from 'fs'; import { Dirent } from 'fs';
import * as path from 'path'; import * as path from 'path';
import { getErrorMessage, isNodeError } from './errors.js'; import { getErrorMessage, isNodeError } from './errors.js';
import { GitIgnoreParser, GitIgnoreFilter } from './gitIgnoreParser.js';
import { isGitRepository } from './gitUtils.js';
const MAX_ITEMS = 200; const MAX_ITEMS = 200;
const TRUNCATION_INDICATOR = '...'; const TRUNCATION_INDICATOR = '...';
@ -23,13 +25,18 @@ interface FolderStructureOptions {
ignoredFolders?: Set<string>; ignoredFolders?: Set<string>;
/** Optional regex to filter included files by name. */ /** Optional regex to filter included files by name. */
fileIncludePattern?: RegExp; fileIncludePattern?: RegExp;
/** Whether to respect .gitignore patterns. Defaults to true. */
respectGitIgnore?: boolean;
/** The root of the project, used for gitignore resolution. */
projectRoot?: string;
} }
// Define a type for the merged options where fileIncludePattern remains optional // Define a type for the merged options where fileIncludePattern remains optional
type MergedFolderStructureOptions = Required< type MergedFolderStructureOptions = Required<
Omit<FolderStructureOptions, 'fileIncludePattern'> Omit<FolderStructureOptions, 'fileIncludePattern' | 'projectRoot'>
> & { > & {
fileIncludePattern?: RegExp; fileIncludePattern?: RegExp;
projectRoot?: string;
}; };
/** Represents the full, unfiltered information about a folder and its contents. */ /** Represents the full, unfiltered information about a folder and its contents. */
@ -52,6 +59,7 @@ interface FullFolderInfo {
async function readFullStructure( async function readFullStructure(
rootPath: string, rootPath: string,
options: MergedFolderStructureOptions, options: MergedFolderStructureOptions,
gitIgnoreFilter: GitIgnoreFilter | null,
): Promise<FullFolderInfo | null> { ): Promise<FullFolderInfo | null> {
const rootName = path.basename(rootPath); const rootName = path.basename(rootPath);
const rootNode: FullFolderInfo = { const rootNode: FullFolderInfo = {
@ -119,6 +127,12 @@ async function readFullStructure(
break; break;
} }
const fileName = entry.name; const fileName = entry.name;
const filePath = path.join(currentPath, fileName);
if (gitIgnoreFilter) {
if (gitIgnoreFilter.isIgnored(filePath)) {
continue;
}
}
if ( if (
!options.fileIncludePattern || !options.fileIncludePattern ||
options.fileIncludePattern.test(fileName) options.fileIncludePattern.test(fileName)
@ -148,7 +162,14 @@ async function readFullStructure(
const subFolderName = entry.name; const subFolderName = entry.name;
const subFolderPath = path.join(currentPath, subFolderName); const subFolderPath = path.join(currentPath, subFolderName);
if (options.ignoredFolders.has(subFolderName)) { let isIgnoredByGit = false;
if (gitIgnoreFilter) {
if (gitIgnoreFilter.isIgnored(subFolderPath)) {
isIgnoredByGit = true;
}
}
if (options.ignoredFolders.has(subFolderName) || isIgnoredByGit) {
const ignoredSubFolder: FullFolderInfo = { const ignoredSubFolder: FullFolderInfo = {
name: subFolderName, name: subFolderName,
path: subFolderPath, path: subFolderPath,
@ -275,11 +296,26 @@ export async function getFolderStructure(
maxItems: options?.maxItems ?? MAX_ITEMS, maxItems: options?.maxItems ?? MAX_ITEMS,
ignoredFolders: options?.ignoredFolders ?? DEFAULT_IGNORED_FOLDERS, ignoredFolders: options?.ignoredFolders ?? DEFAULT_IGNORED_FOLDERS,
fileIncludePattern: options?.fileIncludePattern, fileIncludePattern: options?.fileIncludePattern,
respectGitIgnore: options?.respectGitIgnore ?? true,
projectRoot: options?.projectRoot ?? resolvedPath,
}; };
let gitIgnoreFilter: GitIgnoreFilter | null = null;
if (mergedOptions.respectGitIgnore && mergedOptions.projectRoot) {
if (isGitRepository(mergedOptions.projectRoot)) {
const parser = new GitIgnoreParser(mergedOptions.projectRoot);
await parser.initialize();
gitIgnoreFilter = parser;
}
}
try { try {
// 1. Read the structure using BFS, respecting maxItems // 1. Read the structure using BFS, respecting maxItems
const structureRoot = await readFullStructure(resolvedPath, mergedOptions); const structureRoot = await readFullStructure(
resolvedPath,
mergedOptions,
gitIgnoreFilter,
);
if (!structureRoot) { if (!structureRoot) {
return `Error: Could not read directory "${resolvedPath}". Check path and permissions.`; return `Error: Could not read directory "${resolvedPath}". Check path and permissions.`;
@ -317,7 +353,8 @@ export async function getFolderStructure(
const summary = const summary =
`Showing up to ${mergedOptions.maxItems} items (files + folders). ${disclaimer}`.trim(); `Showing up to ${mergedOptions.maxItems} items (files + folders). ${disclaimer}`.trim();
return `${summary}\n\n${displayPath}/\n${structureLines.join('\n')}`; const output = `${summary}\n\n${displayPath}/\n${structureLines.join('\n')}`;
return output;
} catch (error: unknown) { } catch (error: unknown) {
console.error(`Error getting folder structure for ${resolvedPath}:`, error); console.error(`Error getting folder structure for ${resolvedPath}:`, error);
return `Error processing directory "${resolvedPath}": ${getErrorMessage(error)}`; return `Error processing directory "${resolvedPath}": ${getErrorMessage(error)}`;