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
12 changes: 2 additions & 10 deletions src/commands/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
*/

import { join } from 'path';
import { execFileSync } from 'child_process';
import { config as loadEnv } from 'dotenv';
import { createContainer } from '../infrastructure/di';
import { readStdin } from '../hooks/utils/stdin';
import { setupShutdown } from '../hooks/utils/shutdown';
import { loadHookConfig } from '../hooks/utils/config';
import { resolveGitRoot } from '../infrastructure/git/resolveGitRoot';
import type { ILogger } from '../domain/interfaces/ILogger';
import type { IHooksConfig } from '../domain/interfaces/IHookConfig';
import type { HookEvent, HookEventType } from '../domain/events/HookEvents';
Expand Down Expand Up @@ -108,15 +108,7 @@ export function buildEvent(eventType: HookEventType, input: IHookInput): HookEve
* Returns cwd if git command fails (graceful fallback).
*/
function findGitRoot(cwd: string): string {
try {
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch {
return cwd;
}
return resolveGitRoot(cwd);
}

/** Stderr labels per event for user-facing messages. */
Expand Down
9 changes: 5 additions & 4 deletions src/commands/init-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { homedir } from 'os';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import type { ILogger } from '../domain/interfaces/ILogger';
import { getConfigPath, getConfigDir } from '../hooks/utils/config';
import { resolveGitRoot } from '../infrastructure/git/resolveGitRoot';

interface IInitHooksOptions {
yes?: boolean;
Expand Down Expand Up @@ -147,11 +148,11 @@ export function deepMergeGitMemConfig(

// ── Config builders ──────────────────────────────────────────────────

export function getSettingsPath(scope: string): string {
export function getSettingsPath(scope: string, cwd?: string): string {
if (scope === 'user') {
return join(homedir(), '.claude', 'settings.json');
}
return join(process.cwd(), '.claude', 'settings.json');
return join(cwd ?? process.cwd(), '.claude', 'settings.json');
}

export function readExistingSettings(path: string): Record<string, unknown> {
Expand Down Expand Up @@ -232,8 +233,8 @@ export function buildGitMemConfig(): Record<string, unknown> {
export async function initHooksCommand(options: IInitHooksOptions, logger?: ILogger): Promise<void> {
const log = logger?.child({ command: 'init-hooks' });
const scope = options.scope ?? 'project';
const settingsPath = getSettingsPath(scope);
const cwd = process.cwd();
const cwd = resolveGitRoot();
const settingsPath = getSettingsPath(scope, cwd);
const configDir = getConfigDir(cwd);
const configPath = getConfigPath(cwd);

Expand Down
5 changes: 3 additions & 2 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { installCommitMsgHook, uninstallCommitMsgHook } from '../hooks/commit-ms
import { createContainer } from '../infrastructure/di';
import { createStderrProgressHandler } from './progress';
import { getConfigPath, getConfigDir } from '../hooks/utils/config';
import { resolveGitRoot } from '../infrastructure/git/resolveGitRoot';

interface IInitCommandOptions {
yes?: boolean;
Expand Down Expand Up @@ -161,7 +162,7 @@ export function ensureEnvPlaceholder(cwd: string): void {
/** Run unified project setup: hooks, MCP config, .gitignore, and .env. */
export async function initCommand(options: IInitCommandOptions, logger?: ILogger): Promise<void> {
const log = logger?.child({ command: 'init' });
const cwd = process.cwd();
const cwd = resolveGitRoot();

log?.info('Command invoked', { yes: options.yes, uninstallHooks: options.uninstallHooks });

Expand Down Expand Up @@ -235,7 +236,7 @@ export async function initCommand(options: IInitCommandOptions, logger?: ILogger

// ── Claude Code hooks ──────────────────────────────────────────
if (claudeIntegration) {
const settingsPath = getSettingsPath('project');
const settingsPath = getSettingsPath('project', cwd);
const settingsDir = join(settingsPath, '..');
if (!existsSync(settingsDir)) {
mkdirSync(settingsDir, { recursive: true });
Expand Down
38 changes: 38 additions & 0 deletions src/infrastructure/git/resolveGitRoot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Resolve the git repository root directory.
*
* Uses `git rev-parse --show-toplevel` and normalizes the path with
* path.resolve() for cross-platform compatibility (Windows returns
* forward-slash paths from git).
*
* @param cwd - Optional working directory to resolve from
* @returns Absolute path to the repo root, or fallback on failure
*/

import { execFileSync } from 'child_process';
import { resolve } from 'path';

/**
* Resolve git root, returning null on failure.
* Use this when the caller needs to handle the missing-repo case explicitly.
*/
export function getGitRoot(cwd?: string): string | null {
try {
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], {
cwd,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
return resolve(root);
} catch {
return null;
}
}

/**
* Resolve git root, falling back to process.cwd() on failure.
* Use this when a directory is always needed (e.g. init commands).
*/
export function resolveGitRoot(cwd?: string): string {
return getGitRoot(cwd) ?? process.cwd();
}
13 changes: 1 addition & 12 deletions src/infrastructure/logging/factory.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,15 @@
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
import { ILogger, ILoggerOptions, LogLevel } from '../../domain/interfaces/ILogger';
import { Logger } from './Logger';
import { NullLogger } from './NullLogger';
import { getGitRoot } from '../git/resolveGitRoot';

const VALID_LEVELS: readonly LogLevel[] = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];

function isValidLogLevel(value: string): value is LogLevel {
return VALID_LEVELS.includes(value as LogLevel);
}

function getGitRoot(): string | null {
try {
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch {
return null;
}
}

export function defaultLogDir(): string {
const base = getGitRoot() ?? process.cwd();
return path.join(base, '.git-mem', 'logs');
Expand Down
9 changes: 8 additions & 1 deletion tests/integration/hooks/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ const PROJECT_ROOT = resolve(__dirname, '../../..');
const CLI_PATH = resolve(PROJECT_ROOT, 'src/cli.ts');

// Use tsx binary from project node_modules — works even when cwd is a temp dir
const TSX_BIN = resolve(PROJECT_ROOT, 'node_modules/.bin/tsx');
// On Windows, spawnSync() needs the .cmd wrapper; on Unix, use the shell script
const TSX_BIN = resolve(
PROJECT_ROOT,
'node_modules/.bin',
process.platform === 'win32' ? 'tsx.cmd' : 'tsx',
);

export interface IRunResult {
stdout: string;
Expand All @@ -30,6 +35,7 @@ export function runHook(eventName: string, input: Record<string, unknown>): IRun
input: JSON.stringify(input),
encoding: 'utf8',
timeout: 15_000,
shell: process.platform === 'win32',
});

return {
Expand All @@ -46,6 +52,7 @@ export function runCli(args: string[], opts?: { cwd?: string; input?: string }):
cwd: opts?.cwd,
encoding: 'utf8',
timeout: 15_000,
shell: process.platform === 'win32',
});

return {
Expand Down
16 changes: 14 additions & 2 deletions tests/integration/mcp/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function sendMcpRequest(request: object): Promise<IMcpResponse> {
return new Promise((resolve, reject) => {
const proc = spawn(TSX_BIN, [SERVER_PATH], {
stdio: ['pipe', 'pipe', 'pipe'],
shell: process.platform === 'win32',
});

let stdout = '';
Expand Down Expand Up @@ -90,6 +91,7 @@ export function mcpSession(cwd: string, requests: object[]): Promise<IMcpRespons
const proc = spawn(TSX_BIN, [SERVER_PATH], {
stdio: ['pipe', 'pipe', 'pipe'],
cwd,
shell: process.platform === 'win32',
});

let stdout = '';
Expand Down Expand Up @@ -192,7 +194,17 @@ export function createTestRepo(prefix = 'git-mem-mcp-'): { dir: string; sha: str
return { dir, sha };
}

/** Remove a temp directory. */
/** Remove a temp directory. Silently ignores EPERM on Windows where killed processes may still hold file locks. */
export function cleanupRepo(dir: string): void {
rmSync(dir, { recursive: true, force: true });
try {
rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 500 });
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (process.platform === 'win32' && code === 'EPERM') {
// On Windows, killed shell processes may still hold .git lock files briefly.
// Temp dirs will be cleaned up by the OS eventually.
return;
}
throw err;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
6 changes: 3 additions & 3 deletions tests/unit/hooks/utils/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,20 @@ describe('config', () => {
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');
assert.equal(result, join('/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'));
assert.ok(result.endsWith(join('.git-mem', '.git-mem.yaml')));
});
});

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');
assert.equal(result, join('/some/dir', '.git-mem'));
});

it('should use process.cwd() when no cwd provided', () => {
Expand Down