0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude coworkが削除できない環境で git を自動化したら .git にゴミが98個溜まった話

0
Posted at

はじめに

毎朝6時に走らせていた自動タスクが、.git/index.lock に阻まれて止まりました。ログを見ると、そのロックファイルのタイムスタンプは3日前rmmvOperation not permitted で弾かれます。

.git/ 直下を数えたところ、なんとサイズ0のゴミファイルが 98個 溜まっていました。最も古いものは3か月半前の日付です。つまり、筆者の自動タスクは4月からずっと壊れたまま「完了しました」と報告し続けていたわけです。

この記事は、その原因調査と是正のポストモーテムです。「ファイル削除に保護がかかるサンドボックス環境で git を動かす」という、AI エージェントに定期実行を任せる人なら誰でも踏みうる構造の話でもあります。

背景 — 毎朝6時のバッチが git commit していた

筆者は個人のナレッジベースを Markdown で管理していて、毎朝6時にトレンド記事を収集してファイルに書き出す定期タスクを走らせています。実行環境は AI エージェントのサンドボックス(Linux コンテナ)で、成果物は同期フォルダ配下のリポジトリに置いていました。

タスクの最終フェーズはこうなっていました。

git add .
git commit -m "daily digest $(date +%F)"
git push origin main

ごく普通の構成です。問題は、この実行環境がファイルの削除(unlink)に保護をかけていたことでした。ユーザーの資産を誤って消さないための安全機構で、それ自体は妥当な設計です。

しかしこの保護は、git が前提にしている挙動と正面から衝突しました。

症状 — 3日前のタイムスタンプの index.lock

ある朝、commit がこのエラーで停止しました。

fatal: Unable to create '/path/to/repo/.git/index.lock': File exists.

Another git process seems to be running in this repository, e.g.
an editor opened by 'git commit'. Please make sure all processes
are terminated then try again. If it still fails, a git process
may have crashed in this repository earlier:
remove the file manually to continue.

最後の行に注目してください。git 自身が「手で削除しろ」と言っています。 これが後の伏線になります。

別の git プロセスは動いていませんでした。ls -l するとタイムスタンプが3日前です。典型的な stale lock なので消そうとしたところ、こうなりました。

$ rm -f .git/index.lock
rm: cannot remove '.git/index.lock': Operation not permitted

$ mv .git/index.lock .git/index.lock.bak
# → 成功してしまう

削除は拒否されるのに、同一ディレクトリ内のリネームは通る。 ここが今回の話の分岐点でした。

調べたら .git 直下にゴミが98個あった

その場は退避で切り抜けられたのですが、「毎回この作業をしている気がする」という違和感があり、.git/ 直下を数えてみました。

$ ls -a .git/ | wc -l
111

正常なリポジトリの .git/ 直下は 13 エントリ前後です。98 個が余分でした。作成月で集計するとこうなります。

生成数
2026-04 1
2026-05 22
2026-06 54
2026-07 19

タイムスタンプは 06:12〜06:20 に集中していました。定期タスクの起動時刻(06:06)と一致します。犯人は筆者自身の自動化でした。

ファイル名を眺めると、歴代の実行が何を試したのかが読み取れます。

index.lock.bak                index.lock.retry1 〜 .retry5
index.lock.mvd_1783890832     index.lock.stale.1784755111
HEAD.lock.cleared.<epoch>     zz.index.lock.<epoch>
__perm_test                   write-test
lockdump.10934                stale-locks/            ← 退避用ディレクトリまで

.retry1 から .retry5 まであるあたりに、朝6時のエージェントが順に手詰まっていった様子が出ています。__perm_testwrite-test は「そもそも書き込めるのか」を確かめようとした痕跡でしょう。どれも0バイトです。

なぜ増え続けたのか — 自己増殖ループの構造

原因は単純で、しかし放っておくと止まらない形をしていました。

git は「自分のロックは自分で消せる」前提で書かれている

git のロックファイルは lockfile API という仕組みで管理されています。公式ドキュメントによれば、この API の目的は「相互排除と原子的なファイル更新」に加えて、stale lock の自動削除です(Git — lockfile API)。

