git push rejected 해결: fetch first·non-fast-forward·refusing to merge unrelated histories·divergent branches

이 글의 핵심

git push가 ! [rejected]로 거부되는 것은 원격 브랜치에 내가 갖고 있지 않은 커밋이 있어서, 그대로 올리면 그 커밋이 사라지기 때문입니다. 이 글은 fetch first와 non-fast-forward의 차이, 원격 변경을 merge 또는 rebase로 합치는 방법, 새 GitHub 저장소에서 나오는 refusing to merge unrelated histories, Git 2.27 이후의 divergent branches 안내, --force 대신 --force-with-lease를 써야 하는 이유, GH006 보호 브랜치 거부를 실제 출력 기준으로 설명합니다.

push가 거부되는 진짜 이유

작업을 마치고 git push를 했는데 이런 출력이 나왔다면, 이 글이 다루는 상황입니다.

To github.com:example/app.git
 ! [rejected]        main -> main (fetch first)
error: failed to push some refs to 'github.com:example/app.git'
hint: Updates were rejected because the remote contains work that you do not
hint: have locally. This is usually caused by another repository pushing to
hint: the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

또는 괄호 안이 non-fast-forward로 바뀐 형태입니다.

To github.com:example/app.git
 ! [rejected]        main -> main (non-fast-forward)
error: failed to push some refs to 'github.com:example/app.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. If you want to integrate the remote changes,
hint: use 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

hint: 줄의 문구는 Git 버전에 따라 조금씩 다릅니다. 오래된 버전은 “Integrate the remote changes (e.g. ‘git pull …’) before pushing again.”처럼 출력합니다. 하지만 첫 줄의 ! [rejected]와 괄호 안 사유는 같습니다.

이 오류는 Git이 고장 난 것이 아니라 Git이 데이터를 지켜 준 것입니다. git push는 기본적으로 원격 브랜치를 앞으로만 움직일 수 있습니다. 원격 main이 커밋 C를 가리키고 있을 때, 내가 올리려는 커밋이 C의 후손이면 원격은 포인터를 내 커밋으로 옮기기만 하면 됩니다. 이것을 fast-forward라고 합니다. 반대로 원격에 C가 있는데 내 브랜치 이력에는 C가 없다면, 원격 포인터를 내 커밋으로 옮기는 순간 C는 어떤 브랜치에서도 닿을 수 없는 커밋이 됩니다. 다른 사람이 올린 작업이 사라지는 것이죠. Git은 이걸 막기 위해 push를 거부합니다.

flowchart LR
  A["A"] --> B["B"]
  B --> C["C (원격 main)"]
  B --> D["D (내 로컬 main)"]

위 그림처럼 A–B까지는 공통이고, 원격에는 C가, 내 로컬에는 D가 따로 붙은 상태가 가장 흔합니다. 해결은 결국 하나입니다. 원격의 C를 내 쪽으로 가져와 D와 합친 다음 push하는 것입니다. 합치는 방법(merge 또는 rebase)만 고르면 됩니다.

기준 환경은 Git 2.40 이상, 원격은 GitHub입니다. GitLab이나 Bitbucket도 거부 원리는 같고, 보호 브랜치 메시지 문구만 다릅니다.

fetch first와 non-fast-forward는 무엇이 다른가

두 메시지는 해결 방법이 같아서 구분 없이 넘어가기 쉽지만, Git이 알려 주는 정보가 다릅니다.

  • (fetch first): 원격 브랜치가 가리키는 커밋 객체가 내 저장소에 아예 없습니다. 즉 내가 마지막으로 fetch한 뒤에 누군가 push했고, 나는 그 커밋을 받아 본 적이 없습니다. Git은 그 커밋이 내 커밋의 조상인지 판단조차 할 수 없으니 “먼저 가져오라”고 말합니다.
  • (non-fast-forward): 원격 커밋을 이미 받아 두었지만 내 브랜치가 그것을 포함하지 않습니다. 예를 들어 git fetch는 했는데 merge나 rebase를 하지 않은 경우, 또는 내가 로컬에서 git reset이나 git commit --amend로 이미 push한 커밋을 바꾼 경우입니다.

