Skip to content

Commit 145f3ce

Browse files
wlgns5376claude
andcommitted
fix(#40): git clone 시 따옴표 처리 버그 수정
## 문제 - Git clone 실행 시 `protocol '"https' is not supported` 에러 발생 - safeExec 메서드가 명령어를 split(' ')로 파싱하면서 따옴표가 인자에 포함됨 - spawn에 전달될 때 따옴표가 제거되지 않아 Git이 프로토콜을 인식하지 못함 ## 해결 - parseCommand() 메서드 추가: 따옴표를 고려한 명령어 파싱 - 따옴표 안의 공백을 구분자로 인식하지 않도록 처리 - URL, 경로 등에 공백이나 특수문자가 있어도 올바르게 처리 ## 테스트 - 따옴표가 포함된 URL 파싱 테스트 추가 - 공백이 포함된 경로 파싱 테스트 추가 - 여러 인자가 있는 명령어 파싱 테스트 추가 - 총 9개 테스트 모두 통과 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 637d2cf commit 145f3ce

2 files changed

Lines changed: 154 additions & 4 deletions

File tree

src/services/git/git.service.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,65 @@ export class GitService implements GitServiceInterface {
2222
private readonly dependencies: GitServiceDependencies
2323
) {}
2424

25+
/**
26+
* 명령어 문자열을 파싱하여 배열로 변환 (따옴표 처리 포함)
27+
*/
28+
private parseCommand(command: string): string[] {
29+
const parts: string[] = [];
30+
let current = '';
31+
let inQuotes = false;
32+
let quoteChar = '';
33+
34+
for (let i = 0; i < command.length; i++) {
35+
const char = command[i];
36+
37+
if ((char === '"' || char === "'") && !inQuotes) {
38+
// 따옴표 시작
39+
inQuotes = true;
40+
quoteChar = char;
41+
} else if (char === quoteChar && inQuotes) {
42+
// 따옴표 종료
43+
inQuotes = false;
44+
quoteChar = '';
45+
} else if (char === ' ' && !inQuotes) {
46+
// 공백이고 따옴표 밖이면 구분자로 처리
47+
if (current.length > 0) {
48+
parts.push(current);
49+
current = '';
50+
}
51+
} else {
52+
// 일반 문자 추가
53+
current += char;
54+
}
55+
}
56+
57+
// 마지막 파트 추가
58+
if (current.length > 0) {
59+
parts.push(current);
60+
}
61+
62+
return parts;
63+
}
64+
2565
/**
2666
* 프로세스 추적을 포함한 안전한 exec 실행
2767
*/
2868
private async safeExec(command: string, options: { cwd?: string; timeout?: number } = {}): Promise<{ stdout: string; stderr: string }> {
2969
return new Promise((resolve, reject) => {
3070
const timeoutMs = options.timeout || this.dependencies.gitOperationTimeoutMs;
31-
32-
this.dependencies.logger.debug('Executing git command', {
71+
72+
this.dependencies.logger.debug('Executing git command', {
3373
command: command.substring(0, 100),
3474
cwd: options.cwd,
3575
timeout: timeoutMs
3676
});
3777

3878
// spawn을 사용하여 프로세스 추적
39-
const parts = command.split(' ').filter(part => part.length > 0);
79+
// 따옴표를 고려하여 명령어를 파싱
80+
const parts = this.parseCommand(command);
4081
const cmd = parts[0];
4182
const args = parts.slice(1);
42-
83+
4384
if (!cmd) {
4485
reject(new Error('Invalid command: empty command string'));
4586
return;

tests/unit/services/git/git.service.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,4 +276,113 @@ describe('GitService - 프로세스 관리', () => {
276276
expect(mockedSpawn).toHaveBeenCalledTimes(3);
277277
});
278278
});
279+
280+
describe('parseCommand - 명령어 파싱 테스트', () => {
281+
it('따옴표가 포함된 URL을 올바르게 파싱해야 한다', async () => {
282+
// Given: 따옴표가 포함된 clone 명령어
283+
class MockChildProcess extends EventEmitter {
284+
stdout = new EventEmitter();
285+
stderr = new EventEmitter();
286+
stdin = { end: jest.fn() };
287+
pid = 12345;
288+
kill = jest.fn();
289+
}
290+
291+
const mockChild = new MockChildProcess();
292+
mockedSpawn.mockReturnValue(mockChild as any);
293+
294+
// When: clone 실행
295+
const clonePromise = gitService.clone('https://github.com/test/repo.git', '/tmp/test-repo');
296+
297+
// 비동기 실행을 위해 대기
298+
process.nextTick(() => {
299+
mockChild.stderr.emit('data', 'Cloning into...\n');
300+
mockChild.emit('close', 0);
301+
});
302+
303+
await clonePromise;
304+
305+
// Then: spawn이 올바른 인자로 호출되었는지 확인
306+
expect(mockedSpawn).toHaveBeenCalledWith(
307+
'git',
308+
['clone', 'https://github.com/test/repo.git', '/tmp/test-repo'],
309+
expect.any(Object)
310+
);
311+
});
312+
313+
it('공백이 포함된 경로를 올바르게 파싱해야 한다', async () => {
314+
// Given: 공백이 포함된 경로
315+
class MockChildProcess extends EventEmitter {
316+
stdout = new EventEmitter();
317+
stderr = new EventEmitter();
318+
stdin = { end: jest.fn() };
319+
pid = 12345;
320+
kill = jest.fn();
321+
}
322+
323+
const mockChild = new MockChildProcess();
324+
mockedSpawn.mockReturnValue(mockChild as any);
325+
326+
// When: clone 실행 (공백 포함 경로)
327+
const clonePromise = gitService.clone('https://github.com/test/repo.git', '/tmp/test repo with spaces');
328+
329+
// 비동기 실행을 위해 대기
330+
process.nextTick(() => {
331+
mockChild.stderr.emit('data', 'Cloning into...\n');
332+
mockChild.emit('close', 0);
333+
});
334+
335+
await clonePromise;
336+
337+
// Then: spawn이 올바른 인자로 호출되었는지 확인
338+
expect(mockedSpawn).toHaveBeenCalledWith(
339+
'git',
340+
['clone', 'https://github.com/test/repo.git', '/tmp/test repo with spaces'],
341+
expect.any(Object)
342+
);
343+
});
344+
345+
it('여러 인자가 있는 명령어를 올바르게 파싱해야 한다', async () => {
346+
// Given: worktree add 명령어
347+
class MockChildProcess extends EventEmitter {
348+
stdout = new EventEmitter();
349+
stderr = new EventEmitter();
350+
stdin = { end: jest.fn() };
351+
pid = 12345;
352+
kill = jest.fn();
353+
}
354+
355+
const mockChild = new MockChildProcess();
356+
mockedSpawn.mockReturnValue(mockChild as any);
357+
358+
// 유효한 저장소로 만들기 위한 설정
359+
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); // isValidRepository
360+
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); // worktree list
361+
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); // worktree prune
362+
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); // branchExists
363+
mockExecAsync.mockResolvedValueOnce({ stdout: 'main', stderr: '' }); // getMainBranchName
364+
365+
// When: worktree 생성
366+
const worktreePromise = gitService.createWorktree(
367+
'/tmp/main-repo',
368+
'feature-branch',
369+
'/tmp/worktree path',
370+
'main'
371+
);
372+
373+
// 비동기 실행을 위해 대기
374+
process.nextTick(() => {
375+
mockChild.stderr.emit('data', 'Preparing worktree...\n');
376+
mockChild.emit('close', 0);
377+
});
378+
379+
await worktreePromise;
380+
381+
// Then: spawn이 올바른 인자로 호출되었는지 확인 (마지막 호출)
382+
const lastCall = mockedSpawn.mock.calls[mockedSpawn.mock.calls.length - 1];
383+
expect(lastCall).toBeDefined();
384+
expect(lastCall![0]).toBe('git');
385+
expect(lastCall![1]).toEqual(['worktree', 'add', '-b', 'feature-branch', '/tmp/worktree path', 'main']);
386+
});
387+
});
279388
});

0 commit comments

Comments
 (0)