Git 통합: 제로 서브프로세스, 파일시스템 우선

서브프로세스 없이 순수 파일시스템 읽기로 Git 상태를 파악하는 방법.

01

개요

Claude Code의 Git 통합은 git CLI를 서브프로세스로 호출하지 않습니다. 대신 .git/ 디렉토리의 파일을 직접 읽어 브랜치 이름, HEAD 커밋, 설정값을 파악합니다. 이 접근 방식은 서브프로세스 오버헤드(프로세스 생성 ~5-20ms)를 제거하고, git이 설치되지 않은 환경에서도 기본적인 Git 정보를 제공합니다.

다루는 소스 파일: git/config.tsgit/filesystem.tsgit/watcher.tsgit/security.tsgit/tracking.ts

핵심 설계 원칙:

02

설계 철학: 서브프로세스 없음

Git 서브프로세스를 피하는 세 가지 이유:

  1. 시작 비용: child_process.spawn('git', ...)은 프로세스 생성, PATH 탐색, git 초기화에 5-20ms가 소요됩니다. 부트 시퀀스에서 여러 번 호출하면 50-100ms가 추가됩니다.
  2. 환경 독립성: 일부 컨테이너나 제한된 환경에서는 git이 설치되지 않을 수 있습니다. 파일시스템 읽기는 Node.js의 fs 모듈만 필요합니다.
  3. 예측 가능성: git 명령어의 출력 형식은 버전마다 미묘하게 다를 수 있습니다. 파일시스템 구조는 Git의 내부 포맷이 안정적이므로 파싱이 더 신뢰할 수 있습니다.
// git/filesystem.ts — 서브프로세스 없이 HEAD 읽기
function readGitHead(gitDir: string): string | null {
  try {
    const head = readFileSync(
      join(gitDir, 'HEAD'), 'utf-8'
    ).trim()

    // "ref: refs/heads/main" → 브랜치 참조
    if (head.startsWith('ref: ')) {
      return head.slice(5)  // "refs/heads/main"
    }

    // 직접 SHA → detached HEAD 상태
    return head
  } catch {
    return null
  }
}
03

설정 파서: 3레벨 조회

Git config 파서는 세 개의 설정 레벨을 순서대로 조회합니다. 나중에 읽히는 레벨이 이전 레벨을 오버라이드합니다.

  1. System: /etc/gitconfig — 시스템 전역 설정
  2. Global: ~/.gitconfig 또는 $XDG_CONFIG_HOME/git/config — 사용자 전역 설정
  3. Local: .git/config — 레포지토리별 설정
// git/config.ts — 3레벨 설정 조회
function getGitConfig(key: string, gitDir: string): string | undefined {
  const configs = [
    '/etc/gitconfig',                          // system
    join(homedir(), '.gitconfig'),               // global
    join(gitDir, 'config'),                      // local
  ]

  let result: string | undefined
  for (const path of configs) {
    const value = parseGitConfigFile(path, key)
    if (value !== undefined) result = value  // 후속 레벨이 오버라이드
  }
  return result
}

대소문자 규칙

Git config의 대소문자 규칙은 직관적이지 않습니다:

주의사항

서브섹션 이름의 대소문자 보존은 흔한 버그 원인입니다. [remote "Origin"][remote "origin"]은 다른 remote로 취급됩니다. 파서가 대소문자 무시로 모든 것을 정규화하면 서브섹션이 잘못 병합됩니다.

04

Git 파일시스템 상태 읽기

세 개의 핵심 함수가 Git 상태를 파일시스템에서 직접 읽습니다.

resolveGitDir

현재 디렉토리에서 상위로 탐색하며 .git 디렉토리(또는 worktree의 .git 파일)를 찾습니다.

// git/filesystem.ts — .git 디렉토리 해석
function resolveGitDir(startPath: string): string | null {
  let dir = startPath
  while (true) {
    const gitPath = join(dir, '.git')
    const stat = statSafe(gitPath)

    if (stat?.isDirectory()) return gitPath  // 일반 레포

    if (stat?.isFile()) {
      // worktree: .git 파일이 실제 gitdir을 가리킴
      const content = readFileSync(gitPath, 'utf-8').trim()
      if (content.startsWith('gitdir: ')) {
        return resolve(dir, content.slice(8))
      }
    }

    const parent = dirname(dir)
    if (parent === dir) return null  // 루트 도달
    dir = parent
  }
}

readGitHead & resolveRef

readGitHead.git/HEAD 파일에서 현재 브랜치 참조 또는 detached HEAD의 SHA를 읽습니다. resolveRef는 심볼릭 ref를 따라가며 최종 커밋 SHA를 해석합니다.