두 번째 경우는 특히 주의해야 합니다. 원격에 있는 커밋이 다른 사람의 것이 아니라 내가 예전에 올린 커밋인데, amend나 rebase로 고쳐서 해시가 바뀐 상황일 수 있습니다. 이때는 pull로 합치면 고치기 전 커밋과 고친 커밋이 둘 다 이력에 남는 이상한 결과가 나옵니다. 아래 “강제 push가 필요한 경우”에서 다시 다룹니다.

지금 어떤 상태인지 확인하는 가장 확실한 방법은 먼저 fetch해서 원격 상태를 받아 온 뒤 비교하는 것입니다.

git fetch origin
git status
On branch main
Your branch and 'origin/main' have diverged,
and have 1 and 2 different commits each, respectively.
  (use "git pull" if you want to integrate the remote branch with yours)

“1 and 2 different commits”는 내 쪽에만 있는 커밋이 1개, 원격에만 있는 커밋이 2개라는 뜻입니다. 어떤 커밋인지는 다음 명령으로 봅니다.

# 원격에만 있는 커밋 (내가 아직 안 받은 것)
git log --oneline main..origin/main

# 내 쪽에만 있는 커밋 (아직 안 올린 것)
git log --oneline origin/main..main

원격에만 있는 커밋 목록에 동료의 커밋이 보이면 정상적인 동시 작업입니다. 내가 고치기 전에 올렸던 커밋이 보이면 amend·rebase로 이력을 바꾼 경우입니다. 이 구분을 먼저 해 두면 다음 단계에서 엉뚱한 선택을 하지 않습니다.

fetch와 pull의 차이부터 정리하기

git pull은 독립된 동작이 아니라 두 명령을 묶은 것입니다.

git pull  =  git fetch  +  (git merge 또는 git rebase)

git fetch는 원격의 새 커밋을 내려받아 origin/main 같은 원격 추적 브랜치만 갱신합니다. 내 main 브랜치와 작업 디렉터리는 전혀 건드리지 않으니 언제 실행해도 안전합니다. 그 다음 단계인 merge 또는 rebase가 실제로 내 브랜치를 바꿉니다.

pull이 한 번에 처리해 주니 편하지만, 원인을 모르는 상태에서 pull을 치면 merge 커밋이 생기거나 충돌이 나는 이유를 이해하기 어렵습니다. push가 거부되었을 때는 git fetch → git log로 차이 확인 → 합치기 순서로 나눠서 하는 습관이 결과적으로 시간을 아낍니다. 원격 저장소와 원격 추적 브랜치 개념이 낯설다면 Git 원격 저장소와 협업을 먼저 보는 것을 권합니다.

원격 변경을 merge로 합치기

가장 단순한 방법은 merge입니다.

git fetch origin
git merge origin/main
# 충돌이 없으면 merge 커밋 메시지 편집기가 뜹니다. 저장하고 닫습니다.
git push origin main

git pull --no-rebase도 같은 동작을 합니다. merge 방식은 기존 커밋을 하나도 바꾸지 않고, 두 갈래를 잇는 merge 커밋을 하나 새로 만듭니다. 이미 공유된 커밋의 해시가 바뀌지 않으므로 여러 사람이 같은 브랜치에 push하는 상황에서 안전합니다.

단점은 이력입니다. 작은 수정마다 pull을 하면 “Merge branch ‘main’ of github.com:example/app” 같은 커밋이 계속 쌓여 git log --graph가 지저분해집니다. 동작에는 문제가 없지만, 나중에 어떤 변경이 언제 들어왔는지 추적하기가 번거로워집니다.

충돌이 나면 Git은 다음처럼 멈춥니다.

