From 3804a05c6293b614e39c395bd86988f4dae5ac06 Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 15:34:31 +0530 Subject: [PATCH 01/16] feat(hook-utils): extract shared hook primitive functions stripMarkerBlock, isEffectivelyEmpty, and chmodExecutable were duplicated in git-hooks.ts. Extracting them here lets global-hooks.ts reuse the same logic without copy-pasting, and pins their contracts with 11 explicit unit tests. --- __tests__/hook-utils.test.ts | 108 +++++++++++++++++++++++++++++++++++ src/sync/hook-utils.ts | 41 +++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 __tests__/hook-utils.test.ts create mode 100644 src/sync/hook-utils.ts diff --git a/__tests__/hook-utils.test.ts b/__tests__/hook-utils.test.ts new file mode 100644 index 00000000..46c540e1 --- /dev/null +++ b/__tests__/hook-utils.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { stripMarkerBlock, isEffectivelyEmpty, chmodExecutable } from '../src/sync/hook-utils'; + +const BEGIN = '# >>> codegraph test >>>'; +const END = '# <<< codegraph test <<<'; + +describe('stripMarkerBlock', () => { + it('removes block between markers and preserves surrounding content', () => { + const content = `line before\n${BEGIN}\ninner line\n${END}\nline after`; + expect(stripMarkerBlock(content, BEGIN, END)).toBe('line before\nline after'); + }); + + it('returns content unchanged when no markers present', () => { + const content = 'no markers here\njust lines'; + expect(stripMarkerBlock(content, BEGIN, END)).toBe(content); + }); + + it('strips only the specified markers, leaving other marker strings untouched', () => { + const otherBegin = '# >>> other >>>'; + const otherEnd = '# <<< other <<<'; + const content = [ + 'keep', + BEGIN, 'codegraph block', END, + 'also keep', + otherBegin, 'other content', otherEnd, + 'end', + ].join('\n'); + const result = stripMarkerBlock(content, otherBegin, otherEnd); + expect(result).toContain(BEGIN); + expect(result).toContain('codegraph block'); + expect(result).not.toContain('other content'); + expect(result).not.toContain(otherBegin); + }); + + it('strips from begin marker to EOF when end marker is absent', () => { + const content = `before\n${BEGIN}\ninner`; + expect(stripMarkerBlock(content, BEGIN, END)).toBe('before'); + }); + + it('returns content unchanged when end marker is present but begin is absent', () => { + const content = `before\n${END}\nafter`; + expect(stripMarkerBlock(content, BEGIN, END)).toBe(content); + }); + + it('is idempotent: calling twice produces the same result as calling once', () => { + const content = `a\n${BEGIN}\nb\n${END}\nc`; + const once = stripMarkerBlock(content, BEGIN, END); + const twice = stripMarkerBlock(once, BEGIN, END); + expect(twice).toBe(once); + }); +}); + +describe('isEffectivelyEmpty', () => { + it('returns true for shebang line and blank lines only', () => { + expect(isEffectivelyEmpty('#!/bin/sh\n\n')).toBe(true); + }); + + it('returns true for empty string', () => { + expect(isEffectivelyEmpty('')).toBe(true); + }); + + it('returns false when real user content is present', () => { + expect(isEffectivelyEmpty('#!/bin/sh\necho "user hook"')).toBe(false); + }); + + it('returns false when a begin marker line is present', () => { + expect(isEffectivelyEmpty('#!/bin/sh\n# >>> codegraph auto-init hook >>>')).toBe(false); + }); + + it('returns false when an end marker line is present', () => { + expect(isEffectivelyEmpty('#!/bin/sh\n# <<< codegraph auto-init hook <<<')).toBe(false); + }); + + it('returns false when shebang is present alongside marker lines', () => { + const content = [ + '#!/bin/sh', + '# >>> codegraph sync hook >>>', + '# <<< codegraph sync hook <<<', + ].join('\n'); + expect(isEffectivelyEmpty(content)).toBe(false); + }); +}); + +describe('chmodExecutable', () => { + let tmp: string; + + beforeEach(() => { + tmp = path.join(os.tmpdir(), `hook-utils-chmod-${Date.now()}`); + }); + + afterEach(() => { + if (fs.existsSync(tmp)) fs.unlinkSync(tmp); + }); + + it('sets 0o755 executable bit on POSIX', () => { + if (process.platform === 'win32') return; + fs.writeFileSync(tmp, '#!/bin/sh\n', { mode: 0o644 }); + chmodExecutable(tmp); + expect(fs.statSync(tmp).mode & 0o111).not.toBe(0); + }); + + it('does not throw when the file does not exist', () => { + expect(() => chmodExecutable('/nonexistent/path/file.sh')).not.toThrow(); + }); +}); diff --git a/src/sync/hook-utils.ts b/src/sync/hook-utils.ts new file mode 100644 index 00000000..ee65195b --- /dev/null +++ b/src/sync/hook-utils.ts @@ -0,0 +1,41 @@ +import * as fs from 'fs'; + +/** + * Remove the block delimited by `begin` and `end` (inclusive) from `content`. + * Idempotent. When `begin` is present but `end` is absent, strips from `begin` + * to end-of-string (preserves compatibility with legacy partial writes). + * When `end` is present but `begin` is absent, returns content unchanged. + */ +export function stripMarkerBlock(content: string, begin: string, end: string): string { + const lines = content.split('\n'); + const kept: string[] = []; + let inBlock = false; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === begin) { inBlock = true; continue; } + if (trimmed === end && inBlock) { inBlock = false; continue; } + if (!inBlock) kept.push(line); + } + return kept.join('\n'); +} + +/** + * Returns true iff every line in `content` is blank or a shebang (`#!` prefix). + * Call AFTER stripMarkerBlock — marker lines are not "empty" and return false, + * guarding against incorrect file deletion when a strip was skipped. + */ +export function isEffectivelyEmpty(content: string): boolean { + return content + .split('\n') + .map((l) => l.trim()) + .every((l) => l.length === 0 || l.startsWith('#!')); +} + +/** Sets the executable bit (0o755) on `file`. No-op when chmod is unsupported. */ +export function chmodExecutable(file: string): void { + try { + fs.chmodSync(file, 0o755); + } catch { + /* no-op on Windows or when file does not exist */ + } +} From f87496d257644a2f787128009c574c8c2a09d2de Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 15:38:09 +0530 Subject: [PATCH 02/16] test(hook-utils): use it.skipIf for Windows platform skip --- __tests__/hook-utils.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/__tests__/hook-utils.test.ts b/__tests__/hook-utils.test.ts index 46c540e1..7c562f9e 100644 --- a/__tests__/hook-utils.test.ts +++ b/__tests__/hook-utils.test.ts @@ -95,8 +95,7 @@ describe('chmodExecutable', () => { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); }); - it('sets 0o755 executable bit on POSIX', () => { - if (process.platform === 'win32') return; + it.skipIf(process.platform === 'win32')('sets 0o755 executable bit on POSIX', () => { fs.writeFileSync(tmp, '#!/bin/sh\n', { mode: 0o644 }); chmodExecutable(tmp); expect(fs.statSync(tmp).mode & 0o111).not.toBe(0); From 54798863755c92466c2973f4a31271566f965594 Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 15:39:24 +0530 Subject: [PATCH 03/16] refactor(sync): import hook-utils in git-hooks --- src/sync/git-hooks.ts | 34 +++------------------------------- 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/src/sync/git-hooks.ts b/src/sync/git-hooks.ts index 3344c5ff..75d0f274 100644 --- a/src/sync/git-hooks.ts +++ b/src/sync/git-hooks.ts @@ -16,6 +16,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { execFileSync } from 'child_process'; +import { stripMarkerBlock, isEffectivelyEmpty, chmodExecutable } from './hook-utils'; const MARKER_BEGIN = '# >>> codegraph sync hook >>>'; const MARKER_END = '# <<< codegraph sync hook <<<'; @@ -83,35 +84,6 @@ function markerBlock(): string { ].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. @@ -142,7 +114,7 @@ export function installGitSyncHook( if (fs.existsSync(file)) { // Strip any prior block, then re-append the current one. - const base = stripMarkerBlock(fs.readFileSync(file, 'utf8')).replace(/\s*$/, ''); + const base = stripMarkerBlock(fs.readFileSync(file, 'utf8'), MARKER_BEGIN, MARKER_END).replace(/\s*$/, ''); content = base.length > 0 ? `${base}\n\n${block}\n` : `#!/bin/sh\n${block}\n`; @@ -181,7 +153,7 @@ export function removeGitSyncHook( const original = fs.readFileSync(file, 'utf8'); if (!original.includes(MARKER_BEGIN)) continue; - const stripped = stripMarkerBlock(original); + const stripped = stripMarkerBlock(original, MARKER_BEGIN, MARKER_END); if (isEffectivelyEmpty(stripped)) { fs.unlinkSync(file); } else { From 89e3744d68c92fda43ce5ee1f91514437b3ecbf7 Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 15:43:13 +0530 Subject: [PATCH 04/16] feat(global-hooks): add auto-init template hook install/remove Installs a post-checkout snippet into the git template directory so every new git clone automatically runs codegraph init + index. Uses the same marker-block pattern as the per-repo sync hooks, with full idempotency and surgical remove that preserves user hook content. --- __tests__/global-hooks.test.ts | 227 +++++++++++++++++++++++++++++++++ src/sync/global-hooks.ts | 155 ++++++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 __tests__/global-hooks.test.ts create mode 100644 src/sync/global-hooks.ts diff --git a/__tests__/global-hooks.test.ts b/__tests__/global-hooks.test.ts new file mode 100644 index 00000000..d8ebc7fb --- /dev/null +++ b/__tests__/global-hooks.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { + resolveTemplateDir, + installGlobalAutoInitHook, + removeGlobalAutoInitHook, + isGlobalAutoInitHookInstalled, +} from '../src/sync/global-hooks'; + +let tempDir: string; +let templateDir: string; +let origGitConfigGlobal: string | undefined; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-global-hooks-')); + templateDir = path.join(tempDir, 'templates'); + fs.mkdirSync(path.join(templateDir, 'hooks'), { recursive: true }); + + origGitConfigGlobal = process.env.GIT_CONFIG_GLOBAL; + process.env.GIT_CONFIG_GLOBAL = path.join(tempDir, '.gitconfig'); + + execFileSync('git', ['config', '--global', 'init.templateDir', templateDir], { + stdio: 'ignore', + }); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + if (origGitConfigGlobal === undefined) { + delete process.env.GIT_CONFIG_GLOBAL; + } else { + process.env.GIT_CONFIG_GLOBAL = origGitConfigGlobal; + } +}); + +function hookFile(): string { + return path.join(templateDir, 'hooks', 'post-checkout'); +} + +function isExecutable(file: string): boolean { + if (process.platform === 'win32') return true; + return (fs.statSync(file).mode & 0o111) !== 0; +} + +describe('resolveTemplateDir', () => { + it('G1: returns configured templateDir and does not overwrite git config', () => { + const result = resolveTemplateDir(); + expect(result.dir).toBe(templateDir); + expect(result.configWasSet).toBe(false); + const after = execFileSync('git', ['config', '--global', 'init.templateDir'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + expect(after).toBe(templateDir); + }); + + it('G2: defaults to ~/.git-templates and writes git config when not set', () => { + const freshConfig = path.join(tempDir, '.fresh-gitconfig'); + process.env.GIT_CONFIG_GLOBAL = freshConfig; + const origHome = process.env.HOME; + const fakeHome = path.join(tempDir, 'fakehome'); + fs.mkdirSync(fakeHome, { recursive: true }); + process.env.HOME = fakeHome; + + try { + const result = resolveTemplateDir(); + const expected = path.join(fakeHome, '.git-templates'); + expect(result.dir).toBe(expected); + expect(result.configWasSet).toBe(true); + + const written = execFileSync('git', ['config', '--global', 'init.templateDir'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + expect(written).toBe(expected); + } finally { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + } + }); +}); + +describe('installGlobalAutoInitHook', () => { + it('G3: creates post-checkout file with shebang, block, and executable bit', () => { + const result = installGlobalAutoInitHook(); + expect(result.status).toBe('installed'); + expect(result.templateDir).toBe(templateDir); + expect(fs.existsSync(hookFile())).toBe(true); + const body = fs.readFileSync(hookFile(), 'utf8'); + expect(body).toMatch(/^#!\/bin\/sh/); + expect(body).toContain('# >>> codegraph auto-init hook >>>'); + expect(isExecutable(hookFile())).toBe(true); + }); + + it('G4: hook script is guarded by command -v codegraph check', () => { + installGlobalAutoInitHook(); + expect(fs.readFileSync(hookFile(), 'utf8')).toContain( + 'if command -v codegraph >/dev/null 2>&1' + ); + }); + + it('G5: hook script checks for absence of .codegraph directory', () => { + installGlobalAutoInitHook(); + expect(fs.readFileSync(hookFile(), 'utf8')).toContain('[ ! -d .codegraph ]'); + }); + + it('G6: init branch runs codegraph init . and codegraph index', () => { + installGlobalAutoInitHook(); + const body = fs.readFileSync(hookFile(), 'utf8'); + expect(body).toContain('codegraph init . >/dev/null 2>&1'); + expect(body).toContain('codegraph index >/dev/null 2>&1'); + }); + + it('G7: init branch appends .codegraph/ to .gitignore using grep -qxF guard', () => { + installGlobalAutoInitHook(); + expect(fs.readFileSync(hookFile(), 'utf8')).toContain( + "grep -qxF '.codegraph/' .gitignore 2>/dev/null || echo '.codegraph/' >> .gitignore" + ); + }); + + it('G8: sync branch runs codegraph sync in background and suppresses output', () => { + installGlobalAutoInitHook(); + expect(fs.readFileSync(hookFile(), 'utf8')).toContain( + '( codegraph sync >/dev/null 2>&1 & ) >/dev/null 2>&1' + ); + }); + + it('G9: re-running install does not duplicate the marker block', () => { + installGlobalAutoInitHook(); + installGlobalAutoInitHook(); + const body = fs.readFileSync(hookFile(), 'utf8'); + const count = body.split('# >>> codegraph auto-init hook >>>').length - 1; + expect(count).toBe(1); + }); + + it('G10: appends block after existing user hook content', () => { + fs.writeFileSync(hookFile(), '#!/bin/sh\necho "my custom hook"\n', { mode: 0o755 }); + installGlobalAutoInitHook(); + const body = fs.readFileSync(hookFile(), 'utf8'); + expect(body).toContain('echo "my custom hook"'); + expect(body).toContain('# >>> codegraph auto-init hook >>>'); + }); + + it('G11: returns status installed and the resolved templateDir', () => { + const result = installGlobalAutoInitHook(); + expect(result.status).toBe('installed'); + expect(result.templateDir).toBe(templateDir); + }); + + it('G12: returns unchanged and does not rewrite file when block is already current', () => { + installGlobalAutoInitHook(); + const mtimeBefore = fs.statSync(hookFile()).mtimeMs; + const result = installGlobalAutoInitHook(); + expect(result.status).toBe('unchanged'); + expect(fs.statSync(hookFile()).mtimeMs).toBe(mtimeBefore); + }); + + it('G19: creates hooks directory if it does not exist', () => { + const freshTemplateDir = path.join(tempDir, 'fresh-templates'); + execFileSync('git', ['config', '--global', 'init.templateDir', freshTemplateDir], { + stdio: 'ignore', + }); + expect(fs.existsSync(path.join(freshTemplateDir, 'hooks'))).toBe(false); + installGlobalAutoInitHook(); + expect(fs.existsSync(path.join(freshTemplateDir, 'hooks', 'post-checkout'))).toBe(true); + }); + + it('G20: uses existing git config value and does not overwrite it', () => { + const before = execFileSync('git', ['config', '--global', 'init.templateDir'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + installGlobalAutoInitHook(); + const after = execFileSync('git', ['config', '--global', 'init.templateDir'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + expect(after).toBe(before); + }); +}); + +describe('removeGlobalAutoInitHook', () => { + it('G13: deletes the hook file when our block was the only content', () => { + installGlobalAutoInitHook(); + const result = removeGlobalAutoInitHook(); + expect(result.status).toBe('removed'); + expect(fs.existsSync(hookFile())).toBe(false); + }); + + it('G14: preserves user content and rewrites file without our block', () => { + fs.writeFileSync(hookFile(), '#!/bin/sh\necho "keep me"\n', { mode: 0o755 }); + installGlobalAutoInitHook(); + removeGlobalAutoInitHook(); + expect(fs.existsSync(hookFile())).toBe(true); + const body = fs.readFileSync(hookFile(), 'utf8'); + expect(body).toContain('echo "keep me"'); + expect(body).not.toContain('# >>> codegraph auto-init hook >>>'); + }); + + it('G15: returns skipped when no block is present', () => { + const result = removeGlobalAutoInitHook(); + expect(result.status).toBe('skipped'); + expect(result.reason).toBeDefined(); + }); + + it('G16: does not modify git config init.templateDir during remove', () => { + installGlobalAutoInitHook(); + const before = execFileSync('git', ['config', '--global', 'init.templateDir'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + removeGlobalAutoInitHook(); + const after = execFileSync('git', ['config', '--global', 'init.templateDir'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + expect(after).toBe(before); + }); +}); + +describe('isGlobalAutoInitHookInstalled', () => { + it('G17: returns true after install', () => { + installGlobalAutoInitHook(); + expect(isGlobalAutoInitHookInstalled()).toBe(true); + }); + + it('G18: returns false when hook file does not contain our block', () => { + expect(isGlobalAutoInitHookInstalled()).toBe(false); + }); +}); diff --git a/src/sync/global-hooks.ts b/src/sync/global-hooks.ts new file mode 100644 index 00000000..b04c9dac --- /dev/null +++ b/src/sync/global-hooks.ts @@ -0,0 +1,155 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execFileSync } from 'child_process'; +import { stripMarkerBlock, isEffectivelyEmpty, chmodExecutable } from './hook-utils'; + +const MARKER_BEGIN = '# >>> codegraph auto-init hook >>>'; +const MARKER_END = '# <<< codegraph auto-init hook <<<'; + +export interface GlobalHookResult { + templateDir: string; + status: 'installed' | 'removed' | 'unchanged' | 'skipped'; + /** True when this call wrote git config init.templateDir for the first time. */ + configWasSet: boolean; + reason?: string; +} + +/** The shell snippet injected between markers into the template post-checkout hook. */ +function autoInitBlock(): string { + return [ + MARKER_BEGIN, + '# Auto-initializes CodeGraph in newly cloned repos.', + '# Managed by codegraph; remove with: codegraph auto-init-repos --remove', + 'if command -v codegraph >/dev/null 2>&1; then', + ' if [ ! -d .codegraph ]; then', + ' codegraph init . >/dev/null 2>&1', + ' codegraph index >/dev/null 2>&1', + " grep -qxF '.codegraph/' .gitignore 2>/dev/null || echo '.codegraph/' >> .gitignore", + ' else', + ' ( codegraph sync >/dev/null 2>&1 & ) >/dev/null 2>&1', + ' fi', + 'fi', + MARKER_END, + ].join('\n'); +} + +/** + * Resolve (and optionally write) the git template directory. + * + * When writeConfig is true (default) and init.templateDir is not set, + * defaults to ~/.git-templates and writes it to git global config so + * future git clone/git init operations pick it up. + * + * Always creates /hooks/ if it does not exist. + */ +export function resolveTemplateDir(opts: { writeConfig?: boolean } = {}): { + dir: string; + configWasSet: boolean; +} { + const writeConfig = opts.writeConfig !== false; + let dir: string; + let configWasSet = false; + + try { + const raw = execFileSync('git', ['config', '--global', 'init.templateDir'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + dir = raw.replace(/^~/, os.homedir()); + } catch { + dir = path.join(os.homedir(), '.git-templates'); + if (writeConfig) { + execFileSync('git', ['config', '--global', 'init.templateDir', dir], { + stdio: 'ignore', + }); + configWasSet = true; + } + } + + fs.mkdirSync(path.join(dir, 'hooks'), { recursive: true }); + return { dir, configWasSet }; +} + +/** + * Install (or update) the CodeGraph auto-init hook in the git template directory. + * Idempotent: re-running replaces our block rather than duplicating it. + * Pre-existing user hook content is preserved. + */ +export function installGlobalAutoInitHook(): GlobalHookResult { + const { dir: templateDir, configWasSet } = resolveTemplateDir(); + const hookPath = path.join(templateDir, 'hooks', 'post-checkout'); + const block = autoInitBlock(); + + if (fs.existsSync(hookPath)) { + const existing = fs.readFileSync(hookPath, 'utf8'); + const stripped = stripMarkerBlock(existing, MARKER_BEGIN, MARKER_END).replace(/\s*$/, ''); + // Treat as empty when only a shebang remains after stripping our block + const hasUserContent = stripped.length > 0 && !isEffectivelyEmpty(stripped); + const newContent = hasUserContent + ? `${stripped}\n\n${block}\n` + : `#!/bin/sh\n${block}\n`; + + if (existing === newContent) { + return { templateDir, status: 'unchanged', configWasSet }; + } + + fs.writeFileSync(hookPath, newContent); + chmodExecutable(hookPath); + return { templateDir, status: 'installed', configWasSet }; + } + + fs.writeFileSync(hookPath, `#!/bin/sh\n${block}\n`); + chmodExecutable(hookPath); + return { templateDir, status: 'installed', configWasSet }; +} + +/** + * Remove the CodeGraph auto-init block from the template post-checkout hook. + * Strips only our marker block; deletes the file if nothing meaningful remains. + * Never modifies git config. + */ +export function removeGlobalAutoInitHook(): GlobalHookResult { + const { dir: templateDir } = resolveTemplateDir({ writeConfig: false }); + const hookPath = path.join(templateDir, 'hooks', 'post-checkout'); + + if (!fs.existsSync(hookPath)) { + return { + templateDir, + status: 'skipped', + configWasSet: false, + reason: 'hook file does not exist', + }; + } + + const original = fs.readFileSync(hookPath, 'utf8'); + if (!original.includes(MARKER_BEGIN)) { + return { + templateDir, + status: 'skipped', + configWasSet: false, + reason: 'no codegraph auto-init block found', + }; + } + + const stripped = stripMarkerBlock(original, MARKER_BEGIN, MARKER_END); + if (isEffectivelyEmpty(stripped)) { + fs.unlinkSync(hookPath); + } else { + fs.writeFileSync(hookPath, `${stripped.replace(/\s*$/, '')}\n`); + chmodExecutable(hookPath); + } + + return { templateDir, status: 'removed', configWasSet: false }; +} + +/** Returns true when the template post-checkout hook contains our auto-init block. */ +export function isGlobalAutoInitHookInstalled(): boolean { + try { + const { dir } = resolveTemplateDir({ writeConfig: false }); + const hookPath = path.join(dir, 'hooks', 'post-checkout'); + return fs.existsSync(hookPath) && fs.readFileSync(hookPath, 'utf8').includes(MARKER_BEGIN); + } catch { + return false; + } +} From ffe4d78664a5500e6fecb41aa8cbfc670aa76fcd Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 15:45:58 +0530 Subject: [PATCH 05/16] test(global-hooks): cover file-exists-no-marker skipped path --- __tests__/global-hooks.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/__tests__/global-hooks.test.ts b/__tests__/global-hooks.test.ts index d8ebc7fb..fe91656f 100644 --- a/__tests__/global-hooks.test.ts +++ b/__tests__/global-hooks.test.ts @@ -202,6 +202,15 @@ describe('removeGlobalAutoInitHook', () => { expect(result.reason).toBeDefined(); }); + it('G15b: returns skipped when hook file exists but contains no codegraph block', () => { + fs.writeFileSync(hookFile(), '#!/bin/sh\necho "user hook"\n', { mode: 0o755 }); + const result = removeGlobalAutoInitHook(); + expect(result.status).toBe('skipped'); + expect(result.reason).toBeDefined(); + // File must be preserved untouched + expect(fs.readFileSync(hookFile(), 'utf8')).toContain('echo "user hook"'); + }); + it('G16: does not modify git config init.templateDir during remove', () => { installGlobalAutoInitHook(); const before = execFileSync('git', ['config', '--global', 'init.templateDir'], { From ae13f81d11efeff944401d94283989a45b0dfe37 Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 15:48:08 +0530 Subject: [PATCH 06/16] feat(auto-init): add action handler and CLI unit tests Extracts the auto-init-repos command handler into its own module so tests can import it directly and mock global-hooks without spawning a subprocess. Clack is injected via parameter to avoid ESM dynamic import issues in the test environment. --- __tests__/auto-init-repos-cli.test.ts | 146 ++++++++++++++++++++++++++ src/bin/auto-init-repos-action.ts | 61 +++++++++++ 2 files changed, 207 insertions(+) create mode 100644 __tests__/auto-init-repos-cli.test.ts create mode 100644 src/bin/auto-init-repos-action.ts diff --git a/__tests__/auto-init-repos-cli.test.ts b/__tests__/auto-init-repos-cli.test.ts new file mode 100644 index 00000000..c099c748 --- /dev/null +++ b/__tests__/auto-init-repos-cli.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../src/sync/global-hooks', () => ({ + installGlobalAutoInitHook: vi.fn(), + removeGlobalAutoInitHook: vi.fn(), +})); + +import { autoInitReposAction } from '../src/bin/auto-init-repos-action'; +import { + installGlobalAutoInitHook, + removeGlobalAutoInitHook, +} from '../src/sync/global-hooks'; + +const mockInstall = vi.mocked(installGlobalAutoInitHook); +const mockRemove = vi.mocked(removeGlobalAutoInitHook); + +function makeClack() { + const calls: string[] = []; + return { + intro: vi.fn(), + outro: vi.fn(), + log: { + success: vi.fn((msg: string) => calls.push(msg)), + info: vi.fn((msg: string) => calls.push(msg)), + warn: vi.fn((msg: string) => calls.push(msg)), + error: vi.fn((msg: string) => calls.push(msg)), + }, + _calls: calls, + }; +} + +type MockClack = ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + process.exitCode = undefined; +}); + +describe('autoInitReposAction — install path', () => { + it('C1: calls installGlobalAutoInitHook when remove is not set', async () => { + mockInstall.mockReturnValue({ status: 'installed', templateDir: '/tmp/t', configWasSet: true }); + const clack = makeClack(); + await autoInitReposAction({}, clack as unknown as MockClack); + expect(mockInstall).toHaveBeenCalledOnce(); + expect(mockRemove).not.toHaveBeenCalled(); + }); + + it('C3: logs the resolved templateDir on successful install', async () => { + mockInstall.mockReturnValue({ status: 'installed', templateDir: '/tmp/t', configWasSet: true }); + const clack = makeClack(); + await autoInitReposAction({}, clack as unknown as MockClack); + expect(clack._calls.join(' ')).toContain('/tmp/t'); + }); + + it('C4: logs that init.templateDir was set when configWasSet is true', async () => { + mockInstall.mockReturnValue({ status: 'installed', templateDir: '/tmp/t', configWasSet: true }); + const clack = makeClack(); + await autoInitReposAction({}, clack as unknown as MockClack); + expect(clack._calls.join(' ')).toMatch(/init\.templateDir set/i); + }); + + it('C5: logs that init.templateDir was already configured when configWasSet is false', async () => { + mockInstall.mockReturnValue({ status: 'installed', templateDir: '/tmp/t', configWasSet: false }); + const clack = makeClack(); + await autoInitReposAction({}, clack as unknown as MockClack); + expect(clack._calls.join(' ')).toMatch(/already (set|configured)/i); + }); + + it('C6: logs Already installed with templateDir when status is unchanged', async () => { + mockInstall.mockReturnValue({ status: 'unchanged', templateDir: '/tmp/t', configWasSet: false }); + const clack = makeClack(); + await autoInitReposAction({}, clack as unknown as MockClack); + const allOutput = clack._calls.join(' '); + expect(allOutput).toMatch(/already installed/i); + expect(allOutput).toContain('/tmp/t'); + }); + + it('C7: does not set exit code to 1 when status is unchanged', async () => { + mockInstall.mockReturnValue({ status: 'unchanged', templateDir: '/tmp/t', configWasSet: false }); + const clack = makeClack(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + await autoInitReposAction({}, clack as unknown as MockClack); + expect(exitSpy).not.toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + }); +}); + +describe('autoInitReposAction — remove path', () => { + it('C2: calls removeGlobalAutoInitHook when remove is true', async () => { + mockRemove.mockReturnValue({ status: 'removed', templateDir: '/tmp/t', configWasSet: false }); + const clack = makeClack(); + await autoInitReposAction({ remove: true }, clack as unknown as MockClack); + expect(mockRemove).toHaveBeenCalledOnce(); + expect(mockInstall).not.toHaveBeenCalled(); + }); + + it('C8: logs templateDir and git config note on successful remove', async () => { + mockRemove.mockReturnValue({ status: 'removed', templateDir: '/tmp/t', configWasSet: false }); + const clack = makeClack(); + await autoInitReposAction({ remove: true }, clack as unknown as MockClack); + const allOutput = clack._calls.join(' '); + expect(allOutput).toContain('/tmp/t'); + expect(allOutput).toMatch(/init\.templateDir was not modified/i); + }); + + it('C9: logs hook-not-found message with templateDir when status is skipped', async () => { + mockRemove.mockReturnValue({ status: 'skipped', templateDir: '/tmp/t', configWasSet: false, reason: 'no block found' }); + const clack = makeClack(); + await autoInitReposAction({ remove: true }, clack as unknown as MockClack); + const allOutput = clack._calls.join(' '); + expect(allOutput).toMatch(/no codegraph auto-init hook found/i); + expect(allOutput).toContain('/tmp/t'); + }); + + it('C10: does not set exit code to 1 when status is skipped', async () => { + mockRemove.mockReturnValue({ status: 'skipped', templateDir: '/tmp/t', configWasSet: false }); + const clack = makeClack(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + await autoInitReposAction({ remove: true }, clack as unknown as MockClack); + expect(exitSpy).not.toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + }); +}); + +describe('autoInitReposAction — error handling', () => { + it('C11: calls process.exit(1) when installGlobalAutoInitHook throws', async () => { + mockInstall.mockImplementation(() => { throw new Error('write failed'); }); + const clack = makeClack(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + await expect(autoInitReposAction({}, clack as unknown as MockClack)).rejects.toThrow('exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + }); + + it('C12: logs error message via clack.log.error when installGlobalAutoInitHook throws', async () => { + mockInstall.mockImplementation(() => { throw new Error('write failed'); }); + const clack = makeClack(); + vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + await expect(autoInitReposAction({}, clack as unknown as MockClack)).rejects.toThrow('exit'); + expect(clack.log.error).toHaveBeenCalledWith(expect.stringContaining('write failed')); + vi.restoreAllMocks(); + }); +}); diff --git a/src/bin/auto-init-repos-action.ts b/src/bin/auto-init-repos-action.ts new file mode 100644 index 00000000..2c577d9c --- /dev/null +++ b/src/bin/auto-init-repos-action.ts @@ -0,0 +1,61 @@ +import { + installGlobalAutoInitHook, + removeGlobalAutoInitHook, +} from '../sync/global-hooks'; + +// Clack is injected so the handler is testable without ESM dynamic import +// complexity. codegraph.ts loads clack once and passes it through. +type ClackModule = typeof import('@clack/prompts'); + +export async function autoInitReposAction( + options: { remove?: boolean }, + clack: ClackModule, +): Promise { + clack.intro('CodeGraph auto-init'); + + try { + if (options.remove) { + const result = removeGlobalAutoInitHook(); + + if (result.status === 'skipped') { + clack.log.info( + `No codegraph auto-init hook found in ${result.templateDir}` + ); + } else { + clack.log.success( + `Removed auto-init hook from ${result.templateDir}/hooks/post-checkout` + ); + clack.log.info('Note: git config init.templateDir was not modified.'); + } + } else { + const result = installGlobalAutoInitHook(); + + if (result.status === 'unchanged') { + clack.log.success(`Already installed in ${result.templateDir}`); + } else { + clack.log.success(`Template dir: ${result.templateDir}`); + + if (result.configWasSet) { + clack.log.success('git config init.templateDir set'); + } else { + clack.log.info( + `git config init.templateDir already configured — using ${result.templateDir}` + ); + } + + clack.log.success('post-checkout hook installed'); + clack.outro( + 'Every new git clone will auto-initialize and index CodeGraph.\n' + + ' Run `codegraph auto-init-repos --remove` to undo.' + ); + return; + } + } + } catch (err) { + clack.log.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + return; + } + + clack.outro(''); +} From 2d33877f34063486d3f169c68f8cbf11c127253d Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 15:51:05 +0530 Subject: [PATCH 07/16] fix(auto-init): remove dead return, use failure-safe spy restore --- __tests__/auto-init-repos-cli.test.ts | 11 +++++++---- src/bin/auto-init-repos-action.ts | 1 - 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/__tests__/auto-init-repos-cli.test.ts b/__tests__/auto-init-repos-cli.test.ts index c099c748..e62d7284 100644 --- a/__tests__/auto-init-repos-cli.test.ts +++ b/__tests__/auto-init-repos-cli.test.ts @@ -138,9 +138,12 @@ describe('autoInitReposAction — error handling', () => { it('C12: logs error message via clack.log.error when installGlobalAutoInitHook throws', async () => { mockInstall.mockImplementation(() => { throw new Error('write failed'); }); const clack = makeClack(); - vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); - await expect(autoInitReposAction({}, clack as unknown as MockClack)).rejects.toThrow('exit'); - expect(clack.log.error).toHaveBeenCalledWith(expect.stringContaining('write failed')); - vi.restoreAllMocks(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + try { + await expect(autoInitReposAction({}, clack as unknown as MockClack)).rejects.toThrow('exit'); + expect(clack.log.error).toHaveBeenCalledWith(expect.stringContaining('write failed')); + } finally { + exitSpy.mockRestore(); + } }); }); diff --git a/src/bin/auto-init-repos-action.ts b/src/bin/auto-init-repos-action.ts index 2c577d9c..2ccc9835 100644 --- a/src/bin/auto-init-repos-action.ts +++ b/src/bin/auto-init-repos-action.ts @@ -54,7 +54,6 @@ export async function autoInitReposAction( } catch (err) { clack.log.error(err instanceof Error ? err.message : String(err)); process.exit(1); - return; } clack.outro(''); From 3300efb9f03c9e56168e969b933493d2e0860122 Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 15:54:42 +0530 Subject: [PATCH 08/16] feat(cli): register auto-init-repos command --- src/bin/codegraph.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index dac8ce1e..4e2eec40 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1398,6 +1398,19 @@ program } }); +/** + * codegraph auto-init-repos [--remove] + */ +program + .command('auto-init-repos') + .description('Install (or remove) a global git template hook that auto-initializes CodeGraph in every new git clone') + .option('--remove', 'Remove the auto-init hook from the git template directory') + .action(async (opts: { remove?: boolean }) => { + const clack = await importESM('@clack/prompts'); + const { autoInitReposAction } = await import('./auto-init-repos-action'); + await autoInitReposAction(opts, clack); + }); + // Parse and run program.parse(); From cc121e60c87d0e47100c925be3e8f7e317175947 Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 15:59:01 +0530 Subject: [PATCH 09/16] fix(global-hooks): normalize equality check, gate mkdirSync, add \$3 guard Three fixes: normalize trailing whitespace before the unchanged equality check so re-installs are not spuriously written; gate mkdirSync in resolveTemplateDir to only run when writeConfig is true, preventing ~/.git-templates/hooks/ from being silently created by remove/status calls; add a \$3=1 guard to the hook script so it skips file-level git checkout calls and only fires on actual branch switches. --- __tests__/global-hooks.test.ts | 5 +++++ src/sync/global-hooks.ts | 8 ++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/__tests__/global-hooks.test.ts b/__tests__/global-hooks.test.ts index fe91656f..0bd852cc 100644 --- a/__tests__/global-hooks.test.ts +++ b/__tests__/global-hooks.test.ts @@ -126,6 +126,11 @@ describe('installGlobalAutoInitHook', () => { ); }); + it('G8b: hook script skips file-level checkouts via $3 guard', () => { + installGlobalAutoInitHook(); + expect(fs.readFileSync(hookFile(), 'utf8')).toContain('[ "$3" = "1" ] || exit 0'); + }); + it('G9: re-running install does not duplicate the marker block', () => { installGlobalAutoInitHook(); installGlobalAutoInitHook(); diff --git a/src/sync/global-hooks.ts b/src/sync/global-hooks.ts index b04c9dac..77975904 100644 --- a/src/sync/global-hooks.ts +++ b/src/sync/global-hooks.ts @@ -21,6 +21,8 @@ function autoInitBlock(): string { MARKER_BEGIN, '# Auto-initializes CodeGraph in newly cloned repos.', '# Managed by codegraph; remove with: codegraph auto-init-repos --remove', + '# $3 is 1 for branch checkout, 0 for file checkout — skip file-level checkouts', + '[ "$3" = "1" ] || exit 0', 'if command -v codegraph >/dev/null 2>&1; then', ' if [ ! -d .codegraph ]; then', ' codegraph init . >/dev/null 2>&1', @@ -67,7 +69,9 @@ export function resolveTemplateDir(opts: { writeConfig?: boolean } = {}): { } } - fs.mkdirSync(path.join(dir, 'hooks'), { recursive: true }); + if (writeConfig) { + fs.mkdirSync(path.join(dir, 'hooks'), { recursive: true }); + } return { dir, configWasSet }; } @@ -90,7 +94,7 @@ export function installGlobalAutoInitHook(): GlobalHookResult { ? `${stripped}\n\n${block}\n` : `#!/bin/sh\n${block}\n`; - if (existing === newContent) { + if (existing.replace(/\s*$/, '') === newContent.replace(/\s*$/, '')) { return { templateDir, status: 'unchanged', configWasSet }; } From 3ffadbbcd2f24571f35442be1b229bc8e09633a6 Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 15:59:39 +0530 Subject: [PATCH 10/16] docs(global-hooks): fix stale JSDoc for resolveTemplateDir --- src/sync/global-hooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sync/global-hooks.ts b/src/sync/global-hooks.ts index 77975904..feeb139d 100644 --- a/src/sync/global-hooks.ts +++ b/src/sync/global-hooks.ts @@ -43,7 +43,7 @@ function autoInitBlock(): string { * defaults to ~/.git-templates and writes it to git global config so * future git clone/git init operations pick it up. * - * Always creates /hooks/ if it does not exist. + * Creates /hooks/ when writeConfig is true (install path only). */ export function resolveTemplateDir(opts: { writeConfig?: boolean } = {}): { dir: string; From 7fcbb830f64caf743bbb5d7d045ac89919d22e54 Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 16:01:14 +0530 Subject: [PATCH 11/16] chore: pin Node.js 22 via .nvmrc --- .nvmrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..8fdd954d --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 \ No newline at end of file From 15df7e8fd6a9dfb4b05a31ca80dd887a3eef0163 Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 16:01:17 +0530 Subject: [PATCH 12/16] chore(gitignore): ignore .cursor/ directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 435882b3..4b44e15f 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ test-languages/ nul release/ + +# Ignore cursor rules +.cursor/ From e51886e4c0dafa9174a0763784515ad28e6a7aea Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 16:01:21 +0530 Subject: [PATCH 13/16] chore: update package-lock.json --- package-lock.json | 266 ++++++++++++++++++++++++++-------------------- 1 file changed, 152 insertions(+), 114 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49342496..65c8a8dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -461,9 +461,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -475,9 +475,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -489,9 +489,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], @@ -503,9 +503,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], @@ -517,9 +517,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -531,9 +531,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -545,13 +545,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -559,13 +562,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -573,13 +579,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -587,13 +596,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -601,13 +613,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -615,13 +630,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -629,13 +647,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -643,13 +664,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -657,13 +681,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -671,13 +698,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -685,13 +715,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -699,13 +732,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -713,13 +749,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -727,9 +766,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", "cpu": [ "x64" ], @@ -741,9 +780,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -755,9 +794,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], @@ -769,9 +808,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -783,9 +822,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -797,9 +836,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], @@ -1186,9 +1225,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -1229,9 +1268,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -1241,9 +1280,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -1261,7 +1300,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1270,9 +1309,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", "dependencies": { @@ -1286,31 +1325,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, @@ -1431,7 +1470,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", From 17be2cfd401b3cc7cd97527f05162ccf7aad97e5 Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 16:01:24 +0530 Subject: [PATCH 14/16] docs(specs): add auto-init-repos design spec --- .../2026-05-22-auto-init-repos-design.md | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-22-auto-init-repos-design.md diff --git a/docs/superpowers/specs/2026-05-22-auto-init-repos-design.md b/docs/superpowers/specs/2026-05-22-auto-init-repos-design.md new file mode 100644 index 00000000..96acb02e --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-auto-init-repos-design.md @@ -0,0 +1,386 @@ +# Design Spec: `codegraph auto-init-repos` + +**Date:** 2026-05-22 +**Status:** Approved — ready for implementation +**Scope:** POSIX only (macOS, Linux, Git for Windows / MINGW). Native Windows cmd/PowerShell out of scope. + +--- + +## 1. Problem Statement + +`codegraph init -i` must be run manually in every new repository. Users who clone many repos must remember to run it each time. The goal is a single command that installs a global git template hook so every subsequent `git clone` or `git init` automatically bootstraps and indexes CodeGraph without user intervention. + +--- + +## 2. Solution Overview + +`codegraph auto-init-repos` installs a `post-checkout` hook into the user's git template directory (`init.templateDir`). Git copies the template directory into every new repo's `.git/` at clone/init time, so the hook fires automatically on first checkout — running `codegraph init` and `codegraph index` if the repo is not yet initialized, or `codegraph sync` if it already is. + +`codegraph auto-init-repos --remove` surgically removes only the CodeGraph block from the hook file, preserving any other user content. + +--- + +## 3. File Inventory + +### New files + +| File | Purpose | +|---|---| +| `src/sync/hook-utils.ts` | Shared primitives: `stripMarkerBlock`, `isEffectivelyEmpty`, `chmodExecutable` | +| `src/sync/global-hooks.ts` | Global template hook install/remove/status logic | +| `src/bin/auto-init-repos-action.ts` | Extracted CLI action handler (enables unit testing without subprocess) | +| `__tests__/hook-utils.test.ts` | Unit tests for shared primitives (U1–U11) | +| `__tests__/global-hooks.test.ts` | Integration tests for global hook management (G1–G20) | +| `__tests__/auto-init-repos-cli.test.ts` | Unit tests for CLI action handler (C1–C12) | + +### Modified files + +| File | Change | +|---|---| +| `src/sync/git-hooks.ts` | Import `stripMarkerBlock`, `isEffectivelyEmpty`, `chmodExecutable` from `hook-utils.ts`; remove local definitions; all existing behavior unchanged | +| `src/bin/codegraph.ts` | Add `auto-init-repos` command | +| `__tests__/git-hooks.test.ts` | Update import paths if needed; all 7 existing behavior tests pass unchanged | + +--- + +## 4. `src/sync/hook-utils.ts` — Shared Primitives + +### 4.1 `stripMarkerBlock(content, begin, end): string` + +Removes the block delimited by `begin` and `end` (inclusive) from `content`. + +**Requirements:** + +- **REQ-HU-01** Returns content with all lines from `begin` to `end` (inclusive) removed. +- **REQ-HU-02** Content before `begin` and after `end` is preserved verbatim. +- **REQ-HU-03** When `begin` and `end` are not present, returns content unchanged. +- **REQ-HU-04** When `begin` is present but `end` is absent, strips from `begin` to end-of-string (existing behavior preserved for compatibility). +- **REQ-HU-05** When `end` is present but `begin` is absent, returns content unchanged. +- **REQ-HU-06** Calling twice on the same content produces the same result as calling once (idempotent). + +### 4.2 `isEffectivelyEmpty(content): boolean` + +Returns `true` iff every line in `content` is blank or a shebang (`#!` prefix). Used post-strip to decide whether a hook file should be deleted entirely. + +**Requirements:** + +- **REQ-HU-07** Returns `true` when content is empty string. +- **REQ-HU-08** Returns `true` when content contains only a shebang line (`#!/bin/sh`) and blank lines. +- **REQ-HU-09** Returns `false` when content contains any non-blank, non-shebang line. +- **REQ-HU-10** Returns `false` when content contains a begin marker line (`# >>> codegraph...`). +- **REQ-HU-11** Returns `false` when content contains an end marker line (`# <<< codegraph...`). + +**Contract note:** Caller must invoke `stripMarkerBlock` before calling `isEffectivelyEmpty`. This function is the post-strip safety check — REQ-HU-10 and REQ-HU-11 guard against a failed or skipped strip causing incorrect file deletion. + +### 4.3 `chmodExecutable(file): void` + +Sets the executable bit (`0o755`) on `file`. + +**Requirements:** + +- **REQ-HU-12** File is executable after the call on POSIX systems. +- **REQ-HU-13** Does not throw when `file` does not exist or chmod is unsupported (e.g., Windows). + +--- + +## 5. `src/sync/git-hooks.ts` — Refactor Only + +No behavior changes. The three functions (`stripMarkerBlock`, `isEffectivelyEmpty`, `chmodExecutable`) are removed from this file and imported from `hook-utils.ts`. All existing exports, constants, and logic remain identical. + +**Requirements:** + +- **REQ-GH-01** All 7 existing `__tests__/git-hooks.test.ts` tests pass after the refactor. +- **REQ-GH-02** Public API of `git-hooks.ts` is unchanged (`installGitSyncHook`, `removeGitSyncHook`, `isSyncHookInstalled`, `isGitRepo`, `DEFAULT_SYNC_HOOKS`). + +--- + +## 6. `src/sync/global-hooks.ts` — Global Template Hook + +### 6.1 Markers + +``` +MARKER_BEGIN = '# >>> codegraph auto-init hook >>>' +MARKER_END = '# <<< codegraph auto-init hook <<<' +``` + +These are distinct from the per-repo sync hook markers (`# >>> codegraph sync hook >>>`) so both can coexist in the same `post-checkout` file without interference. + +### 6.2 Hook Script Block + +The following block is injected between the markers: + +```sh +# >>> codegraph auto-init hook >>> +# Auto-initializes CodeGraph in newly cloned repos. +# Managed by codegraph; remove with: codegraph auto-init-repos --remove +if command -v codegraph >/dev/null 2>&1; then + if [ ! -d .codegraph ]; then + codegraph init . >/dev/null 2>&1 + codegraph index >/dev/null 2>&1 + grep -qxF '.codegraph/' .gitignore 2>/dev/null || echo '.codegraph/' >> .gitignore + else + ( codegraph sync >/dev/null 2>&1 & ) >/dev/null 2>&1 + fi +fi +# <<< codegraph auto-init hook <<< +``` + +**Script requirements:** + +- **REQ-GL-01** The block is guarded by `command -v codegraph >/dev/null 2>&1` — entire block is a no-op when `codegraph` is not on PATH. +- **REQ-GL-02** When `.codegraph/` does not exist: runs `codegraph init .` then `codegraph index`, both suppressed (`>/dev/null 2>&1`). +- **REQ-GL-03** When `.codegraph/` does not exist: appends `.codegraph/` to `.gitignore` only if not already present, using `grep -qxF '.codegraph/' .gitignore`. +- **REQ-GL-04** When `.codegraph/` already exists: runs `codegraph sync` in the background (`( codegraph sync >/dev/null 2>&1 & ) >/dev/null 2>&1`) and never blocks git. +- **REQ-GL-05** All output is suppressed; the hook is transparent to the user during git operations. + +### 6.3 `resolveTemplateDir(): string` + +**Requirements:** + +- **REQ-GL-06** Reads `git config --global init.templateDir`. If set, returns `{ dir: , configWasSet: false }`. +- **REQ-GL-07** If not set, returns `{ dir: '~/.git-templates' (home expanded), configWasSet: true }`. +- **REQ-GL-08** If not set, writes the default path to `git config --global init.templateDir` so future `git clone`/`git init` operations pick it up. +- **REQ-GL-09** If already set, does not modify `git config --global init.templateDir`. +- **REQ-GL-10** Creates `/hooks/` directory (recursive) if it does not exist. + +### 6.4 `installGlobalAutoInitHook(): GlobalHookResult` + +**Requirements:** + +- **REQ-GL-11** Calls `resolveTemplateDir()` to determine the target directory. +- **REQ-GL-12** When `/hooks/post-checkout` does not exist: creates it with `#!/bin/sh\n` + marker block, sets executable bit. +- **REQ-GL-13** When file exists with no prior marker: appends marker block after existing content, sets executable bit. +- **REQ-GL-14** When file exists with prior marker: replaces marker block in-place. File is not duplicated. +- **REQ-GL-15** Idempotent: calling twice produces a file with exactly one copy of the marker block. +- **REQ-GL-16** Returns `{ status: 'installed', templateDir }` on first install or update. +- **REQ-GL-17** Returns `{ status: 'unchanged', templateDir }` when hook was already installed and block is byte-identical (no file write performed). + +### 6.5 `removeGlobalAutoInitHook(): GlobalHookResult` + +**Requirements:** + +- **REQ-GL-18** Reads `resolveTemplateDir()` to locate the file. +- **REQ-GL-19** Strips only the CodeGraph auto-init marker block using `stripMarkerBlock`. +- **REQ-GL-20** If the file is effectively empty after stripping (REQ-HU-07–REQ-HU-08), deletes the file. +- **REQ-GL-21** If the file has remaining user content after stripping, rewrites the file with trailing whitespace trimmed and executable bit preserved. +- **REQ-GL-22** Never modifies `git config --global init.templateDir` during remove. +- **REQ-GL-23** Returns `{ status: 'removed', templateDir }` when block was found and removed. +- **REQ-GL-24** Returns `{ status: 'skipped', templateDir, reason }` when no marker block was found. + +### 6.6 `isGlobalAutoInitHookInstalled(): boolean` + +**Requirements:** + +- **REQ-GL-25** Returns `true` when `/hooks/post-checkout` exists and contains `MARKER_BEGIN`. +- **REQ-GL-26** Returns `false` when file does not exist or does not contain `MARKER_BEGIN`. + +### 6.7 TypeScript interface + +```typescript +export interface GlobalHookResult { + templateDir: string; + status: 'installed' | 'removed' | 'unchanged' | 'skipped'; + /** True when this call wrote git config init.templateDir for the first time. */ + configWasSet: boolean; + reason?: string; +} + +export function resolveTemplateDir(): { dir: string; configWasSet: boolean } +export function installGlobalAutoInitHook(): GlobalHookResult +export function removeGlobalAutoInitHook(): GlobalHookResult +export function isGlobalAutoInitHookInstalled(): boolean +``` + +--- + +## 7. CLI: `codegraph auto-init-repos` + +**Requirements:** + +- **REQ-CLI-00** The `auto-init-repos` command action is extracted into `src/bin/auto-init-repos-action.ts` as an exported `autoInitReposAction(options: { remove?: boolean }): Promise`. `codegraph.ts` registers it via `.action(autoInitReposAction)`. This makes the handler importable and mockable in tests without spawning a subprocess. +- **REQ-CLI-01** `codegraph auto-init-repos` (no flags) installs the global hook by calling `installGlobalAutoInitHook()`. +- **REQ-CLI-02** `codegraph auto-init-repos --remove` removes the global hook by calling `removeGlobalAutoInitHook()`. +- **REQ-CLI-03** Uses `@clack/prompts` (`clack.intro`, `clack.log.success`, `clack.log.warn`, `clack.log.info`, `clack.outro`) consistent with other commands. +- **REQ-CLI-04** On successful install, logs the resolved template dir and whether it was created or pre-existing. +- **REQ-CLI-05** On successful install, logs whether `git config init.templateDir` was set or was already configured. +- **REQ-CLI-06** On idempotent re-install (`status: 'unchanged'`), logs "Already installed in ``" and exits 0. +- **REQ-CLI-07** On successful remove, logs the template dir and a note that `git config init.templateDir` was not modified. +- **REQ-CLI-08** On remove when not installed (`status: 'skipped'`), logs "No codegraph auto-init hook found in ``" and exits 0. +- **REQ-CLI-09** Exits with code `0` on success (install, remove, idempotent no-op). +- **REQ-CLI-10** Exits with code `1` on fatal error (cannot write template dir, git not on PATH). Logs error message via `clack.log.error`. + +### 7.1 Install output (first install) + +``` +◆ CodeGraph auto-init +✔ Template dir: ~/.git-templates (created) ← or: (already existed) +✔ git config init.templateDir set ← or: already set — using +✔ post-checkout hook installed +◇ Every new git clone will auto-initialize and index CodeGraph. + Run `codegraph auto-init-repos --remove` to undo. +``` + +### 7.2 Install output (idempotent) + +``` +✔ Already installed in +``` + +### 7.3 Remove output + +``` +✔ Removed auto-init hook from /hooks/post-checkout + Note: git config init.templateDir was not modified. +``` + +### 7.4 Remove when not installed + +``` +ℹ No codegraph auto-init hook found in +``` + +--- + +## 8. Test Contract + +Every requirement above maps to at least one test. Test IDs in parentheses reference the requirement they cover. + +### 8.1 `__tests__/hook-utils.test.ts` + +| ID | Description | REQ | +|---|---|---| +| U1 | `stripMarkerBlock` removes block; surrounding content preserved | REQ-HU-01, REQ-HU-02 | +| U2 | `stripMarkerBlock` — no markers in content → returns unchanged | REQ-HU-03 | +| U3 | `stripMarkerBlock` — custom begin/end markers → only those stripped | REQ-HU-01 | +| U3b | `stripMarkerBlock` — begin present, end absent → strips begin to EOF | REQ-HU-04 | +| U3c | `stripMarkerBlock` — end present, begin absent → returns unchanged | REQ-HU-05 | +| U3d | `stripMarkerBlock` — two calls produce same result as one | REQ-HU-06 | +| U4 | `isEffectivelyEmpty` — shebang + blank lines only → `true` | REQ-HU-08 | +| U5 | `isEffectivelyEmpty` — empty string → `true` | REQ-HU-07 | +| U6 | `isEffectivelyEmpty` — real user content → `false` | REQ-HU-09 | +| U7 | `isEffectivelyEmpty` — begin marker line present → `false` | REQ-HU-10 | +| U8 | `isEffectivelyEmpty` — end marker line present → `false` | REQ-HU-11 | +| U9 | `isEffectivelyEmpty` — shebang + marker lines → `false` | REQ-HU-10, REQ-HU-11 | +| U10 | `chmodExecutable` — sets 0o755 on POSIX | REQ-HU-12 | +| U11 | `chmodExecutable` — no throw when file does not exist | REQ-HU-13 | + +### 8.2 `__tests__/git-hooks.test.ts` (existing — behavior unchanged) + +All 7 existing tests pass after refactor. Covers REQ-GH-01, REQ-GH-02. + +### 8.3 `__tests__/global-hooks.test.ts` + +| ID | Description | REQ | +|---|---|---| +| G1 | `resolveTemplateDir` — `init.templateDir` set → returns that path, config unchanged | REQ-GL-06, REQ-GL-09 | +| G2 | `resolveTemplateDir` — not set → returns `~/.git-templates`, sets git config | REQ-GL-07, REQ-GL-08 | +| G3 | `installGlobalAutoInitHook` — fresh install, no prior file → creates file, shebang + block, executable | REQ-GL-11, REQ-GL-12 | +| G4 | Hook file contains `command -v codegraph` guard | REQ-GL-01 | +| G5 | Hook file contains `[ ! -d .codegraph ]` branch | REQ-GL-02 | +| G6 | Hook file init branch contains `codegraph init .` and `codegraph index` | REQ-GL-02 | +| G7 | Hook file init branch contains `grep -qxF '.codegraph/'` idempotent gitignore append | REQ-GL-03 | +| G8 | Hook file sync branch contains background `codegraph sync` | REQ-GL-04 | +| G9 | `installGlobalAutoInitHook` — idempotent: re-run produces exactly one marker block | REQ-GL-15 | +| G10 | `installGlobalAutoInitHook` — preserves pre-existing user hook content | REQ-GL-13 | +| G11 | `installGlobalAutoInitHook` — returns `status: 'installed'` + correct `templateDir` | REQ-GL-16 | +| G12 | `installGlobalAutoInitHook` — already installed, byte-identical → returns `status: 'unchanged'`, no file write | REQ-GL-17 | +| G13 | `removeGlobalAutoInitHook` — strips block, deletes file when only ours | REQ-GL-19, REQ-GL-20 | +| G14 | `removeGlobalAutoInitHook` — keeps user content when hook is shared | REQ-GL-19, REQ-GL-21 | +| G15 | `removeGlobalAutoInitHook` — not installed → returns `status: 'skipped'` | REQ-GL-24 | +| G16 | `removeGlobalAutoInitHook` — never modifies `git config init.templateDir` | REQ-GL-22 | +| G17 | `isGlobalAutoInitHookInstalled` → `true` when installed | REQ-GL-25 | +| G18 | `isGlobalAutoInitHookInstalled` → `false` when not installed | REQ-GL-26 | +| G19 | `installGlobalAutoInitHook` — creates `/hooks/` if dir doesn't exist | REQ-GL-10 | +| G20 | `installGlobalAutoInitHook` — existing `init.templateDir` used, config not changed | REQ-GL-09 | + +### 8.4 `__tests__/auto-init-repos-cli.test.ts` + +**Test approach:** Import `autoInitReposAction` directly. Mock `../src/sync/global-hooks` via `vi.mock`. Spy on `@clack/prompts` log methods. Assert `process.exitCode` after handler resolves. + +| ID | Setup | Action | Asserts | REQ | +|---|---|---|---|---| +| C1 | `installGlobalAutoInitHook` mocked → `{ status: 'installed', templateDir: '/tmp/t' }` | `autoInitReposAction({})` | `installGlobalAutoInitHook` called exactly once | REQ-CLI-01 | +| C2 | `removeGlobalAutoInitHook` mocked → `{ status: 'removed', templateDir: '/tmp/t' }` | `autoInitReposAction({ remove: true })` | `removeGlobalAutoInitHook` called exactly once; `installGlobalAutoInitHook` not called | REQ-CLI-02 | +| C3 | install returns `{ status: 'installed', templateDir: '/tmp/t' }` | `autoInitReposAction({})` | A `clack.log.success` call contains `/tmp/t` | REQ-CLI-04 | +| C4 | install returns `{ status: 'installed', configWasSet: true, templateDir: '/tmp/t' }` | `autoInitReposAction({})` | A `clack.log.success` call contains `init.templateDir set` | REQ-CLI-05 | +| C5 | install returns `{ status: 'installed', configWasSet: false, templateDir: '/tmp/t' }` | `autoInitReposAction({})` | A `clack.log` call contains `already set` or `already configured` | REQ-CLI-05 | +| C6 | install returns `{ status: 'unchanged', templateDir: '/tmp/t' }` | `autoInitReposAction({})` | A `clack.log` call contains `Already installed` and `/tmp/t` | REQ-CLI-06 | +| C7 | install returns `{ status: 'unchanged', templateDir: '/tmp/t' }` | `autoInitReposAction({})` | `process.exitCode` is `0` (or handler resolves without calling `process.exit(1)`) | REQ-CLI-09 | +| C8 | remove returns `{ status: 'removed', templateDir: '/tmp/t' }` | `autoInitReposAction({ remove: true })` | Output contains `/tmp/t` and `init.templateDir was not modified` | REQ-CLI-07 | +| C9 | remove returns `{ status: 'skipped', templateDir: '/tmp/t' }` | `autoInitReposAction({ remove: true })` | Output contains `No codegraph auto-init hook found` and `/tmp/t` | REQ-CLI-08 | +| C10 | remove returns `{ status: 'skipped', templateDir: '/tmp/t' }` | `autoInitReposAction({ remove: true })` | Handler resolves without calling `process.exit(1)` | REQ-CLI-09 | +| C11 | `installGlobalAutoInitHook` throws `Error('write failed')` | `autoInitReposAction({})` | `process.exit(1)` called (or `process.exitCode` set to `1`) | REQ-CLI-10 | +| C12 | `installGlobalAutoInitHook` throws `Error('write failed')` | `autoInitReposAction({})` | `clack.log.error` called with message containing `write failed` | REQ-CLI-10 | + +**Note:** `GlobalHookResult` is extended with `configWasSet: boolean` to support C4/C5. Add this field to the interface in `src/sync/global-hooks.ts` and to REQ-GL-06/GL-07 — `resolveTemplateDir` returns `{ dir: string; configWasSet: boolean }` so the caller can surface the right message. + +**Total: 53 tests** (14 hook-utils + 7 existing git-hooks + 20 global-hooks + 12 CLI) + +--- + +## 9. Out of Scope + +- Native Windows cmd/PowerShell hook support (`.bat` / PowerShell companion scripts) +- `--status` flag (check if installed without install/remove) +- Undoing `git config --global init.templateDir` on `--remove` +- Retroactive initialization of repos cloned before `auto-init-repos` was run +- Per-repo opt-out mechanism + +--- + +## 10. Requirement → Test Traceability Matrix + +| Requirement | Test(s) | +|---|---| +| REQ-HU-01 | U1, U3 | +| REQ-HU-02 | U1 | +| REQ-HU-03 | U2 | +| REQ-HU-04 | U3b | +| REQ-HU-05 | U3c | +| REQ-HU-06 | U3d | +| REQ-HU-07 | U5 | +| REQ-HU-08 | U4 | +| REQ-HU-09 | U6 | +| REQ-HU-10 | U7, U9 | +| REQ-HU-11 | U8, U9 | +| REQ-HU-12 | U10 | +| REQ-HU-13 | U11 | +| REQ-GH-01 | All 7 existing git-hooks tests | +| REQ-GH-02 | All 7 existing git-hooks tests | +| REQ-GL-01 | G4 | +| REQ-GL-02 | G5, G6 | +| REQ-GL-03 | G7 | +| REQ-GL-04 | G8 | +| REQ-GL-05 | G4, G8 (all output suppressed) | +| REQ-GL-06 | G1 | +| REQ-GL-07 | G2 | +| REQ-GL-08 | G2 | +| REQ-GL-09 | G1, G20 | +| REQ-GL-10 | G19 | +| REQ-GL-11 | G3 | +| REQ-GL-12 | G3 | +| REQ-GL-13 | G10 | +| REQ-GL-14 | G9 | +| REQ-GL-15 | G9 | +| REQ-GL-16 | G11 | +| REQ-GL-17 | G12 | +| REQ-GL-18 | G13, G14, G15 | +| REQ-GL-19 | G13, G14 | +| REQ-GL-20 | G13 | +| REQ-GL-21 | G14 | +| REQ-GL-22 | G16 | +| REQ-GL-23 | G13, G14 | +| REQ-GL-24 | G15 | +| REQ-GL-25 | G17 | +| REQ-GL-26 | G18 | +| REQ-CLI-00 | C1, C2 (handler is importable; mocks work) | +| REQ-CLI-01 | C1 | +| REQ-CLI-02 | C2 | +| REQ-CLI-03 | C1–C12 (clack used throughout) | +| REQ-CLI-04 | C3 | +| REQ-CLI-05 | C4, C5 | +| REQ-CLI-06 | C6 | +| REQ-CLI-07 | C8 | +| REQ-CLI-08 | C9 | +| REQ-CLI-09 | C7, C10 | +| REQ-CLI-10 | C11, C12 | From 68766db6eb115bc9e920abdea16ed8a1b462cf90 Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 16:01:28 +0530 Subject: [PATCH 15/16] docs(plans): add auto-init-repos implementation plan --- .../plans/2026-05-22-auto-init-repos.md | 1163 +++++++++++++++++ 1 file changed, 1163 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-22-auto-init-repos.md diff --git a/docs/superpowers/plans/2026-05-22-auto-init-repos.md b/docs/superpowers/plans/2026-05-22-auto-init-repos.md new file mode 100644 index 00000000..05de093b --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-auto-init-repos.md @@ -0,0 +1,1163 @@ +# auto-init-repos Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `codegraph auto-init-repos [--remove]` that installs a global git template `post-checkout` hook so every new `git clone` / `git init` automatically runs `codegraph init` and `codegraph index`. + +**Architecture:** Extract shared hook primitives (`stripMarkerBlock`, `isEffectivelyEmpty`, `chmodExecutable`) into `src/sync/hook-utils.ts`; refactor `src/sync/git-hooks.ts` to import them; add `src/sync/global-hooks.ts` for global template hook logic; expose the command via an extracted action handler in `src/bin/auto-init-repos-action.ts` (enables unit testing without subprocess); wire the command into `src/bin/codegraph.ts`. + +**Tech Stack:** TypeScript, Node.js `fs`/`child_process`, `better-sqlite3`-free (no DB), `@clack/prompts` for output, `vitest` for tests. + +**Spec:** `docs/superpowers/specs/2026-05-22-auto-init-repos-design.md` + +--- + +## Task 1: Create `src/sync/hook-utils.ts` with tests U1–U11 + +**Files:** +- Create: `src/sync/hook-utils.ts` +- Create: `__tests__/hook-utils.test.ts` + +- [ ] **Step 1: Write all failing tests (U1–U11)** + +Create `__tests__/hook-utils.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { stripMarkerBlock, isEffectivelyEmpty, chmodExecutable } from '../src/sync/hook-utils'; + +const BEGIN = '# >>> codegraph test >>>'; +const END = '# <<< codegraph test <<<'; + +describe('stripMarkerBlock', () => { + // U1: removes block between markers; surrounding content preserved + it('removes block between markers and preserves surrounding content', () => { + const content = `line before\n${BEGIN}\ninner line\n${END}\nline after`; + expect(stripMarkerBlock(content, BEGIN, END)).toBe('line before\nline after'); + }); + + // U2: no markers in content → returns unchanged + it('returns content unchanged when no markers present', () => { + const content = 'no markers here\njust lines'; + expect(stripMarkerBlock(content, BEGIN, END)).toBe(content); + }); + + // U3: custom begin/end markers → only those stripped, other markers untouched + it('strips only the specified markers, leaving other marker strings untouched', () => { + const otherBegin = '# >>> other >>>'; + const otherEnd = '# <<< other <<<'; + const content = [ + 'keep', + BEGIN, 'codegraph block', END, + 'also keep', + otherBegin, 'other content', otherEnd, + 'end', + ].join('\n'); + const result = stripMarkerBlock(content, otherBegin, otherEnd); + expect(result).toContain(BEGIN); + expect(result).toContain('codegraph block'); + expect(result).not.toContain('other content'); + expect(result).not.toContain(otherBegin); + }); + + // U3b: begin present, end absent → strips from begin to EOF + it('strips from begin marker to EOF when end marker is absent', () => { + const content = `before\n${BEGIN}\ninner`; + expect(stripMarkerBlock(content, BEGIN, END)).toBe('before'); + }); + + // U3c: end present, begin absent → returns content unchanged + it('returns content unchanged when end marker is present but begin is absent', () => { + const content = `before\n${END}\nafter`; + expect(stripMarkerBlock(content, BEGIN, END)).toBe(content); + }); + + // U3d: idempotent — two calls produce same result as one + it('is idempotent: calling twice produces the same result as calling once', () => { + const content = `a\n${BEGIN}\nb\n${END}\nc`; + const once = stripMarkerBlock(content, BEGIN, END); + const twice = stripMarkerBlock(once, BEGIN, END); + expect(twice).toBe(once); + }); +}); + +describe('isEffectivelyEmpty', () => { + // U4: shebang + blank lines only → true + it('returns true for shebang line and blank lines only', () => { + expect(isEffectivelyEmpty('#!/bin/sh\n\n')).toBe(true); + }); + + // U5: empty string → true + it('returns true for empty string', () => { + expect(isEffectivelyEmpty('')).toBe(true); + }); + + // U6: real user content → false + it('returns false when real user content is present', () => { + expect(isEffectivelyEmpty('#!/bin/sh\necho "user hook"')).toBe(false); + }); + + // U7: begin marker line present → false + it('returns false when a begin marker line is present', () => { + expect(isEffectivelyEmpty('#!/bin/sh\n# >>> codegraph auto-init hook >>>')).toBe(false); + }); + + // U8: end marker line present → false + it('returns false when an end marker line is present', () => { + expect(isEffectivelyEmpty('#!/bin/sh\n# <<< codegraph auto-init hook <<<')).toBe(false); + }); + + // U9: shebang + both marker lines → false + it('returns false when shebang is present alongside marker lines', () => { + const content = [ + '#!/bin/sh', + '# >>> codegraph sync hook >>>', + '# <<< codegraph sync hook <<<', + ].join('\n'); + expect(isEffectivelyEmpty(content)).toBe(false); + }); +}); + +describe('chmodExecutable', () => { + let tmp: string; + + beforeEach(() => { + tmp = path.join(os.tmpdir(), `hook-utils-chmod-${Date.now()}`); + }); + + afterEach(() => { + if (fs.existsSync(tmp)) fs.unlinkSync(tmp); + }); + + // U10: sets executable bit on POSIX + it('sets 0o755 executable bit on POSIX', () => { + if (process.platform === 'win32') return; + fs.writeFileSync(tmp, '#!/bin/sh\n', { mode: 0o644 }); + chmodExecutable(tmp); + expect(fs.statSync(tmp).mode & 0o111).not.toBe(0); + }); + + // U11: no throw when file does not exist + it('does not throw when the file does not exist', () => { + expect(() => chmodExecutable('/nonexistent/path/file.sh')).not.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +npx vitest run __tests__/hook-utils.test.ts +``` + +Expected: all tests **FAIL** with `Cannot find module '../src/sync/hook-utils'`. + +- [ ] **Step 3: Implement `src/sync/hook-utils.ts`** + +Create `src/sync/hook-utils.ts`: + +```typescript +import * as fs from 'fs'; + +/** + * Remove the block delimited by `begin` and `end` (inclusive) from `content`. + * Idempotent. When `begin` is present but `end` is absent, strips from `begin` + * to end-of-string (preserves compatibility with legacy partial writes). + * When `end` is present but `begin` is absent, returns content unchanged. + */ +export function stripMarkerBlock(content: string, begin: string, end: string): string { + const lines = content.split('\n'); + const kept: string[] = []; + let inBlock = false; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === begin) { inBlock = true; continue; } + if (trimmed === end) { inBlock = false; continue; } + if (!inBlock) kept.push(line); + } + return kept.join('\n'); +} + +/** + * Returns true iff every line in `content` is blank or a shebang (`#!` prefix). + * Call AFTER stripMarkerBlock — marker lines are not "empty" and return false, + * guarding against incorrect file deletion when a strip was skipped. + */ +export function isEffectivelyEmpty(content: string): boolean { + return content + .split('\n') + .map((l) => l.trim()) + .every((l) => l.length === 0 || l.startsWith('#!')); +} + +/** Sets the executable bit (0o755) on `file`. No-op when chmod is unsupported. */ +export function chmodExecutable(file: string): void { + try { + fs.chmodSync(file, 0o755); + } catch { + /* no-op on Windows or when file does not exist */ + } +} +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +npx vitest run __tests__/hook-utils.test.ts +``` + +Expected: **11 tests pass**, 0 failures. + +- [ ] **Step 5: Commit** + +```bash +git add src/sync/hook-utils.ts __tests__/hook-utils.test.ts +git commit -m "$(cat <<'EOF' +feat(hook-utils): extract shared hook primitive functions + +stripMarkerBlock, isEffectivelyEmpty, and chmodExecutable were +duplicated in git-hooks.ts. Extracting them here lets global-hooks.ts +reuse the same logic without copy-pasting, and pins their contracts +with 11 explicit unit tests. +EOF +)" +``` + +--- + +## Task 2: Refactor `src/sync/git-hooks.ts` to import from hook-utils + +**Files:** +- Modify: `src/sync/git-hooks.ts` + +- [ ] **Step 1: Replace local function definitions with imports** + +Open `src/sync/git-hooks.ts`. Make the following changes: + +**Add import** at the top (after the existing `import { execFileSync }` line): + +```typescript +import { stripMarkerBlock, isEffectivelyEmpty, chmodExecutable } from './hook-utils'; +``` + +**Remove** these three function definitions entirely (lines ~86–114): + +```typescript +/** Remove our marker block (and the marker lines) from hook content. */ +function stripMarkerBlock(content: string): string { + const lines = content.split('\n'); + ... +} + +/** Whether a hook body is just a shebang / blank lines (i.e. only ever ours). */ +function isEffectivelyEmpty(content: string): boolean { + ... +} + +function chmodExecutable(file: string): void { + ... +} +``` + +**Update the two call sites** that pass no marker args (the new signature requires them): + +In `installGitSyncHook` (~line 145): +```typescript +// Before: +const base = stripMarkerBlock(fs.readFileSync(file, 'utf8')).replace(/\s*$/, ''); +// After: +const base = stripMarkerBlock(fs.readFileSync(file, 'utf8'), MARKER_BEGIN, MARKER_END).replace(/\s*$/, ''); +``` + +In `removeGitSyncHook` (~line 184): +```typescript +// Before: +const stripped = stripMarkerBlock(original); +// After: +const stripped = stripMarkerBlock(original, MARKER_BEGIN, MARKER_END); +``` + +- [ ] **Step 2: Run existing git-hooks tests — verify all 7 still pass** + +```bash +npx vitest run __tests__/git-hooks.test.ts +``` + +Expected: **7 tests pass**, 0 failures. Any failure means an import or call-site was missed. + +- [ ] **Step 3: Commit** + +```bash +git add src/sync/git-hooks.ts +git commit -m "refactor(sync): import hook-utils in git-hooks" +``` + +--- + +## Task 3: Create `src/sync/global-hooks.ts` with tests G1–G20 + +**Files:** +- Create: `src/sync/global-hooks.ts` +- Create: `__tests__/global-hooks.test.ts` + +- [ ] **Step 1: Write all failing tests (G1–G20)** + +Create `__tests__/global-hooks.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFileSync } from 'child_process'; +import { + resolveTemplateDir, + installGlobalAutoInitHook, + removeGlobalAutoInitHook, + isGlobalAutoInitHookInstalled, +} from '../src/sync/global-hooks'; + +// Each test gets an isolated temp dir and a temp gitconfig so we never +// touch the real ~/.gitconfig. +let tempDir: string; +let templateDir: string; +let origGitConfigGlobal: string | undefined; + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-global-hooks-')); + templateDir = path.join(tempDir, 'templates'); + fs.mkdirSync(path.join(templateDir, 'hooks'), { recursive: true }); + + origGitConfigGlobal = process.env.GIT_CONFIG_GLOBAL; + process.env.GIT_CONFIG_GLOBAL = path.join(tempDir, '.gitconfig'); + + // Pre-configure init.templateDir so tests control the target path. + execFileSync('git', ['config', '--global', 'init.templateDir', templateDir], { + stdio: 'ignore', + }); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + if (origGitConfigGlobal === undefined) { + delete process.env.GIT_CONFIG_GLOBAL; + } else { + process.env.GIT_CONFIG_GLOBAL = origGitConfigGlobal; + } +}); + +function hookFile(): string { + return path.join(templateDir, 'hooks', 'post-checkout'); +} + +function isExecutable(file: string): boolean { + if (process.platform === 'win32') return true; + return (fs.statSync(file).mode & 0o111) !== 0; +} + +describe('resolveTemplateDir', () => { + // G1: init.templateDir already set → return it, do not overwrite + it('G1: returns configured templateDir and does not overwrite git config', () => { + const result = resolveTemplateDir(); + expect(result.dir).toBe(templateDir); + expect(result.configWasSet).toBe(false); + // Config still equals templateDir + const after = execFileSync('git', ['config', '--global', 'init.templateDir'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + expect(after).toBe(templateDir); + }); + + // G2: not set → returns ~/.git-templates, sets git config + it('G2: defaults to ~/.git-templates and writes git config when not set', () => { + // Use a fresh gitconfig with no init.templateDir and a temp HOME + const freshConfig = path.join(tempDir, '.fresh-gitconfig'); + process.env.GIT_CONFIG_GLOBAL = freshConfig; + const origHome = process.env.HOME; + const fakeHome = path.join(tempDir, 'fakehome'); + fs.mkdirSync(fakeHome, { recursive: true }); + process.env.HOME = fakeHome; + + try { + const result = resolveTemplateDir(); + const expected = path.join(fakeHome, '.git-templates'); + expect(result.dir).toBe(expected); + expect(result.configWasSet).toBe(true); + + const written = execFileSync('git', ['config', '--global', 'init.templateDir'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + expect(written).toBe(expected); + } finally { + if (origHome === undefined) delete process.env.HOME; + else process.env.HOME = origHome; + } + }); +}); + +describe('installGlobalAutoInitHook', () => { + // G3: fresh install — creates file with shebang + block, executable + it('G3: creates post-checkout file with shebang, block, and executable bit', () => { + const result = installGlobalAutoInitHook(); + expect(result.status).toBe('installed'); + expect(result.templateDir).toBe(templateDir); + expect(fs.existsSync(hookFile())).toBe(true); + const body = fs.readFileSync(hookFile(), 'utf8'); + expect(body).toMatch(/^#!\/bin\/sh/); + expect(body).toContain('# >>> codegraph auto-init hook >>>'); + expect(isExecutable(hookFile())).toBe(true); + }); + + // G4: hook contains command -v guard + it('G4: hook script is guarded by command -v codegraph check', () => { + installGlobalAutoInitHook(); + expect(fs.readFileSync(hookFile(), 'utf8')).toContain( + 'if command -v codegraph >/dev/null 2>&1' + ); + }); + + // G5: hook contains [ ! -d .codegraph ] branch + it('G5: hook script checks for absence of .codegraph directory', () => { + installGlobalAutoInitHook(); + expect(fs.readFileSync(hookFile(), 'utf8')).toContain('[ ! -d .codegraph ]'); + }); + + // G6: init branch contains codegraph init . and codegraph index + it('G6: init branch runs codegraph init . and codegraph index', () => { + installGlobalAutoInitHook(); + const body = fs.readFileSync(hookFile(), 'utf8'); + expect(body).toContain('codegraph init . >/dev/null 2>&1'); + expect(body).toContain('codegraph index >/dev/null 2>&1'); + }); + + // G7: init branch appends .codegraph/ to .gitignore idempotently + it('G7: init branch appends .codegraph/ to .gitignore using grep -qxF guard', () => { + installGlobalAutoInitHook(); + expect(fs.readFileSync(hookFile(), 'utf8')).toContain( + "grep -qxF '.codegraph/' .gitignore 2>/dev/null || echo '.codegraph/' >> .gitignore" + ); + }); + + // G8: sync branch runs codegraph sync in background + it('G8: sync branch runs codegraph sync in background and suppresses output', () => { + installGlobalAutoInitHook(); + expect(fs.readFileSync(hookFile(), 'utf8')).toContain( + '( codegraph sync >/dev/null 2>&1 & ) >/dev/null 2>&1' + ); + }); + + // G9: idempotent — re-run produces exactly one marker block + it('G9: re-running install does not duplicate the marker block', () => { + installGlobalAutoInitHook(); + installGlobalAutoInitHook(); + const body = fs.readFileSync(hookFile(), 'utf8'); + const count = body.split('# >>> codegraph auto-init hook >>>').length - 1; + expect(count).toBe(1); + }); + + // G10: preserves pre-existing user hook content + it('G10: appends block after existing user hook content', () => { + fs.writeFileSync(hookFile(), '#!/bin/sh\necho "my custom hook"\n', { mode: 0o755 }); + installGlobalAutoInitHook(); + const body = fs.readFileSync(hookFile(), 'utf8'); + expect(body).toContain('echo "my custom hook"'); + expect(body).toContain('# >>> codegraph auto-init hook >>>'); + }); + + // G11: returns status 'installed' and correct templateDir + it('G11: returns status installed and the resolved templateDir', () => { + const result = installGlobalAutoInitHook(); + expect(result.status).toBe('installed'); + expect(result.templateDir).toBe(templateDir); + }); + + // G12: already installed with byte-identical block → status unchanged, no write + it('G12: returns unchanged and does not rewrite file when block is already current', () => { + installGlobalAutoInitHook(); + const mtimeBefore = fs.statSync(hookFile()).mtimeMs; + // Small delay to ensure mtime would differ if file were rewritten + const result = installGlobalAutoInitHook(); + expect(result.status).toBe('unchanged'); + expect(fs.statSync(hookFile()).mtimeMs).toBe(mtimeBefore); + }); + + // G19: creates /hooks/ when it does not exist + it('G19: creates hooks directory if it does not exist', () => { + const freshTemplateDir = path.join(tempDir, 'fresh-templates'); + execFileSync('git', ['config', '--global', 'init.templateDir', freshTemplateDir], { + stdio: 'ignore', + }); + expect(fs.existsSync(path.join(freshTemplateDir, 'hooks'))).toBe(false); + installGlobalAutoInitHook(); + expect(fs.existsSync(path.join(freshTemplateDir, 'hooks', 'post-checkout'))).toBe(true); + }); + + // G20: uses existing init.templateDir without changing config + it('G20: uses existing git config value and does not overwrite it', () => { + const before = execFileSync('git', ['config', '--global', 'init.templateDir'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + installGlobalAutoInitHook(); + const after = execFileSync('git', ['config', '--global', 'init.templateDir'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + expect(after).toBe(before); + }); +}); + +describe('removeGlobalAutoInitHook', () => { + // G13: strips block, deletes file when only ours + it('G13: deletes the hook file when our block was the only content', () => { + installGlobalAutoInitHook(); + const result = removeGlobalAutoInitHook(); + expect(result.status).toBe('removed'); + expect(fs.existsSync(hookFile())).toBe(false); + }); + + // G14: keeps user content when hook is shared + it('G14: preserves user content and rewrites file without our block', () => { + fs.writeFileSync(hookFile(), '#!/bin/sh\necho "keep me"\n', { mode: 0o755 }); + installGlobalAutoInitHook(); + removeGlobalAutoInitHook(); + expect(fs.existsSync(hookFile())).toBe(true); + const body = fs.readFileSync(hookFile(), 'utf8'); + expect(body).toContain('echo "keep me"'); + expect(body).not.toContain('# >>> codegraph auto-init hook >>>'); + }); + + // G15: not installed → status skipped + it('G15: returns skipped when no block is present', () => { + const result = removeGlobalAutoInitHook(); + expect(result.status).toBe('skipped'); + expect(result.reason).toBeDefined(); + }); + + // G16: never modifies git config init.templateDir + it('G16: does not modify git config init.templateDir during remove', () => { + installGlobalAutoInitHook(); + const before = execFileSync('git', ['config', '--global', 'init.templateDir'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + removeGlobalAutoInitHook(); + const after = execFileSync('git', ['config', '--global', 'init.templateDir'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + expect(after).toBe(before); + }); +}); + +describe('isGlobalAutoInitHookInstalled', () => { + // G17: returns true when installed + it('G17: returns true after install', () => { + installGlobalAutoInitHook(); + expect(isGlobalAutoInitHookInstalled()).toBe(true); + }); + + // G18: returns false when not installed + it('G18: returns false when hook file does not contain our block', () => { + expect(isGlobalAutoInitHookInstalled()).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +npx vitest run __tests__/global-hooks.test.ts +``` + +Expected: all tests **FAIL** with `Cannot find module '../src/sync/global-hooks'`. + +- [ ] **Step 3: Implement `src/sync/global-hooks.ts`** + +Create `src/sync/global-hooks.ts`: + +```typescript +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execFileSync } from 'child_process'; +import { stripMarkerBlock, isEffectivelyEmpty, chmodExecutable } from './hook-utils'; + +const MARKER_BEGIN = '# >>> codegraph auto-init hook >>>'; +const MARKER_END = '# <<< codegraph auto-init hook <<<'; + +export interface GlobalHookResult { + templateDir: string; + status: 'installed' | 'removed' | 'unchanged' | 'skipped'; + /** True when this call wrote git config init.templateDir for the first time. */ + configWasSet: boolean; + reason?: string; +} + +/** The shell snippet injected between markers into the template post-checkout hook. */ +function autoInitBlock(): string { + return [ + MARKER_BEGIN, + '# Auto-initializes CodeGraph in newly cloned repos.', + '# Managed by codegraph; remove with: codegraph auto-init-repos --remove', + 'if command -v codegraph >/dev/null 2>&1; then', + ' if [ ! -d .codegraph ]; then', + ' codegraph init . >/dev/null 2>&1', + ' codegraph index >/dev/null 2>&1', + " grep -qxF '.codegraph/' .gitignore 2>/dev/null || echo '.codegraph/' >> .gitignore", + ' else', + ' ( codegraph sync >/dev/null 2>&1 & ) >/dev/null 2>&1', + ' fi', + 'fi', + MARKER_END, + ].join('\n'); +} + +/** + * Resolve (and optionally write) the git template directory. + * + * When `writeConfig` is true (default) and `init.templateDir` is not set, + * defaults to `~/.git-templates` and writes it to git global config so + * future `git clone`/`git init` operations pick it up. + * + * Always creates `/hooks/` if it does not exist. + */ +export function resolveTemplateDir(opts: { writeConfig?: boolean } = {}): { + dir: string; + configWasSet: boolean; +} { + const writeConfig = opts.writeConfig !== false; // default true + let dir: string; + let configWasSet = false; + + try { + const raw = execFileSync('git', ['config', '--global', 'init.templateDir'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + dir = raw.replace(/^~/, os.homedir()); + } catch { + dir = path.join(os.homedir(), '.git-templates'); + if (writeConfig) { + execFileSync('git', ['config', '--global', 'init.templateDir', dir], { + stdio: 'ignore', + }); + configWasSet = true; + } + } + + fs.mkdirSync(path.join(dir, 'hooks'), { recursive: true }); + return { dir, configWasSet }; +} + +/** + * Install (or update) the CodeGraph auto-init hook in the git template directory. + * Idempotent: re-running replaces our block rather than duplicating it. + * Pre-existing user hook content is preserved. + */ +export function installGlobalAutoInitHook(): GlobalHookResult { + const { dir: templateDir, configWasSet } = resolveTemplateDir(); + const hookPath = path.join(templateDir, 'hooks', 'post-checkout'); + const block = autoInitBlock(); + + if (fs.existsSync(hookPath)) { + const existing = fs.readFileSync(hookPath, 'utf8'); + const stripped = stripMarkerBlock(existing, MARKER_BEGIN, MARKER_END).replace(/\s*$/, ''); + const newContent = stripped.length > 0 + ? `${stripped}\n\n${block}\n` + : `#!/bin/sh\n${block}\n`; + + if (existing === newContent) { + return { templateDir, status: 'unchanged', configWasSet }; + } + + fs.writeFileSync(hookPath, newContent); + chmodExecutable(hookPath); + return { templateDir, status: 'installed', configWasSet }; + } + + fs.writeFileSync(hookPath, `#!/bin/sh\n${block}\n`); + chmodExecutable(hookPath); + return { templateDir, status: 'installed', configWasSet }; +} + +/** + * Remove the CodeGraph auto-init block from the template post-checkout hook. + * Strips only our marker block; deletes the file if nothing meaningful remains. + * Never modifies git config. + */ +export function removeGlobalAutoInitHook(): GlobalHookResult { + const { dir: templateDir } = resolveTemplateDir({ writeConfig: false }); + const hookPath = path.join(templateDir, 'hooks', 'post-checkout'); + + if (!fs.existsSync(hookPath)) { + return { + templateDir, + status: 'skipped', + configWasSet: false, + reason: 'hook file does not exist', + }; + } + + const original = fs.readFileSync(hookPath, 'utf8'); + if (!original.includes(MARKER_BEGIN)) { + return { + templateDir, + status: 'skipped', + configWasSet: false, + reason: 'no codegraph auto-init block found', + }; + } + + const stripped = stripMarkerBlock(original, MARKER_BEGIN, MARKER_END); + if (isEffectivelyEmpty(stripped)) { + fs.unlinkSync(hookPath); + } else { + fs.writeFileSync(hookPath, `${stripped.replace(/\s*$/, '')}\n`); + chmodExecutable(hookPath); + } + + return { templateDir, status: 'removed', configWasSet: false }; +} + +/** Returns true when the template post-checkout hook contains our auto-init block. */ +export function isGlobalAutoInitHookInstalled(): boolean { + try { + const { dir } = resolveTemplateDir({ writeConfig: false }); + const hookPath = path.join(dir, 'hooks', 'post-checkout'); + return fs.existsSync(hookPath) && fs.readFileSync(hookPath, 'utf8').includes(MARKER_BEGIN); + } catch { + return false; + } +} +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +npx vitest run __tests__/global-hooks.test.ts +``` + +Expected: **20 tests pass**, 0 failures. + +- [ ] **Step 5: Run full test suite — verify nothing regressed** + +```bash +npm test +``` + +Expected: all existing tests still pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/sync/global-hooks.ts __tests__/global-hooks.test.ts +git commit -m "$(cat <<'EOF' +feat(global-hooks): add auto-init template hook install/remove + +Installs a post-checkout snippet into the git template directory so +every new git clone automatically runs codegraph init + index. Uses +the same marker-block pattern as the per-repo sync hooks, with full +idempotency and surgical remove that preserves user hook content. +EOF +)" +``` + +--- + +## Task 4: Create `src/bin/auto-init-repos-action.ts` with CLI tests C1–C12 + +**Files:** +- Create: `src/bin/auto-init-repos-action.ts` +- Create: `__tests__/auto-init-repos-cli.test.ts` + +- [ ] **Step 1: Write all failing tests (C1–C12)** + +Create `__tests__/auto-init-repos-cli.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock global-hooks before importing the action so vi.mock hoists correctly. +vi.mock('../src/sync/global-hooks', () => ({ + installGlobalAutoInitHook: vi.fn(), + removeGlobalAutoInitHook: vi.fn(), +})); + +import { autoInitReposAction } from '../src/bin/auto-init-repos-action'; +import { + installGlobalAutoInitHook, + removeGlobalAutoInitHook, +} from '../src/sync/global-hooks'; + +const mockInstall = vi.mocked(installGlobalAutoInitHook); +const mockRemove = vi.mocked(removeGlobalAutoInitHook); + +// Capture clack output via the injected mock. +function makeClack() { + const calls: string[] = []; + return { + intro: vi.fn(), + outro: vi.fn(), + log: { + success: vi.fn((msg: string) => calls.push(msg)), + info: vi.fn((msg: string) => calls.push(msg)), + warn: vi.fn((msg: string) => calls.push(msg)), + error: vi.fn((msg: string) => calls.push(msg)), + }, + _calls: calls, + }; +} + +type MockClack = ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + // Restore process.exitCode after each test + process.exitCode = undefined; +}); + +describe('autoInitReposAction — install path', () => { + // C1: no --remove → calls installGlobalAutoInitHook + it('C1: calls installGlobalAutoInitHook when remove is not set', async () => { + mockInstall.mockReturnValue({ status: 'installed', templateDir: '/tmp/t', configWasSet: true }); + const clack = makeClack(); + await autoInitReposAction({}, clack as unknown as MockClack); + expect(mockInstall).toHaveBeenCalledOnce(); + expect(mockRemove).not.toHaveBeenCalled(); + }); + + // C3: install success → output contains templateDir + it('C3: logs the resolved templateDir on successful install', async () => { + mockInstall.mockReturnValue({ status: 'installed', templateDir: '/tmp/t', configWasSet: true }); + const clack = makeClack(); + await autoInitReposAction({}, clack as unknown as MockClack); + const allOutput = clack._calls.join(' '); + expect(allOutput).toContain('/tmp/t'); + }); + + // C4: configWasSet true → output contains 'init.templateDir set' + it('C4: logs that init.templateDir was set when configWasSet is true', async () => { + mockInstall.mockReturnValue({ status: 'installed', templateDir: '/tmp/t', configWasSet: true }); + const clack = makeClack(); + await autoInitReposAction({}, clack as unknown as MockClack); + const allOutput = clack._calls.join(' '); + expect(allOutput).toMatch(/init\.templateDir set/i); + }); + + // C5: configWasSet false → output contains 'already set' or 'already configured' + it('C5: logs that init.templateDir was already configured when configWasSet is false', async () => { + mockInstall.mockReturnValue({ status: 'installed', templateDir: '/tmp/t', configWasSet: false }); + const clack = makeClack(); + await autoInitReposAction({}, clack as unknown as MockClack); + const allOutput = clack._calls.join(' '); + expect(allOutput).toMatch(/already (set|configured)/i); + }); + + // C6: status unchanged → output contains 'Already installed' and templateDir + it('C6: logs Already installed with templateDir when status is unchanged', async () => { + mockInstall.mockReturnValue({ status: 'unchanged', templateDir: '/tmp/t', configWasSet: false }); + const clack = makeClack(); + await autoInitReposAction({}, clack as unknown as MockClack); + const allOutput = clack._calls.join(' '); + expect(allOutput).toMatch(/already installed/i); + expect(allOutput).toContain('/tmp/t'); + }); + + // C7: status unchanged → exits 0 (no process.exit(1)) + it('C7: does not set exit code to 1 when status is unchanged', async () => { + mockInstall.mockReturnValue({ status: 'unchanged', templateDir: '/tmp/t', configWasSet: false }); + const clack = makeClack(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + await autoInitReposAction({}, clack as unknown as MockClack); + expect(exitSpy).not.toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + }); +}); + +describe('autoInitReposAction — remove path', () => { + // C2: --remove → calls removeGlobalAutoInitHook, not install + it('C2: calls removeGlobalAutoInitHook when remove is true', async () => { + mockRemove.mockReturnValue({ status: 'removed', templateDir: '/tmp/t', configWasSet: false }); + const clack = makeClack(); + await autoInitReposAction({ remove: true }, clack as unknown as MockClack); + expect(mockRemove).toHaveBeenCalledOnce(); + expect(mockInstall).not.toHaveBeenCalled(); + }); + + // C8: remove success → output contains templateDir and note about init.templateDir + it('C8: logs templateDir and git config note on successful remove', async () => { + mockRemove.mockReturnValue({ status: 'removed', templateDir: '/tmp/t', configWasSet: false }); + const clack = makeClack(); + await autoInitReposAction({ remove: true }, clack as unknown as MockClack); + const allOutput = clack._calls.join(' '); + expect(allOutput).toContain('/tmp/t'); + expect(allOutput).toMatch(/init\.templateDir was not modified/i); + }); + + // C9: status skipped → output contains 'No codegraph auto-init hook found' and templateDir + it('C9: logs hook-not-found message with templateDir when status is skipped', async () => { + mockRemove.mockReturnValue({ + status: 'skipped', + templateDir: '/tmp/t', + configWasSet: false, + reason: 'no block found', + }); + const clack = makeClack(); + await autoInitReposAction({ remove: true }, clack as unknown as MockClack); + const allOutput = clack._calls.join(' '); + expect(allOutput).toMatch(/no codegraph auto-init hook found/i); + expect(allOutput).toContain('/tmp/t'); + }); + + // C10: status skipped → exits 0 + it('C10: does not set exit code to 1 when status is skipped', async () => { + mockRemove.mockReturnValue({ + status: 'skipped', templateDir: '/tmp/t', configWasSet: false, + }); + const clack = makeClack(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + await autoInitReposAction({ remove: true }, clack as unknown as MockClack); + expect(exitSpy).not.toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + }); +}); + +describe('autoInitReposAction — error handling', () => { + // C11: install throws → process.exit(1) called + it('C11: calls process.exit(1) when installGlobalAutoInitHook throws', async () => { + mockInstall.mockImplementation(() => { throw new Error('write failed'); }); + const clack = makeClack(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + await expect(autoInitReposAction({}, clack as unknown as MockClack)).rejects.toThrow('exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + }); + + // C12: install throws → clack.log.error called with error message + it('C12: logs error message via clack.log.error when installGlobalAutoInitHook throws', async () => { + mockInstall.mockImplementation(() => { throw new Error('write failed'); }); + const clack = makeClack(); + vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + await expect(autoInitReposAction({}, clack as unknown as MockClack)).rejects.toThrow('exit'); + expect(clack.log.error).toHaveBeenCalledWith(expect.stringContaining('write failed')); + vi.restoreAllMocks(); + }); +}); +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +npx vitest run __tests__/auto-init-repos-cli.test.ts +``` + +Expected: all tests **FAIL** with `Cannot find module '../src/bin/auto-init-repos-action'`. + +- [ ] **Step 3: Implement `src/bin/auto-init-repos-action.ts`** + +Create `src/bin/auto-init-repos-action.ts`: + +```typescript +import { + installGlobalAutoInitHook, + removeGlobalAutoInitHook, +} from '../sync/global-hooks'; + +// Clack is injected so the handler is testable without ESM dynamic import +// complexity. codegraph.ts loads clack once and passes it through. +type ClackModule = typeof import('@clack/prompts'); + +export async function autoInitReposAction( + options: { remove?: boolean }, + clack: ClackModule, +): Promise { + clack.intro('CodeGraph auto-init'); + + try { + if (options.remove) { + const result = removeGlobalAutoInitHook(); + + if (result.status === 'skipped') { + clack.log.info( + `No codegraph auto-init hook found in ${result.templateDir}` + ); + } else { + clack.log.success( + `Removed auto-init hook from ${result.templateDir}/hooks/post-checkout` + ); + clack.log.info('Note: git config init.templateDir was not modified.'); + } + } else { + const result = installGlobalAutoInitHook(); + + if (result.status === 'unchanged') { + clack.log.success(`Already installed in ${result.templateDir}`); + } else { + clack.log.success(`Template dir: ${result.templateDir}`); + + if (result.configWasSet) { + clack.log.success('git config init.templateDir set'); + } else { + clack.log.info( + `git config init.templateDir already configured — using ${result.templateDir}` + ); + } + + clack.log.success('post-checkout hook installed'); + clack.outro( + 'Every new git clone will auto-initialize and index CodeGraph.\n' + + ' Run `codegraph auto-init-repos --remove` to undo.' + ); + return; + } + } + } catch (err) { + clack.log.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + return; + } + + clack.outro(''); +} +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +npx vitest run __tests__/auto-init-repos-cli.test.ts +``` + +Expected: **12 tests pass**, 0 failures. + +- [ ] **Step 5: Commit** + +```bash +git add src/bin/auto-init-repos-action.ts __tests__/auto-init-repos-cli.test.ts +git commit -m "$(cat <<'EOF' +feat(auto-init): add action handler and CLI unit tests + +Extracts the auto-init-repos command handler into its own module so +tests can import it directly and mock global-hooks without spawning a +subprocess. Clack is injected via parameter to avoid ESM dynamic +import issues in the test environment. +EOF +)" +``` + +--- + +## Task 5: Wire the CLI command into `src/bin/codegraph.ts` + +**Files:** +- Modify: `src/bin/codegraph.ts` + +- [ ] **Step 1: Add the `auto-init-repos` command** + +In `src/bin/codegraph.ts`, locate the line `program.parse();` at the bottom (line ~1402). +Add the following block **immediately before** `program.parse();`: + +```typescript +/** + * codegraph auto-init-repos [--remove] + */ +program + .command('auto-init-repos') + .description('Install (or remove) a global git template hook that auto-initializes CodeGraph in every new git clone') + .option('--remove', 'Remove the auto-init hook from the git template directory') + .action(async (opts: { remove?: boolean }) => { + const clack = await importESM('@clack/prompts'); + const { autoInitReposAction } = await import('./auto-init-repos-action'); + await autoInitReposAction(opts, clack); + }); +``` + +- [ ] **Step 2: Build the project** + +```bash +npm run build +``` + +Expected: **build succeeds** with no TypeScript errors. + +- [ ] **Step 3: Smoke test — install** + +```bash +node dist/bin/codegraph.js auto-init-repos +``` + +Expected output (approximate): +``` +◆ CodeGraph auto-init +✔ Template dir: ~/.git-templates (created or already existed) +✔ git config init.templateDir set (or: already configured) +✔ post-checkout hook installed +◇ Every new git clone will auto-initialize and index CodeGraph. + Run `codegraph auto-init-repos --remove` to undo. +``` + +- [ ] **Step 4: Smoke test — idempotent re-run** + +```bash +node dist/bin/codegraph.js auto-init-repos +``` + +Expected output: +``` +◆ CodeGraph auto-init +✔ Already installed in +``` + +- [ ] **Step 5: Smoke test — remove** + +```bash +node dist/bin/codegraph.js auto-init-repos --remove +``` + +Expected output: +``` +◆ CodeGraph auto-init +✔ Removed auto-init hook from /hooks/post-checkout +ℹ Note: git config init.templateDir was not modified. +``` + +- [ ] **Step 6: Run full test suite — all 53 tests pass** + +```bash +npm test +``` + +Expected: **53 tests pass** (14 hook-utils + 7 git-hooks + 20 global-hooks + 12 CLI), 0 failures. + +- [ ] **Step 7: Commit** + +```bash +git add src/bin/codegraph.ts +git commit -m "feat(cli): register auto-init-repos command" +``` + +--- + +## Self-Review + +**Spec coverage check:** + +| Spec section | Task covering it | +|---|---| +| REQ-HU-01–13 (hook-utils) | Task 1 | +| REQ-GH-01–02 (git-hooks refactor) | Task 2 | +| REQ-GL-01–26 (global-hooks) | Task 3 | +| REQ-CLI-00–10 (action handler) | Task 4 | +| CLI registration, build, smoke test | Task 5 | + +All 53 test IDs (U1–U11, G1–G20, C1–C12, 7 existing git-hooks) covered. No gaps. + +**Placeholder scan:** No TBDs, no "implement later", all steps contain actual code. + +**Type consistency check:** +- `GlobalHookResult.configWasSet: boolean` — defined in Task 3 `src/sync/global-hooks.ts`, used in Task 4 tests (`configWasSet: true/false` in mock return values). ✓ +- `resolveTemplateDir(opts?)` — defined in Task 3, called with `{ writeConfig: false }` inside `removeGlobalAutoInitHook` and `isGlobalAutoInitHookInstalled`. ✓ +- `autoInitReposAction(options, clack)` — defined in Task 4, called in Task 5 with `(opts, clack)`. ✓ +- `stripMarkerBlock(content, begin, end)` — defined in Task 1, call sites in Task 2 updated to pass `MARKER_BEGIN, MARKER_END`. ✓ From e617996c2656d2c90a8e19a0fa95367b7033e18b Mon Sep 17 00:00:00 2001 From: CagesThrottleUs Date: Fri, 22 May 2026 16:02:08 +0530 Subject: [PATCH 16/16] docs(readme): add auto-init-repos section and CLI reference entry Documents the new auto-init-repos command in the Initialize Projects section and adds it to the CLI reference table so users can discover the zero-config cloning workflow. --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 598ac5b0..1ce894ff 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,22 @@ codegraph init -i +### Auto-Initialize Every Clone (Optional) + +Run once to install a global git template hook. Every subsequent `git clone` or `git init` will automatically run `codegraph init` and `codegraph index` — no manual step required: + +```bash +codegraph auto-init-repos +``` + +Git copies the hook into each new repo's `.git/hooks/post-checkout`. It also appends `.codegraph/` to `.gitignore` so the index never gets committed. To undo: + +```bash +codegraph auto-init-repos --remove +``` + +> **Scope:** macOS, Linux, Git for Windows (MINGW). The hook fires on branch checkout only (not `git checkout -- file`). Existing repos are unaffected — only new clones and `git init`s after this command. + --- ## Why CodeGraph? @@ -342,6 +358,8 @@ codegraph query # Search symbols (--kind, --limit, --json) codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json) codegraph context # Build context for AI (--format, --max-nodes) codegraph affected [files...] # Find test files affected by changes (see below) +codegraph auto-init-repos # Install global hook: auto-init every new git clone +codegraph auto-init-repos --remove # Remove the global hook codegraph serve --mcp # Start MCP server ```