具体的には、<filename>.lockO_CREAT|O_EXCL で作り、書き込みが完了したら最終名へ原子的に rename します。処理を中断した場合は rollback_lock_file() がロックを unlink します。さらに atexit() ハンドラとシグナルハンドラが登録されていて、正常終了・die()・シグナル受信のいずれでも、登録済みのロックファイルを消してから終了する設計です。

つまり git は、unlink が成功することを暗黙の前提にして安全性を組み立てています。削除保護のある環境ではこの前提が崩れますが、git はそれを検知する手段を持っていません。エラーにもなりません。ただロックが残るだけです。

リネーム退避は対症療法どころか、ゴミの生産装置だった

厄介だったのは、リネーム退避が毎回エラーなく成功していたことです。その日の commit は通り、タスクは「完了しました」と報告します。エラーで止まってくれていれば初日に気づけたはずですが、成功したように見えるので3か月半気づけませんでした。

そして退避したファイルは .git/ 直下に残り続け、翌朝また新しいロックが残り、また退避され、を繰り返します。対症療法そのものが原因を再生産するループになっていました。

副次被害 — 偽ブランチと止まった gc

.git/ 直下だけの話では済んでいませんでした。

1. refs/heads の汚染

refs/heads/main.lock.moved.1780780478 のような偽ブランチが4本できていました。refs/heads/ 配下のファイルは中身がコミットハッシュであれば git がブランチとして解釈するため、退避したロックファイルがそのままブランチ扱いになっていたわけです。

$ git branch
* main
  main.lock.moved.1780780478
  main.lock.moved.1782258911
  ...

このうち2本は main に含まれない孤立コミットを指していました。幸い内容はフォルダ整理後に main へ再コミット済みで、データ損失はありませんでした。

2. 自動メンテナンスの停止

.git/objects/maintenance.lock が1週間前から残留していました。git のメンテナンスはオブジェクトデータベースに対してロックを取り、競合時は片方のタスクを実行しない仕様です(git-maintenance / TROUBLESHOOTING)。残ったロックのせいで、自動 gc がずっと動いていませんでした。

本丸 — push は一度も成功していなかった

掃除の過程で、より深刻な事実が出てきました。

$ git log --oneline origin/main..main | wc -l
54

未 push のコミットが54件。 origin の URL は設定されているのに、認証情報が一切設定されておらず、非対話の定期実行では原理的に push できない状態でした。

にもかかわらず、タスクは4月からずっと「同期しました」と報告し続けていました。push の失敗を終了コードで判定していなかったためです。ローカルの .git が汚れ続けていただけで、同期の実益はゼロでした。

清掃手順

再現できる形で残しておきます。実行前にリポジトリ全体のコピーを取ることをおすすめします。

# 1. 現状把握
ls -a .git/ | wc -l
ls -a .git/ | grep -E 'lock|test|dump'

# 2. refs のバックアップ(消す前に必ず)
cp -r .git/refs/heads /tmp/refs-heads-backup

