About

worktree는 심볼릭 링크도 클론도 아니다

파일은 복사본, 저장소는 하나

worktree를 쓰기 시작하면 한 번은 걸리는 질문이 있다. 이게 심볼릭 링크인가, 아니면 clone처럼 통째로 따로인가.

둘 다 아니다. 작업 파일은 물리적으로 따로 있고, 저장소는 하나를 같이 쓴다.

.git이 디렉토리가 아니다

추가한 worktree에서 .git을 열어 보면 한 줄짜리 텍스트다. 심볼릭 링크도 아니다.

$ file ../wt/.git
../wt/.git: ASCII text

$ cat ../wt/.git
gitdir: /Users/me/repo/.git/worktrees/wt

git은 이 한 줄을 읽고 메인 저장소로 찾아간다. 연결 고리는 이게 전부다.

무엇이 공유되고 무엇이 따로인가

추측할 필요 없이 git이 직접 알려준다. worktree 안에서 git rev-parse --git-path를 때려 보면 된다.

$ git rev-parse --git-path HEAD
/Users/me/repo/.git/worktrees/wt/HEAD

$ git rev-parse --git-path index
/Users/me/repo/.git/worktrees/wt/index

$ git rev-parse --git-path refs/heads
/Users/me/repo/.git/refs/heads

$ git rev-parse --git-path objects
/Users/me/repo/.git/objects

HEAD와 index는 worktree 전용 디렉토리를 가리키고, refs와 objects는 메인 .git을 가리킨다. 공식 문서 용어로는 앞쪽이 $GIT_DIR, 뒤쪽이 $GIT_COMMON_DIR이다. 예외는 refs/bisect와 refs/worktree 둘뿐이고 이건 worktree마다 따로다.

HEAD와 index가 따로라서 서로 다른 브랜치를 동시에 체크아웃할 수 있다. 딱 그 이유다.

커밋은 fetch 없이 건너온다

refs와 objects가 한 곳이니 당연한 결과다.

# worktree 쪽
$ git commit -m "sentinel"
[wt/demo c48eba9] sentinel

# 메인 쪽 — fetch 안 했다
$ git log wt/demo --oneline -1
c48eba9 sentinel

clone 두 개였다면 push와 fetch를 거쳐야 보인다. 실무에서 체감되는 차이는 대부분 여기서 나온다.

디스크가 아낀다는 말은 절반만 맞다

"clone보다 용량이 절약된다"는 설명을 자주 보는데, 상대가 로컬 clone이면 이야기가 달라진다.

같은 파일시스템에서 git clone은 pack 파일을 하드링크한다. 복사가 아니다.

$ stat -f '%l links  %N' repo/.git/objects/pack/*.pack
2 links  .../pack-1eb982d2.pack        # 원본
2 links  .../clone/.git/.../pack-1eb982d2.pack  # 같은 inode

--no-hardlinks를 주면 링크 수가 1로 떨어진다. 즉 기본 동작이 하드링크다.

이 저장소(.git 439MB)에서 메인과 clone의 .git을 한 번의 du 호출로 재 보면 clone 쪽이 더 쓴 건 1.2MB다. 별도로 재면 365MB로 보이지만 그건 같은 inode를 두 번 센 것이다.

작업 파일은 worktree든 clone이든 어차피 복사된다. 이 저장소 기준 431MB.

정리하면 로컬 clone 상대로 worktree의 이점은 용량이 아니다. refs와 objects가 한 곳이라는 것이다.

폴더만 지우면 git이 계속 붙잡고 있다

제일 자주 밟는 함정이다. rm -rf로 디렉토리만 날리면 등록 정보가 남는다.

$ rm -rf ../wt
$ git worktree list
/private/tmp/wt  c48eba9 [wt/demo] prunable

$ git worktree add ../wt2 wt/demo
fatal: 'wt/demo' is already checked out at '/private/tmp/wt'

$ git branch -d wt/demo
error: Cannot delete branch 'wt/demo' checked out at '/private/tmp/wt'

디렉토리는 없는데 브랜치를 다시 체크아웃할 수도, 지울 수도 없다. git worktree prune 한 번이면 풀린다. 애초에 git worktree remove를 쓰면 겪을 일이 없다.

gitignore 대상은 따라오지 않는다

새 worktree에는 git이 추적하는 파일만 체크아웃된다. Rails 프로젝트라면 .env, config/master.key, node_modules/, storage/의 SQLite 파일이 전부 빠진다. 서버가 안 뜨는데 원인을 못 찾는다면 대개 이것이다.

측정 환경은 git 2.39.5 (Apple Git-154), macOS.

동작 흐름

1

`git worktree add ../wt -b feature` — 추적 중인 파일이 체크아웃되고, 그 자리에 `.git` 텍스트 파일이 생긴다

2

`.git` 안의 `gitdir:` 한 줄이 메인 저장소의 `.git/worktrees/<이름>/`을 가리킨다

3

HEAD와 index는 그 디렉토리에서 읽고(`$GIT_DIR`), refs와 objects는 메인 `.git`에서 읽는다(`$GIT_COMMON_DIR`)

4

커밋하면 객체와 ref가 메인 저장소에 바로 쌓인다. 반대쪽에서 fetch 없이 보인다

5

`git worktree remove ../wt` — 파일과 등록 정보를 같이 지운다. 폴더만 지우면 `prune`이 따로 필요하다

장점

  • ✓ refs와 objects가 한 곳이라 커밋이 fetch 없이 건너온다. fetch도 한 번이면 전부에 반영된다
  • ✓ 같은 브랜치를 두 곳에서 체크아웃하면 git이 막아 준다. 실수로 갈라지지 않는다

단점

  • ✗ gitignore 대상은 따라오지 않는다. `.env`, master.key, node_modules를 매번 따로 넣어 줘야 한다
  • ✗ 폴더만 지우면 등록 정보가 남아 브랜치를 지울 수도 다시 쓸 수도 없게 된다
  • ✗ 작업 파일은 복사본이라 디스크는 그만큼 더 쓴다. 로컬 clone 대비 용량 이점은 없다

사용 사례

긴 빌드나 테스트가 도는 동안 다른 브랜치를 건드려야 할 때 AI 에이전트를 세션마다 격리해서 병렬로 돌릴 때 리뷰용 체크아웃을 자기 작업과 분리하고 싶을 때
← Portless란? localhost 포트 번호를 myapp.localhost 이름으로 바꾸는 원리 Postgres 볼륨 경로가 3년 틀렸는데 멀쩡했던 이유 — VOLUME 선언이 몰래 붙인 이름 없는 볼륨 →