שלום אורח התחבר

הבלוג של ינון פרק

טיפים קצרים וחדשות למתכנתים

מעדיפים לקרוא מהטלגרם? בקרו אותנו ב:@tocodeil

או הזינו את כתובת המייל וקבלו את הפוסט היומי בכל בוקר אליכם לתיבה:

נתקעתם עם ענף שיש בו יותר מדי שינויים ואתם פשוט צריכים לקחת את כל הקוד משם בתור קומיט יחיד אליכם לעץ? ככה תוכלו להשתמש ב merge squash כדי לעשות בדיוק את זה.

המשך קריאה...

יש משהו יפה בבראנץ שסיימת לעבוד עליו, אתה מכווץ את הכל לקומיט אחד ומחבר ל main. אפשר להסתכל על git log מ main ולראות שכל קומיט מייצג את כל השינויים שהיית צריך לכתוב בשביל פיצ'ר מסוים בלי להיתקע על כל מיני קומיטי תיקונים שלא מוסיפים כלום. אבל, להרגל לכווץ ולאחד קומיטים יש גם צד אפל והוא עלול לעלות לכם בלא מעט שעות שינה.

הבאג בסיפור שלי למשל התחיל מקוד תמים שנראה בערך כך (השמות שונו כדי לשמור על פרטיות המעורבים, הסביבה נשארה ריילס):

class Task < ActiveRecord::Base
    validates :name, presence: true
    validates :description, presence: true, unless: :new_record?
end

עד לפה הכל קל - משימה נחשבת וולידית אם יש לה שם ותיאור. אבל רגע, מה זה ה unless שם בסוף התיאור? למה זה משנה אם משימה היא new_record? או לא בשביל לחבר לה תיאור?

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

בחזרה ל unless שלנו. בשביל לגלות למה קוד מסוים נכנס למערכת אני משתמש ב git blame:

$ git blame -- app/models/task.rb | grep unless

ומוצא שהשורה נכנסה בקומיט מספר c8fd7f671 עם ההודעה המופלאה:

Squashed commit of the following:

ואחריה רשימה של עשרות קומיטים שאוחדו לקומיט מספר c8fd7f671 הקסום.

בלי ה Squash Commit הצעד הבא שלי היה להפעיל:

$ git log -p c8fd7f671

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

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

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

אחד הדברים שאני אוהב ב Github Actions (בטח יש את זה גם ב Gitlab ובמקומות אחרים, פשוט אני עובד בינתיים עם גיטהאב) הוא העובדה שכבר בסביבת ברירת המחדל שלהם הם התקינו המון המון ספריות וקינפגו את הכל שיעבוד יפה יחד.

בסיפור היום רציתי להפעיל את Selenium אבל לא במצב Headless Chrome אלא במצב עבודה של כרום רגיל, ועל שרת מרוחק כלומר בלי GUI. עם גיטהאב אקשנס זה לקח פחות מעשר דקות עבודה. הנה מה שצריך:

המשך קריאה...

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

בינתיים בכל מקרה וכהכנה לאירוע שני טיפים קשורים-

הראשון הוא שאתם יכולים (ותמיד יכולתם) לבחור מה יהיה שם הענף הראשון במקום master, פשוט באמצעות יצירת ענף ראשון חדש לפני הקומיט הראשון:

$ git init
$ git checkout -b main

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

$ git config --global init.defaultBranch main

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

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

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

המשך קריאה...

אחד הרעיונות שלקח לי המון זמן להבין ב git אבל אחרי שהבנתי שינה לגמרי את כל מה שעשיתי הוא הכלל הבא:

קומיטים הם לנצח, בראנצ'ים הם רק מדבקות

אני יודע, זה לא הניסוח הכי אקדמי בעולם, אבל אני מקווה שהוא מעביר את המסר.

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

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

$ git log --oneline
4980b49 (HEAD -> master) initial commit

$ git commit --amend -m 'initial commit - take 2'
$ git log --oneline
392bc0d (HEAD -> master) initial commit - take 2

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

$ git log --oneline 4980b49
4980b49 initial commit

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

