איך לבטל מיזוג של ענף ישן ב git

07/01/2023

בעבודה עם גיט אנחנו אוהבים לבנות כל פיצ'ר בענף משלו ורק אחרי שבדקנו והכל עובד למזג לתוך main (זאת לא הדרך היחידה לעבוד, אבל היא מספיק פופולרית כדי להיחשב למיינסטרים). בעיה שחוזרת לא פעם בשיטת עבודה כזו היא הצורך לבטל מיזוג של ענף, למשל כי אחרי שמיזגנו והעלינו לפרודקשן פתאום גילינו את הבאג ההוא שפספסנו בבדיקות.

1. מאגר לדוגמה

בשביל הדוגמה בניתי מאגר מסקריפט פייתון שהלוג שלו נראה ככה:

* ae4ea11 (HEAD -> main) add tests
* 5ad40d1 add generic method
*   91cdcd3 fixed conflicts
|\
| * 6a8ccb0 add comments
| * 71b3207 fixed syntax
| * b2cc15b first try
* | 05df720 add script docs
|/
* 5161eec fixed print statement
* c299ef3 add print statement
* 6adbc5b initial commit

אפשר לראות את הענף הממוזג בין הקומיט add comments לקומיט first try. האמת היא שיש עוד כמה ענפים שמוזגו פנימה, אבל רק בענף הזה היו קונפליקטים ולכן נוצר שם Merge Commit (זה קומיט מספר 91cdcd3). שאר המיזוגים עברו חלק עם Fast Forward. בנוסף אחרי כל מיזוג גם מחקתי את שם הענף.

במאגר יש רק סקריפט פייתון יחיד בקובץ demo.py וזה התוכן שלו:

import unittest, sys

class TestMultMethod(unittest.TestCase):
    def test_mul_positives(self):
        self.assertEqual(6, mul(2, 3))

#
# This script does all kinds of nice things
# to help children learn math
#
def three_by_three():
    return mul(3, 3)

def two_by_two():
    return mul(2, 2)

def mul(x, y):
    return x * y

# run self test with --test
if len(sys.argv) > 1 and sys.argv[1] == "--test":
    sys.argv = sys.argv[:1]
    unittest.main()
    sys.exit(0)


print("Hello world")

# part 1
print(f"2 * 2 = {two_by_two()}")

# part 2
print(f"3 * 3 = {three_by_three()}")

והקומיטים שאני רוצה לבטל הם אלה שהוסיפו את הפיצ'ר של "הדפסת 3 כפול 3", הפיצ'ר הזה מורכב מהפונקציה three_by_three ומהודעת ההדפסה בשורה האחרונה של הסקריפט. מאז שינוי זה היו עוד שינויים שנגעו בכל המקומות בסקריפט ולכן צפויים קונפליקטים.

2. איך לבטל את המיזוג עם revert

דרך אחת לבטל את המיזוג היא פקודת revert. אבל יש עם זה שתי בעיות:

  1. ריוורט היא פקודה מאוד רגישה לשינויים שקרו אחרי המיזוג, ולכן ככל שעברו במאגר יותר שינויים אחרי הקומיט שאתם רוצים לבטל, כך ה revert יהיה פחות אמין. ריוורט בעצם מייצר קומיט חדש שמוחק את הקוד שהקומיט הבעייתי הכניס.

  2. אם אי פעם בעתיד תרצו למזג מחדש את הענף (למשל כי תמצאו את הבעיה ותתקנו אותה), גיט לא ימזג מחדש את הקומיטים שכבר "מוזגו" ו"רוברטו" (reverted), כי ה revert לא משנה דברים בהיסטוריה אלא הוא בסך הכל קומיט חדש.

זה אומר שאם ניקח את הדרך הזאת ובעתיד נרצה לתקן משהו בענף שכבר מוזג נצטרך לעשות rebase לכל הקומיטים באותו ענף - כלומר לקומיטים 6a8ccb0, 71b3207 ו b2cc15b - כי אחרת שלושתם לא ימוזגו מחדש לעולם.