Auto-merging src/config.js
CONFLICT (content): Merge conflict in src/config.js
Automatic merge failed; fix conflicts and then commit the result.

충돌 표시(<<<<<<<, =======, >>>>>>>)가 들어간 파일을 열어 원하는 내용으로 고친 다음, git add로 해결을 표시하고 git commit으로 merge를 완료합니다. 중간에 포기하고 싶으면 git merge --abort로 merge 전 상태로 돌아갈 수 있습니다. 충돌이 많은 경우의 정리 요령은 Git 머지 충돌 해결 실전 사례에 따로 정리되어 있습니다.

원격 변경을 rebase로 합치기

rebase는 내 커밋을 원격 커밋 뒤로 옮겨 다시 쌓습니다.

git fetch origin
git rebase origin/main
git push origin main

git pull --rebase가 같은 동작입니다. 앞의 그림에서 D를 C 뒤로 옮겨 A–B–C–D′ 형태의 일직선 이력을 만듭니다. D′는 D와 내용은 같지만 부모가 달라졌으므로 새 해시를 가진 새 커밋입니다. 결과적으로 원격 main(C)이 D′의 조상이 되니 push가 fast-forward로 통과합니다.

rebase는 merge 커밋이 생기지 않아 이력이 깔끔합니다. 대신 지켜야 할 조건이 하나 있습니다. rebase로 다시 쓰는 커밋은 아직 push하지 않은 내 커밋이어야 합니다. 위 예시에서 D는 원격에 올라간 적이 없으니 해시가 바뀌어도 아무도 영향을 받지 않습니다. 반대로 이미 다른 사람이 받아 간 커밋을 rebase하면 그 사람의 로컬 이력과 어긋나 같은 문제가 반복됩니다.

rebase 중 충돌이 나면 커밋 하나씩 멈춥니다.

Auto-merging src/config.js
CONFLICT (content): Merge conflict in src/config.js
error: could not apply 3f2a91c... Update API endpoint
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".

파일을 고치고 git add 후 git rebase --continue를 실행하면 다음 커밋으로 넘어갑니다. merge와 다르게 git commit을 직접 치지 않는다는 점이 헷갈리기 쉽습니다. 내 커밋이 여러 개면 충돌도 여러 번 날 수 있는데, 이때 git rebase --abort로 전부 취소하고 merge로 바꾸는 것도 충분히 합리적인 선택입니다. 커밋을 정리하면서 rebase하는 방법은 Git rebase interactive 사용법에서 다룹니다.

merge와 rebase 중 무엇을 고를지는 팀 규칙을 따르는 것이 우선입니다. 규칙이 없다면 다음 기준이 무난합니다.

상황권장
혼자 쓰는 브랜치, 아직 push 안 한 커밋 몇 개rebase
여러 사람이 동시에 push하는 공유 브랜치merge (또는 rebase하되 내 미푸시 커밋만)
충돌이 많고 커밋 수도 많음merge (충돌을 한 번에 해결)
이미 push한 커밋을 고쳐야 함아래 force-with-lease 절 참고

hint: You have divergent branches 메시지

Git 2.27부터는 git pull을 옵션 없이 실행했을 때 merge할지 rebase할지 정해 두지 않았으면 경고를 냈고, 이후 버전(2.33 무렵부터)에서는 두 브랜치가 실제로 갈라진 상태면 아예 멈추도록 바뀌었습니다. 지금 버전에서 보는 메시지는 이렇습니다.

hint: You have divergent branches and need to specify how to reconcile them.
hint: You can do so by running one of the following commands sometime before
hint: your next pull:
hint:
hint:   git config pull.rebase false  # merge
hint:   git config pull.rebase true   # rebase
hint:   git config pull.ff only       # fast-forward only
hint:
hint: You can replace "git config" with "git config --global" to set a default
hint: preference for all repositories. You can also pass --rebase, --no-rebase,
hint: or --ff-only on the command line to override the configured default per
hint: invocation.
fatal: Need to specify how to reconcile divergent branches.

