git-hooks.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. /**
  2. * Git Sync Hooks
  3. *
  4. * When the live file watcher is disabled (e.g. on WSL2 `/mnt/*` drives,
  5. * see watch-policy.ts), the CodeGraph index would otherwise go stale until
  6. * the user runs `codegraph sync` by hand. As an opt-in alternative, we can
  7. * install git hooks that refresh the index after the operations that change
  8. * files on disk: commit, merge (covers `git pull`), and checkout.
  9. *
  10. * The hooks run `codegraph sync` in the background so they never block git,
  11. * and are guarded by `command -v codegraph` so they no-op cleanly when the
  12. * CLI isn't on PATH. Our snippet is delimited by marker comments so install
  13. * is idempotent and removal preserves any user-authored hook content.
  14. */
  15. import * as fs from 'fs';
  16. import * as path from 'path';
  17. import { execFileSync } from 'child_process';
  18. const MARKER_BEGIN = '# >>> codegraph sync hook >>>';
  19. const MARKER_END = '# <<< codegraph sync hook <<<';
  20. export type GitHookName = 'post-commit' | 'post-merge' | 'post-checkout';
  21. /** Hooks installed by default: commit, merge (git pull), and checkout. */
  22. export const DEFAULT_SYNC_HOOKS: GitHookName[] = ['post-commit', 'post-merge', 'post-checkout'];
  23. export interface GitHookResult {
  24. /** Hook names that were created or updated. */
  25. installed: GitHookName[];
  26. /** Resolved hooks directory, or null when not a git repo. */
  27. hooksDir: string | null;
  28. /** Reason nothing happened (e.g. not a git repository). */
  29. skipped?: string;
  30. }
  31. /**
  32. * Whether `projectRoot` is inside a git working tree. Returns false if git
  33. * isn't installed or the path isn't a repo.
  34. */
  35. export function isGitRepo(projectRoot: string): boolean {
  36. try {
  37. const out = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
  38. cwd: projectRoot,
  39. encoding: 'utf8',
  40. stdio: ['ignore', 'pipe', 'ignore'],
  41. windowsHide: true,
  42. timeout: 5000, // fail fast instead of hanging init/sync on a stuck git (#1139)
  43. }).trim();
  44. return out === 'true';
  45. } catch {
  46. return false;
  47. }
  48. }
  49. /**
  50. * Resolve the git hooks directory for a project, honoring `core.hooksPath`
  51. * and git worktrees. Returns an absolute path, or null when not a repo.
  52. */
  53. function gitHooksDir(projectRoot: string): string | null {
  54. try {
  55. const out = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
  56. cwd: projectRoot,
  57. encoding: 'utf8',
  58. stdio: ['ignore', 'pipe', 'ignore'],
  59. windowsHide: true,
  60. timeout: 5000, // same rationale as isGitRepo
  61. }).trim();
  62. if (!out) return null;
  63. return path.isAbsolute(out) ? out : path.resolve(projectRoot, out);
  64. } catch {
  65. return null;
  66. }
  67. }
  68. /** The shell snippet (between markers) injected into each hook. */
  69. function markerBlock(): string {
  70. return [
  71. MARKER_BEGIN,
  72. '# Keeps the CodeGraph index fresh while the live file watcher is off',
  73. '# (e.g. WSL2 /mnt drives). Runs in the background so it never blocks git.',
  74. '# Managed by codegraph; remove with `codegraph uninit` or delete this block.',
  75. 'if command -v codegraph >/dev/null 2>&1; then',
  76. ' ( codegraph sync >/dev/null 2>&1 & ) >/dev/null 2>&1',
  77. 'fi',
  78. MARKER_END,
  79. ].join('\n');
  80. }
  81. /** Remove our marker block (and the marker lines) from hook content. */
  82. function stripMarkerBlock(content: string): string {
  83. const lines = content.split('\n');
  84. const kept: string[] = [];
  85. let inBlock = false;
  86. for (const line of lines) {
  87. const trimmed = line.trim();
  88. if (trimmed === MARKER_BEGIN) { inBlock = true; continue; }
  89. if (trimmed === MARKER_END) { inBlock = false; continue; }
  90. if (!inBlock) kept.push(line);
  91. }
  92. return kept.join('\n');
  93. }
  94. /** Whether a hook body is just a shebang / blank lines (i.e. only ever ours). */
  95. function isEffectivelyEmpty(content: string): boolean {
  96. return content
  97. .split('\n')
  98. .map((l) => l.trim())
  99. .every((l) => l.length === 0 || l.startsWith('#!'));
  100. }
  101. function chmodExecutable(file: string): void {
  102. try {
  103. fs.chmodSync(file, 0o755);
  104. } catch {
  105. /* chmod is a no-op / unsupported on some platforms (e.g. Windows) */
  106. }
  107. }
  108. /**
  109. * Install (or update) the CodeGraph sync hooks in a git repository.
  110. * Idempotent: re-running replaces our marker block rather than duplicating
  111. * it, and any user-authored hook content is preserved.
  112. */
  113. export function installGitSyncHook(
  114. projectRoot: string,
  115. hooks: GitHookName[] = DEFAULT_SYNC_HOOKS,
  116. ): GitHookResult {
  117. const hooksDir = gitHooksDir(projectRoot);
  118. if (!hooksDir) {
  119. return { installed: [], hooksDir: null, skipped: 'not a git repository' };
  120. }
  121. try {
  122. fs.mkdirSync(hooksDir, { recursive: true });
  123. } catch {
  124. return { installed: [], hooksDir, skipped: 'could not access the git hooks directory' };
  125. }
  126. const block = markerBlock();
  127. const installed: GitHookName[] = [];
  128. for (const hook of hooks) {
  129. const file = path.join(hooksDir, hook);
  130. let content: string;
  131. if (fs.existsSync(file)) {
  132. // Strip any prior block, then re-append the current one.
  133. const base = stripMarkerBlock(fs.readFileSync(file, 'utf8')).replace(/\s*$/, '');
  134. content = base.length > 0
  135. ? `${base}\n\n${block}\n`
  136. : `#!/bin/sh\n${block}\n`;
  137. } else {
  138. content = `#!/bin/sh\n${block}\n`;
  139. }
  140. fs.writeFileSync(file, content);
  141. chmodExecutable(file);
  142. installed.push(hook);
  143. }
  144. return { installed, hooksDir };
  145. }
  146. /**
  147. * Remove the CodeGraph sync hooks. Strips only our marker block; deletes the
  148. * hook file entirely when nothing but a shebang remains, otherwise rewrites
  149. * the user's content untouched.
  150. */
  151. export function removeGitSyncHook(
  152. projectRoot: string,
  153. hooks: GitHookName[] = DEFAULT_SYNC_HOOKS,
  154. ): GitHookResult {
  155. const hooksDir = gitHooksDir(projectRoot);
  156. if (!hooksDir) {
  157. return { installed: [], hooksDir: null, skipped: 'not a git repository' };
  158. }
  159. const removed: GitHookName[] = [];
  160. for (const hook of hooks) {
  161. const file = path.join(hooksDir, hook);
  162. if (!fs.existsSync(file)) continue;
  163. const original = fs.readFileSync(file, 'utf8');
  164. if (!original.includes(MARKER_BEGIN)) continue;
  165. const stripped = stripMarkerBlock(original);
  166. if (isEffectivelyEmpty(stripped)) {
  167. fs.unlinkSync(file);
  168. } else {
  169. fs.writeFileSync(file, `${stripped.replace(/\s*$/, '')}\n`);
  170. chmodExecutable(file);
  171. }
  172. removed.push(hook);
  173. }
  174. return { installed: removed, hooksDir };
  175. }
  176. /** Whether any CodeGraph sync hook is currently installed. */
  177. export function isSyncHookInstalled(
  178. projectRoot: string,
  179. hooks: GitHookName[] = DEFAULT_SYNC_HOOKS,
  180. ): boolean {
  181. const hooksDir = gitHooksDir(projectRoot);
  182. if (!hooksDir) return false;
  183. return hooks.some((hook) => {
  184. const file = path.join(hooksDir, hook);
  185. return fs.existsSync(file) && fs.readFileSync(file, 'utf8').includes(MARKER_BEGIN);
  186. });
  187. }