git status の出力を1行ずつ解剖
「addしたのにコミットに入っていない」が消える。git statusの出力を1行ずつ分解し、作業ツリー・インデックス・HEADの3領域の関係、ahead/behindの読み方、--shortの2文字表記まで整理する。
- git statusは作業ツリー・インデックス・HEADの差を示す。Changes to be committedはHEAD対インデックス、not stagedはインデックス対作業ツリー、残りは未追跡ファイルだ。
- ahead of 'origin/main' by N commitsが比べるのはローカルmainと、最後のfetch時点を写すorigin/mainだ。リモート追跡ブランチはキャッシュなので、数字はfetchまで更新されない。
- git status --shortは1列目がHEAD対インデックス、2列目がインデックス対作業ツリー。MMはadd後の再編集で、コミット対象はadd時点だけ。??は未追跡、!!は無視対象を示す。
このコマンドは何を見せているか
git status は、Gitが管理する3つの領域を突き合わせ、その差を人間向けに報告するコマンドだ。3領域とは、直近のコミットを指すHEAD、次のコミットの下書きであるインデックス(ステージング領域)、そしてディスク上の実ファイルである作業ツリーを指す。
この3つを意識すると出力の構造は一気に単純になる。git statusが見せているのは本質的に2つの差分だからだ。HEADとインデックスの差が上の節に、インデックスと作業ツリーの差が下の節に並び、どちらにも現れずGitがまだ存在を知らないファイルが最後の節に落ちる。3領域が完全に一致していれば報告することは何もない。
| 出力の節 | 比べている2領域 | 同じ内容を見るコマンド |
|---|---|---|
| Changes to be committed | HEAD ↔ インデックス | git diff --staged |
| Changes not staged for commit | インデックス ↔ 作業ツリー | git diff |
| Untracked files | どちらにも存在しない | git ls-files --others --exclude-standard |
git statusはリモートへ一切通信しない。後述するブランチの進み具合まで含め、出力はすべてローカルの.gitディレクトリにある情報だけで組み立てられる。実行が速い理由であり、数字がずれる原因でもある。
コミットとブランチがグラフとしてどう繋がるかはGitのコミットグラフに譲り、本稿は出力の読解に集中する。
横にスクロール
1行ずつ解剖
題材は、認証まわりの機能を実装している途中の、いかにもありがちな状態だ。
$ git status
On branch main
Your branch is ahead of 'origin/main' by 2 commits.
(use "git push" to publish your local commits)
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: src/app.ts
new file: src/auth/session.ts
renamed: src/util/token.ts -> src/auth/token.ts
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: src/app.ts
deleted: src/legacy/old-auth.ts
Untracked files:
(use "git add <file>..." to include in what will be committed)
docs/session-design.md
tmp/
On branch main — HEADが指しているもの
1行目はHEADの状態の宣言だ。HEADは通常ブランチを指す参照で、この表示はHEADがmainブランチを指していることを意味する。ここでコミットすれば、mainが1つ先へ進む。
タグや過去のコミットを直接チェックアウトしてHEADがブランチを経由しなくなると、この行は HEAD detached at 3a2b1c9 に変わる。この状態でコミットしてもどのブランチも進まない。1行目がOn branchで始まらなければ、手を動かす前にブランチを作るべきサインだ。
Your branch is ahead of 'origin/main' — 比べているのはローカル同士
2行目は追跡関係の報告で、上流ブランチが設定されているときだけ現れる。ここで比較されているのは、ローカルのmainと、リモート追跡ブランチであるorigin/mainだ。
肝心なのは、origin/mainがリモートサーバーそのものではない点である。origin/mainは、最後にfetchした時点のリモートのmainをローカルの.gitへ写し取ったキャッシュにすぎない。つまりこの行は、ローカルの参照2つを比べた結果でしかない。aheadは自分だけが持つコミットの数、behindはorigin/mainだけが持つコミットの数を表す。
| ブランチ行 | 意味 | 次の一手 |
|---|---|---|
| Your branch is up to date with 'origin/main'. | 両者が同じコミットを指す | なし |
| Your branch is ahead of 'origin/main' by 2 commits. | origin/mainに無い自分のコミットが2つある | git push |
| Your branch is behind 'origin/main' by 3 commits, and can be fast-forwarded. | 自分側に独自のコミットが無く、相手が3つ先 | git pull(早送りで済む) |
| Your branch and 'origin/main' have diverged, | 双方に独自のコミットがある | mergeかrebaseが要る |
| 行そのものが出ない | 上流が未設定 | git push -u origin main で設定 |
Changes to be committed — HEADとインデックスの差
ここに並ぶのは、インデックスへ登録済みの変更、つまり「いまgit commitを打ったらコミットに入るもの」だ。中身はHEADのツリーとインデックスの差分そのものである。
行頭の語が変更の種類を示す。new fileはHEADに無くインデックスにあるファイル、modifiedは両方にあって内容が違うもの、deletedはHEADにあってインデックスから消えたものだ。renamedは矢印で旧パスと新パスを併記する。
ただしGitはリネームを記録していない。renamedは削除と追加のペアを内容の類似度から後追いで推測した表示にすぎず、中身も大きく書き換えるとnew fileとdeletedの2行に割れて見える。
ヒントのrestore --stagedは、インデックスをHEADの内容で上書きしてステージだけを取り消す操作だ。
Changes not staged for commit — インデックスと作業ツリーの差
こちらはインデックスと作業ツリーの差、すなわちまだaddしていない変更である。この節に載るのは追跡済みのファイルだけだ。
modifiedは作業ツリーの内容がインデックスと違うこと、deletedは追跡済みファイルを消したがaddしていないことを示す。ファイルを消しただけではコミットに反映されず、削除もステージが要る。
ヒントの2行は対になっている。git addはこの節の変更を上の節へ動かし、git restoreは作業ツリーをインデックスの内容で上書きして変更を捨てる破壊的操作だ。
Untracked files — Gitがまだ知らないファイル
HEADにもインデックスにも存在せず、無視設定にも該当しないファイルがここに並ぶ。一度もaddされたことのない新規ファイルが典型だ。
tmp/ のようにディレクトリ名で終わっている行は省略表示で、その配下が丸ごと未追跡であることを意味する。中身を1件ずつ確かめたいときはオプションを足す。
$ git status --untracked-files=all # -uall と同じ。既定は normal
逆に、無視設定に一致したファイルはこの節にも出ない。何がどのルールで無視されているのかを追う方法は.gitignoreを1行ずつ解剖で扱っている。
末尾の要約行
サンプルには出ていないが、ステージ済みの変更が1つも無いとき、Gitは末尾に総括を1行足す。
nothing to commit, working tree clean
no changes added to commit (use "git add" and/or "git commit -a")
nothing added to commit but untracked files present (use "git add" to track)
上から順に、3領域が完全一致、未ステージの変更だけがある、未追跡ファイルだけがある、を意味する。裏を返せば、この行が出ていないこと自体が「インデックスにコミットできる中身がある」証明になる。
--short — 2文字が示す2つの差分
同じ状態を git status --short(短縮形は -s)で見ると、こうなる。
$ git status --short
MM src/app.ts
A src/auth/session.ts
R src/util/token.ts -> src/auth/token.ts
D src/legacy/old-auth.ts
?? docs/session-design.md
?? tmp/
行頭の2文字が要点のすべてだ。1列目がHEADとインデックスの差、2列目がインデックスと作業ツリーの差を表す。長い表示のChanges to be committedが1列目に、Changes not staged for commitが2列目に対応していると考えればよい。空白はその領域に差が無いことを示す。
| 表記 | 意味 |
|---|---|
| M | 変更あり。1列目ならステージ済み、2列目なら未ステージ |
| A | インデックスへの新規追加。1列目にだけ現れる |
| D | 削除。1列目なら削除をステージ済み、2列目なら消しただけ |
| R | リネーム。旧パスと新パスを矢印で併記する |
| ?? | 未追跡。2列とも疑問符になる |
| !! | 無視されたファイル。--ignored を付けたときだけ出る |
そして MM src/app.ts である。1列目のMはaddした変更がインデックスにあること、2列目のMはそのadd以降にさらに編集した差分が作業ツリーに残っていることを示す。長い表示で同じファイルが上下2つの節に現れていたのと、まったく同じ事実を2文字で表しているにすぎない。
--porcelain は --short とほぼ同じ書式だが、ロケールや設定に左右されず出力が固定される。人が読むなら --short、スクリプトから読むなら --porcelain と使い分ける。どちらも -b を足せば ## main...origin/main [ahead 2] というブランチ行が付く。
なお例外が1つある。マージの衝突中だけは、2文字がそれぞれマージの片側の状態を表す規則に切り替わり、UU(双方が変更)やAA(双方が追加)が出る。このとき「左=インデックス」の読み方は当てはまらず、長い表示ではUnmerged pathsという専用の節に分かれる。
つまずきやすい点
第一に、ahead/behindはfetchしないと古いままだ。origin/mainはローカルに置かれたキャッシュにすぎず、git statusはネットワークへ出ない。同僚が10個のコミットをpushしていても、自分がfetchするまでstatusは平然とup to dateと言い続ける。本当の距離を知りたいなら順序が要る。
$ git fetch # リモート追跡ブランチだけを更新する
$ git status # ここで初めて正しい ahead/behind が出る
git pullはfetchとmergeを続けて行うので追跡ブランチも更新されるが、統合まで走ってしまう。距離を見たいだけならfetchで止めるのが安全だ。
第二に、--shortの2文字を左右逆に読むこと。左がインデックス、右が作業ツリーである。
M src/app.ts # 右が空白:addした後は触っていない
M src/app.ts # 左が空白:編集しただけでaddしていない
MM src/app.ts # 両方:addした後にさらに編集した
この2つを取り違えると、コミットに何が入るかの予測が真逆になる。列と差分の対応を毎回思い出すより、「左はコミットに入る側」と覚えるほうが早い。
第三に、一度addした後に編集すると同じファイルが両方の節に現れる。これは異常ではなく設計どおりだ。addはファイルを予約する操作ではなく、その瞬間の中身をインデックスへコピーする操作だからである。add後の編集は、インデックスとは別物の作業ツリー側の差分として積み上がる。サンプルのsrc/app.tsが2箇所に出るのも--shortがMMになるのも、同じ事実の2通りの見せ方だ。
git commitはインデックスの内容をそのまま記録するので、MMの状態ではaddした時点の中身だけがコミットされ、その後の編集は取り残される。「直したはずの箇所がコミットに入っていない」の正体はたいていこれだ。add後に手を入れたら、コミット前にもう一度 git add するか git diff で残りを確かめる。
第四に、.gitignoreは追跡済みのファイルには効かない。無視設定が働くのは未追跡かどうかの判定だけなので、一度コミットしたことのあるファイルは、後から.gitignoreへ書き足してもChanges not staged for commitに出続ける。止めるには追跡そのものを外す必要がある。
$ git rm --cached config/local.json # 作業ツリーのファイルは残したまま追跡を外す
これはインデックスからの削除としてステージされるので、コミットして初めてリモート側からも消える。過去のコミットには残り続けるため、すでにpushした秘密情報の始末はこれだけでは終わらない。
まとめ
git statusの出力は、HEAD・インデックス・作業ツリーという3領域の位置関係を2つの差分として並べたものだ。Changes to be committedはHEADとインデックスの差、Changes not staged for commitはインデックスと作業ツリーの差、Untracked filesはどちらにも属さないファイル。--shortの2文字は、その2つを左右へ押し込んだだけの同じ情報である。先頭のブランチ行だけが別枠で、リモート追跡ブランチとの距離を最後にfetchした時点の情報で語る。この構造が頭に入れば、どの1行も推測なしで読める。他のコマンドも同じ要領で分解できる。一覧はコマンド出力の読み方へ。
コマンド出力の読み方の記事ガイド
git status の出力を1行ずつ解剖を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
コマンド出力
比較で見る軸
難易度: advanced / カテゴリ: コマンド出力の読み方 / タグ数: 3
導入後に効く点
ahead of 'origin/main' by N commitsが比べるのはローカルmainと、最後のfetch時点を写すorigin/mainだ。リモート追跡ブランチはキャッシュなので、数字はfetchまで更新されない。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- コマンド出力の読み方
- タグ数
- 3
判断チェックリスト
- 自社の用途が「コマンド出力 / Git」に近いか確認する。
- 強みである「git statusは作業ツリー・インデックス・HEADの差を示す。Changes to be committedはHEAD対インデックス、not stagedはインデックス対作業ツリー、残りは未追跡ファイルだ。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。