この記事はNuco Advent Calendar 2022の7日目の記事です
はじめに
株式会社Nucoでエンジニアをしている@noshishiです。
今回は、ついついその場限りのコマンド実行で乗り越えがちなGitを、コマンドを使わず理解するための記事を書こうと思います。
Gitとは
バージョンを管理し、作業を分散する
Gitは、分散型バージョン管理システムと呼ばれるソースコードの管理システムの1種です。
Gitは、ファイルの変更履歴(バージョン)を記録・追跡することで、過去と現在のファイルを比較し、変更点を明らかにすることで、円滑に開発作業を進めるためのツールです。
また、一度に複数の開発者がファイルを編集できるシステムなので、作業を分散して行うことができます。
Gitを使うということ
まず、みんなで共有できる保存場所(以下、リモートリポジトリ)にあるファイルなどを、手元のパソコン(以下、ローカルリポジトリ)にコピーを作って、新しいコードやファイルを追加・編集します。
そして、ローカルリポジトリからリモートリポジトリへ登録することでファイルを更新していくことです。
完全理解の鍵はイメージ
Gitを扱う上で、重要なのは 「何」から「何」へ・「どんな作業」を行う のかを追うことです。
コマンド操作だけだと、何が起きているかを理解できず、誤ったコマンドを入力する可能性があります。
(もちろん、著者も例外なくやっちまったことある勢です。)
Gitの操作は、操作前と操作後でどんなことが起こっているのかをイメージしよう。
一人で使うこともできるのでたくさん練習してみるのが良いです。
新しい作業を始める
リポジトリ
Gitにおけるリポジトリとは、ファイルを保存しておくための倉庫で、リモートとローカルの二つがあります。
リモートリポジトリは、ソースコードをインターネットのサーバーに置いて、みんなで共有できるリポジトリです。
ローカルリポジトリは、ソースコードを手元パソコンに置いて、自分だけが変更できるリポジトリです。
リポジトリを複製して作業開始
まず、自分の開発環境を用意します。
といっても、どのディレクトリで作業するかを決めるだけです。
例えば、ホームディレクトリでもいいし、普段使っているディレクトリで構いません。
次に、リモートリポジトリからファイルをコピーして持ってきます。
これをcloneといいます。
この段階で一緒に作業ディレクトリが作成されます。
なので、新しいディレクトリを作成しなくていいです。
projectというリモートリポジトリには、first.txtだけ入っていて、そのリモートリポジトリをcloneした時のイメージです。
もちろん最初にローカルリポジトリを作成して、その後にリモートに反映させることもあります。
これはinitializeと呼ばれ、すでに作業しているディレクトリをリポジトリに変換することができます。
(補足)ワーキングディレクトリ
ワーキングディレクトリは、特殊なディレクトリではなく、いつも作業するディレクトリが、Gitによって拡張されるようになったと考えていただけたらと思います。
つまり、Gitが管理する対象のディレクトリ(今回であればproject)は、Gitのステージングエリアやローカルリポジトリと接続できるということです。
ファイルを変更・追加する
リポジトリのソースコードの変更は、ワーキングディレクトリ、ステージングエリアを通して行われます。
実際には、我々がソースコードを変更するのは、ワーキングディレクトリです。
新しく、second.txtというファイルを作成してみます。
次に、ステージングエリアに変更したファイルを追加します。
これをaddと言います。
変更したファイルをローカルリポジトリに反映させる前にワンクッションおくのがGitの特徴です。
なぜこのクッションがあるのか後ほど詳しく説明します。
そして、ステージングエリアで追加された内容をローカルリポジトリへ登録します。
これをcommitと言います。
commitするとステージングエリアは、空になります。
(※12/9追記)厳密には空ではありません。1
ちなみに、commitする際にコメントがかけます。
今回なら、ファイルを追加したので、git commit -m 'add second.txt'と書きましょう。
コミットすると、リポジトリにコミットオブジェクトが作成されます。
コミットオブジェクトを超簡単に説明すると、更新者の情報や変更後のファイルが保存されているデータです。
(このときデータは、差分だけではなく、その瞬間のファイルの状態(スナップショット)全てが保存されます。)
Gitのオブジェクトについては、Gitのオブジェクトの中身を参考にしてください。
リモートリポジトリを変更する
上記作業で、自分の手元の作業は終わりました。
最後に行うのが、ローカルリポジトリの変更をリモートリポジトリへ反映させることです。
これをpushと言います。
リモートリポジトリに対して行うcommitだと考えると分かりやすいかもしれません。
差分をみる
同じファイルの同士の変更点をdiffといいます。
作業の途中で自分が行った変更を確認することができます。
git diffというコマンドを使用します。
コマンドの詳しい説明は省きますが、よく使う3つを紹介します。
addする前に、元のワーキングディレクトリとの変更点をみるgit diff。
addした後に、作業中のワーキングディレクトリとの変更点を見るならgit diff --stage。
コミット同士を比較するならgit diff <commit> <commit>など。
(余談)ステージングエリアというクッション
開発作業が大きくなると、多くの変更を一つのワーキングディレクトリで行うことあります。
全ての変更を一気にローカルレポジトリに登録するとどうなるでしょうか?
この場合、コミットを解析する時に、ある機能をどこで実装したかわからないといったことが起こりうります。
Gitでは、1つの機能につき一つのcommitを行うことが推奨されています。
そのために、commitを行う単位を細かく分けることができるステージングエリアがあるということです。
必要な分だけステージングし、作業を進めたり、先にcommitすることで、実装ごとに履歴を辿れる効率的な開発を進めていこうというのがGitのコンセプトなのです。
まとめ
一度cloneして,作業ごとにadd,commit,pushが基本的な作業の流れです。
clone:リモートリポジトリから自分の開発環境(ローカルリポジトリとワーキングディレクトリ)にコピーを作る。
add:ワーキングディレクトリからステージングエリアにファイルを追加し、コミットの準備をする。
commit:ステージングエリアからローカルリポジトリに登録する。この時、コミットオブジェクトが作成される。
push:ローカルリポジトリからリモートリポジトリへ変更内容を登録する。
ブランチ
ファイルの変更や追加を複数の分岐で作業を行うためにbranchを作ります。
mainブランチで保存しているファイルは、現在進行形で使用されています。
ブランチを分ける理由は、現在稼働しているソースコードに影響を与えることなく作業を行うためです。
新しいブランチを作る
developというブランチを作ってみます。
git branch <new branch>やgit checkout -b <new branch>で作ることができます。
前者はブランチを作るだけ、後者はブランチを作ってそのブランチに移動します。
(ブランチはリポジトリ内で管理されています。)
ブランチを生やす時のポイントは、どのブランチを派生元にするかということです。
派生元をgit checkout -b <new branch> <from branch>として指定することができます。
指定しなければ、現在作業しているブランチが<from branch>になります。
ブランチは、実はコミット(厳密にいうとコミットオブジェクトのハッシュ値)のポインタです。
新しいブランチを生やすということは、派生元のブランチがポイントしているコミットを、新しいブランチも同様にポイントすることを意味します。
ブランチで作業を進める
作業するブランチを移動することをcheckoutすると言います。
現在作業しているブランチのポインタをHEADと呼びます。
つまり、mainブランチからdevelopブランチ移動するというのはHEADを変えることを意味します。
現在は、Atr3ulというコミットを両方のブランチが指しています。
先ほどはsecond.txtをmainブランチでコミットして追加したので、f27bazというコミットからひとつ前に進んでいる状態です。
ここから、developブランチでsecond.txtを変更し、新しいコミットを行うとします。
そうすると図のように、m9sgleという新しいコミットが作成され、developブランチはそのコミットをポイントすることになりました。
現在のHEADの位置(作業ブランチの位置)やファイルがどの段階まで作業を進めたか、あるいは誰がその作業を行なっているかの状態をstatusと言います。
コミットの矢印の理由について、オブジェクト指向の考え方に慣れている方だと分かるかもしれません。
これは「親」コミットと「子」コミットの関係を表しています。
親←-子、つまり親(コミット)から生まれた子(コミット)がどれだけ成長(変化)したかというのが、前提としてあります。
(余談)Git-FlowとGitHub-Flow
ブランチの生やし方や運用は、開発チームごとによって異なると思います。
一方で、プログラミングの命名規則のように、Gitのブランチの生やし方には一般的なモデルが存在します。
簡単に2つを紹介します。こんなものがあるんだな程度でいいと思います。
「Git Flow」は、かなり複雑に入り組んだ構造をしています。
本来のあるべきGitの使い方みたいなモデルかなと思います。
各ブランチの定義
master:プロダクトとしてリリースする用のブランチ。※このブランチ上での作業は行わない
develop:開発用ブランチ。リリース準備ができたらreleaseへマージする。※このブランチ上での作業は行わない
feature:機能の追加用。developから分岐し、developにマージする。
hotfix:リリース後の緊急対応(クリティカルなバグフィックスなど)用。masterから分岐し、masterにマージすると共にdevelopにマージする。
release:プロダクトリリースの準備用。リリース予定の機能やバグフィックスが反映された状態のdevelopから分岐する。
リリース準備が整ったら、masterにマージすると共にdevelopにマージする。
「GitHub Flow」は、Git Flowをやや簡略化したモデルです。
見ての通り、masterとfeatureだけで構成されており、主な違いとしてプルリクエスト(下のプルで説明)というクッションでブランチ間の統合を行います。
まとめ
基本的にmain(master)上で作業することはないので、行いたい作業単位でブランチを作成し、新しいコミットを作成していきましょう。
branch:コミットに対する新しいポインタ
checkout:HEADを移動させて、作業するbranchを変える。
マージ
枝分かれたブランチ同士を統合することをmergeと言います。
基本的に、mainブランチやdevelopブランチに対して統合を行なっていきます。
注意点は、「どのブランチ」が「どのブランチ」を統合(吸収)するかの主語を間違わないことです。
必ず、派生元のブランチに(HEADを)移動して、派生先のブランチからの統合を行うことになります。
現在、featureブランチで作業を行なっていて、下記のような third.txtを作成しました。
Hello, World! I'm noshishi, from Japan.
I like dancing on house music.
そして、addしてcommitまで終えました。
ファストフォワード
featureブランチが、派生元であるdevelopブランチから辿れるコミットを指しているとき、developブランチはfast-forwardな状態と言います。
まずは、checkoutでdevelopへ移動します。
この場合、developブランチは全く進んでいないので、featureブランチをmergeすると、単にコミットを前に進めるだけになります。
この時、developブランチとfeatureブランチは同じコミットを共有することになります。
ノーファストフォワード
もし、developブランチがコミットやマージによって、新しいコミットに進んでいたらどうなるでしょうか?
これをno fast-forwardな状態と言います。
developブランチでは、first.txtを変更を行なってcommitまで終えました。
そのため、developブランチと'featureブランチは、完全に枝分かれてしまいました。
developブランチから、featureブランチをmergeしようとすると、Gitは変更履歴同士を確認します。
もしお互いに競合しあう編集をしていない場合は、すぐにmerge commitが作成されます。
これをAutomatic mergeと呼ばれます。
コンフリクトに対処する
no fast-forward状態で、作業内容が競合していることをconflictと言います。
この場合は、手動でconflictをしている内容を修正し、commitを行います。
developブランチでは、以下のようなthird.txtが作成され、commitされています。
Hello, World! I'm nope, from USA.
I like dancing on house music.
developブランチでは、I'm nope, from USAと書いてあり、
featureブランチでは、I'm noshishi, from Japanと書いてあります。
1行目の内容が競合している状態です。
この時にmergeを行うと、conflictが起こります。
Gitがconflictを解決してからcommitしてくれと指示が出てきます。
(作業場所は`develop`ブランチ)
指示通りにthird.txtを見てみると、以下のような追記がされています。
<<<<<<< HEAD
Hello, World! I'm nope, from USA.
=======
Hello, World! I'm noshishi, from Japan.
>>>>>>> feature
I like dancing on house music.
=======で区切られた上側のHEADがdevelopブランチの内容を表しています。
下側がfeatureブランチを表しています。
まずどちらを採用するかを考え、今回はfeatureブランチでの変更内容を採用することにしました。
その時の作業は、third.txtを手作業で編集(不要な部分を削除)するだけです。
Hello, World! I'm noshishi, from Japan.
I like dancing on house music.
そして次に行うのが、addしてcommitです。
conflictが解消され、新しいmerge commitが作成されます。
初心者が恐怖のコンフリクトですが、これを覚えればもう怖くありません。
mergeを行なってconflictを解消したらなぜもう一度mergeしないのか?と疑問に思うと思います。
mergeを一度実行した時点でdevelopブランチはmerge状態に入り、ブランチ同士の比較しconflictがなければ、新しいファイルを自動でcommitを行い、merge状態が解消されます。
なので、conflictを解消した後に、特別にcommitしているのではないということです。
だからこそmerge commitと呼ばれるわけです。
不要なブランチは削除する
統合されたブランチは、基本お役御免なので、削除していきます。
ブランチを放置しておくと、削除したいブランチから他のブランチに移動して、git branch -d <branch>すればおさらばです。
ちなみに、削除されたらそのブランチのコミットは無くなるのかというとそうではありません。
マージしたブランチにしっかりと引き継がれています。
git logを使用すると、ブランチ内で行なったすべてのコミットおよびマージしたブランチのコミットを閲覧できます。
(余談)ブランチの正体を知りたい
ブランチは、コミットを指すポインタと言いましたが、もうひとつ重要なデータを保持しています。
それは、そのブランチで行ってきたすべてのコミットです。
ブランチは、コミットの集合体であり、なおかつその中で最新のコミットを指すポインタを持っているということです。(厳密に言えば、ポインタしているコミットからそれ以前のコミット辿れるということです。)
図で表すと以下の通りです。
だから、Git Flowの様に横軸でブランチを考えることができるんです。
ちなみに、上の図を横軸にブランチを置いて書いてみるとこうなります。
まとめ
no fast-forward merge with conflict

