Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4a21f03
fix: enforce provider body timeouts
404-Page-Found Sep 22, 2026
e813cd9
fix: allow provider request cancellation
404-Page-Found Sep 22, 2026
dbe3b3e
fix: propagate hook cancellation to LLM calls
404-Page-Found Sep 22, 2026
5d023f9
fix: propagate cancellation in OpenAI requests
404-Page-Found Sep 22, 2026
f7878df
fix: propagate cancellation in Anthropic requests
404-Page-Found Sep 22, 2026
087d40a
fix: propagate cancellation in Cohere requests
404-Page-Found Sep 22, 2026
ec77a0f
fix: bound prepare-commit-msg latency
404-Page-Found Sep 22, 2026
b9c8086
test: cover provider body timeout
404-Page-Found Sep 22, 2026
d954bff
test: bound prepare hook latency
404-Page-Found Sep 22, 2026
098a14f
fix: avoid uninitialized timeout closure
404-Page-Found Sep 22, 2026
2305ce7
test: make stalled body honor abort
404-Page-Found Sep 22, 2026
02d8fd1
fix: type prepare hook timeout dependency
404-Page-Found Sep 22, 2026
ff103ce
chore: add temporary hook format inspection workflow
404-Page-Found Sep 22, 2026
6b31f2c
fix: preserve hook state cleanup
404-Page-Found Sep 22, 2026
543117f
chore: run hook formatter inspection on branch pushes
404-Page-Found Sep 22, 2026
2444f36
style: format prepare hook timeout error
404-Page-Found Sep 22, 2026
7d28c28
chore: remove temporary format inspection workflow
404-Page-Found Sep 22, 2026
8ba65ab
fix: preserve streaming response semantics
404-Page-Found Sep 22, 2026
09b9340
fix: keep streaming timeout semantics
404-Page-Found Sep 22, 2026
d374465
fix: keep streaming timeout semantics
404-Page-Found Sep 22, 2026
c861534
test: update timeout lifecycle expectation
404-Page-Found Sep 22, 2026
58bae31
fix: make prepare hook timeout deadline-safe
404-Page-Found Sep 22, 2026
e437c70
test: cover prepare hook deadline races
404-Page-Found Sep 22, 2026
45fa176
fix: preserve external cancellation through response bodies
404-Page-Found Sep 22, 2026
e2878a3
test: cover streaming cancellation after headers
404-Page-Found Sep 22, 2026
0781cbc
fix: keep prepare hook timeout bounded before mutations
404-Page-Found Sep 22, 2026
c3a2dd8
style: preserve hook declaration spacing
404-Page-Found Sep 22, 2026
ecf81c1
fix: make hook git setup abortable
404-Page-Found Sep 22, 2026
d7ab719
fix: make prepare hook git setup cancellable
404-Page-Found Sep 22, 2026
b6c436c
fix: release wrapped provider readers on cleanup
404-Page-Found Sep 22, 2026
53762ae
test: make config timeout assertion deterministic
404-Page-Found Sep 22, 2026
99c2507
test: bound streaming cancellation regression
404-Page-Found Sep 22, 2026
6589a04
fix: correct async git exec options
404-Page-Found Sep 22, 2026
c8bf5c8
fix: wire abortable git helper into hook deps
404-Page-Found Sep 22, 2026
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
53 changes: 52 additions & 1 deletion src/git/diff.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { execFile, execFileSync, spawnSync } from 'node:child_process';
import { accessSync, copyFileSync, existsSync, constants, mkdtempSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, isAbsolute, join, normalize, resolve } from 'node:path';
import { promisify } from 'node:util';

export interface DiffResult {
diff: string;
Expand Down Expand Up @@ -31,6 +32,7 @@ const GIT_REPOSITORY_ENV_VARS = [
] as const;
const GIT_EXECUTABLE_NAME = process.platform === 'win32' ? 'git.exe' : 'git';
let gitExecutable: string | undefined;
const execFileAsync = promisify(execFile);

function getGitEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
const env = { ...process.env };
Expand Down Expand Up @@ -119,6 +121,55 @@ export function checkGitRepo(): void {
}
}

export async function checkGitRepoWithSignal(signal?: AbortSignal): Promise<void> {
const executable = getGitExecutable();
try {
await execFileAsync(executable, ['rev-parse', '--git-dir'], {
encoding: 'utf-8',
signal,
});
} catch (err) {
if (signal?.aborted) {
throw signal.reason instanceof Error ? signal.reason : new Error('Git repository check was cancelled');
}

const nodeErr = err as NodeJS.ErrnoException & { stderr?: string };
if (nodeErr.code === 'ENOENT') {
throw new Error('git is not installed or not found in a supported location');
}

const stderr = nodeErr.stderr?.trim();
throw new Error(stderr || 'Not a git repository');
}
}

