Git、ブランチ、Pull Request — チームの進め方
git init から push・pull まで、.gitignore で .venv と .env を除外する方法、小さな機能ブランチ、きれいなコミットメッセージ、Pull Request。そして git log を読んでコミットをチェックする Python ツールまで。
どうやって学びますか?
解決する問題
AI エンジニアが、チケットのトリアージ用のプロジェクトに 1 週間取り組みました。プロンプトを変え、キーワードリストを増やし、関数を 1 つ書き直しました。ところが金曜日の結果は、月曜日より悪くなっています。どの変更が原因でしょうか? フォルダには triage.py、triage_v2.py、triage_final.py、triage_final_REAL.py があり、どのファイルで何が変わったのか、誰にも分かりません。
その間に、共有フォルダで同じファイルを編集していた同僚が、自分の作業を消してしまいました。さらに 1 か月前には、誰かが簡単なテストのために API キーを直接コードに貼りつけ、そのフォルダを通じて、ほかに 10 人の手に渡ってしまいました。
この 3 つの問題には、答えが一つあります。世界のほとんどのソフトウェアチームが使っている方法です。Git で履歴を残し、作業は一つずつ自分のブランチで行い、メインのコードに入れる前に、別の人が Pull Request でレビューします。この章では、その一連の流れをすべて学びます。そして最後には、レビュアーの仕事の一つを自動化する Python ツールを書きます。
この章を終える頃には
git init、add、commit、push、pullで、プロジェクトの履歴を残し、共有できる.gitignoreで.venvと.envをリポジトリから除外し、誤ってコミットされたときの対処法を知っている- 小さく短命な機能ブランチで作業し、分かりやすい Pull Request を開ける
- レビュアーが読みやすいように、コミットメッセージを書ける
subprocessを使ってgit logを読み、コミットメッセージをチェックする Python ツールを書ける
前提条件: AI エンジニアのトラックと、uv による開発環境 — 特に uv init と uv run。
最初の設定
Git は、すべてのコミットを誰が作ったかを記録します。そこで最初に一度だけ、自分の名前を Git に教えておきます。また、新しいリポジトリのメインブランチを main という名前にするよう設定しておくと良いでしょう。今では多くのチームがそうしているからです。
git config --global user.name "Nadia Karim"
git config --global user.email "[email protected]"
git config --global init.defaultBranch main名前とメールアドレスには、自分のものを使ってください。メールアドレスは、コードホスティングサービスのアカウントに登録したものにします。最後の行がないと、古いバージョンの Git ではメインブランチが master という名前になります。それでも動くことは動きますが、チームのほかの人とそろいません。
3 つの場所:フォルダ、ステージング、履歴
前の章で uv init を実行したとき、Git リポジトリは自動的に作られました。この章のサンプルプロジェクトも同じように始めます:
uv init ticket-triage --python 3.12
cd ticket-triageGit は 3 つの場所で考えます。そして、どのコマンドも、何かを一つの場所から別の場所へ動かします。
- 作業ツリー — ファイルを編集する、自分のフォルダ
- ステージングエリア — 次のコミットに入るものの一覧。
git addで、ここに入れます - 履歴 — コミットの連なり。
git commitが、ステージングエリアの内容を永続的なコミットにします
なぜステージングという段階が別にあるのでしょうか? 作業の途中では、5 つのファイルを変更しているかもしれません。そのうち 2 つはバグの修正、3 つは機能の追加だとします。ステージングを使うと、それを 2 つの別々の、意味のあるコミットに分けられます。これはレビュアーにとって、とても価値があることです。
作業の状態を確認するコマンドは git status、短い形式なら git status --short です。
.gitignore:リポジトリに決して入れないもの
AI プロジェクトには、API キーを入れた .env ファイルがほぼ必ずあります。たとえば、プロジェクトに次のようなファイルができたとします(ここの sk-... はただのプレースホルダーで、本物のキーではありません):
LLM_API_KEY=sk-...uv init が作る .gitignore には .venv は入っていますが、.env は入っていません。そこで最初の作業は、末尾に追加することです:
# Secrets
.envでは一度 uv run main.py を実行して(.venv と uv.lock が作られます)、状態を確認しましょう:
git status --short
?? .gitignore
?? .python-version
?? README.md
?? main.py
?? pyproject.toml
?? uv.lock?? は、Git がそのファイルを見てはいるけれど、まだ追跡していないことを意味します。重要なのは、一覧に ない ものです。.venv も .env もありません。どちらもフォルダにはありますが、Git は見えないふりをしています。(.venv には実は 2 重の保護があります。uv が環境を作るとき、中に * だけが書かれた小さな .gitignore も一緒に置くのです。)そして uv.lock はあります。これはコミットすべきものです。同僚がまったく同じ環境を得るための手段だからです。
では、同僚はどのキーが必要か、どうやって分かるのでしょうか? 一般的な方法は、値は入れずに名前だけを書いた .env.example ファイルをコミットすることです。新しく参加した人は、それを .env にコピーして、自分のキーを書き込みます。
一度コミットされた秘密情報は、次のコミットで消しても取り除かれません。 Git の役目は、それ以前のすべての状態を覚えておくことです。ですから、そのキーは以前のコミットに永遠に残ります。誰でもgit showで取り出せます。キーが一度でもリモートに push されたら、安全な方法は一つだけです。そのキーを発行したサービスへ行き、キーを無効化して、新しいものを作ることです。だからこそ、.envは、そのファイルを作る 前に.gitignoreに入れておく必要があるのです。
最初のコミットと、良いコミットメッセージ
git add .
git commit -m "chore: scaffold project with uv"
[main (root-commit) 0f78e59] chore: scaffold project with uv
6 files changed, 35 insertions(+)
create mode 100644 .gitignore
create mode 100644 .python-version
create mode 100644 README.md
create mode 100644 main.py
create mode 100644 pyproject.toml
create mode 100644 uv.lock0f78e59 は、そのコミットを識別する番号です(ハッシュの先頭部分)。時刻と作成者もハッシュに混ぜられるため、みなさんのマシンでは違う値になります。
コミットメッセージは、未来の読み手のために書きます。6 か月後のあなた、そして今日のレビュアーです。多くのチームは、Conventional Commits と呼ばれる共通のスタイルに従います:
type(scope): short summary in the imperative- type は、変更の種類を表します。
feat(新しい機能)、fix(バグの修正)、docs(ドキュメント)、refactor(リファクタリング)、test(テスト)、chore(保守作業) - scope は省略可能です。丸括弧に入れて、プロジェクトのどの部分かを書きます。例:
feat(triage): ... - summary は命令形で書きます("add"、"fix"、"remove" — "added" ではなく)。72 文字以内で、末尾にはピリオドを付けません
並べてみると、違いは一目瞭然です:
wip
fixed stuff
Fixed the bug where priority returned normal for tickets that say OUTAGE in capitals
fix(triage): lowercase ticket text before keyword matching最初の 3 つは、どれもエラーにはなりません。Git はどんなメッセージでも受け付けます。しかし git log では、最初の 2 つは何も伝えず、3 つ目は長すぎて、1 行表示では途中で切れてしまいます。最後のものを読めば、レビュアーは何が、どこで変わったのかが分かります。「なぜ」の説明に余裕が必要なら、空行を挟んで、メッセージの本文に書きます。
ブランチ:作業ごとに自分の部屋を
main ブランチは、チームにとって最も大切なものです。そこにあるものは動き、いつでもデプロイできる状態でなければなりません。ですから、誰も main で直接作業しません。タスクごとに、新しいブランチを作ります:
git switch -c feat/priority-score
Switched to a new branch 'feat/priority-score'ここで triage.py を書きます:
def priority(text: str) -> str:
urgent_words = ("outage", "down", "breach")
if any(word in text for word in urgent_words):
return "high"
return "normal"git add triage.py
git commit -m "feat(triage): add keyword-based priority"
[feat/priority-score ded4456] feat(triage): add keyword-based priority
1 file changed, 5 insertions(+)
create mode 100644 triage.pyブランチ名も同じスタイルに従います:feat/...、fix/...、docs/...。一覧を見れば、それぞれが何なのか一目で分かります。
ブランチ戦略について一言。 かつては、多くのチームが develop、release、hotfix といった、長く残るブランチをいくつも持ち、複雑なルールに従ってコードを行き来させていました。今では、もっと単純な方法を選ぶチームがほとんどです。main を一つだけ置き、そこから小さく短命な機能ブランチを切って、1、2 日以内にレビューしてマージし直します。ブランチが離れている期間が長いほど、main は先に進み、戻すときの衝突(コンフリクト)も大きくなります。このコースは、単純な方法に従います。
リモート:push と pull
ここまでは、すべて自分のマシンの中での作業です。チームと共有するには、リモートリポジトリが必要です。通常は GitHub などのサービス上にあります。そこでリポジトリを作ると、アドレスが得られ、次のように書きます:
git remote add origin https://github.com/<your-account>/ticket-triage.gitこの章の出力は、オンラインのサービスを使わずに確認されました。隣のフォルダに「ベア」リポジトリを作り、それをリモートとして使いました。Git にとっては、どちらも同じものです。自分のマシンだけでも、この方法で練習できます:
git init --bare ../remote.git
git remote add origin ../remote.git最初の push では、このブランチをどのリモートブランチに紐づけるかを示すために -u が必要です:
git push -u origin main
branch 'main' set up to track 'origin/main'.
To ../remote.git
* [new branch] main -> main機能ブランチも同じように進みます:
git push -u origin feat/priority-score
branch 'feat/priority-score' set up to track 'origin/feat/priority-score'.
To ../remote.git
* [new branch] feat/priority-score -> feat/priority-scoreでは、同僚がリポジトリを clone します:
git clone remote.git teammate
Cloning into 'teammate'...
done.同僚のフォルダには README.md、main.py、pyproject.toml、uv.lock(と隠しファイル)がありますが、.venv と .env はありません。これで正しいのです。同僚は uv sync を実行して自分の環境を作り、.env.example から自分の .env を作ります。
機能が main にマージされたあと(実際の作業では、Pull Request がマージされたときです。後述します)、同僚は新しい状態を fetch します:
git pull
Updating 0f78e59..5f802b4
Fast-forward
triage.py | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 triage.py(実際の出力は、リモートのフルパスを示す From ... の行から始まりますが、ここでは省略しています。)Fast-forward は、同僚自身の新しいコミットが一つもなかったことを意味します。そのため Git は、同僚の main を前に進めるだけで済みました。
Issue と Pull Request
会社では、作業は通常 Issue から始まります。Issue とは、コードホスティングサービス上に書かれた、番号付きの問題の説明です。たとえば、#12 — 大文字のキーワードが検出されない(「OUTAGE」と書かれたチケットが通常扱いになる) のようなものです。
そのあとの流れは、次のとおりです:
mainから分岐したブランチを作る:git switch -c fix/uppercase-keywords- 小さく、意味のあるコミットを積む
- ブランチを push する
- サービスのウェブサイト上、またはコマンドラインから Pull Request(PR)を開く。「このブランチの変更を
mainに取り込んでください」という依頼です - レビュアーがコメントし、あなたは同じブランチへの新しいコミットで返答する
- 承認され、自動チェック(CI)が通ったら、PR がマージされ、ブランチは削除される
GitHub のコマンドラインツール gh を使うと、手順 4 は次のようになります(出力はアカウントとリポジトリによって変わるため、ここには載せていません):
gh pr create --base main --title "fix(triage): detect uppercase keywords" --body-file PR.md分かりやすい PR の説明文(PR.md)は、レビュアーの 3 つの質問に答えます。何を、なぜ、そしてどうテストしたか、です:
## What
Lowercase ticket text before matching urgent keywords.
## Why
Tickets written in capitals ("OUTAGE") were scored as normal.
Closes #12
## How I tested
priority("Service DOWN") returned "normal" on main, "high" on this branch
uv run check_commits.py -> 0 of 2 commit messages need workCloses #12 の行は、ちょっとした便利機能です。PR がデフォルトブランチにマージされると、サービスが Issue #12 を自動的に閉じてくれます。
メンターはどうレビューするか
このコースのすべての実習は、レビュアーの目線で見られます。会社では、次のような点が問題になります。
- PR は小さく、目的が一つか? 機能、バグ修正、リファクタリングのどれか一つだけ。3 つ一緒ではなく。数百行を超えると、人は長い差分に注意を保てず、レビューの質が下がります。
- コミットメッセージは、履歴として読めるか?
wipやfixばかりの履歴は、誰の役にも立ちません。 - 入ってはいけないものが入っていないか?
.venv、.env、__pycache__、大きなデータファイル。どれかが差分に現れたら、PR は差し戻されます。 - 入っているべきものが入っているか? 新しいパッケージを追加したなら、
pyproject.tomlとuv.lockの変更が差分に含まれていなければなりません。 - 説明文に、どうテストしたかが書かれているか?
最後のコミットのメッセージが間違っていたら、push の前に変更できます:
git commit --amend -m "docs: describe the project in README"
git log --oneline -1
a18cc1a docs: describe the project in READMEpush したあとにコミットを変えるのは、履歴を書き換えることになり、同僚に迷惑がかかります。その場合は、新しいコミットを作る方が良いでしょう。多くのチームは、PR を「スカッシュ」でマージします。ブランチのすべてのコミットを一つにまとめ、PR のタイトルがそのメッセージになります。その場合、PR のタイトルも同じスタイルに従う必要があります。
完全な例
レビュアーの仕事の一つは、機械に任せることができます。ブランチ上のすべてのコミットメッセージが、ルールに従っているかどうかを確認することです。チームは CI でこのようなチェックを実行するので、PR を開くとすぐに結果が表示されます。下の check_commits.py は、まさにそれを行います。git log を実行し、現在のブランチのコミット(main にはない、ブランチ独自のコミット)だけを読みます:
import re
import subprocess
import sys
ALLOWED_TYPES = {"feat", "fix", "docs", "refactor", "test", "chore"}
SUBJECT = re.compile(r"^(?P<type>[a-z]+)(\([a-z0-9-]+\))?: (?P<summary>.+)$")
MAX_LENGTH = 72
VAGUE = {"wip", "fix", "update", "changes", "stuff", "misc"}
def problems_in(subject: str) -> list[str]:
found = []
if subject.strip().lower() in VAGUE:
found.append("too vague for a reviewer")
if len(subject) > MAX_LENGTH:
found.append(f"longer than {MAX_LENGTH} characters ({len(subject)})")
match = SUBJECT.match(subject)
if match is None:
found.append("missing 'type: summary' prefix")
elif match["type"] not in ALLOWED_TYPES:
found.append(f"unknown type '{match['type']}'")
if subject.endswith("."):
found.append("ends with a period")
return found
def branch_subjects(base: str) -> list[str]:
result = subprocess.run(
["git", "log", "--reverse", "--format=%s", f"{base}..HEAD"],
capture_output=True,
encoding="utf-8",
)
if result.returncode != 0:
raise SystemExit(f"git log failed: {result.stderr.strip()}")
return result.stdout.splitlines()
def main() -> int:
subjects = branch_subjects("main")
failures = 0
for subject in subjects:
problems = problems_in(subject)
print(("ok " if not problems else "FAIL") + " " + subject)
for problem in problems:
print(f" - {problem}")
failures += 1 if problems else 0
print(f"{failures} of {len(subjects)} commit messages need work")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())テストするために、main から分岐したブランチに 5 つのコミットを作りました。わざと悪いメッセージのものも含みます。(--allow-empty は、どのファイルも変えずにコミットを作るオプションです。メッセージのチェックにはそれで十分です。実際の作業では、すべてのコミットにコードの変更が含まれています。)
git switch -c fix/uppercase-keywords
git commit --allow-empty -m "wip"
git commit --allow-empty -m "Fixed the bug where priority returned normal for tickets that say OUTAGE in capitals"
git commit --allow-empty -m "fix(triage): lowercase text before matching."
git commit --allow-empty -m "feature: add tests"
git commit --allow-empty -m "test(triage): cover uppercase keywords"リポジトリの中で、次を実行します:
uv run check_commits.py出力は次のとおりです:
FAIL wip
- too vague for a reviewer
- missing 'type: summary' prefix
FAIL Fixed the bug where priority returned normal for tickets that say OUTAGE in capitals
- longer than 72 characters (84)
- missing 'type: summary' prefix
FAIL fix(triage): lowercase text before matching.
- ends with a period
FAIL feature: add tests
- unknown type 'feature'
ok test(triage): cover uppercase keywords
4 of 5 commit messages need workプログラムの終了コードは、少なくとも 1 つのメッセージが失敗したため 1 になります。すべて問題なければ 0 です。
このスクリプトの判断ポイントは、次のとおりです。
main..HEAD — この 2 つのドットの範囲指定は、「HEAD にあって main にないコミット」という意味です。つまり、このブランチ自身の作業だけです。履歴全体をチェックすると、何年も前に別の人が書いたコミットのせいで、みなさんの PR が失敗してしまいます。--reverse は、コミットを古い順、つまり作業が行われた順に並べます。
--format=%s — %s は、コミットの 1 行目(件名)だけを意味します。ハッシュ、日付、作成者は含みません。だから解析するものは何もなく、1 行がそのまま 1 つのメッセージになります。
encoding="utf-8" — text=True を使うと、Python はオペレーティングシステムの既定のエンコーディングを使います。それは Windows では UTF-8 でないことがよくあります。コミットメッセージにベンガル語や絵文字が含まれていると、出力が壊れることがあります。エンコーディングを明示すると、その不確かさがなくなります。
check=True ではなく、独自のメッセージ — git が失敗したとき(リポジトリの外で実行した場合や、main という名前のブランチがない場合)、スクリプトは長い Python のトレースバックではなく、git 自身のエラーメッセージを表示します。SystemExit に文字列を渡すと、Python はそれを標準エラー出力に表示し、終了コード 1 で止まります。
終了コード — CI は出力を読みません。プログラムが 0 で終わったかどうかだけを見ます。そこで main() は数値を返し、sys.exit がそれをオペレーティングシステムに渡します。
うまくいかないとき
Author identity unknown のあとに fatal: unable to auto-detect email address Git は、コミットに誰の名前を付ければいいか分かりません。この章の最初にある git config --global user.name ... と user.email ... の 2 つのコマンドを、一度実行してください。
error: src refspec main does not match any まだコミットがないため、main ブランチは実際には存在しません。push するものがないのです。先に git add と git commit を行い、それから push してください。ブランチが実際には master という名前の場合にも、同じメッセージが出ます。git branch で、今どのブランチにいるかを確認してください。
fatal: The current branch feat/x has no upstream branch. 新しいブランチを初めて push しようとしていて、Git はどのリモートブランチへ送ればよいか分かりません。メッセージ自体が直し方を示しています。git push --set-upstream origin feat/x(短くは git push -u origin feat/x)です。一度そうすれば、あとは普通の git push で済みます。
! [rejected] main -> main (fetch first) 最後に fetch したあと、同じブランチに別の人が push しています。まず git pull で同僚の作業を取り込み、それから push してください。git push --force で「直そう」としてはいけません。同僚の作業が消えてしまいます。
warning: in the working copy of 'main.py', LF will be replaced by CRLF the next time Git touches it Windows では、これはエラーではなく警告です。Windows と macOS/Linux では、行末の文字が異なります。Git は、次に触るときにそれを揃えると伝えているのです。コミットは問題なく完了しています。
.env を誤ってコミットしてしまった git rm --cached .env を実行してください。これはファイルをディスク上に残したまま、Git の追跡からだけ外します(出力は rm '.env')。そのあと .env を .gitignore に追加し、コミットします。ただし、キーは以前のコミットにまだ残っていることを忘れないでください。すでに push されていたら、キーを無効化して新しいものを作る以外に安全な方法はありません。
ステップ 4 / 6 — 予想する
理解度をチェック
3 つのメッセージを、この章の SUBJECT パターンと照合します。何が表示されるでしょうか?
import re
SUBJECT = re.compile(r"^(?P<type>[a-z]+)(\([a-z0-9-]+\))?: (?P<summary>.+)$")
for subject in ["fix: handle empty input", "Fix: handle empty input", "fix:handle empty input"]:
print(SUBJECT.match(subject) is not None)- A`True`、`False`、`True`
- B`True`、`True`、`True`
- C`True`、`True`、`False`
- D`True`、`False`、`False`
同僚が check_commits.py のこの関数を「修正」しました。すると、wip と feature: add tests のコミットがあるブランチでも CI は緑になり、出力は 0 of 0 commit messages need work になります。バグはどこにあるでしょうか?
import subprocess
def branch_subjects(base: str) -> list[str]:
result = subprocess.run(
["git", "log", "--reverse", "--format=%s", f"HEAD..{base}"],
capture_output=True,
encoding="utf-8",
)
if result.returncode != 0:
raise SystemExit(f"git log failed: {result.stderr.strip()}")
return result.stdout.splitlines()- A範囲が逆になっている。`HEAD..main` は「`main` にあって、このブランチにないもの」を意味するので、このブランチ自身のコミットは一つも返ってこない。正しくは `main..HEAD` であるべき
- B`--reverse` がコミットを捨てている。取り除けば表示される
- C`returncode` のチェックがすべてのコミットを捨てている。必要なのは `check=True`
- D`encoding="utf-8"` を付けると `git` は何も出力しない。必要なのは `text=True`
PR の説明に Closes #12 と書かれています。これは何をしますか?
- APR がデフォルトブランチにマージされると、コードホスティングサービスが Issue #12 を自動的に閉じ、二つを紐づけたままにする
- BPR を開いた時点で、Issue #12 が閉じられる
- C何もしない — レビュアーへのメモにすぎず、それを読む機械はない
- DGit が履歴から 12 番目のコミットを削除する
プレイは Pro の機能です
+30 XP が待っています
Pro でこの章をプレイ
読むのは無料で、章はすべて公開されています。無料コースでは、最初の 5 章を無料でプレイできます。第 6 章からは、問題への回答、XP の獲得、連続記録の更新が Pro の機能になります。
Pro でプレイを解放やってみよう
自分のマシンで、プロジェクトを一つ作り、一連の流れを一度通してみてください。
uv init my-triage --python 3.12でプロジェクトを作り、.envを.gitignoreに追加し、.env.exampleファイル(中身はLLM_API_KEY=だけ)を置きます。git status --shortに.envや.venvが表示されないことを確かめ、最初のコミットを作ります- ベアリポジトリをリモートとして追加し(
git init --bare ../my-triage-remote.git)、mainを push します feat/priority-scoreという名前のブランチを作り、この章のtriage.pyとcheck_commits.pyを、ルールに従った 3 つのコミットに分けて追加します。そのブランチを push しますPR.mdを書きます(What、Why、How I tested)。コードホスティングのアカウントがあれば、この説明文で実際に PR を開いてください
ブランチ上で uv run check_commits.py を実行すると、3 行の ok のあとに、ちょうど次の行で終わるはずです:
0 of 3 commit messages need workそれから、わざと悪いコミットを作ってみてください(git commit --allow-empty -m "wip")。もう一度実行して、どの 2 つの問題が検出されるかを確かめ、git commit --amend -m "..." で直します。あるいは、次を試してください。スクリプトの main..HEAD を HEAD..main に置き換えて、何が表示されるかを見て、それが CI チェックにとって最も危険なミスである理由を考えてみてください。