Git hunk 変更の一部をステージしてコミット git add -p

インタラクティブに変更の一部分のみをステージに追加することができる git add -p (Hunk)の使い方についての覚書です。

関連ページ

作成日:2022年8月30日

hunk(ハンク)を使ってみる

git add コマンドを実行すると変更をファイル単位でステージングしますが、-p オプションを指定すると、ファイル内の変更部分を確認しながら変更の一部分のみをステージングすることができます。

その際、Git は変更を行ごとに分析し、変更をひとまとまりの hunk(ハンク)として扱います。

英語の hunk には「塊(かたまり)」などの意味があります。

git add -p

git add -p(または git add --patch)を実行すると、Git は変更を hunk と呼ばれる小さな塊に分割して1つずつ表示し、対話的にユーザーがどの部分をステージングするかを選択することができます。

ファイルを指定せずに実行すると、ワーキングツリーの全ての変更を対象とします。

% git add -p // または git add --patch

特定のファイルを対象にする場合は、ファイル名を指定します。

% git add -p ファイル名

コマンドを実行すると、分割された変更部分(hunk)が順番に表示され、それぞれについてステージするかを聞かれるので、表示される選択肢からアクションを選択します。

Stage this hunk [y,n,q,a,d,s,e,?]?  // 表示されたハンクをステージするかどうかの選択肢

それぞれのハンクに対して以下のようなアクションを選択できます。

アクション 説明
y 表示されたハンクをステージングする(Yes)
n 表示されたハンクをステージングしない(No)
q 終了する(Quit)
a 表示されたハンクと残りすべてのハンクをステージングする(All)
d 表示されたハンクと残りのハンクをステージしない
s 表示されたハンクを更に小さなハンクに分割する(Split)
e 表示されたハンクを手動で編集する(Edit)
? ヘルプを表示する

キーボードの入力を使ってハンクをステージングするかどうかを選択します。

ステージングする場合は y を、スキップする(ステージングしない)場合は n を押します。

操作を中止する場合は q を押します。

必要に応じて、ハンクを編集するためのオプションもあります。

例えば、s を押すと、ハンクを更に小さなハンクに分割することができます。

また、e を押すと、ハンクの中身を編集するためのエディタが開くので、変更内容の一部を選択するなど変更を手動で調整できます。

インタラクティブモード

git add -p は --interactive (または -i) を指定してインタラクティブモードで表示されるメニューの patch サブコマンドを実行するのと同じです。

% git add --interactive  // または git add -i
           staged     unstaged path
  1:    unchanged        +1/-1 greeting.js

*** Commands ***
  1: status       2: update       3: revert       4: add untracked
  5: patch        6: diff         7: quit         8: help
What now> 5  // git add -p は 5: patch を選択するのと同じ

hunk への分割

Git は変更を行ごとに分析し、変更が連続している一連の行をひとまとまりの hunk として扱います。

hunk は変更の開始行と終了行で指定され、その範囲内の変更が hunk としてステージングされる対象となります。

通常、hunk は変更のコンテキストを保持しやすい大きさに自動的に分割されます。但し、hunk を分割する際、変更がどのように分割されるかは Git の判断に依存します。

hunk の分割が期待通りに行われない場合は、オプションで更に分割したり、手動で hunk を分割することができます。

hunk(ハンク)の使用例

以下のファイルの6行目と10行目を修正します。

greeting.js
function hello(name) {
  console.log('Hello!', name);
}

function goodbye(name) {
  console.log('Hello!', name);  // 修正が必要
}

function seeyou(name) {
  console.log('Hello!', name);  // 修正が必要
}

const user = 'Foo';

hello(user);
goodbye(user);
seeyou(user);

以下は修正後のファイルです。

この例では関数 goodbye() と seeyou() に行った修正を別々にステージングしてコミットします。

greeting.js(修正後)
function hello(name) {
  console.log('Hello!', name);
}

function goodbye(name) {
  console.log('Goodbye!', name);  // 変更
}

function seeyou(name) {
  console.log('See you!', name);  // 変更
}

const user = 'Foo';

hello(user);
goodbye(user);
seeyou(user);