export async function getStagedDiffWithSignal(
cwd = process.cwd(),
indexFile?: string,
signal?: AbortSignal,
): Promise<DiffResult> {
try {
const { stdout } = await execFileAsync(getGitExecutable(), ['diff', '--cached'], {
cwd,
encoding: 'utf-8',
env: getGitEnv(indexFile ? { GIT_INDEX_FILE: indexFile } : {}),
maxBuffer: GIT_DIFF_MAX_BUFFER,
signal,
});

return {
diff: stdout,
hasChanges: stdout.trim().length > 0,
staged: true,
};
} catch (err) {
if (signal?.aborted) {
throw signal.reason instanceof Error ? signal.reason : new Error('Git staged diff was cancelled');
}
throw err;
}
}

export function hasCommits(): boolean {
try {
const count = execFileSync(getGitExecutable(), ['rev-list', '--count', 'HEAD'], {
Expand Down
146 changes: 120 additions & 26 deletions src/git/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import type { CommitEntry, Config, Suggestion, StyleProfile } from '../types.js';
import { checkGitRepo, getGitExecutable, getStagedDiff } from './diff.js';
import { checkGitRepo, checkGitRepoWithSignal, getGitExecutable, getStagedDiffWithSignal } from './diff.js';
import type { DiffResult } from './diff.js';
import { loadConfig } from '../config/store.js';
import { appendEntry, buildProfile } from '../history/store.js';
Expand All @@ -15,6 +15,7 @@
const POST_COMMIT_HOOK_NAME = 'post-commit';
const PENDING_HOOK_ENTRY_FILE = 'commit-echo-pending-entry.json';
const BACKUP_OWNER_MARKER = '# commit-echo managed backup';
export const PREPARE_COMMIT_MSG_HOOK_TIMEOUT_MS = 5_000;

export interface PrepareCommitMsgHookArgs {
messageFile: string;
Expand All @@ -32,16 +33,17 @@
}

export interface PrepareCommitMsgHookDeps {
checkGitRepo: () => void;
checkGitRepo: (signal?: AbortSignal) => void | Promise<void>;
loadConfig: () => Promise<Config>;
getStagedDiff: () => DiffResult;
getStagedDiff: (signal?: AbortSignal) => DiffResult | Promise<DiffResult>;
buildProfile: (historySize: number) => Promise<StyleProfile>;
generateSuggestions: typeof generateSuggestions;
readMessageFile: (messageFile: string) => Promise<string>;
writeMessageFile: (messageFile: string, content: string) => Promise<void>;
writePendingEntryFile: (content: string) => Promise<void>;
removePendingEntryFile: () => Promise<void>;
warn: (message: string) => void;
timeoutMs?: number;
}

export interface InstalledCommitHooks {
Expand Down Expand Up @@ -602,12 +604,12 @@
});
}

export async function runPrepareCommitMsgHook(

Check failure on line 607 in src/git/hook.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=404-PF_commit-echo&issues=AaDHHyihDoFd0vvsvRj2&open=AaDHHyihDoFd0vvsvRj2&pullRequest=322
args: PrepareCommitMsgHookArgs,
deps: PrepareCommitMsgHookDeps = {
checkGitRepo,
checkGitRepo: checkGitRepoWithSignal,
loadConfig,
getStagedDiff,
getStagedDiff: (signal) => getStagedDiffWithSignal(process.cwd(), undefined, signal),
buildProfile,
generateSuggestions,
readMessageFile: async (messageFile) => readFile(messageFile, 'utf-8'),
Expand All @@ -622,39 +624,131 @@
return;
}

try {
deps.checkGitRepo();
const controller = new AbortController();
const timeoutMs = deps.timeoutMs ?? PREPARE_COMMIT_MSG_HOOK_TIMEOUT_MS;
const deadline = Date.now() + timeoutMs;
let timeoutError: Error | undefined;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
let originalMessage: string | undefined;
let messageWriteAttempted = false;

const config = await deps.loadConfig().catch(() => null);
if (!config) {
deps.warn('commit-echo hook: no configuration found; skipping.');
await clearPendingEntryFile(deps.removePendingEntryFile);
const abortForTimeout = () => {
if (timedOut) {
return;
}
timedOut = true;
timeoutError = new Error('timed out after ' + timeoutMs + 'ms; leaving commit message unchanged.');
controller.abort(timeoutError);
};

const diffResult = deps.getStagedDiff();
if (!diffResult.hasChanges) {
await clearPendingEntryFile(deps.removePendingEntryFile);
return;
const ensureWithinDeadline = () => {
if (controller.signal.aborted) {
throw controller.signal.reason instanceof Error
? controller.signal.reason
: (timeoutError ?? new Error('commit-echo hook request was cancelled'));
}

const profile = await deps.buildProfile(config.historySize);
const { suggestions } = await deps.generateSuggestions(config, diffResult.diff, profile);
const selected = suggestions[0];
if (!selected) {
deps.warn('commit-echo hook: no suggestions were generated; leaving commit message unchanged.');
if (Date.now() >= deadline) {
abortForTimeout();
throw timeoutError!;

Check warning on line 654 in src/git/hook.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected an error object to be thrown.

See more on https://sonarcloud.io/project/issues?id=404-PF_commit-echo&issues=AaDHHnqzZyITcfwk-tfA&open=AaDHHnqzZyITcfwk-tfA&pullRequest=322
}
};

const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
abortForTimeout();
reject(timeoutError!);
}, timeoutMs);
});

let hookOperation: Promise<void> | undefined;

try {
hookOperation = (async () => {
await deps.checkGitRepo(controller.signal);
ensureWithinDeadline();

let config: Config | null;
try {
config = await deps.loadConfig();
} catch {
ensureWithinDeadline();
config = null;
}
ensureWithinDeadline();

if (!config) {
deps.warn('commit-echo hook: no configuration found; skipping.');
await clearPendingEntryFile(deps.removePendingEntryFile);
return;
}

const diffResult = await deps.getStagedDiff(controller.signal);
ensureWithinDeadline();

if (!diffResult.hasChanges) {
await clearPendingEntryFile(deps.removePendingEntryFile);
return;
}

const profile = await deps.buildProfile(config.historySize);
ensureWithinDeadline();

const { suggestions } = await deps.generateSuggestions(
config,
diffResult.diff,
profile,
undefined,
undefined,
controller.signal,
);
ensureWithinDeadline();

const selected = suggestions[0];
if (!selected) {
deps.warn('commit-echo hook: no suggestions were generated; leaving commit message unchanged.');
await clearPendingEntryFile(deps.removePendingEntryFile);
return;
}

originalMessage = await deps.readMessageFile(args.messageFile);
ensureWithinDeadline();

const nextContent = buildHookCommitMessage(selected, originalMessage);
ensureWithinDeadline();
messageWriteAttempted = true;
await deps.writeMessageFile(args.messageFile, nextContent);
ensureWithinDeadline();

await deps.writePendingEntryFile(buildPendingHookEntry(config, diffResult.diff));
ensureWithinDeadline();
})();

await Promise.race([hookOperation, timeout]);
} catch (err) {
if (timedOut) {
if (messageWriteAttempted) {
await hookOperation?.catch(() => {});
} else {
void hookOperation?.catch(() => {});
}

if (messageWriteAttempted && originalMessage !== undefined) {
await deps.writeMessageFile(args.messageFile, originalMessage).catch(() => {});
}

await clearPendingEntryFile(deps.removePendingEntryFile);
const message = timeoutError?.message ?? (err instanceof Error ? err.message : String(err));
deps.warn('commit-echo hook: ' + message);
return;
}

const existingContent = await deps.readMessageFile(args.messageFile).catch(() => '');
const nextContent = buildHookCommitMessage(selected, existingContent);
await deps.writeMessageFile(args.messageFile, nextContent);
await deps.writePendingEntryFile(buildPendingHookEntry(config, diffResult.diff));
} catch (err) {
await clearPendingEntryFile(deps.removePendingEntryFile);
const message = err instanceof Error ? err.message : String(err);
deps.warn(`commit-echo hook: ${message}`);
deps.warn('commit-echo hook: ' + message);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/llm/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export async function generateSuggestions(
profileParam?: StyleProfile,
apiKeyParam?: string,
precomputedTruncation?: TruncationInfo,
signal?: AbortSignal,
): Promise<{
suggestions: Suggestion[];
profile: StyleProfile;
Expand Down Expand Up @@ -73,6 +74,7 @@ export async function generateSuggestions(
temperature: 0.7,
maxTokens: 1024,
apiKey,
signal,
});

const parsed = parseSuggestions(result.content);
Expand Down
5 changes: 5 additions & 0 deletions src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export class AnthropicProvider implements Provider {
body: JSON.stringify(body),
},
'Anthropic API request',
DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
new AbortController(),
params.signal,
);

if (!response.ok) {
Expand Down Expand Up @@ -92,6 +95,8 @@ export class AnthropicProvider implements Provider {
'Anthropic streaming request',
DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
controller,
params.signal,
false,
);

if (!response.ok) {
Expand Down
5 changes: 4 additions & 1 deletion src/providers/cohere.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ChatParams, ChatResult, Provider } from '../types.js';
import { fetchWithTimeout } from './request.js';
import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS, fetchWithTimeout } from './request.js';

export class CohereProvider implements Provider {
async complete(params: ChatParams): Promise<ChatResult> {
Expand Down Expand Up @@ -40,6 +40,9 @@ export class CohereProvider implements Provider {
body: JSON.stringify(body),
},
'Cohere API request',
DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
new AbortController(),
params.signal,
);

if (!response.ok) {
Expand Down
5 changes: 5 additions & 0 deletions src/providers/openai-compatible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export class OpenAICompatibleProvider implements Provider {
body: JSON.stringify(buildOpenAiRequestBody(params)),
},
'OpenAI-compatible API request',
DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
new AbortController(),
params.signal,
);

if (!response.ok) {
Expand Down Expand Up @@ -89,6 +92,8 @@ export class OpenAICompatibleProvider implements Provider {
'OpenAI-compatible streaming request',
DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS,
controller,
params.signal,
false,
);

if (!response.ok) {
Expand Down
Loading
Loading