From 4dffd81b12123a668d42da98e91aeab28845e77f Mon Sep 17 00:00:00 2001 From: Shunsuke Hayashi Date: Sun, 22 Mar 2026 13:20:02 +0900 Subject: [PATCH 1/3] fix(analyze): allow indexing folders without a .git directory (#384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously gitnexus analyze exited with an error on any directory that lacked a .git entry, making it impossible to index generated code, vendored libraries, or monorepo sub-trees that are not git roots. Changes: storage/git.ts - Add hasGitDir(dirPath): boolean — a lightweight synchronous check for the presence of a .git file or directory. Works for git worktrees (.git file pointing at the real repo) as well as standard repos. cli/analyze.ts - Add noGit?: boolean to AnalyzeOptions. - When the explicit inputPath resolves to a non-git folder (or the cwd is not inside any git repo), respect --no-git instead of hard-failing. - Print an actionable tip pointing at --no-git when git is absent and the flag was not supplied. - currentCommit defaults to an empty string for non-git folders so the up-to-date check still functions (empty string never matches a real commit hash, so the index is always rebuilt). - Skip addToGitignore() when no .git is present — there is nothing to update and the function would create a stale .gitignore at the root. Git-dependent features that remain disabled for non-git folders: - Incremental update (always rebuilds from scratch) - Commit tracking in metadata - .gitignore update Closes #384 --- gitnexus/src/cli/analyze.ts | 32 +++++--- gitnexus/src/storage/git.ts | 20 +++++ gitnexus/test/unit/git-utils.test.ts | 113 +++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 gitnexus/test/unit/git-utils.test.ts diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 4965563ea..cec7074c3 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -15,7 +15,7 @@ import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReuse // versions whose ABI is not yet supported by the native binary (#89). // disposeEmbedder intentionally not called — ONNX Runtime segfaults on cleanup (see #38) import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, getGlobalRegistryPath, cleanupOldKuzuFiles } from '../storage/repo-manager.js'; -import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js'; +import { getCurrentCommit, isGitRepo, getGitRoot, hasGitDir } from '../storage/git.js'; import { generateAIContextFiles } from './ai-context.js'; import { generateSkillFiles, type GeneratedSkillInfo } from './skill-gen.js'; import fs from 'fs/promises'; @@ -48,6 +48,8 @@ export interface AnalyzeOptions { embeddings?: boolean; skills?: boolean; verbose?: boolean; + /** Index the folder even when no .git directory is present. */ + noGit?: boolean; } /** Threshold: auto-skip embeddings for repos with more nodes than this */ @@ -87,18 +89,27 @@ export const analyzeCommand = async ( } else { const gitRoot = getGitRoot(process.cwd()); if (!gitRoot) { - console.log(' Not inside a git repository\n'); - process.exitCode = 1; - return; + if (!options?.noGit) { + console.log(' Not inside a git repository.\n Tip: pass --no-git to index any folder without a .git directory.\n'); + process.exitCode = 1; + return; + } + // --no-git: fall back to cwd as the root + repoPath = path.resolve(process.cwd()); + } else { + repoPath = gitRoot; } - repoPath = gitRoot; } - if (!isGitRepo(repoPath)) { - console.log(' Not a git repository\n'); + const repoHasGit = isGitRepo(repoPath); + if (!repoHasGit && !options?.noGit) { + console.log(' Not a git repository.\n Tip: pass --no-git to index any folder without a .git directory.\n'); process.exitCode = 1; return; } + if (!repoHasGit) { + console.log(' Warning: no .git directory found — commit-tracking and incremental updates disabled.\n'); + } const { storagePath, lbugPath } = getStoragePaths(repoPath); @@ -109,7 +120,7 @@ export const analyzeCommand = async ( console.log(' Migrating from KuzuDB to LadybugDB — rebuilding index...\n'); } - const currentCommit = getCurrentCommit(repoPath); + const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : ''; const existingMeta = await loadMeta(storagePath); if (existingMeta && !options?.force && !options?.skills && existingMeta.lastCommit === currentCommit) { @@ -317,7 +328,10 @@ export const analyzeCommand = async ( }; await saveMeta(storagePath, meta); await registerRepo(repoPath, meta); - await addToGitignore(repoPath); + // Only attempt to update .gitignore when a git repository is present + if (repoHasGit) { + await addToGitignore(repoPath); + } const projectName = path.basename(repoPath); let aggregatedClusterCount = 0; diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index 99609ac1c..3b4381b93 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -34,3 +34,23 @@ export const getGitRoot = (fromPath: string): string | null => { return null; } }; +/** + * Check whether a directory contains a .git entry (file or folder). + * + * This is intentionally a simple filesystem check rather than running + * `git rev-parse`, so it works even when git is not installed or when + * the directory is a git-worktree root (which has a .git file, not a + * directory). Use `isGitRepo` for a definitive git answer. + * + * @param dirPath - Absolute path to the directory to inspect. + * @returns `true` when `.git` is present, `false` otherwise. + */ +export const hasGitDir = (dirPath: string): boolean => { + try { + const { statSync } = require('fs') as typeof import('fs'); + statSync(path.join(dirPath, '.git')); + return true; + } catch { + return false; + } +}; diff --git a/gitnexus/test/unit/git-utils.test.ts b/gitnexus/test/unit/git-utils.test.ts new file mode 100644 index 000000000..adb3c339b --- /dev/null +++ b/gitnexus/test/unit/git-utils.test.ts @@ -0,0 +1,113 @@ +/** + * Unit Tests: git utility helpers (storage/git.ts) + * + * Tests isGitRepo, getCurrentCommit, getGitRoot, and the newly added + * hasGitDir helper introduced for issue #384 (indexing non-git folders). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; + +// ─── hasGitDir ──────────────────────────────────────────────────────────── +// +// hasGitDir is a synchronous fs.statSync check — we test it by actually +// creating temporary directories rather than mocking the fs module, +// because the implementation is a simple one-liner and real disk I/O is +// fast and deterministic for this purpose. + +describe('hasGitDir', () => { + // Import after test setup to ensure module resolution is correct + const getHasGitDir = async () => { + const mod = await import('../../src/storage/git.js'); + return mod.hasGitDir; + }; + + it('returns true when .git directory exists', async () => { + const hasGitDir = await getHasGitDir(); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-')); + try { + fs.mkdirSync(path.join(tmpDir, '.git')); + expect(hasGitDir(tmpDir)).toBe(true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns true when .git is a file (git worktree)', async () => { + const hasGitDir = await getHasGitDir(); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-')); + try { + fs.writeFileSync(path.join(tmpDir, '.git'), 'gitdir: /some/other/.git\n'); + expect(hasGitDir(tmpDir)).toBe(true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns false when .git entry is absent', async () => { + const hasGitDir = await getHasGitDir(); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-')); + try { + // No .git here — plain directory + expect(hasGitDir(tmpDir)).toBe(false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns false for a non-existent path', async () => { + const hasGitDir = await getHasGitDir(); + expect(hasGitDir('/tmp/__gitnexus_nonexistent_path__')).toBe(false); + }); +}); + +// ─── isGitRepo ──────────────────────────────────────────────────────────── +// +// isGitRepo shells out to `git rev-parse` — we verify it returns false +// for a plain temp directory without running git init. + +describe('isGitRepo', () => { + it('returns false for a plain (non-git) directory', async () => { + const { isGitRepo } = await import('../../src/storage/git.js'); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-')); + try { + expect(isGitRepo(tmpDir)).toBe(false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('returns false for a non-existent path', async () => { + const { isGitRepo } = await import('../../src/storage/git.js'); + expect(isGitRepo('/tmp/__gitnexus_nonexistent__')).toBe(false); + }); +}); + +// ─── getCurrentCommit ───────────────────────────────────────────────────── + +describe('getCurrentCommit', () => { + it('returns empty string for a non-git directory', async () => { + const { getCurrentCommit } = await import('../../src/storage/git.js'); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-')); + try { + expect(getCurrentCommit(tmpDir)).toBe(''); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +// ─── getGitRoot ─────────────────────────────────────────────────────────── + +describe('getGitRoot', () => { + it('returns null for a plain temp directory', async () => { + const { getGitRoot } = await import('../../src/storage/git.js'); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-test-')); + try { + expect(getGitRoot(tmpDir)).toBeNull(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); From f0f384aab796ca0c309c989079659bf4f7db08c2 Mon Sep 17 00:00:00 2001 From: Shunsuke Hayashi Date: Sun, 22 Mar 2026 16:24:21 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(analyze):=20address=20Copilot=20review?= =?UTF-8?q?=20=E2=80=94=20ESM=20import,=20CLI=20option,=20.gitignore=20gua?= =?UTF-8?q?rd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace require(" fs\) with ESM-compatible top-level import (statSync) - Register --no-git option in Commander CLI definition - Use hasGitDir() instead of isGitRepo() for .gitignore update guard to match the PR intent (filesystem check vs git CLI invocation) --- gitnexus/src/cli/analyze.ts | 6 ++++-- gitnexus/src/cli/index.ts | 1 + gitnexus/src/storage/git.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index cec7074c3..dc6270f2b 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -328,8 +328,10 @@ export const analyzeCommand = async ( }; await saveMeta(storagePath, meta); await registerRepo(repoPath, meta); - // Only attempt to update .gitignore when a git repository is present - if (repoHasGit) { + // Only attempt to update .gitignore when a .git directory is present. + // Use hasGitDir (filesystem check) rather than isGitRepo (shells out to git) + // so we skip correctly for --no-git folders even if git CLI is available. + if (hasGitDir(repoPath)) { await addToGitignore(repoPath); } diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 6f5164f5c..f0e88acc3 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -28,6 +28,7 @@ program .option('-f, --force', 'Force full re-index even if up to date') .option('--embeddings', 'Enable embedding generation for semantic search (off by default)') .option('--skills', 'Generate repo-specific skill files from detected communities') + .option('--no-git', 'Index a folder without requiring a .git directory') .option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)') .addHelpText('after', '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)') .action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand')); diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index 3b4381b93..b9330ff91 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -1,4 +1,5 @@ import { execSync } from 'child_process'; +import { statSync } from 'fs'; import path from 'path'; // Git utilities for repository detection, commit tracking, and diff analysis @@ -47,7 +48,6 @@ export const getGitRoot = (fromPath: string): string | null => { */ export const hasGitDir = (dirPath: string): boolean => { try { - const { statSync } = require('fs') as typeof import('fs'); statSync(path.join(dirPath, '.git')); return true; } catch { From 09e36093760ff908367254efb698d90ca083ac75 Mon Sep 17 00:00:00 2001 From: Shunsuke Hayashi Date: Sun, 22 Mar 2026 17:40:02 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(analyze):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20rename=20--no-git=20to=20--skip-git,=20fix=20stale?= =?UTF-8?q?=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses all review items from @magyargergo and Copilot: 1. **Rename --no-git to --skip-git**: Commander.js treats --no-X flags as negation of --X (stores as options.git = false, not options.noGit). --skip-git maps correctly to options.skipGit. 2. **Fix false " Already up to date\ on non-git folders**: When currentCommit is empty string, skip the cache check — we cannot detect changes without git, so always rebuild. 3. **Replace isGitRepo() with hasGitDir()**: Use filesystem check (statSync on .git) instead of shelling out to git CLI. Consistent, faster, and works when git is not installed. 4. **Fix misleading warning**: Message now only fires when .git directory is actually absent (not when git CLI fails). 5. **Add CLI integration tests**: Verify Commander maps --skip-git correctly and that non-git folders are rejected without the flag. --- gitnexus/src/cli/analyze.ts | 29 ++++++++++--------- gitnexus/src/cli/index.ts | 2 +- gitnexus/test/unit/skip-git-cli.test.ts | 38 +++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 gitnexus/test/unit/skip-git-cli.test.ts diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index dc6270f2b..50bc7ef8d 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -15,7 +15,7 @@ import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReuse // versions whose ABI is not yet supported by the native binary (#89). // disposeEmbedder intentionally not called — ONNX Runtime segfaults on cleanup (see #38) import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, getGlobalRegistryPath, cleanupOldKuzuFiles } from '../storage/repo-manager.js'; -import { getCurrentCommit, isGitRepo, getGitRoot, hasGitDir } from '../storage/git.js'; +import { getCurrentCommit, getGitRoot, hasGitDir } from '../storage/git.js'; import { generateAIContextFiles } from './ai-context.js'; import { generateSkillFiles, type GeneratedSkillInfo } from './skill-gen.js'; import fs from 'fs/promises'; @@ -49,7 +49,7 @@ export interface AnalyzeOptions { skills?: boolean; verbose?: boolean; /** Index the folder even when no .git directory is present. */ - noGit?: boolean; + skipGit?: boolean; } /** Threshold: auto-skip embeddings for repos with more nodes than this */ @@ -89,26 +89,26 @@ export const analyzeCommand = async ( } else { const gitRoot = getGitRoot(process.cwd()); if (!gitRoot) { - if (!options?.noGit) { - console.log(' Not inside a git repository.\n Tip: pass --no-git to index any folder without a .git directory.\n'); + if (!options?.skipGit) { + console.log(' Not inside a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n'); process.exitCode = 1; return; } - // --no-git: fall back to cwd as the root + // --skip-git: fall back to cwd as the root repoPath = path.resolve(process.cwd()); } else { repoPath = gitRoot; } } - const repoHasGit = isGitRepo(repoPath); - if (!repoHasGit && !options?.noGit) { - console.log(' Not a git repository.\n Tip: pass --no-git to index any folder without a .git directory.\n'); + const repoHasGit = hasGitDir(repoPath); + if (!repoHasGit && !options?.skipGit) { + console.log(' Not a git repository.\n Tip: pass --skip-git to index any folder without a .git directory.\n'); process.exitCode = 1; return; } if (!repoHasGit) { - console.log(' Warning: no .git directory found — commit-tracking and incremental updates disabled.\n'); + console.log(' Warning: no .git directory found \u2014 commit-tracking and incremental updates disabled.\n'); } const { storagePath, lbugPath } = getStoragePaths(repoPath); @@ -124,8 +124,11 @@ export const analyzeCommand = async ( const existingMeta = await loadMeta(storagePath); if (existingMeta && !options?.force && !options?.skills && existingMeta.lastCommit === currentCommit) { - console.log(' Already up to date\n'); - return; + // Non-git folders have currentCommit = '' — always rebuild since we can't detect changes + if (currentCommit !== '') { + console.log(' Already up to date\n'); + return; + } } if (process.env.GITNEXUS_NO_GITIGNORE) { @@ -329,8 +332,8 @@ export const analyzeCommand = async ( await saveMeta(storagePath, meta); await registerRepo(repoPath, meta); // Only attempt to update .gitignore when a .git directory is present. - // Use hasGitDir (filesystem check) rather than isGitRepo (shells out to git) - // so we skip correctly for --no-git folders even if git CLI is available. + // Use hasGitDir (filesystem check) rather than git CLI subprocess + // so we skip correctly for --skip-git folders even if git CLI is available. if (hasGitDir(repoPath)) { await addToGitignore(repoPath); } diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index f0e88acc3..9743e135c 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -28,7 +28,7 @@ program .option('-f, --force', 'Force full re-index even if up to date') .option('--embeddings', 'Enable embedding generation for semantic search (off by default)') .option('--skills', 'Generate repo-specific skill files from detected communities') - .option('--no-git', 'Index a folder without requiring a .git directory') + .option('--skip-git', 'Index a folder without requiring a .git directory') .option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)') .addHelpText('after', '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)') .action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand')); diff --git a/gitnexus/test/unit/skip-git-cli.test.ts b/gitnexus/test/unit/skip-git-cli.test.ts new file mode 100644 index 000000000..73707fe61 --- /dev/null +++ b/gitnexus/test/unit/skip-git-cli.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { execSync } from 'child_process'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; + +describe('--skip-git CLI flag', () => { + it('Commander maps --skip-git to options.skipGit (not --no-git inversion)', () => { + // Verify the CLI defines --skip-git, not --no-git + const helpOutput = execSync('node dist/cli/index.js analyze --help', { + cwd: path.resolve(__dirname, '../..'), + encoding: 'utf8', + timeout: 10000, + }); + + expect(helpOutput).toContain('--skip-git'); + expect(helpOutput).not.toContain('--no-git'); + }); + + it('rejects non-git folder without --skip-git', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-no-git-')); + fs.writeFileSync(path.join(tmpDir, 'test.ts'), 'export const x = 1;'); + + try { + execSync(`node dist/cli/index.js analyze "${tmpDir}"`, { + cwd: path.resolve(__dirname, '../..'), + encoding: 'utf8', + timeout: 10000, + }); + // Should not reach here + expect.unreachable('Should have exited with non-zero'); + } catch (err: any) { + expect(err.stdout || err.stderr || '').toContain('--skip-git'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +});