/** * Gitignore-aware file filtering * * Two strategies: * 1. Git repo: Use `git ls-files -co --exclude-standard` (perfect accuracy) * 2. Non-git repo: Parse .gitignore files manually (fallback) * * Handles nested .gitignore files in subdirectories with proper scoping. */ import fs from 'fs/promises'; import path from 'path'; import { debug } from '$shared/utils/logger'; import { execGit } from '../git/git-executor'; import { findNestedRepoPaths } from '../git/nested-repos'; /** * Safety-net directories to always exclude regardless of .gitignore. * These are never useful in snapshots and could be massive. */ const ALWAYS_EXCLUDE_DIRS = new Set([ '.git', 'node_modules', ]); /** * Get list of snapshot-eligible files using git (preferred) or manual scan. * Returns full absolute paths. */ export async function getSnapshotFiles(projectPath: string): Promise { // Try git-based scan first (handles all .gitignore rules perfectly) const gitFiles = await scanWithGit(projectPath); if (gitFiles !== null) { return gitFiles; } // Fallback: manual scan with .gitignore parsing return scanWithGitignoreParsing(projectPath); } // ============================================================================ // Strategy 1: Git-based scanning // ============================================================================ async function scanWithGit(dirPath: string): Promise { // Check if this is a git repo try { await fs.access(path.join(dirPath, '.git')); } catch { return null; } try { // Scan the outer repo const files = await scanSingleGitRepo(dirPath); // Find and scan nested git repos — separate repositories living inside // the project (e.g. a theme extracted into its own repo). The parent // repo's `git ls-files --exclude-standard` skips these because git treats // a nested repo as a single gitlink entry (or excludes it entirely when // it's listed in the parent's .gitignore). Without this step, files // inside nested repos are invisible to the snapshot system, so AI // changes to them never appear in the checkpoint banner. const nestedFiles = await findAndScanNestedRepos(dirPath); const allFiles = [...files, ...nestedFiles]; debug.log('snapshot', `Git scan found ${allFiles.length} files (${files.length} outer, ${nestedFiles.length} nested)`); return allFiles; } catch (err) { debug.warn('snapshot', 'git ls-files failed, falling back to manual scan:', err); return null; } } /** * Run `git ls-files -co --exclude-standard` in a single git repo and return * absolute paths. Used for both the outer repo and nested repos. */ async function scanSingleGitRepo(dirPath: string): Promise { try { const result = await execGit(['ls-files', '-co', '--exclude-standard'], dirPath, 60_000); if (result.exitCode !== 0) return []; const files: string[] = []; for (const line of result.stdout.split('\n')) { const relativePath = line.trim(); if (!relativePath) continue; // Skip always-excluded directories const firstSegment = relativePath.split('/')[0]; if (ALWAYS_EXCLUDE_DIRS.has(firstSegment)) continue; // Skip directory entries — git emits nested repos as a single // entry with a trailing slash (e.g. `theme/`). These are handled // by findAndScanNestedRepos, not here. if (relativePath.endsWith('/')) continue; files.push(path.join(dirPath, relativePath)); } return files; } catch (err) { debug.warn('snapshot', `git ls-files failed for ${dirPath}:`, err); return []; } } /** * Scan every nested git repo inside the project and return their files. * Discovery is delegated to `findNestedRepoPaths` — the same source of truth * the Git panel uses — so a repo the panel shows is a repo the snapshot * captures, and a package-manager checkout neither of them touches. Each repo * is then scanned with its own `git ls-files --exclude-standard`, which applies * that repo's .gitignore rules. */ async function findAndScanNestedRepos(rootPath: string): Promise { const files: string[] = []; for (const repoPath of await findNestedRepoPaths(rootPath)) { files.push(...await scanSingleGitRepo(repoPath)); } return files; } // ============================================================================ // Strategy 2: Manual scan with .gitignore parsing // ============================================================================ /** * Rule from a .gitignore file. * scope = relative directory containing the .gitignore ('' for root). */ interface IgnoreRule { pattern: RegExp; negate: boolean; dirOnly: boolean; // pattern ends with / scope: string; // relative dir of the .gitignore that defined this rule } /** * Parse a single .gitignore line into an IgnoreRule (or null if comment/blank). */ function parseGitignoreLine(line: string, scope: string): IgnoreRule | null { // Strip trailing whitespace (unless escaped) let trimmed = line.replace(/(? { try { const content = await fs.readFile(filepath, 'utf-8'); for (const line of content.split('\n')) { const rule = parseGitignoreLine(line, scope); if (rule) this.rules.push(rule); } } catch { // File doesn't exist or can't be read - no rules to add } } /** * Check if a path should be ignored. * @param relativePath - Path relative to project root (forward slashes) * @param isDirectory - Whether the path is a directory */ isIgnored(relativePath: string, isDirectory: boolean): boolean { let ignored = false; for (const rule of this.rules) { // Skip dir-only rules for files if (rule.dirOnly && !isDirectory) continue; // Check scope: rule only applies within its .gitignore directory if (rule.scope && !relativePath.startsWith(rule.scope + '/') && relativePath !== rule.scope) { continue; } if (rule.pattern.test(relativePath)) { ignored = !rule.negate; } } return ignored; } } /** * Scan directory manually, parsing .gitignore files at each level. */ async function scanWithGitignoreParsing(projectPath: string): Promise { const files: string[] = []; const filter = new GitignoreFilter(); // Load root .gitignore await filter.loadFromFile(path.join(projectPath, '.gitignore'), ''); const scan = async (currentPath: string): Promise => { try { const entries = await fs.readdir(currentPath, { withFileTypes: true }); const relativeDir = path.relative(projectPath, currentPath).replace(/\\/g, '/'); // Load .gitignore in this directory (if not root - root already loaded) if (relativeDir) { await filter.loadFromFile(path.join(currentPath, '.gitignore'), relativeDir); } for (const entry of entries) { const fullPath = path.join(currentPath, entry.name); const relativePath = path.relative(projectPath, fullPath).replace(/\\/g, '/'); // Always exclude certain directories if (ALWAYS_EXCLUDE_DIRS.has(entry.name)) continue; if (entry.isDirectory()) { // Check if this is a nested git repo — scan it independently // and bypass the .gitignore filter so files inside a // gitignored nested repo are still tracked. try { await fs.access(path.join(fullPath, '.git')); const nestedFiles = await scanSingleGitRepo(fullPath); files.push(...nestedFiles); continue; } catch { // Not a git repo — apply gitignore filter and recurse } if (!filter.isIgnored(relativePath, true)) { await scan(fullPath); } } else if (entry.isFile()) { if (!filter.isIgnored(relativePath, false)) { files.push(fullPath); } } } } catch (err) { debug.warn('snapshot', `Could not read directory ${currentPath}:`, err); } }; await scan(projectPath); debug.log('snapshot', `Manual scan found ${files.length} files`); return files; }