feat(core): Add .gitignore support to getFolderStructure (#865)
This commit is contained in:
parent
72fa01f62d
commit
ccdd1df039
|
@ -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/...');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -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)}`;
|
||||||
|
|
Loading…
Reference in New Issue