diff --git a/docs/capabilities.md b/docs/capabilities.md index 59806d6..17629cf 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -20,6 +20,7 @@ Config-registered project roots (from `~/.codebase-context/config.json`) are loa Per-project config overrides supported today: - `projects[].excludePatterns`: merged with the built-in exclusion set for that project at index time +- `projects[].includeExcluded`: root-relative source globs explicitly restored through built-in exclusions, `excludePatterns` and Git ignores (for example, `[".agents/tools/**/*.ts"]`). Normal `include` patterns narrow the scan and cannot override exclusions. Binary/unsupported files, files over the size limit and paths outside the project remain excluded. - `projects[].analyzerHints.analyzer`: prefers a registered analyzer by name for that project and falls back safely when the name is missing or invalid - `projects[].analyzerHints.extensions`: adds project-local source extensions for indexing and auto-refresh watching without changing defaults for other projects - `projects[].parsing.maxChunks`: sets the maximum number of searchable chunks indexed for that project @@ -262,6 +263,12 @@ Impact is 2-hop transitive: direct importers (hop 1) and their importers (hop 2) ## Indexing +Inside a Git worktree, indexing uses `git ls-files -co --exclude-standard`: tracked files and untracked files that are not ignored remain eligible, including nested `.gitignore`, `.git/info/exclude` and Git's global excludes. Tracked files stay eligible even if a later ignore rule matches them. Outside Git (or without Git installed), the existing root `.gitignore` behavior remains. Library callers can set `respectGitignore: false` to disable Git filtering. + +The index additionally skips `out`, `.work`, `.agents`, `.codex`, `.cursor`, `.github/agents`, lockfiles (`*.lock`, `*.lockb`, `package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `packages.lock.json`), minified JavaScript/CSS and source maps. These new filters apply only to indexing, preserving file-watcher and project-discovery behavior. This lets watchers observe explicitly restored source in those directories. Previously excluded directories such as `coverage` still require a manual `reindex` after edits when opted in. + +To index selected excluded source, add `"includeExcluded": [".agents/tools/**/*.ts"]` to that project's entry in `~/.codebase-context/config.json` (with its absolute `root`). Both MCP indexing and CLI `reindex` use it. Library callers pass the same array as `config.includeExcluded` to `CodebaseIndexer`. Use narrow globs: these files become searchable even when Git ignores them. Run a full `reindex` after changing selection settings to remove previously indexed junk. + - Initial: full scan → chunking (50 lines, 0 overlap) → embedding → vector DB (LanceDB) + keyword index (Fuse.js) - Incremental: SHA-256 manifest diffing, selective embed/delete, full intelligence regeneration - Auto-refresh (MCP server mode only): chokidar file watcher triggers incremental reindex after a debounce on any source file change — `node_modules/`, `.git/`, `dist/`, and `.codebase-context/` are excluded. One-shot CLI runs skip the watcher entirely. diff --git a/src/cli.ts b/src/cli.ts index 2e5f17e..0498df7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -138,7 +138,12 @@ async function initToolContext(): Promise { let lastLoggedProgress = { phase: '', percentage: -1 }; const indexer = new CodebaseIndexer({ rootPath, - ...(projectConfig?.parsing ? { config: { parsing: projectConfig.parsing } } : {}), + config: { + ...(projectConfig?.parsing ? { parsing: projectConfig.parsing } : {}), + ...(projectConfig?.includeExcluded + ? { includeExcluded: projectConfig.includeExcluded } + : {}) + }, incrementalOnly, onProgress: (progress) => { const shouldLog = diff --git a/src/constants/codebase-context.ts b/src/constants/codebase-context.ts index 473748f..502cc60 100644 --- a/src/constants/codebase-context.ts +++ b/src/constants/codebase-context.ts @@ -56,6 +56,23 @@ export const EXCLUDED_GLOB_PATTERNS: readonly string[] = EXCLUDED_DIRECTORY_NAME (dir) => `**/${dir}/**` ); +/** Index-only non-source files. Watchers still observe them for live updates. */ +export const INDEX_ONLY_EXCLUDED_GLOB_PATTERNS: readonly string[] = [ + ...['out', '.work', '.agents', '.codex', '.cursor'].map((dir) => `**/${dir}/**`), + '**/.github/agents/**', + '**/.github/FUNDING.yml', + '**/.github/ISSUE_TEMPLATE/**', + '**/*.lock', + '**/*.lockb', + '**/package-lock.json', + '**/npm-shrinkwrap.json', + '**/pnpm-lock.yaml', + '**/packages.lock.json', + '**/*.min.js', + '**/*.min.css', + '**/*.map' +]; + /** * Additional directories skipped only during project discovery (not generated * code, just not useful roots to recurse into). diff --git a/src/core/indexer.ts b/src/core/indexer.ts index 93aebbe..3c951d8 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -4,6 +4,8 @@ */ import { randomUUID } from 'crypto'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; import { promises as fs } from 'fs'; import path from 'path'; import { glob } from 'glob'; @@ -23,6 +25,7 @@ import { import { analyzerRegistry } from './analyzer-registry.js'; import type { AnalyzerSelectionOptions } from './analyzer-registry.js'; import { getSupportedExtensions, isBinaryFile, isCodeFile } from '../utils/language-detection.js'; +import { gitEnvironmentWithoutLocalRepositoryContext } from '../utils/git-environment.js'; import { getEmbeddingProvider, getConfiguredDimensions, @@ -42,6 +45,7 @@ import { getFileCommitDates } from '../utils/git-dates.js'; import { CODEBASE_CONTEXT_DIRNAME, EXCLUDED_GLOB_PATTERNS, + INDEX_ONLY_EXCLUDED_GLOB_PATTERNS, HEALTH_FILENAME, INDEX_FORMAT_VERSION, INDEXING_STATS_FILENAME, @@ -58,6 +62,7 @@ import { deriveCodebaseHealth } from '../health/derive.js'; const STAGING_DIRNAME = '.staging'; const PREVIOUS_DIRNAME = '.previous'; const DEFAULT_MAX_CHUNKS = 5000; +const execFileAsync = promisify(execFile); import { computeFileHashes, @@ -1091,13 +1096,65 @@ export class CodebaseIndexer { } } + private async gitFiles(): Promise | null> { + let gitEnvironment: NodeJS.ProcessEnv; + + try { + gitEnvironment = gitEnvironmentWithoutLocalRepositoryContext(); + const { stdout } = await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { + cwd: this.rootPath, + env: { ...gitEnvironment, LC_ALL: 'C' } + }); + if (stdout.trim() !== 'true') return null; + } catch (error) { + const gitError = error as { code?: string | number; stderr?: string }; + // Keep the existing root-.gitignore fallback outside Git or without Git installed. + if ( + gitError.code === 'ENOENT' || + (gitError.code === 128 && gitError.stderr?.includes('not a git repository')) + ) + return null; + throw error; + } + + return new Promise((resolve, reject) => { + const git = spawn('git', ['ls-files', '-co', '--exclude-standard', '-z'], { + cwd: this.rootPath, + env: gitEnvironment, + stdio: ['ignore', 'pipe', 'pipe'] + }); + const files = new Set(); + let pending = ''; + let stderr = ''; + git.stdout.setEncoding('utf8'); + git.stdout.on('data', (chunk: string) => { + const paths = (pending + chunk).split('\0'); + pending = paths.pop() ?? ''; + for (const file of paths) if (file) files.add(file); + }); + git.stderr.setEncoding('utf8'); + git.stderr.on('data', (chunk: string) => { + stderr = (stderr + chunk).slice(-4096); + }); + git.on('error', reject); + git.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Git file discovery failed (${code}): ${stderr.trim()}`)); + } else { + resolve(files); + } + }); + }); + } + private async scanFiles(): Promise { const files: string[] = []; const seen = new Set(); - // Read .gitignore if respecting it + const gitFiles = this.config.respectGitignore ? await this.gitFiles() : null; + // Outside Git, preserve the existing root-only .gitignore behavior. let ig: ReturnType | null = null; - if (this.config.respectGitignore) { + if (this.config.respectGitignore && !gitFiles) { try { const gitignorePath = path.join(this.rootPath, '.gitignore'); const gitignoreContent = await fs.readFile(gitignorePath, 'utf-8'); @@ -1108,14 +1165,17 @@ export class CodebaseIndexer { } // Scan with glob - const includePatterns = this.getIncludePatterns(); - const excludePatterns = this.config.exclude || []; + const excludePatterns = [...INDEX_ONLY_EXCLUDED_GLOB_PATTERNS, ...(this.config.exclude || [])]; - for (const pattern of includePatterns) { - const matches = await glob(pattern, { + for (const selection of [ + { patterns: this.getIncludePatterns(), explicit: false }, + { patterns: this.config.includeExcluded ?? [], explicit: true } + ]) { + const matches = await glob(selection.patterns, { cwd: this.rootPath, absolute: true, - ignore: excludePatterns, + ignore: selection.explicit ? [] : excludePatterns, + dot: selection.explicit || gitFiles !== null, nodir: true }); @@ -1124,12 +1184,13 @@ export class CodebaseIndexer { if (seen.has(normalizedFile)) { continue; } - seen.add(normalizedFile); - - const relativePath = path.relative(this.rootPath, file); + const relativePath = path.relative(this.rootPath, file).replace(/\\/g, '/'); + if (relativePath.startsWith('../') || path.isAbsolute(relativePath)) continue; - // Check gitignore - if (ig && ig.ignores(relativePath)) { + if ( + !selection.explicit && + ((gitFiles && !gitFiles.has(relativePath)) || (ig && ig.ignores(relativePath))) + ) { continue; } @@ -1149,6 +1210,7 @@ export class CodebaseIndexer { continue; } + seen.add(normalizedFile); files.push(file); } } diff --git a/src/index.ts b/src/index.ts index 3e7a989..3115622 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1253,21 +1253,19 @@ async function performIndexingOnce( let lastLoggedProgress = { phase: '', percentage: -1 }; const indexer = new CodebaseIndexer({ rootPath: project.rootPath, - ...(project.runtimeOverrides.extraExcludePatterns?.length - ? { - config: { - exclude: [ - ...EXCLUDED_GLOB_PATTERNS, - ...project.runtimeOverrides.extraExcludePatterns - ], - ...(project.runtimeOverrides.maxChunks - ? { parsing: { maxChunks: project.runtimeOverrides.maxChunks } } - : {}) + config: { + ...(project.runtimeOverrides.extraExcludePatterns?.length + ? { + exclude: [...EXCLUDED_GLOB_PATTERNS, ...project.runtimeOverrides.extraExcludePatterns] } - } - : project.runtimeOverrides.maxChunks - ? { config: { parsing: { maxChunks: project.runtimeOverrides.maxChunks } } } : {}), + ...(project.runtimeOverrides.includeExcluded?.length + ? { includeExcluded: project.runtimeOverrides.includeExcluded } + : {}), + ...(project.runtimeOverrides.maxChunks + ? { parsing: { maxChunks: project.runtimeOverrides.maxChunks } } + : {}) + }, ...(project.runtimeOverrides.preferredAnalyzer || project.runtimeOverrides.extraSourceExtensions?.length ? { @@ -1650,6 +1648,9 @@ function buildProjectRuntimeOverrides(projectConfig: ProjectConfig): ProjectRunt if (projectConfig.excludePatterns?.length) { runtimeOverrides.extraExcludePatterns = [...projectConfig.excludePatterns]; } + if (projectConfig.includeExcluded?.length) { + runtimeOverrides.includeExcluded = [...projectConfig.includeExcluded]; + } if (projectConfig.parsing?.maxChunks) { runtimeOverrides.maxChunks = projectConfig.parsing.maxChunks; diff --git a/src/project-state.ts b/src/project-state.ts index deaa5a6..91f27ad 100644 --- a/src/project-state.ts +++ b/src/project-state.ts @@ -14,6 +14,8 @@ import type { ToolPaths, IndexState } from './tools/types.js'; export interface ProjectRuntimeOverrides { /** Extra glob exclusion patterns merged with the default index-time exclusions. */ extraExcludePatterns?: string[]; + /** Source globs explicitly restored through exclusions and Git ignores. */ + includeExcluded?: string[]; /** Maximum chunks retained in semantic and keyword indexes for this project. */ maxChunks?: number; /** Analyzer name to prefer for this project without mutating global registry order. */ diff --git a/src/server/config.ts b/src/server/config.ts index ff6c1d6..707ed31 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -5,6 +5,7 @@ import path from 'node:path'; export interface ProjectConfig { root: string; excludePatterns?: string[]; + includeExcluded?: string[]; parsing?: { maxChunks?: number; }; @@ -89,6 +90,10 @@ export async function loadServerConfig(): Promise { if (excludePatterns) { parsedProject.excludePatterns = excludePatterns; } + const includeExcluded = parseStringArray(project.includeExcluded); + if (includeExcluded) { + parsedProject.includeExcluded = includeExcluded; + } if ( typeof project.parsing === 'object' && diff --git a/src/types/index.ts b/src/types/index.ts index 9010843..ece51e2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -470,6 +470,8 @@ export interface CodebaseConfig { // File filtering include?: string[]; exclude?: string[]; + /** Root-relative source globs that override exclusions and Git ignores. */ + includeExcluded?: string[]; respectGitignore?: boolean; // Parsing options diff --git a/src/utils/git-dates.ts b/src/utils/git-dates.ts index 42c7dc8..12d4b0f 100644 --- a/src/utils/git-dates.ts +++ b/src/utils/git-dates.ts @@ -5,6 +5,7 @@ import { exec } from 'child_process'; import { promisify } from 'util'; +import { gitEnvironmentWithoutLocalRepositoryContext } from './git-environment.js'; const execAsync = promisify(exec); const commitDateCache = new Map>(); @@ -46,6 +47,7 @@ export async function getFileCommitDates(rootPath: string): Promise { await rmWithRetries(tempDir); }); + it('still observes source in index-only exclusions so opt-in files can refresh', async () => { + let resolveReady!: () => void; + let resolveChanged!: () => void; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + const changed = new Promise((resolve) => { + resolveChanged = resolve; + }); + const stop = startFileWatcher({ + rootPath: tempDir, + debounceMs: 50, + onReady: resolveReady, + onChanged: resolveChanged + }); + try { + await ready; + await fs.mkdir(path.join(tempDir, '.agents')); + await fs.writeFile(path.join(tempDir, '.agents', 'source.ts'), 'export const fresh = 1;'); + await changed; + } finally { + stop(); + } + }, 8000); + it('triggers onChanged after debounce window', async () => { const debounceMs = 400; let callCount = 0; @@ -29,7 +54,9 @@ describe('FileWatcher', () => { rootPath: tempDir, debounceMs, onReady: () => resolveReady(), - onChanged: () => { callCount++; }, + onChanged: () => { + callCount++; + } }); try { @@ -57,7 +84,9 @@ describe('FileWatcher', () => { rootPath: tempDir, debounceMs, onReady: () => resolveReady(), - onChanged: () => { callCount++; }, + onChanged: () => { + callCount++; + } }); try { @@ -89,7 +118,9 @@ describe('FileWatcher', () => { rootPath: tempDir, debounceMs, onReady: () => resolveReady(), - onChanged: () => { callCount++; }, + onChanged: () => { + callCount++; + } }); // Give chokidar a moment to finish initializing before the first write diff --git a/tests/index-scope.test.ts b/tests/index-scope.test.ts new file mode 100644 index 0000000..dd3ef8a --- /dev/null +++ b/tests/index-scope.test.ts @@ -0,0 +1,250 @@ +import { execFileSync } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CodebaseIndexer } from '../src/core/indexer.js'; +import { + CODEBASE_CONTEXT_DIRNAME, + KEYWORD_INDEX_FILENAME +} from '../src/constants/codebase-context.js'; +import { gitEnvironmentWithoutLocalRepositoryContext } from '../src/utils/git-environment.js'; +import { rmWithRetries } from './test-helpers.js'; + +const junk = [ + 'out/generated.ts', + 'packages/ui/out/generated.ts', + '.work/plan.json', + '.agents/skill.ts', + '.codex/tool.ts', + '.cursor/rule.ts', + '.github/agents/reviewer.ts', + 'packages/ui/.agents/skill.ts', + 'coverage/report.js', + 'package-lock.json', + 'npm-shrinkwrap.json', + 'pnpm-lock.yaml', + 'yarn.lock', + 'Cargo.lock', + 'Gemfile.lock', + 'poetry.lock', + 'uv.lock', + 'bun.lockb', + 'src/bundle.min.js', + 'src/style.min.css', + 'src/app.js.map', + '.github/FUNDING.yml', + '.github/ISSUE_TEMPLATE/bug_report.yml', + '.github/ISSUE_TEMPLATE/feature_request.yml', + '.github/ISSUE_TEMPLATE/friction.yml' +]; + +describe('index source scope', () => { + let root: string; + let outerRoot: string | undefined; + + afterEach(async () => { + if (root) await rmWithRetries(root); + if (outerRoot) await rmWithRetries(outerRoot); + }); + + async function write(relative: string, content = 'export const sample = 1;\n') { + const file = path.join(root, relative); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, content); + } + + async function fixture() { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-index-scope-')); + const git = (...args: string[]) => + execFileSync('git', args, { + cwd: root, + stdio: 'pipe', + env: gitEnvironmentWithoutLocalRepositoryContext() + }); + git('init'); + await write('src/app.ts'); + await write('nested/keep.ts'); + await write('tracked-ignored.ts'); + await write('.github/workflows/ci.yml', 'name: ci\n'); + await write('.releaserc.json', '{"branches":["main"]}\n'); + for (const file of junk) { + await write( + file, + file.endsWith('.json') || file.endsWith('.map') ? '{"value":1}\n' : undefined + ); + } + git('add', '-f', '.'); + git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-m', 'fixture'); + await write('.gitignore', 'root-ignored.ts\ntracked-ignored.ts\n'); + await write('nested/.gitignore', 'generated.ts\n'); + await write('.git/info/exclude', 'info-ignored.ts\n'); + await write('root-ignored.ts'); + await write('info-ignored.ts'); + await write('nested/generated.ts'); + await write('fresh.ts'); // Deliberately untracked and not ignored. + } + + async function indexed( + config: { includeExcluded?: string[]; respectGitignore?: boolean; exclude?: string[] } = {} + ) { + const options = { skipEmbedding: true, include: ['**/*'], ...config }; + const indexer = new CodebaseIndexer({ + rootPath: root, + config: options, + projectOptions: { extraFileExtensions: ['.lock', '.lockb', '.map'] } + }); + expect((await indexer.detectMetadata()).rootPath).toBe(root); + const stats = await indexer.index(); + const raw: unknown = JSON.parse( + await fs.readFile(path.join(root, CODEBASE_CONTEXT_DIRNAME, KEYWORD_INDEX_FILENAME), 'utf8') + ); + const chunks = (Array.isArray(raw) ? raw : (raw as { chunks: unknown[] }).chunks) as Array<{ + filePath: string; + }>; + const paths = [ + ...new Set( + chunks.map(({ filePath }) => { + const relative = path.relative(root, filePath).replace(/\\/g, '/'); + expect(path.isAbsolute(relative) || relative.startsWith('..')).toBe(false); + return relative; + }) + ) + ].sort(); + return { stats, paths }; + } + + it('uses nested and Git-standard ignores, retains tracked files and fresh untracked source, and skips junk', async () => { + await fixture(); + const { stats, paths } = await indexed(); + expect(paths).toEqual([ + '.github/workflows/ci.yml', + '.releaserc.json', + 'fresh.ts', + 'nested/keep.ts', + 'src/app.ts', + 'tracked-ignored.ts' + ]); + expect(stats.totalFiles).toBe(6); + expect(stats.indexedFiles).toBe(6); + }); + + it('opts specific excluded or Git-ignored source back in without admitting other junk', async () => { + await fixture(); + const restored = [ + '.agents/skill.ts', + 'coverage/report.js', + 'nested/generated.ts', + 'out/generated.ts', + 'src/bundle.min.js' + ]; + const { paths } = await indexed({ includeExcluded: restored }); + for (const file of restored) expect(paths).toContain(file); + expect(paths).not.toContain('.cursor/rule.ts'); + expect(paths).not.toContain('pnpm-lock.yaml'); + }); + + it('keeps include/exclude selection and lets respectGitignore=false disable Git filtering', async () => { + await fixture(); + const { paths } = await indexed({ respectGitignore: false, exclude: ['**/fresh.ts'] }); + expect(paths).toContain('root-ignored.ts'); + expect(paths).toContain('nested/generated.ts'); + expect(paths).not.toContain('fresh.ts'); + expect(paths).not.toContain('src/bundle.min.js'); + }); + + it('keeps root-only gitignore behavior outside Git', async () => { + await fixture(); + await fs.rm(path.join(root, '.git'), { recursive: true, force: true }); + const { paths } = await indexed(); + expect(paths).not.toContain('root-ignored.ts'); + expect(paths).not.toContain('tracked-ignored.ts'); + expect(paths).toContain('nested/generated.ts'); + expect(paths).toContain('fresh.ts'); + }); + + it('indexes the requested subdirectory rather than its parent Git repository', async () => { + await fixture(); + const repository = root; + root = path.join(repository, 'nested'); + try { + const { paths } = await indexed(); + expect(paths).toEqual(['keep.ts']); + } finally { + root = repository; + } + }); + + it('uses the target repository when caller Git variables point at another repository', async () => { + await fixture(); + outerRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cbc-index-outer-git-')); + const outerGit = (...args: string[]) => + execFileSync('git', args, { + cwd: outerRoot, + stdio: 'pipe', + env: gitEnvironmentWithoutLocalRepositoryContext() + }); + + outerGit('init'); + await fs.writeFile(path.join(outerRoot, 'outer-only.ts'), 'export const outer = true;\n'); + outerGit('add', '-f', '.'); + outerGit('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-m', 'outer fixture'); + + const globalIgnorePath = path.join(outerRoot, 'global-ignore'); + const globalConfigPath = path.join(outerRoot, 'global.gitconfig'); + await fs.writeFile(globalIgnorePath, 'globally-ignored.ts\n'); + await write('globally-ignored.ts'); + outerGit('config', '--file', globalConfigPath, 'core.excludesFile', globalIgnorePath); + + const outerGitDirectory = path.join(outerRoot, '.git'); + const outerIndexPath = path.join(outerGitDirectory, 'index'); + const headBefore = outerGit('rev-parse', 'HEAD').toString('utf8').trim(); + const indexBefore = await fs.readFile(outerIndexPath); + const inheritedVariables = [ + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_INDEX_FILE', + 'GIT_COMMON_DIR', + 'GIT_CONFIG_GLOBAL' + ]; + const previousValues = new Map( + inheritedVariables.map((name) => [name, process.env[name]] as const) + ); + + process.env.GIT_DIR = outerGitDirectory; + process.env.GIT_WORK_TREE = outerRoot; + process.env.GIT_INDEX_FILE = outerIndexPath; + process.env.GIT_COMMON_DIR = outerGitDirectory; + process.env.GIT_CONFIG_GLOBAL = globalConfigPath; + + try { + const { paths } = await indexed(); + + expect(paths).toEqual([ + '.github/workflows/ci.yml', + '.releaserc.json', + 'fresh.ts', + 'nested/keep.ts', + 'src/app.ts', + 'tracked-ignored.ts' + ]); + expect(paths).not.toContain('nested/generated.ts'); + expect(paths).not.toContain('outer-only.ts'); + expect(paths).not.toContain('globally-ignored.ts'); + expect((await fs.readFile(outerIndexPath)).equals(indexBefore)).toBe(true); + expect(outerGit('rev-parse', 'HEAD').toString('utf8').trim()).toBe(headBefore); + } finally { + for (const [name, value] of previousValues) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + } + }); + + it('handles untracked Git paths with spaces and Unicode', async () => { + await fixture(); + await write('nested/über file.ts'); + const { paths } = await indexed(); + expect(paths).toContain('nested/über file.ts'); + }); +}); diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts index eb7f6c6..e0519d1 100644 --- a/tests/server-config.test.ts +++ b/tests/server-config.test.ts @@ -114,7 +114,11 @@ describe('loadServerConfig', () => { const projB = path.join(os.tmpdir(), 'ccc-test-proj-b'); const config = JSON.stringify({ projects: [ - { root: projA, excludePatterns: ['**/dist/**'] }, + { + root: projA, + excludePatterns: ['**/dist/**'], + includeExcluded: [' .agents/source.ts ', '', 17] + }, { root: projB } ], server: { port: 5199, host: '0.0.0.0' } @@ -126,6 +130,7 @@ describe('loadServerConfig', () => { expect(result!.projects).toHaveLength(2); expect(result!.projects![0].root).toBe(path.resolve(projA)); expect(result!.projects![0].excludePatterns).toEqual(['**/dist/**']); + expect(result!.projects![0].includeExcluded).toEqual(['.agents/source.ts']); expect(result!.projects![1].root).toBe(path.resolve(projB)); expect(result!.server?.port).toBe(5199); expect(result!.server?.host).toBe('0.0.0.0');