gitnexus 1.6.10-rc.79 → 1.6.10-rc.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -511,9 +511,19 @@ For very large repositories:
511
511
  # Increase Node.js heap size
512
512
  NODE_OPTIONS="--max-old-space-size=16384" npx gitnexus analyze
513
513
 
514
- # Exclude large directories
514
+ # Exclude large directories (this repo only)
515
515
  echo "vendor/" >> .gitnexusignore
516
516
  echo "dist/" >> .gitnexusignore
517
+
518
+ # Exclude a directory across every repo you index, without touching each
519
+ # repo's own .gitnexusignore or needing push/commit access to it. GitNexus
520
+ # reads the same sources `git` itself does: core.excludesFile (all repos)
521
+ # and $GIT_DIR/info/exclude (this repo only, untracked). A repo's own
522
+ # .gitignore/.gitnexusignore can still override either with a `!pattern`
523
+ # negation. Skip both entirely with GITNEXUS_NO_GLOBAL_IGNORE=1.
524
+ git config --global core.excludesFile ~/.gitignore_global # applies to every repo
525
+ echo "docs/" >> ~/.gitignore_global
526
+ echo "build/" >> .git/info/exclude # this repo only, untracked
517
527
  ```
518
528
 
519
529
  ### Large files are being skipped
@@ -10,6 +10,8 @@ export declare const isHardcodedIgnoredDirectory: (name: string) => boolean;
10
10
  export interface IgnoreOptions {
11
11
  /** Skip .gitignore parsing, only read .gitnexusignore. Defaults to GITNEXUS_NO_GITIGNORE env var. */
12
12
  noGitignore?: boolean;
13
+ /** Skip core.excludesFile and $GIT_COMMON_DIR/info/exclude. Defaults to GITNEXUS_NO_GLOBAL_IGNORE env var. */
14
+ noGlobalIgnore?: boolean;
13
15
  }
14
16
  export declare const loadIgnoreRules: (repoPath: string, options?: IgnoreOptions) => Promise<Ignore | null>;
15
17
  /**
@@ -2,6 +2,7 @@ import ignore from 'ignore';
2
2
  import fs from 'fs/promises';
3
3
  import nodePath from 'path';
4
4
  import { logger } from '../core/logger.js';
5
+ import { getCoreExcludesFilePath, getGitInfoExcludePath } from '../storage/git.js';
5
6
  const DEFAULT_IGNORE_LIST = new Set([
6
7
  // Version Control
7
8
  '.git',
@@ -312,6 +313,32 @@ export const isHardcodedIgnoredDirectory = (name) => {
312
313
  export const loadIgnoreRules = async (repoPath, options) => {
313
314
  const ig = ignore();
314
315
  let hasRules = false;
316
+ // Mirror git's own precedence for ignore sources (gitignore(5)): patterns
317
+ // from core.excludesFile are consulted first (lowest precedence — git's
318
+ // real global, all-repos file), then $GIT_COMMON_DIR/info/exclude
319
+ // (per-repo, untracked — no write access to the repo needed), then
320
+ // .gitignore/.gitnexusignore below. Later ig.add() calls win on
321
+ // conflicting patterns, matching git's own last-match-wins semantics (#2606).
322
+ const skipGlobalIgnore = options?.noGlobalIgnore ?? !!process.env.GITNEXUS_NO_GLOBAL_IGNORE;
323
+ if (!skipGlobalIgnore) {
324
+ const globalSources = [
325
+ getCoreExcludesFilePath(repoPath),
326
+ getGitInfoExcludePath(repoPath),
327
+ ].filter((candidate) => candidate !== null);
328
+ for (const sourcePath of globalSources) {
329
+ try {
330
+ const content = await fs.readFile(sourcePath, 'utf-8');
331
+ ig.add(content);
332
+ hasRules = true;
333
+ }
334
+ catch (err) {
335
+ const code = err.code;
336
+ if (code !== 'ENOENT') {
337
+ logger.warn(` Warning: could not read ${sourcePath}: ${err.message}`);
338
+ }
339
+ }
340
+ }
341
+ }
315
342
  // Allow users to bypass .gitignore parsing (e.g. when .gitignore accidentally excludes source files)
316
343
  const skipGitignore = options?.noGitignore ?? !!process.env.GITNEXUS_NO_GITIGNORE;
317
344
  const filenames = skipGitignore ? ['.gitnexusignore'] : ['.gitignore', '.gitnexusignore'];
@@ -65,6 +65,28 @@ export declare const getGitRoot: (fromPath: string) => string | null;
65
65
  * path if the caller later resolved it from a different directory.
66
66
  */
67
67
  export declare const getCanonicalRepoRoot: (fromPath: string) => string | null;
68
+ /**
69
+ * Path to the repo's `$GIT_COMMON_DIR/info/exclude` file — git's own
70
+ * per-repo, untracked exclude list (same tier as `.gitignore` in
71
+ * precedence, but never committed, so it works even when the caller has
72
+ * no write access to the repo's tracked content). Shared across every
73
+ * linked worktree of a repo, matching git's own resolution (#2606).
74
+ *
75
+ * Returns `null` when `fromPath` is not inside a git repository or `git`
76
+ * is unavailable; callers should treat that the same as "no file".
77
+ */
78
+ export declare const getGitInfoExcludePath: (fromPath: string) => string | null;
79
+ /**
80
+ * Path to git's own global, all-repos ignore file: the value of
81
+ * `core.excludesFile` (any config scope — system/global/local, resolved
82
+ * the same way `git` itself would from `fromPath`), or git's documented
83
+ * default of `$XDG_CONFIG_HOME/git/ignore` when unset (gitignore(5)).
84
+ * Lowest-precedence source, mirroring git's own behavior (#2606).
85
+ *
86
+ * Never throws: an unset key or unavailable `git` falls through to the
87
+ * default path, which is always computable without `git`.
88
+ */
89
+ export declare const getCoreExcludesFilePath: (fromPath: string) => string;
68
90
  /**
69
91
  * Resolve `fromPath` to the directory whose basename should drive the
70
92
  * registry name (#1259) — the *identity root*. Three outcomes:
@@ -1,6 +1,7 @@
1
1
  import { execFileSync, execSync } from 'child_process';
2
2
  import { statSync } from 'fs';
3
3
  import path from 'path';
4
+ import os from 'os';
4
5
  // Git utilities for repository detection, commit tracking, and diff analysis
5
6
  const chompGitOutput = (value) => value.toString().replace(/\r?\n$/, '');
6
7
  /**
@@ -198,6 +199,80 @@ export const getCanonicalRepoRoot = (fromPath) => {
198
199
  return null;
199
200
  }
200
201
  };
202
+ // getGitInfoExcludePath/getCoreExcludesFilePath are called once per repo
203
+ // PER language/contract extractor during group sync (#2606) — an N-repo
204
+ // group fans out to 6+ extractors each calling these, so an uncached
205
+ // execSync per call turns into O(extractors × repos) blocking subprocess
206
+ // spawns. Both resolve to the same value for the same fromPath for the
207
+ // life of the process (git config/exclude files don't change mid-run), so
208
+ // memoize by fromPath. ponytail: process-lifetime cache, never invalidated
209
+ // — fine for one-shot CLI runs; the long-lived MCP server would need a
210
+ // TTL or explicit invalidation if a user edits core.excludesFile mid-session.
211
+ const gitInfoExcludePathCache = new Map();
212
+ const coreExcludesFilePathCache = new Map();
213
+ /**
214
+ * Path to the repo's `$GIT_COMMON_DIR/info/exclude` file — git's own
215
+ * per-repo, untracked exclude list (same tier as `.gitignore` in
216
+ * precedence, but never committed, so it works even when the caller has
217
+ * no write access to the repo's tracked content). Shared across every
218
+ * linked worktree of a repo, matching git's own resolution (#2606).
219
+ *
220
+ * Returns `null` when `fromPath` is not inside a git repository or `git`
221
+ * is unavailable; callers should treat that the same as "no file".
222
+ */
223
+ export const getGitInfoExcludePath = (fromPath) => {
224
+ const cached = gitInfoExcludePathCache.get(fromPath);
225
+ if (cached !== undefined)
226
+ return cached;
227
+ let result;
228
+ try {
229
+ const commonDir = chompGitOutput(execSync('git rev-parse --path-format=absolute --git-common-dir', {
230
+ cwd: fromPath,
231
+ stdio: ['ignore', 'pipe', 'ignore'],
232
+ windowsHide: true,
233
+ }));
234
+ result = commonDir ? path.join(path.resolve(commonDir), 'info', 'exclude') : null;
235
+ }
236
+ catch {
237
+ result = null;
238
+ }
239
+ gitInfoExcludePathCache.set(fromPath, result);
240
+ return result;
241
+ };
242
+ /**
243
+ * Path to git's own global, all-repos ignore file: the value of
244
+ * `core.excludesFile` (any config scope — system/global/local, resolved
245
+ * the same way `git` itself would from `fromPath`), or git's documented
246
+ * default of `$XDG_CONFIG_HOME/git/ignore` when unset (gitignore(5)).
247
+ * Lowest-precedence source, mirroring git's own behavior (#2606).
248
+ *
249
+ * Never throws: an unset key or unavailable `git` falls through to the
250
+ * default path, which is always computable without `git`.
251
+ */
252
+ export const getCoreExcludesFilePath = (fromPath) => {
253
+ const cached = coreExcludesFilePathCache.get(fromPath);
254
+ if (cached !== undefined)
255
+ return cached;
256
+ let result;
257
+ try {
258
+ const configured = chompGitOutput(execSync('git config --get --type=path core.excludesFile', {
259
+ cwd: fromPath,
260
+ stdio: ['ignore', 'pipe', 'ignore'],
261
+ windowsHide: true,
262
+ }));
263
+ if (configured)
264
+ result = configured;
265
+ }
266
+ catch {
267
+ // Unset, or git unavailable — fall through to git's documented default.
268
+ }
269
+ if (!result) {
270
+ const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
271
+ result = path.join(xdgConfigHome, 'git', 'ignore');
272
+ }
273
+ coreExcludesFilePathCache.set(fromPath, result);
274
+ return result;
275
+ };
201
276
  /**
202
277
  * Resolve `fromPath` to the directory whose basename should drive the
203
278
  * registry name (#1259) — the *identity root*. Three outcomes:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.10-rc.79",
3
+ "version": "1.6.10-rc.80",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",