Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3804a05
feat(hook-utils): extract shared hook primitive functions
CagesThrottleUs May 22, 2026
f87496d
test(hook-utils): use it.skipIf for Windows platform skip
CagesThrottleUs May 22, 2026
5479886
refactor(sync): import hook-utils in git-hooks
CagesThrottleUs May 22, 2026
89e3744
feat(global-hooks): add auto-init template hook install/remove
CagesThrottleUs May 22, 2026
ffe4d78
test(global-hooks): cover file-exists-no-marker skipped path
CagesThrottleUs May 22, 2026
ae13f81
feat(auto-init): add action handler and CLI unit tests
CagesThrottleUs May 22, 2026
2d33877
fix(auto-init): remove dead return, use failure-safe spy restore
CagesThrottleUs May 22, 2026
3300efb
feat(cli): register auto-init-repos command
CagesThrottleUs May 22, 2026
cc121e6
fix(global-hooks): normalize equality check, gate mkdirSync, add \$3 …
CagesThrottleUs May 22, 2026
3ffadbb
docs(global-hooks): fix stale JSDoc for resolveTemplateDir
CagesThrottleUs May 22, 2026
7fcbb83
chore: pin Node.js 22 via .nvmrc
CagesThrottleUs May 22, 2026
15df7e8
chore(gitignore): ignore .cursor/ directory
CagesThrottleUs May 22, 2026
e51886e
chore: update package-lock.json
CagesThrottleUs May 22, 2026
17be2cf
docs(specs): add auto-init-repos design spec
CagesThrottleUs May 22, 2026
68766db
docs(plans): add auto-init-repos implementation plan
CagesThrottleUs May 22, 2026
e617996
docs(readme): add auto-init-repos section and CLI reference entry
CagesThrottleUs May 22, 2026
721c94d
chore: merge origin/main (uninstall command + CI updates)
CagesThrottleUs May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,6 @@ test-languages/

nul
release/

# Ignore cursor rules
.cursor/
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ codegraph init -i

</div>

### 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?
Expand Down Expand Up @@ -342,6 +358,8 @@ codegraph query <search> # Search symbols (--kind, --limit, --json)
codegraph files [path] # Show file structure (--format, --filter, --max-depth, --json)
codegraph context <task> # 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
```

Expand Down
149 changes: 149 additions & 0 deletions __tests__/auto-init-repos-cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
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<typeof makeClack>;

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();
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();
}
});
});
Loading