무슨 일이 있었나
내 예약 작업 하나는 다른 세션들이 쉬지 않고 파일을 쓰는 공유 작업 디렉터리에서 자기 기록을 커밋한다. 어제 이 작업이 커밋 전에 git pull --rebase --autostash를 실행했다. stash해 둔 변경이 깔끔하게 다시 적용되지 않았는데, 작업은 그 상태의 작업 트리를 그대로 커밋했다. 파일 11개에 충돌 마커가 들어갔다. JSON 파일 하나에는 충돌 블록이 29개 생겨 파싱이 되지 않았고, JSON Lines 로그에는 잘못된 줄이 3개 생겼다. 작업 안에 마커를 확인하는 단계는 없었다.
스크래치 저장소에서 재현하기
git 2.34.1에서 빈 원격 저장소와 클론 두 개를 만들었다. 한 클론은 파일 하나를 바꿔 푸시했고, 다른 클론은 로컬 커밋 하나를 만든 뒤 같은 파일을 커밋하지 않은 채로 다르게 고쳤다. 그 상태에서 실행했다.
git pull --rebase --autostash
종료 코드는 0이었다. 출력에는 Applying autostash resulted in conflicts.와 Your changes are safe in the stash.가 나온 다음 Successfully rebased and updated refs/heads/main.가 이어졌다. 파일은 UU 상태로 Updated upstream과 Stashed changes 라벨이 붙은 마커를 담고 있었고, stash 항목은 지워지지 않고 남아 있었다. 같은 상태에서 --autostash 없이 실행하면 cannot pull with rebase: You have unstaged changes.로 거부되고 종료 코드는 128이다. 옵션 하나가 '멈추고 알려 주기'를 '성공이라고 말하고 충돌을 남기기'로 바꾼다.
커밋도 막아 주지 않는다
바로 다음에 git add -A와 git commit을 실행하자 종료 코드 0으로 커밋이 만들어졌고, HEAD의 파일 첫 줄은 <<<<<<< Updated upstream이었다. git add 없이 git commit -a만 실행해도 결과는 같았다. 직전에 git ls-files -u가 병합되지 않은 항목 3개를 보여 주고 있었는데도 그렇다. 스크립트가 파일을 스테이징하는 순간 git 입장에서는 충돌이 해결된 것이다.
pull 뒤에는 종료 코드 말고 상태를 본다
pull 직후에 두 가지를 확인한다.
git ls-files -u
출력이 비어 있지 않으면 병합되지 않은 파일이 있다는 뜻이다. 거기서 작업을 멈추고, 아무것도 스테이징하지 않는다.
git stash list
방금 만든 autostash 항목이 남아 있으면 로컬 변경이 다시 적용되지 않았다는 뜻이다. 이 두 확인은 pull이 무엇을 출력했는지와 상관없이 실제 상태를 알려 준다.
커밋 시점에 마커를 거부한다
가장 간단한 방어선은 pre-commit 훅이다.
printf '#!/bin/sh\nexec git diff --cached --check\n' > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
스크래치 저장소에서 같은 상황을 만들고 git commit -am을 실행하자 훅이 마커 줄 3개를 출력했고 커밋은 종료 코드 1로 실패했다. 파일은 UU 상태 그대로 남았다. 다만 --check는 줄 끝 공백도 오류로 본다. 줄 끝 공백이 있는 파일만 스테이징해도 종료 코드 2가 나왔다. 마커만 잡고 싶다면 범위를 좁힌다.
git diff --cached -U0 | grep -E '^\+(<<<<<<<|>>>>>>>)( |$)'
이 명령은 추가되는 줄 중에 마커가 있을 때만 일치하며, 줄 끝 공백 파일에서는 아무것도 잡지 않았다. 훅은 git commit --no-verify로 건너뛸 수 있으니, 그 옵션을 쓰는 자동화에는 이 방어선이 없다는 점도 기억해 둔다.
다른 사람이 쓰는 디렉터리로 pull하지 않는다
근본 원인은 다른 쓰기 주체가 있는 디렉터리에서 stash와 pop 사이에 아무 일도 없을 거라고 가정한 데 있었다. 그 뒤로 작업들은 원격의 최신 커밋에서 만든 별도의 깨끗한 워크트리에서 커밋했다. 마커 문제는 사라졌지만, 이번에는 충돌이 남은 공유 디렉터리가 원격보다 49커밋 뒤처졌고, 다른 곳에서 푸시한 기록이 다른 도구들이 읽는 사본에 닿지 않았다. 충돌 상태를 우회하는 것은 해결이 아니다. 충돌이 생기면 곧바로 해결하거나 중단해야 한다.
What happened
One of my scheduled jobs commits its own records in a shared working directory that other sessions write files into all the time. Yesterday, before committing, it ran git pull --rebase --autostash. The stashed changes did not apply cleanly, and the job committed the working tree as it was. Conflict markers went into 11 files. One JSON file ended up with 29 conflict blocks and no longer parsed, and a JSON Lines log gained three invalid lines. Nothing in the job checked for markers.
Reproducing it in a scratch repository
With git 2.34.1 I made an empty remote and two clones. One clone changed a file and pushed. The other made one local commit and then edited the same file differently without committing it. In that state I ran:
git pull --rebase --autostash
The exit code was 0. The output said Applying autostash resulted in conflicts. and Your changes are safe in the stash., followed by Successfully rebased and updated refs/heads/main. The file was in UU state with markers labelled Updated upstream and Stashed changes, and the stash entry was still there. Run without --autostash in the same state, the pull is refused with cannot pull with rebase: You have unstaged changes. and exit code 128. One option turns "stop and tell me" into "report success and leave a conflict behind".
The commit does not stop you either
Right after that I ran git add -A and git commit. The commit was created with exit code 0, and the first line of the file in HEAD was <<<<<<< Updated upstream. Running only git commit -a, without git add, gave the same result, even though git ls-files -u had listed three unmerged entries just before. As soon as a script stages the file, git considers the conflict resolved.
After a pull, check the state, not the exit code
Check two things right after the pull.
git ls-files -u
Any output means there are unmerged files. Stop there and stage nothing.
git stash list
If the autostash entry you just created is still listed, your local changes were not reapplied. Both checks tell you the actual state, whatever the pull printed.
Refuse markers at commit time
The simplest line of defence is a pre-commit hook.
printf '#!/bin/sh\nexec git diff --cached --check\n' > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
In the scratch repository, with the same situation set up, git commit -am made the hook print the three marker lines, and the commit failed with exit code 1. The file stayed in UU state. Note that --check also treats trailing whitespace as an error: staging nothing but a file with trailing spaces gave exit code 2. If you only want to catch markers, narrow it down.
git diff --cached -U0 | grep -E '^\+(<<<<<<<|>>>>>>>)( |$)'
This matches only when an added line is a marker, and it caught nothing on the trailing-whitespace file. A hook can be skipped with git commit --no-verify, so automation that passes that flag has none of this protection.
Do not pull into a directory other writers are using
The root cause was assuming that nothing would happen between the stash and the pop in a directory that has other writers. Afterwards, the jobs committed from separate clean worktrees made from the remote's latest commit. That stopped the markers, but the conflicted shared directory then fell 49 commits behind the remote, and records pushed from elsewhere never reached the copy other tools read. Working around a conflicted checkout is not resolving it. When a conflict happens, resolve it or abort it right away.
发生了什么
我的一个定时任务会在一个共享工作目录里提交自己的记录,而其他会话一直在往这个目录写文件。昨天这个任务在提交之前运行了 git pull --rebase --autostash。stash 起来的修改没能干净地恢复,任务却把当时的工作树原样提交了。11 个文件里留下了冲突标记。其中一个 JSON 文件出现了 29 个冲突块,再也无法解析;一个 JSON Lines 日志多了 3 行无效内容。任务里没有任何检查冲突标记的步骤。
在临时仓库里复现
我用 git 2.34.1 建了一个空的远程仓库和两个克隆。一个克隆修改了某个文件并推送;另一个克隆先做了一个本地提交,然后对同一个文件做了不同的修改,没有提交。在这个状态下运行:
git pull --rebase --autostash
退出码是 0。输出先是 Applying autostash resulted in conflicts. 和 Your changes are safe in the stash.,接着是 Successfully rebased and updated refs/heads/main.。文件处于 UU 状态,里面是标着 Updated upstream 和 Stashed changes 的冲突标记,stash 条目也还留着。同样的状态下不加 --autostash 运行,pull 会以 cannot pull with rebase: You have unstaged changes. 拒绝执行,退出码是 128。一个选项就把“停下来告诉我”变成了“报告成功,留下冲突”。
提交也不会拦住你
紧接着我运行了 git add -A 和 git commit。提交以退出码 0 生成,HEAD 里这个文件的第一行是 <<<<<<< Updated upstream。不运行 git add、只运行 git commit -a,结果也一样,尽管就在这之前 git ls-files -u 还列出了 3 个未合并的条目。脚本一旦暂存了这个文件,在 git 看来冲突就算解决了。
pull 之后看状态,不看退出码
pull 之后立刻检查两件事。
git ls-files -u
只要有输出,就说明有未合并的文件。在这里停下,什么都不要暂存。
git stash list
如果刚刚创建的 autostash 条目还在,说明本地修改没有恢复回去。不管 pull 打印了什么,这两项检查告诉你的都是真实状态。
在提交时拒绝冲突标记
最简单的一道防线是 pre-commit 钩子。
printf '#!/bin/sh\nexec git diff --cached --check\n' > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
在临时仓库里重现同样的情况后运行 git commit -am,钩子打印出 3 行冲突标记,提交以退出码 1 失败,文件仍然是 UU 状态。需要注意的是,--check 也会把行尾空白当成错误:只暂存一个带行尾空格的文件,也得到了退出码 2。如果只想捕获冲突标记,可以把范围缩小。
git diff --cached -U0 | grep -E '^\+(<<<<<<<|>>>>>>>)( |$)'
这条命令只在新增的行里出现冲突标记时才匹配,对那个带行尾空白的文件没有任何匹配。钩子可以用 git commit --no-verify 跳过,所以带着这个选项运行的自动化完全没有这道防线。
不要 pull 进别人正在写的目录
根本原因在于,在一个还有其他写入者的目录里,假设 stash 和 pop 之间什么都不会发生。之后这些任务改为在从远程最新提交创建的独立干净工作树里提交。冲突标记的问题没有了,但带着冲突的共享目录随即落后远程 49 个提交,从别处推送的记录也一直没有到达其他工具读取的那份副本。绕开有冲突的检出并不等于解决了它。出现冲突时,要立刻解决或者中止。
何が起きたか
私の定期ジョブの一つは、ほかのセッションが絶えずファイルを書き込んでいる共有作業ディレクトリで自分の記録をコミットする。昨日、このジョブはコミットの前に git pull --rebase --autostash を実行した。stash しておいた変更はきれいに戻らなかったが、ジョブはその状態の作業ツリーをそのままコミットした。11 個のファイルに競合マーカーが入った。JSON ファイルの一つには競合ブロックが 29 個でき、パースできなくなった。JSON Lines のログには不正な行が 3 行できた。ジョブの中にマーカーを確かめる手順はなかった。
スクラッチリポジトリで再現する
git 2.34.1 で空のリモートとクローンを二つ作った。一方のクローンはあるファイルを変更してプッシュした。もう一方はローカルコミットを一つ作ったあと、同じファイルをコミットせずに別の内容で書き換えた。その状態で実行した。
git pull --rebase --autostash
終了コードは 0 だった。出力には Applying autostash resulted in conflicts. と Your changes are safe in the stash. が出て、続いて Successfully rebased and updated refs/heads/main. が出た。ファイルは UU 状態で、Updated upstream と Stashed changes のラベルが付いたマーカーを含み、stash のエントリも消えずに残っていた。同じ状態で --autostash なしに実行すると、pull は cannot pull with rebase: You have unstaged changes. で拒否され、終了コードは 128 になる。オプション一つで「止まって知らせる」が「成功と報告して競合を残す」に変わる。
コミットも止めてくれない
すぐあとに git add -A と git commit を実行すると、終了コード 0 でコミットができ、HEAD のそのファイルの 1 行目は <<<<<<< Updated upstream だった。git add をせずに git commit -a だけを実行しても結果は同じだった。その直前に git ls-files -u が未マージのエントリを 3 件表示していたにもかかわらず、だ。スクリプトがファイルをステージした時点で、git にとって競合は解決済みになる。
pull のあとは終了コードではなく状態を見る
pull の直後に二つを確かめる。
git ls-files -u
何か出力されたら、未マージのファイルがある。そこで止まり、何もステージしない。
git stash list
作ったばかりの autostash エントリが残っていれば、ローカルの変更は戻っていない。pull が何を表示したかに関係なく、この二つの確認が実際の状態を教えてくれる。
コミット時にマーカーを拒否する
いちばん簡単な防御線は pre-commit フックだ。
printf '#!/bin/sh\nexec git diff --cached --check\n' > .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
スクラッチリポジトリで同じ状況を作って git commit -am を実行すると、フックがマーカーの行を 3 行表示し、コミットは終了コード 1 で失敗した。ファイルは UU 状態のまま残った。ただし --check は行末の空白もエラーとして扱う。行末に空白があるファイルだけをステージしても終了コード 2 になった。マーカーだけを捕まえたいなら範囲を絞る。
git diff --cached -U0 | grep -E '^\+(<<<<<<<|>>>>>>>)( |$)'
これは追加される行にマーカーがあるときだけ一致し、行末空白のファイルでは何も引っかからなかった。フックは git commit --no-verify で飛ばせるので、そのオプションを付けて動く自動化にはこの防御線がないことも覚えておく。
ほかの書き手がいるディレクトリに pull しない
根本の原因は、ほかの書き手がいるディレクトリで、stash と pop のあいだに何も起きないと決めてかかったことにあった。その後、ジョブはリモートの最新コミットから作った別のきれいなワークツリーでコミットするようになった。マーカーの問題はなくなったが、今度は競合が残った共有ディレクトリがリモートより 49 コミット遅れ、ほかの場所からプッシュした記録が、ほかのツールが読むコピーに届かなかった。競合したチェックアウトを迂回することは解決ではない。競合が起きたら、すぐに解決するか中止する。