git add でファイル単位で変更をステージングする場合の例

もし、以下のように git add にオプションを指定せずに実行すると、ファイル単位で変更をステージングすることになります(通常はこの方法を使うことが多いと思います)。

この場合、ステージングする際に変更内容を確認することはできません。変更はファイル単位でステージされ、コミットすることになります。

% git status
On branch main
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:   greeting.js

no changes added to commit (use "git add" and/or "git commit -a")

% git add greeting.js  // ファイルの変更をステージに追加(通常のステージング)

% git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   greeting.js

% git commit -m "fix bug on greeting.js"  // コミット
[main 81cbfd4] fix bug on greeting.js
 1 file changed, 2 insertions(+), 2 deletions(-)

git add -p で hunk 単位でステージング

この例では以下のように git add -p コマンドを使って、変更を部分的にステージングします。

git add -p を実行すると、以下のように変更箇所が hunk 単位で(行の塊ごとに)表示され、差分を確認しながらステージングすることができます。

% git add -p greeting.js  // git add -p を実行(変更箇所が hunk 単位で表示される)
diff --git a/greeting.js b/greeting.js
index b748bbf..744728d 100644
--- a/greeting.js
+++ b/greeting.js
@@ -3,11 +3,11 @@ function hello(name) {
 }

 function goodbye(name) {
-  console.log('Hello!', name);
+  console.log('Goodbye!', name);
 }

 function seeyou(name) {
-  console.log('Hello!', name);
+  console.log('See you!', name);
 }

 const user = 'Foo';
(1/1) Stage this hunk [y,n,q,a,d,s,e,?]?  // この hunk をステージするかどうかを選択

@@ -3,11 +3,11 @@ はこの hunk の変更内容の範囲を示しています。-3,11 は元のファイルの3行目から11行目までを示し、+3,11 は新しいファイルの3行目から11行目までを示しています(上記画面に表示されているのは変更の前後の行も含まれています)。

この例の場合、変更が小さいので1つのハンクとして表示されています。

ここでは2つの関数の修正をそれぞれ別々にコミットしたいので s を選択して、表示されたハンクを更に分割(Split)します。 e を選択してハンクを編集することもできます(ハンクを編集)。

ハンクを分割(s)

現在2つの関数の修正部分が1つのハンクとして表示されているので、s を選択してハンクを分割します。

s を押して return を押すと、この例の場合、2つのハンクに分割されて以下のように最初のハンクに対してのアクションを求められます。

const user = 'Foo';
(1/1) Stage this hunk [y,n,q,a,d,s,e,?]? s  // s を選択して return
Split into 2 hunks.
@@ -3,7 +3,7 @@ function hello(name) {
 }

 function goodbye(name) {
-  console.log('Hello!', name);
+  console.log('Goodbye!', name);
 }

 function seeyou(name) {
(1/2) Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? }

上記で表示されているハンクは、関数 goodbye() に加えた変更である最初のハンクになります。

y を選択してこのハンクをステージングします。

続いて次のハンクが表示されますが、このハンクは2つ目の関数 seeyou() の変更部分なので n を選択してこのハンクをステージングしません。

(1/1) Stage this hunk [y,n,q,a,d,s,e,?]? s
Split into 2 hunks.
@@ -3,7 +3,7 @@ function hello(name) {
 }

 function goodbye(name) {
-  console.log('Hello!', name);
+  console.log('Goodbye!', name);
 }

 function seeyou(name) {
(1/2) Stage this hunk [y,n,q,a,d,j,J,g,/,/e,?]? y // yで上記のハンクをステージに追加
@@ -7,7 +7,7 @@
 }

 function seeyou(name) {
-  console.log('Hello!', name);
+  console.log('See you!', name);
 }

 const user = 'Foo';
(2/2) Stage this hunk [y,n,q,a,d,K,g,/,/e,?]? n // nで上記のハンクはステージに追加しない

git diff --staged を実行してインデックスとリポジトリの内容を比較すると、1つ目の関数 goodbye() のみがステージされているのが確認できます。