בכל מקרה ואם אתם מוכנים לוותר על מיזוג עתידי בואו נתקדם. אני מזכיר שהריפו הוא:

$ git log

* ae4ea11 (HEAD -> main) add tests
* 5ad40d1 add generic method
*   91cdcd3 fixed conflicts
|\
| * 6a8ccb0 add comments
| * 71b3207 fixed syntax
| * b2cc15b first try
* | 05df720 add script docs
|/
* 5161eec fixed print statement
* c299ef3 add print statement
* 6adbc5b initial commit

בגלל שהמיזוג בוצע ב Merge Commit אני יכול לייצר (נו, לנסות לייצר) את קוד ההיפוך ישירות מאותו merge commit. בגלל שזה merge commit אני צריך לציין איזו רגל שלו מייצגת את הענף הראשי, כלומר זה שהקוד מוזג אליו. המיספור מתחיל מ-1 ולכן אצלי המספר הוא 1:

$ git revert 91cdcd3 -m 1

התוצאה היא כמובן קונפליקט אבל אפשר לחיות עם זה. ככה נראה הקובץ עם הקונפליקט:

import unittest, sys

class TestMultMethod(unittest.TestCase):
    def test_mul_positives(self):
        self.assertEqual(6, mul(2, 3))

#
# This script does all kinds of nice things
# to help children learn math
#
<<<<<<< HEAD
def three_by_three():
    return mul(3, 3)
=======
>>>>>>> parent of 91cdcd3 (fixed conflicts)

def two_by_two():
    return mul(2, 2)

def mul(x, y):
    return x * y

# run self test with --test
if len(sys.argv) > 1 and sys.argv[1] == "--test":
    sys.argv = sys.argv[:1]
    unittest.main()
    sys.exit(0)


print("Hello world")

# part 1
print(f"2 * 2 = {two_by_two()}")

<<<<<<< HEAD
# part 2
print(f"3 * 3 = {three_by_three()}")



=======
>>>>>>> parent of 91cdcd3 (fixed conflicts)

אני רואה בקובץ שהקטעים המסומנים הם באמת אלה שמתאימים לקוד שנכנס מהבראנץ שאני רוצה למחוק, משאיר רק את ה HEAD ומסיים עם הסקריפט הבא:

import unittest, sys

class TestMultMethod(unittest.TestCase):
    def test_mul_positives(self):
        self.assertEqual(6, mul(2, 3))

#
# This script does all kinds of nice things
# to help children learn math
#
def two_by_two():
    return mul(2, 2)

def mul(x, y):
    return x * y

# run self test with --test
if len(sys.argv) > 1 and sys.argv[1] == "--test":
    sys.argv = sys.argv[:1]
    unittest.main()
    sys.exit(0)


print("Hello world")

# part 1
print(f"2 * 2 = {two_by_two()}")

ומסיים את ה revert עם:

$ git add demo.py
$ git revert --continue
$ git log --oneline --graph

* 492cb53 (HEAD -> main) Revert printing 3 by 3
* ae4ea11 add tests
* 5ad40d1 add generic method
*   91cdcd3 fixed conflicts
|\
| * 6a8ccb0 add comments
| * 71b3207 fixed syntax
| * b2cc15b first try
* | 05df720 add script docs
|/
* 5161eec fixed print statement
* c299ef3 add print statement
* 6adbc5b initial commit

הקוד חזר למצבו לפני המיזוג, המיזוג מופיע בהיסטוריה וגם הביטול שלו ורק צריך לזכור לעולם לא להוסיף קומיט עם תיקון אחרי קומיט 6a8ccb0, כלומר לענף השבור, בגלל שאי אפשר למזג מחדש משהו שכבר מיזגנו. כן אפשר יהיה לעשות rebase לשלושת הקומיטים הבעייתיים ואז למזג את הגירסה החדשה המרובסת שלהם.