// git/filesystem.ts — ref 해석 체인
function resolveRef(gitDir: string, ref: string): string | null {
  // 1. loose ref 확인: .git/refs/heads/main
  const loosePath = join(gitDir, ref)
  const loose = readFileSafe(loosePath)
  if (loose) return loose.trim()

  // 2. packed-refs 확인: .git/packed-refs
  const packed = readFileSafe(join(gitDir, 'packed-refs'))
  if (packed) {
    for (const line of packed.split('\n')) {
      if (line.endsWith(` ${ref}`)) {
        return line.split(' ')[0]
      }
    }
  }

  return null
}
딥 다이브 — packed-refs란?

Git은 성능 최적화를 위해 많은 ref를 단일 packed-refs 파일에 압축합니다. git gc 실행 시 .git/refs/ 하위의 개별 파일들이 packed-refs로 통합됩니다. resolveRef는 loose ref(개별 파일)를 먼저 확인하고, 없으면 packed-refs에서 탐색합니다. 이 순서가 중요한 이유는 loose ref가 packed-refs보다 최신 정보를 담고 있기 때문입니다.

05

GitFileWatcher: 싱글톤 & 더티 비트 캐시

GitFileWatcher는 .git/HEAD.git/index 파일의 변경을 감시하는 싱글톤입니다. 변경이 감지되면 더티 비트를 설정하고, 다음 조회 시 캐시를 무효화합니다.

// git/watcher.ts — 싱글톤 파일 워처
class GitFileWatcher {
  private static instance: GitFileWatcher | null = null
  private dirty = false
  private cachedBranch: string | null = null

  static getInstance(gitDir: string): GitFileWatcher {
    if (!GitFileWatcher.instance) {
      GitFileWatcher.instance = new GitFileWatcher(gitDir)
    }
    return GitFileWatcher.instance
  }

  private constructor(gitDir: string) {
    // HEAD와 index 파일 감시
    watch(join(gitDir, 'HEAD'), () => this.dirty = true)
    watch(join(gitDir, 'index'), () => this.dirty = true)
  }

  getBranch(): string | null {
    if (this.dirty || !this.cachedBranch) {
      this.cachedBranch = readCurrentBranch()
      this.dirty = false
    }
    return this.cachedBranch
  }
}

싱글톤 패턴은 세션 내에서 하나의 워처 인스턴스만 존재하도록 보장합니다. 여러 컴포넌트가 Git 상태를 조회해도 파일 감시는 한 번만 설정됩니다.

06

보안: isSafeRefName & isValidGitSha

파일시스템에서 직접 Git 데이터를 읽기 때문에 경로 탐색(path traversal) 공격에 취약할 수 있습니다. 두 개의 보안 함수가 이를 방지합니다.

// git/security.ts — ref 이름 검증
function isSafeRefName(ref: string): boolean {
  // 경로 탐색 방지
  if (ref.includes('..')) return false
  if (ref.startsWith('/')) return false
  // 제어 문자, 공백, 특수 문자 금지
  if (/[\x00-\x1f\x7f ~^:?*\[\\]/.test(ref)) return false
  // 연속 점 또는 @{ 금지
  if (ref.includes('@{')) return false
  if (ref.endsWith('.') || ref.endsWith('.lock')) return false
  return true
}

// SHA 형식 검증
function isValidGitSha(sha: string): boolean {
  return /^[0-9a-f]{40}$/.test(sha)  // 정확히 40자 hex
}
딥 다이브 — 왜 ref 이름 검증이 필수인가?

악의적으로 조작된 .git/HEAD 파일이 ref: ../../etc/passwd를 포함하면, resolveRef.git/../../etc/passwd를 읽게 됩니다. isSafeRefName.. 포함 ref를 거부하여 이런 경로 탐색 공격을 차단합니다. 이 검증은 Git 자체의 check-ref-format 규칙을 Node.js로 재구현한 것입니다.

07

작업 추적 & PR 자동 연결

Git 통합은 단순한 상태 읽기를 넘어 작업 추적과 PR 연결 기능을 제공합니다.

작업 추적

현재 브랜치, 커밋 수, 변경된 파일 수 등이 세션 메타데이터로 기록됩니다. 이 정보는 대화 컨텍스트에 포함되어 어시스턴트가 현재 작업 상태를 파악할 수 있게 합니다.

PR 자동 연결

브랜치 이름에서 PR 번호를 추출하거나, remote의 push URL에서 GitHub/GitLab 레포 정보를 파싱하여 관련 PR에 자동으로 연결합니다.

// git/tracking.ts — PR 번호 추출
function extractPRNumber(branchName: string): number | null {
  // 패턴: pr/123, pull/123, #123
  const match = branchName.match(/(?:pr|pull)[/-](\d+)|#(\d+)/)
  return match ? parseInt(match[1] || match[2]) : null
}

GitHub 인증 상태

~/.config/gh/hosts.yml 파일을 읽어 GitHub CLI(gh)의 인증 상태를 확인합니다. 인증된 경우 GitHub API를 통해 PR 정보를 가져올 수 있습니다.

08

핵심 요약

핵심 포인트

  • Git 통합은 서브프로세스를 전혀 사용하지 않고 .git/ 디렉토리를 직접 읽어 5-20ms의 프로세스 생성 오버헤드를 제거합니다
  • 설정 파서는 system → global → local 3레벨 조회를 수행하며, 나중에 읽히는 레벨이 이전을 오버라이드합니다
  • 대소문자 규칙에 주의: 섹션/변수 이름은 대소문자 무시, 서브섹션 이름과 변수 값은 보존
  • resolveGitDir은 일반 레포와 worktree를 모두 처리하며, .git 파일의 gitdir: 지시자를 따라갑니다
  • resolveRef는 loose ref를 먼저 확인 후 packed-refs를 탐색합니다. 순서가 중요한 이유는 loose ref가 더 최신이기 때문입니다
  • GitFileWatcher는 싱글톤 패턴으로 .git/HEAD.git/index를 감시하며, 더티 비트로 캐시를 무효화합니다
  • isSafeRefName.., 제어 문자, @{ 등을 거부하여 경로 탐색 공격을 차단합니다
  • 브랜치 이름에서 PR 번호를 추출하고, GitHub CLI 인증 상태를 확인하여 PR 자동 연결을 지원합니다
09

지식 확인

퀴즈 — 5문제

Q1. Claude Code가 git CLI 서브프로세스 대신 파일시스템 직접 읽기를 사용하는 주된 이유는?

  • A) git의 라이선스 문제를 피하기 위해
  • B) 프로세스 생성 오버헤드(5-20ms) 제거, 환경 독립성, 출력 형식의 예측 가능성
  • C) git의 파일 잠금 메커니즘과 충돌을 방지하기 위해
  • D) 병렬 Git 작업을 지원하기 위해
