Skip to content

Commit 3e5c501

Browse files
authored
Merge pull request #41 from wlgns5376/issue-40
fix(#40): 초기 셋팅 시 리포지토리 clone 없이 worktree 검증하는 버그 수정
2 parents 3eb9573 + 145f3ce commit 3e5c501

8 files changed

Lines changed: 703 additions & 405 deletions

File tree

package-lock.json

Lines changed: 369 additions & 369 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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;

src/services/manager/workspace-manager.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -436,20 +436,19 @@ export class WorkspaceManager implements WorkspaceManagerInterface {
436436
*/
437437
async isWorktreeValid(workspaceInfo: WorkspaceInfo): Promise<boolean> {
438438
try {
439-
// 디렉토리 존재 확인 - 이것이 가장 중요한 검증
439+
// 디렉토리 존재 확인
440440
const directoryExists = await this.checkDirectoryExists(workspaceInfo.workspaceDir);
441441
if (!directoryExists) {
442442
return false;
443443
}
444444

445-
// 디렉토리가 있으면 기본적으로 유효한 것으로 간주
446-
// Git 워크트리 세부 검증은 선택적으로 수행
445+
// .git 파일이 존재하고 유효한 worktree인지 검증
447446
const gitPath = path.join(workspaceInfo.workspaceDir, '.git');
448447
try {
449448
const gitContent = await fs.readFile(gitPath, 'utf-8');
450449
// Git worktree는 .git 파일에 "gitdir: ..." 형태로 저장됨
451450
const isWorktree = gitContent.trim().startsWith('gitdir:');
452-
451+
453452
this.dependencies.logger.debug('Worktree validation result', {
454453
taskId: workspaceInfo.taskId,
455454
workspaceDir: workspaceInfo.workspaceDir,
@@ -458,22 +457,22 @@ export class WorkspaceManager implements WorkspaceManagerInterface {
458457
gitContent: gitContent.substring(0, 100) // 첫 100자만 로그
459458
});
460459

461-
// Git worktree가 아니어도 디렉토리가 있으면 재사용 가능
462460
if (!isWorktree) {
463-
this.dependencies.logger.info('Directory exists but not a valid worktree, will be reused anyway', {
461+
this.dependencies.logger.debug('Directory exists but not a valid worktree', {
464462
taskId: workspaceInfo.taskId,
465463
workspaceDir: workspaceInfo.workspaceDir
466464
});
467465
}
468466

469-
return true; // 디렉토리가 있으면 항상 유효
467+
// 유효한 worktree일 때만 true 반환
468+
return isWorktree;
470469
} catch {
471-
// .git 파일이 없어도 디렉토리가 있으면 사용 가능
472-
this.dependencies.logger.debug('.git file not found, but directory exists and will be reused', {
470+
// .git 파일이 없으면 유효하지 않은 worktree
471+
this.dependencies.logger.debug('.git file not found, directory is not a valid worktree', {
473472
taskId: workspaceInfo.taskId,
474473
workspaceDir: workspaceInfo.workspaceDir
475474
});
476-
return true;
475+
return false;
477476
}
478477
} catch (error) {
479478
this.dependencies.logger.error('Error validating workspace directory', {

src/services/worker/workspace-setup.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export class WorkspaceSetup implements WorkspaceSetupInterface {
9898
try {
9999
// 기본 디렉토리 존재 확인
100100
await fs.access(workspaceInfo.workspaceDir);
101-
101+
102102
// 디렉토리인지 확인
103103
const stat = await fs.stat(workspaceInfo.workspaceDir);
104104
if (!stat.isDirectory()) {
@@ -114,22 +114,32 @@ export class WorkspaceSetup implements WorkspaceSetupInterface {
114114
});
115115
}
116116

117-
// Git worktree 검증은 선택적으로 수행 - 실패해도 디렉토리가 있으면 재사용
118-
if (this.dependencies.workspaceManager && typeof this.dependencies.workspaceManager.isWorktreeValid === 'function') {
119-
try {
120-
const isWorktreeValid = await this.dependencies.workspaceManager.isWorktreeValid(workspaceInfo);
121-
if (!isWorktreeValid) {
122-
this.dependencies.logger.info('Git worktree validation failed, but reusing existing directory', {
117+
// Git worktree 검증 - worktree가 생성되었다고 표시된 경우에만 검증
118+
// 초기 셋팅 시에는 worktreeCreated가 false이므로 worktree 검증을 건너뜀
119+
if (workspaceInfo.worktreeCreated) {
120+
if (this.dependencies.workspaceManager && typeof this.dependencies.workspaceManager.isWorktreeValid === 'function') {
121+
try {
122+
const isWorktreeValid = await this.dependencies.workspaceManager.isWorktreeValid(workspaceInfo);
123+
if (!isWorktreeValid) {
124+
this.dependencies.logger.warn('Worktree validation failed, workspace is invalid', {
125+
taskId: workspaceInfo.taskId,
126+
reason: 'Git worktree is not valid'
127+
});
128+
return false;
129+
}
130+
} catch (worktreeError) {
131+
this.dependencies.logger.warn('Worktree validation error, workspace is invalid', {
123132
taskId: workspaceInfo.taskId,
124-
reason: 'Directory exists and will be reused'
133+
error: worktreeError
125134
});
135+
return false;
126136
}
127-
} catch (worktreeError) {
128-
this.dependencies.logger.debug('Git worktree validation error, but continuing with existing directory', {
129-
taskId: workspaceInfo.taskId,
130-
error: worktreeError
131-
});
132137
}
138+
} else {
139+
this.dependencies.logger.debug('Skipping worktree validation (worktree not created yet)', {
140+
taskId: workspaceInfo.taskId,
141+
worktreeCreated: workspaceInfo.worktreeCreated
142+
});
133143
}
134144

135145
this.dependencies.logger.debug('Workspace environment validation passed', {

tests/integration/task-reassignment.test.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -242,18 +242,20 @@ describe('Task Reassignment Integration Tests', () => {
242242
// When: workspace 유효성 검증
243243
const isValid = await workspaceManager.isWorktreeValid(workspaceInfo);
244244

245-
// Then: .git 파일이 없어도 디렉토리가 있으면 유효한 것으로 판단 (재사용 가능)
246-
expect(isValid).toBe(true);
245+
// Then: .git 파일이 없으면 유효하지 않은 worktree로 판단
246+
expect(isValid).toBe(false);
247247
});
248248

249249
it('WorkerPoolManager의 canAssignIdleWorkerToTask가 올바르게 작동한다', async () => {
250-
// Given: workspace가 있는 작업
250+
// Given: 유효한 worktree가 있는 작업
251251
const taskId = 'test-task-4';
252252
const workspaceInfo = await workspaceManager.createWorkspace(
253253
taskId,
254254
'test-owner/test-repo'
255255
);
256+
// 디렉토리와 .git 파일 생성 (유효한 worktree)
256257
await fs.mkdir(workspaceInfo.workspaceDir, { recursive: true });
258+
await fs.writeFile(path.join(workspaceInfo.workspaceDir, '.git'), 'gitdir: /path/to/repo/.git/worktrees/test');
257259

258260
// Given: idle 상태 Worker
259261
const worker = await workerPoolManager.getAvailableWorker();
@@ -266,29 +268,30 @@ describe('Task Reassignment Integration Tests', () => {
266268
{ id: taskId, title: '테스트 작업 4' }
267269
);
268270

269-
// Then: workspace가 있으므로 할당 가능
271+
// Then: 유효한 worktree가 있으므로 할당 가능
270272
expect(canAssign).toBe(true);
271273
});
272274

273275
it('TaskAssignmentValidator의 우선순위 시스템이 올바르게 작동한다', async () => {
274-
// Given: workspace가 있는 작업과 없는 작업
276+
// Given: 유효한 worktree가 있는 작업과 없는 작업
275277
const taskWithWorkspace = 'task-with-workspace';
276278
const taskWithoutWorkspace = 'task-without-workspace';
277279

278-
// workspace 생성
280+
// 유효한 worktree 생성 (디렉토리 + .git 파일)
279281
const workspaceInfo = await workspaceManager.createWorkspace(
280282
taskWithWorkspace,
281283
'test-owner/test-repo'
282284
);
283285
await fs.mkdir(workspaceInfo.workspaceDir, { recursive: true });
286+
await fs.writeFile(path.join(workspaceInfo.workspaceDir, '.git'), 'gitdir: /path/to/repo/.git/worktrees/test');
284287

285288
// When: 우선순위 확인
286289
const priorityWithWorkspace = await workerPoolManager['taskAssignmentValidator']
287290
.getTaskReassignmentPriority(taskWithWorkspace);
288291
const priorityWithoutWorkspace = await workerPoolManager['taskAssignmentValidator']
289292
.getTaskReassignmentPriority(taskWithoutWorkspace);
290293

291-
// Then: workspace가 있는 작업이 더 높은 우선순위를 가짐
294+
// Then: 유효한 worktree가 있는 작업이 더 높은 우선순위를 가짐
292295
expect(priorityWithWorkspace).toBe(10); // 높은 우선순위
293296
expect(priorityWithoutWorkspace).toBe(5); // 중간 우선순위
294297
});

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)