Skip to content

Commit 382c256

Browse files
committed
pre push hook
1 parent 06d5747 commit 382c256

3 files changed

Lines changed: 166 additions & 2 deletions

File tree

changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
## [0.3.7] - 03/04/2026
4+
- Pre-push hook
5+
36
## [0.3.6] - 02/04/2026
47
- new login approach
58

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codeant-cli",
3-
"version": "0.3.6",
3+
"version": "0.3.7",
44
"description": "Code review CLI tool",
55
"type": "module",
66
"bin": {
@@ -25,7 +25,8 @@
2525
"main": "./src/reviewHeadless.js",
2626
"exports": {
2727
".": "./src/reviewHeadless.js",
28-
"./review": "./src/reviewHeadless.js"
28+
"./review": "./src/reviewHeadless.js",
29+
"./push-protection": "./src/utils/installPushProtectionHook.js"
2930
},
3031
"files": [
3132
"src"
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { execSync } from 'child_process';
2+
import { readFileSync, writeFileSync, chmodSync, existsSync, unlinkSync } from 'fs';
3+
import path from 'path';
4+
5+
const HOOK_MARKER = '# codeant-push-protection';
6+
7+
/**
8+
* Build the full pre-push hook script (with shebang).
9+
* @param {string} failOn - Severity threshold (e.g. "HIGH", "CRITICAL")
10+
*/
11+
function buildHookScript(failOn = 'HIGH') {
12+
return `#!/bin/sh
13+
${buildHookBlock(failOn)}
14+
`;
15+
}
16+
17+
/**
18+
* Build just the CodeAnt block (no shebang), used when appending to an existing hook.
19+
* @param {string} failOn - Severity threshold
20+
*/
21+
function buildHookBlock(failOn = 'HIGH') {
22+
return `${HOOK_MARKER}
23+
# Auto-installed by CodeAnt AI — blocks pushes containing secrets.
24+
# To disable: delete this hook or run "codeant push-protection disable"
25+
command -v codeant >/dev/null 2>&1 || exit 0
26+
codeant secrets --committed --fail-on ${failOn}
27+
${HOOK_MARKER_END}`;
28+
}
29+
30+
const HOOK_MARKER_END = '# end-codeant-push-protection';
31+
32+
/**
33+
* Replace the CodeAnt block in a hook file with new content (or remove it).
34+
*/
35+
function replaceCodeAntBlock(fileContent, newBlock) {
36+
const startIdx = fileContent.indexOf(HOOK_MARKER);
37+
let endIdx = fileContent.indexOf(HOOK_MARKER_END);
38+
if (startIdx === -1) return fileContent;
39+
if (endIdx === -1) {
40+
// Legacy hook without end marker — remove from start marker to EOF
41+
endIdx = fileContent.length;
42+
} else {
43+
endIdx += HOOK_MARKER_END.length;
44+
}
45+
const before = fileContent.slice(0, startIdx);
46+
const after = fileContent.slice(endIdx);
47+
return (before + newBlock + after).replace(/\n{3,}/g, '\n\n');
48+
}
49+
50+
/**
51+
* Find the git root directory for a given workspace path.
52+
*/
53+
function findGitRoot(workspacePath) {
54+
try {
55+
return execSync('git rev-parse --show-toplevel', {
56+
cwd: workspacePath,
57+
encoding: 'utf-8',
58+
timeout: 5000,
59+
stdio: ['pipe', 'pipe', 'pipe'],
60+
}).trim();
61+
} catch {
62+
return null;
63+
}
64+
}
65+
66+
/**
67+
* Get the effective hooks directory (respects core.hooksPath).
68+
*/
69+
function getHooksDir(gitRoot) {
70+
try {
71+
const custom = execSync('git config --get core.hooksPath', {
72+
cwd: gitRoot,
73+
encoding: 'utf-8',
74+
timeout: 5000,
75+
stdio: ['pipe', 'pipe', 'pipe'],
76+
}).trim();
77+
if (custom) return path.resolve(gitRoot, custom);
78+
} catch {
79+
// No custom hooksPath — use default
80+
}
81+
return path.join(gitRoot, '.git', 'hooks');
82+
}
83+
84+
/**
85+
* Install a pre-push hook that runs secret scanning before push.
86+
*
87+
* @param {string} workspacePath - Path to the git repository
88+
* @param {object} [options]
89+
* @param {string} [options.failOn="HIGH"] - Severity threshold
90+
* @returns {{ installed: boolean, hookPath: string|null, message: string }}
91+
*/
92+
export function installPushProtectionHook(workspacePath, options = {}) {
93+
const { failOn = 'HIGH' } = options;
94+
95+
const gitRoot = findGitRoot(workspacePath);
96+
if (!gitRoot) {
97+
return { installed: false, hookPath: null, message: 'Not a git repository' };
98+
}
99+
100+
const hooksDir = getHooksDir(gitRoot);
101+
const hookPath = path.join(hooksDir, 'pre-push');
102+
103+
// If hook already exists, check if it's ours
104+
if (existsSync(hookPath)) {
105+
const existing = readFileSync(hookPath, 'utf-8');
106+
if (existing.includes(HOOK_MARKER)) {
107+
// Replace only our block, preserve everything else
108+
const updated = replaceCodeAntBlock(existing, buildHookBlock(failOn));
109+
writeFileSync(hookPath, updated, 'utf-8');
110+
chmodSync(hookPath, 0o755);
111+
return { installed: true, hookPath, message: 'Hook updated' };
112+
}
113+
// There's a user-managed hook — append our block (no duplicate shebang)
114+
const appended = existing.trimEnd() + '\n\n' + buildHookBlock(failOn) + '\n';
115+
writeFileSync(hookPath, appended, 'utf-8');
116+
chmodSync(hookPath, 0o755);
117+
return { installed: true, hookPath, message: 'Hook appended to existing pre-push' };
118+
}
119+
120+
writeFileSync(hookPath, buildHookScript(failOn), 'utf-8');
121+
chmodSync(hookPath, 0o755);
122+
return { installed: true, hookPath, message: 'Hook installed' };
123+
}
124+
125+
/**
126+
* Remove the CodeAnt pre-push hook (or just our section if appended).
127+
*
128+
* @param {string} workspacePath
129+
* @returns {{ removed: boolean, message: string }}
130+
*/
131+
export function removePushProtectionHook(workspacePath) {
132+
const gitRoot = findGitRoot(workspacePath);
133+
if (!gitRoot) {
134+
return { removed: false, message: 'Not a git repository' };
135+
}
136+
137+
const hooksDir = getHooksDir(gitRoot);
138+
const hookPath = path.join(hooksDir, 'pre-push');
139+
140+
if (!existsSync(hookPath)) {
141+
return { removed: false, message: 'No pre-push hook found' };
142+
}
143+
144+
const content = readFileSync(hookPath, 'utf-8');
145+
if (!content.includes(HOOK_MARKER)) {
146+
return { removed: false, message: 'Hook is not managed by CodeAnt' };
147+
}
148+
149+
// Remove our block (between start and end markers)
150+
const remaining = replaceCodeAntBlock(content, '').trim();
151+
if (!remaining || remaining === '#!/bin/sh') {
152+
// Nothing left — delete the file
153+
unlinkSync(hookPath);
154+
return { removed: true, message: 'Hook removed' };
155+
}
156+
157+
writeFileSync(hookPath, remaining + '\n', 'utf-8');
158+
chmodSync(hookPath, 0o755);
159+
return { removed: true, message: 'CodeAnt section removed from hook' };
160+
}

0 commit comments

Comments
 (0)