From bec83f439d2e3b6056aa43e61ce270e0631741bf Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sat, 14 Feb 2026 19:15:44 +0000 Subject: [PATCH] feat: migrate config from JSON to YAML (GIT-89) - Update config.ts to read from .git-mem/.git-mem.yaml - Export getConfigPath() and getConfigDir() helpers - Export CONFIG_DIR and CONFIG_FILE constants - Update integration test helpers to write YAML config - Update all unit and integration tests This also completes GIT-93 (update integration test helpers). Co-Authored-By: Claude Opus 4.5 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Decision: migrate config from JSON to YAML (GIT-89). - Update config.ts to read from .git-mem/.git-mem.yaml AI-Confidence: medium AI-Tags: hooks, utils, tests, integration, unit AI-Lifecycle: project AI-Memory-Id: ea93b196 AI-Source: heuristic --- src/hooks/utils/config.ts | 34 ++- tests/integration/hooks/helpers.ts | 13 +- .../integration/hooks/hook-commit-msg.test.ts | 8 +- tests/unit/hooks/utils/config.test.ts | 201 ++++++++++++------ 4 files changed, 178 insertions(+), 78 deletions(-) diff --git a/src/hooks/utils/config.ts b/src/hooks/utils/config.ts index 5f098b83..cb44383b 100644 --- a/src/hooks/utils/config.ts +++ b/src/hooks/utils/config.ts @@ -1,15 +1,42 @@ /** * Hook configuration reader. * - * Reads .git-mem.json from the working directory and returns + * Reads .git-mem/.git-mem.yaml from the working directory and returns * typed hook configuration with sensible defaults. * Never throws — returns defaults on missing file or parse errors. */ import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; +import { parse as parseYaml } from 'yaml'; import type { IHookConfig, IHooksConfig } from '../../domain/interfaces/IHookConfig'; +/** Directory containing git-mem configuration */ +export const CONFIG_DIR = '.git-mem'; + +/** Config file name */ +export const CONFIG_FILE = '.git-mem.yaml'; + +/** + * Get the full path to the config file. + * @param cwd - Working directory (defaults to process.cwd()) + * @returns Absolute path to .git-mem/.git-mem.yaml + */ +export function getConfigPath(cwd?: string): string { + const dir = cwd ?? process.cwd(); + return join(dir, CONFIG_DIR, CONFIG_FILE); +} + +/** + * Get the path to the config directory. + * @param cwd - Working directory (defaults to process.cwd()) + * @returns Absolute path to .git-mem/ + */ +export function getConfigDir(cwd?: string): string { + const dir = cwd ?? process.cwd(); + return join(dir, CONFIG_DIR); +} + const DEFAULTS: IHookConfig = { hooks: { enabled: true, @@ -30,15 +57,14 @@ const DEFAULTS: IHookConfig = { }; export function loadHookConfig(cwd?: string): IHookConfig { - const dir = cwd ?? process.cwd(); - const configPath = join(dir, '.git-mem.json'); + const configPath = getConfigPath(cwd); if (!existsSync(configPath)) { return DEFAULTS; } try { - const raw = JSON.parse(readFileSync(configPath, 'utf8')) as Record; + const raw = parseYaml(readFileSync(configPath, 'utf8')) as Record; const rawHooks = (raw.hooks ?? {}) as Partial; const rawStop = (rawHooks.sessionStop ?? {}) as Record; diff --git a/tests/integration/hooks/helpers.ts b/tests/integration/hooks/helpers.ts index 9ca7d188..a6999099 100644 --- a/tests/integration/hooks/helpers.ts +++ b/tests/integration/hooks/helpers.ts @@ -6,9 +6,11 @@ */ import { spawnSync, execFileSync } from 'child_process'; -import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; import { join, resolve } from 'path'; import { tmpdir } from 'os'; +import { stringify as stringifyYaml } from 'yaml'; +import { CONFIG_DIR, CONFIG_FILE } from '../../../src/hooks/utils/config'; const PROJECT_ROOT = resolve(__dirname, '../../..'); const CLI_PATH = resolve(PROJECT_ROOT, 'src/cli.ts'); @@ -82,7 +84,7 @@ export function addCommit(dir: string, filename: string, content: string, messag return git(['rev-parse', 'HEAD'], dir); } -/** Write .git-mem.json into a directory with optional per-hook overrides. */ +/** Write .git-mem/.git-mem.yaml into a directory with optional per-hook overrides. */ export function writeGitMemConfig( dir: string, overrides?: Partial>, @@ -97,8 +99,11 @@ export function writeGitMemConfig( }, }; + const configDir = join(dir, CONFIG_DIR); + mkdirSync(configDir, { recursive: true }); + if (!overrides) { - writeFileSync(join(dir, '.git-mem.json'), JSON.stringify(defaults, null, 2) + '\n'); + writeFileSync(join(configDir, CONFIG_FILE), stringifyYaml(defaults)); return; } @@ -111,7 +116,7 @@ export function writeGitMemConfig( postCommit: { ...defaults.hooks.postCommit, ...(overrides.postCommit as object) }, }, }; - writeFileSync(join(dir, '.git-mem.json'), JSON.stringify(merged, null, 2) + '\n'); + writeFileSync(join(configDir, CONFIG_FILE), stringifyYaml(merged)); } /** Remove a temp directory. */ diff --git a/tests/integration/hooks/hook-commit-msg.test.ts b/tests/integration/hooks/hook-commit-msg.test.ts index 73c4b679..b335200d 100644 --- a/tests/integration/hooks/hook-commit-msg.test.ts +++ b/tests/integration/hooks/hook-commit-msg.test.ts @@ -10,6 +10,7 @@ import { describe, it, before, after, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { writeFileSync, readFileSync, mkdirSync } from 'fs'; import { join } from 'path'; +import { stringify as stringifyYaml } from 'yaml'; import { runHook, createTestRepo, @@ -17,9 +18,10 @@ import { cleanupRepo, git, } from './helpers'; +import { CONFIG_DIR, CONFIG_FILE } from '../../../src/hooks/utils/config'; /** - * Write a commit-msg config to .git-mem.json. + * Write a commit-msg config to .git-mem/.git-mem.yaml. * Extends the default config with commitMsg settings. */ function writeCommitMsgConfig( @@ -49,7 +51,9 @@ function writeCommitMsgConfig( }, }, }; - writeFileSync(join(dir, '.git-mem.json'), JSON.stringify(config, null, 2) + '\n'); + const configDir = join(dir, CONFIG_DIR); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, CONFIG_FILE), stringifyYaml(config)); } /** diff --git a/tests/unit/hooks/utils/config.test.ts b/tests/unit/hooks/utils/config.test.ts index 84b02379..c487565f 100644 --- a/tests/unit/hooks/utils/config.test.ts +++ b/tests/unit/hooks/utils/config.test.ts @@ -4,12 +4,19 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, writeFileSync, rmSync } from 'fs'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; -import { loadHookConfig } from '../../../../src/hooks/utils/config'; - -describe('loadHookConfig', () => { +import { stringify as stringifyYaml } from 'yaml'; +import { + loadHookConfig, + getConfigPath, + getConfigDir, + CONFIG_DIR, + CONFIG_FILE, +} from '../../../../src/hooks/utils/config'; + +describe('config', () => { let tempDir: string; before(() => { @@ -24,74 +31,132 @@ describe('loadHookConfig', () => { return mkdtempSync(join(tempDir, 'test-')); } - it('should return defaults when no config file exists', () => { - const testDir = createTestDir(); - const config = loadHookConfig(testDir); - - assert.equal(config.hooks.enabled, true); - assert.equal(config.hooks.sessionStart.enabled, true); - assert.equal(config.hooks.sessionStart.memoryLimit, 20); - assert.equal(config.hooks.sessionStop.enabled, true); - assert.equal(config.hooks.sessionStop.autoExtract, true); - assert.equal(config.hooks.sessionStop.threshold, 3); - assert.equal(config.hooks.promptSubmit.enabled, false); - assert.equal(config.hooks.promptSubmit.recordPrompts, false); - assert.equal(config.hooks.promptSubmit.surfaceContext, true); - }); - - it('should read and merge config from .git-mem.json', () => { - const testDir = createTestDir(); - writeFileSync(join(testDir, '.git-mem.json'), JSON.stringify({ - hooks: { - enabled: true, - sessionStart: { enabled: true, memoryLimit: 50 }, - promptSubmit: { enabled: true }, - }, - })); - - const config = loadHookConfig(testDir); - - assert.equal(config.hooks.sessionStart.memoryLimit, 50); - assert.equal(config.hooks.promptSubmit.enabled, true); - // Defaults preserved for unset fields - assert.equal(config.hooks.promptSubmit.recordPrompts, false); - assert.equal(config.hooks.sessionStop.enabled, true); - }); - - it('should return defaults for invalid JSON', () => { - const testDir = createTestDir(); - writeFileSync(join(testDir, '.git-mem.json'), 'not-json{{{'); - - const config = loadHookConfig(testDir); - - assert.equal(config.hooks.enabled, true); - assert.equal(config.hooks.sessionStart.memoryLimit, 20); - }); - - it('should return defaults when hooks key is missing', () => { - const testDir = createTestDir(); - writeFileSync(join(testDir, '.git-mem.json'), JSON.stringify({ other: 'stuff' })); - - const config = loadHookConfig(testDir); + function writeConfig(testDir: string, config: object): void { + const configDir = join(testDir, CONFIG_DIR); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, CONFIG_FILE), stringifyYaml(config)); + } - assert.equal(config.hooks.enabled, true); - assert.equal(config.hooks.sessionStart.enabled, true); + describe('getConfigPath', () => { + it('should return path to .git-mem/.git-mem.yaml', () => { + const testDir = '/some/dir'; + const result = getConfigPath(testDir); + assert.equal(result, '/some/dir/.git-mem/.git-mem.yaml'); + }); + + it('should use process.cwd() when no cwd provided', () => { + const result = getConfigPath(); + assert.ok(result.endsWith('.git-mem/.git-mem.yaml')); + }); }); - it('should allow disabling all hooks', () => { - const testDir = createTestDir(); - writeFileSync(join(testDir, '.git-mem.json'), JSON.stringify({ - hooks: { enabled: false }, - })); - - const config = loadHookConfig(testDir); - - assert.equal(config.hooks.enabled, false); + describe('getConfigDir', () => { + it('should return path to .git-mem directory', () => { + const testDir = '/some/dir'; + const result = getConfigDir(testDir); + assert.equal(result, '/some/dir/.git-mem'); + }); + + it('should use process.cwd() when no cwd provided', () => { + const result = getConfigDir(); + assert.ok(result.endsWith('.git-mem')); + }); }); - it('should use process.cwd() when no cwd provided', () => { - // This should not throw — just returns defaults if no .git-mem.json - const config = loadHookConfig(); - assert.ok(config.hooks); + describe('loadHookConfig', () => { + it('should return defaults when no config file exists', () => { + const testDir = createTestDir(); + const config = loadHookConfig(testDir); + + assert.equal(config.hooks.enabled, true); + assert.equal(config.hooks.sessionStart.enabled, true); + assert.equal(config.hooks.sessionStart.memoryLimit, 20); + assert.equal(config.hooks.sessionStop.enabled, true); + assert.equal(config.hooks.sessionStop.autoExtract, true); + assert.equal(config.hooks.sessionStop.threshold, 3); + assert.equal(config.hooks.promptSubmit.enabled, false); + assert.equal(config.hooks.promptSubmit.recordPrompts, false); + assert.equal(config.hooks.promptSubmit.surfaceContext, true); + }); + + it('should read and merge config from .git-mem/.git-mem.yaml', () => { + const testDir = createTestDir(); + writeConfig(testDir, { + hooks: { + enabled: true, + sessionStart: { enabled: true, memoryLimit: 50 }, + promptSubmit: { enabled: true }, + }, + }); + + const config = loadHookConfig(testDir); + + assert.equal(config.hooks.sessionStart.memoryLimit, 50); + assert.equal(config.hooks.promptSubmit.enabled, true); + // Defaults preserved for unset fields + assert.equal(config.hooks.promptSubmit.recordPrompts, false); + assert.equal(config.hooks.sessionStop.enabled, true); + }); + + it('should return defaults for invalid YAML', () => { + const testDir = createTestDir(); + const configDir = join(testDir, CONFIG_DIR); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, CONFIG_FILE), 'not: valid: yaml: {{{}}}'); + + const config = loadHookConfig(testDir); + + assert.equal(config.hooks.enabled, true); + assert.equal(config.hooks.sessionStart.memoryLimit, 20); + }); + + it('should return defaults when hooks key is missing', () => { + const testDir = createTestDir(); + writeConfig(testDir, { other: 'stuff' }); + + const config = loadHookConfig(testDir); + + assert.equal(config.hooks.enabled, true); + assert.equal(config.hooks.sessionStart.enabled, true); + }); + + it('should allow disabling all hooks', () => { + const testDir = createTestDir(); + writeConfig(testDir, { + hooks: { enabled: false }, + }); + + const config = loadHookConfig(testDir); + + assert.equal(config.hooks.enabled, false); + }); + + it('should use process.cwd() when no cwd provided', () => { + // This should not throw — just returns defaults if no config + const config = loadHookConfig(); + assert.ok(config.hooks); + }); + + it('should handle commitMsg configuration', () => { + const testDir = createTestDir(); + writeConfig(testDir, { + hooks: { + commitMsg: { + enabled: true, + enrich: false, + enrichTimeout: 10000, + }, + }, + }); + + const config = loadHookConfig(testDir); + + assert.equal(config.hooks.commitMsg.enabled, true); + assert.equal(config.hooks.commitMsg.enrich, false); + assert.equal(config.hooks.commitMsg.enrichTimeout, 10000); + // Defaults preserved + assert.equal(config.hooks.commitMsg.autoAnalyze, true); + assert.equal(config.hooks.commitMsg.inferTags, true); + }); }); });