본문 바로가기
개발일기

git bisect run으로 버그 커밋 찾기: 빌드 깨진 커밋을 범인으로 찍지 않으려면 exit 125

by 꼬질꼬질두부 2026. 10. 19.
반응형

주문 합계가 1,300원에 30% 할인을 적용하면 910원이 나와야 하는데 909원이 나온다. 2주 전 커밋에서는 멀쩡했다는 건 아는데, 그 사이 커밋이 스무 개쯤 된다. 이럴 때 꺼내는 게 git bisect run이다. 테스트 스크립트 하나만 주면 Git이 이진 탐색으로 범인 커밋을 찾아준다.

나는 스크립트를 "통과하면 0, 실패하면 1"로만 짜면 된다고 생각했다. 그런데 중간에 빌드가 잠깐 깨졌던 커밋이 끼어 있으면, bisect가 엉뚱한 커밋을 범인으로 지목한다. 이 글에서는 실습용 저장소에서 그 상황을 그대로 재현하고, exit 125 한 줄로 결과가 어떻게 바뀌는지 확인한다.

git bisect run에서 빌드가 깨진 커밋을 skip해야 하는 이유를 확인한 실습의 표지.

결론 먼저
1. git bisect run 스크립트의 종료 코드는 0이면 good, 1~127(125 제외)이면 bad, 125면 skip이다.
2. 빌드·import 실패까지 1로 돌려주면, 버그와 상관없이 깨졌던 커밋이 "first bad commit"으로 나올 수 있다. 실습에서도 그랬다.
3. 판정할 수 없는 커밋은 exit 125로 건너뛰게 하자. 다만 건너뛴 커밋이 범인 바로 옆이면 Git도 후보 목록만 줄 수 있다.

이 글이 필요한 사람: 회귀 버그가 언제 들어왔는지 찾으려고 git bisect를 처음 자동화해보는 개발자, 특히 히스토리 중간에 빌드가 깨진 커밋이 섞여 있는 저장소를 다루는 사람.

실습 저장소: 커밋 20개, 중간에 빌드가 깨진 구간

실제 프로젝트가 아니라 이 글을 위해 만든 Python 저장소다. git version 2.50.1 (Apple Git-155), Python 3.13.12, macOS에서 돌렸다. 커밋 구성은 이렇다.

커밋 내용 상태
c01 total() 함수 추가 (round 사용) 정상
c02~c09 문서만 수정 정상
c10 price.py에 from formatting import won 추가. 그런데 formatting.py가 없다 import 실패
c11 문서만 수정 import 실패
c12 formatting.py 추가 정상으로 복구
c13 문서만 수정 정상
c14 round(...)를 int(...)로 변경 진짜 버그
c15~c20 문서만 수정 버그 있음

실습용으로 만든 커밋 구성. 해시는 실행 환경마다 달라진다.

c14의 변경은 한 줄이다. 보기엔 "정리"인데, 이게 909원의 원인이다.

-    return round(subtotal * (1 - discount_rate))
+    return int(subtotal * (1 - discount_rate))
$ python3 -c "v = 1300 * (1 - 0.3); print(v, int(v), round(v))"
909.9999999999999 909 910

부동소수점 계산 결과가 909.9999999999999라서 int()는 소수점을 버리고 909를 돌려준다. 이 글의 주제는 bisect라서 원인은 여기까지만 짚고 넘어간다. 금액 계산을 float로 하면 안 되는 이유는 따로 다룰 만한 주제다.

처음 짠 스크립트: 실패하면 무조건 1

처음엔 이렇게 짰다. 909원이 나오면 assert가 실패하고, Python이 1로 종료하니까 bisect가 bad로 처리할 거라고 생각했다.

#!/bin/sh
# check_naive.sh
python3 -c "from price import total; assert total([1000, 300], 0.3) == 910, total([1000, 300], 0.3)"

스크립트는 저장소 바깥(../bisect-scripts/)에 뒀다. bisect가 커밋을 오갈 때 스크립트까지 바뀌면 곤란하기 때문인데, 공식 문서도 스크립트를 저장소 밖에 두는 게 더 안전하다고 적고 있다.