החלק השני של הכלל יעזור לנו להבין את הקומיט הנסתר הזה - 4980b49. באופן רגיל פקודת log מספרת סיפור, וסיפור זה פשוט הצגת רצף קומיטים. מתחילים מהקומיט שיש עליו את המדבקה (master במקרה שלנו) וממשיכים אחורה.

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

$ git switch -c history 4980b49
$ git log --oneline
4980b49 (HEAD -> history) initial commit

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

$ git branch -f master history
$ git switch master
$ git branch -f history 392bc0d

והנה הסיפורים אחרי השינוי:

$ git log --oneline history
392bc0d (history) initial commit - take 2

$ git log --oneline master
4980b49 (HEAD -> master) initial commit

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

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

לכן כשמתכנתים שחדשים ל Git לומדים על הרעיון של Git Flow ושל פיתוח Feature Branch, אחת השאלות הראשונות שעולות היא מה לעשות כשצריך לתקן Branch ישן אחרי שכבר מיזגנו אותו. תציצו רגע במאגר הבא:

% git log --oneline --graph
*   5dc5b62 (HEAD -> master) merged
|\
| * 9630f65 (funnytext) add answer
| * 862a455 add some funny text
* | c99a456 made some changes
|/
* 7042a02 initial commit

הענף funnytext כולל שני קומיטים שנוצרו מתוך 7042a02, וענף master כולל גם הוא שני קומיטים שיצאו מתוך 7042a02, הראשון קומיט רגיל והשני הוא Merge Commit. הקומיט השני הוא זה שמעניין אותנו.

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

*   f64231d (HEAD -> master) fixed conflicts
|\
| * ad878a8 (funnytext) FIXED joke
* |   5dc5b62 merged
|\ \
| |/
| * 9630f65 add answer
| * 862a455 add some funny text
* | c99a456 made some changes
|/
* 7042a02 initial commit

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

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

% git log --oneline --graph
*   5dc5b62 (HEAD -> master) merged
|\
| * 9630f65 (funnytext) add answer
| * 862a455 add some funny text
* | c99a456 made some changes
|/
* 7042a02 initial commit

במצב כזה אחרי ה merge אם אנחנו רוצים להמשיך לשמור את funnytext בחיים הדבר הנכון לעשות הוא להזיז אותו כך שיהיה מסונכרן עם master. הרי ממילא הקונפליקטים נפתרו בקומיט 5dc5b62, ואין שום סיבה שקומיט זה (שכולל את פיתרון הקונפליקטים) לא ייכנס גם לענף funnytext. הפקודה reset יכולה לעזור להזיז ענף ולכן נפעיל:

% git switch funnytext
% git reset --hard master

וקיבלנו לוג הרבה יותר פשוט:

*   5dc5b62 (HEAD -> master, funnytext) merged
|\
| * 9630f65 add answer
| * 862a455 add some funny text
* | c99a456 made some changes
|/
* 7042a02 initial commit

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

הגיעה אליי שאלה מעניינת הבוקר במייל וחשבתי לשתף את התשובה כאן לטובת כולם. והשאלה: מה ההבדל בין הפקודה git rm --cached לבין הפקודה git reset, מתי אשתמש בכל אחת?

הדבר הראשון שצריך לזכור הוא שקומיט כולל Snapshot של הפרויקט, כלומר אפשר לחשוב עליו בתור עותק של כל הקבצים והתיקיות שבפרויקט. עד שנוצר קומיט יש לנו תיקיה זמנית שנקראת Staging Area אליה אפשר להוסיף וממנה אפשר למחוק קבצים. ברגע שנרגיש מוכנים נפעיל git commit ואז ה Staging Area יהפוך ל Commit Object ויישמר במאגר הגיט לעולמי עולמים. ה Staging Area אגב לא יימחק וימשיך לשמור את כל הקבצים והתיקיות כמו שהם היו בקומיט האחרון.

הפקודה git add לוקחת קובץ מתיקיית העבודה ומעתיקה אותו לאותה תיקיה זמנית של "הכנה לקומיט" שקראתי לה Staging Area. הפקודה git reset לוקחת קובץ מהריפוזיטורי ומעתיקה אותו לאותה תיקיה זמנית. לכן אפשר לחשוב על שתי פקודות אלה בתור הפכים. נכין רגע מאגר לדוגמא:

