Răsfoiți Sursa

fix(extraction): detect untracked files inside untracked directories (#1213) (#1215)

git status --porcelain collapses an entirely-untracked directory into a
single '?? dir/' entry. collectGitStatus only recurses into such dirs to
find embedded git repos, so source files in a plain untracked directory
were never surfaced to sync — 'codegraph sync' reported 'Already up to
date' and the watcher missed them too.

Add -uall so git lists individual untracked files. Nested untracked git
repos still collapse to '?? repo/' even with -uall (git never crosses a
repo boundary), so the embedded-repo recursion is unaffected.

Export getGitChangedFiles and add regression tests for both the plain
untracked-directory case and the embedded-repo recursion (no -uall
regression).

Root-cause analysis and fix suggested by the reporter in #1213.
Max Hsu 1 săptămână în urmă
părinte
comite
cc9ce09256
2 a modificat fișierele cu 76 adăugiri și 2 ștergeri
  1. 68 0
      __tests__/git-changed-untracked-dir.test.ts
  2. 8 2
      src/extraction/index.ts

+ 68 - 0
__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');
+  });
+});

+ 8 - 2
src/extraction/index.ts

@@ -1104,7 +1104,7 @@ interface GitChanges {
  * case this cannot see (the child status that would report the deletions is gone
  * case this cannot see (the child status that would report the deletions is gone
  * with it); a full `codegraph index` reconciles that.
  * with it); a full `codegraph index` reconciles that.
  */
  */
-function getGitChangedFiles(rootDir: string): GitChanges | null {
+export function getGitChangedFiles(rootDir: string): GitChanges | null {
   try {
   try {
     const changes: GitChanges = { modified: [], added: [], deleted: [] };
     const changes: GitChanges = { modified: [], added: [], deleted: [] };
     // Custom extension → language overrides from the project's codegraph.json,
     // Custom extension → language overrides from the project's codegraph.json,
@@ -1120,7 +1120,13 @@ function getGitChangedFiles(rootDir: string): GitChanges | null {
 function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, overrides?: Record<string, Language>, includeIgnored: Ignore | null = null, exclude: Ignore | null = null): void {
 function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, overrides?: Record<string, Language>, includeIgnored: Ignore | null = null, exclude: Ignore | null = null): void {
   const output = execFileSync(
   const output = execFileSync(
     'git',
     '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 }
     { cwd: repoDir, encoding: 'utf-8', timeout: 10000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }
   );
   );