서브프로세스 없이 순수 파일시스템 읽기로 Git 상태를 파악하는 방법.
Claude Code의 Git 통합은 git CLI를 서브프로세스로 호출하지 않습니다. 대신 .git/ 디렉토리의 파일을 직접 읽어 브랜치 이름, HEAD 커밋, 설정값을 파악합니다. 이 접근 방식은 서브프로세스 오버헤드(프로세스 생성 ~5-20ms)를 제거하고, git이 설치되지 않은 환경에서도 기본적인 Git 정보를 제공합니다.
git/config.ts → git/filesystem.ts → git/watcher.ts → git/security.ts → git/tracking.ts
핵심 설계 원칙:
git CLI 호출 대신 .git/ 파일시스템 직접 읽기Git 서브프로세스를 피하는 세 가지 이유:
child_process.spawn('git', ...)은 프로세스 생성, PATH 탐색, git 초기화에 5-20ms가 소요됩니다. 부트 시퀀스에서 여러 번 호출하면 50-100ms가 추가됩니다.fs 모듈만 필요합니다.// 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
}
}
Git config 파서는 세 개의 설정 레벨을 순서대로 조회합니다. 나중에 읽히는 레벨이 이전 레벨을 오버라이드합니다.
/etc/gitconfig — 시스템 전역 설정~/.gitconfig 또는 $XDG_CONFIG_HOME/git/config — 사용자 전역 설정.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의 대소문자 규칙은 직관적이지 않습니다:
[Core] == [core])autocrlf == AutoCRLF)[remote "Origin"] != [remote "origin"])서브섹션 이름의 대소문자 보존은 흔한 버그 원인입니다. [remote "Origin"]과 [remote "origin"]은 다른 remote로 취급됩니다. 파서가 대소문자 무시로 모든 것을 정규화하면 서브섹션이 잘못 병합됩니다.
세 개의 핵심 함수가 Git 상태를 파일시스템에서 직접 읽습니다.
현재 디렉토리에서 상위로 탐색하며 .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는 .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
}
Git은 성능 최적화를 위해 많은 ref를 단일 packed-refs 파일에 압축합니다. git gc 실행 시 .git/refs/ 하위의 개별 파일들이 packed-refs로 통합됩니다. resolveRef는 loose ref(개별 파일)를 먼저 확인하고, 없으면 packed-refs에서 탐색합니다. 이 순서가 중요한 이유는 loose ref가 packed-refs보다 최신 정보를 담고 있기 때문입니다.
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 상태를 조회해도 파일 감시는 한 번만 설정됩니다.
파일시스템에서 직접 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
}
악의적으로 조작된 .git/HEAD 파일이 ref: ../../etc/passwd를 포함하면, resolveRef가 .git/../../etc/passwd를 읽게 됩니다. isSafeRefName이 .. 포함 ref를 거부하여 이런 경로 탐색 공격을 차단합니다. 이 검증은 Git 자체의 check-ref-format 규칙을 Node.js로 재구현한 것입니다.
Git 통합은 단순한 상태 읽기를 넘어 작업 추적과 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
}
~/.config/gh/hosts.yml 파일을 읽어 GitHub CLI(gh)의 인증 상태를 확인합니다. 인증된 경우 GitHub API를 통해 PR 정보를 가져올 수 있습니다.
.git/ 디렉토리를 직접 읽어 5-20ms의 프로세스 생성 오버헤드를 제거합니다resolveGitDir은 일반 레포와 worktree를 모두 처리하며, .git 파일의 gitdir: 지시자를 따라갑니다resolveRef는 loose ref를 먼저 확인 후 packed-refs를 탐색합니다. 순서가 중요한 이유는 loose ref가 더 최신이기 때문입니다.git/HEAD와 .git/index를 감시하며, 더티 비트로 캐시를 무효화합니다isSafeRefName은 .., 제어 문자, @{ 등을 거부하여 경로 탐색 공격을 차단합니다Q1. Claude Code가 git CLI 서브프로세스 대신 파일시스템 직접 읽기를 사용하는 주된 이유는?
spawn의 5-20ms 오버헤드 제거, (2) git이 설치되지 않은 환경에서도 동작, (3) git 버전별 출력 형식 차이 대신 안정적인 파일시스템 구조 활용.Q2. Git config 파서의 대소문자 규칙에서 대소문자가 보존되는 것은?
[remote "Origin"])과 변수 값(경로, URL 등)은 대소문자가 보존됩니다. 서브섹션의 대소문자 보존은 흔한 버그 원인입니다.Q3. resolveRef가 loose ref를 packed-refs보다 먼저 확인하는 이유는?
git gc 시 loose ref가 packed-refs로 압축됩니다. 이후 새로운 커밋이 생기면 해당 ref의 loose 파일이 업데이트되므로, loose ref가 packed-refs보다 최신입니다. 같은 ref에 대해 두 곳 모두에 값이 있으면 loose ref가 우선합니다.Q4. isSafeRefName이 ..을 포함하는 ref를 거부하는 이유는?
.git/HEAD가 ref: ../../etc/passwd를 포함하면 resolveRef가 민감한 파일을 읽게 됩니다. isSafeRefName의 .. 검사가 이런 경로 탐색 공격을 차단합니다.Q5. GitFileWatcher가 싱글톤 패턴을 사용하는 이유는?
.git/HEAD와 .git/index에 대한 워치는 한 번만 설정되며, 더티 비트 기반 캐시 무효화가 모든 소비자에게 적용됩니다.