claude-dev-env 1.26.5 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/git_hooks_installer.mjs +245 -0
- package/bin/git_hooks_installer.test.mjs +208 -0
- package/bin/install.mjs +68 -1
- package/hooks/blocking/destructive_command_blocker.py +63 -10
- package/hooks/blocking/tdd_enforcer.py +52 -6
- package/hooks/blocking/test_destructive_command_blocker.py +169 -0
- package/hooks/blocking/test_tdd_enforcer.py +126 -3
- package/hooks/config/messages.py +1 -1
- package/hooks/config/test_messages.py +13 -0
- package/hooks/git-hooks/config.py +48 -0
- package/hooks/git-hooks/gate_utils.py +86 -0
- package/hooks/git-hooks/pre_commit.py +61 -0
- package/hooks/git-hooks/pre_push.py +146 -0
- package/hooks/git-hooks/test_config.py +24 -0
- package/hooks/git-hooks/test_gate_utils.py +225 -0
- package/hooks/git-hooks/test_pre_commit.py +179 -0
- package/hooks/git-hooks/test_pre_push.py +316 -0
- package/hooks/hooks.json +0 -5
- package/package.json +4 -1
- package/skills/bugteam/scripts/bugteam_code_rules_gate.py +150 -0
- package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +271 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { writeFileSync, copyFileSync, chmodSync, mkdirSync, renameSync, lstatSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
export const KNOWN_GIT_HOOK_NAMES = Object.freeze([
|
|
7
|
+
'pre-commit',
|
|
8
|
+
'pre-push',
|
|
9
|
+
'post-commit',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export const SHIM_DOCSTRING = (
|
|
13
|
+
'Generated by claude-dev-env install.mjs. '
|
|
14
|
+
+ 'Delegates to the matching Python module in this directory.'
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
const RENAME_RETRY_DELAYS_MS = Object.freeze([50, 100, 200]);
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
function renameSyncWithWindowsRetry(sourcePath, destinationPath) {
|
|
22
|
+
let lastError;
|
|
23
|
+
for (const delayMs of [0, ...RENAME_RETRY_DELAYS_MS]) {
|
|
24
|
+
if (delayMs > 0) {
|
|
25
|
+
const deadline = Date.now() + delayMs;
|
|
26
|
+
while (Date.now() < deadline) { /* spin */ }
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
renameSync(sourcePath, destinationPath);
|
|
30
|
+
return;
|
|
31
|
+
} catch (renameError) {
|
|
32
|
+
if (renameError.code !== 'EPERM' && renameError.code !== 'EBUSY') {
|
|
33
|
+
throw renameError;
|
|
34
|
+
}
|
|
35
|
+
lastError = renameError;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
renameSync(sourcePath, destinationPath);
|
|
40
|
+
return;
|
|
41
|
+
} catch (secondRenameError) {
|
|
42
|
+
const partialPath = destinationPath + '.partial';
|
|
43
|
+
try {
|
|
44
|
+
copyFileSync(sourcePath, partialPath);
|
|
45
|
+
if (process.platform !== 'win32') {
|
|
46
|
+
chmodSync(partialPath, 0o755);
|
|
47
|
+
}
|
|
48
|
+
renameSync(partialPath, destinationPath);
|
|
49
|
+
} catch (copyError) {
|
|
50
|
+
try { unlinkSync(partialPath); } catch { /* ENOENT-safe */ }
|
|
51
|
+
try { unlinkSync(sourcePath); } catch { /* ENOENT-safe */ }
|
|
52
|
+
throw new Error(
|
|
53
|
+
`claude-dev-env: atomic rename failed (${secondRenameError.message}) `
|
|
54
|
+
+ `and copy fallback also failed (${copyError.message})`,
|
|
55
|
+
{ cause: copyError },
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
unlinkSync(sourcePath);
|
|
60
|
+
} catch (cleanupError) {
|
|
61
|
+
if (cleanupError.code !== 'ENOENT') {
|
|
62
|
+
console.warn(`claude-dev-env: could not remove temp source after copy: ${cleanupError.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
function deriveModuleNameFromGitNativeHookName(gitNativeHookName) {
|
|
70
|
+
return gitNativeHookName.replaceAll('-', '_');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
function buildShimContent(pythonModuleName) {
|
|
75
|
+
const shimContentHeader = '#!/usr/bin/env python3\n';
|
|
76
|
+
return (
|
|
77
|
+
shimContentHeader
|
|
78
|
+
+ `"""${SHIM_DOCSTRING}"""\n`
|
|
79
|
+
+ 'import sys\n'
|
|
80
|
+
+ 'from pathlib import Path\n'
|
|
81
|
+
+ '\n'
|
|
82
|
+
+ 'shim_directory = Path(__file__).resolve().parent\n'
|
|
83
|
+
+ 'if str(shim_directory) not in sys.path:\n'
|
|
84
|
+
+ ' sys.path.insert(0, str(shim_directory))\n'
|
|
85
|
+
+ '\n'
|
|
86
|
+
+ `import ${pythonModuleName}\n`
|
|
87
|
+
+ '\n'
|
|
88
|
+
+ `sys.exit(${pythonModuleName}.main())\n`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
function ensureHooksDirectoryExists(gitHooksDirectory) {
|
|
94
|
+
let preExistingStat = null;
|
|
95
|
+
try {
|
|
96
|
+
preExistingStat = lstatSync(gitHooksDirectory);
|
|
97
|
+
} catch (statError) {
|
|
98
|
+
if (statError.code !== 'ENOENT') {
|
|
99
|
+
throw statError;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (preExistingStat !== null) {
|
|
103
|
+
if (preExistingStat.isSymbolicLink()) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`claude-dev-env: refusing to write hook shims — hooks directory is a symlink: ${gitHooksDirectory}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (!preExistingStat.isDirectory()) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`claude-dev-env: refusing to write hook shims — hooks path is not a directory: ${gitHooksDirectory}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
mkdirSync(gitHooksDirectory, { recursive: false });
|
|
116
|
+
const postMkdirStat = lstatSync(gitHooksDirectory);
|
|
117
|
+
if (postMkdirStat.isSymbolicLink()) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`claude-dev-env: refusing to write hook shims — hooks directory is a symlink: ${gitHooksDirectory}`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (!postMkdirStat.isDirectory()) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`claude-dev-env: refusing to write hook shims — hooks path is not a directory: ${gitHooksDirectory}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
export function writeGitHookShim({
|
|
131
|
+
gitHooksDirectory,
|
|
132
|
+
gitNativeHookName,
|
|
133
|
+
pythonModuleName,
|
|
134
|
+
}) {
|
|
135
|
+
ensureHooksDirectoryExists(gitHooksDirectory);
|
|
136
|
+
const shimPath = join(gitHooksDirectory, gitNativeHookName);
|
|
137
|
+
const shimTempPath = shimPath + '.tmp';
|
|
138
|
+
const shimContent = buildShimContent(pythonModuleName);
|
|
139
|
+
try { unlinkSync(shimTempPath); } catch { /* ENOENT-safe */ }
|
|
140
|
+
writeFileSync(shimTempPath, shimContent, { encoding: 'utf8', mode: 0o600 });
|
|
141
|
+
try {
|
|
142
|
+
const postWriteStat = lstatSync(shimTempPath);
|
|
143
|
+
if (postWriteStat.isSymbolicLink()) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`claude-dev-env: refusing to use temp shim — path became a symlink after write: ${shimTempPath}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
chmodSync(shimTempPath, 0o755);
|
|
150
|
+
} catch (chmodError) {
|
|
151
|
+
if (process.platform !== 'win32') {
|
|
152
|
+
throw chmodError;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
renameSyncWithWindowsRetry(shimTempPath, shimPath);
|
|
156
|
+
} catch (postWriteError) {
|
|
157
|
+
try { unlinkSync(shimTempPath); } catch { /* ENOENT-safe */ }
|
|
158
|
+
throw postWriteError;
|
|
159
|
+
}
|
|
160
|
+
return shimPath;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
export function writeAllGitHookShims({ gitHooksDirectory }) {
|
|
165
|
+
const createdShimPaths = [];
|
|
166
|
+
for (const gitNativeHookName of KNOWN_GIT_HOOK_NAMES) {
|
|
167
|
+
const pythonModuleName = deriveModuleNameFromGitNativeHookName(gitNativeHookName);
|
|
168
|
+
const shimPath = writeGitHookShim({
|
|
169
|
+
gitHooksDirectory,
|
|
170
|
+
gitNativeHookName,
|
|
171
|
+
pythonModuleName,
|
|
172
|
+
});
|
|
173
|
+
createdShimPaths.push(shimPath);
|
|
174
|
+
}
|
|
175
|
+
return createdShimPaths;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
function readCurrentGlobalHooksPathViaExecSync() {
|
|
180
|
+
try {
|
|
181
|
+
return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
|
|
182
|
+
encoding: 'utf8',
|
|
183
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
184
|
+
});
|
|
185
|
+
} catch (gitReadError) {
|
|
186
|
+
if (gitReadError.status === 1) {
|
|
187
|
+
return '';
|
|
188
|
+
}
|
|
189
|
+
throw gitReadError;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
function writeGlobalHooksPathViaExecSync(targetHooksPath) {
|
|
195
|
+
execFileSync('git', ['config', '--global', 'core.hooksPath', targetHooksPath], { stdio: 'ignore' });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
export function configureGlobalGitHooksPath({
|
|
200
|
+
targetGitHooksDirectory,
|
|
201
|
+
readCurrentHooksPath = readCurrentGlobalHooksPathViaExecSync,
|
|
202
|
+
writeHooksPath = writeGlobalHooksPathViaExecSync,
|
|
203
|
+
}) {
|
|
204
|
+
const normalizedTargetDirectory = targetGitHooksDirectory.replaceAll('\\', '/');
|
|
205
|
+
const currentRawValue = readCurrentHooksPath();
|
|
206
|
+
const currentTrimmedValue = (currentRawValue || '').trim();
|
|
207
|
+
const normalizedCurrentValue = currentTrimmedValue.replaceAll('\\', '/');
|
|
208
|
+
if (currentTrimmedValue === '') {
|
|
209
|
+
writeHooksPath(normalizedTargetDirectory);
|
|
210
|
+
return { action: 'set' };
|
|
211
|
+
}
|
|
212
|
+
if (normalizedCurrentValue === normalizedTargetDirectory) {
|
|
213
|
+
return { action: 'already-set' };
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
action: 'skip',
|
|
217
|
+
reason: (
|
|
218
|
+
`core.hooksPath is already set to "${currentTrimmedValue}". `
|
|
219
|
+
+ 'Skipping to avoid overriding an existing setup (e.g. husky, lefthook). '
|
|
220
|
+
+ `To adopt claude-dev-env hooks, manually run: `
|
|
221
|
+
+ `git config --global core.hooksPath ${JSON.stringify(normalizedTargetDirectory)}`
|
|
222
|
+
),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
export function installAllGitHooks({ claudeHomeDirectory }) {
|
|
228
|
+
const gitHooksDirectory = join(claudeHomeDirectory, 'hooks', 'git-hooks');
|
|
229
|
+
const createdShimPaths = writeAllGitHookShims({ gitHooksDirectory });
|
|
230
|
+
let hooksPathConfigurationResult;
|
|
231
|
+
try {
|
|
232
|
+
hooksPathConfigurationResult = configureGlobalGitHooksPath({
|
|
233
|
+
targetGitHooksDirectory: gitHooksDirectory,
|
|
234
|
+
});
|
|
235
|
+
} catch (gitConfigError) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
`claude-dev-env: shims written but git config --global core.hooksPath failed: ${gitConfigError.message}`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
gitHooksDirectory,
|
|
242
|
+
createdShimPaths,
|
|
243
|
+
hooksPathConfigurationResult,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import { strict as assert } from 'node:assert';
|
|
3
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, statSync, symlinkSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
writeGitHookShim,
|
|
9
|
+
writeAllGitHookShims,
|
|
10
|
+
configureGlobalGitHooksPath,
|
|
11
|
+
KNOWN_GIT_HOOK_NAMES,
|
|
12
|
+
} from './git_hooks_installer.mjs';
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
function makeTemporaryGitHooksDirectory() {
|
|
16
|
+
const temporaryRoot = mkdtempSync(join(tmpdir(), 'cdev-git-hooks-test-'));
|
|
17
|
+
const gitHooksDirectory = join(temporaryRoot, 'git-hooks');
|
|
18
|
+
mkdirSync(gitHooksDirectory, { recursive: true });
|
|
19
|
+
return { temporaryRoot, gitHooksDirectory };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
test('writeGitHookShim creates a file with the git-native name and imports the matching module', () => {
|
|
24
|
+
const { temporaryRoot, gitHooksDirectory } = makeTemporaryGitHooksDirectory();
|
|
25
|
+
try {
|
|
26
|
+
const shimPath = writeGitHookShim({
|
|
27
|
+
gitHooksDirectory,
|
|
28
|
+
gitNativeHookName: 'pre-commit',
|
|
29
|
+
pythonModuleName: 'pre_commit',
|
|
30
|
+
});
|
|
31
|
+
assert.equal(shimPath, join(gitHooksDirectory, 'pre-commit'));
|
|
32
|
+
assert.ok(existsSync(shimPath));
|
|
33
|
+
const shimContent = readFileSync(shimPath, 'utf8');
|
|
34
|
+
assert.ok(shimContent.startsWith('#!/usr/bin/env python3\n'));
|
|
35
|
+
assert.match(shimContent, /import\s+pre_commit/);
|
|
36
|
+
assert.match(shimContent, /pre_commit\.main\(\)/);
|
|
37
|
+
} finally {
|
|
38
|
+
rmSync(temporaryRoot, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
test('writeAllGitHookShims creates one shim per known hook name', () => {
|
|
44
|
+
const { temporaryRoot, gitHooksDirectory } = makeTemporaryGitHooksDirectory();
|
|
45
|
+
try {
|
|
46
|
+
const createdShimPaths = writeAllGitHookShims({ gitHooksDirectory });
|
|
47
|
+
assert.equal(createdShimPaths.length, KNOWN_GIT_HOOK_NAMES.length);
|
|
48
|
+
for (const gitNativeHookName of KNOWN_GIT_HOOK_NAMES) {
|
|
49
|
+
const expectedShimPath = join(gitHooksDirectory, gitNativeHookName);
|
|
50
|
+
assert.ok(
|
|
51
|
+
existsSync(expectedShimPath),
|
|
52
|
+
`missing shim at ${expectedShimPath}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(temporaryRoot, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
test('configureGlobalGitHooksPath sets the path when nothing is currently configured', () => {
|
|
62
|
+
const commandsRun = [];
|
|
63
|
+
const gitConfigReaderReturningEmpty = () => '';
|
|
64
|
+
const gitConfigWriter = (value) => {
|
|
65
|
+
commandsRun.push(['set', value]);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const result = configureGlobalGitHooksPath({
|
|
69
|
+
targetGitHooksDirectory: '/home/example/.claude/hooks/git-hooks',
|
|
70
|
+
readCurrentHooksPath: gitConfigReaderReturningEmpty,
|
|
71
|
+
writeHooksPath: gitConfigWriter,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
assert.equal(result.action, 'set');
|
|
75
|
+
assert.deepEqual(commandsRun, [['set', '/home/example/.claude/hooks/git-hooks']]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
test('configureGlobalGitHooksPath reports already-set when the current value matches the target', () => {
|
|
80
|
+
const commandsRun = [];
|
|
81
|
+
const gitConfigReaderReturningOurPath = () => '/home/example/.claude/hooks/git-hooks';
|
|
82
|
+
const gitConfigWriter = (value) => {
|
|
83
|
+
commandsRun.push(['set', value]);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const result = configureGlobalGitHooksPath({
|
|
87
|
+
targetGitHooksDirectory: '/home/example/.claude/hooks/git-hooks',
|
|
88
|
+
readCurrentHooksPath: gitConfigReaderReturningOurPath,
|
|
89
|
+
writeHooksPath: gitConfigWriter,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
assert.equal(result.action, 'already-set');
|
|
93
|
+
assert.deepEqual(commandsRun, []);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
test('configureGlobalGitHooksPath skips and reports reason when a foreign path is already configured', () => {
|
|
98
|
+
const commandsRun = [];
|
|
99
|
+
const gitConfigReaderReturningHuskyPath = () => '/home/example/project/.husky';
|
|
100
|
+
const gitConfigWriter = (value) => {
|
|
101
|
+
commandsRun.push(['set', value]);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const result = configureGlobalGitHooksPath({
|
|
105
|
+
targetGitHooksDirectory: '/home/example/.claude/hooks/git-hooks',
|
|
106
|
+
readCurrentHooksPath: gitConfigReaderReturningHuskyPath,
|
|
107
|
+
writeHooksPath: gitConfigWriter,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
assert.equal(result.action, 'skip');
|
|
111
|
+
assert.match(result.reason, /\.husky/);
|
|
112
|
+
assert.deepEqual(commandsRun, []);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
test('configureGlobalGitHooksPath normalizes trailing whitespace before comparing current to target', () => {
|
|
117
|
+
const gitConfigReaderReturningOurPathWithNewline = () => '/home/example/.claude/hooks/git-hooks\n';
|
|
118
|
+
const gitConfigWriter = () => {};
|
|
119
|
+
|
|
120
|
+
const result = configureGlobalGitHooksPath({
|
|
121
|
+
targetGitHooksDirectory: '/home/example/.claude/hooks/git-hooks',
|
|
122
|
+
readCurrentHooksPath: gitConfigReaderReturningOurPathWithNewline,
|
|
123
|
+
writeHooksPath: gitConfigWriter,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
assert.equal(result.action, 'already-set');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
test('configureGlobalGitHooksPath detects already-set when target has Windows backslashes and stored value has forward slashes', () => {
|
|
131
|
+
const commandsRun = [];
|
|
132
|
+
const gitConfigReaderReturningForwardSlashPath = () => 'C:/Users/example/.claude/hooks/git-hooks';
|
|
133
|
+
const gitConfigWriter = (value) => {
|
|
134
|
+
commandsRun.push(value);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const result = configureGlobalGitHooksPath({
|
|
138
|
+
targetGitHooksDirectory: 'C:\\Users\\example\\.claude\\hooks\\git-hooks',
|
|
139
|
+
readCurrentHooksPath: gitConfigReaderReturningForwardSlashPath,
|
|
140
|
+
writeHooksPath: gitConfigWriter,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
assert.equal(result.action, 'already-set');
|
|
144
|
+
assert.deepEqual(commandsRun, []);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
test('configureGlobalGitHooksPath writes forward-slash path when setting on Windows', () => {
|
|
149
|
+
const writtenPaths = [];
|
|
150
|
+
const gitConfigReaderReturningEmpty = () => '';
|
|
151
|
+
const gitConfigWriter = (value) => {
|
|
152
|
+
writtenPaths.push(value);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
configureGlobalGitHooksPath({
|
|
156
|
+
targetGitHooksDirectory: 'C:\\Users\\example\\.claude\\hooks\\git-hooks',
|
|
157
|
+
readCurrentHooksPath: gitConfigReaderReturningEmpty,
|
|
158
|
+
writeHooksPath: gitConfigWriter,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
assert.deepEqual(writtenPaths, ['C:/Users/example/.claude/hooks/git-hooks']);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
test('writeGitHookShim output is executable on POSIX (mode includes user-execute bit)', () => {
|
|
166
|
+
if (process.platform === 'win32') {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const { temporaryRoot, gitHooksDirectory } = makeTemporaryGitHooksDirectory();
|
|
170
|
+
try {
|
|
171
|
+
const shimPath = writeGitHookShim({
|
|
172
|
+
gitHooksDirectory,
|
|
173
|
+
gitNativeHookName: 'pre-commit',
|
|
174
|
+
pythonModuleName: 'pre_commit',
|
|
175
|
+
});
|
|
176
|
+
const stats = statSync(shimPath);
|
|
177
|
+
const userExecuteBit = 0o100;
|
|
178
|
+
assert.ok((stats.mode & userExecuteBit) !== 0, 'shim missing user-execute bit');
|
|
179
|
+
} finally {
|
|
180
|
+
rmSync(temporaryRoot, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
test('writeGitHookShim rejects hooks directory that is a symlink (loopP5c-5)', () => {
|
|
186
|
+
if (process.platform === 'win32') {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const temporaryRoot = mkdtempSync(join(tmpdir(), 'cdev-git-hooks-test-'));
|
|
190
|
+
try {
|
|
191
|
+
const realDirectory = join(temporaryRoot, 'real-hooks');
|
|
192
|
+
const symlinkPath = join(temporaryRoot, 'symlink-hooks');
|
|
193
|
+
mkdirSync(realDirectory, { recursive: true });
|
|
194
|
+
symlinkSync(realDirectory, symlinkPath);
|
|
195
|
+
assert.throws(
|
|
196
|
+
() => writeGitHookShim({
|
|
197
|
+
gitHooksDirectory: symlinkPath,
|
|
198
|
+
gitNativeHookName: 'pre-commit',
|
|
199
|
+
pythonModuleName: 'pre_commit',
|
|
200
|
+
}),
|
|
201
|
+
(err) => err.message.includes('symlink'),
|
|
202
|
+
);
|
|
203
|
+
} finally {
|
|
204
|
+
rmSync(temporaryRoot, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
|
package/bin/install.mjs
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync, unlinkSync, rmSync } from 'node:fs';
|
|
4
4
|
import { join, dirname, resolve, relative } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { execSync } from 'node:child_process';
|
|
6
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { createRequire } from 'node:module';
|
|
9
|
+
import { installAllGitHooks } from './git_hooks_installer.mjs';
|
|
9
10
|
|
|
10
11
|
const CLAUDE_HOME = join(homedir(), '.claude');
|
|
11
12
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
@@ -355,6 +356,27 @@ function install(selectedGroups) {
|
|
|
355
356
|
console.log(` Hook files: ${totalHooksCreated} new, ${totalHooksUpdated} updated`);
|
|
356
357
|
summary.hookGroups = totalHookGroups;
|
|
357
358
|
console.log(` Hook groups: ${totalHookGroups} merged into settings.json`);
|
|
359
|
+
|
|
360
|
+
console.warn(
|
|
361
|
+
' Warning: git hook installation sets core.hooksPath globally — '
|
|
362
|
+
+ 'the hook will run in every git repo on this machine.',
|
|
363
|
+
);
|
|
364
|
+
const gitHookInstallationResult = installAllGitHooks({ claudeHomeDirectory: CLAUDE_HOME });
|
|
365
|
+
summary.gitHooks = {
|
|
366
|
+
shimPaths: gitHookInstallationResult.createdShimPaths,
|
|
367
|
+
hooksPathConfiguration: gitHookInstallationResult.hooksPathConfigurationResult,
|
|
368
|
+
};
|
|
369
|
+
const hooksPathConfigurationAction = gitHookInstallationResult.hooksPathConfigurationResult.action;
|
|
370
|
+
if (hooksPathConfigurationAction === 'set') {
|
|
371
|
+
allInstalledFiles.push(...gitHookInstallationResult.createdShimPaths);
|
|
372
|
+
console.log(` Git hooks: configured core.hooksPath -> ${gitHookInstallationResult.gitHooksDirectory}`);
|
|
373
|
+
} else if (hooksPathConfigurationAction === 'already-set') {
|
|
374
|
+
allInstalledFiles.push(...gitHookInstallationResult.createdShimPaths);
|
|
375
|
+
console.log(' Git hooks: core.hooksPath already points to claude-dev-env, no change');
|
|
376
|
+
} else {
|
|
377
|
+
console.warn(` Git hooks: ${gitHookInstallationResult.hooksPathConfigurationResult.reason}`);
|
|
378
|
+
}
|
|
379
|
+
console.log(` Git hook shims: ${gitHookInstallationResult.createdShimPaths.length} files (pre-commit, pre-push, post-commit)`);
|
|
358
380
|
}
|
|
359
381
|
const claudeHubSource = join(PACKAGE_ROOT, 'CLAUDE.md');
|
|
360
382
|
if (existsSync(claudeHubSource)) {
|
|
@@ -387,6 +409,50 @@ function install(selectedGroups) {
|
|
|
387
409
|
console.log(` python: ${pythonCommand}\n`);
|
|
388
410
|
}
|
|
389
411
|
|
|
412
|
+
function normalizePathForComparison(rawPath) {
|
|
413
|
+
return rawPath.trim().replaceAll('\\', '/');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
function pathsAreEquivalent(storedPath, installedPath) {
|
|
418
|
+
const normalizedStored = normalizePathForComparison(storedPath);
|
|
419
|
+
const normalizedInstalled = normalizePathForComparison(installedPath);
|
|
420
|
+
if (normalizedStored === normalizedInstalled) {
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
const isMaybeCaseInsensitive = process.platform === 'win32' || process.platform === 'darwin';
|
|
424
|
+
return isMaybeCaseInsensitive && normalizedStored.toLowerCase() === normalizedInstalled.toLowerCase();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
function unsetGlobalGitHooksPathIfOurs() {
|
|
429
|
+
const installedGitHooksDirectory = join(CLAUDE_HOME, 'hooks', 'git-hooks');
|
|
430
|
+
let currentHooksPath = '';
|
|
431
|
+
try {
|
|
432
|
+
currentHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
|
|
433
|
+
encoding: 'utf8',
|
|
434
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
435
|
+
}).trim();
|
|
436
|
+
} catch (gitReadError) {
|
|
437
|
+
if (gitReadError.status === 1) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const stderrDetail = gitReadError.stderr ? ` stderr: ${gitReadError.stderr.trim()}` : '';
|
|
441
|
+
console.warn(` Git hooks: could not read core.hooksPath during uninstall (${gitReadError.message}${stderrDetail}) — hooks path may need manual cleanup`);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (!pathsAreEquivalent(currentHooksPath, installedGitHooksDirectory)) {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore' });
|
|
449
|
+
console.log(' Git hooks: unset global core.hooksPath');
|
|
450
|
+
} catch (gitUnsetError) {
|
|
451
|
+
console.warn(` Git hooks: could not unset core.hooksPath (${gitUnsetError.message})`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
|
|
390
456
|
function uninstall() {
|
|
391
457
|
console.log(`\nUninstalling ${PACKAGE_NAME}...\n`);
|
|
392
458
|
if (!existsSync(MANIFEST_FILE)) {
|
|
@@ -421,6 +487,7 @@ function uninstall() {
|
|
|
421
487
|
console.log(' Hook entries removed from settings.json');
|
|
422
488
|
}
|
|
423
489
|
}
|
|
490
|
+
unsetGlobalGitHooksPathIfOurs();
|
|
424
491
|
unlinkSync(MANIFEST_FILE);
|
|
425
492
|
for (const directory of [...CONTENT_DIRECTORIES, 'skills', 'hooks']) {
|
|
426
493
|
const dirPath = join(CLAUDE_HOME, directory);
|
|
@@ -3,7 +3,9 @@ import datetime
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
|
+
import subprocess
|
|
6
7
|
import sys
|
|
8
|
+
import tempfile
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
|
|
9
11
|
CLAUDE_DIRECTORY_PATH = os.path.normpath(os.path.expanduser("~/.claude"))
|
|
@@ -15,10 +17,59 @@ def gh_redirect_is_active() -> bool:
|
|
|
15
17
|
env_var_value = os.environ.get(GH_REDIRECT_ACTIVE_ENV_VAR, "").strip().lower()
|
|
16
18
|
return env_var_value in GH_REDIRECT_ACTIVE_TRUTHY_VALUES
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
def directory_is_ephemeral(directory_path: str) -> bool:
|
|
21
|
+
ephemeral_auto_allow_disabled_env_var = "CLAUDE_DESTRUCTIVE_DISABLE_EPHEMERAL_AUTO_ALLOW"
|
|
22
|
+
truthy_string_values = frozenset({"1", "true", "yes", "on"})
|
|
23
|
+
if os.environ.get(ephemeral_auto_allow_disabled_env_var, "").strip().lower() in truthy_string_values:
|
|
24
|
+
return False
|
|
25
|
+
forward_slash_normalized_directory_path = os.path.normpath(directory_path).replace("\\", "/").lower()
|
|
26
|
+
all_ephemeral_path_segments = ("/worktrees/", "/worktree/", "/tmp/", "/temp/")
|
|
27
|
+
for each_segment in all_ephemeral_path_segments:
|
|
28
|
+
if each_segment in forward_slash_normalized_directory_path + "/":
|
|
29
|
+
return True
|
|
30
|
+
system_temporary_root = os.path.normpath(tempfile.gettempdir()).replace("\\", "/").lower()
|
|
31
|
+
if forward_slash_normalized_directory_path.startswith(system_temporary_root + "/") or forward_slash_normalized_directory_path == system_temporary_root:
|
|
32
|
+
return True
|
|
33
|
+
try:
|
|
34
|
+
git_rev_parse_completion = subprocess.run(
|
|
35
|
+
["git", "rev-parse", "--git-dir"],
|
|
36
|
+
cwd=directory_path,
|
|
37
|
+
capture_output=True,
|
|
38
|
+
text=True,
|
|
39
|
+
check=False,
|
|
40
|
+
)
|
|
41
|
+
except (OSError, subprocess.SubprocessError):
|
|
42
|
+
return False
|
|
43
|
+
if git_rev_parse_completion.returncode != 0:
|
|
44
|
+
return False
|
|
45
|
+
git_directory_path_normalized = git_rev_parse_completion.stdout.strip().replace("\\", "/").lower()
|
|
46
|
+
return "/.git/worktrees/" in git_directory_path_normalized or "/worktrees/" in git_directory_path_normalized
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_allow_git_reset_hard_projects() -> list[str]:
|
|
50
|
+
allow_git_reset_hard_settings_key = "allowGitResetHardProjects"
|
|
51
|
+
settings_path = Path(CLAUDE_DIRECTORY_PATH) / "settings.json"
|
|
52
|
+
try:
|
|
53
|
+
raw_settings_text = settings_path.read_text(encoding="utf-8")
|
|
54
|
+
except OSError:
|
|
55
|
+
return []
|
|
56
|
+
try:
|
|
57
|
+
parsed_settings = json.loads(raw_settings_text)
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
return []
|
|
60
|
+
if not isinstance(parsed_settings, dict):
|
|
61
|
+
return []
|
|
62
|
+
hooks_section = parsed_settings.get("hooks")
|
|
63
|
+
if not isinstance(hooks_section, dict):
|
|
64
|
+
return []
|
|
65
|
+
raw_allow_list = hooks_section.get(allow_git_reset_hard_settings_key)
|
|
66
|
+
if not isinstance(raw_allow_list, list):
|
|
67
|
+
return []
|
|
68
|
+
return [
|
|
69
|
+
each_project_path
|
|
70
|
+
for each_project_path in raw_allow_list
|
|
71
|
+
if isinstance(each_project_path, str)
|
|
72
|
+
]
|
|
22
73
|
|
|
23
74
|
DESTRUCTIVE_BASH_PATTERNS = [
|
|
24
75
|
(re.compile(r'\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r', re.IGNORECASE), "rm -rf (destructive recursive forced delete)"),
|
|
@@ -136,15 +187,17 @@ def main() -> None:
|
|
|
136
187
|
|
|
137
188
|
# Allow git reset --hard in explicitly approved projects (case-insensitive for Windows drive letters)
|
|
138
189
|
if matched_description is not None and "git reset --hard" in matched_description:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
190
|
+
current_working_directory = os.getcwd()
|
|
191
|
+
if directory_is_ephemeral(current_working_directory):
|
|
192
|
+
sys.exit(0)
|
|
193
|
+
current_working_directory_lowercased = os.path.normpath(current_working_directory).lower()
|
|
194
|
+
for allowed_project in load_allow_git_reset_hard_projects():
|
|
195
|
+
allowed_project_lowercased = os.path.normpath(allowed_project).lower()
|
|
196
|
+
if current_working_directory_lowercased.startswith(allowed_project_lowercased):
|
|
144
197
|
sys.exit(0)
|
|
145
198
|
# Also check the cd target in the command itself
|
|
146
199
|
for path_match in re.findall(r'cd\s+"([^"]+)"', command):
|
|
147
|
-
if os.path.normpath(path_match).lower().startswith(
|
|
200
|
+
if os.path.normpath(path_match).lower().startswith(allowed_project_lowercased):
|
|
148
201
|
sys.exit(0)
|
|
149
202
|
|
|
150
203
|
if matched_description is not None:
|