예전 Git은 묻지 않고 merge를 했습니다. 그 결과 rebase를 원하는 사람도 모르는 사이 merge 커밋을 만들어 왔고, Git 개발진이 “어느 쪽인지 명시하라”로 기본 동작을 바꾼 것입니다. 이 메시지가 나왔다는 것은 아무것도 변경되지 않았다는 뜻이니 안심해도 됩니다.

세 가지 선택지의 의미는 다음과 같습니다.

  • pull.rebase false: 예전처럼 merge합니다. 갈라졌으면 merge 커밋이 생깁니다.
  • pull.rebase true: 항상 rebase합니다. 내 미푸시 커밋을 원격 뒤로 옮깁니다.
  • pull.ff only: fast-forward로 끝날 때만 pull을 허용하고, 갈라졌으면 거부합니다. 내가 직접 merge나 rebase를 고르고 싶을 때 쓰는 보수적인 설정입니다.

전역 기본값으로 하나를 정해 두면 이후 메시지가 나오지 않습니다.

# 개인 작업이 많다면 rebase 기본 + 자동 stash
git config --global pull.rebase true
git config --global rebase.autoStash true

rebase.autoStash를 켜지 않으면, 커밋하지 않은 수정이 있을 때 rebase가 시작되지 않고 “cannot pull with rebase: You have unstaged changes.” 같은 메시지로 멈춥니다. autoStash는 그 수정을 잠시 stash했다가 rebase가 끝난 뒤 다시 적용해 줍니다.

fatal: refusing to merge unrelated histories

GitHub에서 새 저장소를 만들 때 “Add a README file”이나 .gitignore, 라이선스를 체크하면, 원격 저장소에 첫 커밋이 하나 생깁니다. 그런데 로컬에서는 이미 git init으로 별도의 첫 커밋을 만들어 두었다면, 두 저장소는 공통 조상이 전혀 없는 이력 두 개가 됩니다. 이 상태에서 push하면 먼저 (fetch first)로 거부되고, 시키는 대로 pull하면 다음 오류가 나옵니다.

fatal: refusing to merge unrelated histories

Git 2.9부터 서로 관련 없는 이력은 기본적으로 merge하지 않습니다. 실수로 엉뚱한 저장소를 remote로 연결했을 때 두 프로젝트가 섞여 버리는 사고를 막기 위해서입니다. 지금 상황처럼 의도한 것이 맞다면 옵션으로 허용합니다.

git pull origin main --allow-unrelated-histories
# README.md 같은 파일이 양쪽에 다 있으면 충돌이 날 수 있습니다. 해결 후 commit.
git push -u origin main

이 옵션은 그 merge 한 번에만 적용됩니다. 전역 설정으로 켜 둘 수 있는 옵션이 아니고, 켜 둘 이유도 없습니다.

다른 선택지도 있습니다. 원격의 README 커밋이 별로 중요하지 않다면, 원격 이력을 버리고 로컬 이력으로 덮어써도 됩니다. 저장소를 막 만들어서 아무도 clone하지 않은 상태라면 이것도 안전합니다.

git push --force-with-lease origin main

다만 이미 누군가 clone했거나 다른 커밋이 올라갔을 가능성이 있다면 덮어쓰지 말고 --allow-unrelated-histories로 합치는 쪽을 택하세요. 다음부터는 로컬에 이미 코드가 있는 프로젝트를 올릴 때 GitHub에서 README를 추가하지 않고 빈 저장소로 만들면 이 문제가 처음부터 생기지 않습니다.

반대로 이 오류가 예상하지 못한 상황에서 나왔다면 옵션부터 붙이지 말고 git remote -v로 remote 주소를 확인하세요. 비슷한 이름의 다른 저장소를 연결해 둔 경우가 의외로 많고, 그 상태에서 허용 옵션을 붙이면 전혀 다른 프로젝트 파일이 섞입니다.

