git-hooks.test.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. /**
  2. * Git Sync Hooks Tests
  3. *
  4. * Covers installing/removing the opt-in commit/merge/checkout hooks that
  5. * keep the index fresh when the live watcher is disabled (issue #199).
  6. * Exercises real git repos in temp dirs — no mocking.
  7. */
  8. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  9. import { execFileSync } from 'child_process';
  10. import * as fs from 'fs';
  11. import * as path from 'path';
  12. import * as os from 'os';
  13. import {
  14. installGitSyncHook,
  15. removeGitSyncHook,
  16. isSyncHookInstalled,
  17. isGitRepo,
  18. DEFAULT_SYNC_HOOKS,
  19. } from '../src/sync/git-hooks';
  20. function gitInit(dir: string): void {
  21. execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' });
  22. }
  23. function isExecutable(file: string): boolean {
  24. if (process.platform === 'win32') return true; // mode bits not meaningful
  25. return (fs.statSync(file).mode & 0o111) !== 0;
  26. }
  27. describe('git sync hooks', () => {
  28. let repo: string;
  29. beforeEach(() => {
  30. repo = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-githooks-'));
  31. });
  32. afterEach(() => {
  33. if (fs.existsSync(repo)) fs.rmSync(repo, { recursive: true, force: true });
  34. });
  35. it('installs all default hooks, executable, invoking codegraph sync', () => {
  36. gitInit(repo);
  37. const result = installGitSyncHook(repo);
  38. expect(result.installed.sort()).toEqual([...DEFAULT_SYNC_HOOKS].sort());
  39. expect(result.skipped).toBeUndefined();
  40. for (const hook of DEFAULT_SYNC_HOOKS) {
  41. const file = path.join(repo, '.git', 'hooks', hook);
  42. expect(fs.existsSync(file)).toBe(true);
  43. const body = fs.readFileSync(file, 'utf8');
  44. expect(body).toContain('codegraph sync');
  45. expect(body).toContain('command -v codegraph'); // no-op when not on PATH
  46. expect(isExecutable(file)).toBe(true);
  47. }
  48. expect(isSyncHookInstalled(repo)).toBe(true);
  49. });
  50. it('is idempotent — re-install does not duplicate the block', () => {
  51. gitInit(repo);
  52. installGitSyncHook(repo);
  53. installGitSyncHook(repo);
  54. const body = fs.readFileSync(path.join(repo, '.git', 'hooks', 'post-commit'), 'utf8');
  55. const occurrences = body.split('# >>> codegraph sync hook >>>').length - 1;
  56. expect(occurrences).toBe(1);
  57. });
  58. it('preserves a pre-existing user hook and appends our block', () => {
  59. gitInit(repo);
  60. const file = path.join(repo, '.git', 'hooks', 'post-commit');
  61. fs.writeFileSync(file, '#!/bin/sh\necho "my custom hook"\n', { mode: 0o755 });
  62. installGitSyncHook(repo, ['post-commit']);
  63. const body = fs.readFileSync(file, 'utf8');
  64. expect(body).toContain('echo "my custom hook"');
  65. expect(body).toContain('codegraph sync');
  66. });
  67. it('remove strips our block; deletes a hook that was only ours', () => {
  68. gitInit(repo);
  69. installGitSyncHook(repo, ['post-commit']);
  70. const file = path.join(repo, '.git', 'hooks', 'post-commit');
  71. expect(fs.existsSync(file)).toBe(true);
  72. const result = removeGitSyncHook(repo, ['post-commit']);
  73. expect(result.installed).toEqual(['post-commit']);
  74. expect(fs.existsSync(file)).toBe(false); // was ours-only → deleted
  75. expect(isSyncHookInstalled(repo)).toBe(false);
  76. });
  77. it('remove keeps user content when the hook is shared', () => {
  78. gitInit(repo);
  79. const file = path.join(repo, '.git', 'hooks', 'post-commit');
  80. fs.writeFileSync(file, '#!/bin/sh\necho "keep me"\n', { mode: 0o755 });
  81. installGitSyncHook(repo, ['post-commit']);
  82. removeGitSyncHook(repo, ['post-commit']);
  83. expect(fs.existsSync(file)).toBe(true);
  84. const body = fs.readFileSync(file, 'utf8');
  85. expect(body).toContain('echo "keep me"');
  86. expect(body).not.toContain('codegraph sync');
  87. });
  88. it('honors core.hooksPath', () => {
  89. gitInit(repo);
  90. const customHooks = path.join(repo, '.husky');
  91. fs.mkdirSync(customHooks);
  92. execFileSync('git', ['config', 'core.hooksPath', '.husky'], { cwd: repo, stdio: 'ignore' });
  93. const result = installGitSyncHook(repo, ['post-commit']);
  94. expect(result.hooksDir).toBe(customHooks);
  95. expect(fs.existsSync(path.join(customHooks, 'post-commit'))).toBe(true);
  96. // The default .git/hooks dir should NOT have received the hook.
  97. expect(fs.existsSync(path.join(repo, '.git', 'hooks', 'post-commit'))).toBe(false);
  98. });
  99. it('skips cleanly when not a git repository', () => {
  100. expect(isGitRepo(repo)).toBe(false);
  101. const result = installGitSyncHook(repo);
  102. expect(result.installed).toEqual([]);
  103. expect(result.hooksDir).toBeNull();
  104. expect(result.skipped).toMatch(/not a git repository/);
  105. expect(isSyncHookInstalled(repo)).toBe(false);
  106. });
  107. });