빌드노트
git merge --ff-only가 거절한 뒤 커밋과 파일 확인하기
분기된 이력에서 --ff-only가 거절된 뒤 HEAD·인덱스·파일 해시가 보존되는지 비교했다. ORIG_HEAD의 변화와 양방향 조상 조회를 확인하고, 별도 복사본의 병합 커밋과 대조해 다음 통합 방식을 판단한다.
git merge --ff-only topic이 Not possible to fast-forward, aborting.으로 끝났다면, 먼저 현재 브랜치와 topic의 이력이 갈라졌는지 본다. 양쪽에 독립적인 커밋이 있으면 현재 브랜치의 끝을 topic으로 옮기는 것만으로 두 이력을 합칠 수 없다. 서로 다른 파일을 수정해 충돌할 내용이 없어도 거절된다. Git의 fast-forward와 --ff-only 설명 (새 탭에서 열림)
별도 합성 저장소에서 이 거절을 재현했을 때 로컬 커밋, 인덱스, 작업 파일은 그대로였다. 하지만 ORIG_HEAD라는 부가 기록은 생겼다. 따라서 “파일이 보존됐다”와 “Git 내부 기록까지 전혀 바뀌지 않았다”는 구분해야 한다. 오류를 없애려고 reset부터 실행할 이유는 없었다.
거절 직후 현재 상태를 읽는다
다음은 Git과 PowerShell이 있는 환경에서, 병합을 시도했던 저장소 안에서 실행하는 조회다. 현재 브랜치와 커밋을 확인하고, 미커밋 변경 및 진행 중인 병합 기록을 살핀다.
git branch --show-current
git status --short
git rev-parse --short HEAD
git rev-parse --verify --quiet MERGE_HEAD
$LASTEXITCODE
실험의 분기 저장소에서는 브랜치가 main, HEAD가 ed2c537이었고 status --short는 아무것도 출력하지 않았다. MERGE_HEAD 조회도 출력이 없었으며 바로 다음 줄의 종료 코드는 1이었다. 진행 중인 병합의 대상 커밋 기록이 없고, 미커밋 변경도 없는 상태였다. --verify --quiet는 이름을 해석하지 못하면 오류 문구 없이 비정상 종료한다. git-rev-parse 옵션 (새 탭에서 열림)
자기 저장소에서 변경 목록이 나온다면 그것이 실패 전부터 있던 작업인지 먼저 확인한다. git diff로 작업 파일과 인덱스를, git diff --cached로 인덱스와 HEAD를 비교할 수 있다. git-diff의 비교 대상 (새 탭에서 열림)을 확인해 필요한 쪽을 읽는다. MERGE_HEAD의 커밋 ID가 출력되면 이번 실험처럼 병합이 시작되지 않은 상태와 다르다. 단순히 같은 오류라고 보고 다음 병합을 덧붙이지 않는다.
실패 전 HEAD나 파일 해시를 기록하지 않았다면, 현재 조회만으로 과거와 한 바이트도 같다고 소급 증명할 수는 없다. 이번 비교는 실행 직전에 HEAD, 인덱스의 파일 목록·blob ID, 작업 파일의 SHA-256을 저장한 뒤 같은 값을 다시 읽었다.
파일이 겹치지 않아도 이력은 갈라질 수 있다
새 저장소의 공통 커밋에는 common.txt에 base 한 줄을 넣었다. topic은 topic.txt를 추가했고 main은 local.txt를 추가했다. 각각 topic, local 한 줄을 담아 별도로 커밋했다. main에 돌아왔을 때 작업 폴더는 깨끗했다.
이 상태에서 다음 명령으로 두 브랜치 끝의 연결을 읽었다. 자기 저장소에서는 topic을 실제로 합치려던 브랜치 이름으로 바꾼다. 이 조회는 브랜치를 전환하지 않는다.
git log --graph --oneline --decorate HEAD topic
실제 출력은 다음과 같았다. 커밋 ID는 이 실험의 값이며, 두 줄기가 공통 커밋에서 갈라지는 모양을 읽으면 된다.
* ed2c537 (HEAD -> main) local-change
| * 475ba2d (topic) topic-change
|/
* 793f274 base
main을 475ba2d로 옮기면 main에만 있는 ed2c537을 그 이력에 포함할 수 없다. --ff-only는 이런 이동 대신 비정상 종료한다. 위 상태에서 git merge --ff-only topic은 종료 코드 128을 반환했다. 병합·rebase를 제안하는 안내문 뒤의 마지막 오류 줄은 다음과 같았다.
fatal: Not possible to fast-forward, aborting.
실행 전후 HEAD는 모두 ed2c537이었고 topic의 끝도 475ba2d에 남았다. 인덱스 항목과 인덱스가 나타내는 파일 트리가 같았으며, 두 브랜치에서 도달 가능한 커밋 수는 3개 그대로였다. common.txt의 SHA-256 앞 12자리는 전후 모두 f34848ca9266, local.txt는 모두 efb83f2a277e였다. main의 작업 폴더에는 topic.txt가 생기지 않았다. 충돌을 나타내는 인덱스 항목도 없었다.
PowerShell에서 특정 파일의 바이트를 비교하려면 Get-FileHash -LiteralPath './local.txt' -Algorithm SHA256처럼 같은 파일을 전후에 읽는다. 여기서 local.txt는 합성 파일 이름이므로 자기 저장소에서는 확인할 파일의 실제 경로를 쓴다. 이 검사는 해당 파일의 내용 비교이고, 인덱스나 전체 저장소를 대신 검사하지는 않는다.
한 가지 변화는 별도로 남았다. 거절 전에는 없던 ORIG_HEAD가 거절 뒤 ed2c537을 가리켰다. git rev-parse --verify --quiet ORIG_HEAD로 읽은 결과다. 공식 문서도 merge 등의 명령이 이전 HEAD 위치를 이 이름에 기록한다고 설명한다. 이번 거절에서는 HEAD가 이동하지 않았어도 ORIG_HEAD가 기록됐다. 이 값의 존재만으로 병합 성공이나 진행 중인 병합을 판단하면 안 된다. HEAD·ORIG_HEAD·MERGE_HEAD의 의미 (새 탭에서 열림)
포인터를 옮길 수 있는지는 조상 관계로 확인한다
그래프가 길다면 현재 HEAD가 대상 커밋의 조상인지 직접 확인한다. 각 Git 명령 바로 다음 줄에서 종료 코드를 읽어야 한다.
git merge-base --is-ancestor HEAD topic
$LASTEXITCODE
git merge-base --is-ancestor topic HEAD
$LASTEXITCODE
--is-ancestor의 0은 첫 커밋이 두 번째의 조상이라는 뜻이고, 1은 아니라는 뜻이다. 그 밖의 비정상 종료는 조회 오류이므로 분기 판정으로 쓰지 않는다. 위 분기에서는 두 방향 모두 1이었다. 공통 커밋은 있지만 어느 끝도 다른 끝의 조상이 아니었다. git-merge-base의 종료 코드 (새 탭에서 열림)
비교용 일직선 저장소에서는 main을 공통 커밋 793f274에 둔 채 topic에서만 common.txt를 바꿨다. 첫 번째 조상 조회는 0이었다. 여기서 같은 git merge --ff-only topic을 실행하자 종료 코드 0과 Fast-forward가 나왔고 HEAD는 topic의 509a44e로 이동했다. 파일 내용도 base에서 topic으로 바뀌었으며 SHA-256은 f34848ca9266…에서 d5d6462af0be…로 달라졌다. 도달 가능한 커밋 수는 2개 그대로였다. 새 병합 커밋 없이 기존 커밋으로 이동해도 파일은 갱신된다.
반대 방향도 구분해야 한다. topic을 공통 커밋에 두고 main에서만 커밋한 세 번째 저장소에서는 첫 조회가 1, 두 번째 조회가 0이었다. 대상 이력이 이미 현재 HEAD에 포함된 경우다. --ff-only는 Already up to date.와 종료 코드 0을 반환했고 HEAD와 파일은 그대로였다. 첫 조회가 1이라는 이유만으로 항상 분기 오류라고 결론 내릴 수는 없다. 이미 포함된 커밋의 처리 (새 탭에서 열림)
두 방향 모두 1인데 공통 조상도 없다면 위의 단순 분기와 조건이 다르다. 브랜치 이름이 맞는지, 이력을 일부만 받은 저장소인지부터 확인한다. 조상 조회가 성공했다는 사실도 미커밋 변경 등 다른 병합 조건까지 보장하지는 않는다.
다음 시도는 남길 이력과 팀 정책으로 정한다
분기된 저장소를 별도 폴더에 복사한 뒤, 그 복사본에서만 git merge --no-ff -m integrate-topic topic을 실행했다. 종료 코드 0으로 병합됐고 새 커밋 f83ae46의 부모는 기존 main의 ed2c537과 topic의 475ba2d였다. 작업 폴더에는 common.txt, local.txt, topic.txt가 함께 있었으며 원래 분기 저장소는 그대로였다. 이 대조에서 두 파일 변경은 함께 합칠 수 있었다. 앞선 거절은 파일 충돌 해결 실패가 아니라 fast-forward 조건을 충족하지 못한 결과였다.
기존 커밋을 그대로 남기는 병합 커밋을 허용하는 팀이라면 이런 통합 방식이 선택지가 된다. 일직선 이력을 요구한다면 어느 브랜치의 커밋을 다른 기준 위에 다시 적용할지 정해야 한다. rebase는 커밋을 다시 적용하는 작업이므로 이미 다른 사람이 기반으로 사용한 이력을 바꾸는지 함께 확인한다. 오류 안내에 rebase가 나온다는 이유만으로 바로 실행할 명령은 아니다. git-rebase의 동작과 이력 재작성 주의 (새 탭에서 열림)
직접 실행한 환경은 Windows 11, Git 2.45.1.windows.1, PowerShell 7.6.6이다. 깨끗한 작업 폴더와 작은 일반 텍스트 파일, 공통 조상이 있는 로컬 브랜치만 비교했다. 미커밋 변경, 자동 stash, 사용자 훅, shallow 저장소, 서브모듈은 시험하지 않았다. 실제 파일 충돌 해결과 rebase도 실행하지 않았다. 여기서 확인한 것은 거절 뒤의 보존 범위와, 이력 모양에 따라 다음 통합 방식을 골라야 한다는 점이다.