diff --git a/__tests__/git-changed-untracked-dir.test.ts b/__tests__/git-changed-untracked-dir.test.ts new file mode 100644 index 000000000..699c01ec2 --- /dev/null +++ b/__tests__/git-changed-untracked-dir.test.ts @@ -0,0 +1,68 @@ +/** + * Regression test for #1213: `codegraph sync` silently skips untracked files + * that live inside an untracked directory. + * + * `git status --porcelain` collapses an entirely-untracked directory into a + * single `?? frontend/` entry. getGitChangedFiles must still surface the source + * files inside it (via `-uall`) rather than dropping the whole directory. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { getGitChangedFiles } from '../src/extraction/index'; + +function git(cwd: string, args: string[]): void { + execFileSync('git', args, { cwd, stdio: 'pipe' }); +} + +describe('getGitChangedFiles — untracked directories (#1213)', () => { + const dirs: string[] = []; + + function makeRepo(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1213-')); + dirs.push(dir); + git(dir, ['init']); + git(dir, ['config', 'user.email', 'test@example.com']); + git(dir, ['config', 'user.name', 'test']); + fs.writeFileSync(path.join(dir, 'root.js'), 'function foo() {}\n'); + git(dir, ['add', 'root.js']); + git(dir, ['commit', '-m', 'init']); + return dir; + } + + afterEach(() => { + while (dirs.length) { + fs.rmSync(dirs.pop()!, { recursive: true, force: true }); + } + }); + + it('detects source files inside a fully-untracked directory', () => { + const dir = makeRepo(); + fs.mkdirSync(path.join(dir, 'frontend')); + fs.writeFileSync(path.join(dir, 'frontend', 'app.js'), 'function bar() {}\n'); + + const changes = getGitChangedFiles(dir); + + expect(changes).not.toBeNull(); + expect(changes!.added).toContain('frontend/app.js'); + }); + + it('still recurses into an untracked embedded git repo (no -uall regression)', () => { + // `-uall` must not break the embedded-repo path: git collapses a nested + // repo to `?? embedded/` regardless of `-uall`, so its files are only + // reachable through collectGitStatus's recursion. + const dir = makeRepo(); + const embedded = path.join(dir, 'embedded'); + fs.mkdirSync(embedded); + git(embedded, ['init']); + fs.writeFileSync(path.join(embedded, 'inner.js'), 'function baz() {}\n'); + + const changes = getGitChangedFiles(dir); + + expect(changes).not.toBeNull(); + expect(changes!.added).toContain('embedded/inner.js'); + }); +}); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index bda1746ba..7a1cadc3b 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -1065,7 +1065,7 @@ interface GitChanges { * case this cannot see (the child status that would report the deletions is gone * with it); a full `codegraph index` reconciles that. */ -function getGitChangedFiles(rootDir: string): GitChanges | null { +export function getGitChangedFiles(rootDir: string): GitChanges | null { try { const changes: GitChanges = { modified: [], added: [], deleted: [] }; // Custom extension → language overrides from the project's codegraph.json, @@ -1081,7 +1081,13 @@ function getGitChangedFiles(rootDir: string): GitChanges | null { function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, overrides?: Record, includeIgnored: Ignore | null = null, exclude: Ignore | null = null): void { const output = execFileSync( 'git', - ['status', '--porcelain', '--no-renames'], + // `-uall` lists individual untracked files instead of collapsing an + // entirely-untracked directory into one `?? dir/` entry, which would + // otherwise be dropped here (only embedded git repos are recursed into + // below). Nested untracked git repos still collapse to `?? repo/` even + // with `-uall` — git never crosses a repo boundary — so the recursion + // still handles them. (#1213) + ['status', '--porcelain', '--no-renames', '-uall'], { cwd: repoDir, encoding: 'utf-8', timeout: 10000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } );