Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
2025af2
revert: remove unnecessary changes
CagesThrottleUs May 24, 2026
0eb2b83
chore: merge changes from "main"
CagesThrottleUs May 24, 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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ 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.
### Uninstall

Changed your mind? One command removes CodeGraph from every agent it configured:
Expand Down Expand Up @@ -341,6 +356,8 @@ codegraph callers <symbol> # Find what calls a function/method (--limit,
codegraph callees <symbol> # Find what a function/method calls (--limit, --json)
codegraph impact <symbol> # Analyze what code is affected by changing a symbol (--depth, --json)
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