Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions __tests__/nested-gitignore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/**
* Nested gitignore + sub-git-repo resolution tests.
*
* Covers buildDefaultIgnore (the single flat matcher used by the file watcher)
* and scanDirectory's filesystem fallback when the scan root is NOT a single
* git repo but contains independent sub-repos.
*/

import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execFileSync } from 'child_process';
import { buildDefaultIgnore, findSubGitRepos, scanDirectory } from '../src/extraction';
import { normalizePath } from '../src/utils';

const tmpDirs: string[] = [];

function mkTmp(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nested-gi-'));
tmpDirs.push(dir);
return fs.realpathSync(dir);
}

function write(root: string, rel: string, content = '') {
const full = path.join(root, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}

function gitInit(dir: string) {
fs.mkdirSync(dir, { recursive: true }); // git init needs the cwd to exist
const opts = { cwd: dir, stdio: 'pipe' as const, windowsHide: true };
execFileSync('git', ['init', '-q'], opts);
execFileSync('git', ['config', 'user.email', 't@t.dev'], opts);
execFileSync('git', ['config', 'user.name', 'test'], opts);
}

afterEach(() => {
while (tmpDirs.length) {
const d = tmpDirs.pop()!;
try {
fs.rmSync(d, { recursive: true, force: true });
} catch {
// best-effort
}
}
});

describe('buildDefaultIgnore — root anchoring (regression)', () => {
it('keeps a root-anchored /generated pattern anchored (does NOT float to nested)', () => {
const root = mkTmp();
write(root, '.gitignore', '/generated\n');

const ig = buildDefaultIgnore(root);

// Anchored: root-level generated IS ignored.
expect(ig.ignores('generated/')).toBe(true);
// Anchored: a NESTED generated must NOT be ignored by `/generated`.
expect(ig.ignores('src/generated/')).toBe(false);
expect(ig.ignores('packages/ui/generated/')).toBe(false);
});

it('unanchored bare pattern still floats to any depth', () => {
const root = mkTmp();
write(root, '.gitignore', 'mycache\n');

const ig = buildDefaultIgnore(root);

expect(ig.ignores('mycache/')).toBe(true);
expect(ig.ignores('packages/ui/mycache/')).toBe(true);
});
});

describe('buildDefaultIgnore — nested gitignore scoping', () => {
it('scopes a nested anchored pattern to its own directory only', () => {
const root = mkTmp();
write(root, '.gitignore', 'mycache\n');
// Nested gitignore at apps/web: `/artifacts` is anchored to apps/web.
write(root, 'apps/web/.gitignore', '/artifacts\n');

const ig = buildDefaultIgnore(root);

// The nested anchored pattern applies under its dir...
expect(ig.ignores('apps/web/artifacts/')).toBe(true);
// ...but NOT at the root, and NOT under a sibling.
expect(ig.ignores('artifacts/')).toBe(false);
expect(ig.ignores('apps/api/artifacts/')).toBe(false);
});

it('scopes a nested unanchored pattern to any depth under its directory', () => {
const root = mkTmp();
write(root, 'apps/web/.gitignore', 'reports\n');

const ig = buildDefaultIgnore(root);

expect(ig.ignores('apps/web/reports/')).toBe(true);
expect(ig.ignores('apps/web/sub/reports/')).toBe(true);
// Not outside its dir.
expect(ig.ignores('reports/')).toBe(false);
expect(ig.ignores('apps/api/reports/')).toBe(false);
});

it('stops collecting gitignores below a nested .git repo boundary', () => {
const root = mkTmp();
fs.mkdirSync(path.join(root, 'thirdparty/lib/.git'), { recursive: true });
// A .gitignore DEEPER inside the embedded repo. Walk returns at the repo
// boundary, so this one is never reached and must not leak into the matcher.
write(root, 'thirdparty/lib/src/.gitignore', 'deepsecret\n');

const ig = buildDefaultIgnore(root);

expect(ig.ignores('thirdparty/lib/src/deepsecret/')).toBe(false);
});
});

describe('findSubGitRepos', () => {
it('finds independent sub-repos and does not descend into them', () => {
const root = mkTmp();
gitInit(path.join(root, 'apps/web'));
gitInit(path.join(root, 'apps/api'));
write(root, 'apps/web/src/a.ts', 'export const a = 1;');

const repos = findSubGitRepos(root).map((r) => normalizePath(path.relative(root, r))).sort();

expect(repos).toEqual(['apps/api', 'apps/web']);
});

it('returns the root itself when root is a git repo', () => {
const root = mkTmp();
gitInit(root);

const repos = findSubGitRepos(root);

expect(repos.map((r) => fs.realpathSync(r))).toEqual([fs.realpathSync(root)]);
});

it('finds a sub-repo nested deeper than 3 levels (no depth cap)', () => {
const root = mkTmp();
gitInit(path.join(root, 'a/b/c/d/e/web'));

const repos = findSubGitRepos(root).map((r) => normalizePath(path.relative(root, r)));

expect(repos).toEqual(['a/b/c/d/e/web']);
});
});

describe('scanDirectory — non-git root with sub-repos', () => {
it('collects source files from sub-repos when root is not a git repo', () => {
const root = mkTmp();
gitInit(path.join(root, 'apps/web'));
write(root, 'apps/web/src/index.ts', 'export const x = 1;');
write(root, 'apps/web/dist/bundle.js', '// built');
write(root, 'apps/web/.gitignore', 'dist\n');
// Stage so git ls-files sees the tracked source.
execFileSync('git', ['add', '-A'], { cwd: path.join(root, 'apps/web'), stdio: 'pipe', windowsHide: true });

const files = scanDirectory(root);

expect(files).toContain('apps/web/src/index.ts');
// dist is gitignored in the sub-repo → excluded.
expect(files).not.toContain('apps/web/dist/bundle.js');
});
});
2 changes: 2 additions & 0 deletions scripts/build-bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ while [ -L "$SELF" ]; do
esac
done
DIR="$(cd "$(dirname "$SELF")/.." && pwd)"
# Increase file descriptor limit for large workspaces (Chokidar v4 fallback on macOS).
ulimit -n 65536 2>/dev/null || ulimit -n 10240 2>/dev/null || true
# --liftoff-only: avoid the V8 turboshaft WASM Zone OOM (issues #293/#298).
exec "$DIR/node" --liftoff-only "$DIR/lib/dist/bin/codegraph.js" "$@"
LAUNCH
Expand Down
Loading