이 글에는 git 커밋을 파일 한 장으로 만들어 옮기는 방법(
format-patch/am)과, 머지 커밋을 자동으로 분류하는 트릭(merge-treeexit code)이 담겨 있습니다. 망 분리 환경이 아니어도, 백업·이관·오프라인 전달에 쓸 수 있습니다.
문제 인지 — push 가 아예 안 되는 저장소가 정본입니다
제가 다니는 회사는 보안 정책상 개발 작업을 하는 망과 정본을 두는 망이 분리돼 있습니다. 학생 개인정보를 다루는 교육 서비스라 코드와 데이터를 자산으로 보고 관리하는 구조입니다.
정리하면 이렇습니다.
| 어디 | 상태 | |
|---|---|---|
| 외부망 | 실제 개발 작업이 이뤄지는 곳 | 여기 저장소만 접근 가능 |
| 내부망 | 정본 저장소 | 외부망에서 push·fetch 자체가 안 됨 |
즉 "외부망에서 커밋하면 정본에 자동으로 반영" 이 구조적으로 불가능합니다. 누군가 손으로 옮겨야 합니다.
이 방식 자체는 팀의 정식 작업 절차입니다. 제가 우회하려고 만든 게 아니라, 원래 그렇게 일하는 환경이고 그 안에서 매번 발생하는 수작업을 줄인 이야기입니다.
고민 — 자동화가 원리적으로 막혀 있습니다
처음엔 그 절차를 사람이 매번 손으로 했습니다.
- 정본 저장소가 어디까지 반영됐는지 확인 (기준 커밋 찾기)
- 외부망 저장소에서 기준 커밋부터 지금까지의 변경을 비교 화면으로 확인
- 내부망에서 수동으로 적용
chore(sync): 외부망 업데이트 반영 (기준 커밋: N)으로 커밋
동작은 했습니다. 그런데 두 가지가 걸렸습니다.
① 매번 사람이 diff 를 읽고 재현해야 합니다. 10분 남짓이지만 실수가 섞입니다. 파일 하나 빠뜨리면 나중에 왜 다른지 추적하기 어렵습니다.
② 커밋 이력이 뭉개집니다. 며칠치 작업이 chore(sync) 하나로 합쳐집니다.
정본 저장소의 히스토리를 나중에 보면 "이 기능이 왜 이렇게 됐는지"를 읽을 수 없습니다.
동료가 방향을 명확히 잡아줬습니다.
"두 저장소 커밋을 통일하는 게 맞다."
뭉치지 말고 원본 커밋을 그대로 보존해서 양쪽 히스토리를 같게 유지하자는 뜻이었습니다.
여기서 고민이 시작됐습니다. 네트워크가 막혀 있는데 커밋을 어떻게 그대로 옮기나?
💡 비유 — 다리는 없고 바구니 하나가 왕복합니다
강 양쪽에 마을이 있습니다. 다리는 없습니다. 대신 줄에 매달린 바구니 하나가 왕복합니다. 그 바구니에 들어가는 것만 건너갈 수 있습니다.
이 상황에서 할 일은 "다리를 놓는 방법"을 찾는 게 아닙니다. 물건을 바구니에 맞게 포장하는 방법을 찾는 겁니다.
그리고 그 바구니가 무엇인지 정확히 아는 게 먼저였습니다. 여기서 한 번 크게 틀렸습니다.
원인 탐색 — "정답"이라 믿었던 방법이 채널을 못 탔습니다
커밋을 SHA까지 완전히 동일하게 복제하려면 이론적으로 답이 하나 있습니다. git bundle 입니다.
# 저장소의 일부(또는 전체)를 파일 한 개로 묶는다
git bundle create sync.bundle <base>..<head>
# 받는 쪽에서 그냥 원격처럼 취급해 가져온다
git fetch sync.bundle <branch>
이건 저장소 객체를 그대로 담기 때문에 메시지도, 작성자도, 커밋 SHA 도 완전히 동일합니다. 기준 커밋을 주석으로 남길 필요도 없습니다. 이론상 최선입니다.
그래서 이걸 1순위로 정하고 만들기 시작했습니다.
그런데 실제 전송 채널을 확인해보니 파일 전송이 아니었습니다. 건너갈 수 있는 건 클립보드 텍스트 하나였습니다.
git bundle 은 바이너리입니다. 클립보드로 옮길 수 없습니다.
💡 비유 — 바구니에 안 들어가는 짐
"짐을 완벽하게 포장하는 방법"을 찾아 상자를 아주 잘 만들었는데, 그 상자가 바구니보다 큽니다.
포장 기술이 나쁜 게 아닙니다. 바구니 크기를 먼저 재지 않은 게 문제였습니다.
이 실수에서 배운 게 하나 있습니다. "최선의 방법"을 고르기 전에 "전송 채널의 제약"을 먼저 확정해야 합니다. 제약이 방법을 결정하는데, 저는 방법을 먼저 고르고 제약을 나중에 봤습니다.
개선 — 텍스트로 건너갈 수 있는 형태로 바꿨습니다
바구니가 텍스트만 받는다면, 커밋을 텍스트로 만들면 됩니다.
git 에는 정확히 그 기능이 있습니다.
# 커밋 범위를 텍스트 패치로 뽑아 표준출력으로
git format-patch <base>..<head> --stdout
출력이 그냥 텍스트입니다. 메시지·작성자·작성일·파일 변경 내용이 다 들어 있습니다. 그리고 받는 쪽에서는
# 패치를 커밋으로 되살린다 (커밋 단위가 그대로 복원됨)
git am patch.txt
git apply 와 다릅니다. apply 는 파일 변경만 적용하고 커밋은 직접 해야 합니다.
am 은 커밋 메시지와 작성자까지 복원해서 커밋을 만들어 줍니다.
즉 이렇게 됩니다.
외부망: git format-patch --stdout | 클립보드
↓ (바구니가 건너간다)
내부망: 붙여넣기 → patch.txt → git am
↓
원본과 같은 커밋들이 되살아난다
감수한 트레이드오프 — SHA 는 달라집니다
솔직하게 적습니다. 최초 목표를 완전히 달성하지 못했습니다.
git am 으로 패치를 적용하면
| 유지되나 | |
|---|---|
| 커밋 메시지 | ✅ 그대로 |
| author (작성자·작성일) | ✅ 패치에 기록된 원본 그대로 |
| 파일 변경 내용 | ✅ 그대로 |
| committer (커밋 시각) | ❌ 적용한 시점으로 새로 찍힘 |
| 커밋 SHA | ❌ 원본과 달라짐 |
커밋 SHA 는 커밋 시각까지 포함해 계산되기 때문입니다.
"SHA까지 동일"은 bundle 만 할 수 있고, bundle 은 이 채널을 못 탑니다.
그래서 "메시지·작성자·내용은 동일한 부분적 통일" 로 목표를 낮췄습니다.
이게 나쁜 결정이라고 생각하지 않습니다. 원래 원했던 건 "히스토리를 읽을 수 있게 하는 것"이었고, 그건 달성했습니다. SHA 동일성은 수단이었는데 목적으로 착각했던 겁니다.
개선 방법 — 머지 커밋이 복병이었습니다
여기서 예상 못 한 문제가 나왔습니다. 머지 커밋입니다.
기능 브랜치를 통합 브랜치에 합치는 게 흔한 흐름이라, 옮길 범위 안에 머지가 섞입니다. 그런데 머지는 두 종류이고, 둘을 같게 다루면 하나는 100% 실패합니다.
종류 ① 충돌 없이 자동 병합된 머지
이건 쉽습니다. 평소처럼 패치를 만들고, 받는 쪽에서 3-way 옵션으로 재생하면 됩니다.
git am --3way patch.txt
--3way 는 문맥이 살짝 안 맞아도 양쪽 조상을 보고 맞춰 줍니다.
종류 ② 손으로 충돌을 해결한 머지
이건 개별 커밋 패치로는 재현으로 확인한 결과 100% 실패합니다.
왜 그런지 순서를 따라가 보면 이렇습니다.
1. 패치를 순서대로 재생한다
2. 원래 겪었던 충돌이 그 자리에서 그대로 재발한다 ← 당연하다. 같은 커밋들이니까
3. 손으로 해결한다 → 이미 정답 상태가 된다
4. 그 뒤에 "해결 내용" 패치를 적용하려 하면
→ "patch does not apply" (이미 적용된 상태라 적용할 게 없다)
💡 비유 — 이미 푼 문제집에 정답지를 덮어쓰기
문제집을 복사해서 보냈습니다. 받는 사람이 문제를 다 풀었습니다. 그 뒤에 "정답지를 이 페이지에 붙이세요"라고 하면 붙일 자리가 없습니다. 이미 그 자리에 답이 쓰여 있으니까요.
그래서 이 경우는 접근을 바꿨습니다. 개별 커밋을 보내지 말고 최종 결과물을 커밋 하나로 뭉쳐서 통째로 보냅니다.
CURRENT=$(git branch --show-current)
git checkout -b temp-sync <end> # 임시 브랜치에서만 작업
git reset --soft <base> # 커밋만 풀고 변경은 남긴다
git commit -m "외부망 작업분 통합 반영 (머지 충돌 해결 포함)"
git format-patch -1 HEAD --stdout | pbcopy
git checkout "$CURRENT"
git branch -D temp-sync # 임시 브랜치 즉시 삭제
원본 브랜치 히스토리는 건드리지 않습니다. 임시 브랜치에서만 뭉치고 바로 지웁니다.
💡 두 종류를 자동으로 구분하는 트릭
문제는 "이 머지가 어느 종류인지"를 매번 사람이 기억할 수 없다는 것이었습니다.
git merge-tree 가 답이었습니다.
PARENTS=$(git log -1 --format=%P <merge_sha>) # "parent1 parent2"
git merge-tree --write-tree <parent1> <parent2> > /dev/null 2>&1
echo $? # 0 = 충돌 없이 자동 병합 가능했던 머지
# 0 아님 = 손으로 충돌을 해결한 머지
이 명령은 워킹 트리를 건드리지 않고 객체 데이터베이스 안에서만 3-way 병합을 시뮬레이션합니다. 체크아웃도, abort 도 필요 없습니다.
그래서 옮길 범위 안의 머지 커밋마다 이걸 돌려서 분기를 자동 결정합니다.
| 판정 | 처리 |
|---|---|
| 머지 없음 | 그냥 패치 |
| 순수 자동 병합만 | 패치 + --3way |
| 충돌 해결된 머지 있음 | 임시 브랜치에서 뭉쳐 한 커밋으로 |
두 번 실패하고 배운 것 — 양쪽이 각자 만드는 파일
이 절차를 굳힌 뒤에도 두 회차 연속으로 같은 이유로 실패했습니다.
package-lock.json 이었습니다.
error: could not build fake ancestor
patch failed: package-lock.json:1234
원인은 단순했습니다. 그 파일은 양쪽이 각자 npm install 로 만듭니다.
같은 package.json 에서 만들어도 내용이 미세하게 갈라지고,
그러면 패치가 기준으로 삼을 조상 상태를 찾을 수 없습니다.
💡 비유 — 각자 구운 빵을 서로 배송하려는 것
레시피(package.json)를 보내는 건 됩니다. 받는 쪽에서 같은 빵을 구울 수 있습니다.
그런데 구운 빵 자체를 보내려 하면, 받는 쪽에 이미 자기가 구운 빵이 있어서 겹칩니다.
같은 이유로 실패한 파일이 하나 더 있었습니다. 이 절차의 진행 기록을 남기는 파일도 양쪽이 각자 추가하고 있었습니다.
그래서 패치를 만들 때 두 파일을 명시적으로 제외했습니다.
git format-patch <base>..<end> --stdout -- ':(top)' \
':(exclude,top)package-lock.json' \
':(exclude,top).claude/skills/<skill>/hash-history.md'
pathspec 으로 빼면 그 파일만 건드린 커밋은 통째로 빠지고(패치 번호는 자동 재정렬), 코드와 함께 건드린 커밋은 그 파일 부분 diff 만 빠집니다.
대신 의존성이 바뀐 회차에는 받는 쪽에서 npm install 을 한 번 돌려야 합니다.
그 안내를 절차에 넣었습니다 — 레시피는 보내고 빵은 각자 굽는다는 원칙입니다.
반복 작업을 스킬로 굳혔습니다
여기까지 오니 사람이 기억해야 할 게 너무 많아졌습니다.
- 기준 커밋을 어디서 찾나
- 머지가 섞였는지 어떻게 판단하나
- 어느 파일을 빼야 하나
- 패치 순서가 맞는지 어떻게 확인하나
그래서 이 판단들을 AI 코딩 도구의 스킬로 굳혔습니다. 트리거 문장 하나면 절차 전체가 돕니다.
굳히면서 일반화한 것들이 있습니다.
① 리모트 이름·브랜치·클립보드 명령을 하드코딩하지 않습니다. 매 실행 시 자동 감지합니다. 저장소마다 이름 규칙이 다르고, 클립보드 명령은 OS 마다 다릅니다.
copy_to_clipboard() {
if command -v pbcopy >/dev/null 2>&1; then pbcopy # macOS
elif command -v clip.exe >/dev/null 2>&1; then clip.exe # Windows
elif command -v wl-copy >/dev/null 2>&1; then wl-copy # Wayland
elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard
else cat > /tmp/sync.patch
echo "⚠️ 클립보드 도구를 못 찾아 파일로 저장했습니다: /tmp/sync.patch" >&2
fi
}
pbcopy 를 하드코딩하면 macOS 밖에서는 command not found 로 조용히 실패합니다.
패치가 클립보드에 없는데 사용자는 있다고 믿는 게 최악입니다.
② 기준 커밋을 파일로 추적합니다. "마지막으로 반영한 커밋"을 기록해두면 다음 실행 때 그 다음부터 자동으로 범위를 잡습니다. 같은 커밋을 두 번 보내는 일이 없어집니다.
③ 패치 순서를 사람이 눈으로 확인할 수 있게 합니다.
패치는 반드시 오래된 것 → 최신 순서여야 합니다. 순서가 어긋나면 적용이 깨집니다.
format-patch --stdout 은 기본이 그 순서라 --reverse 를 넣으면 안 됩니다.
그런데 사용자가 "정말 순서대로 만들어졌나" 불안해할 수 있습니다. 패치 원문을 채팅에 다시 뿌리면 노이즈가 크니, 번호 매긴 커밋 목록으로 확인하게 했습니다.
git log --oneline --reverse --no-merges <base>..<end> -- <pathspec> | nl -w2 -s') '
이 번호가 곧 패치 파일에 매겨진 번호와 같습니다.
개선 후 — 한 가지를 뒤집었습니다
기록 파일을 처음엔 아래에 계속 쌓는 방식(위=오래된, 아래=최신)으로 만들었습니다. 로그 파일의 일반적인 관례라서요.
동료가 요청했습니다. "최근 게 맨 위에 오게 해주세요."
기록이 늘어나면 최신을 보려고 매번 파일 끝까지 스크롤해야 하니까요. 그리고 이 파일에서 가장 자주 보는 건 언제나 맨 마지막 기록입니다.
뒤집었습니다. 그런데 여기서 사소한 함정이 있었습니다.
헤더(설명문)와 본문(기록 줄들)을 분리해서 새 줄을 헤더 바로 뒤에 끼워야 하는데, 처음에는 셸의 명령 치환으로 분리했습니다.
HEADER=$(awk '...' "$FILE") # ← 후행 빈 줄이 잘린다
명령 치환은 뒤쪽 개행을 없앱니다. 그래서 헤더와 첫 기록 줄 사이 빈 줄이 사라져 마크다운이 깨졌습니다. 삽입 위치만 계산하는 방식으로 바꿨습니다.
s = open(path).read()
i = s.index('\n- ') # 첫 로그 줄 위치
open(path, 'w').write(s[:i] + '\n' + new_line + s[i:])
헤더를 다시 쓰지 않으니 빈 줄이 보존됩니다.
남아 있는 트레이드오프
정직하게 두 가지를 적어둡니다.
① SHA 불일치 — 앞서 적은 대로입니다. 정본 저장소의 커밋 SHA 는 외부망 원본과 다릅니다. 메시지·작성자·내용만 동일한 부분적 통일입니다.
② 충돌 머지 구간의 이력 손실 — 충돌을 해결한 머지가 섞인 회차는 뭉쳐서 넘어가므로, 정본 히스토리엔 그 구간이 커밋 하나로만 남습니다. 머지 없는 날처럼 개별 커밋 단위로는 보존되지 않습니다.
둘 다 전송 채널의 제약에서 나온 것이고, 채널이 바뀌지 않으면 해결되지 않습니다. "완전한 해결"이 아니라 "제약 안에서의 최선"입니다.
배운 것
제약을 먼저 확정하고 방법을 고르세요
가장 큰 실수는 git bundle 이 틀린 답이라서가 아니었습니다.
bundle 은 이 문제의 이론적 정답입니다.
문제는 전송 채널이 무엇인지 확인하기 전에 방법을 골랐다는 것입니다.
채널이 텍스트라는 걸 처음에 알았다면 bundle 은 후보에 오르지도 않았습니다.
최적해를 찾기 전에 해가 통과해야 하는 구멍의 크기를 재세요. 구멍이 방법을 결정합니다.
"수단"을 "목적"으로 착각하지 마세요
"SHA까지 동일"을 목표로 잡았는데, 원래 원했던 건 히스토리를 읽을 수 있게 하는 것이었습니다. SHA 동일성은 그걸 달성하는 한 가지 수단이었을 뿐입니다.
수단을 목적으로 놓으면 더 쉬운 길이 보이지 않습니다. 목표를 "부분적 통일"로 낮춘 순간 문제가 풀렸습니다.
양쪽이 각자 만드는 것은 옮기지 마세요
같은 이유로 두 회차 연속 실패한 뒤 정리한 원칙입니다.
생성물은 보내지 않고, 생성 방법을 보냅니다.
package-lock.json 은 package.json 을 보내고 받는 쪽에서 만들게 합니다.
이건 망 분리 환경만의 이야기가 아닙니다. 저장소 간 이관·백업 복원·환경 복제 어디서나 같습니다.
조용한 실패를 만들지 마세요
pbcopy 하드코딩이 그랬습니다. macOS 밖에서는 아무 말 없이 실패하고,
사용자는 클립보드에 패치가 있다고 믿습니다.
폴백을 넣고 폴백했다는 사실을 반드시 알리도록 했습니다. "조용히 실패하는 것"보다 "시끄럽게 실패하는 것"이 언제나 낫습니다.
마무리
이 작업을 한 줄로 적으면 이렇습니다.
다리를 놓을 수 없는 상황에서, 바구니에 맞는 포장법을 찾았다.
환경을 바꿀 수 없을 때 할 수 있는 건 두 가지입니다. 환경을 탓하며 멈추거나, 환경을 전제로 두고 그 안에서 마찰을 줄이는 것.
매번 10분씩 걸리던 수작업이 트리거 문장 하나로 줄었고, 무엇보다 커밋 이력이 뭉개지지 않게 됐습니다. 나중에 정본 저장소를 볼 사람이 "이 기능이 왜 이렇게 됐는지"를 읽을 수 있습니다.
완전한 해결은 아닙니다. SHA 는 여전히 다르고, 충돌 머지 구간은 뭉쳐집니다. 그래도 제약 안에서 갈 수 있는 만큼은 갔다고 생각합니다.
읽어주셔서 고맙습니다 :)
부록 — 이 글에 나온 git 명령
| 명령 | 하는 일 |
|---|---|
git format-patch <base>..<head> --stdout | 커밋 범위를 텍스트 패치로. 기본 순서가 오래된 것 → 최신 |
git am <patch> | 패치를 커밋 단위로 복원. 메시지·작성자 유지 |
git am --3way <patch> | 문맥이 살짝 어긋나도 양쪽 조상을 보고 병합 |
git merge-tree --write-tree <p1> <p2> | 워킹 트리를 건드리지 않고 병합 시뮬레이션. exit 0 = 충돌 없음 |
git reset --soft <base> | 커밋만 풀고 변경은 워킹 트리에 남김 (뭉치기용) |
git format-patch ... -- ':(exclude,top)<path>' | 특정 파일을 패치에서 제외 |
git log --oneline --reverse --no-merges <base>..<end> | 패치에 매겨질 순서를 눈으로 확인 |