L'option --patch de Git

Pierre Gradot - Feb 9 '23 - - Dev Community

Vous avez déjà remarqué que plusieurs commandes Git ont une option --patch ? On peut citer add, checkout, commit, reset,
restore ou encore stash.

Dans la suite de cet article, on va modifier ce fichier et on verra comment utiliser l'option --patch (-p en version courte) de certaines commandes pour gérer finement ces modifications.

Définition d'un hunk

Si vous avez suivi les liens donnés pour chacune des commandes, vous aurez sûrement constaté que la description de l'option --patch commence souvent par :

Interactively select hunks

On retrouve souvent ce terme de "hunk". C'est un terme important de l'appréhender pour comprendre comment Git gère les changements.

On trouve une bonne définition de "hunk" dans la documentation de diff :

When comparing two files, diff finds sequences of lines common to both files, interspersed with groups of differing lines called hunks. Comparing two identical files yields one sequence of common lines and no hunks, because no lines differ. Comparing two entirely different files yields no common lines and one large hunk that contains all lines of both files.

Si vous vous demandez ce que la commande Unix diff vient faire dans un article parlant de Git, c'est juste que git diff et git apply ne sont pas très différentes de diff et patch (comme discuté ici ou ).

--patch vs --interactive

On vient de dire que l'option --patch permet de sélectionner interactivement des hunks (et vous verrez clairement dans les exemples de la suite de cet article pourquoi on dit ça et comment on le fait).

Pourtant, il existe une autre option : --interactive. Quelle différence entre les 2 ?

Voici ce qu'on obtient en utilisant --interactive (ici avec git add,mais c'est totalement similaire avec d'autres commandes) :

git add --interactive

*** Commands ***
  1: status       2: update       3: revert       4: add untracked
  5: patch        6: diff         7: quit         8: help
What now>
Enter fullscreen mode Exit fullscreen mode

En vrai, --patch est un raccourci pour lancer le mode interactif et choisir patch dans ce menu. C'est clairement expliqué dans la documentation de git add :

-p

--patch

Interactively choose hunks of patch between the index and the work tree and add them to the index. This gives the user a chance to review the difference before adding modified contents to the index.

This effectively runs add --interactive, but bypasses the initial command menu and directly jumps to the patch subcommand.

Disclaimer

Quand on prononce le mot "patch", on pense instinctivement à des fichiers avec l'extension .patch, qui décrivent des changements et qu'on peut appliquer à notre dépôt. On ne parlera pas de tels fichiers ici.

En effet, il existe d'autres commandes avec une option --patch : diff, log, show, diff-index, diff-tree, diff-files. Elles génèrent alors des patchs, qu'on peut récupérer (par exemple dans des fichiers) et ensuite appliquer avec git am ou git apply.

Dans cet article, on s'intéresse aux commandes interactives (ce n'est pas le cas des commandes citées à l'instant, qui n'ont pas d'option --interactive).

Notre application

Place aux exemples maintenant ! Pour cela, partons d'un dépôt avec un unique fichier app.py pour réaliser une superbe application avec Flask :

from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return 'hello, wordl'
Enter fullscreen mode Exit fullscreen mode

Si vous voulez exécuter ce code (ça ne sert à rien pour comprendre cet article, mais c'est marrant), il suffit de vous placer dans le dossier où est app.py et de faire :

$ pip install flask
$ flask run
Enter fullscreen mode Exit fullscreen mode

On fait un premier commit de ce fichier pour obtenir l'état initial de notre dépôt :

$ git log --oneline
a512faf (HEAD -> master) Initial
Enter fullscreen mode Exit fullscreen mode

Evolution du code

Continuons le développement de notre application et ajoutons une autre route. Au passage, corrigeons également l'affreuse typo à wordl (vous l'aviez remarqué hein ?).

Notre fichier ressemble maintenant à ça :

from flask import Flask

app = Flask(__name__)

@app.route('/about')
def about():
    return 'This is a great app made with Flask'

@app.route('/')
def index():
    return 'hello, world'
Enter fullscreen mode Exit fullscreen mode

On peut voir les modifications apportées au fichier grâce à git diff :

$ git diff
diff --git a/app.py b/app.py
index 71a502c..198f2d0 100644
--- a/app.py
+++ b/app.py
@@ -2,7 +2,10 @@ from flask import Flask

 app = Flask(__name__)

+@app.route('/about')
+def about():
+    return 'This is a great app made with Flask'

 @app.route('/')
 def index():
-    return 'hello, wordl'
+    return 'hello, world'
Enter fullscreen mode Exit fullscreen mode

Commiter séparément les modifications