% git diff --staged  // または git diff --cached
diff --git a/greeting.js b/greeting.js
index b748bbf..8d66c6d 100644
--- a/greeting.js
+++ b/greeting.js
@@ -3,7 +3,7 @@ function hello(name) {
 }

 function goodbye(name) {
-  console.log('Hello!', name);
+  console.log('Goodbye!', name);
 }

 function seeyou(name)

ステータスを確認すると、greeting.js の一部分(1つ目の関数の変更部分)のみステージングされているので、以下のようにステージ済みとステージされていない欄の両方に greeting.js が表示されます。

% git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   greeting.js

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:   greeting.js

この時点でコミットすると、1つ目の関数 goodbye() の変更部分のみをコミットすることができます。

% git commit -m "fixed function goodbye()"
[main 88876cc] fixed function goodbye()
 1 file changed, 1 insertion(+), 1 deletion(-)

ステータスを確認するとファイルとしてはステージングされていない状態になっています。

% git status
On branch main
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:   greeting.js

no changes added to commit (use "git add" and/or "git commit -a")

続いてもう1つの関数 seeyou() の変更箇所をステージしてコミットします。

git add -p を実行すると、もう1つの関数 seeyou() の変更箇所が表示されるので y を選択して変更箇所をステージングします。

% git add -p  // 変更箇所をハンク単位でステージング
diff --git a/greeting.js b/greeting.js
index 8d66c6d..744728d 100644
--- a/greeting.js
+++ b/greeting.js
@@ -7,7 +7,7 @@ function goodbye(name) {
 }

 function seeyou(name) {
-  console.log('Hello!', name);
+  console.log('See you!', name);
 }

 const user = 'Foo';
(1/1) Stage this hunk [y,n,q,a,d,e,?]? y // y を選択

git diff --staged を実行してインデックスとリポジトリの内容を比較すると、2つ目の関数 seeyou() の変更部分がステージされているのが確認できます。

また、ステータスを確認すると2つのハンクのステージングが完了したのでファイルとしてもステージ済みになっています。

% git diff --staged  // インデックスとリポジトリの差分を表示
diff --git a/greeting.js b/greeting.js
index 8d66c6d..744728d 100644
--- a/greeting.js
+++ b/greeting.js
@@ -7,7 +7,7 @@ function goodbye(name) {
 }

 function seeyou(name) {
-  console.log('Hello!', name);
+  console.log('See you!', name);
 }

 const user = 'Foo';

% git status  // ステータスを確認
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   greeting.js

2つ目の関数 seeyou() の変更部をコミットします。

ステータスを確認するとワーキングツリーはクリーンになっていて、ログを確認するとそれぞれの変更部分のコミットが確認できます。

% git commit -m "fixed function seeyou()"
[main bfb9b07] fixed function seeyou()
 1 file changed, 1 insertion(+), 1 deletion(-)

% git status
On branch main
nothing to commit, working tree clean

% git log --oneline
bfb9b07 (HEAD -> main) fixed function seeyou()  // 2つ目のハンクのコミット
88876cc fixed function  goodbye()  // 1つ目のハンクのコミット
c82ced8 updated
22686de initial commit
ハンクを編集(e)

git add -p を実行して変更箇所が hunk 単位で表示された際に、s を選択して表示されたハンクを更に分割しようとしても、先述のようにうまく分割できない場合もあります。

そのような場合や、変更内容の一部を選択するなど手動で編集する必要がある場合は、e を押して return を押すと編集モードになり、デフォルトのテキストエディタが開きます。

% git add -p greeting.js  // git add -p を実行
diff --git a/greeting.js b/greeting.js
index b748bbf..744728d 100644
--- a/greeting.js
+++ b/greeting.js
@@ -3,11 +3,11 @@ function hello(name) {
 }

 function goodbye(name) {
-  console.log('Hello!', name);
+  console.log('Goodbye!', name);
 }

 function seeyou(name) {
-  console.log('Hello!', name);
+  console.log('See you!', name);
 }

 const user = 'Foo';
(1/1) Stage this hunk [y,n,q,a,d,s,e,?]? e // e キーを押して return

この例の場合、テキストエディタが開き以下のように表示されます。