$ mkdir demo
$ cd demo
$ git init
$ echo one > demo.txt
$ git add demo.txt
$ git commit -m 'commit 1'

אז כשאני יוצר קובץ חדש בתיקיית העבודה אני יכול להשתמש ב git add כדי להוסיף אותו לתיקיה הזמנית:

$ echo two > two.txt
$ git add two.txt 
$ git ls-files --stage
100644 5626abf0f72e58d7a153368ba57db4c673c0e171 0   demo.txt
100644 f719efd430d52bcfc8566a43b2eb655688d38871 0   two.txt

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

$ git reset two.txt
$ git ls-files --stage
100644 5626abf0f72e58d7a153368ba57db4c673c0e171 0   demo.txt

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

לעומתה הפקודה git rm הרבה יותר דומה לפקודה git add, רק שבעוד ש git add מעתיקה קובץ מתיקיית העבודה ל Staging Area, תפקיד הפקודה git rm הוא להעתיק את ה"אין קובץ", כלומר למחוק קובץ מה Staging Area.

נזכור שהדרך הרגילה שלנו להוציא קובץ מה Staging Area היא לדרוס את הקובץ ולהחליף אותו בגירסא ששמורה בריפוזיטורי, וזה מה שעושה git reset. אבל אם אנחנו רוצים למחוק קובץ לחלוטין זה לא באמת יעזור להעתיק את הגירסא מהריפוזיטורי. במילים אחרות בדוגמא שלנו הקובץ demo.txt נמצא ב Staging Area וגם ב Repository. אם אפעיל git reset עליו לא יקרה כלום:

$ git ls-files --stage
100644 5626abf0f72e58d7a153368ba57db4c673c0e171 0   demo.txt

$ git reset demo.txt
$ git ls-files --stage
100644 5626abf0f72e58d7a153368ba57db4c673c0e171 0   demo.txt

בכל מקרה הוא נשאר ב Staging Area וייכנס לקומיט הבא. הפקודה git rm היא הדרך שלנו להעתיק את העובדה שהקובץ לא קיים אל ה Staging Area. שימו לב להשפעה שלה על המאגר שלנו:

$ git rm demo.txt
$ git ls-files --stage
$ ls
two.txt

עכשיו ה Staging Area ריק. מאחר והקומיט הבא שיווצר הוא בסך הכל עותק של ה Staging Area ברגע יצירת הקומיט, הקובץ demo.txt לא יהיה חלק מהקומיט הבא. הפעם לא לקחנו את הגירסא מהריפוזיטורי והעתקנו אותה ל Staging Area, אלא בדיוק כמו עם add לקחנו את הגירסא מתיקיית העבודה והעתקנו אותה ל Staging Area, אבל עשינו את זה אחרי שמחקנו את הקובץ מתיקיית העבודה.

ומה לגבי git rm --cached ? אם הגעתם עד לפה הסיפור שלו הוא הכי פשוט מכולם. לפעמים אנחנו תקועים עם קובץ או תיקייה שאנחנו רוצים להוציא מהריפוזיטורי אבל להשאיר אצלנו בתיקיית העבודה. דוגמא קלאסית היא תיקיית node_modules: בטעות הוספנו תיקיה זו למאגר ועכשיו אנחנו רוצים להוציא אותה, אבל התיקיה כן עוזרת לי בעבודה השוטפת ולכן אני לא רוצה למחוק אותה מהדיסק. הפעלת git rm -r node_modules לא באה בחשבון כיוון שהיא תביא למחיקת התיקיה מהדיסק. אבל, אם נוסיף את --cached נקבל גירסא של git rm שלא מוחקת את הקובץ או התיקיה מהדיסק אלא רק מה Staging Area.

בחזרה למאגר הדוגמא נחזיר את הקובץ demo.txt מהריפוזיטורי עם:

$ git restore -s HEAD demo.txt
$ git ls-files --stage
100644 5626abf0f72e58d7a153368ba57db4c673c0e171 0   demo.txt