On a fait deux modifications qui n'ont aucun lien entre elles. D'un côté, on corrige un bug ; de l'autre, on ajoute une nouvelle fonctionnalité. Or, un commit doit idéalement avoir une justification unique. Cela veut dire que commit --all -m "Fix bug + add feature" n'est pas optimal car il a 2 justifications.

On a donc envie de faire 2 commits d'app.py, mais comment faire ?

Grâce à l'option --patch des commandes git add et git commit, c'est simple.

Dans la suite, on va voir 2 techniques pour d'abord commiter uniquement la modification pour la correction de la typo. Après ce commit, la modification pour l'ajout de la route sera toujours dans le working tree et on pourra commiter app.py en entier, comme on le fait généralement.

Solution 1 = add puis commit

Au lieu de faire git add mon_fichier, on fait git add --patch mon_fichier. On peut ainsi choisir quoi faire de chaque hunk. On peut ainsi ajouter sélectivement des hunks à la staging area et laisser dans les autres dans le working tree.

λ git add --patch app.py
diff --git a/app.py b/app.py
index 71a502c..198f2d0 100644
--- a/app.py
+++ b/app.py
@@ -2,7 +2,10 @@ from flask import Flask

 app = Flask(__name__)

+@app.route('/about')
+def about():
+    return 'This is a great app made with Flask'

 @app.route('/')
 def index():
-    return 'hello, wordl'
+    return 'hello, world'
(1/1) Stage this hunk [y,n,q,a,d,s,e,?]?
Enter fullscreen mode Exit fullscreen mode

Git pense qu'il n'y a qu'un seul hunk (d'où le (1/1)) et nous demande quoi en faire. On lui répond par la lettre correspondant à l'action souhaitée :

y - stage this hunk
n - do not stage this hunk
q - quit; do not stage this hunk or any of the remaining ones
a - stage this hunk and all later hunks in the file
d - do not stage this hunk or any of the later hunks in the file
s - split the current hunk into smaller hunks
e - manually edit the current hunk
? - print help
Enter fullscreen mode Exit fullscreen mode

On souhaite splitter ce hunk en 2 hunks, pour n'ajouter que la seconde modification. On répond donc s, puis n et enfin y :

(1/1) Stage this hunk [y,n,q,a,d,s,e,?]? s
Split into 2 hunks.
@@ -2,6 +2,9 @@

 app = Flask(__name__)

+@app.route('/about')
+def about():
+    return 'This is a great app made with Flask'

 @app.route('/')
 def index():
(1/2) Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? n
@@ -5,4 +8,4 @@

 @app.route('/')
 def index():
-    return 'hello, wordl'
+    return 'hello, world'
(2/2) Stage this hunk [y,n,q,a,d,K,g,/,e,?]? y
Enter fullscreen mode Exit fullscreen mode

Vérifions le contenu de la staging area :

$ git diff --staged
diff --git a/app.py b/app.py
index 71a502c..efb2808 100644
--- a/app.py
+++ b/app.py
@@ -5,4 +5,4 @@ app = Flask(__name__)

 @app.route('/')
 def index():
-    return 'hello, wordl'
+    return 'hello, world'
Enter fullscreen mode Exit fullscreen mode

Vérifions aussi le contenu du working tree :

$ git diff
diff --git a/app.py b/app.py
index efb2808..198f2d0 100644
--- a/app.py
+++ b/app.py
@@ -2,6 +2,9 @@ from flask import Flask

 app = Flask(__name__)

+@app.route('/about')
+def about():
+    return 'This is a great app made with Flask'

 @app.route('/')
 def index():
Enter fullscreen mode Exit fullscreen mode

C'est parfait ! Pour terminer, il suffit de commiter le contenu de la staging area avec git commit -m "Fix typo in route /".

Solution 2 = commit direct

On n'est pas obligé de passer par la staging area pour commiter des changements. On peut faire directement :

$ git commit --patch -m "Fix typo in route /"
Enter fullscreen mode Exit fullscreen mode

On suit alors exactement le même cheminement : on splitte le hunk en 2, on ignore le premier hunk résultant, et on ajoute le second.

A l'issue de cette commande, un commit a été fait et il reste la modification pour l'ajout de la nouvelle route :

$ git log --oneline
4a39c0b (HEAD -> master) Fix typo in route /
a512faf Initial
Enter fullscreen mode Exit fullscreen mode
$ git diff
diff --git a/app.py b/app.py
index 13357d9..3b00884 100644
--- a/app.py
+++ b/app.py
@@ -2,6 +2,9 @@ from flask import Flask

 app = Flask(__name__)

+@app.route('/about')
+def about():
+    return 'This is a great app made with Flask'

 @app.route('/')
 def index():