merge:特定のブランチ(mainやdevelopなど)に、作業用のブランチ(featureなど)を統合(吸収)し、新しいコミットを作成すること。
リベース
各ブランチの派生元のコミットを変えてブランチ同士を統合することをrebaseと言います。
mergeに似ていますが、異なる点は、作業を行うブランチが 「派生先」ブランチということです。
developブランチとfeatureブランチで作業していているとしましょう。
ブランチごとごっそり移動させる
developブランチの現在のコミット、featureブランチに反映させるためには、featureブランチが派生したgp55swコミットから3x7oitコミットに移動させる必要があります。
これはfeatureブランチからgit rebase developとすることで一気に移動できます。
mergeをしているというより、developブランチの最新のコミットからfeatureブランチを生やし直すに近いです。
ただし、コミットごと移動し、新しいコミットを行うことが違いです。
なぜ、こんな移動(統合)をするのかというと、一つは fast-forwardになりいつでもmergeを行いやすいことです。
もう一つは、コミットが一直線になることで容易にコミット履歴を辿れ、ファイルの更新順に整合性を持たせることができるためです。
リベースのコンフリクトに対処する
もちろんrebaseにもconflictが存在します。
上記の場合、featureブランチでは、fourth.txtを追加しましたが、その後developブランチでのコミットでは、fourth.txtに関わる変更がないため、conflictは起こりません。
ですが、以下のように変更内容が被っていた場合、conflictが起こります。
でもmergeと同じように対処すれば大丈夫です。
ただし、差分を確認してファイルの編集を終えたら、git rebase --continueで作業を終わりましょう。
commitしなくても自動でコミットしてくれます。
rebase:ブランチの派生元であるコミットを移動させて新しいコミットを行うこと。
ローカルリポジトリを最新にする
ローカルである程度作業を進めると、リモートリポジトリが他の開発者によって更新されている場合があります。
この場合において、リモートリポジトリの情報を再度ローカルリポジトリに反映させるために行うのがpullです。
ブランチとリポジトリ
ブランチは、各リポジトリに保存されています。
実際に作業を行うブランチです。
一方で、ローカルリポジトリには、リモートリポジトリをコピーしたブランチがあります。
これは「リモート追跡ブランチ」と呼ばれます。
remotes/<remote branch>でリモートのブランチと紐づく名前のブランチです。
これは、あくまでもリモートリポジトリを監視しているに過ぎません。
最新の状況を確認する
リモートリポジトリのdevelopブランチがリモート追跡ブランチより一つ進んでいる状況だったとします。。
リモートリポジトリのブランチの最新の状況をリモート追跡ブランチに反映させることをfetchと言います。
最新の状態に更新する
もう少し踏み込んで、ローカルブランチにも反映させたい場合、pullを行います。
pullするとまず、ローカルのリモート追跡ブランチが更新されます。
その後にローカルブランチにmergeを行います。
今回は、developブランチの一つ先に進んだコミットがあったので、ローカルブランチのdevelopブランチにmergeして新たなコミットが作成されました。
プルのコンフリクトに対処する
リモートリポジトリのコミットで行われた変更と、ローカルリポジトリのコミットで行った変更が競合してしまった時、pullしたときにリモート追跡ブランチとローカルブランチでconflictが起こります。
下記の場合、remotes/developとdevelopブランチが競合しています。
pullは、fetchとmergeなので、mergeのconflictと同じ対処方法で解決できます。
今回はdevelopがremotes/developをmergeするので、作業ブランチはdevelopです。
原因のフォルダを開いて、修正したらcommitを行いましょう。
リモート追跡ブランチとmergeって少し不思議に感じると思います。
というのも、基本的にローカルブランチと同じになっていることがほとんどだから直接現れてこないためです。
git logでコミットを辿ると面白いことがわかります。
da7a... (HEAD -> develop, origin/develop)
実はリモート追跡ブランチは、ローカルブランチと並行しているブランチだったことがわかります。
(余談)プルリクエストの正体
基本的にリモートとローカルの関係は、リモートリポジトリからローカルリポジトリにpullし、ローカルリポジトリからリモートリポジトリにpushすることになります。
ですが、GitHubをはじめとするサービスには、ローカルリモートリポジトリ内にあるブランチから、mainブランチのようなブランチにmergeする前にrequestを送るという仕組みをとっています。(※12/9追記)ローカルから直接反映する訳ではありません。
というのは、開発者の個人の判断mainブランチなどにpushして、リモートリポジトリを更新してしまうと誰もチェックできずに大きな障害が発生する可能性があります。
一旦上位の開発者がコードをレビューするプロセスを挟むのがpull requestです。
pull:fetch + merge。pullは、リモートリポジトリの状態をローカルリポジトリに反映させること。
ここでのmergeは、ローカルブランチにリモート追跡ブランチを統合すること。
便利な機能
コミットを訂正する
指定した(あるいは直前の)コミットを訂正するためのcommitをrevertと言います。
例えばm9sgLeでsecond.txtをローカルリポジトリに追加したとしましょう。
revertを行うと、新しいpgmx9sというコミットを作成しsecond.txtはローカルリポジトリの登録から削除します。
revertの良さは、commitを残せることです。後ほど紹介するresetと区別しましょう。
作業内容を避難させる
変更ファイルがある状態で他のブランチに移動すると変更内容を保持したまま移動したり、あるいは移動できないことがあります。
そのため、commitまでするか変更を破棄してしまうかを選択しなければなりません。
そんな時に活躍するのがstashです。
ワーキングディレクトリやステージングエリアにあるファイルを一時避難させることができます。
他のブランチに移動したい時、stashし、帰ってきたらstash popで避難したファイルを取り戻して作業を再開します。
特定のコミットを持ってくる
任意のコミットを現在のブランチに持ってきてコミットを作ることをcherry-pickと言います。
まさにいいとこ取りのような機能です。
以前にfeatureブランチのzvcd2eコミットで実装した〇〇〇な機能だけ持ってきて、現在developブランチの作業に使用したいときなどに使用します。
HEADを使いこなす
HEADは、現在作業中のブランチのポインタと説明しました。
また、ブランチはコミットを指すポインタだ、とも説明しました。
下の図を見てください。
HEADが指すものはdevelopブランチ、developブランチが指すものeaPk76というコミットです。
つまり、この状況でのHEADは、eaPk76のコミットを指しているということになります。
よくGitのドキュメントや記事などに、コマンドの後ろにHEADを使うのを見たことがありませんか?
例えば、git revert HEADなど。
これは、HEADからコミットを辿れるからこそ実現できるコマンドという分けです。
(おまけ))コミットを削除する
現在の最新のコミットを取り消してもう一度作業することをresetといいます。
--softオプションを使用するとaddした直後に戻ることができます。
--mixedオプションを使用すると、ワーキングディレクトリで作業していた段階に戻ることができます。
--hard <commit>オプションを使用すると、戻るコミット地点までのすべてのコミットを削除し、指定コミットにHEADを移動させます。
resetはコミットログから完全にコミットが削除されるので、注意して使用しましょう。(12/9追記)2
もっとGitを知る
Git以外のソースコードの管理
Gitと同じ歴史を持ったMercurialというサービスがあります。
特徴は、Gitのような柔軟性を犠牲に非常にシンプルなコマンドラインインターフェース(CLI)を採用していることです。
最近だと、このMercurialをベースにMeta社がSaplingという新しいソースコード管理システムをオープンソースで公開されましたね。
また今度、ちょっと触ってみて感想を書いてみたいなと思います。
リモートリポジトリの居場所
リモートリポジトリ用のサーバーを貸してくれるサービスをホスティングサービスと言います。
代表的なものであれば、GitHub, Bitbucket、プライベートに使用するAws Code Commitなどがあります。
GitとGit Hubは、全く別物です。
ちなみに、上で書いた通り、リモートリポジトリ用のサーバーは自分達のサーバーでも大丈夫です。
ポインタ
C言語のようなメモリを直接扱うプログラミングに触れたことがある方は、なんとなく「ポインタ」の意味がわかると思います。
一方で、初学者の方にとって、すごく曖昧なものに感じると思います。
コミットオブジェクトは、リポジトリ内に保存されていると言いました。
リポジトリ内に、たくさんのコミットオブジェクトが溢れていたら、どのように欲しいオブジェクトを選ぶことができるでしょうか。
それは、特定のコミットオブジェクトありかを突き止めるラベル(住所)が必要になります。
「ポインタ」は、そのラベルを忘れないように指差してくれる貴重なデータというわけです。
ちなみにラベルは、ハッシュ関数と呼ばれる関数を通じて不思議な文字列へと変換されたものを使います。
気になる方は、Gitのハッシュ値の求め方を参考にしてください。
さらにGitを理解するために
この記事で言及できなかったことがたくさんあります。
- Gitのコアの部分はシンプルなキー・バリュー型データストア
- バリューとなるGitオブジェクトの詳細
- オブジェクトの扱い方
いつか完全攻略したいと思います。
お礼
最後まで読んでいただきありがとうございました。
この記事の作成を通して、本当の意味でGitと向き合えました。
まだまだ見習いエンジニアなので、なんとか小手先で解決したくなるGitのコマンドでしたが、最近ではイメージが湧いてくるのでコマンドが楽しくなってきました。誰かにとって同じような体験となれば幸いです。
GitHubでも公開しています。
GitHub上ではコマンドあり版も記載していく予定なので、もしよければご覧ください。
参考サイト
- Git Documentation
- Learn git concepts, not commands
- 図解 Git
- いまさらだけどGitを基本から分かりやすくまとめてみた
- git add ってなんのためにやるの?
最後に
弊社では、経験の有無を問わず、社員やインターン生の採用を行っています。
興味のある方はこちらをご覧ください。
-
※追記 ステージングエリアのファイル内容も、実はリポジトリに保存されています。ですが、説明のわかりやすさを優先し、空のボックスで表現しています。もしgitのリポジトリを詳しく見たい方は、プロジェクトのディレクト内にある、.gitフォルダを見てみてください!とても興味深いことがわかります。Git Documentation ↩
-
gitのリポジトリは、完全に削除することを基本的に許していないので、実はオブジェクトは残り続けています。ただし、
git logでは確認できないので、git reflogで操作履歴から辿りましょう。もしresetしてしまったコミットを復活したい場合、有効な手立ては残されています! ↩



