지난 1년 동안 클로드코드로 제품을 여러 개 만들었습니다. 제품마다 기획도, 도메인도, 화면도 달랐습니다. 그런데 돌아보니 하루 일과는 놀라울 만큼 똑같았습니다. 이슈를 하나 열고, worktree를 따고, 작업하고, PR을 올리고, 머지하고, 치웁니다. 그리고 다음 이슈에서 또 같은 일을 합니다.
이 글은 그중 가장 지루하고, 그래서 가장 자주 미뤘던 마지막 단계, 정리에 대한 이야기입니다. 대화 기록을 다시 훑어보니 "브랜치 정리해 줘", "원격에 남은 브랜치 지워 줘", "워크트리가 왜 이렇게 많지?" 같은 세션이 제품마다 몇 번씩 나왔습니다. 같은 부탁을 이렇게 반복했다면, 그건 대화가 아니라 스킬이 되어야 할 일입니다. prune-branch 스킬은 그렇게 태어났습니다.
매번 반복한 네 단계
혼자 일하지만 흐름은 팀처럼 잡았습니다. 브랜치는 feature → dev → main 세 단계이고, feature → dev는 Squash merge, dev → main은 머지 커밋으로 합칩니다.
- 이슈 — 할 일을 GitHub 이슈로 적습니다. 작업 단위이자 나중에 돌아볼 기록입니다.
- worktree — dev에서 feature 브랜치를 새 워크트리로 땁니다. 클로드코드 세션 하나가 워크트리 하나를 맡으니, 세션 여러 개가 서로 다른 이슈를 동시에 진행할 수 있습니다.
- PR — 작업이 끝나면 dev로 PR을 올립니다. 셀프호스티드 러너가 PHPUnit과 PHPStan을 돌리고, 통과하면 Squash merge 합니다.
- 정리 — 이슈를 닫고, 워크트리를 지우고, 로컬·원격 브랜치를 지우고, dev를 최신으로 맞춥니다.
앞의 세 단계는 결과물이 남습니다. 이슈가 생기고, 코드가 생기고, 머지 기록이 생깁니다. 마지막 단계만 아무것도 남기지 않습니다. 그래서 늘 "나중에"로 밀렸습니다.
정리를 미루면 생기는 일
세션을 병렬로 돌리기 시작하자 쓰레기가 쌓이는 속도가 확 빨라졌습니다. 하루에 이슈 서너 개를 닫으면 워크트리 서너 개, 로컬 브랜치 서너 개, 원격 브랜치 서너 개가 남습니다. 일주일이면 git branch 출력이 한 화면을 넘깁니다.
문제는 단순히 지저분한 것에서 끝나지 않았습니다.
- 어느 게 끝난 건지 모른다. 이름만 보고는 머지가 끝난 브랜치인지, 아직 PR이 열려 있는 브랜치인지, 하다 만 브랜치인지 구분이 안 됩니다.
- 워크트리가 브랜치를 붙잡는다. 워크트리에 체크아웃된 브랜치는 지워지지 않습니다. 브랜치만 지우려다 에러를 보고 나서야 워크트리가 남아 있다는 걸 알게 됩니다.
- 원격과 로컬이 따로 논다. 원격에서는 지웠는데 로컬 추적 브랜치가 남아 있거나, 그 반대인 경우가 생깁니다.
- AI에게 맡기면 매번 방식이 다르다. 같은 "정리해 줘"에도 어떤 세션은 원격까지 지우고, 어떤 세션은 로컬만 지우고, 어떤 세션은 지우기 전에 묻고, 어떤 세션은 묻지 않습니다.
마지막이 결정적이었습니다. 사람에게 일을 시킬 때도 매번 말로 설명하면 결과가 들쭉날쭉합니다. 절차서를 한 번 써 두는 게 맞습니다.
가장 큰 함정: git branch --merged가 거짓말을 한다
브랜치 정리를 시키면 AI는 거의 예외 없이 이 명령부터 꺼냅니다.
git branch --merged dev | grep -v '\*\|dev\|main' | xargs git branch -d
교과서적인 방법이고, 머지 커밋으로 합치는 저장소에서는 잘 동작합니다. 그런데 저는 feature → dev를 Squash merge로 합칩니다. Squash merge는 feature의 커밋들을 새 커밋 하나로 만들어 dev에 올리기 때문에, feature 브랜치의 원래 커밋은 dev 이력 어디에도 없습니다. git 입장에서 그 브랜치는 "머지되지 않은" 브랜치입니다.
결과는 두 가지 중 하나입니다. --merged가 아무것도 찾지 못해 정리가 안 되거나, 이걸 본 AI가 친절하게 -D로 강제 삭제를 시도합니다. 후자는 아직 PR이 열려 있는 브랜치까지 함께 날릴 수 있다는 점에서 위험합니다.
그래서 기준을 바꿨습니다. 브랜치가 머지됐는지는 git이 아니라 PR에게 묻는다. GitHub에서 머지 완료된 PR의 head 브랜치만 정리 대상으로 삼습니다.
gh pr list --state merged --base dev --limit 100 \
--json headRefName -q '.[].headRefName'
이 한 가지 판단 기준이 prune-branch의 핵심입니다. 나머지는 그 기준을 안전하게 실행하는 순서일 뿐입니다.
정리에는 순서가 있다
기록을 다시 보니 정리 세션에서 막혔던 지점은 대부분 순서 문제였습니다. 그래서 스킬에 순서를 못박았습니다.
- 원격 상태부터 맞춘다.
git fetch --prune으로 이미 사라진 원격 브랜치의 추적 참조를 지웁니다. - 대상을 고른다. 머지된 PR의 브랜치만 후보로 올립니다. 열린 PR, 닫혔지만 머지되지 않은 PR, PR이 없는 브랜치는 건드리지 않고 목록으로만 보여 줍니다.
- 워크트리를 먼저 지운다. 후보 브랜치를 쓰는 워크트리가 있으면 먼저 제거합니다. 단, 커밋하지 않은 변경이 있으면 건너뛰고 알립니다.
- 로컬 브랜치를 지운다. 워크트리가 풀렸으니 이제 지워집니다. Squash merge라
-d는 거부하므로-D를 쓰되, 2단계에서 PR로 확인한 브랜치에만 씁니다. - 원격 브랜치를 지운다. GitHub의 "머지 후 head 브랜치 자동 삭제"가 켜져 있으면 이미 없을 수 있으니, 실패해도 넘어갑니다.
- 마무리한다.
git worktree prune으로 흔적을 치우고, dev를 최신으로 당깁니다.
사람이 손으로 할 때는 이 순서를 머릿속에서 처리합니다. AI는 순서가 적혀 있지 않으면 브랜치부터 지우려다 워크트리 에러를 만나고, 그때부터 즉흥적으로 해결하기 시작합니다. 즉흥이 쌓이면 사고가 납니다.
스킬로 옮기기
스킬 본문은 길지 않습니다. 절차를 길게 설명하기보다, AI가 틀리기 쉬운 지점과 하지 말아야 할 일을 분명히 적는 데 집중했습니다. 핵심만 추리면 이런 모양입니다.
---
name: prune-branch
description: 머지가 끝난 feature 브랜치와 워크트리를 로컬·원격에서 정리한다. "브랜치 정리", "워크트리 정리", 이슈 종료 후 호출.
---
# 판단 기준
- 이 저장소는 feature → dev를 Squash merge 한다.
- 그러므로 `git branch --merged`로 머지 여부를 판단하지 않는다.
- 머지 여부는 `gh pr list --state merged --base dev`로만 판단한다.
# 순서
1. git fetch --prune
2. 머지된 PR의 head 브랜치 목록을 만든다.
3. 그 브랜치를 쓰는 워크트리를 먼저 제거한다.
- 커밋하지 않은 변경이 있으면 건너뛰고 보고한다.
4. 로컬 브랜치 삭제 (-D 허용, 2번 목록에 있는 것만)
5. 원격 브랜치 삭제 (이미 없으면 무시)
6. git worktree prune, dev 최신화
# 하지 말 것
- main, dev, 현재 세션의 브랜치는 절대 지우지 않는다.
- 열린 PR, 머지 없이 닫힌 PR, PR이 없는 브랜치는 지우지 않고 목록만 보여 준다.
- 지우기 전에 삭제 대상 목록을 먼저 보여 준다.
스킬이 직접 실행하는 명령도 같이 정리해 두면 세션마다 결과가 같아집니다. 아래는 스킬이 따르는 절차를 셸로 옮긴 형태입니다.
#!/usr/bin/env bash
set -euo pipefail
BASE=dev
git fetch --prune origin
merged=$(gh pr list --state merged --base "$BASE" --limit 100 \
--json headRefName -q '.[].headRefName')
for b in $merged; do
# 1) 이 브랜치를 체크아웃한 워크트리 찾기
wt=$(git worktree list --porcelain | awk -v ref="refs/heads/$b" '
$1 == "worktree" { path = $2 }
$1 == "branch" && $2 == ref { print path }')
# 2) 워크트리가 있으면 먼저 제거 (변경이 남아 있으면 건너뜀)
if [ -n "$wt" ]; then
if [ -n "$(git -C "$wt" status --porcelain)" ]; then
echo "건너뜀 - 커밋 안 된 변경 있음: $wt"
continue
fi
git worktree remove "$wt"
fi
# 3) 로컬 → 원격 순서로 삭제
git branch -D "$b" 2>/dev/null || true
git push origin --delete "$b" 2>/dev/null || true
done
git worktree prune
git switch "$BASE" && git pull --ff-only
마지막 줄의 git switch는 메인 체크아웃에서만 돌립니다. 워크트리 세션 안에서는 브랜치를 바꾸지 않는다는 규칙을 따로 두었기 때문입니다.
작게 만들었기에 어디서든 쓴다
prune-branch는 일부러 정리 한 가지만 하도록 작게 만들었습니다. 덕분에 쓰임새가 두 갈래로 나뉩니다.
- 단독으로. 며칠 바쁘게 이슈를 처리하고 나서 "브랜치 정리"라고만 하면 됩니다. 쌓인 것을 한 번에 치웁니다.
- 다른 스킬 안에서. 이슈 종료를 맡는 상위 스킬이 PR 머지와 이슈 종료 뒤에 이 스킬을 부릅니다. 이슈 하나가 끝날 때마다 그 이슈의 흔적이 바로 사라지니, 애초에 쌓일 틈이 없습니다.
정리 로직이 한 곳에만 있다는 점도 중요합니다. 나중에 규칙이 바뀌면, 예를 들어 dev 말고 release 브랜치로도 PR을 받게 되면, 이 스킬 하나만 고치면 됩니다.
돌아보며
스킬로 만들 일을 고르는 기준은 생각보다 단순했습니다. 같은 부탁을 세 번 했다면 스킬 후보입니다. 특히 정리처럼 결과물이 남지 않는 일일수록 그렇습니다. 결과물이 없으니 잘했는지 확인할 방법도 없고, 그래서 매번 다른 방식으로 대충 하게 됩니다.
그리고 스킬에 담아야 할 것은 일반적인 git 지식이 아니라 내 저장소의 사정이었습니다. git branch --merged는 틀린 명령이 아닙니다. 제 저장소가 Squash merge를 쓰기 때문에 맞지 않을 뿐입니다. AI는 일반론을 이미 잘 알고 있습니다. AI가 모르는 건 내가 어떤 선택을 했고, 그 선택 때문에 무엇이 달라지는지입니다. 그걸 채팅으로 말하면 다음 세션에 사라지고, 스킬에 적으면 남습니다.
지금은 이슈를 닫고 나서 브랜치 목록을 들여다볼 일이 거의 없습니다. 제품은 계속 늘어나는데, 치우는 일은 오히려 줄었습니다.
댓글 0개