worktree 마다 .env 를 다시 만들지 않기 — post-checkout 훅으로 심볼릭 링크

git worktree add 는 tracked 파일만 체크아웃하므로 gitignore 된 .env 는 절대 따라오지 않는다. 래퍼 함수 대신 post-checkout 훅을 쓰면 어떤 방식으로 만들든 자동으로 연결된다.

git worktree add 로 새 워크트리를 만들 때마다 같은 일을 반복하고 있었다. 메인 체크아웃에서 .env 를 찾아 복사하기.

빠뜨리면 dev 서버가 안 뜨거나, 더 나쁘게는 API 키가 비어 있는 채로 절반쯤 동작한다.

왜 안 따라오는가

버그가 아니다. git worktree addtracked 파일만 체크아웃한다. .env.gitignore 에 걸려 untracked 이므로 새 워크트리에 존재할 이유가 없다.

즉 git 에게 고쳐달라고 할 문제가 아니라, 워크트리를 만들 때마다 뭔가를 덧붙여야 하는 문제다.

래퍼 함수를 쓰지 않은 이유

처음에는 셸 함수로 감쌌다.

wt() { git worktree add "$@" && cp ../main/.env "$1/"; }

혼자 손으로 칠 때는 충분하다. 그런데 AI 에이전트에게 작업을 맡기는 세션이 늘면서 무너졌다. 에이전트는 내 셸 함수를 모르고 그냥 git worktree add 를 친다. 그러면 래퍼가 통째로 우회된다.

git worktree addpost-checkout 훅을 발동시킨다. 브랜치 체크아웃이므로 세 번째 인자가 1 로 들어온다. 훅은 git 이 무조건 실행하므로 누가 어떤 방식으로 워크트리를 만들든 걸린다. 멀티세션 환경에서는 훅이 정답이다.

설치 위치도 유리하다. 훅을 .git/hooks 에 두면 모든 워크트리가 공유한다(common dir). 메인에서 한 번 깔면 끝이다.

복사가 아니라 링크

cp 를 쓰면 시크릿을 로테이션할 때마다 워크트리 개수만큼 stale 사본이 생긴다. 어느 워크트리가 옛 키를 들고 있는지 추적할 방법이 없다.

심볼릭 링크면 메인 한 곳만 고치면 전부 반영된다.

링크 소스는 메인 체크아웃으로 잡는다. --git-common-dir 의 부모 디렉터리다. 메인은 삭제되지 않는 영구 체크아웃이라 링크가 깨질 일이 없다. 형제 워크트리를 소스로 잡으면 그 워크트리를 지우는 순간 다른 워크트리들의 링크가 전부 죽는다.

훅 본문

<repo>/.git/hooks/post-checkout 에 두고 chmod +x 한다.

#!/usr/bin/env bash
# post-checkout — mirror gitignored *.env from the main checkout into new worktrees.
set -u
[ "${3:-0}" = "1" ] || exit 0   # branch checkout(worktree add 포함)에서만

common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || exit 0
case "$common_dir" in /*) ;; *) common_dir="$PWD/$common_dir" ;; esac
main_root=$(dirname "$common_dir")
wt_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
[ "$wt_root" = "$main_root" ] && exit 0   # 메인 체크아웃 자신은 skip

linked=0
while IFS= read -r -d '' rel; do
  src="$main_root/$rel"; dest="$wt_root/$rel"
  [ -e "$dest" ] && continue              # 기존 파일/링크 절대 덮지 않음
  mkdir -p "$(dirname "$dest")"
  ln -s "$src" "$dest" && linked=$((linked + 1))
done < <(git -C "$main_root" ls-files --others --ignored --exclude-standard -z \
           -- '*.env' '*.env.*' 2>/dev/null)
[ "$linked" -gt 0 ] && echo "post-checkout: linked $linked gitignored env file(s) from main checkout"
exit 0

몇 군데가 의도적이다.

--others --ignored --exclude-standard 조합은 gitignore 된 파일만 열거한다. tracked 인 .env.example 이나 .env.tpl 은 여기 안 걸린다. 이 셋을 다 붙여야 “무시된 것 중에서만” 이 성립한다.

pathspec 이 '*.env' '*.env.*' 인 이유는 모노레포 때문이다. 루트에만 .env 가 있는 구조가 아니라 scripts/<프로젝트>/.env 처럼 중첩돼 있어도 매칭된다.

[ -e "$dest" ] && continue 는 기존 파일을 절대 덮지 않는다. 워크트리에서 일부러 다른 .env 를 쓰고 있을 수 있고, 훅이 그걸 지워버리면 원인 찾기 어려운 사고가 된다.

만들어진 심볼릭 링크는 여전히 gitignore 대상이라 워크트리의 git status 를 더럽히지 않는다. 확인해봤다.

실제로 붙일 때 밟은 함정

stale core.hooksPath

훅을 깔았는데 아무 일도 안 일어났다. 에러도 없었다.

저장소를 다른 디렉터리로 옮긴 적이 있었는데, core.hooksPath옛 절대경로를 가리킨 채 남아 있었다. 그 경로는 이미 존재하지 않는다. 결과는 “모든 훅이 조용히 무동작”이다.

git config --show-origin --get core.hooksPath

해결은 지우는 것이다.

git config --local --unset core.hooksPath

git 기본값인 .git/hooks 로 돌아가고, 이건 워크트리 전체가 공유한다. core.hooksPath 에 절대경로를 박지 않는 게 낫다. 다음에 디렉터리를 옮기면 또 같은 방식으로 조용히 죽는다.

기존 워크트리는 backfill 이 필요하다

훅은 신규 add 에만 발동한다. 이미 살아 있던 워크트리들은 그대로 비어 있다. 같은 로직을 한 번 돌려 채워주면 되는데, [ -e "$dest" ] && continue 덕분에 멱등이라 여러 번 실행해도 안전하다.

정리하다 보니 worktree list 에 없는 orphan 디렉터리도 여럿 나왔다. 이건 별개 위생 문제다. git worktree prune 후 디렉터리를 지우면 된다.

웹앱이면 PORT 는 공유하면 안 된다

자동화 스크립트 저장소라면 .env 에 API 키 정도만 있어서 그대로 공유해도 문제없다. dev 서버가 없으니 포트 충돌도 없다.

Vite / Next 같은 웹앱은 다르다. 공유 .envPORT 가 들어 있으면 모든 워크트리가 같은 포트를 잡으려 해서 동시에 못 띄운다.

.env 는 링크로 공유하되, 포트만 브랜치별로 갈라낸다. .env.local.env 보다 우선하는 건 Vite 와 Next 가 공통이다.

port=$((3000 + $(git branch --show-current | cksum | cut -d' ' -f1) % 1000))
echo "PORT=$port" >> .env.local

브랜치 이름에서 결정적으로 뽑으므로 같은 워크트리는 항상 같은 포트를 쓴다. 북마크가 유효하다는 뜻이라 생각보다 크게 편하다.

포트만으로는 부족한 경우도 있다. 쿠키나 localStorage 가 origin 단위로 격리되므로, 로그인 상태를 워크트리마다 따로 두려면 포트가 아니라 서브도메인이 필요하다. dev 서버 자체를 어떻게 굴릴지는 worktree 기반 멀티세션 dev 서버 전략에 따로 적었다.

정리

핵심은 세 가지다. 훅으로 걸 것(래퍼는 우회된다), 링크로 걸 것(복사는 stale 된다), 소스는 메인으로 잡을 것(형제는 사라진다). 이 셋을 지키면 워크트리를 몇 개를 만들든 .env 를 다시 생각할 일이 없다.