Merge pull request #426 from ShunsukeHayashi/fix/no-git-folder-384

This commit is contained in:
Gergő Magyar 2026-03-22 08:46:47 +00:00 • committed by GitHub
commit 22b5fce19e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 202 additions and 11 deletions

View file

@ -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, 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. */
skipGit?: 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?.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;
}
// --skip-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 = 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 \u2014 commit-tracking and incremental updates disabled.\n');
}
const { storagePath, lbugPath } = getStoragePaths(repoPath);
@ -109,12 +120,15 @@ 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) {
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) {
@ -317,7 +331,12 @@ export const analyzeCommand = async (
};
await saveMeta(storagePath, meta);
await registerRepo(repoPath, meta);
await addToGitignore(repoPath);
// Only attempt to update .gitignore when a .git directory is present.
// 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);
}
const projectName = path.basename(repoPath);
let aggregatedClusterCount = 0;

View file

@ -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('--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'));

View file

@ -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
@ -34,3 +35,22 @@ 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 {
statSync(path.join(dirPath, '.git'));
return true;
} catch {
return false;
}
};

View file

@ -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 });
}
});
});

View file

@ -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 });
}
});
});