Skip to content
Closed
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);
}

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

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

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