diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca5b9ff1..5e1b098e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixes + +- `codegraph status` now sees your edits when the project sits in a **subdirectory** of its git repository (a monorepo package, an app folder next to a server folder). Previously it reported `Index is up to date` no matter how many files had changed, while `codegraph sync` run a second later found them all and reindexed — the two commands flatly contradicted each other, and anything that trusts the status count (the staleness reminder, a scripted check) read the index as clean forever. Change detection also stops at your project's boundary now, so an edit to a sibling package in the same repository is no longer counted as yours. A project that IS its repository root is unaffected. + +- A project living in a directory its parent repository **gitignores** no longer reports a permanently clean index either. Git can say nothing at all about such a directory, so change detection now falls back to the same filesystem scan that indexes the project in the first place, instead of taking git's silence for "nothing changed". + ## [1.6.0] - 2026-08-26 diff --git a/__tests__/git-changed-subdir-project.test.ts b/__tests__/git-changed-subdir-project.test.ts new file mode 100644 index 000000000..3c15564b2 --- /dev/null +++ b/__tests__/git-changed-subdir-project.test.ts @@ -0,0 +1,202 @@ +/** + * Regression test: a project that lives in a SUBDIRECTORY of its git repository + * saw every change silently dropped, so `codegraph status` always printed + * "Index is up to date" while `codegraph sync` immediately reindexed the files. + * + * `git status --porcelain` prints paths relative to the REPOSITORY root no + * matter what `cwd` it runs in (porcelain deliberately ignores + * `status.relativePaths`), and it reports the whole repo rather than just `cwd`. + * getGitChangedFiles fed those repo-relative paths straight into + * `path.join(projectRoot, …)`, producing `///file` — a path that + * cannot be read, so getChangedFiles dropped every entry. + * + * getGitVisibleFiles (the scan path) is NOT affected — `git ls-files` is both + * cwd-relative and cwd-scoped — which is exactly why the two commands disagreed. + * It is pinned here so the asymmetry stays deliberate. + */ + +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, scanDirectory } from '../src/extraction/index'; +import CodeGraph from '../src/index'; + +function git(cwd: string, args: string[]): void { + execFileSync('git', args, { cwd, stdio: 'pipe' }); +} + +describe('change detection for a project inside a subdirectory of its repo', () => { + const dirs: string[] = []; + const graphs: CodeGraph[] = []; + + /** + * A repo with a source file at the root and a project living in `app/`, all + * committed — the layout where `status` and `sync` disagreed. + */ + function makeRepoWithSubProject(): { repo: string; project: string } { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-subdir-')); + dirs.push(repo); + git(repo, ['init']); + git(repo, ['config', 'user.email', 'test@example.com']); + git(repo, ['config', 'user.name', 'test']); + fs.writeFileSync(path.join(repo, 'outside.js'), 'function outside() {}\n'); + const project = path.join(repo, 'app'); + fs.mkdirSync(path.join(project, 'src'), { recursive: true }); + fs.writeFileSync(path.join(project, 'src', 'index.js'), 'function hello() {}\n'); + git(repo, ['add', '-A']); + git(repo, ['commit', '-m', 'init']); + return { repo, project }; + } + + afterEach(() => { + while (graphs.length) { + try { graphs.pop()!.destroy(); } catch { /* already closed */ } + } + while (dirs.length) { + fs.rmSync(dirs.pop()!, { recursive: true, force: true }); + } + }); + + it('reports a modified tracked file with a project-relative path', () => { + const { project } = makeRepoWithSubProject(); + fs.writeFileSync(path.join(project, 'src', 'index.js'), 'function hello() { return 1; }\n'); + + const changes = getGitChangedFiles(project); + + expect(changes).not.toBeNull(); + expect(changes!.modified).toContain('src/index.js'); + }); + + it('reports an untracked new file with a project-relative path', () => { + const { project } = makeRepoWithSubProject(); + fs.writeFileSync(path.join(project, 'src', 'added.js'), 'function added() {}\n'); + + const changes = getGitChangedFiles(project); + + expect(changes).not.toBeNull(); + expect(changes!.added).toContain('src/added.js'); + }); + + it('reports a deleted tracked file with a project-relative path', () => { + const { project } = makeRepoWithSubProject(); + fs.unlinkSync(path.join(project, 'src', 'index.js')); + + const changes = getGitChangedFiles(project); + + expect(changes).not.toBeNull(); + expect(changes!.deleted).toContain('src/index.js'); + }); + + it('ignores changes that live outside the project root', () => { + const { repo, project } = makeRepoWithSubProject(); + fs.writeFileSync(path.join(repo, 'outside.js'), 'function outside() { return 1; }\n'); + fs.writeFileSync(path.join(repo, 'sibling.js'), 'function sibling() {}\n'); + + const changes = getGitChangedFiles(project); + + expect(changes).not.toBeNull(); + const all = [...changes!.modified, ...changes!.added, ...changes!.deleted]; + expect(all).toHaveLength(0); + }); + + it('scans a subdirectory project with project-relative paths (getGitVisibleFiles)', () => { + const { project } = makeRepoWithSubProject(); + + const files = scanDirectory(project); + + expect(files).toContain('src/index.js'); + expect(files).not.toContain('outside.js'); + expect(files.some((f) => f.startsWith('..') || f.includes('app/app/'))).toBe(false); + }); + + it('status agrees with sync for a subdirectory project (end to end)', async () => { + const { project } = makeRepoWithSubProject(); + const cg = CodeGraph.initSync(project, { config: { include: ['**/*.js'], exclude: [] } }); + graphs.push(cg); + await cg.indexAll(); + + fs.writeFileSync(path.join(project, 'src', 'index.js'), 'function renamedHello() { return 2; }\n'); + + const changes = cg.getChangedFiles(); + expect(changes.modified).toContain('src/index.js'); + + const result = await cg.sync(); + expect(result.filesModified).toBe(1); + expect(cg.searchNodes('renamedHello').length).toBeGreaterThan(0); + }); + + it('still recurses into an untracked embedded repo below a subdirectory project (#1213)', () => { + const { project } = makeRepoWithSubProject(); + const embedded = path.join(project, 'embedded'); + fs.mkdirSync(embedded); + git(embedded, ['init']); + fs.writeFileSync(path.join(embedded, 'inner.js'), 'function inner() {}\n'); + + const changes = getGitChangedFiles(project); + + expect(changes).not.toBeNull(); + expect(changes!.added).toContain('embedded/inner.js'); + }); + + it("applies the project's own .gitignore to subdirectory-project paths (#766)", () => { + const { repo, project } = makeRepoWithSubProject(); + fs.writeFileSync(path.join(project, '.gitignore'), 'skipped/\n'); + git(repo, ['add', '-A']); + git(repo, ['commit', '-m', 'ignore']); + fs.mkdirSync(path.join(project, 'skipped')); + fs.writeFileSync(path.join(project, 'skipped', 'gen.js'), 'function gen() {}\n'); + fs.writeFileSync(path.join(project, 'src', 'kept.js'), 'function kept() {}\n'); + + const changes = getGitChangedFiles(project); + + expect(changes).not.toBeNull(); + expect(changes!.added).toContain('src/kept.js'); + expect(changes!.added).not.toContain('skipped/gen.js'); + }); + + it('falls back to a full scan when the parent repo gitignores the project dir', () => { + const { repo, project } = makeRepoWithSubProject(); + fs.writeFileSync(path.join(repo, '.gitignore'), 'app/\n'); + git(repo, ['rm', '-r', '--cached', 'app']); + git(repo, ['add', '-A']); + git(repo, ['commit', '-m', 'ignore app']); + + // git sees nothing inside `app/`, so the git fast path cannot answer at all — + // it must decline (null) exactly like getGitVisibleFiles does, leaving the + // caller on the filesystem scan that DOES index this project. + expect(getGitChangedFiles(project)).toBeNull(); + expect(scanDirectory(project)).toContain('src/index.js'); + }); + + it('keeps working when the project IS the repository root', () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-atroot-')); + dirs.push(repo); + git(repo, ['init']); + git(repo, ['config', 'user.email', 'test@example.com']); + git(repo, ['config', 'user.name', 'test']); + fs.mkdirSync(path.join(repo, 'src')); + fs.writeFileSync(path.join(repo, 'src', 'index.js'), 'function hello() {}\n'); + git(repo, ['add', '-A']); + git(repo, ['commit', '-m', 'init']); + fs.writeFileSync(path.join(repo, 'src', 'index.js'), 'function hello() { return 1; }\n'); + fs.writeFileSync(path.join(repo, 'src', 'added.js'), 'function added() {}\n'); + + const changes = getGitChangedFiles(repo); + + expect(changes).not.toBeNull(); + expect(changes!.modified).toContain('src/index.js'); + expect(changes!.added).toContain('src/added.js'); + }); + + it('keeps working for a project that is not in git at all', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-nogit-')); + dirs.push(dir); + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync(path.join(dir, 'src', 'index.js'), 'function hello() {}\n'); + + expect(getGitChangedFiles(dir)).toBeNull(); + expect(scanDirectory(dir)).toContain('src/index.js'); + }); +}); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 93be48352..488cd465a 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -1024,6 +1024,55 @@ function collectGitFiles(repoDir: string, prefix: string, files: Set, em } } +/** + * Where `dir` sits inside its git repository, as a normalized '/'-terminated + * prefix — '' when `dir` IS the repo root. Throws when `dir` is not in a repo, + * which the git fast paths already read as "fall back to a filesystem walk". + * + * Every caller of `git status --porcelain` needs this: porcelain prints + * REPOSITORY-relative paths from any cwd (the format deliberately ignores + * `status.relativePaths`) and reports the whole repo, while `git ls-files` + * prints cwd-relative paths scoped to cwd. A project indexed from a + * subdirectory must strip this prefix itself or its paths land outside the + * project root. + */ +function gitPathPrefix(dir: string): string { + const prefix = execFileSync( + 'git', + ['rev-parse', '--show-prefix'], + { cwd: dir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } + ).trim(); + return prefix ? normalizePath(prefix) : ''; +} + +/** + * True when a parent repo GITIGNORES `rootDir`. Git then reports nothing inside + * it — no `ls-files` entries, no `status` lines — so both git fast paths must + * decline and let the filesystem walk answer. Enumeration and change detection + * have to make this call identically: if only one of them declines, `status` + * reports a clean index for a project `sync` keeps reindexing. + */ +function gitScopeIsIgnored(rootDir: string): boolean { + const gitRoot = execFileSync( + 'git', + ['rev-parse', '--show-toplevel'], + { cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } + ).trim(); + + if (path.resolve(gitRoot) === path.resolve(rootDir)) return false; + try { + // git check-ignore exits 0 if the path IS ignored, 1 if not + execFileSync( + 'git', + ['check-ignore', '-q', path.resolve(rootDir)], + { cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } + ); + return true; + } catch { + return false; + } +} + /** * Get all files visible to git (tracked + untracked but not ignored). * Respects .gitignore at all levels (root, subdirectories) and descends into @@ -1032,29 +1081,7 @@ function collectGitFiles(repoDir: string, prefix: string, files: Set, em */ function getGitVisibleFiles(rootDir: string): Set | null { try { - // Check if the project directory is gitignored by a parent repo. - // When rootDir lives inside a parent git repo that ignores it, - // `git ls-files` returns nothing — fall back to filesystem walk. - const gitRoot = execFileSync( - 'git', - ['rev-parse', '--show-toplevel'], - { cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } - ).trim(); - - if (path.resolve(gitRoot) !== path.resolve(rootDir)) { - try { - // git check-ignore exits 0 if the path IS ignored, 1 if not - execFileSync( - 'git', - ['check-ignore', '-q', path.resolve(rootDir)], - { cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } - ); - // Directory is gitignored by parent repo — fall back to filesystem walk - return null; - } catch { - // Not ignored — safe to use git ls-files - } - } + if (gitScopeIsIgnored(rootDir)) return null; const files = new Set(); const embeddedRoots = new Set(); @@ -1106,6 +1133,9 @@ interface GitChanges { */ export function getGitChangedFiles(rootDir: string): GitChanges | null { try { + // Same scope call getGitVisibleFiles makes, so status and the index agree + // about which projects git can speak for at all. + if (gitScopeIsIgnored(rootDir)) return null; const changes: GitChanges = { modified: [], added: [], deleted: [] }; // Custom extension → language overrides from the project's codegraph.json, // so change detection sees the same custom-extension files the full index does. @@ -1117,7 +1147,22 @@ export 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 { +/** + * Collect `git status` changes for the tree rooted at `dir`, which is a repo + * root only in the recursive calls — the top-level one passes the PROJECT root, + * which may be any subdirectory of its repository. + * + * `git status --porcelain` is indifferent to cwd in both directions: it reports + * the whole repository, and it prints repository-relative paths. So a project + * below the repo root has to scope the report to its own subtree and rebase the + * paths onto itself — `repoPrefix` is that subtree's path inside the repo, and + * everything outside it belongs to another project. (Left unconverted, every + * path resolved to `///…`, which no read could open, so change + * detection reported a permanently clean index while `sync` — a filesystem + * reconcile — kept finding the same edits.) + */ +function collectGitStatus(dir: string, prefix: string, out: GitChanges, overrides?: Record, includeIgnored: Ignore | null = null, exclude: Ignore | null = null): void { + const repoPrefix = gitPathPrefix(dir); const output = execFileSync( 'git', // `-uall` lists individual untracked files instead of collapsing an @@ -1126,8 +1171,13 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, over // 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 } + // The `-- .` pathspec (only when below the repo root, so a repo-root + // project keeps the exact command it had) makes git skip the rest of the + // repository instead of us reporting and then discarding it. + repoPrefix + ? ['status', '--porcelain', '--no-renames', '-uall', '--', '.'] + : ['status', '--porcelain', '--no-renames', '-uall'], + { cwd: dir, encoding: 'utf-8', timeout: 10000, maxBuffer: 50 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true } ); // This repo's own ignore rules — built-in defaults (#407) plus its .gitignore. @@ -1137,17 +1187,26 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, over // vendor/ dir, or a tracked file under a .gitignored dir, surfaces here as a // change — so `codegraph status` (which reads getChangedFiles) reports a // pending edit the full index never tracks and `sync` never clears. Matching - // repo-relative `rel` at each recursion level mirrors getGitVisibleFiles' + // `dir`-relative `rel` at each recursion level mirrors getGitVisibleFiles' // ScopeIgnore: every embedded repo is judged by ITS OWN rules, never the - // parent's. (#766) - const ig = buildDefaultIgnore(repoDir); + // parent's — and a subdirectory project by its own .gitignore, not the + // enclosing repo's. (#766) + const ig = buildDefaultIgnore(dir); const untrackedDirs: string[] = []; for (const line of output.split('\n')) { if (line.length < 4) continue; // Minimum: "XY file" const statusCode = line.substring(0, 2); - const rel = normalizePath(line.substring(3)); + let rel = normalizePath(line.substring(3)); + + // Porcelain paths are repository-relative — rebase them onto `dir` and drop + // whatever lies outside it (a sibling project in the same repo). + if (repoPrefix) { + if (!rel.startsWith(repoPrefix)) continue; + rel = rel.slice(repoPrefix.length); + if (!rel) continue; // the project dir itself, reported as one opaque entry + } // Untracked directory entries (trailing slash) may hide an embedded repo — // collect for the recursion below instead of treating as a file. @@ -1168,7 +1227,7 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, over } // Added (`??`) / modified files inside an excluded dir must not enter the - // index — match against the repo-relative path, same as the full scan. (#766) + // index — match against the `dir`-relative path, same as the full scan. (#766) if (ig.ignores(rel)) continue; // User `codegraph.json` `exclude` (#999) is project-root-relative, so it's // matched against the full path — sync must not re-add a tracked file the @@ -1189,12 +1248,12 @@ function collectGitStatus(repoDir: string, prefix: string, out: GitChanges, over // project opted in via `includeIgnored`; by default `.gitignore` is respected // and they are left alone (#970, #976), mirroring the full-index scan. for (const rel of untrackedDirs) { - for (const repoRel of findNestedGitRepos(path.join(repoDir, rel), rel)) { - collectGitStatus(path.join(repoDir, repoRel), prefix + repoRel, out, overrides, includeIgnored, exclude); + for (const repoRel of findNestedGitRepos(path.join(dir, rel), rel)) { + collectGitStatus(path.join(dir, repoRel), prefix + repoRel, out, overrides, includeIgnored, exclude); } } - for (const rel of findIgnoredEmbeddedRepos(repoDir, includeIgnored, prefix)) { - collectGitStatus(path.join(repoDir, rel), prefix + rel, out, overrides, includeIgnored, exclude); + for (const rel of findIgnoredEmbeddedRepos(dir, includeIgnored, prefix)) { + collectGitStatus(path.join(dir, rel), prefix + rel, out, overrides, includeIgnored, exclude); } }