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
738 changes: 369 additions & 369 deletions package-lock.json

Large diffs are not rendered by default.

49 changes: 45 additions & 4 deletions src/services/git/git.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,65 @@ export class GitService implements GitServiceInterface {
private readonly dependencies: GitServiceDependencies
) {}

/**
* 명령어 문자열을 파싱하여 배열로 변환 (따옴표 처리 포함)
*/
private parseCommand(command: string): string[] {
const parts: string[] = [];
let current = '';
let inQuotes = false;
let quoteChar = '';

for (let i = 0; i < command.length; i++) {
const char = command[i];

if ((char === '"' || char === "'") && !inQuotes) {
// 따옴표 시작
inQuotes = true;
quoteChar = char;
} else if (char === quoteChar && inQuotes) {
// 따옴표 종료
inQuotes = false;
quoteChar = '';
} else if (char === ' ' && !inQuotes) {
// 공백이고 따옴표 밖이면 구분자로 처리
if (current.length > 0) {
parts.push(current);
current = '';
}
} else {
// 일반 문자 추가
current += char;
}
}

// 마지막 파트 추가
if (current.length > 0) {
parts.push(current);
}

return parts;
}

/**
* 프로세스 추적을 포함한 안전한 exec 실행
*/
private async safeExec(command: string, options: { cwd?: string; timeout?: number } = {}): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const timeoutMs = options.timeout || this.dependencies.gitOperationTimeoutMs;
this.dependencies.logger.debug('Executing git command', {

this.dependencies.logger.debug('Executing git command', {
command: command.substring(0, 100),
cwd: options.cwd,
timeout: timeoutMs
});

// spawn을 사용하여 프로세스 추적
const parts = command.split(' ').filter(part => part.length > 0);
// 따옴표를 고려하여 명령어를 파싱
const parts = this.parseCommand(command);
const cmd = parts[0];
const args = parts.slice(1);

if (!cmd) {
reject(new Error('Invalid command: empty command string'));
return;
Expand Down
19 changes: 9 additions & 10 deletions src/services/manager/workspace-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,20 +436,19 @@ export class WorkspaceManager implements WorkspaceManagerInterface {
*/
async isWorktreeValid(workspaceInfo: WorkspaceInfo): Promise<boolean> {
try {
// 디렉토리 존재 확인 - 이것이 가장 중요한 검증
// 디렉토리 존재 확인
const directoryExists = await this.checkDirectoryExists(workspaceInfo.workspaceDir);
if (!directoryExists) {
return false;
}

// 디렉토리가 있으면 기본적으로 유효한 것으로 간주
// Git 워크트리 세부 검증은 선택적으로 수행
// .git 파일이 존재하고 유효한 worktree인지 검증
const gitPath = path.join(workspaceInfo.workspaceDir, '.git');
try {
const gitContent = await fs.readFile(gitPath, 'utf-8');
// Git worktree는 .git 파일에 "gitdir: ..." 형태로 저장됨
const isWorktree = gitContent.trim().startsWith('gitdir:');

this.dependencies.logger.debug('Worktree validation result', {
taskId: workspaceInfo.taskId,
workspaceDir: workspaceInfo.workspaceDir,
Expand All @@ -458,22 +457,22 @@ export class WorkspaceManager implements WorkspaceManagerInterface {
gitContent: gitContent.substring(0, 100) // 첫 100자만 로그
});

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

return true; // 디렉토리가 있으면 항상 유효
// 유효한 worktree일 때만 true 반환
return isWorktree;
} catch {
// .git 파일이 없어도 디렉토리가 있으면 사용 가능
this.dependencies.logger.debug('.git file not found, but directory exists and will be reused', {
// .git 파일이 없으면 유효하지 않은 worktree
this.dependencies.logger.debug('.git file not found, directory is not a valid worktree', {
taskId: workspaceInfo.taskId,
workspaceDir: workspaceInfo.workspaceDir
});
return true;
return false;
}
} catch (error) {
this.dependencies.logger.error('Error validating workspace directory', {
Expand Down
36 changes: 23 additions & 13 deletions src/services/worker/workspace-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export class WorkspaceSetup implements WorkspaceSetupInterface {
try {
// 기본 디렉토리 존재 확인
await fs.access(workspaceInfo.workspaceDir);

// 디렉토리인지 확인
const stat = await fs.stat(workspaceInfo.workspaceDir);
if (!stat.isDirectory()) {
Expand All @@ -114,22 +114,32 @@ export class WorkspaceSetup implements WorkspaceSetupInterface {
});
}

// Git worktree 검증은 선택적으로 수행 - 실패해도 디렉토리가 있으면 재사용
if (this.dependencies.workspaceManager && typeof this.dependencies.workspaceManager.isWorktreeValid === 'function') {
try {
const isWorktreeValid = await this.dependencies.workspaceManager.isWorktreeValid(workspaceInfo);
if (!isWorktreeValid) {
this.dependencies.logger.info('Git worktree validation failed, but reusing existing directory', {
// Git worktree 검증 - worktree가 생성되었다고 표시된 경우에만 검증
// 초기 셋팅 시에는 worktreeCreated가 false이므로 worktree 검증을 건너뜀
if (workspaceInfo.worktreeCreated) {
if (this.dependencies.workspaceManager && typeof this.dependencies.workspaceManager.isWorktreeValid === 'function') {
try {
const isWorktreeValid = await this.dependencies.workspaceManager.isWorktreeValid(workspaceInfo);
if (!isWorktreeValid) {
this.dependencies.logger.warn('Worktree validation failed, workspace is invalid', {
taskId: workspaceInfo.taskId,
reason: 'Git worktree is not valid'
});
return false;
}
} catch (worktreeError) {
this.dependencies.logger.warn('Worktree validation error, workspace is invalid', {
taskId: workspaceInfo.taskId,
reason: 'Directory exists and will be reused'
error: worktreeError
});
return false;
}
} catch (worktreeError) {
this.dependencies.logger.debug('Git worktree validation error, but continuing with existing directory', {
taskId: workspaceInfo.taskId,
error: worktreeError
});
}
} else {
this.dependencies.logger.debug('Skipping worktree validation (worktree not created yet)', {
taskId: workspaceInfo.taskId,
worktreeCreated: workspaceInfo.worktreeCreated
});
}

this.dependencies.logger.debug('Workspace environment validation passed', {
Expand Down
17 changes: 10 additions & 7 deletions tests/integration/task-reassignment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,18 +242,20 @@ describe('Task Reassignment Integration Tests', () => {
// When: workspace 유효성 검증
const isValid = await workspaceManager.isWorktreeValid(workspaceInfo);

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

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

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

// Then: workspace가 있으므로 할당 가능
// Then: 유효한 worktree가 있으므로 할당 가능
expect(canAssign).toBe(true);
});

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

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

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

// Then: workspace가 있는 작업이 더 높은 우선순위를 가짐
// Then: 유효한 worktree가 있는 작업이 더 높은 우선순위를 가짐
expect(priorityWithWorkspace).toBe(10); // 높은 우선순위
expect(priorityWithoutWorkspace).toBe(5); // 중간 우선순위
});
Expand Down
109 changes: 109 additions & 0 deletions tests/unit/services/git/git.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,4 +276,113 @@ describe('GitService - 프로세스 관리', () => {
expect(mockedSpawn).toHaveBeenCalledTimes(3);
});
});

describe('parseCommand - 명령어 파싱 테스트', () => {
it('따옴표가 포함된 URL을 올바르게 파싱해야 한다', async () => {
// Given: 따옴표가 포함된 clone 명령어
class MockChildProcess extends EventEmitter {
stdout = new EventEmitter();
stderr = new EventEmitter();
stdin = { end: jest.fn() };
pid = 12345;
kill = jest.fn();
}

const mockChild = new MockChildProcess();
mockedSpawn.mockReturnValue(mockChild as any);

// When: clone 실행
const clonePromise = gitService.clone('https://github.com/test/repo.git', '/tmp/test-repo');

// 비동기 실행을 위해 대기
process.nextTick(() => {
mockChild.stderr.emit('data', 'Cloning into...\n');
mockChild.emit('close', 0);
});

await clonePromise;

// Then: spawn이 올바른 인자로 호출되었는지 확인
expect(mockedSpawn).toHaveBeenCalledWith(
'git',
['clone', 'https://github.com/test/repo.git', '/tmp/test-repo'],
expect.any(Object)
);
});

it('공백이 포함된 경로를 올바르게 파싱해야 한다', async () => {
// Given: 공백이 포함된 경로
class MockChildProcess extends EventEmitter {
stdout = new EventEmitter();
stderr = new EventEmitter();
stdin = { end: jest.fn() };
pid = 12345;
kill = jest.fn();
}

const mockChild = new MockChildProcess();
mockedSpawn.mockReturnValue(mockChild as any);

// When: clone 실행 (공백 포함 경로)
const clonePromise = gitService.clone('https://github.com/test/repo.git', '/tmp/test repo with spaces');

// 비동기 실행을 위해 대기
process.nextTick(() => {
mockChild.stderr.emit('data', 'Cloning into...\n');
mockChild.emit('close', 0);
});

await clonePromise;

// Then: spawn이 올바른 인자로 호출되었는지 확인
expect(mockedSpawn).toHaveBeenCalledWith(
'git',
['clone', 'https://github.com/test/repo.git', '/tmp/test repo with spaces'],
expect.any(Object)
);
});

it('여러 인자가 있는 명령어를 올바르게 파싱해야 한다', async () => {
// Given: worktree add 명령어
class MockChildProcess extends EventEmitter {
stdout = new EventEmitter();
stderr = new EventEmitter();
stdin = { end: jest.fn() };
pid = 12345;
kill = jest.fn();
}

const mockChild = new MockChildProcess();
mockedSpawn.mockReturnValue(mockChild as any);

// 유효한 저장소로 만들기 위한 설정
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); // isValidRepository
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); // worktree list
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); // worktree prune
mockExecAsync.mockResolvedValueOnce({ stdout: '', stderr: '' }); // branchExists
mockExecAsync.mockResolvedValueOnce({ stdout: 'main', stderr: '' }); // getMainBranchName

// When: worktree 생성
const worktreePromise = gitService.createWorktree(
'/tmp/main-repo',
'feature-branch',
'/tmp/worktree path',
'main'
);

// 비동기 실행을 위해 대기
process.nextTick(() => {
mockChild.stderr.emit('data', 'Preparing worktree...\n');
mockChild.emit('close', 0);
});

await worktreePromise;

// Then: spawn이 올바른 인자로 호출되었는지 확인 (마지막 호출)
const lastCall = mockedSpawn.mock.calls[mockedSpawn.mock.calls.length - 1];
expect(lastCall).toBeDefined();
expect(lastCall![0]).toBe('git');
expect(lastCall![1]).toEqual(['worktree', 'add', '-b', 'feature-branch', '/tmp/worktree path', 'main']);
});
});
});
Loading
Loading