본문 바로가기
빌드노트 검색

빌드노트

force-with-lease도 fetch 뒤에는 동료 커밋을 덮어쓸 수 있다

인자 없는 force-with-lease는 fetch로 갱신된 원격 추적 참조를 기준으로 삼는다. 로컬 bare 원격과 세 clone에서 동료 커밋이 빠지는 조건을 확인하고, 내용을 검토한 전체 커밋 ID를 expected 값으로 고정하는 방법을 설명한다.

git push --force-with-lease를 썼는데도 동료 커밋이 원격 브랜치 이력에서 빠질 수 있다. expected 값을 생략하면 Git은 로컬의 원격 추적 참조가 가리키는 커밋을 기준으로 삼는다. 편집기가 백그라운드에서 fetch해 그 참조를 갱신하면, 내가 내용을 확인하지 않은 동료 커밋까지 대체하도록 허용할 수 있다.

기준을 유지하려면 원격 상태를 가져온 뒤 전체 커밋 ID를 저장하고, 그 ID의 내용을 검토한 다음 명시적 lease에 사용한다. 이력 재작성이 필요한지, 어떤 변경을 남겨야 하는지는 사람이 먼저 판단해야 한다.

fetch한 뒤 ID를 저장하고 그 내용을 읽는다

기본 clone 설정에서 origin/main은 원격 main의 위치를 로컬에 기록한 참조다. git fetch origin이 이를 갱신하는 매핑은 git-fetch 공식 문서 (새 탭에서 열림)에서 확인할 수 있다.

한 브랜치만 보낼 때 인자 없는 lease는 원격 브랜치의 현재 ID와 이 추적 참조의 ID를 비교한다. --force-with-lease=refs/heads/main처럼 브랜치 이름만 적어도 기대값은 추적 참조에서 가져온다. --force-with-lease=refs/heads/main:<expected-ID>처럼 값까지 지정해야 비교 기준을 직접 정할 수 있다. git-push 공식 문서 (새 탭에서 열림)도 expected 생략형과 백그라운드 fetch의 조합을 주의할 조건으로 설명한다.

다음은 origin의 main을 재작성하기로 합의한 경우의 PowerShell 7 예다. 해당 저장소 안에서 실행하며, 원격 이름·대상 브랜치와 기본 fetch 매핑을 먼저 확인한다. 일반적인 변경 추가라면 강제 갱신 없이 push하면 된다.

재작성 전에 이 블록으로 fetch하고 전체 ID를 $expected에 저장한 뒤, 저장한 바로 그 ID의 이력과 변경을 읽는다. 어느 조회든 실패하면 여기서 중단한다.

git fetch origin
if ($LASTEXITCODE -ne 0) { throw 'fetch 실패: 여기서 중단합니다.' }
$expected = git rev-parse --verify 'refs/remotes/origin/main^{commit}'
if ($LASTEXITCODE -ne 0) { throw '기준 커밋을 확인하지 못했습니다.' }
$expected = $expected.Trim()
git log --oneline $expected
if ($LASTEXITCODE -ne 0) { throw '기준 이력을 읽지 못했습니다.' }
git show $expected
if ($LASTEXITCODE -ne 0) { throw '기준 커밋의 변경을 읽지 못했습니다.' }

rev-parse --verify와 ^{commit}은 참조를 커밋으로 확인하고 전체 ID를 얻는 데 쓰인다. log는 그 ID까지의 커밋 목록을, show는 지정한 커밋의 변경을 보여 준다. 앞선 커밋의 변경도 판단해야 한다면 목록에서 해당 ID를 골라 따로 읽는다. 실습에서는 공개한 커밋 P의 ID가 저장됐고, show에 note.txt의 -base, +my-v1 변경이 나왔다. rev-parse의 객체 검증 (새 탭에서 열림), show의 커밋 출력 (새 탭에서 열림)

push할 때 저장한 기준을 유지한다

필요한 변경을 보존하며 이력을 재작성한 뒤 보낼 결과를 검토한다. 같은 PowerShell 창에 앞의 $expected를 유지하고, HEAD가 검토한 재작성 결과를 가리킬 때 다음 명령으로 한 브랜치만 보낸다. HEAD:refs/heads/main은 현재 커밋을 원격 main에 보내겠다는 뜻이다.

