Git worktree는 코딩 에이전트의 격리 경계가 아닙니다
요약
Git worktree는 코딩 에이전트의 완전한 격리 경계가 아니라는 점을 지적합니다. worktree는 단지 하나의 `.git`에 연결된 두 번째 작업 디렉토리일 뿐이며, 모든 핵심 정보(훅, 오브젝트 스토어 등)는 공통 `.git` 디렉터리에 공유됩니다. 따라서 에이전트들은 외부 상태에 접근할 수 있어 '격리'라는 개념 자체가 오해입니다.
핵심 포인트
- worktree는 격리가 아닌 두 번째 작업 디렉토리일 뿐이다.
- 훅(Hooks), 오브젝트 스토어 등 핵심 정보는 공통 `.git`에서 공유된다.
- 진정한 경계란 작업 디렉터리, HEAD, 인덱스에 국한되며 그 외는 공유된다.
- 코딩 에이전트가 외부 상태에 접근할 수 있어 '격리'라는 개념은 잘못되었다.
코딩 에이전트에게 자체 git worktree를 할당해 줄 수 있습니다. 대부분의 에이전트를 병렬로 실행하는 도구들이 이 방식을 사용하며, 그 결과 해당 에이전트는 다음에 사용자가 실제 저장소에 커밋할 때 작동하는 훅(hook)을 설치할 수 있게 됩니다. 심지어 사용자 자신의 커밋에 할당된 이메일 주소를 재작성할 수도 있습니다. 다른 에이전트의 stash를 가져와서 자신만의 트리에 풀 수도 있습니다.
이 모든 과정은 버그나 특이한 명령어를 필요로 하지 않습니다. worktree는 본래 경계가 아니었습니다. 그것은 하나의 .git에 연결된 두 번째 작업 디렉토리일 뿐이며, 흥미로운 모든 것은 그 .git 안에 존재합니다.
Worktrees가 기본값으로 자리 잡게 된 이유는 가장 명백한 해답이었기 때문입니다. 한 개의 명령어만으로, 중복된 히스토리가 없고, 약 1초 만에 두 번째 체크아웃이 가능합니다. 이 방식의 장점은 거의 제로 비용으로 격리를 달성한다는 것인데, 이 두 가지 모두 잘못된 결론임이 밝혀졌습니다. 첫째, 격리는 '격리된 worktree'라는 문구가 암시하는 것보다 훨씬 얇하며, 둘째, 이를 제대로 구현하는 데 드는 비용은 동일합니다. 저는 이전 버전의 포스팅에서 이 비용 부분을 잘못 이해하고 있다가 직접 측정해보고야 알게 되었습니다.
연결된 worktree가 실제로 공유하는 것들
연결된 worktree 내부에서 .git은 디렉토리가 아닙니다. 그것은 파일입니다:
$ cat ../my-worktree/.git
gitdir: /path/to/repo/.git/worktrees/my-worktree
모든 것은 이 경로로부터 파생됩니다. Git은 저장소 상태를 worktree별 상태와 공통 상태로 분할하며, 어느 것이 무엇인지 알려줍니다:
$ cd ../my-worktree$
git rev-parse --git-dir # worktree별/path/to/repo/.git/worktrees/my-worktree
$ git rev-parse --git-common-dir # 부모 및 모든 형제와 공유/path/to/repo/.git
작업트리별 상태(Per-worktree state)는 짧은 목록입니다: HEAD, 인덱스, ORIG_HEAD, 그리고 몇 가지 bisect 및 rebase 파일. 나머지 모든 것은 --git-common-dir을 통해 원래 저장소로 되돌아갑니다:
- 오브젝트 스토어 (
.git/objects), 이것이 핵심이며 공유해도 안전합니다. - 참조(Refs)와 브랜치들 (
refs/heads,packed-refs). 모든 worktree에 대해 하나의 네임스페이스를 가집니다. - 설정 파일 (
.git/config). - stash (
refs/stash).
훅(Hooks)(.git/hooks)
)), 즉 공유 디렉터리가 임의 코드 실행 지점이 되는 곳입니다.
따라서 격리는 실제로 존재하며 그 범위가 좁습니다: 작업 디렉터리(working directory), HEAD, 그리고 인덱스(index)입니다. 이들은 유용하지만 경계는 아닙니다. 경계란 작업 트리 내에 갇힌 프로세스가 외부 상태에 접근할 수 없다는 것을 의미하는데, 두 번째 목록은 그것이 가능한 방식들을 나열한 카탈로그입니다. “격리된 작업 트리(Isolated worktree)”라는 표현은 도구 설명이나 조언 스레드에서 끊임없이 사용되지만, 어느 목록을 지칭하는지는 결코 명시하지 않습니다.
에이전트가 할 수 있는 일들
최악의 경우부터 나열합니다. 각 블록은 자체 리포지토리를 생성하므로, 이들을 하나의 빈 디렉터리에 붙여넣고 어떤 순서로든 실행할 수 있습니다. 아래 출력의 모든 줄은 실제이며 git 2.50.1에서 포착된 것입니다.
호스트에서 코드를 실행하기
.git/hooks는 공통 디렉터리(common directory)에 존재합니다. 따라서 작업 트리 내부에서 설치된 훅(hook)은 부모 리포지토리를 위해 설치되며, 다음번에 트리거 명령을 실행할 때 부모 환경의 당신(you)으로 실행됩니다.
git init -q --initial-branch=main hook-democd hook-demogit commit -q --allow-empty -m initgit branch agent-workgit worktree add -q ../hook-agent agent-work
#
worktree 내부에서는 부모의 `.git/config` 파일을 작성합니다.
git init -q --initial-branch=main id-democd id-demogit commit -q --allow-empty -m initgit branch agent-workgit worktree add -q ../id-agent agent-work
cd ../id-agentgit config user.email "[email protected]"
cd ../id-demogit config user.email# -> [email protected] config --show-origin user.email# -> file:.git/config [email protected]
...
마지막 두 줄에 주목하세요. 이것은 에이전트의 커밋이 아닙니다. 그것은 당신의 저장소에 있는 것이며, 다른 무언가가 선택한 작성자 라인을 가지고 있습니다.
### 또 다른 worktree의 stash 사용하기
저장소당 하나의 `refs/stash`가 존재하며, 이는 조정되지 않은 작가들 사이에 공유되는 단일 스택처럼 작동합니다:
git init -q --initial-branch=main stash-democd stash-demoecho original > file.txt && git add file.txt && git commit -q -m "add file"git branch branch-agit branch branch-bgit worktree add -q ../stash-a branch-agit worktree add -q ../stash-b branch-b
cd ../stash-aecho "AGENT A PRECIOUS WORK" > file.txtgit stash -q # A가 자신의 작업을 보관합니다
cd ../stash-bgit git stash pop # B가 그것을 꺼내 쓰지만, 요청하지는 않았습니다
cat file.txt# -> AGENT A PRECIOUS WORK
...
한 가지 세부 사항이 핵심적이며, 이것이 이 레포지토리를 떠돌아다니는 일부 버전들이 작동하지 않는 이유입니다: `file.txt`는 브랜치가 생성되기 *전에* 커밋되므로, 두 worktree 모두에서 추적됩니다. 일반적인 `git stash`는 추적되지 않은 파일을 무시하므로, 완전히 새로운 파일에 대해서는 `No local changes to save`를 출력하고, 아무것도 스태시하지 않으며, `pop`은 `No stash entries found`로 실패합니다. 추적된 파일을 사용하거나 `git stash -u`를 사용하세요.
### 형제(sibling) 아래의 refs 재작성하기
모든 worktree는 하나의 `refs/heads`와 하나의 객체 저장소에 작성합니다. `git gc`를 실행하는 에이전트는 부모를 포함하여 전체 저장소에 대해 이를 실행합니다. 브랜치를 강제로 업데이트하거나, 리베이스(rebase)하거나, `git reset --hard`를 사용하는 에이전트가
다른 worktree가 참조하는 ref를 변경하며, 한 에이전트가 버린 커밋은 다른 에이전트의 눈앞에서 참조되지 않은 채 방치될 수 있습니다. 이것들은 특이한 명령어가 아닙니다. 무언가가 혼란에 빠져 정리 정돈을 하려고 할 때 손대는 것들입니다.
### 브랜치 이름 충돌
공유되는 ref는 또한 git이 두 worktree에서 동일한 브랜치를 거부한다는 것을 의미합니다:
`$ git worktree add ../probe mainPreparing worktree (checking out 'main')fatal: 'main' is already used by worktree at '/path/to/repo'`
나머지 부분에 비하면 무해하지만, 사람들이 부딪히는 첫 번째 장벽이며, 그 때문에 사람들은 단순히 git을 만족시키기 위해 worktree당 임시 브랜치를 생성하도록 강요받습니다.
## 문제를 해결하지 못하는 수정 방안들
이 주제가 논의될 때마다 두 가지 완화책이 제시됩니다. 둘 다 효과가 없습니다.
**worktree별 설정.** `extensions.worktreeConfig`
은 기본적으로 비활성화되어 있으며, 이를 켜는 것은 작성자가 `git config --worktree`를 선택적으로 사용할 경우에만 도움이 됩니다. 단순히 `git config`는 특별히 주의하지 않는 것이 실행하는 것인데도 여전히 공유 파일을 작성합니다:
git config extensions.worktreeConfig true # 상위 디렉토리에서 옵트인
cd ../my-worktree
git config --worktree user.email [email protected]
git -C ../repo config user.email # -> [email protected], 영향을 받지 않음
git config user.email [email protected]
git -C ../repo config user.email # -> [email protected]
**훅(hooks)을 .git에서 분리하기.** 이 방법은 순환적입니다.
`core.hooksPath`는 설정이며, 설정은 공유되므로 worktree가 원하는 곳으로 가리킵니다:
cd repo && git config core.hooksPath ../safe-hooks # 당신의 완화책
cd ../my-worktree
mkdir -p evil-hooks && printf '#!/bin/sh
echo pwned
' > evil-hooks/pre-commit
mchmod +x evil-hooks/pre-commit
git config core.hooksPath "$(pwd)/evil-hooks" # 설정이 공유됨
cd ../repo && git commit --allow-empty -m "an ordinary commit"# -> pwned
`--separate-git-dir`도 같은 방식으로 실패합니다. `.git`을 재배치하며, 모든 worktree는 여전히 그것이 재배치된 곳의 내용을 공유합니다.
세 가지 모두 행동하려는 작가에게 제약을 가합니다. 그 어떤 것도 worktree가 *쓸 수 있는* 것을 줄이지는 않습니다.
## 대신 clone을 사용하세요. 비용은 같습니다.
일반적인 반론은 worker별로 clone을 사용하면 히스토리를 복사한다는 것이지만, 로컬 소스의 경우 실제로 그렇게 일어나지 않습니다. 이것이 제가 이전에 잘못 알고 있던 부분이니, 여기 수치를 제시합니다.
git/git의 전체 clone(81,772 커밋, 4,828 추적 파일, 318 MB의 `.git`)을 기준으로 할 때, 작업 디렉토리(working tree)는 58 MB입니다. 각 행은 해당 로컬 소스에서 새로운 체크아웃을 생성합니다.
방법에 대한 참고 사항이 필요한데, 왜냐하면 이것이 답변을 바꾸기 때문입니다. 디스크 용량은 전체 트리에 걸친 누적 변화량으로 측정됩니다. `du`는 호출당 inode를 한 번만 계산하므로, 하드링크된 clone 자체만을 측정할 경우 실제로 추가하지 않은 바이트까지 크레딧을 받을 수 있습니다.
| 하나의 체크아웃 생성 | 벽시계 시간 (Wall time) | 추가 디스크 용량 (Disk added) | `.git` 표시 크기 (apparent size) |
|---|---|---|---|
| `git clone --no-hardlinks` | 1791 ms | 373 MB | 315 MB | 1 |
| `git clone` (local) | 982 ms | 58.9 MB | 318 MB | 1 (하드링크됨) |
| `git clone --shared` | 870 ms | 58.9 MB | 660 KB | 0 |
| `git worktree add` | 826 ms | 58.7 MB | 4 KB | 0 |
가장 아래 세 행을 보세요. 58.7 MB에서 58.9 MB, 그리고 826 ms에서 982 ms입니다. 디스크 관점에서 볼 때 이들은 동일한 작업이며, 그 수치는 소스의 58 MB 트리와 거의 정확하게 일치하는 working-tree 체크아웃을 나타냅니다. 어떤 메커니즘을 선택하든, 파일 한 복사본에 대한 비용만 지불할 뿐, 중요하지 않은 다른 것은 아무것도 아닙니다.
로컬 `git clone`은 객체를 복사하는 것이 아니라 하드링크(hardlinks)합니다. 이것이 `.git`이 318 MB를 보고하면서 실제 디스크에는 59 MB만 추가되는 이유입니다:
`$ stat -f '%i links=%l' reference/.git/objects/pack/*.pack154102172 links=2$ stat -f '%i links=%l' plain-clone/.git/objects/pack/*.pack154102172 links=2 # 동일한 inode`
오직 `--no-hardlinks`만이 복사본에 대한 비용을 지불하며, 아무도 그것을 실수로 실행하지 않습니다. 만약 로컬 clone이 히스토리를 중복한다고 가정했기 때문에 피하고 있었다면, 그렇지 않습니다.
`--shared`는 더 나아가 아무것도 복사하지 않습니다. 이 명령어는 하나의 파일만 작성합니다:
따라서 660 KB의 `.git`이 생깁니다.
이는 일반 클론의 하드링크된 318 MB와 비교했을 때의 수치입니다. 여기서 실제로 이점을 주는 속성은 히스토리 크기에 독립적이라는 점입니다: `--shared`는 소스가 얼마나 많은 히스토리를 가졌는지에 대해서는 O(1)이고, 몇 개의 파일을 체크아웃하는지에 대해서는 O(n)입니다. 테이블에서 볼 수 있듯이 체크아웃 과정이 가장 큰 영향을 미칩니다.
저는 이전에 이 포스트에서 이것을 “킬로바이트와 밀리초”의 비용이라고 설명했습니다. 이는 메타데이터에 해당되는 이야기이며, 실제 작업에는 그렇지 않습니다. 단지 `.git` 킬로바이트 몇 개와 함께 하나의 워킹 트리만 있으면, 1초도 안 되는 시간에 끝납니다.
동일한 59 MB를 가지고는 다음을 얻을 수 있습니다:
`$ cd plain-clone && git config user.zzTest x && git -C ../reference config user.zzTest# -> (not set)$ cd shared-clone && git config user.zzTest x && git -C ../reference config user.zzTest# -> (not set)$ cd worktree && git config user.zzTest x && git -C ../reference config user.zzTest# -> set-from-worktree`
| 상태 | 워킹 트리 | `--shared` 클론 |
|---|---|---|
| 오브젝트 저장소 | 공유됨 | 공유됨, alternates를 통해 |
| ... |
둘 다 복사하기에 비용이 많이 들고 공유하기 안전한 한 가지 것을 공유합니다. 이 클론은 또한 작가(writer)가 두 명 이상일 경우 문제가 될 수 있는 다섯 가지 요소를 격리합니다.
## `--shared`가 요구하는 것
이는 오브젝트를 빌려 쓰기 때문에, 소스 오브젝트 저장소가 제자리에 남아 있어야 합니다. 따라서 아래에 있는 소스를 삭제하거나 클론이 활성화된 상태에서 소스에 공격적인 `git gc`를 실행하지 마십시오. 그렇지 않으면 클론이 여전히 참조하는 오브젝트가 제거될 수 있습니다. 나중에 독립하고 싶다면, `git clone --dissociate`가 빌려 온 오브젝트들을 복사하여 종속성을 끊어줍니다.
일반 하드링크 클론이 더 안정적이며, 그 이유는 알아두는 가치가 있습니다. 하드링크는 inode를 스스로 살아있게 유지합니다. 만약 소스가 해당 팩파일을 삭제하거나 재패킹하더라도, 클론의 링크가 데이터를 열어두기 때문입니다. `--shared`
이는 참조(reference)가 아닌 경로(path)를 담고 있기 때문에 그러한 보호 기능이 없습니다. 포기하는 것은 O(1) 속성, 영구적인 중복 제거(deduplication), 그리고 파일 시스템을 넘나들 수 있는 능력이며, 이는 작업 공간(workspaces)이 다른 볼륨에 있거나 컨테이너 내부에 바인드 마운트(bind-mounted)되는 순간 중요해지기 시작합니다.
어느 쪽도 엄격하게 더 낫다고 할 수는 없습니다. 둘 다 다르게 실패하며, 모두 참조(refs), 설정(config), 스태시(stash), 그리고 훅(hooks)을 격리합니다.
## worktree가 적절한 경우
Worktrees는 `.git` 디렉터리를 의도적으로 공유하며, 이 공유 자체가 그것이 만들어진 목적입니다:
- 한 사람이 두세 개의 브랜치 사이를 이동할 때. 모든 곳에서 브랜치, 스태시, 설정이 보이는 것이 *필요*합니다.
- 인덱스를 방해하지 않고 다른 브랜치를 빠르게 빌드하거나 테스트할 때.
- 작성자 본인만이 유일한 작성자이고 실행하기 전에 각 명령어가 무엇을 할지 알고 있는 모든 경우.
AI 자동 생성 콘텐츠
본 콘텐츠는 HN OpenAI Codex의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기