git bisect start main 0754dbe     # main(c20)은 bad, c01은 good
git bisect run ../bisect-scripts/check_naive.sh
Bisecting: 9 revisions left to test after this (roughly 3 steps)
[458e4244...] c10: 금액 포맷 함수 연결 (모듈 누락)
running '../bisect-scripts/check_naive.sh'
...
ModuleNotFoundError: No module named 'formatting'
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[5d10138b...] c05: 문서 정리 5
running '../bisect-scripts/check_naive.sh'
Bisecting: 2 revisions left to test after this (roughly 1 step)
[e775782f...] c07: 문서 정리 7
running '../bisect-scripts/check_naive.sh'
Bisecting: 0 revisions left to test after this (roughly 1 step)
[300462f3...] c09: 문서 정리 9
running '../bisect-scripts/check_naive.sh'
458e4244b4b6ee0ebd4f7048db2b5835b0299a74 is the first bad commit
    c10: 금액 포맷 함수 연결 (모듈 누락)
bisect found first bad commit

결과는 c10. 범인으로 나온 건 모듈이 빠져서 import가 실패한 커밋이다. 할인 계산은 건드리지도 않은 커밋이다.

과정을 보면 이유가 보인다. bisect가 처음 고른 중간 지점이 하필 c10이었다. 스크립트는 ModuleNotFoundError로 1을 돌려줬고, bisect는 "여기부터 버그가 있구나" 하고 c10 이후를 통째로 bad로 봤다. 그 뒤로는 c01~c09만 뒤져서, 전부 good이 나오자 c10을 first bad로 확정했다.

이 결과만 믿었으면 나는 c10 작성자한테 "할인 계산 깨진 거 이 커밋 같은데요?"라고 물어봤을 거다. 꽤 민망한 상황이 됐을 듯 ㅠ

exit 125로 판정 불가 커밋 건너뛰기

bisect 입장에서 c10은 "버그가 있는 커밋"이 아니라 "테스트를 해볼 수조차 없는 커밋"이다. git bisect run은 이런 경우를 위해 종료 코드 125를 따로 남겨뒀다. 125를 받으면 그 커밋을 skip하고 근처의 다른 커밋을 고른다.

#!/bin/sh
# check.sh
# 1) import조차 안 되면 이 커밋은 판정 불가 → 125(skip)
python3 -c "import price" 2>/dev/null || exit 125
# 2) 실제 버그 검사: 1,300원에 30% 할인 → 910원이어야 한다
python3 -c "from price import total; assert total([1000, 300], 0.3) == 910, total([1000, 300], 0.3)"

같은 범위로 다시 돌리고 git bisect log로 판정 기록을 봤다.

# bad: [0cda5054...] c20: 문서 정리 20
# good: [0754dbe6...] c01: 주문 합계 함수 추가
# skip: [458e4244...] c10: 금액 포맷 함수 연결 (모듈 누락)
# skip: [171e1d54...] c11: 문서 정리 11
# good: [35941c7a...] c04: 문서 정리 4
# good: [a2d56e80...] c12: formatting 모듈 추가 (빌드 복구)
# bad: [cf8f427b...] c16: 문서 정리 16
# bad: [3f7742b8...] c14: 할인 계산 정리
# good: [225055da...] c13: 문서 정리 13
# first bad commit: [3f7742b8...] c14: 할인 계산 정리

이번엔 c14: 할인 계산 정리가 나왔다. c10과 c11은 skip으로 기록됐고, bisect는 c04와 c12처럼 판정 가능한 커밋으로 우회했다. c12가 good, c14가 bad, c13이 good이니 범인은 c14로 좁혀진다.

재밌는 건 skip 이후의 순서다. c11 다음에 갑자기 c04를 테스트한다. 건너뛴 지점 대신 테스트할 커밋을 고르다 보니 범위의 반대쪽으로 튄 건데, 처음엔 "왜 뒤로 가지?" 싶었다. 결과적으로 테스트가 몇 번 더 늘었을 뿐, 답은 정확했다.

