Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions src/hooks/utils/config.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +20 to +37
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc for getConfigPath() / getConfigDir() says the returned path is absolute, but join(cwd, ...) will return a relative path if the caller passes a relative cwd. Consider either resolving cwd (e.g., via resolve()) or adjusting the docs to say the path is relative when cwd is relative.

Copilot uses AI. Check for mistakes.
}

const DEFAULTS: IHookConfig = {
hooks: {
enabled: true,
Expand All @@ -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<string, unknown>;
const raw = parseYaml(readFileSync(configPath, 'utf8')) as Record<string, unknown>;
const rawHooks = (raw.hooks ?? {}) as Partial<IHooksConfig>;
Comment on lines 59 to 68
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadHookConfig() now only looks for .git-mem/.git-mem.yaml. However, the CLI init flows in this repo still create and deep-merge .git-mem.json (e.g., src/commands/init.ts, src/commands/init-hooks.ts), meaning user config written by init will be silently ignored by hooks. Either update the init commands to write the YAML file in the new location, or add a backward-compatible fallback here that reads legacy .git-mem.json if the YAML file is missing.

Copilot uses AI. Check for mistakes.

const rawStop = (rawHooks.sessionStop ?? {}) as Record<string, unknown>;
Expand Down
13 changes: 9 additions & 4 deletions tests/integration/hooks/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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<Record<'enabled' | 'sessionStart' | 'sessionStop' | 'promptSubmit' | 'postCommit', unknown>>,
Expand All @@ -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;
}

Expand All @@ -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. */
Expand Down
8 changes: 6 additions & 2 deletions tests/integration/hooks/hook-commit-msg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,18 @@ 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,
writeGitMemConfig,
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(
Expand Down Expand Up @@ -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));
}

/**
Expand Down
201 changes: 133 additions & 68 deletions tests/unit/hooks/utils/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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'));
});
Comment on lines +41 to +50
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These assertions hard-code POSIX separators (/some/dir/... and .endsWith('.git-mem/.git-mem.yaml')). Since getConfigPath() uses path.join(), the output is platform-dependent and these tests will fail on Windows. Consider asserting using join(testDir, CONFIG_DIR, CONFIG_FILE) and using path.sep-safe checks for the process.cwd() cases.

Copilot uses AI. Check for mistakes.
});

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);
});
});
});