From 8f0a5aee42c3a092bcc0a0aaeabd565cd096ed4c Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sat, 14 Feb 2026 14:59:31 +0000 Subject: [PATCH 1/7] docs: add smart commit analysis to changelog Because users need to know about the new commit-msg hook feature that automatically adds AI trailers. AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33dd9cd4..d6f2f9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Smart commit analysis** — commit-msg hook that automatically analyzes commit messages and adds AI-* trailers: + - Detects memory type (decision, gotcha, convention, fact) from heuristic patterns + - Infers tags from conventional commit scope and staged file paths + - Adds AI-Agent, AI-Model, AI-Confidence, AI-Lifecycle, AI-Memory-Id trailers + - Configurable via `.git-mem.json` (autoAnalyze, inferTags, requireType, defaultLifecycle) + ## [0.3.0] - 2026-02-12 ### Added From 3bf8016a3273559fea0f2a299f5938e98b66ebbc Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sat, 14 Feb 2026 15:00:15 +0000 Subject: [PATCH 2/7] docs: test smart commit analysis Using the commit-msg hook because it automatically adds AI trailers. AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 39d3ed6f..2372524f 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,4 @@ See the [Getting Started Guide](./docs/getting-started.md) for full CLI and MCP ## License MIT +# Test From 5f1bedbf5dd63d4f573c9c70f4cb42b1f115f046 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sat, 14 Feb 2026 15:00:41 +0000 Subject: [PATCH 3/7] Revert "docs: test smart commit analysis" This reverts commit 3bf8016a3273559fea0f2a299f5938e98b66ebbc. --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 2372524f..39d3ed6f 100644 --- a/README.md +++ b/README.md @@ -39,4 +39,3 @@ See the [Getting Started Guide](./docs/getting-started.md) for full CLI and MCP ## License MIT -# Test From 2ebd2e3f439ce448f21efdcf20af9ca0afc3b2e2 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sat, 14 Feb 2026 15:05:48 +0000 Subject: [PATCH 4/7] fix: install all hooks by default and fix hook chaining - Remove --hooks flag from init, hooks now install automatically - Change commit-msg skip check from AI-Agent to AI-Memory-Id - This allows prepare-commit-msg and commit-msg to work together - Bump commit-msg hook to v3 Because both hooks need to run: prepare-commit-msg adds basic trailers, commit-msg adds the full analysis (Decision/Gotcha/Convention, Tags, etc). AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Decision: both hooks need to run: prepare-commit-msg adds basic trailers, AI-Confidence: medium AI-Tags: application, handlers, commands, hooks, tests, integration, unit, pattern:because-clause AI-Lifecycle: project AI-Memory-Id: d8ed5636 --- src/application/handlers/CommitMsgHandler.ts | 6 +++--- src/cli.ts | 3 +-- src/commands/init.ts | 6 +++--- src/hooks/commit-msg.ts | 6 +++--- tests/integration/hooks/hook-commit-msg.test.ts | 10 +++++----- tests/unit/hooks/commit-msg.test.ts | 6 +++--- 6 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/application/handlers/CommitMsgHandler.ts b/src/application/handlers/CommitMsgHandler.ts index 63000303..6b3d7f00 100644 --- a/src/application/handlers/CommitMsgHandler.ts +++ b/src/application/handlers/CommitMsgHandler.ts @@ -42,9 +42,9 @@ export class CommitMsgHandler implements IEventHandler { // 1. Read the commit message file const message = readFileSync(event.commitMsgPath, 'utf8'); - // 2. Check if AI-Agent trailer already exists (avoid duplicates from prepare-commit-msg) - if (message.includes('AI-Agent:')) { - this.logger.debug('AI trailers already present, skipping analysis'); + // 2. Check if full analysis already done (AI-Memory-Id is unique to commit-msg) + if (message.includes('AI-Memory-Id:')) { + this.logger.debug('Full analysis already done, skipping'); return { handler: 'CommitMsgHandler', success: true }; } diff --git a/src/cli.ts b/src/cli.ts index fb2588f6..0c466f19 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,8 +26,7 @@ program .command('init') .description('Set up git-mem: hooks, MCP config, .gitignore') .option('-y, --yes', 'Accept defaults without prompting') - .option('--hooks', 'Install prepare-commit-msg git hook for AI-Agent trailers') - .option('--uninstall-hooks', 'Remove the prepare-commit-msg git hook') + .option('--uninstall-hooks', 'Remove all git-mem hooks') .option('--extract', 'Extract knowledge from commit history (use with --yes)') .option('--commit-count ', 'Number of commits to extract (default: 10)', parseInt) .action((options) => initCommand(options, logger)); diff --git a/src/commands/init.ts b/src/commands/init.ts index eea3ed27..91ac6d8d 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -26,7 +26,6 @@ import { createStderrProgressHandler } from './progress'; interface IInitCommandOptions { yes?: boolean; - hooks?: boolean; uninstallHooks?: boolean; extract?: boolean; commitCount?: number; @@ -118,7 +117,7 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger const log = logger?.child({ command: 'init' }); const cwd = process.cwd(); - log?.info('Command invoked', { yes: options.yes, hooks: options.hooks, uninstallHooks: options.uninstallHooks }); + log?.info('Command invoked', { yes: options.yes, uninstallHooks: options.uninstallHooks }); // ── Git hook uninstall (early exit) ───────────────────────────── if (options.uninstallHooks) { @@ -213,7 +212,8 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger } // ── Git hooks (prepare-commit-msg, commit-msg, post-commit) ───── - if (options.hooks) { + // Always install hooks during init (core functionality) + { const prepareResult = installHook(cwd); if (prepareResult.installed) { console.log(`✓ Installed prepare-commit-msg hook${prepareResult.wrapped ? ' (wrapped existing hook)' : ''}`); diff --git a/src/hooks/commit-msg.ts b/src/hooks/commit-msg.ts index c1fdbaee..d3ac9e76 100644 --- a/src/hooks/commit-msg.ts +++ b/src/hooks/commit-msg.ts @@ -17,7 +17,7 @@ import { execFileSync } from 'child_process'; const HOOK_FINGERPRINT_PREFIX = '# git-mem:commit-msg'; /** Full fingerprint with version — used for upgrade detection. */ -const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v2`; +const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v3`; /** * The shell hook script. @@ -32,8 +32,8 @@ COMMIT_MSG_FILE="$1" # Skip if no commit message file [ -z "$COMMIT_MSG_FILE" ] && exit 0 -# Skip if AI trailers already exist (likely from prepare-commit-msg or manual) -grep -q "^AI-Agent:" "$COMMIT_MSG_FILE" && exit 0 +# Skip if full analysis already done (AI-Memory-Id is unique to commit-msg analysis) +grep -q "^AI-Memory-Id:" "$COMMIT_MSG_FILE" && exit 0 # Escape values for safe JSON inclusion (handles quotes, backslashes) COMMIT_MSG_FILE_ESC=$(printf '%s' "$COMMIT_MSG_FILE" | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g') diff --git a/tests/integration/hooks/hook-commit-msg.test.ts b/tests/integration/hooks/hook-commit-msg.test.ts index fa5fef39..534e6326 100644 --- a/tests/integration/hooks/hook-commit-msg.test.ts +++ b/tests/integration/hooks/hook-commit-msg.test.ts @@ -278,10 +278,10 @@ describe('Integration: hook commit-msg', () => { }); describe('skip conditions', () => { - it('should skip if AI-Agent trailer already exists', () => { + it('should skip if AI-Memory-Id trailer already exists (full analysis done)', () => { process.env.GIT_MEM_AGENT = 'TestAgent/2.0'; - const commitMsg = 'feat: something\n\nAI-Agent: ExistingAgent/1.0'; + const commitMsg = 'feat: something\n\nAI-Memory-Id: abc12345'; const msgPath = createCommitMsgFile(repoDir, commitMsg); const result = runHook('commit-msg', { @@ -291,10 +291,10 @@ describe('Integration: hook commit-msg', () => { assert.equal(result.status, 0); - // Message should be unchanged + // Message should be unchanged - no new trailers added const modifiedMsg = readFileSync(msgPath, 'utf8'); - assert.ok(!modifiedMsg.includes('TestAgent'), 'Should not add duplicate trailers'); - assert.ok(modifiedMsg.includes('ExistingAgent'), 'Should preserve existing trailers'); + assert.ok(!modifiedMsg.includes('AI-Agent:'), 'Should not add trailers when already analyzed'); + assert.ok(modifiedMsg.includes('AI-Memory-Id: abc12345'), 'Should preserve existing trailers'); }); it('should exit successfully without changes when no agent detected', () => { diff --git a/tests/unit/hooks/commit-msg.test.ts b/tests/unit/hooks/commit-msg.test.ts index 3604e7f5..d884df17 100644 --- a/tests/unit/hooks/commit-msg.test.ts +++ b/tests/unit/hooks/commit-msg.test.ts @@ -39,7 +39,7 @@ describe('installCommitMsgHook', () => { const content = readFileSync(result.hookPath, 'utf8'); assert.ok(content.includes('#!/bin/sh')); - assert.ok(content.includes('git-mem:commit-msg v2')); + assert.ok(content.includes('git-mem:commit-msg v3')); assert.ok(content.includes('git-mem hook commit-msg')); }); @@ -74,7 +74,7 @@ describe('installCommitMsgHook', () => { // Installed hook should contain both fingerprint and wrapper reference const content = readFileSync(hookPath, 'utf8'); - assert.ok(content.includes('git-mem:commit-msg v2')); + assert.ok(content.includes('git-mem:commit-msg v3')); assert.ok(content.includes('user-backup')); } finally { rmSync(freshRepo, { recursive: true, force: true }); @@ -102,7 +102,7 @@ describe('installCommitMsgHook', () => { assert.equal(result.wrapped, false); const content = readFileSync(hookPath, 'utf8'); - assert.ok(content.includes('git-mem:commit-msg v2'), 'Should be upgraded to v1'); + assert.ok(content.includes('git-mem:commit-msg v3'), 'Should be upgraded to v1'); assert.ok(content.includes('git-mem hook commit-msg'), 'Should include git-mem command'); // Second install should be idempotent From acdb090c7b7dca8f1ca5f2732ad5acc702760ed5 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sat, 14 Feb 2026 15:07:09 +0000 Subject: [PATCH 5/7] fix: prevent duplicate Agent/Model trailers Skip adding AI-Agent and AI-Model in commit-msg if prepare-commit-msg already added them. Both hooks now work together correctly. AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Gotcha: prevent duplicate Agent/Model trailers. Skip adding AI-Agent and AI-Model in commit-msg if prepare-commit-msg AI-Confidence: medium AI-Tags: application, handlers, typescript AI-Lifecycle: project AI-Memory-Id: 70ce8b50 --- src/application/handlers/CommitMsgHandler.ts | 51 +++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/src/application/handlers/CommitMsgHandler.ts b/src/application/handlers/CommitMsgHandler.ts index 6b3d7f00..b50365a9 100644 --- a/src/application/handlers/CommitMsgHandler.ts +++ b/src/application/handlers/CommitMsgHandler.ts @@ -51,7 +51,7 @@ export class CommitMsgHandler implements IEventHandler { // 3. Check autoAnalyze config - if false, only add basic Agent/Model trailers if (!config.autoAnalyze) { this.logger.debug('autoAnalyze is false, adding only Agent/Model trailers'); - const basicTrailers = this.buildBasicTrailers(); + const basicTrailers = this.buildBasicTrailers(message); await this.appendTrailers(event.commitMsgPath, basicTrailers, event.cwd); return { handler: 'CommitMsgHandler', success: true }; } @@ -70,8 +70,8 @@ export class CommitMsgHandler implements IEventHandler { return { handler: 'CommitMsgHandler', success: true }; } - // 7. Build trailers (skip tags if inferTags is false) - const trailers = this.buildTrailers(analysis, config); + // 7. Build trailers (skip Agent/Model if already present from prepare-commit-msg) + const trailers = this.buildTrailers(analysis, config, message); // 8. Append trailers to the commit message using git interpret-trailers await this.appendTrailers(event.commitMsgPath, trailers, event.cwd); @@ -99,17 +99,24 @@ export class CommitMsgHandler implements IEventHandler { /** * Build basic AI-Agent and AI-Model trailers only (when autoAnalyze is false). + * Skips if already present from prepare-commit-msg. */ - private buildBasicTrailers(): ITrailer[] { + private buildBasicTrailers(existingMessage: string): ITrailer[] { const trailers: ITrailer[] = []; - const agent = this.agentResolver.resolveAgent(); - const model = this.agentResolver.resolveModel(); + const hasAgent = existingMessage.includes('AI-Agent:'); + const hasModel = existingMessage.includes('AI-Model:'); - if (agent) { - trailers.push({ key: AI_TRAILER_KEYS.AGENT, value: agent }); + if (!hasAgent) { + const agent = this.agentResolver.resolveAgent(); + if (agent) { + trailers.push({ key: AI_TRAILER_KEYS.AGENT, value: agent }); + } } - if (model) { - trailers.push({ key: AI_TRAILER_KEYS.MODEL, value: model }); + if (!hasModel) { + const model = this.agentResolver.resolveModel(); + if (model) { + trailers.push({ key: AI_TRAILER_KEYS.MODEL, value: model }); + } } return trailers; @@ -117,22 +124,30 @@ export class CommitMsgHandler implements IEventHandler { /** * Build all AI-* trailers from the analysis result. + * Skips Agent/Model if they already exist (from prepare-commit-msg). */ private buildTrailers( analysis: ReturnType, - config: ICommitMsgConfig + config: ICommitMsgConfig, + existingMessage: string ): ITrailer[] { const trailers: ITrailer[] = []; - // Always add Agent and Model via injected resolver - const agent = this.agentResolver.resolveAgent(); - const model = this.agentResolver.resolveModel(); + // Only add Agent and Model if not already present (prepare-commit-msg may have added them) + const hasAgent = existingMessage.includes('AI-Agent:'); + const hasModel = existingMessage.includes('AI-Model:'); - if (agent) { - trailers.push({ key: AI_TRAILER_KEYS.AGENT, value: agent }); + if (!hasAgent) { + const agent = this.agentResolver.resolveAgent(); + if (agent) { + trailers.push({ key: AI_TRAILER_KEYS.AGENT, value: agent }); + } } - if (model) { - trailers.push({ key: AI_TRAILER_KEYS.MODEL, value: model }); + if (!hasModel) { + const model = this.agentResolver.resolveModel(); + if (model) { + trailers.push({ key: AI_TRAILER_KEYS.MODEL, value: model }); + } } // Add type-specific trailer if we detected a type From 8e87265dfcaa0afa4c6882112ed17df6c43af02a Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sat, 14 Feb 2026 15:12:51 +0000 Subject: [PATCH 6/7] feat: auto-configure git to push notes with commits git-mem init now configures remote.origin.push to include refs/notes/* so notes travel with commits on regular git push. AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Decision: auto-configure git to push notes with commits. git-mem init now configures remote.origin.push to include refs/notes/* AI-Confidence: medium AI-Tags: commands, typescript AI-Lifecycle: project AI-Memory-Id: ff173fa7 --- src/commands/init.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/commands/init.ts b/src/commands/init.ts index 91ac6d8d..d779f128 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -6,6 +6,7 @@ */ import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync } from 'fs'; +import { execFileSync } from 'child_process'; import { join } from 'path'; import prompts from 'prompts'; import type { ILogger } from '../domain/interfaces/ILogger'; @@ -75,6 +76,38 @@ export function ensureGitignoreEntries(cwd: string, entries: string[]): void { appendFileSync(gitignorePath, append); } +/** + * Configure git to push notes automatically with regular pushes. + * Sets remote.origin.push to include refs/notes/* alongside refs/heads/*. + * Idempotent - safe to call multiple times. + */ +export function configureNotesPush(cwd: string): void { + try { + // Check if notes push is already configured + const existing = execFileSync('git', ['config', '--local', '--get-all', 'remote.origin.push'], { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + if (existing.includes('refs/notes')) { + return; // Already configured + } + } catch { + // Config doesn't exist yet, proceed to set it + } + + // Set push refspecs: heads and notes + execFileSync('git', ['config', '--local', 'remote.origin.push', '+refs/heads/*:refs/heads/*'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + execFileSync('git', ['config', '--local', '--add', 'remote.origin.push', '+refs/notes/*:refs/notes/*'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + /** * Read ANTHROPIC_API_KEY value from .env file. * Returns the value if set, null otherwise. @@ -234,6 +267,10 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger } else { console.log('✓ post-commit hook already installed (skipped)'); } + + // Configure git to push notes automatically with regular pushes + configureNotesPush(cwd); + console.log('✓ Configured git to push notes with commits'); } // ── MCP config (skip if already exists) ──────────────────────── From cdeccea22fbd0f0635729474e6774eb88ed60c48 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sat, 14 Feb 2026 15:23:31 +0000 Subject: [PATCH 7/7] fix: address PR review comments (GIT-73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix assertion message: v1 → v3 in commit-msg.test.ts - Clarify AI-Memory-Id comment wording in hook and handler - Fix configureNotesPush to preserve existing user refspecs Co-Authored-By: Claude Opus 4.5 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Gotcha: address PR review comments (GIT-73). - Fix assertion message: v1 → v3 in commit-msg.test.ts AI-Confidence: medium AI-Tags: application, handlers, commands, hooks, tests, unit AI-Lifecycle: project AI-Memory-Id: 605c296b --- src/application/handlers/CommitMsgHandler.ts | 2 +- src/commands/init.ts | 29 ++++++++++++++------ src/hooks/commit-msg.ts | 2 +- tests/unit/hooks/commit-msg.test.ts | 2 +- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/application/handlers/CommitMsgHandler.ts b/src/application/handlers/CommitMsgHandler.ts index b50365a9..3f6d020e 100644 --- a/src/application/handlers/CommitMsgHandler.ts +++ b/src/application/handlers/CommitMsgHandler.ts @@ -42,7 +42,7 @@ export class CommitMsgHandler implements IEventHandler { // 1. Read the commit message file const message = readFileSync(event.commitMsgPath, 'utf8'); - // 2. Check if full analysis already done (AI-Memory-Id is unique to commit-msg) + // 2. Check if full analysis already done (AI-Memory-Id indicates commit-msg hook has run) if (message.includes('AI-Memory-Id:')) { this.logger.debug('Full analysis already done, skipping'); return { handler: 'CommitMsgHandler', success: true }; diff --git a/src/commands/init.ts b/src/commands/init.ts index d779f128..78e8366f 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -78,30 +78,41 @@ export function ensureGitignoreEntries(cwd: string, entries: string[]): void { /** * Configure git to push notes automatically with regular pushes. - * Sets remote.origin.push to include refs/notes/* alongside refs/heads/*. + * Adds refs/notes/* to existing push refspecs, preserving any user-configured refspecs. * Idempotent - safe to call multiple times. */ export function configureNotesPush(cwd: string): void { + let existingRefspecs: string[] = []; + try { - // Check if notes push is already configured + // Get existing push refspecs const existing = execFileSync('git', ['config', '--local', '--get-all', 'remote.origin.push'], { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], }).trim(); - if (existing.includes('refs/notes')) { - return; // Already configured + if (existing) { + existingRefspecs = existing.split('\n').filter(Boolean); + } + + // Already has notes configured - nothing to do + if (existingRefspecs.some((ref) => ref.includes('refs/notes'))) { + return; } } catch { // Config doesn't exist yet, proceed to set it } - // Set push refspecs: heads and notes - execFileSync('git', ['config', '--local', 'remote.origin.push', '+refs/heads/*:refs/heads/*'], { - cwd, - stdio: ['pipe', 'pipe', 'pipe'], - }); + // If no existing refspecs, add heads first + if (existingRefspecs.length === 0) { + execFileSync('git', ['config', '--local', 'remote.origin.push', '+refs/heads/*:refs/heads/*'], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + } + + // Add notes refspec (preserves existing refspecs) execFileSync('git', ['config', '--local', '--add', 'remote.origin.push', '+refs/notes/*:refs/notes/*'], { cwd, stdio: ['pipe', 'pipe', 'pipe'], diff --git a/src/hooks/commit-msg.ts b/src/hooks/commit-msg.ts index d3ac9e76..383f0bc5 100644 --- a/src/hooks/commit-msg.ts +++ b/src/hooks/commit-msg.ts @@ -32,7 +32,7 @@ COMMIT_MSG_FILE="$1" # Skip if no commit message file [ -z "$COMMIT_MSG_FILE" ] && exit 0 -# Skip if full analysis already done (AI-Memory-Id is unique to commit-msg analysis) +# Skip if full analysis already done (AI-Memory-Id indicates commit-msg hook has run) grep -q "^AI-Memory-Id:" "$COMMIT_MSG_FILE" && exit 0 # Escape values for safe JSON inclusion (handles quotes, backslashes) diff --git a/tests/unit/hooks/commit-msg.test.ts b/tests/unit/hooks/commit-msg.test.ts index d884df17..a4430a8c 100644 --- a/tests/unit/hooks/commit-msg.test.ts +++ b/tests/unit/hooks/commit-msg.test.ts @@ -102,7 +102,7 @@ describe('installCommitMsgHook', () => { assert.equal(result.wrapped, false); const content = readFileSync(hookPath, 'utf8'); - assert.ok(content.includes('git-mem:commit-msg v3'), 'Should be upgraded to v1'); + assert.ok(content.includes('git-mem:commit-msg v3'), 'Should be upgraded to v3'); assert.ok(content.includes('git-mem hook commit-msg'), 'Should include git-mem command'); // Second install should be idempotent