概要
Windows の Git で core.autocrlf=true のとき、未コミットの変更と重なって拒否された git merge のあとに、そのファイルの改行が LF から CRLF に変わることがありました。
中身の文字は変わらず、改行だけです。
手元で再現したのは、次の2つがそろったときだけでした。
- ローカルに自分の commit があり、fast-forward できない
core.autocrlf=true
この記事では、再現の手順と結果、それから自動処理側でどう避けたかを書きます。
別のセッションや人が未コミットの変更を残しうる作業ツリーで、git merge を自動で走らせている人向けです。
なお、確かめたのは1つの環境(Git for Windows 2.49.0・2026-10-18)だけです。
どの版から・どの条件まで起きるのかは調べていません。
前提:なぜ merge で合わせていたか
夜間に自動で commit を押す仕組みを作っていました。
作業ツリーには、別のセッションが触った未コミットのファイルが残っていることがあります。
リモートが進んでいたら合わせる必要があります。
ただ、git pull --rebase は作業ツリーが汚いと走りません(--autostash を使わない場合)。
そこで git merge --no-edit origin/master で合わせる設計にしました。
merge が未コミットのファイルを上書きしそうなときは、git が次のように止めてくれます。
error: Your local changes to the following files would be overwritten by merge:
a.md
Please commit your changes or stash them before you merge.
Aborting
「拒否されたなら何も起きていない」と考えていたのですが、対照テストで未コミットのファイルの改行が変わっていて、前提が崩れました。
再現の手順
使い捨てのリポジトリで試せるスクリプトです。
Git Bash で動かしました(git init -b を使うので Git 2.28 以降が要ります)。
#!/usr/bin/env bash
# 使い方: bash repro.sh <autocrlf の値> <ff|nonff>
set -u
ac="$1"; mode="$2"
rm -rf origin.git work other
git init -q --bare -b master origin.git
git clone -q origin.git other 2>/dev/null
cd other
git config core.autocrlf "$ac"
printf 'base\n' > a.md && git add a.md && git -c user.name=t -c user.email=t@t commit -qm base && git push -q origin master
cd ..
git clone -q origin.git work
cd other
# リモート側で a.md を変える(merge が a.md を触るようにする)
printf 'remote\n' > a.md && git -c user.name=t -c user.email=t@t commit -qam remote && git push -q origin master
cd ../work
git config core.autocrlf "$ac"
# nonff のときだけ、ローカルに別ファイルの commit を作って fast-forward できなくする
if [ "$mode" = nonff ]; then printf 'x\n' > b.md && git add b.md && git -c user.name=t -c user.email=t@t commit -qm local; fi
printf 'dirty\n' > a.md # 未コミットの変更(LF)
git fetch -q
git merge --no-edit origin/master >/dev/null 2>merge.err; echo "merge exit=$?"
head -1 merge.err
# 改行を1バイトずつ見る(\r があれば CRLF)
od -c a.md | head -2
4通りを順に走らせます。
# autocrlf(true/false)× ローカルの状態(nonff/ff)の4通りを順に走らせる
for ac in true false; do for m in nonff ff; do echo "=== autocrlf=$ac $m"; bash repro.sh $ac $m; done; done
結果
| ローカルの状態 | core.autocrlf | merge の終了コード | merge 後の a.md |
|---|---|---|---|
| 自分の commit あり(nonff) | true | 2(拒否) |
d i r t y \r \n=CRLF に変わった
|
| 自分の commit なし(ff) | true | 1(拒否) |
d i r t y \n=LF のまま |
| 自分の commit あり(nonff) | false | 2(拒否) |
d i r t y \n=LF のまま |
| 自分の commit なし(ff) | false | 1(拒否) |
d i r t y \n=LF のまま |
4通りとも merge は拒否されています。
メッセージは同じ Your local changes to the following files would be overwritten by merge でした。
それでも、nonff かつ autocrlf=true の1通りだけ、未コミットの a.md が CRLF になっています。
この1通りでは、merge 自身の出力(stderr)に次の警告も出ていました。
fast-forward の場合の merge の出力には出ていません。
warning: in the working copy of 'a.md', LF will be replaced by CRLF the next time Git touches it
なぜこうなるのか、git の中の仕組みまでは追っていません。
対処:merge を走らせる前に、重なりを自分で調べる
git の拒否を安全装置として当てにせず、merge の前に「merge が触るファイル」と「未コミットのファイル」の重なりを自分で調べるようにしました。
重なれば、merge そのものを走らせません。
使うコマンドは2つです。
# merge が触るファイル(merge-base からリモートまでの差分)
git diff --name-only HEAD...origin/master
# 未コミットのファイル(未追跡も含める)
git status --porcelain=v1 -uall
夜間の push(Node.js)では、こう書いています(一部を省き、porcelain の行の読み方と「重なったら」の2つのコメントは記事用に足しました)。
// 🔴 merge は走らせる前に重なりを自分で見る(2026-10-15 実測)。
// merge が触るファイル(merge-base→リモート)と未コミット(未追跡を含む)が1つでも重なれば走らせない。
const incoming = git(repo, ['diff', '--name-only', `HEAD...${remote}`], runner);
const dirty = git(repo, ['status', '--porcelain=v1', '-uall'], runner);
if (incoming.status !== 0 || dirty.status !== 0) return { status: 'failed', reason: `重なりを確かめられない: …` };
// porcelain の行は「XY パス」。先頭3文字を落とし、リネームは「->」の後ろ、引用符は外す
const dirtySet = new Set(dirty.stdout.split(/\r?\n/u).filter(Boolean)
.map((l) => l.slice(3).split(' -> ').pop().replace(/^"|"$/gu, '')));
const overlap = incoming.stdout.split(/\r?\n/u).filter((p) => p && dirtySet.has(p));
if (overlap.length > 0) {
// 重なったら merge しない。その夜は押さずに理由を残す
return { status: 'failed', reason: `リモートの変更が未コミットのファイルと重なる(…)=merge しない。…` };
}
const merged = git(repo, ['merge', '--no-edit', remote], runner);
テストでは「merge が未コミットと重なれば中止する」ことと「未追跡のファイルと重なっても merge しない」ことを見ています。
重なりの確認を外すとテストが落ちることも確かめました。
この方法を選んだ理由
別の手として、-c core.autocrlf=false を付けて merge する方法もあります。
手元の再現では、false の2通り(nonff・ff)とも LF のままでした(再現スクリプトでは -c ではなく git config で設定しています)。
それでも重なりの確認を選んだのは、次の理由からです。
- 改行が変わらなくても、自分以外(別のセッション)の未コミットと重なる merge は、そもそも走らせたくない
-
autocrlfだけを止めても、他の条件で同じことが起きないとは確かめていない - 重なりの確認なら、git の挙動に頼らず、自分のコードの中で「触らない」を保証できる
自分以外の未コミットが残る作業ツリーで自動処理を回すときは、「git が拒否してくれる」ではなく「自分で重なりを見て、重なったら触らない」に寄せるのが安心かなって思ってます。
関連記事
- Claude CodeのStop hookが止まらない原因と並行セッション対策
- 未コミットの変更が消える:並行セッションとgit addの空振り
- 監視の0件を誤読した:「もう出た」と「一度も出ていない」は同じ0
個人でAIツールや業務自動化を作っているMiyokiといいます。
今回の夜間の自動 push のように、自分の作業を楽にするための小さな仕組みを作っては、その開発ログをZennとQiitaに書いています。
作ったものやポートフォリオはこちらです。