3. איך לבטל את המיזוג עם rebase

דרך אחרת לגשת לאותה בעיה, אם לא דחפתם עדיין את המאגר, היא לעשות rebase לקומיטים ae4ea11 ו 5ad40d1 כך שאותו הקוד שיש בהם "יולבש" על קומיט 05df720 שהיה לפני המיזוג. זה נראה ככה:

$ git rebase --onto 05df720 91cdcd3 ae4ea11

יש פה 3 מספרי קומיטים:

  1. הראשון הוא הקומיט שממנו אנחנו רוצים להמשיך (בדיוק לפני המיזוג הבעייתי)

  2. השני הוא קומיט המיזוג (הקומיט האחרון שאנחנו רוצים למחוק)

  3. השלישי הוא הקומיט האחרון בפרויקט (הקומיט אליו אנחנו רוצים להגיע). אפשר להשתמש בשם main במקום מספר קומיט כאן.

התוצאה היא כמובן קונפליקט אבל זה צפוי:

Auto-merging demo.py
CONFLICT (content): Merge conflict in demo.py
error: could not apply 5ad40d1... add generic method
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
Could not apply 5ad40d1... add generic method

הקוד עם הקונפליקט מסומן הוא:

#
# This script does all kinds of nice things
# to help children learn math
#
<<<<<<< HEAD
=======
def three_by_three():
    return mul(3, 3)
>>>>>>> 5ad40d1 (add generic method)

def two_by_two():
    return mul(2, 2)

def mul(x, y):
    return x * y

print("Hello world")

# part 1
print(f"2 * 2 = {two_by_two()}")

אני מוחק את הפונקציה המיותרת כדי לתקן את הקונפליקט וממשיך בריבייס, רק בשביל לקבל קונפליקט נוסף:

$ git add .
$ git rebase --continue

Auto-merging demo.py
CONFLICT (content): Merge conflict in demo.py
error: could not apply ae4ea11... add tests
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
Could not apply ae4ea11... add tests

הקוד עכשיו עם הקונפליקט מסומן הוא:

import unittest, sys

class TestMultMethod(unittest.TestCase):
    def test_mul_positives(self):
        self.assertEqual(6, mul(2, 3))

#
# This script does all kinds of nice things
# to help children learn math
#
def two_by_two():
    return mul(2, 2)

def mul(x, y):
    return x * y

# run self test with --test
if len(sys.argv) > 1 and sys.argv[1] == "--test":
    sys.argv = sys.argv[:1]
    unittest.main()
    sys.exit(0)


print("Hello world")

# part 1
print(f"2 * 2 = {two_by_two()}")

<<<<<<< HEAD
=======
# part 2
print(f"3 * 3 = {three_by_three()}")



>>>>>>> ae4ea11 (add tests)

זאת ההדפסה בשורה האחרונה - אני מוחק אותה וממשיך את הריבייס:

$ git add .
$ git rebase --continue

[detached HEAD 82f6008] add tests
 1 file changed, 13 insertions(+)
Successfully rebased and updated detached HEAD.

נעביר את main למקום החדש שלו ונקבל:

$ git branch -f main HEAD
$ git switch main
$ git log --oneline --graph

* 82f6008 (HEAD -> main) add tests
* 3e0d862 add generic method
* 05df720 add script docs
* 5161eec fixed print statement
* c299ef3 add print statement
* 6adbc5b initial commit

עכשיו ההיסטוריה נראית הרבה יותר טוב, בלי שום זכר לענף שמוזג. אם בעתיד נרצה לחזור אליו, להוסיף עוד קומיטים ולמזג לא תהיה עם זה שום בעיה. העניין היחיד הוא שאם עשיתם push למאגר לפני הריבייס, תצטרכו לדחוף גירסה מעודכנת ולדרוס את מה ששיתפתם, וזה יכול לסבך אנשים אחרים שעובדים אתכם על הפרויקט ואולי כבר השתמשו בגירסה ששיתפתם.