같은 저장소에서 두 스크립트가 고른 범인을 비교한 개념도. 판정은 위 실습 로그를 그대로 옮겼다.

skip이 만능은 아니다: 후보 목록만 남는 경우

그럼 125만 붙이면 끝일까? 공식 문서에는 찾는 커밋 바로 옆의 커밋을 skip하면 Git이 그중 어느 게 first bad인지 정확히 말할 수 없다는 경고가 있다. 직접 확인해보고 싶어서 일부러 범인을 c12로 옮겨봤다. "formatting.py가 처음 생긴 커밋"을 bad로 보는 개념 실험이다.

#!/bin/sh
# check_c12.sh (개념 실험)
python3 -c "import price" 2>/dev/null || exit 125
test ! -f formatting.py
There are only 'skip'ped commits left to test.
The first bad commit could be any of:
171e1d54da770a395bf4d49ff08766c8978792fd
458e4244b4b6ee0ebd4f7048db2b5835b0299a74
a2d56e8022689acc3bcedd074cb41e6021ddc6c9
We cannot bisect more!
error: bisect run cannot continue any more

c09는 good, c12는 bad인데 그 사이 c10·c11이 판정 불가라서, Git은 c10·c11·c12를 모두 후보로 내놓고 멈췄다. 틀린 답을 내는 대신 "여기까지밖에 모른다"고 말해주는 셈이라, 첫 번째 실험의 조용한 오답보다는 훨씬 낫다고 느꼈다.

이 상태가 되면 후보 커밋 몇 개를 직접 읽거나, 깨진 빌드를 임시로 고치는 패치를 테스트 스크립트에서 적용하는 방법이 있다. 공식 문서에도 hot-fix 브랜치를 merge해서 테스트하고 git reset --hard로 되돌리는 예시가 있다. 이번 실습에서는 거기까지는 해보지 않았다.

종료 코드별로 bisect가 하는 일

스크립트 종료 코드 bisect 판정 주의점
0 good 테스트가 실제로 돌았는지 확인. import만 하고 끝나는 스크립트도 0이다
1~127 (125 제외) bad 빌드·import 실패도 여기 들어가면 오답 원인이 된다
125 skip 범인 바로 옆을 건너뛰면 후보 목록만 나온다
그 밖의 값 (128 이상 등) bisect 중단 exit(-1)은 255가 되어 중단된다

git-bisect 공식 문서의 bisect run 설명 기준. 0·1·125 동작은 이번 실습에서 직접 확인했다.

실습하면서 하나 더 본 것: Python 스크립트를 돌리고 나면 저장소에 __pycache__/가 untracked로 남는다. 이번엔 bisect 진행에 영향이 없었지만, 빌드 산출물이 커밋 전환을 방해하는 프로젝트라면 스크립트에서 정리하거나 PYTHONDONTWRITEBYTECODE=1 같은 설정을 고려해볼 만하다. 이건 일반적인 팁이고, 이번 실습에서 문제를 재현한 건 아니다.

정리: bisect 스크립트는 "버그 있음"과 "판정 불가"를 구분해야 한다

지금은 이렇게 이해하고 있다. git bisect run이 믿는 건 내 스크립트의 종료 코드 하나뿐이다. 그래서 스크립트가 "버그가 있다"와 "테스트를 못 한다"를 같은 1로 말하면, bisect는 그걸 구분할 방법이 없다.

실무에서 쓸 때는 스크립트 앞부분에 빌드·설치·import 단계를 두고 실패하면 exit 125, 그 다음에 진짜 검사를 두는 구조가 기본값이 될 것 같다. 공식 문서 예시도 make || exit 125로 같은 모양이다. 그리고 결과가 후보 목록으로 나오면 그건 실패가 아니라, skip한 구간을 직접 봐야 한다는 신호다.

다음에는 merge 커밋이 많은 저장소에서 --first-parent 옵션을 쓰면 브랜치 안의 깨진 커밋을 어떻게 피해 가는지 확인해봐야겠다. 20개짜리 장난감 저장소인데도 생각보다 배운 게 많았다.

참고한 자료

반응형

댓글