Skip to content

Commit 9be9024

Browse files
authored
feat(git): add operation timeouts and git ref validation (#139)
- Add dedicated timeouts for ref resolution, description, and worktree creation - Introduce lightweight ref validation (trim + max length 256) - Update git utilities to use new timeout configuration - Extend unit tests for validation and whitespace handling
1 parent 0fa9e03 commit 9be9024

File tree

4 files changed

+121
-17
lines changed

4 files changed

+121
-17
lines changed

src/cli/handlers/compareHandler.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ import {
2424
createBaselinePaths,
2525
cleanupBaselinePaths,
2626
filterGitIgnoredFiles,
27+
GIT_REF_RESOLVE_TIMEOUT,
28+
GIT_REF_DESCRIBE_TIMEOUT,
29+
GIT_WORKTREE_TIMEOUT,
2730
type GitBaselinePaths,
2831
} from '../../utils/git.js';
2932

@@ -352,11 +355,12 @@ async function handleGitBaselineCompare(options: {
352355
}
353356

354357
// Resolve and describe the git ref
358+
// Use appropriate timeouts: ref resolution/description are fast, worktree creation can be slow
355359
let commitHash: string;
356360
let refDescription: string;
357361
try {
358-
commitHash = await resolveGitRef(ref);
359-
refDescription = await describeGitRef(ref);
362+
commitHash = await resolveGitRef(ref, { timeout: GIT_REF_RESOLVE_TIMEOUT });
363+
refDescription = await describeGitRef(ref, { timeout: GIT_REF_DESCRIBE_TIMEOUT });
360364
} catch (error) {
361365
console.error(`❌ ${(error as Error).message}`);
362366
return process.exit(1);
@@ -385,7 +389,7 @@ async function handleGitBaselineCompare(options: {
385389
console.log(`🔄 Creating worktree at ${refDescription}...`);
386390
}
387391

388-
const worktree = await createWorktree(ref, paths.worktreeDir);
392+
const worktree = await createWorktree(ref, paths.worktreeDir, { timeout: GIT_WORKTREE_TIMEOUT });
389393

390394
// Step 2: Generate context for baseline (from worktree)
391395
if (!quiet) {

src/utils/git.ts

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,33 @@ export interface GitWorktreeResult {
2727
export interface GitOptions {
2828
/** Working directory for git commands (default: process.cwd()) */
2929
cwd?: string;
30-
/** Timeout in milliseconds (default: 30000) */
30+
/** Timeout in milliseconds (default: 30000 for regular operations) */
3131
timeout?: number;
3232
}
3333

34+
/**
35+
* Default timeout for regular git operations (30 seconds)
36+
*/
37+
const DEFAULT_GIT_TIMEOUT = 30000;
38+
39+
/**
40+
* Timeout for git ref resolution operations (15 seconds)
41+
* git rev-parse is lightweight and should complete quickly
42+
*/
43+
export const GIT_REF_RESOLVE_TIMEOUT = 15000;
44+
45+
/**
46+
* Timeout for git ref description operations (15 seconds)
47+
* git rev-parse --abbrev-ref and --short are lightweight and should complete quickly
48+
*/
49+
export const GIT_REF_DESCRIBE_TIMEOUT = 15000;
50+
51+
/**
52+
* Timeout for git worktree creation (60 seconds)
53+
* Worktree creation can be slow on large repos due to file checkout operations
54+
*/
55+
export const GIT_WORKTREE_TIMEOUT = 60000;
56+
3457
/**
3558
* Execute a git command and return stdout
3659
* Uses spawn instead of exec for better security (no shell interpretation)
@@ -41,7 +64,7 @@ async function execGit(
4164
options: GitOptions = {}
4265
): Promise<string> {
4366
const cwd = options.cwd ?? process.cwd();
44-
const timeout = options.timeout ?? 30000;
67+
const timeout = options.timeout ?? DEFAULT_GIT_TIMEOUT;
4568
const command = `git ${args.join(' ')}`;
4669

4770
debugLog('git', `Executing: ${command}`, { cwd });
@@ -132,23 +155,52 @@ export async function supportsWorktrees(options: GitOptions = {}): Promise<boole
132155
}
133156
}
134157

158+
/**
159+
* Maximum length for git refs (256 characters)
160+
* This is a defense-in-depth measure - Git itself will validate refs
161+
*/
162+
const MAX_REF_LENGTH = 256;
163+
164+
/**
165+
* Validate git ref input (lightweight validation)
166+
* Trims whitespace and checks length, but lets Git be the source of truth for semantic validity
167+
*
168+
* @param ref - Git ref to validate
169+
* @throws Error if ref is empty or exceeds length limit
170+
*/
171+
function validateGitRef(ref: string): void {
172+
const trimmed = ref.trim();
173+
if (trimmed.length === 0) {
174+
throw new Error('Invalid baseline ref: ref is empty or exceeds 256 characters');
175+
}
176+
if (trimmed.length > MAX_REF_LENGTH) {
177+
throw new Error('Invalid baseline ref: ref is empty or exceeds 256 characters');
178+
}
179+
}
180+
135181
/**
136182
* Resolve a git ref to its commit hash
137183
* Validates that the ref exists
138184
*
139185
* @param ref - Git ref (branch, tag, commit, HEAD, HEAD~1, etc.)
140186
* @returns The resolved commit hash
141-
* @throws Error if ref doesn't exist
187+
* @throws Error if ref doesn't exist or is invalid
142188
*/
143189
export async function resolveGitRef(
144190
ref: string,
145191
options: GitOptions = {}
146192
): Promise<string> {
193+
// Lightweight validation: trim whitespace and check length
194+
// Let Git be the source of truth for semantic validity
195+
validateGitRef(ref);
196+
197+
const trimmedRef = ref.trim();
198+
147199
try {
148-
const hash = await execGit(['rev-parse', '--verify', ref], options);
200+
const hash = await execGit(['rev-parse', '--verify', trimmedRef], options);
149201
return hash;
150202
} catch (error) {
151-
throw new Error(`Invalid git ref "${ref}": ref does not exist`);
203+
throw new Error(`Invalid git ref "${trimmedRef}": ref does not exist`);
152204
}
153205
}
154206

@@ -160,10 +212,12 @@ export async function describeGitRef(
160212
ref: string,
161213
options: GitOptions = {}
162214
): Promise<string> {
215+
// Use trimmed ref for consistency with resolveGitRef
216+
const trimmedRef = ref.trim();
163217
try {
164218
// Try to get a symbolic name first
165219
const symbolic = await execGit(
166-
['rev-parse', '--abbrev-ref', ref],
220+
['rev-parse', '--abbrev-ref', trimmedRef],
167221
options
168222
);
169223
if (symbolic && symbolic !== 'HEAD') {
@@ -175,10 +229,10 @@ export async function describeGitRef(
175229

176230
try {
177231
// Fall back to short commit hash
178-
const shortHash = await execGit(['rev-parse', '--short', ref], options);
232+
const shortHash = await execGit(['rev-parse', '--short', trimmedRef], options);
179233
return shortHash;
180234
} catch {
181-
return ref; // Return original if all else fails
235+
return trimmedRef; // Return trimmed ref if all else fails
182236
}
183237
}
184238

@@ -195,6 +249,9 @@ export async function createWorktree(
195249
targetDir?: string,
196250
options: GitOptions = {}
197251
): Promise<GitWorktreeResult> {
252+
// Use trimmed ref for consistency
253+
const trimmedRef = ref.trim();
254+
198255
// Validate we're in a git repo
199256
if (!(await isGitRepo(options))) {
200257
throw new Error('Not a git repository');
@@ -205,16 +262,16 @@ export async function createWorktree(
205262
throw new Error('Git worktrees not supported (requires git >= 2.5)');
206263
}
207264

208-
// Resolve the ref to a commit hash
209-
const commitHash = await resolveGitRef(ref, options);
265+
// Resolve the ref to a commit hash (this will also validate the ref)
266+
const commitHash = await resolveGitRef(trimmedRef, options);
210267

211268
// Generate worktree path if not provided
212269
const worktreePath = targetDir ?? join(
213270
tmpdir(),
214271
`logicstamp-worktree-${Date.now()}-${commitHash.substring(0, 8)}`
215272
);
216273

217-
debugLog('git', `Creating worktree at ${worktreePath} for ref ${ref}`, {
274+
debugLog('git', `Creating worktree at ${worktreePath} for ref ${trimmedRef}`, {
218275
commitHash,
219276
});
220277

@@ -236,7 +293,7 @@ export async function createWorktree(
236293
return {
237294
worktreePath,
238295
commitHash,
239-
ref,
296+
ref: trimmedRef,
240297
};
241298
} catch (error) {
242299
// Clean up on failure
@@ -248,12 +305,12 @@ export async function createWorktree(
248305

249306
const err = error as Error;
250307
debugError('git', 'createWorktree', {
251-
ref,
308+
ref: trimmedRef,
252309
targetDir: worktreePath,
253310
message: err.message,
254311
});
255312

256-
throw new Error(`Failed to create worktree for "${ref}": ${err.message}`);
313+
throw new Error(`Failed to create worktree for "${trimmedRef}": ${err.message}`);
257314
}
258315
}
259316

tests/unit/handlers/compareHandler.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ vi.mock('../../../src/utils/git.js', () => ({
9797
createBaselinePaths: mockCreateBaselinePaths,
9898
cleanupBaselinePaths: mockCleanupBaselinePaths,
9999
filterGitIgnoredFiles: mockFilterGitIgnoredFiles,
100+
GIT_REF_RESOLVE_TIMEOUT: 15000,
101+
GIT_REF_DESCRIBE_TIMEOUT: 15000,
102+
GIT_WORKTREE_TIMEOUT: 60000,
100103
}));
101104

102105
vi.mock('node:readline', () => ({

tests/unit/utils/git.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,46 @@ describe('git utilities', () => {
238238
expect(result).toBe('abc123def456');
239239
});
240240

241+
it('should trim whitespace from ref', async () => {
242+
mockSpawnResult.mockReturnValue({ stdout: 'abc123def456', stderr: '', code: 0, error: null });
243+
244+
const result = await resolveGitRef(' main ');
245+
expect(result).toBe('abc123def456');
246+
// Verify it was called with trimmed ref
247+
expect(mockSpawn).toHaveBeenCalledWith(
248+
'git',
249+
['rev-parse', '--verify', 'main'],
250+
expect.any(Object)
251+
);
252+
});
253+
254+
it('should reject empty ref', async () => {
255+
await expect(resolveGitRef('')).rejects.toThrow(
256+
'Invalid baseline ref: ref is empty or exceeds 256 characters'
257+
);
258+
});
259+
260+
it('should reject ref with only whitespace', async () => {
261+
await expect(resolveGitRef(' ')).rejects.toThrow(
262+
'Invalid baseline ref: ref is empty or exceeds 256 characters'
263+
);
264+
});
265+
266+
it('should reject ref exceeding 256 characters', async () => {
267+
const longRef = 'a'.repeat(257);
268+
await expect(resolveGitRef(longRef)).rejects.toThrow(
269+
'Invalid baseline ref: ref is empty or exceeds 256 characters'
270+
);
271+
});
272+
273+
it('should accept ref with exactly 256 characters', async () => {
274+
const ref256 = 'a'.repeat(256);
275+
mockSpawnResult.mockReturnValue({ stdout: 'abc123def456', stderr: '', code: 0, error: null });
276+
277+
const result = await resolveGitRef(ref256);
278+
expect(result).toBe('abc123def456');
279+
});
280+
241281
it('should throw for invalid ref', async () => {
242282
mockSpawnResult.mockReturnValue({
243283
stdout: '',

0 commit comments

Comments
 (0)