강제 push가 필요한 경우와 —force가 위험한 이유

지금까지는 원격 커밋을 살리는 방법이었습니다. 그런데 원격 커밋을 버리는 것이 맞는 경우도 있습니다.

  • 기능 브랜치에 push한 커밋을 git commit --amend로 고쳤다.
  • PR 브랜치를 최신 main 위로 rebase했다.
  • 실수로 비밀 키가 들어간 커밋을 올려서 이력에서 지웠다.

이때는 원격의 옛 커밋이 내 로컬 이력에 없으니 non-fast-forward로 거부되고, pull로 합치면 옛 커밋이 되살아납니다. 원격을 내 이력으로 덮어써야 합니다.

git push --force는 원격 상태를 확인하지 않고 무조건 덮어씁니다. 내가 rebase하는 사이 동료가 같은 브랜치에 커밋을 하나 push했다면, 그 커밋은 경고 없이 사라집니다. 동료는 다음에 pull하다가 이상한 충돌을 겪거나, 최악의 경우 자기 작업이 사라졌다는 사실을 한참 뒤에 알게 됩니다. 공유 브랜치에서 --force를 쓰는 것은 다른 사람 커밋을 지울 수 있는 유일한 일상 명령입니다.

--force-with-lease는 조건을 붙인 강제 push입니다.

git push --force-with-lease origin feature/login

이 명령은 “원격 feature/login이 내가 마지막으로 본 값(로컬의 origin/feature/login)과 같을 때만 덮어쓴다”는 뜻입니다. 그 사이 누가 push했다면 원격 값이 달라졌으니 다음처럼 거부됩니다.

To github.com:example/app.git
 ! [rejected]        feature/login -> feature/login (stale info)
error: failed to push some refs to 'github.com:example/app.git'

stale info가 나오면 git fetch 후 새로 들어온 커밋을 확인하고, 그 커밋을 내 이력에 포함한 다음 다시 시도합니다.

허점이 하나 있습니다. IDE나 Git GUI가 백그라운드에서 자동으로 fetch를 하면, 내가 눈으로 확인하지 않았어도 origin/feature/login이 이미 최신 값으로 갱신되어 있습니다. 그러면 lease 조건이 통과해 동료 커밋이 그대로 덮어써집니다. Git 2.30 이상에서는 --force-if-includes를 함께 주면 이 경우까지 막을 수 있습니다. 원격의 커밋이 내 로컬 브랜치의 reflog에 실제로 들어온 적이 있을 때만 push를 허용하는 옵션입니다.

git push --force-with-lease --force-if-includes origin feature/login

매번 치기 길다면 alias로 만들어 두고, 맨 --force는 아예 쓰지 않는 습관을 들이는 것이 좋습니다.

git config --global alias.pushf "push --force-with-lease --force-if-includes"

GH006: Protected branch update failed

GitHub 저장소에서 main 같은 브랜치에 보호 규칙(branch protection)이 걸려 있으면, 위 방법으로 이력을 정리해도 push가 원격 쪽에서 거부됩니다. 이때는 [rejected]가 아니라 [remote rejected]로 나옵니다. 내 로컬 Git이 아니라 GitHub 서버가 거부했다는 뜻입니다.

remote: error: GH006: Protected branch update failed for refs/heads/main.
remote: error: Changes must be made through a pull request.
To github.com:example/app.git
 ! [remote rejected] main -> main (protected branch hook declined)
error: failed to push some refs to 'github.com:example/app.git'

“Changes must be made through a pull request”는 해당 브랜치에 직접 push할 수 없고 PR을 통해서만 반영하도록 설정되었다는 뜻입니다. 강제 push를 시도했다면 “Cannot force-push to this branch” 같은 문구가 붙고, 필수 상태 검사(required status checks)가 걸려 있으면 그 검사 이름이 나열됩니다. 정확한 문구는 어떤 규칙에 걸렸는지에 따라 달라집니다. 저장소에 새 방식인 Rulesets가 설정되어 있으면 GH013: Repository rule violations found for refs/heads/main.처럼 다른 코드로 나오기도 합니다.