# 3. ゴミの削除
rm -f .git/*.lock.* .git/zz.index.lock.* .git/__perm_test .git/write-test .git/lockdump.*
rm -rf .git/stale-locks
rm -f .git/objects/maintenance.lock
rm -f .git/objects/tmp_obj_*

# 4. 偽ブランチの削除
git branch | grep -E '\.lock\.' | xargs -r git branch -D

# 5. 検証
git fsck
git status
git log --oneline -5

結果、.git/ 直下は 111 → 13 エントリになりました。git fsck は dangling commit のみを報告し、それは削除した偽ブランチが指していたもので内容は main に存在することを確認済みです。

なお削除が Operation not permitted で拒否される環境では、実行環境側の削除許可を先に取る必要があります。ここでリネームに逃げると、今回と同じループに入ります。

正しいロック処理と、やってはいけない回避策

# ❌ やってはいけない
mv .git/index.lock .git/index.lock.bak

# ✅ 正しい手順
#    1. 本当に git プロセスが動いていないことを確認する
ps aux | grep -w '[g]it'

#    2. 削除する(リネームではなく削除)
rm -f .git/index.lock .git/HEAD.lock

#    3. 実行後に残骸が増えていないか必ず確認する
ls -a .git/ | wc -l

3番目が要点です。自動化に組み込むなら、後始末が本当に効いているかを毎回数えて記録する。これがあれば98個溜まる前に気づけました。

判断 — git をやめた

再発防止として、まず定期タスクとスキル定義から git 操作を撤去しました。そのうえで、GitHub 同期そのものをやめる判断をしています。

理由は次の通りです。

決定 理由
リネーム退避を明示的に禁止 成功したように見えてゴミを再生産する。3か月半で98個+偽ブランチ4本の実害が出た
定期タスクから git 操作を撤去 push は認証がなく原理的に成功しない。commit だけ続けても .git を汚し翌日の実行を壊すだけ
GitHub 同期そのものを廃止 認証を通す運用コストに対して、クラウドストレージ同期で用が足りている。中途半端に git を残すと同じループが再発する
削除前に bundle を取る 未 push の54件を含む履歴がここにしか無い。復元可能性を残さず消すのは不可逆すぎる

履歴の退避には git bundle を使いました。全参照を1ファイルに固められ、そこから直接 clone できます(git-bundle)。

# 全履歴を1ファイルに退避
git bundle create /tmp/repo-final-2026-08-01.bundle --all
git bundle verify /tmp/repo-final-2026-08-01.bundle
# → "The bundle records a complete history."

# 保存先へ配置し、コピー後にもう一度 verify する
cp /tmp/repo-final-2026-08-01.bundle ./repo-final-2026-08-01.bundle
git bundle verify ./repo-final-2026-08-01.bundle

# .git を削除
rm -rf .git .gitignore

コピー後に verify し直しているのは、同期フォルダへの書き込みで過去にファイル末尾が壊れた経験があるためです。コピー操作そのものを信用しないという方針にしています。

復元したくなったら、bundle から clone するだけです。

git clone repo-final-2026-08-01.bundle restored-repo

ハマりどころ

  • refs/heads/ にファイルを置くとブランチになる — ロックの退避先に使うと偽ブランチが生えます
  • git bundle verify は2回やる — 作成直後と、最終的な保存先へ配置した後。前者だけだとコピー破損を検知できません
  • git fsck の dangling commit に慌てない — 偽ブランチを消した直後は必ず出ます。その内容が main に含まれているかを git log で確認すれば十分です
  • 削除許可を取るのは掃除の「前」 — 権限がないまま始めると、途中で退避に逃げたくなります

教訓

1. 「成功したように見える回避策」がいちばん危ない

リネーム退避は毎回エラーなく通り、その日の処理は完了していました。だから3か月半誰も気づきませんでした。エラーで止まってくれていれば初日に発覚していたはずです。回避策を入れるときは、それが本来の失敗を隠していないかを必ず確認したいところです。

2. 成功条件を検証しないタスクは、失敗を成功として報告する

push は4月から一度も成功していないのに、毎朝「完了しました」と報告されていました。定期実行タスクには、コマンドの終了コードだけでなく「期待する状態になったか」の検証を入れる必要があります。今回で言えば git log origin/main..main | wc -l が 0 かどうかを見ていれば、初日に分かりました。

3. サンドボックスの制約はツールの暗黙の前提を壊す

git は「自分の作ったロックは自分で消せる」を前提に設計されています。削除保護はその前提を壊しますが、git はそれを知らせてくれません。外部ツールを制約環境で動かすときは、そのツールが何を暗黙に仮定しているかを先に洗い出す必要があります。ファイル削除、シンボリックリンク、fsync、ファイルロック、プロセスのシグナルあたりが定番の落とし穴です。

まとめ

サンドボックス環境で git を自動化するなら、最低限このあたりを確認しておくと安全です。

  • .git 配下の unlink が成功する環境か、先に確かめる
  • stale lock は削除で処理する。リネーム退避はしない
  • refs/ 配下を退避先に使わない
  • 実行後に ls -a .git/ | wc -l を記録し、増えていたら警告を出す
  • push を含むなら、非対話で認証が通ることを事前に確認する
  • 「コマンドが返った」ではなく「期待する状態になった」で成否を判定する

1つ目の確認はこれで済みます。rm -f は対象が存在しなくても成功してしまうので、-f は付けずに実ファイルを作って消してください。

touch .git/.unlink-test && rm .git/.unlink-test && echo ok

自動化は、失敗の痕跡を残さないと壊れたまま走り続けます。今回はその痕跡が98個のゴミという形で残っていました。おかげで後から全部読み解けたので、まだ運が良かった方だと思っています。

参考

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?