# Manual hunk edit mode -- see bottom for a quick guide.
@@ -3,11 +3,11 @@ function hello(name) {
 }

 function goodbye(name) {
-  console.log('Hello!', name);
+  console.log('Goodbye!', name);
 }

 function seeyou(name) {
-  console.log('Hello!', name);
+  console.log('See you!', name);
 }

 const user = 'Foo';
# ---
# To remove '-' lines, make them ' ' lines (context).
# To remove '+' lines, delete them.
# Lines starting with # will be removed.
# If the patch applies cleanly, the edited hunk will immediately be marked for staging.
# If it does not apply cleanly, you will be given an opportunity to
# edit again.  If all lines of the hunk are removed, then the edit is
# aborted and the hunk is left unchanged.
"/Applications/MAMP/htdocs/git-sample/.git/addp-hunk-edit.diff" 23L, 722B

行頭に-+がついている行が変更された行です(+ は追加、- は削除)。

操作方法はコメント部分に説明されています。ステージに追加する変更部分は編集する必要はありません。

  • -(削除)を取り消したい場合は、- をスペース' 'で置き換えます。
  • +(追加)を取り消したい場合は、その行を削除します。

また、以下のような説明も書かれています。

  • # で始まる行は削除されます。
  • 編集した hunk のすべての行が適用される場合、編集された hunk は即座にステージングされます。
  • 適用がうまくいかない場合、再度編集する機会が与えられます(例えば - をスペースで置き換えずに - を単に削除した場合など。 )。
  • hunk のすべての行が削除された場合、編集は中止され、hunk は変更されません。

この例の場合、まず、関数 goodbye() の変更のみをステージングしたいので、以下のように編集します。

vim の場合、文字を入力するにはインサートモードにする必要があるため i を押します。

# Manual hunk edit mode -- see bottom for a quick guide.
@@ -3,11 +3,11 @@ function hello(name) {
 }

 function goodbye(name) {
-  console.log('Hello!', name);
+  console.log('Goodbye!', name);
 }

 function seeyou(name) {
   console.log('Hello!', name);   // - をスペースに置き換え(+ の行は削除)
 }

 const user = 'Foo';
# ---
# To remove '-' lines, make them ' ' lines (context).
# To remove '+' lines, delete them.
# Lines starting with # will be removed.
# If the patch applies cleanly, the edited hunk will immediately be marked for staging.
# If it does not apply cleanly, you will be given an opportunity to
# edit again.  If all lines of the hunk are removed, then the edit is
# aborted and the hunk is left unchanged.
~
-- INSERT --  // インサートモード

編集が終了したら esc キーを押してインサートモードを終了し、:wq で変更を保存するとステージングされます。

git diff --staged を実行してインデックスとリポジトリの内容を比較すると、1つ目の関数 goodbye() のみがステージされているのが確認できます。

% git diff --staged
diff --git a/greeting.js b/greeting.js
index b748bbf..8d66c6d 100644
--- a/greeting.js
+++ b/greeting.js
@@ -3,7 +3,7 @@ function hello(name) {
 }

 function goodbye(name) {
-  console.log('Hello!', name);
+  console.log('Goodbye!', name);
 }

 function seeyou(name)

また、ステータスを確認すると、greeting.js の一部分のみステージングされているので以下のように表示されます。

% git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   greeting.js

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:   greeting.js

この時点でコミットすると、1つ目の関数 goodbye() の変更部分のみをコミットすることができます。

% git commit -m "fixed function goodbye()"
[main aeccb98] fixed function goodbye()
 1 file changed, 1 insertion(+), 1 deletion(-)

 % git status
 On branch main
 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:   greeting.js

 no changes added to commit (use "git add" and/or "git commit -a")

続いてもう1つの関数の変更箇所をステージしてコミットします(以降は先述の例と同じです)。

git add -p を実行すると、もう1つの関数 seeyou() の変更箇所が表示されるので y を選択して変更箇所をステージングします。

% git add -p
diff --git a/greeting.js b/greeting.js
index 8d66c6d..744728d 100644
--- a/greeting.js
+++ b/greeting.js
@@ -7,7 +7,7 @@ function goodbye(name) {
 }

 function seeyou(name) {
-  console.log('Hello!', name);
+  console.log('See you!', name);
 }

 const user = 'Foo';