이 거부는 pull이나 rebase로 해결되는 문제가 아닙니다. 순서는 이렇습니다.

# 1. main에 쌓은 커밋을 새 브랜치로 옮깁니다
git switch -c fix/update-config

# 2. 새 브랜치를 push합니다
git push -u origin fix/update-config

# 3. 로컬 main을 원격 상태로 되돌립니다 (커밋은 새 브랜치에 남아 있음)
git switch main
git reset --hard origin/main

그다음 GitHub에서 fix/update-config → main PR을 만들어 리뷰와 검사를 통과시키면 됩니다. 3번의 reset --hard는 커밋하지 않은 수정을 지우므로, 실행 전 git status가 깨끗한지 확인하세요. 커밋은 이미 새 브랜치에 있으니 사라지지 않습니다.

보호 규칙 자체를 바꾸려면 저장소 Settings의 Branches(또는 Rules) 메뉴에서 관리자 권한으로 수정해야 합니다. 급하다고 보호를 잠시 풀고 강제 push하는 것은 그 보호 규칙을 둔 이유를 무력화하므로, 팀 저장소라면 관리자와 먼저 상의하는 것이 맞습니다.

거부와 헷갈리기 쉬운 다른 push 오류

출력 첫 줄만 보고 같은 문제로 착각하기 쉬운 오류들이 있습니다. 원인이 전혀 다르니 구분해 두면 좋습니다.

[email protected]: Permission denied (publickey).
fatal: Could not read from remote repository.

인증 문제입니다. 이력과는 관계없고 SSH 키 설정을 확인해야 합니다. 깃허브 SSH 키 등록과 Permission denied 해결을 참고하세요.

remote: Permission to example/app.git denied to some-user.
fatal: unable to access 'https://github.com/example/app.git/': The requested URL returned error: 403

HTTPS로 push할 때 저장소 쓰기 권한이 없거나, 저장된 자격 증명이 다른 계정 것이거나, 토큰 권한 범위가 부족한 경우입니다.

error: src refspec main does not match any
error: failed to push some refs to 'github.com:example/app.git'

로컬에 main 브랜치가 없을 때 나옵니다. 아직 커밋을 하나도 하지 않았거나, 로컬 기본 브랜치 이름이 master인 경우입니다. git branch로 실제 이름을 확인하고 그 이름으로 push하거나 git branch -m master main으로 바꿉니다.

같은 거부를 덜 보는 작업 습관

거부 자체는 정상 동작이라 완전히 없앨 수는 없지만, 해결이 번거로워지는 상황은 줄일 수 있습니다.

작업을 시작하기 전에 git pull로 최신 상태를 받아 두면, 갈라지는 지점이 늦어져 충돌 범위가 작아집니다. 하루 이상 걸리는 작업은 main에서 바로 하지 말고 기능 브랜치를 만들어 하면, main으로의 push 경쟁 자체가 사라지고 합치는 시점을 PR에서 정할 수 있습니다. 그리고 pull.rebase나 pull.ff를 전역 설정으로 정해 두면 pull할 때마다 무엇이 일어날지 예측할 수 있습니다.

마지막으로, 합치는 과정에서 무언가 잘못되었다고 느껴지면 git reflog를 기억해 두세요. merge나 rebase 직전에 브랜치가 가리키던 커밋이 HEAD@{n} 형태로 남아 있어서, git reset --hard HEAD@{n}으로 그 시점으로 되돌릴 수 있습니다. reflog는 로컬에만 있고 기본적으로 90일간 유지되므로, 이미 push한 뒤가 아니라면 대부분의 실수는 복구할 수 있습니다. reset·revert로 되돌리는 방법을 더 자세히 알고 싶다면 Git 되돌리기: reset·revert·restore 선택 기준이 이어서 읽기 좋습니다.