if ([string]::IsNullOrWhiteSpace($expected)) { throw '확인한 커밋 ID가 필요합니다.' }
git push "--force-with-lease=refs/heads/main:$expected" origin HEAD:refs/heads/main

원격 브랜치의 현재 ID가 저장한 값과 다르면 lease는 거절된다. 같으면 lease 조건은 통과하지만, 서버의 다른 정책까지 통과한다는 뜻은 아니다. 빈 값 검사도 필요하다. 명시적 lease에서 콜론 뒤를 비우면 “그 브랜치가 아직 없어야 한다”는 별도 조건이 된다. git-push의 expected 조건 (새 탭에서 열림)

stale info로 거절되면 새 원격 커밋과 보낼 재작성 결과를 다시 비교한다. 오류를 통과하려고 최신 origin/main으로 $expected만 바꾸면 확인하지 않은 이력의 대체를 허용하게 된다. 원래 확인한 ID를 잃었다면 현재 추적 참조를 그때의 기준으로 간주하지 말고, 무엇을 남길지부터 다시 검토한다.

expected 자리에 origin/main이라는 이름을 직접 넣는 것도 고정과 다르다. 그 이름은 명령 실행 때 해석되므로 나중의 fetch에 영향을 받는다. 저장할 대상은 내용을 확인할 전체 객체 ID다.

새 로컬 원격에서 비교한 결과

Windows 11, Git 2.45.1.windows.1에서 새 실습 폴더 안에 원격 역할의 bare 저장소 remote.git/과 전용 복제본 alice/·bob/·carol/을 만들었다. 세 복제본의 origin은 모두 그 로컬 저장소의 file:/// URL이었다. 아래는 합성 커밋으로 실행한 결과다.

Bob이 공통 커밋 A와 공개 변경 P를 보낸 뒤 Alice와 Carol이 P에서 복제했다. 두 복제본에서 위 첫 블록으로 P의 ID와 내용을 확인한 다음 P를 amend해 같은 재작성 커밋 R을 만들었다. 이어 Bob이 P 위에 colleague.txt를 추가한 커밋 C를 보냈다. 원격 이력은 A → P → C였고, 보낼 이력은 A → R이었다. R에는 동료 파일이 없었다.

이 상태에서 fetch 전후의 push를 비교했다. 모든 거절 시도를 먼저 실행하고 각 시도 직후 원격 tip도 직접 조회했다.

  • fetch 전에는 인자 없는 lease와 expected=P인 명시적 lease가 모두 종료 코드 1, stale info로 거절됐다. 원격 tip은 C로 유지됐다.
  • fetch 후에는 origin/main이 C로 바뀌었다. 일반 push는 non-fast-forward, 저장한 P를 쓰는 명시적 lease는 stale info로 거절됐고 둘 다 원격 C를 유지했다. 위 두 번째 PowerShell 블록도 이 조건에서 실제 실행했다.
  • 같은 시점에 인자 없는 lease는 종료 코드 0, forced update로 성공했다. 원격 tip이 C에서 R로 바뀌었고, C는 더 이상 원격 main의 조상이 아니었다. tip의 파일 목록에서도 colleague.txt가 빠졌다.

두 복제본에서 fetch만 한 뒤 HEAD와 작업 파일은 R 그대로였다. 달라진 추적 참조가 인자 없는 lease의 허용 기준을 바꾼 것이다. Bob의 복제본에는 C가 남았으므로, 모든 커밋 사본이 삭제됐다고 해석해서는 안 된다.

성공 조건도 따로 비교했다. 원격 tip을 P로 유지한 실습 브랜치 lease-control에 expected=P를 지정해 R을 보내자 종료 코드 0으로 성공했다. 명시적 lease는 예상한 tip과의 일치를 검사한다. 새 이력이 기존 변경을 보존하는지까지 검사하지는 않는다.

실제 IDE의 자동 fetch를 실행한 것은 아니며, 경쟁 push 뒤 수동 fetch로 같은 참조 갱신 조건을 만들었다. 직접 확인한 범위는 위 Git 버전의 기본 clone 매핑과 로컬 file 전송이다. 네트워크 서버의 인증·브랜치 보호·수신 훅, 다른 Git 버전과 커밋 복구는 시험하지 않았다.

← 전체 글 보기