(1/1) Stage this hunk [y,n,q,a,d,e,?]? y  // y を押して return

ステータスを確認すると、greeting.js の全ての変更はステージされています。

% git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   greeting.js

2つ目の関数 seeyou() の変更部をコミットします。

% git commit -m "fixed function seeyou()"
[main 4ae10a3] fixed function seeyou()
 1 file changed, 1 insertion(+), 1 deletion(-)

% git status
On branch main
nothing to commit, working tree clean

% git log --oneline
4ae10a3 (HEAD -> main) fixed function seeyou()
aeccb98 fixed function goodbye()
c82ced8 updated
22686de initial commit

適用がうまくいかない場合

例えば、-(削除)を取り消したい場合は、- を半角スペース' ' 1つで置き換える必要がありますが、- をスペースで置き換えずに単に削除したり、またはスペースを余分に入れてしまった場合などでは、以下のようなエラーが表示されます。

error: patch failed: greeting.js:3
error: greeting.js: patch does not apply
error: 'git apply --cached' failed
Your edited hunk does not apply. Edit again (saying "no" discards!) [y/n]? // y 

再度編集するには y を押して return キーを押すと編集画面が表示されます。n を押して return キーを押すと何も変更されずに編集モードを終了します。

ハンクのステージングの取り消し

ステージングされた状態から特定のハンクのステージングを取り消すには git reset -p を使います。

以下は1つのハンクがステージングされていて、そのハンクのステージングを解除する例です。

git reset -p(または git reset --patch)を実行すると、ステージングされているハンクが表示されるので、そのハンクのステージングを解除(Unstage)する場合は y を押して return を押します。

% git reset -p greeting.js  // または git reset --patch greeting.js
diff --git a/greeting.js b/greeting.js
index 8d66c6d..744728d 100644
--- a/greeting.js
+++ b/greeting.js
@@ -7,7 +7,7 @@ function goodbye(name) {
 }

 function seeyou(name) {
-  console.log('Hello!', name);
+  console.log('See you!', name);
 }

 const user = 'Foo';
(1/1) Unstage this hunk [y,n,q,a,d,e,?]? y // y を押して return を押すと解除

git reset -p は git add -p のように、e オプションを使って対話的にハンクのステージングの解除を編集することもできます。

git reset -p は git add -p の逆の操作と言えます。

hunk(ハンク)のメリット・デメリット

メリット

hunk(ハンク)を使用するとコードの変更を部分的にステージングできるためコミットの粒度を制御することができ、以下のようなメリットがあります。

  • 部分的なステージング:
    変更を hunk 単位でステージングすることで、コミットする前に部分的な変更だけを選択的にステージングすることができます。
  • コミットの粒度を制御:
    hunk を使用することで、コミットの粒度を細かく制御できます。関連する変更だけを1つのコミットにまとめることで、各コミットが特定の目的や機能を持つようになります。
  • コードレビューの改善:
    hunk を使用すると、コードレビューの際に特定の変更だけを選択的に表示できます。
  • 不要な変更の除外:
    hunk を使用して変更を選択的にステージングすることで、コミットに含めたくない不要な変更を除外できます。

デメリット

また、逆に hunk を使用することにより以下のようなデメリットも考えられます。

  • 複雑な変更の分割:
    複雑な変更を適切に hunk に分割することは難しいことがあります。変更のコンテキストが複雑である場合、適切な分割が難しくなり、コードの意図が正確に伝わらない可能性があります。
  • 漏れやミスのリスク:
    hunk を使用する際、特定の変更を見落とす、もしくは誤ってステージングしないといったミスが発生する可能性があります。
  • コードレビューの複雑化:
    hunk を多用すると、コードレビューが複雑化する可能性があります。
  • コミットの肥大化:
    hunk を使用してコミットを細かく分割すると、コミット履歴が細分化されて複数の小さなコミットが発生することによってコミット履歴が肥大化し、トラッキングが難しくなる可能性があります。