@abarcenas/ai-workflow-template 1.28.0 → 1.32.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.
@@ -0,0 +1,388 @@
1
+ // ═════════════════════════════════════════════════════════════════════════════
2
+ // discover.test.js — Unit tests for the discover module
3
+ // ═════════════════════════════════════════════════════════════════════════════
4
+
5
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'
6
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'
7
+ import { join } from 'path'
8
+ import { tmpdir } from 'os'
9
+
10
+ // ── Mock utils: environment-dependent functions; keep fs helpers real ─────
11
+
12
+ /** Mutable variable so the mock factory can return different roots per test */
13
+ let mockConsumerRoot = ''
14
+
15
+ vi.mock('./utils.js', async (importOriginal) => {
16
+ const actual = await importOriginal()
17
+ return {
18
+ ...actual,
19
+ getConsumerRoot: () => mockConsumerRoot,
20
+ getNodeVersion: vi.fn(() => 20),
21
+ isCI: vi.fn(() => false),
22
+ }
23
+ })
24
+
25
+ import { discover } from './discover.js'
26
+ import { HOOK_MARKER, TEMPLATE_HOOKS } from './constants.js'
27
+ import { getNodeVersion, isCI } from './utils.js'
28
+
29
+ // ── Test helpers ──────────────────────────────────────────────────────────
30
+
31
+ const tempDirs = new Set()
32
+
33
+ /**
34
+ * Creates a temporary project directory with a configurable structure.
35
+ *
36
+ * @param {object} opts
37
+ * @param {boolean} [opts.git=true] – Create a `.git/` directory
38
+ * @param {boolean} [opts.huskyDir=false] – Create a `.husky/` directory
39
+ * @param {object|boolean} [opts.packageJson=true] – `false` = skip, object = custom
40
+ * @param {object} [opts.hooks={}] – Map of hook filename → content
41
+ * @returns {string} Path to the created temp directory.
42
+ */
43
+ function createProject(opts = {}) {
44
+ const {
45
+ git = true,
46
+ huskyDir = false,
47
+ packageJson = true,
48
+ hooks = {},
49
+ } = opts
50
+
51
+ const dir = mkdtempSync(join(tmpdir(), 'discover-test-'))
52
+ tempDirs.add(dir)
53
+
54
+ if (git) mkdirSync(join(dir, '.git'), { recursive: true })
55
+ if (huskyDir) mkdirSync(join(dir, '.husky'), { recursive: true })
56
+ if (packageJson) {
57
+ const pkg =
58
+ typeof packageJson === 'object' ? packageJson : { name: 'test-pkg' }
59
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(pkg))
60
+ }
61
+ for (const [name, content] of Object.entries(hooks)) {
62
+ const hookDir = join(dir, '.husky')
63
+ if (!existsSync(hookDir)) mkdirSync(hookDir, { recursive: true })
64
+ writeFileSync(join(hookDir, name), content)
65
+ }
66
+
67
+ return dir
68
+ }
69
+
70
+ function cleanupTempDirs() {
71
+ for (const dir of tempDirs) {
72
+ try {
73
+ rmSync(dir, { recursive: true, force: true })
74
+ } catch {
75
+ /* best-effort cleanup */
76
+ }
77
+ }
78
+ tempDirs.clear()
79
+ }
80
+
81
+ // ── Tests ─────────────────────────────────────────────────────────────────
82
+
83
+ describe('discover()', () => {
84
+ beforeEach(() => {
85
+ vi.mocked(getNodeVersion).mockReturnValue(20)
86
+ vi.mocked(isCI).mockReturnValue(false)
87
+ })
88
+
89
+ afterEach(() => {
90
+ cleanupTempDirs()
91
+ })
92
+
93
+ // ── 1. Fresh project (no .husky/) ──────────────────────────────────────
94
+
95
+ it('should detect a fresh project with git but no .husky/ directory', async () => {
96
+ const dir = createProject({ git: true, huskyDir: false })
97
+ mockConsumerRoot = dir
98
+
99
+ const ctx = await discover()
100
+
101
+ expect(ctx.consumerRoot).toBe(dir)
102
+ expect(ctx.hasGit).toBe(true)
103
+ expect(ctx.hasHuskyDir).toBe(false)
104
+ for (const name of Object.keys(TEMPLATE_HOOKS)) {
105
+ expect(ctx.existingHooks[name].exists).toBe(false)
106
+ }
107
+ expect(ctx.hasPackageJson).toBe(true)
108
+ expect(ctx.isCI).toBe(false)
109
+ expect(ctx.nodeVersion).toBe(20)
110
+ expect(ctx.dryRun).toBe(false)
111
+ })
112
+
113
+ // ── 2. Project with .husky/ but no hooks ───────────────────────────────
114
+
115
+ it('should detect .husky/ directory but no hook files inside', async () => {
116
+ const dir = createProject({ huskyDir: true, hooks: {} })
117
+ mockConsumerRoot = dir
118
+
119
+ const ctx = await discover()
120
+
121
+ expect(ctx.hasHuskyDir).toBe(true)
122
+ for (const name of Object.keys(TEMPLATE_HOOKS)) {
123
+ expect(ctx.existingHooks[name].exists).toBe(false)
124
+ expect(ctx.existingHooks[name].content).toBeNull()
125
+ expect(ctx.existingHooks[name].isManaged).toBe(false)
126
+ }
127
+ })
128
+
129
+ // ── 3. Existing unmanaged hooks ────────────────────────────────────────
130
+
131
+ it('should detect unmanaged hooks (first line is NOT the HOOK_MARKER)', async () => {
132
+ const dir = createProject({
133
+ huskyDir: true,
134
+ hooks: { 'pre-commit': '#!/bin/sh\necho "custom hook"\n' },
135
+ })
136
+ mockConsumerRoot = dir
137
+
138
+ const ctx = await discover()
139
+
140
+ const h = ctx.existingHooks['pre-commit']
141
+ expect(h.exists).toBe(true)
142
+ expect(h.content).toBe('#!/bin/sh\necho "custom hook"\n')
143
+ expect(h.isManaged).toBe(false)
144
+ })
145
+
146
+ // ── 4. Existing managed hooks ──────────────────────────────────────────
147
+
148
+ it('should detect managed hooks (first line matches HOOK_MARKER)', async () => {
149
+ const dir = createProject({
150
+ huskyDir: true,
151
+ hooks: { 'pre-commit': `${HOOK_MARKER}\n\necho "managed hook"\n` },
152
+ })
153
+ mockConsumerRoot = dir
154
+
155
+ const ctx = await discover()
156
+
157
+ const h = ctx.existingHooks['pre-commit']
158
+ expect(h.exists).toBe(true)
159
+ expect(h.isManaged).toBe(true)
160
+ })
161
+
162
+ // ── 5. No package.json ─────────────────────────────────────────────────
163
+
164
+ it('should handle a project with no package.json', async () => {
165
+ const dir = createProject({
166
+ git: true,
167
+ huskyDir: false,
168
+ packageJson: false,
169
+ })
170
+ mockConsumerRoot = dir
171
+
172
+ const ctx = await discover()
173
+
174
+ expect(ctx.hasPackageJson).toBe(false)
175
+ expect(ctx.consumerPackageJson).toBeNull()
176
+ expect(ctx.existingPrepare).toBeNull()
177
+ })
178
+
179
+ // ── 6. Package.json with no prepare script ─────────────────────────────
180
+
181
+ it('should set existingPrepare=null when prepare script is missing', async () => {
182
+ const dir = createProject({
183
+ packageJson: { name: 'test', scripts: {} },
184
+ })
185
+ mockConsumerRoot = dir
186
+
187
+ const ctx = await discover()
188
+
189
+ expect(ctx.existingPrepare).toBeNull()
190
+ })
191
+
192
+ // ── 7. Simple prepare script ───────────────────────────────────────────
193
+
194
+ it('should detect a simple prepare script and classify as simple', async () => {
195
+ const dir = createProject({
196
+ packageJson: { scripts: { prepare: 'npm run build' } },
197
+ })
198
+ mockConsumerRoot = dir
199
+
200
+ const ctx = await discover()
201
+
202
+ expect(ctx.existingPrepare).toBe('npm run build')
203
+ expect(ctx.prepareIsSimple).toBe(true)
204
+ })
205
+
206
+ // ── 8. Complex prepare (multi-line) ────────────────────────────────────
207
+
208
+ it('should classify a multi-line prepare script as complex', async () => {
209
+ const dir = createProject({
210
+ packageJson: { scripts: { prepare: 'echo "line1"\necho "line2"' } },
211
+ })
212
+ mockConsumerRoot = dir
213
+
214
+ const ctx = await discover()
215
+
216
+ expect(ctx.prepareIsSimple).toBe(false)
217
+ })
218
+
219
+ // ── 9. Prepare already containing "husky" ──────────────────────────────
220
+
221
+ it('should detect prepare script set to exactly "husky"', async () => {
222
+ const dir = createProject({
223
+ packageJson: { scripts: { prepare: 'husky' } },
224
+ })
225
+ mockConsumerRoot = dir
226
+
227
+ const ctx = await discover()
228
+
229
+ expect(ctx.existingPrepare).toBe('husky')
230
+ expect(ctx.prepareIsSimple).toBe(true)
231
+ })
232
+
233
+ // ── 10. CI environment ─────────────────────────────────────────────────
234
+
235
+ it('should detect CI environment when isCI() returns true', async () => {
236
+ vi.mocked(isCI).mockReturnValue(true)
237
+ const dir = createProject()
238
+ mockConsumerRoot = dir
239
+
240
+ const ctx = await discover()
241
+
242
+ expect(ctx.isCI).toBe(true)
243
+ })
244
+
245
+ // ── 11. Non-CI environment ─────────────────────────────────────────────
246
+
247
+ it('should detect non-CI environment when isCI() returns false', async () => {
248
+ vi.mocked(isCI).mockReturnValue(false)
249
+ const dir = createProject()
250
+ mockConsumerRoot = dir
251
+
252
+ const ctx = await discover()
253
+
254
+ expect(ctx.isCI).toBe(false)
255
+ })
256
+
257
+ // ── 12. Node version detection ─────────────────────────────────────────
258
+
259
+ it('should report the correct Node.js major version', async () => {
260
+ vi.mocked(getNodeVersion).mockReturnValue(18)
261
+ const dir = createProject()
262
+ mockConsumerRoot = dir
263
+
264
+ const ctx = await discover()
265
+
266
+ expect(ctx.nodeVersion).toBe(18)
267
+ })
268
+
269
+ // ── 13. dryRun flag passthrough ────────────────────────────────────────
270
+
271
+ it('should set dryRun=true when the dryRun flag is passed', async () => {
272
+ const dir = createProject()
273
+ mockConsumerRoot = dir
274
+
275
+ const ctx = await discover({ dryRun: true })
276
+
277
+ expect(ctx.dryRun).toBe(true)
278
+ })
279
+
280
+ it('should set dryRun=false by default', async () => {
281
+ const dir = createProject()
282
+ mockConsumerRoot = dir
283
+
284
+ const ctx = await discover()
285
+
286
+ expect(ctx.dryRun).toBe(false)
287
+ })
288
+
289
+ // ── 14. Complex prepare with `||` ──────────────────────────────────────
290
+
291
+ it('should classify a prepare script containing || as complex', async () => {
292
+ const dir = createProject({
293
+ packageJson: { scripts: { prepare: 'cmd1 || cmd2' } },
294
+ })
295
+ mockConsumerRoot = dir
296
+
297
+ const ctx = await discover()
298
+
299
+ expect(ctx.prepareIsSimple).toBe(false)
300
+ })
301
+
302
+ // ── 15. Complex prepare with `;` ───────────────────────────────────────
303
+
304
+ it('should classify a prepare script containing ; as complex', async () => {
305
+ const dir = createProject({
306
+ packageJson: { scripts: { prepare: 'cmd1; cmd2' } },
307
+ })
308
+ mockConsumerRoot = dir
309
+
310
+ const ctx = await discover()
311
+
312
+ expect(ctx.prepareIsSimple).toBe(false)
313
+ })
314
+
315
+ // ── 16. No git repository ──────────────────────────────────────────────
316
+
317
+ it('should set hasGit=false when no .git/ directory exists', async () => {
318
+ const dir = createProject({ git: false })
319
+ mockConsumerRoot = dir
320
+
321
+ const ctx = await discover()
322
+
323
+ expect(ctx.hasGit).toBe(false)
324
+ })
325
+
326
+ // ── 17. Both hooks detected ────────────────────────────────────────────
327
+
328
+ it('should detect both pre-commit and post-merge hooks', async () => {
329
+ const dir = createProject({
330
+ huskyDir: true,
331
+ hooks: {
332
+ 'pre-commit': `${HOOK_MARKER}\n\necho "pre"\n`,
333
+ 'post-merge': `${HOOK_MARKER}\n#!/bin/sh\necho "post"\n`,
334
+ },
335
+ })
336
+ mockConsumerRoot = dir
337
+
338
+ const ctx = await discover()
339
+
340
+ expect(ctx.existingHooks['pre-commit'].exists).toBe(true)
341
+ expect(ctx.existingHooks['post-merge'].exists).toBe(true)
342
+ expect(ctx.existingHooks['post-merge'].isManaged).toBe(true)
343
+ })
344
+
345
+ // ── 18. Empty prepare string ───────────────────────────────────────────
346
+
347
+ it('should treat an empty string prepare script as null', async () => {
348
+ const dir = createProject({
349
+ packageJson: { scripts: { prepare: '' } },
350
+ })
351
+ mockConsumerRoot = dir
352
+
353
+ const ctx = await discover()
354
+
355
+ expect(ctx.existingPrepare).toBeNull()
356
+ })
357
+
358
+ // ── 19. Consumer package JSON content ──────────────────────────────────
359
+
360
+ it('should correctly parse consumer package.json content', async () => {
361
+ const pkg = { name: 'test-pkg', version: '1.0.0', scripts: { test: 'vitest' } }
362
+ const dir = createProject({ packageJson: pkg })
363
+ mockConsumerRoot = dir
364
+
365
+ const ctx = await discover()
366
+
367
+ expect(ctx.hasPackageJson).toBe(true)
368
+ expect(ctx.consumerPackageJson).toEqual(pkg)
369
+ })
370
+
371
+ // ── 20. Post-merge hook content ────────────────────────────────────────
372
+
373
+ it('should correctly detect post-merge hook content and managed flag', async () => {
374
+ const content = `${HOOK_MARKER}\n#!/bin/sh\necho "custom post-merge"\n`
375
+ const dir = createProject({
376
+ huskyDir: true,
377
+ hooks: { 'post-merge': content },
378
+ })
379
+ mockConsumerRoot = dir
380
+
381
+ const ctx = await discover()
382
+
383
+ const h = ctx.existingHooks['post-merge']
384
+ expect(h.exists).toBe(true)
385
+ expect(h.content).toBe(content)
386
+ expect(h.isManaged).toBe(true)
387
+ })
388
+ })
@@ -0,0 +1,261 @@
1
+ // ═════════════════════════════════════════════════════════════════════════════
2
+ // hooks.js — Hook installation and merging for the setup command pipeline
3
+ // ═════════════════════════════════════════════════════════════════════════════
4
+ //
5
+ // Implements the 6-case merge algorithm for installing template git hooks
6
+ // (pre-commit, post-merge) into a consumer project's .husky/ directory:
7
+ //
8
+ // Case A : No .husky/ directory → Create + write
9
+ // Case B : No existing hook file → Write
10
+ // Case C : Existing, isManaged (= ours) → Overwrite (idempotent re-run)
11
+ // Case D : Existing, not ours, --force → Backup + overwrite
12
+ // Case E : Existing, not ours, no --force → Append with separator
13
+ // Case F : dry-run mode → Log only, no writes
14
+ //
15
+ // ═════════════════════════════════════════════════════════════════════════════
16
+
17
+ import { resolve } from 'path'
18
+
19
+ import { HOOK_MARKER, HOOK_MERGE_SEPARATOR, TEMPLATE_HOOKS } from './constants.js'
20
+ import {
21
+ chmodX,
22
+ ensureDir,
23
+ safeReadFile,
24
+ safeWriteFile,
25
+ } from './utils.js'
26
+ import {
27
+ dryRunBanner,
28
+ step,
29
+ stepError,
30
+ stepSuccess,
31
+ stepWarn,
32
+ } from './ui.js'
33
+
34
+ // ── Content builder ──────────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Builds the final content for a hook file.
38
+ *
39
+ * The marker comment (`HOOK_MARKER`) is placed on the very first line for
40
+ * idempotency detection. The template hook content from `TEMPLATE_HOOKS`
41
+ * follows immediately.
42
+ *
43
+ * @param {string} hookName - Key from `TEMPLATE_HOOKS` (e.g. `'pre-commit'`).
44
+ * @returns {string} Complete file content with the marker as line 1.
45
+ *
46
+ * @example
47
+ * buildHookContent('pre-commit')
48
+ * // → "# Managed by @abarcenas/ai-workflow-template setup\nnode scripts/bump-version.js\n..."
49
+ */
50
+ function buildHookContent(hookName) {
51
+ const template = TEMPLATE_HOOKS[hookName]
52
+ // The `content` field is always a string in our constants (pre-composed).
53
+ const raw = template.content
54
+ // Marker is first line, then content.
55
+ return HOOK_MARKER + '\n' + raw
56
+ }
57
+
58
+ // ── Hook file path helper ────────────────────────────────────────────────────
59
+
60
+ /**
61
+ * Returns the absolute path to a hook file inside the consumer's `.husky/`
62
+ * directory.
63
+ *
64
+ * @param {string} consumerRoot - Consumer project root.
65
+ * @param {string} hookName - Hook filename (e.g. `'pre-commit'`).
66
+ * @returns {string} Absolute path.
67
+ */
68
+ function hookPath(consumerRoot, hookName) {
69
+ return resolve(consumerRoot, '.husky', hookName)
70
+ }
71
+
72
+ // ── Dry-run result helper ────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Determines what action *would* be taken for a hook, without performing any
76
+ * writes. Used exclusively by dry-run mode (Case F) and also provides the
77
+ * human-readable message for the result entry.
78
+ *
79
+ * @param {object} context - Context object from `discover.js`.
80
+ * @param {string} hookName - Key from `TEMPLATE_HOOKS`.
81
+ * @returns {{ action: string, message: string }}
82
+ */
83
+ function dryRunResult(context, hookName) {
84
+ const hook = context.existingHooks?.[hookName]
85
+ const hasHuskyDir = !!context.hasHuskyDir
86
+ const existing = hook?.exists ?? false
87
+ const isManaged = hook?.isManaged ?? false
88
+ const force = !!(context.force || context.flags?.force)
89
+ const desc = TEMPLATE_HOOKS[hookName]?.description ?? ''
90
+
91
+ if (!hasHuskyDir) {
92
+ return {
93
+ action: 'dry-run',
94
+ message: `Would create .husky/ and ${hookName} hook (${desc})`,
95
+ }
96
+ }
97
+ if (!existing) {
98
+ return {
99
+ action: 'dry-run',
100
+ message: `Would write ${hookName} hook (${desc})`,
101
+ }
102
+ }
103
+ if (isManaged) {
104
+ return {
105
+ action: 'dry-run',
106
+ message: `Would overwrite existing ${hookName} hook (idempotent update, ${desc})`,
107
+ }
108
+ }
109
+ if (force) {
110
+ return {
111
+ action: 'dry-run',
112
+ message: `Would back up existing ${hookName} hook and overwrite with force (${desc})`,
113
+ }
114
+ }
115
+ // Case E equivalent in dry-run
116
+ return {
117
+ action: 'dry-run',
118
+ message: `Would append to existing ${hookName} hook (${desc})`,
119
+ }
120
+ }
121
+
122
+ // ── Main export ──────────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Installs or merges template hooks into the consumer project's `.husky/`
126
+ * directory.
127
+ *
128
+ * Implements the **6-case merge algorithm** per the setup command design
129
+ * (§4.3). Each hook in `TEMPLATE_HOOKS` (pre-commit, post-merge) is processed
130
+ * independently:
131
+ *
132
+ * | Case | Condition | Action | Result key |
133
+ * |------|----------------------------------------------|-----------------------------|-------------|
134
+ * | A | No `.husky/` directory | Create dir + write hook | `created` |
135
+ * | B | No existing hook file | Write hook file | `created` |
136
+ * | C | Hook exists + `isManaged` (= ours) | Overwrite (idempotent) | `overwritten` |
137
+ * | D | Hook exists + not ours + `--force` | Backup `.bak` + overwrite | `overwritten` |
138
+ * | E | Hook exists + not ours + no `--force` | Append with separator | `merged` |
139
+ * | F | `context.dryRun` is `true` | Log only, no writes | `dry-run` |
140
+ *
141
+ * Every operation is wrapped in a try/catch. A failure for one hook does not
142
+ * prevent the other from being processed.
143
+ *
144
+ * @param {object} context - Context object from `discover.js`.
145
+ * @param {string} context.consumerRoot - Absolute path to consumer project root.
146
+ * @param {boolean} [context.hasHuskyDir] - Whether `.husky/` already exists.
147
+ * @param {Record<string, {exists: boolean, content: string|null, isManaged: boolean}>}
148
+ * [context.existingHooks] - Descriptors for each hook.
149
+ * @param {boolean} [context.dryRun] - When `true`, no files are modified.
150
+ * @param {boolean} [context.force] - When `true`, existing hooks are overwritten.
151
+ * @param {{force?: boolean}} [context.flags] - Parsed CLI flags (alternative to
152
+ * `context.force`).
153
+ * @returns {Promise<Array<{hook: string, action: string, message: string}>>}
154
+ * A result entry for each hook in `TEMPLATE_HOOKS`.
155
+ *
156
+ * @example
157
+ * ```js
158
+ * const ctx = await discover({ dryRun: true })
159
+ * const results = await installHooks(ctx)
160
+ * // → [{ hook: 'pre-commit', action: 'dry-run', message: '...' }, ...]
161
+ * ```
162
+ */
163
+ export async function installHooks(context) {
164
+ const hookNames = Object.keys(TEMPLATE_HOOKS)
165
+ const results = []
166
+
167
+ // ── Case F: dry-run mode ────────────────────────────────────────────
168
+ if (context.dryRun) {
169
+ dryRunBanner()
170
+
171
+ for (const hookName of hookNames) {
172
+ const r = dryRunResult(context, hookName)
173
+ step(r.message)
174
+ results.push({ hook: hookName, action: 'dry-run', message: r.message })
175
+ }
176
+
177
+ return results
178
+ }
179
+
180
+ // ── Process each hook ───────────────────────────────────────────────
181
+ for (const hookName of hookNames) {
182
+ const hookInfo = context.existingHooks?.[hookName]
183
+ const hasHuskyDir = !!context.hasHuskyDir
184
+ const existing = hookInfo?.exists ?? false
185
+ const isManaged = hookInfo?.isManaged ?? false
186
+ const force = !!(context.force || context.flags?.force)
187
+ const desc = TEMPLATE_HOOKS[hookName]?.description ?? ''
188
+ const target = hookPath(context.consumerRoot, hookName)
189
+ const content = buildHookContent(hookName)
190
+
191
+ try {
192
+ // ── Case A: No .husky/ directory — create it and write hook ──
193
+ if (!hasHuskyDir) {
194
+ ensureDir(resolve(context.consumerRoot, '.husky'))
195
+ safeWriteFile(target, content)
196
+ chmodX(target)
197
+ const msg = `${hookName} hook created (\u2713 ${desc})`
198
+ stepSuccess(msg)
199
+ results.push({ hook: hookName, action: 'created', message: msg })
200
+ continue
201
+ }
202
+
203
+ // ── Case B: .husky/ exists but no hook file ─────────────────
204
+ if (!existing) {
205
+ safeWriteFile(target, content)
206
+ chmodX(target)
207
+ const msg = `${hookName} hook created (\u2713 ${desc})`
208
+ stepSuccess(msg)
209
+ results.push({ hook: hookName, action: 'created', message: msg })
210
+ continue
211
+ }
212
+
213
+ // ── Case C: Hook exists AND is managed by us — overwrite ────
214
+ if (isManaged) {
215
+ safeWriteFile(target, content)
216
+ chmodX(target)
217
+ const msg = `${hookName} hook overwritten (idempotent update, ${desc})`
218
+ stepSuccess(msg)
219
+ results.push({ hook: hookName, action: 'overwritten', message: msg })
220
+ continue
221
+ }
222
+
223
+ // ── Case D: Hook exists, not ours, --force — back up + overwrite
224
+ if (force) {
225
+ const bakPath = target + '.bak'
226
+ const originalContent = safeReadFile(target)
227
+ safeWriteFile(bakPath, originalContent ?? '')
228
+ safeWriteFile(target, content)
229
+ chmodX(target)
230
+ const msg =
231
+ `${hookName} hook overwritten (original backed up to .bak, ${desc})`
232
+ stepSuccess(msg)
233
+ results.push({ hook: hookName, action: 'overwritten', message: msg })
234
+ continue
235
+ }
236
+
237
+ // ── Case E: Hook exists, not ours, no --force — append ──────
238
+ const existingContent = safeReadFile(target)
239
+ const mergedContent =
240
+ (existingContent ?? '') + '\n' + HOOK_MERGE_SEPARATOR + '\n' + content
241
+ safeWriteFile(target, mergedContent)
242
+ chmodX(target)
243
+ const msg = `${hookName} hook merged with existing content (${desc})`
244
+ stepSuccess(msg)
245
+ results.push({ hook: hookName, action: 'merged', message: msg })
246
+ } catch (err) {
247
+ // Any error during file operations (permission denied, .husky/
248
+ // is a file not a directory, read-only filesystem, etc.) is
249
+ // caught here — non-fatal for the other hook.
250
+ const errMsg = `${hookName} hook: ${err.message}`
251
+ stepError(errMsg)
252
+ results.push({
253
+ hook: hookName,
254
+ action: 'skipped',
255
+ message: errMsg,
256
+ })
257
+ }
258
+ }
259
+
260
+ return results
261
+ }