세 가지 이유: (1) spawn의 5-20ms 오버헤드 제거, (2) git이 설치되지 않은 환경에서도 동작, (3) git 버전별 출력 형식 차이 대신 안정적인 파일시스템 구조 활용.

Q2. Git config 파서의 대소문자 규칙에서 대소문자가 보존되는 것은?

  • A) 섹션 이름과 변수 이름
  • B) 섹션 이름만
  • C) 모든 이름과 값
  • D) 서브섹션 이름과 변수 값
섹션 이름과 변수 이름은 대소문자 무시입니다. 그러나 서브섹션 이름(예: [remote "Origin"])과 변수 값(경로, URL 등)은 대소문자가 보존됩니다. 서브섹션의 대소문자 보존은 흔한 버그 원인입니다.

Q3. resolveRef가 loose ref를 packed-refs보다 먼저 확인하는 이유는?

  • A) loose ref가 packed-refs보다 최신 정보를 담고 있기 때문
  • B) packed-refs 파싱이 더 느리기 때문
  • C) loose ref가 packed-refs보다 보안적으로 안전하기 때문
  • D) Git의 공식 사양이 이 순서를 요구하기 때문
git gc 시 loose ref가 packed-refs로 압축됩니다. 이후 새로운 커밋이 생기면 해당 ref의 loose 파일이 업데이트되므로, loose ref가 packed-refs보다 최신입니다. 같은 ref에 대해 두 곳 모두에 값이 있으면 loose ref가 우선합니다.

Q4. isSafeRefName..을 포함하는 ref를 거부하는 이유는?

  • A) Git의 브랜치 명명 규칙에서 점이 금지되어 있기 때문
  • B) 브랜치 이름에 연속 점이 미관상 좋지 않기 때문
  • C) ref: ../../etc/passwd 같은 경로 탐색 공격을 방지하기 위해
  • D) Git 서버와의 호환성을 위해
파일시스템에서 직접 ref를 읽기 때문에, 악의적으로 조작된 .git/HEADref: ../../etc/passwd를 포함하면 resolveRef가 민감한 파일을 읽게 됩니다. isSafeRefName.. 검사가 이런 경로 탐색 공격을 차단합니다.

Q5. GitFileWatcher가 싱글톤 패턴을 사용하는 이유는?

  • A) Node.js의 파일 워치 API가 하나의 인스턴스만 허용하기 때문
  • B) 세션 내에서 여러 컴포넌트가 Git 상태를 조회해도 파일 감시 핸들은 하나만 유지하기 위해
  • C) Git 저장소가 동시에 여러 프로세스에서 접근되는 것을 방지하기 위해
  • D) 테스트 코드에서 모킹을 쉽게 하기 위해
싱글톤은 파일 워치 핸들의 중복 생성을 방지합니다. 여러 컴포넌트가 브랜치 정보를 요청해도 .git/HEAD.git/index에 대한 워치는 한 번만 설정되며, 더티 비트 기반 캐시 무효화가 모든 소비자에게 적용됩니다.
0 / 5