| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210 |
- /**
- * Git Sync Hooks
- *
- * When the live file watcher is disabled (e.g. on WSL2 `/mnt/*` drives,
- * see watch-policy.ts), the CodeGraph index would otherwise go stale until
- * the user runs `codegraph sync` by hand. As an opt-in alternative, we can
- * install git hooks that refresh the index after the operations that change
- * files on disk: commit, merge (covers `git pull`), and checkout.
- *
- * The hooks run `codegraph sync` in the background so they never block git,
- * and are guarded by `command -v codegraph` so they no-op cleanly when the
- * CLI isn't on PATH. Our snippet is delimited by marker comments so install
- * is idempotent and removal preserves any user-authored hook content.
- */
- import * as fs from 'fs';
- import * as path from 'path';
- import { execFileSync } from 'child_process';
- const MARKER_BEGIN = '# >>> codegraph sync hook >>>';
- const MARKER_END = '# <<< codegraph sync hook <<<';
- export type GitHookName = 'post-commit' | 'post-merge' | 'post-checkout';
- /** Hooks installed by default: commit, merge (git pull), and checkout. */
- export const DEFAULT_SYNC_HOOKS: GitHookName[] = ['post-commit', 'post-merge', 'post-checkout'];
- export interface GitHookResult {
- /** Hook names that were created or updated. */
- installed: GitHookName[];
- /** Resolved hooks directory, or null when not a git repo. */
- hooksDir: string | null;
- /** Reason nothing happened (e.g. not a git repository). */
- skipped?: string;
- }
- /**
- * Whether `projectRoot` is inside a git working tree. Returns false if git
- * isn't installed or the path isn't a repo.
- */
- export function isGitRepo(projectRoot: string): boolean {
- try {
- const out = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
- cwd: projectRoot,
- encoding: 'utf8',
- stdio: ['ignore', 'pipe', 'ignore'],
- windowsHide: true,
- }).trim();
- return out === 'true';
- } catch {
- return false;
- }
- }
- /**
- * Resolve the git hooks directory for a project, honoring `core.hooksPath`
- * and git worktrees. Returns an absolute path, or null when not a repo.
- */
- function gitHooksDir(projectRoot: string): string | null {
- try {
- const out = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
- cwd: projectRoot,
- encoding: 'utf8',
- stdio: ['ignore', 'pipe', 'ignore'],
- windowsHide: true,
- }).trim();
- if (!out) return null;
- return path.isAbsolute(out) ? out : path.resolve(projectRoot, out);
- } catch {
- return null;
- }
- }
- /** The shell snippet (between markers) injected into each hook. */
- function markerBlock(): string {
- return [
- MARKER_BEGIN,
- '# Keeps the CodeGraph index fresh while the live file watcher is off',
- '# (e.g. WSL2 /mnt drives). Runs in the background so it never blocks git.',
- '# Managed by codegraph; remove with `codegraph uninit` or delete this block.',
- 'if command -v codegraph >/dev/null 2>&1; then',
- ' ( codegraph sync >/dev/null 2>&1 & ) >/dev/null 2>&1',
- 'fi',
- MARKER_END,
- ].join('\n');
- }
- /** Remove our marker block (and the marker lines) from hook content. */
- function stripMarkerBlock(content: string): string {
- const lines = content.split('\n');
- const kept: string[] = [];
- let inBlock = false;
- for (const line of lines) {
- const trimmed = line.trim();
- if (trimmed === MARKER_BEGIN) { inBlock = true; continue; }
- if (trimmed === MARKER_END) { inBlock = false; continue; }
- if (!inBlock) kept.push(line);
- }
- return kept.join('\n');
- }
- /** Whether a hook body is just a shebang / blank lines (i.e. only ever ours). */
- function isEffectivelyEmpty(content: string): boolean {
- return content
- .split('\n')
- .map((l) => l.trim())
- .every((l) => l.length === 0 || l.startsWith('#!'));
- }
- function chmodExecutable(file: string): void {
- try {
- fs.chmodSync(file, 0o755);
- } catch {
- /* chmod is a no-op / unsupported on some platforms (e.g. Windows) */
- }
- }
- /**
- * Install (or update) the CodeGraph sync hooks in a git repository.
- * Idempotent: re-running replaces our marker block rather than duplicating
- * it, and any user-authored hook content is preserved.
- */
- export function installGitSyncHook(
- projectRoot: string,
- hooks: GitHookName[] = DEFAULT_SYNC_HOOKS,
- ): GitHookResult {
- const hooksDir = gitHooksDir(projectRoot);
- if (!hooksDir) {
- return { installed: [], hooksDir: null, skipped: 'not a git repository' };
- }
- try {
- fs.mkdirSync(hooksDir, { recursive: true });
- } catch {
- return { installed: [], hooksDir, skipped: 'could not access the git hooks directory' };
- }
- const block = markerBlock();
- const installed: GitHookName[] = [];
- for (const hook of hooks) {
- const file = path.join(hooksDir, hook);
- let content: string;
- if (fs.existsSync(file)) {
- // Strip any prior block, then re-append the current one.
- const base = stripMarkerBlock(fs.readFileSync(file, 'utf8')).replace(/\s*$/, '');
- content = base.length > 0
- ? `${base}\n\n${block}\n`
- : `#!/bin/sh\n${block}\n`;
- } else {
- content = `#!/bin/sh\n${block}\n`;
- }
- fs.writeFileSync(file, content);
- chmodExecutable(file);
- installed.push(hook);
- }
- return { installed, hooksDir };
- }
- /**
- * Remove the CodeGraph sync hooks. Strips only our marker block; deletes the
- * hook file entirely when nothing but a shebang remains, otherwise rewrites
- * the user's content untouched.
- */
- export function removeGitSyncHook(
- projectRoot: string,
- hooks: GitHookName[] = DEFAULT_SYNC_HOOKS,
- ): GitHookResult {
- const hooksDir = gitHooksDir(projectRoot);
- if (!hooksDir) {
- return { installed: [], hooksDir: null, skipped: 'not a git repository' };
- }
- const removed: GitHookName[] = [];
- for (const hook of hooks) {
- const file = path.join(hooksDir, hook);
- if (!fs.existsSync(file)) continue;
- const original = fs.readFileSync(file, 'utf8');
- if (!original.includes(MARKER_BEGIN)) continue;
- const stripped = stripMarkerBlock(original);
- if (isEffectivelyEmpty(stripped)) {
- fs.unlinkSync(file);
- } else {
- fs.writeFileSync(file, `${stripped.replace(/\s*$/, '')}\n`);
- chmodExecutable(file);
- }
- removed.push(hook);
- }
- return { installed: removed, hooksDir };
- }
- /** Whether any CodeGraph sync hook is currently installed. */
- export function isSyncHookInstalled(
- projectRoot: string,
- hooks: GitHookName[] = DEFAULT_SYNC_HOOKS,
- ): boolean {
- const hooksDir = gitHooksDir(projectRoot);
- if (!hooksDir) return false;
- return hooks.some((hook) => {
- const file = path.join(hooksDir, hook);
- return fs.existsSync(file) && fs.readFileSync(file, 'utf8').includes(MARKER_BEGIN);
- });
- }
|