Enter fullscreen mode Exit fullscreen mode

Forcer le split d'un hunk

Des fois, on ne peut pas splitter un hunk. La lettre s n'est pas dans la liste et on est un peu embêté...

Modifions notre fichier pour être dans un tel cas :

from flask import Flask

app = Flask(__name__)

@app.route('/about')
def about():
    return 'This is a great app (made with Flask!)'

@app.route('/test')
def hello():
    return 'this is a route for testing'

@app.route('/')
def index():
    return 'hello, world'
Enter fullscreen mode Exit fullscreen mode

Imaginons qu'on souhaite supprimer l'ajout de la route /test, mais conserver la modification de la route /about. Tentons de faire un git restore en lui passant l'option --patch :

$ git restore --patch app.py
diff --git a/app.py b/app.py
index 3b00884..9e0d57f 100644
--- a/app.py
+++ b/app.py
@@ -4,7 +4,11 @@ app = Flask(__name__)

 @app.route('/about')
 def about():
-    return 'This is a great app made with Flask'
+    return 'This is a great app (made with Flask!)'
+
+@app.route('/test')
+def hello():
+    return 'this is a route for testing'

 @app.route('/')
 def index():
(1/1) Discard this hunk from worktree [y,n,q,a,d,e,?]?
Enter fullscreen mode Exit fullscreen mode

Aïe ! s n'est pas dans la liste... C'est assez logique : Git voit un groupe de lignes contiguës modifiées, il ne peut pas se douter qu'il s'agit en fait de 2 modifications distinctes.

Pas le choix : il va falloir répondre e pour éditer manuellement le hunk. L'éditeur de texte configuré pour Git s'ouvre alors et nous donne le contrôle. Dans mon cas, c'est Visual Studio Code et le fichier est .git\addp-hunk-edit.diff :

# Manual hunk edit mode -- see bottom for a quick guide.
@@ -4,7 +4,11 @@ app = Flask(__name__)

 @app.route('/about')
 def about():
-    return 'This is a great app made with Flask'
+    return 'This is a great app (made with Flask!)'
+
+@app.route('/test')
+def hello():
+    return 'this is a route for testing'

 @app.route('/')
 def index():
# ---
# 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 discarding.
# 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.
Enter fullscreen mode Exit fullscreen mode

La question posée était "Discard this hunk from worktree [y,n,q,a,d,e,?]?". On doit donc construire un patch qui permettra d'annuler les changements ajoutant la route /test.

On modifie le fichier comme suit (je ne mets que la partie utile) :

@@ -4,7 +4,11 @@ app = Flask(__name__)

 @app.route('/about')
 def about():
     return 'This is a great app made with Flask'
+
+@app.route('/test')
+def hello():
+    return 'this is a route for testing'

 @app.route('/')
 def index():
Enter fullscreen mode Exit fullscreen mode

Quand on ferme le fichier, Git utilise ce patch pour terminer son restore. Pour être sûr qu'on a bien fait ce qu'on voulait, on peut faire un petit git diff :

$ git diff                                             
diff --git a/app.py b/app.py                           
index 3b00884..7a87709 100644                          
--- a/app.py                                           
+++ b/app.py                                           
@@ -4,7 +4,7 @@ app = Flask(__name__)                  

 @app.route('/about')                                  
 def about():                                          
-    return 'This is a great app made with Flask'      
+    return 'This is a great app (made with Flask!)'   

 @app.route('/')                                       
 def index():                                          
Enter fullscreen mode Exit fullscreen mode

Splendide ! La modification qu'on souhaitait conservée est toujours là.

Conclusion

Voilà, c'est super ! Vous savez maintenant comment gérer finement vos changements en ligne de commande.

Dans ces commandes, on se dit que --patch aurait pu être --partial. En fait, on se rend compte que cette option nous laisse gérer finement les patchs que Git utilise en background pour faire ses opérations. Effet :

  • Faire un add, c'est appliquer un patch à la staging area.
  • Faire un commit, c'est appliquer un patch au dépôt.
  • Faire un restore c'est appliquer un patch à la staging area ou au working tree.

C'était très clair avec notre exemple pour git restore : on a édité un fichier de patch.

Bon, il s'avère que les éditeurs de code modernes permettent de faire de telles opérations via leurs GUI. Et il faut avouer que c'est souvent plus facile... Dans Visual Studio Code, il suffit de sélectionner des lignes pour décider quoi en faire (les actions sont aussi possibles en faisant un clic-droit sur le texte) :

plusieurs changements dans VSC

Mais maintenant, vous savez comment comment faire en ligne de commande si vous êtes privé·e·s de GUI !

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player