• בלוג
  • מה קורה כשמושכים מאגר שמישהו שינה מרחוק

מה קורה כשמושכים מאגר שמישהו שינה מרחוק

09/12/2021

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

1. אם רק מחקו קומיטים

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

$ git reset --hard HEAD~
$ git push --force

ושימו לב שאין צורך להפעיל commit כי אני לא יוצר שום שינוי חדש אלא רק דוחף את הענף המרוחק לקומיט אחד אחורה. במחשב השני מריצים:

$ git pull

במצב כזה הפלט של המשיכה יהיה:

From github.com:ynonp/pull-after-change
 + 4db20b1...8ef1cf5 main       -> origin/main  (forced update)
Already up to date.

והלוג יראה בערך כך:

* 4db20b1 (HEAD -> main) add g.txt
* 8ef1cf5 (origin/main, origin/HEAD) replaced e with f
* 2d31fbd replaced c with d
* 24d6085 (dev) initial commit

אנחנו רואים ש origin/main זז קומיט אחד אחורה, והקומיט main עדיין נמצא במחשב השני. אם הקומיט origin/main היה קדימה יותר כמו במצב רגיל, אז גיט היה מבצע Fast Forward Merge ומזיז קדימה את main; אבל לגיט אין מנגנון של Fast Backwards Merge וככה נתקענו שהמחשב שלנו "יותר קדימה" מהקומיט העדכני ביותר בשרת.

אם היינו מוחקים מראש את הענף הכל היה מסתדר, ועוד דרך לצאת מזה היא:

$ git reset --hard origin/main

(ובשביל הדרך הכי טובה לצאת מזה תצטרכו להמשיך לקרוא עד לסוף הפוסט).

2. אם גם הוסיפו קומיט חדש

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

$ git reset --hard HEAD~

$ date > newfile.txt
$ git add newfile.txt
$ git commit -m 'yay'

$ git push --force

הפעם כשהמתכנת במחשב השני ימשוך את המאגר הוא יגלה שהקומיט העדכני ביותר אינו מוכר לו. נפעיל git fetch מהמחשב השני בשביל להבין את הדוגמה לאט. הלוג נראה כך:

$ git log --oneline --graph
* 2d31fbd (HEAD -> main) replaced c with d
* 24d6085 (dev) initial commit

ולא כולל את origin/master, והלוג מ origin/master נראה כך:

$ git log --oneline --graph
* a346d40 (origin/main, origin/HEAD) add f.txt
* 24d6085 (dev) initial commit

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

וזה ממש לא מה שצריך לעשות.

כי merge כזה בעצם משלב את השינויים שיש אצלי (שב remote נמחקו) עם הקומיט החדש מה remote, ויכניס בחזרה את אותם קבצים או שינויים שמישהו מרחוק ניסה למחוק. אנחנו לא צריכים כאן merge אלא reset - להעביר את הענף main שלנו שיהיה מיושר עם origin/main, ולהעיף את הקומיט 2d31fbd גם מהמכונה שלי.

3. ואם גם המתכנת השני הוסיף קומיט חדש

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

$ git log --oneline --graph
* d96ebe5 (HEAD -> main) changed a.txt
* a346d40 add f.txt
* 24d6085 (dev) initial commit

ומהענף origin/main:

$ git log --oneline --graph origin/main
* 6b84f15 (origin/main, origin/HEAD) add g.txt
* 24d6085 (dev) initial commit

הפעם יש לנו שני קומיטים חדשים בענף main המקומי וקומיט אחד חדש ב origin/main. פעולת merge תנסה לשלב את שני הקומיטים ותתן לנו גם את השינויים ב a.txt אותם אנחנו רוצים, וגם את השינויים ב f.txt אותם אנחנו לא רוצים.

בשביל לצאת מזה אי אפשר לעשות git reset כי אז נאבד את הקומיט d96ebe5. נוכל להשתמש ב reset ואחריו cherry pick, או בקסם של הסעיף הבא.

4. פיתרון: git pull --rebase

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

$ git pull --rebase

בדרך כלל pull עושה fetch ואז merge; אבל המתג --rebase גורם ל pull לעשות fetch ואז rebase. וזה מלהיב כי הוא בעצם ייקח את כל השינויים מהענף origin/main אלינו ואז יריץ עליו רק את השינויים שאני עשיתי בענף main שלי.

במקרה הראשון הפקודה תזהה שאצלי אין קומיטים בענף ולכן רק תזיז את main למקום הנכון.

במקרה השני הפקודה תזיז את main לאן ש origin/main נמצא ואז תנסה "להפעיל מחדש" את השינויים מהקומיטים החדשים יותר. גיט מספיק חכם בשביל לזהות שהקומיט היחיד שיש אצלי שלא נמצא בענף של origin/main הוא היסטורי ויתעלם ממנו והתוצאה תהיה יישור של המאגר המקומי שלי עם המאגר המרוחק.

ובמקרה השלישי שוב הפקודה תזיז את main לאן ש origin/main נמצא ואז תפעיל מחדש את הקומיט החדש שאני יצרתי. השינוי יישמר ויקבל מזהה קומיט חדש והמאגר המקומי שלי יישאר לינארי לגמרי.