Gitブランチ戦略:rebase vs merge の使い分けとチーム運用

Git Flow・GitHub Flow・トランクベース開発の3つのブランチ戦略を比較し、rebaseとmergeがオブジェクトグラフに対して何をしているかを実際のコミットハッシュとログで検証しながら解説します。

はじめに

チーム開発において、Git のブランチ戦略はコードの品質と開発速度を左右する重要な要素です。本記事では、代表的なブランチ戦略を比較したうえで、rebasemerge が Git のオブジェクトグラフに対して実際に何をしているのかを、実際に作成した一時リポジトリのコミットハッシュとログ出力を使って検証します。「なぜ共有・プッシュ済みのブランチを rebase してはいけないのか」という定石も、伝聞ではなく実際に壊れる様子を再現して確認します。

ブランチ戦略の比較

Git Flow

Vincent Driessen が提唱した戦略で、以下のブランチを使い分けます。

ブランチ目的ライフサイクル
mainリリース済みコード永続
develop開発統合ブランチ永続
feature/*新機能開発一時的
release/*リリース準備一時的
hotfix/*緊急修正一時的
# feature ブランチの作成と完了
git checkout -b feature/user-auth develop
# ... 開発 ...
git checkout develop
git merge --no-ff feature/user-auth
git branch -d feature/user-auth

適するケース: リリースサイクルが明確なプロダクト、複数バージョンの並行保守

GitHub Flow

main ブランチとフィーチャーブランチのみのシンプルな戦略です。

# 1. main からブランチを作成
git checkout -b feature/add-search main

# 2. コミットしてプッシュ
git add .
git commit -m "feat: add search functionality"
git push -u origin feature/add-search

# 3. Pull Request を作成してレビュー

# 4. main にマージ(PR経由)

# 5. デプロイ

適するケース: 継続的デプロイ、Web アプリケーション、小〜中規模チーム

トランクベース開発

全開発者が main(トランク)に直接コミットまたは短命ブランチで開発します。

# 短命ブランチ(1-2日以内にマージ)
git checkout -b fix/typo main
# ... 修正 ...
git checkout main
git merge fix/typo
git branch -d fix/typo

適するケース: CI/CD が成熟したチーム、フィーチャーフラグを活用する組織

比較表

戦略複雑さリリース管理CI/CD 前提チーム規模
Git Flow明確不要
GitHub Flowシンプル推奨小〜中
トランクベース継続的必須任意

rebase vs merge:オブジェクトグラフレベルで何が起きているか

mergerebase の違いを「履歴が保持されるか、線形になるか」という結果だけで覚えると、なぜ「共有ブランチを rebase してはいけない」のかが腹落ちしません。Git のコミットは以下の要素を持つ不変(immutable)オブジェクトであり、そのオブジェクトIDは中身のハッシュ(SHA-1/SHA-256)そのものです。

git cat-file -p <commit-hash>
tree <このコミット時点の全ファイルのスナップショット>
parent <親コミットのハッシュ>          # merge コミットは parent が2行になる
author <名前> <メール> <タイムスタンプ>
committer <名前> <メール> <タイムスタンプ>

<コミットメッセージ>

tree・parent・author・committer・message のいずれか1バイトでも変われば、生成されるコミットのハッシュは完全に別物になります。 これが以下の2つの操作の違いの正体です。

  • git merge: 双方のブランチの先端を親として持つ新しいコミットを1つ作るだけで、既存のコミットは一切変更しません。両ブランチの元のコミットはそのまま残り、parent が2つある特別なコミット(マージコミット)が履歴に追加されます。
  • git rebase: 移動対象の各コミットの差分(パッチ)を取り出し、新しい親の上に新しいコミットオブジェクトとして再生成します。メッセージや差分の内容は同じでも、parent が変わるため tree も変わり、結果としてハッシュが変わります。つまり rebase は「既存のコミットを移動する」のではなく、「元のコミットとは別物の、似た内容のコミットを新規作成し、ブランチの参照先をそちらに付け替える」操作です。元のコミットはどのブランチからも参照されなくなり(到達不能になり)、いずれ git gc で回収されます。

この違いが、**「プッシュ済み・共有済みのブランチを rebase してはいけない」**という定石の根拠です。rebase 後にできる新しいコミットは、rebase 前のコミットとは Git 内部では完全な別オブジェクトです。すでに古いコミットを pull して手元に持っている人がいれば、その人のローカル履歴は「もう存在しないはずの」コミットを指したまま取り残され、次回の同期で新旧のコミットが両方存在する状態(履歴の分岐・重複)になります。これは比喩ではなく、実際に Git のオブジェクトストアで起きていることです。以下ではこれを実際に手元で再現して確認します。

手を動かして検証する:同じ分岐に merge と rebase をそれぞれ適用する

/private/tmp 以下の一時ディレクトリに、mainfeature/add-search が互いに分岐したリポジトリを作成しました。

git init -q -b main
echo "# Demo Project" > README.md && git add README.md && git commit -q -m "chore: initial commit"
echo "console.log('v1');" > app.js && git add app.js && git commit -q -m "feat: add app.js"

git checkout -q -b feature/add-search
echo "function search() { ... }" > search.js
git add search.js && git commit -q -m "feat: add search.js skeleton"
echo "function search(query) { return query.toLowerCase(); }" > search.js
git add search.js && git commit -q -m "feat: implement search logic"

# main はその間に独立して進む
git checkout -q main
echo "console.log('v1'); console.log('logging enabled');" > app.js
git add app.js && git commit -q -m "feat: add logging to app.js"
echo "MIT License" > LICENSE && git add LICENSE && git commit -q -m "chore: add LICENSE"

分岐直後の状態は次のとおりです(git log --graph --oneline --all の実出力)。

* 65a3a39 feat: implement search logic
* 1b2a1aa feat: add search.js skeleton
| * 00e3649 chore: add LICENSE
| * b6db395 feat: add logging to app.js
|/
* 3438623 feat: add app.js
* 0e7b16d chore: initial commit

この状態のリポジトリを丸ごと2部コピーし、一方には merge --no-ff、もう一方には rebase を適用します。

merge –no-ff を適用した場合

git checkout main
git merge --no-ff feature/add-search -m "Merge branch 'feature/add-search' into main"
Merge made by the 'ort' strategy.
 search.js | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 search.js
git log --graph --oneline --all
*   2565f0c Merge branch 'feature/add-search' into main
|\
| * 65a3a39 feat: implement search logic
| * 1b2a1aa feat: add search.js skeleton
* | 00e3649 chore: add LICENSE
* | b6db395 feat: add logging to app.js
|/
* 3438623 feat: add app.js
* 0e7b16d chore: initial commit

git log -1 --pretty='%H%n%P' でマージコミット自体を調べると、実際に親が2つあることが確認できます。

commit 2565f0c682e4d9573472e03d819c9b5b2494a883
parents: 00e3649374dcf04bdc2a914300c656946434ab77 65a3a39a41bec93ba9914516c02ec1c9ddf68c08

65a3a39(feature 側の最終コミット)は rebase と違って変更されずそのままマージコミットの親として残っています。両方の系列の元のコミットオブジェクトはすべて到達可能なままです。

rebase を適用した場合(コピーしたもう一方のリポジトリ)

git checkout feature/add-search
git rebase main
Successfully rebased and updated refs/heads/feature/add-search.
git log --oneline -2

rebase 前後でハッシュを比較すると、同じコミットメッセージ・同じ差分内容にもかかわらずハッシュが完全に変わっていることが分かります。

rebase 前rebase 後
feat: add search.js skeleton1b2a1aabba2bad
feat: implement search logic65a3a392d4998f

git cat-file -p でオブジェクトの中身を直接比較すると、parent が書き換わったことで tree(スナップショット全体のハッシュ)も変わっているのが分かります。

# rebase 前(オリジナルの 65a3a39)
tree 2d4f674e53a54ea506c2e7c0fa7ca43bb660b6fa
parent 1b2a1aa41fe7f2c034c9c5f79dbda3c22ad6a44e
author Demo Dev <demo@example.com> 1784422199 +0900
committer Demo Dev <demo@example.com> 1784422199 +0900

# rebase 後(新規生成された 2d4998f)
tree 7c0f1ecf8a7ff6f69e4aa3bb22706f2b531d16b5
parent bba2bad1aa8078fead764be62a7fa31b80aab87e
author Demo Dev <demo@example.com> 1784422199 +0900
committer Demo Dev <demo@example.com> 1784422210 +0900

author のタイムスタンプ(元のコミット日時)は保持される一方、committer のタイムスタンプ(再生成された時刻)は変わっている点にも注目してください。tree が変わるのは、search.js の差分自体は同じでも、コミットが表す「その時点の全ファイルのスナップショット」が新しい親(LICENSE や logging を含む main)を土台にしているため、スナップショット全体としては別物になるからです。

このあと maingit merge --ff-only feature/add-search すると、履歴は完全な直線(fast-forward)になります。

* 2d4998f feat: implement search logic
* bba2bad feat: add search.js skeleton
* 00e3649 chore: add LICENSE
* b6db395 feat: add logging to app.js
* 3438623 feat: add app.js
* 0e7b16d chore: initial commit

元の 1b2a1aa65a3a39 はどのブランチからも参照されなくなり、到達不能なオブジェクトとして git gc の対象になります(git reflog が残っている間は復元可能です)。

この2つのシナリオを図にすると次のようになります。上段が merge --no-ff(両方の系列のオブジェクトが生き残り、2親を持つマージコミットで統合される)、下段が rebase → fast-forward(元のコミットは灰色で示した「孤立オブジェクト」となり、新しいコミットが main の延長線上に生成される)です。

git merge –no-ff と git rebase + fast-forward のコミットグラフ比較。上段は2つの親を持つマージコミットで両系列のオブジェクトが保持される一方、下段は元のfeatureコミットが灰色の孤立オブジェクトとなり、同じ内容だが異なるハッシュを持つ新しいコミットがmainの延長線上に再生成される様子を示す。

コンフリクトの発生と解決(実例)

同じ行を両方のブランチで変更すると、merge は自動統合できずコンフリクトになります。以下は実際に発生させた例です。

# main と feature 両方で config.js の同じ行(timeout)を変更
git checkout main
sed -i '' 's/timeout: 30/timeout: 60/' config.js
git commit -am "fix: bump default timeout to 60s"

git checkout feature/increase-timeout
sed -i '' 's/timeout: 30/timeout: 120/' config.js
git commit -am "fix: increase timeout to 120s for slow endpoints"

git checkout main
git merge feature/increase-timeout -m "Merge feature/increase-timeout"
Auto-merging config.js
CONFLICT (content): Merge conflict in config.js
Automatic merge failed; fix conflicts and then commit the result.

git status は該当ファイルを both modified として報告し、config.js の中身は実際に次のようなコンフリクトマーカー入りになります。

const config = {
<<<<<<< HEAD
  timeout: 60,
=======
  timeout: 120,
>>>>>>> feature/increase-timeout
  retries: 3,
};
module.exports = config;

<<<<<<< HEAD から ======= までが現在のブランチ(main)側の内容、======= から >>>>>>> feature/increase-timeout までが取り込もうとしているブランチ側の内容です。両者を見て妥当な値(ここでは大きい方の 120 秒)を選び、マーカーを削除してから確定します。

# エディタでマーカーを削除し、正しい内容に書き換えてから
git add config.js
git commit -m "Merge feature/increase-timeout"
*   8c7b331 Merge feature/increase-timeout
|\
| * e003676 fix: increase timeout to 120s for slow endpoints
* | 8ddaa10 fix: bump default timeout to 60s
|/
* 1e39563 chore: initial config

コンフリクトの解決自体は merge でも rebase でも本質的に同じ作業(マーカーを見て手で統合する)ですが、コミットが複数ある rebase ではコミットの数だけこの作業が繰り返される可能性がある点が実務上の大きな違いです。

危険な実例:共有ブランチを rebase して force-push するとどうなるか

「rebase は共有ブランチでやってはいけない」を実際に壊して確認します。ベアリポジトリ origin.git を作り、Alice と Bob という2つのローカルクローンを用意しました。

git init -q -b main --bare origin.git
git clone -q origin.git alice   # Alice の作業コピー
git clone -q origin.git bob     # Bob の作業コピー
  1. Alice が feature/payments ブランチを作り、1コミットして push します。
# alice/
git checkout -b feature/payments
echo "step1" > payments.js
git commit -am "feat(payments): add payment stub"
git push -u origin feature/payments
# → 3f7afae feat(payments): add payment stub
  1. Bob がこのブランチを clone/checkout し、Alice のコミットの上に自分のコミットを積む(まだ push していない)。
# bob/
git checkout -t origin/feature/payments
echo "step2-bob" > invoices.js
git commit -am "feat(payments): add invoice generation on top of Alice's stub"
42b3dbb feat(payments): add invoice generation on top of Alice's stub
3f7afae feat(payments): add payment stub
8af15f5 chore: initial commit
  1. その間に Alice は main に別の更新が入ったのに気づき、自分の feature/paymentsmain の上に rebase して force-push します。
# alice/
git checkout main && git pull   # main に "docs: update README" (84a6378) が追加されている
git checkout feature/payments
git rebase origin/main
Successfully rebased and updated refs/heads/feature/payments.
git log --oneline
5e7dfb8 feat(payments): add payment stub    # ← 同じメッセージだが新しいハッシュ
84a6378 docs: update README
8af15f5 chore: initial commit
git push --force origin feature/payments

もとの 3f7afae は origin 上ではもう feature/payments ブランチから参照されなくなり、代わりに内容は同じだが別オブジェクトの 5e7dfb8 が指されています。

  1. Bob が何も知らずにいつも通り git pull すると、何が起きるでしょうか。
# bob/
git pull origin feature/payments
 * branch            feature/payments -> FETCH_HEAD
 + 3f7afae...5e7dfb8 feature/payments -> origin/feature/payments  (forced update)
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
fatal: Need to specify how to reconcile divergent branches.
git status
On branch feature/payments
Your branch and 'origin/feature/payments' have diverged,
and have 2 and 2 different commits each, respectively.

Git は「force-update された」ことを検出し、単純な pull を拒否しています。ここで Bob が(意味を理解せず)git pull --no-rebase でマージして解決しようとすると、実際に履歴が汚れます。

git pull --no-rebase origin feature/payments
Merge made by the 'ort' strategy.
git log --graph --oneline --all
*   0f75841 Merge branch 'feature/payments' of .../origin into feature/payments
|\
| * 5e7dfb8 feat(payments): add payment stub
| * 84a6378 docs: update README
* | 42b3dbb feat(payments): add invoice generation on top of Alice's stub
* | 3f7afae feat(payments): add payment stub
|/
* 8af15f5 chore: initial commit

git log --oneline --all | grep "payment stub" で確認すると、「payment stub」というコミットが 3f7afae5e7dfb8 の2つ、まったく同じ diff(payments.js に1行追加)で重複して存在しています。

5e7dfb8 feat(payments): add payment stub
3f7afae feat(payments): add payment stub

これが、rebase 済み・force-push されたブランチに対して他の開発者が通常の pull を行った際に実際に起きることです。履歴には無意味なマージコミットと重複コミットが残り、git blamegit log でのトレーサビリティが損なわれます。正しい対処は Bob が状況を理解したうえで、自分の未push作業を退避してから git pull --rebase するか、git fetch + git rebase origin/feature/payments できれいに乗せ直すことです(それでも Bob のコミットのハッシュ自体は変わります)。これが「rebase は自分だけのブランチ、または誰もまだ取得していないことが確実なブランチに限定する」という運用ルールの実体です。

rebase vs merge の使い分けの指針

上記の検証を踏まえた実務的な指針です。

状況推奨理由
フィーチャーブランチの更新(自分しか触っていない)rebase線形な履歴で差分が見やすく、他者への影響がない
共有ブランチへの統合merge --no-ffマージポイントが明確で、両者のコミットが保持される
すでに他者が pull 済みのブランチmerge(rebase しない)rebase は新しいコミットオブジェクトを作るため、他者の履歴と乖離する
1人で開発(push 前)rebase履歴がきれいになり、副作用がない
チームで開発merge or squash安全で理解しやすい

squash merge

フィーチャーブランチの複数コミットを1つにまとめてマージします。squash も内部的には rebase と同様、新しいコミットオブジェクトを1つ作る操作であり、元のフィーチャーブランチの個々のコミットはマージ先には取り込まれません(フィーチャーブランチ自体を残していれば元のコミットは到達可能なままです)。

git checkout main
git merge --squash feature/add-search
git commit -m "feat: add search functionality"

GitHub の PR では「Squash and merge」ボタンで実行できます。

コミットメッセージ規約

Conventional Commits

<type>(<scope>): <description>

[optional body]

[optional footer]
type用途
feat新機能
fixバグ修正
docsドキュメント
refactorリファクタリング
testテスト追加
choreビルド・ツール
git commit -m "feat(auth): add OAuth 2.0 login support"
git commit -m "fix(api): handle null response from payment service"

実践的な運用パターン

フィーチャーブランチのワークフロー

# 1. 最新の main を取得
git switch main
git pull origin main

# 2. フィーチャーブランチを作成
git switch -c feature/new-api

# 3. 定期的に main の変更を取り込む(自分だけが触っているブランチに限る)
git fetch origin
git rebase origin/main

# 4. プッシュ(rebase 後は force-push が必要。共同編集者がいる場合は
#    履歴を破壊しないよう --force-with-lease を使う)
git push --force-with-lease origin feature/new-api

# 5. PR 作成 → レビュー → マージ

--force-with-lease は、リモートの参照が自分が最後に取得した状態から変わっていないことを確認したうえで force-push します。前節の Bob のケースのように、他者が既にリモートに何かを push していた場合はプッシュが拒否されるため、単純な --force よりも事故を防ぎやすくなります。

コンフリクト解決(rebase 中)

# rebase 中にコンフリクトが発生した場合、コミットごとに繰り返される
git rebase main
# ... コンフリクトを手動で解決(マーカーの読み方は前述のコンフリクト解決の実例を参照)...
git add <resolved-files>
git rebase --continue

# rebase を中止する場合
git rebase --abort

2024–2025年の Git アップデートで押さえておきたい点

ブランチ操作まわりで実務に影響する最近の変更を、Web検索で裏付けが取れたもののみ挙げます。

  • git switch / git restore の experimental 表記が撤廃された(Git 2.44、2024年3月リリース)。両コマンドは 2019年の Git 2.23 で git checkout の分割として導入されて以来 experimental 扱いでしたが、約5年間の安定運用実績を経て正式なコマンドとして扱われるようになりました。本記事のブランチ切り替え・作成例でも git checkout -b の代わりに git switch -c を使えます(git checkout は「ブランチ切り替え」と「ファイルの復元」という2つの役割を持つ紛らわしいコマンドですが、switchrestore はそれぞれの役割に特化しています)。
  • recursive マージ戦略が ort の単なるエイリアスになった(Git 2.50、2025年リリース)。デフォルトのマージ戦略はすでに Git 2.33/2.34 で ort に切り替わっていましたが、recursive は独立した実装として残っていました。Git 2.50 以降は -s recursive を指定しても内部的には ort にリダイレクトされるため、実質的に選択肢は1つに統一されています。本記事の実行例で Merge made by the 'ort' strategy. と表示されているのは、この現行デフォルト戦略によるものです。

関連記事

参考文献