From ccdd1df03935163d5fa39a36873a50c33c17b3a6 Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Sun, 8 Jun 2025 18:42:38 -0700 Subject: [PATCH] feat(core): Add .gitignore support to getFolderStructure (#865) --- .../core/src/utils/getFolderStructure.test.ts | 61 +++++++++++++++++++ packages/core/src/utils/getFolderStructure.ts | 45 ++++++++++++-- 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/packages/core/src/utils/getFolderStructure.test.ts b/packages/core/src/utils/getFolderStructure.test.ts index aecd35c5..b3e5b723 100644 --- a/packages/core/src/utils/getFolderStructure.test.ts +++ b/packages/core/src/utils/getFolderStructure.test.ts @@ -4,11 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest'; import fsPromises from 'fs/promises'; import { Dirent as FSDirent } from 'fs'; import * as nodePath from 'path'; import { getFolderStructure } from './getFolderStructure.js'; +import * as gitUtils from './gitUtils.js'; vi.mock('path', async (importOriginal) => { const original = (await importOriginal()) as typeof nodePath; @@ -20,6 +22,7 @@ vi.mock('path', async (importOriginal) => { }); vi.mock('fs/promises'); +vi.mock('./gitUtils.js'); // Import 'path' again here, it will be the mocked version import * as path from 'path'; @@ -276,3 +279,61 @@ Showing up to 3 items (files + folders). 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/...'); + }); +}); diff --git a/packages/core/src/utils/getFolderStructure.ts b/packages/core/src/utils/getFolderStructure.ts index 6d921811..419a9769 100644 --- a/packages/core/src/utils/getFolderStructure.ts +++ b/packages/core/src/utils/getFolderStructure.ts @@ -8,6 +8,8 @@ import * as fs from 'fs/promises'; import { Dirent } from 'fs'; import * as path from 'path'; import { getErrorMessage, isNodeError } from './errors.js'; +import { GitIgnoreParser, GitIgnoreFilter } from './gitIgnoreParser.js'; +import { isGitRepository } from './gitUtils.js'; const MAX_ITEMS = 200; const TRUNCATION_INDICATOR = '...'; @@ -23,13 +25,18 @@ interface FolderStructureOptions { ignoredFolders?: Set; /** Optional regex to filter included files by name. */ 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 type MergedFolderStructureOptions = Required< - Omit + Omit > & { fileIncludePattern?: RegExp; + projectRoot?: string; }; /** Represents the full, unfiltered information about a folder and its contents. */ @@ -52,6 +59,7 @@ interface FullFolderInfo { async function readFullStructure( rootPath: string, options: MergedFolderStructureOptions, + gitIgnoreFilter: GitIgnoreFilter | null, ): Promise { const rootName = path.basename(rootPath); const rootNode: FullFolderInfo = { @@ -119,6 +127,12 @@ async function readFullStructure( break; } const fileName = entry.name; + const filePath = path.join(currentPath, fileName); + if (gitIgnoreFilter) { + if (gitIgnoreFilter.isIgnored(filePath)) { + continue; + } + } if ( !options.fileIncludePattern || options.fileIncludePattern.test(fileName) @@ -148,7 +162,14 @@ async function readFullStructure( const subFolderName = entry.name; 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 = { name: subFolderName, path: subFolderPath, @@ -275,11 +296,26 @@ export async function getFolderStructure( maxItems: options?.maxItems ?? MAX_ITEMS, ignoredFolders: options?.ignoredFolders ?? DEFAULT_IGNORED_FOLDERS, 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 { // 1. Read the structure using BFS, respecting maxItems - const structureRoot = await readFullStructure(resolvedPath, mergedOptions); + const structureRoot = await readFullStructure( + resolvedPath, + mergedOptions, + gitIgnoreFilter, + ); if (!structureRoot) { return `Error: Could not read directory "${resolvedPath}". Check path and permissions.`; @@ -317,7 +353,8 @@ export async function getFolderStructure( const summary = `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) { console.error(`Error getting folder structure for ${resolvedPath}:`, error); return `Error processing directory "${resolvedPath}": ${getErrorMessage(error)}`;