ועכשיו נמחק אותו רק מה Staging Area בלי למחוק אותו מתיקיית העבודה:

$ git rm --cached demo.txt
$ ls
demo.txt two.txt
$ git ls-files --stage

בעבודה עם גיט אנחנו אוהבים ליצור Feature Branch עבור כל פיצ'ר שאנחנו צריכים, ובסיום העבודה להפעיל merge כדי למזג את כל הקומיטים של ה Feature Branch בחזרה לענף הראשי. בפרויקטים גדולים לפעמים משתלם לכווץ את כל הקומיטים מהפיצ'ר בראנצ' לקומיט יחיד כדי שיהיה יותר קל להסתכל בהיסטוריה.

נניח בשביל הדוגמא ש master הוא הענף הראשי ו dev הוא ענף פיצ'ר קטן, והקומיטים שלהם נראים כך:

$ git log --oneline master
b4ff474 (HEAD -> master) c4
9da14d1 c3
146d73d c2
e82851f c1

$ git log --oneline dev
433746a (dev) c7
8324f82 c6
da5ccb8 c5
146d73d c2
e82851f c1

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

$ git merge dev
$ git log --oneline --graph

*   9b80e8d (HEAD -> master) merge commit
|\  
| * 433746a (dev) c7
| * 8324f82 c6
| * da5ccb8 c5
* | b4ff474 c4
* | 9da14d1 c3
|/  
* 146d73d c2
* e82851f c1

הקומיטים c7, c6 ו-c5 שבוצעו ב Feature Branch עכשיו נמצאים איתנו ויישארו בעץ הפרויקט לנצח. דרך אחת לוותר עליהם היא "לכווץ" אותם לפני ביצוע המיזוג:

  1. נעבור לענף dev

  2. נחזיר את HEAD אחורה ל C2 בלי לשנות את הקבצים בתיקיית העבודה (עם פקודת reset). הקומיט C2 הוא הקומיט המשותף האחרון בין dev ל master ולכן זה בעצם הקומיט שהתחיל את הפיצול. החזרה אליו תאפשר לנו "לאחד" את קומיטים C5, C6 ו C7 לקומיט חדש יחיד.

  3. נעשה קומיט אחד שיכלול את כל הקבצים בתיקיית העבודה, אפשר לקרוא לו C8.

אחרי פעולה זו ב dev יהיה רק קומיט יחיד (הקומיט שעשינו בסעיף 3), וכשנמזג אותו חזרה ל master נראה בהיסטוריה רק את הקומיט היחיד הזה.

לגיט יש קיצור דרך לרצף הזה שעושה משהו אפילו יותר נחמד: הפקודה git merge --squash תיקח את כל הקבצים מענף dev למקום הנוכחי בלי להזיז את HEAD, בלי לעשות קומיט ובלי לעדכן את MERGE_HEAD. הדבר השלישי אומר שהקומיט הבא לא יהיה Merge Commit עם שני הורים אלא קומיט רגיל.

בקוד זה נראה כך:

$ git merge --squash dev
$ git log --oneline --graph

* b4ff474 (HEAD -> master) c4
* 9da14d1 c3
* 146d73d c2
* e82851f c1

עכשיו אפשר לעשות קומיט ולקבל:

$ git add .
$ git commit -m 'C8'
$ git log --oneline --graph

* 08f5e15 (HEAD -> master) C8
* b4ff474 c4
* 9da14d1 c3
* 146d73d c2
* e82851f c1

וקיבלנו מיזוג של Feature Branch לענף הראשי תוך מחיקת ההיסטוריה של ה Feature Branch ושמירה על לוג לינארי בענף הראשי.

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

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

מתקינים את הספריה עם:

$ pip install dulwich --global-option="--pure"

ומתוך תוכנית פייתון נוכל עכשיו לכתוב:

from dulwich.repo import Repo
r = Repo('.')
last_commit_id = r.head().decode('ascii')

result_filename = f'result.{last_commit_id}.txt'

with open(result_filename, 'w') as f:
    f.write('Hello World\n')

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