빌드노트
버그와 무관한 빌드 실패는 git bisect skip으로 남긴다
기능 버그를 찾는 bisect에서 무관한 빌드 실패를 bad로 표시하면 엉뚱한 커밋으로 범위가 줄어든다. 합성 이력에서 종료 코드 1과 125의 결과를 비교하고, skip 뒤에도 후보가 C2·C3·C4로 남은 경우와 판단 기준을 설명한다.
git bisect로 기능 버그를 찾다가 중간 커밋이 빌드되지 않을 수 있다. 이때 bad를 주면 Git은 그 커밋에서도 찾던 버그를 확인했다고 받아들인다. 검사를 실행하지 못했다면 수동 탐색에서는 git bisect skip, 자동 탐색에서는 검사 스크립트의 종료 코드 125로 남긴다. 정상 동작을 확인한 good과도 구별해야 한다.
여기서 찾는 것은 수수료 계산 오류처럼 실행 결과로 판단하는 버그다. 빌드 실패 자체가 조사 대상이라면 빌드에 실패한 커밋을 bad로 분류하는 것이 맞다. 어떤 현상이 bad인지 탐색 내내 같아야 한다.
같은 이력에서 C2와 C4가 다르게 지목됐다
이를 비교하려고 Windows의 Git 2.45.1.windows.1, Python 3.12.10, Git Bash에서 새 합성 저장소를 만들었다. 실제 서비스 장애를 재현한 것은 아니다. 빌드 단계는 Python 파일의 구문 컴파일로, 기능 검사는 fee()가 0을 반환하는지로 정했다.
이력은 C0 → C1 → C2 → C3 → C4 → C5 → C6 → C7 → C8의 아홉 커밋이다. C2에는 수수료 함수와 무관한 줄을 unrelated =로 끊어 구문 오류를 넣었고 C3에서 복구했다. 수수료 반환값을 0에서 1로 바꾼 커밋은 C4다. 이후에는 그 변경을 되돌리지 않았다.
C8을 bad, C0를 good으로 시작하고 두 검사 스크립트를 비교했다. 첫 스크립트는 컴파일 실패도 1로 반환했다. Git은 C4를 bad로 확인한 뒤 C2도 bad로 표시했고, 이어 확인한 C1이 good이자 C2를 원인으로 지목했다. C3은 검사하지 않았다.
두 번째 스크립트는 컴파일 실패에 125를 반환했다. 이번에는 C2를 건너뛰고 C3을 검사해 good으로 확인한 뒤 C4를 지목했다. 아래는 두 실행의 실제 출력에서 가져온 판정 행이다. 첫 줄은 C2, 둘째 줄은 C4다.
2639647ac64ead69925a955b1229f760150b7dff is the first bad commit
83eb8573019eb73695745542a506bf94fc99bb24 is the first bad commit
두 실행 모두 git bisect run 자체는 종료 코드 0으로 끝났다. 탐색 명령의 정상 종료만으로는 분류 기준이 옳았는지 알 수 없었다. 컴파일 오류를 없앤 대조 이력에서도 같은 기능 검사는 C4를 찾았다.
자동 검사에는 세 가지 결과가 필요하다
Git 공식 문서의 bisect run 설명 (새 탭에서 열림)은 검사 명령의 0을 good, 1부터 127 중 125를 제외한 값을 bad로 해석한다고 명시한다. 125는 현재 커밋을 검사할 수 없어 건너뛴다는 뜻이다. 다음은 이 실습에서 실행한 검사다. 준비물은 python으로 실행되는 Python 3와 Git Bash 또는 POSIX sh다.
정상 입력인 app.py의 내용은 다음과 같다.
def fee():
return 0
unrelated = 7
아래 내용을 체크아웃할 저장소 바깥의 classify.sh에 저장하고, 저장소 루트에서 실행한다. 첫 단계가 컴파일 가능 여부를 확인하고, 다음 단계가 수수료 값을 검사한다.
#!/bin/sh
python -m py_compile app.py || exit 125
python -c 'import app; raise SystemExit(0 if app.fee() == 0 else 1)'
실제로 각 입력을 체크아웃해 이 스크립트를 실행했을 때 결과는 다음과 같았다.
- 정상 파일에서는 종료 코드
0이었다. 기능 검사까지 실행해 기대값을 확인했다. return 1로 바꾼 파일에서는 종료 코드1이었다. 기능 검사까지 실행했고 기대값과 달랐다.unrelated =로 끝나는 파일에서는SyntaxError: invalid syntax가 출력되고 종료 코드125였다. 기능 검사는 실행하지 않았다.
자기 코드에 적용할 때는 두 명령을 해당 프로젝트의 빌드와 특정 버그 재현 검사로 바꾼다. 먼저 정상 커밋과 재현 커밋에서 같은 검사가 각각 0과 1을 내는지 확인한다. 명령을 못 찾거나 검사 환경이 깨진 오류까지 버그 재현으로 취급하지 않아야 한다. 위의 짧은 스크립트는 Python이 준비된 이 합성 입력을 위한 것이다.
실험에서는 다음 순서로 실행했다. C8과 C0는 이 합성 저장소에 만든 태그다. 자기 저장소에서는 직접 확인한 bad 커밋과 good 커밋의 실제 해시로 바꾼다. 아래 명령의 ../classify.sh는 저장소 바로 바깥에 둔 검사 파일을 가리킨다. 검사 파일을 바깥에 두면 과거 커밋을 체크아웃할 때 검사 내용까지 바뀌는 일을 피할 수 있다.
git bisect start C8 C0 --
git bisect run sh ../classify.sh
git bisect log
git bisect reset
깨끗한 전용 작업 폴더에서 시작하고, 탐색 결과와 log를 읽은 뒤 마지막 reset까지 실행한다. git bisect reset은 탐색 시작 전 체크아웃 위치로 돌아간다. 수동 탐색 중 같은 이유로 기능 검사를 실행하지 못했다면, 그 커밋에서 git bisect skip을 실행하면 된다. 이 실습의 수동 skip도 C4로 좁혀졌다.
skip을 써도 원인이 한 개로 정해지지 않을 수 있다
별도 합성 이력에서는 C2와 C3 모두 컴파일되지 않게 만들고, 수수료 반환값 변경을 C3에 넣었다. C4에서 컴파일을 복구했다. 자동 검사로 확인할 수 있는 경계는 C1의 정상 동작과 C4의 버그 재현이었다.
이번에는 125를 올바르게 반환해도 Git이 다음 출력을 남겼다.
There are only 'skip'ped commits left to test.
The first bad commit could be any of:
25f3a7794ecfb6f8471410238635cdc2d4a441ca
7b16e0c13366bba783e01e07213436b81a95aed2
83328704c0fbc194117ba42dab728c7d9b03795f
We cannot bisect more!
차례로 C2, C3, C4이며 git bisect run 자체의 종료 코드는 이 실행에서 2였다. 검사 스크립트가 반환한 125와 구별해야 한다. 실험자는 변경을 C3에 넣었다는 사실을 알지만, 탐색기는 검사하지 못한 C2와 C3을 제외할 근거가 없다. C4가 처음 검사 가능한 bad라는 이유만으로 최초 원인이라고 단정할 수 없다.
공식 문서의 bisect skip 설명 (새 탭에서 열림)도 찾는 커밋 근처를 건너뛰면 정확한 한 커밋을 정하지 못할 수 있다고 밝힌다. 이때는 후보 세 개와 검사 불가 사유를 함께 남긴다. 더 좁히려면 건너뛴 버전에서 같은 기능 검사를 실행할 근거가 필요하다.
이번 확인 범위는 짧은 선형 이력, Python 구문 컴파일, 고정된 함수 반환값 검사다. 병합 이력, 버그가 생겼다가 사라지는 이력, 과거 의존성을 복구해 후보를 더 줄이는 과정은 실행하지 않았다. 모든 bisect는 reset으로 시작 전 main과 커밋에 복귀했고 작업 파일 변경이 없음을 확인했다.