להבין את git reset לעומק

לעילוי נשמת הלל מנחם ויגל יעקב למשפחת יניב, מהר-ברכה, שנירצחו על היותם יהודים (ה אדר התשפ"ג).

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

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

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

שלושת העצים

גיט עובד במקביל על שלוש עצים (Git's three trees). זה לא בדיוק מבנה הנתונים עץ שאנחנו מכירים, אלא סוג של רשימה מקושרת. העצים הם working directory, staging index, commit history. ונסביר על כל אחד בנפרד.
כדי להבין את הדברים בצורה טובה נשתנש בדוגמה. נכין את הסביבה שלנו ע"י הפקודות הבאות:

rafael@DELL-RAFAELJ MINGW64 /c/learn/git
$ mkdir git_reset

rafael@DELL-RAFAELJ MINGW64 /c/learn/git
$ cd git_reset/

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset
$ git init .
Initialized empty Git repository in C:/learn/git/git_reset/.git/

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ touch a.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git add a.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git commit -m "initial commit"
[master (root-commit) a358dfe] initial commit
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 a.txt
בפקודות לעיל יצרנו ספריה בשם git_reset, נכנסו אליה ואיתחלנו אותה שתהיה תחת פיקוח של git. בתוכה יצרנו קובץ בשם a.txt והכנסנו אותו פנימה ע"י commit.

working directory

העץ הזה מייצג את השינויים הלוקאלים במחשב שלנו. למשל אם נעשה שינוי בקובץ a.txt, העץ הזה יראה לנו את השינוי הזה:


rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master) $ echo 'hello' > a.txt rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master) $ git status On branch master 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: a.txt no changes added to commit (use "git add" and/or "git commit -a")
השינויים מסומנים באדום לאחר טקסט modified.

staging index

העץ הזה מייצג את השינויים הלוקאלים שהכנסנו אותם ע"י פקודת git add כמיועדים ל-commit הבא. היישום של העץ הזה מתבצע ע"י שימוש במנגנון caching פנימי שגיט בד"כ מנסה להסתיר מאיתנו.
כדי לראות יותר לעומק את המצב של העץ הזה נשתמש בפקודה git ls-files עם הדגל s- או stage--.

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master) $ git ls-files -s 100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0 a.txt
הנתונים שהפקודה הזו נותנת לנו הם (לפי הסדר): staged contents' mode bits, object name, stage number. ה-object name זה החלק הגדול באמצע. זה בעצם SHA-1 hash רגיל של גיט. בעצם זה חישוב ה-hash של של תוכן הקבצים שגיט משתמש בו כדי לעקוב אחרי שינויים.
עכשיו נקדם את השינוי שעשינו בקובץ a.txt ע"י git add לתוך עץ ה-staging index.

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master) $ git add a.txt warning: in the working copy of 'a.txt', LF will be replaced by CRLF the next time Git touches it rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master) $ git status On branch master Changes to be committed: (use "git restore --staged <file>..." to unstage) modified: a.txt
השינויים מסומנים בירוק ויש לפניהם את הטקסט changes to be committed.
חשוב לציין ש-git status לא מראה לנו את המצב של ה-staging index במדויק. מה שהוא מראה לנו זה את השינויים בין ה-commit history, שזה העץ הבא שנדבר עליו, לבין עץ ה-staging index. כדי לבדוק את מצב ה-staging index בדיוק, נשתמש שוב בפקודה הבאה:

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master) $ git ls-files -s 100644 ce013625030ba8dba906f756967f9e9ca394464a 0 a.txt
ניתן לראות שה-SHA-1 של הקובץ a.txt השתנה ממה שהיה לפני פקודת git add.

commit history

פקודת git commit מכניסה שינויים שישמרו כ-snapshot בעץ ה-commit history. ה-snapshot כולל גם את המצב של ה-staging index בזמן ה-commit. נדגים זאת:

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git commit -m "insert some text into a.txt"
[master 043d64c] insert some text into a.txt
 1 file changed, 1 insertion(+)

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git status
On branch master
nothing to commit, working tree clean

ניתן לראות בעזרת git status שלאחר ה-commit אין שינויים באף אחד מהעצים.


אז מה בדיוק עושה פקודת git reset

האמת שהיא דומה לפקודת git checkout. פקודת checkout משנה את הפוינטר HEAD. ולעומתה, פקודת reset פועלת על הפוינטר HEAD וגם על הפוינטר של ה-branch הנוכחי. 

דוגמה תסביר את זה הכי טוב:

בדוגמה זו יש רצף של commits ב-branch שנקרא main. כאשר גם ה-HEAD וגם המצביע של ה-branch מצביעים ל-commit שנקרא d. עכשיו נשווה את הפעולה של checkout מול reset.

אם נבצע git checkout b נקבל את המצב הבא:

רואים שרק המצביע HEAD השתנה. (המצב הזה נקרא deatached HEAD כיון שבמצב הזה HEAD לא מחובר לשום branch). 

ואם נבצע את הפקודה git reset b נקבל את המצב הבא:

כאן רואים שפקודת reset משפיעה גם על HEAD וגם על המצביע של ה-branch.

ה-HEAD והפוינטר של ה-branch נקראים commit ref pointers. ופעולות עליהם הן פעולות בעץ ה-commit history. 

ל-git reset יש 3 אפשרויות, hard, mixed, soft. והן קובעות איך ישתנו שני העצים האחרים. 

בקצרה:

hard-- זו האפשרות הכי מסוכנת. הפעולה הזו גם משנה את הפוינטר ל-commit המבוקש. אבל היא גם מוחקת את השינויים שהיו על ה-staged tree ועל ה-working directory tree.

mixed-- זו אפשרות הדיפולט. היא משנה את הפוינטר ל-commit המבוקש. והיא מעבירה שינויים שהיו על ה-staged tree ל working directory tree. כאילו שלא עשינו להם git add.

soft-- זו האפשרות העדינה ביותר. היא משנה את הפוינטר ל-commit המבוקש. ולא נוגעת ב-staged tree וב-working directory tree.



הפקודה הדיפולטיבית

אם נשתמש בפקודה git reset בלי לציין שום דבר נוסף, זה יהיה זהה לפקודה git reset --mixed HEAD.

הפקודה הזו בעצם תגרום למצביע HEAD ולמצביע של ה-branch להצביע על HEAD. ותעביר שינויים שהיו על ה-staged tree ל working directory tree.

שימוש ב git reset --hard commit_sha1

האפשרות המסוכנת ביותר ועם זאת היא האפשרות שככל הנראה הכי הרבה משתמשים בה. דבר ראשון, כמו בכל שימוש של git reset הפוינטרים של HEAD ושל ה-branch הנוכחי עוברים להצביע על ה-commit_sha1 שאותו בחרנו בפקודה. בנוסף לזה, העצים staging index, working directory מאותחלים כך שגם הם יהיו על אותו commit_sha1 שבחרנו. בפקודה הזו אנחנו מקבלים סביבת עבודה נקיה משינויים. זו הסיבה שזו הפקודה שהכי בשימוש.

כל שינוי שנעשה על ה-working directory וכל שינוי שמחכה על ה-staging index ימחקו כדי להביא את העצים הללו למצב זהה לאותו commit_sha1 שבחרנו.

נדגים את זה בצורה הבאה, נכניס שינוי לעץ ה-working directory ולעץ ה-staging:

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master) $ echo 'some text' > b.txt rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master) $ git add b.txt
rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master) $ echo 'adding some text' >> a.txt rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master) $ git status On branch master Changes to be committed: (use "git restore --staged <file>..." to unstage) new file: b.txt 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: a.txt
יצרנו קובץ חדש שנקרא b.txt וכתבנו בו 'some text' והכנסנו אותו ל-staging. בנוסף הוספנו טקסט לקובץ a.txt. עכשיו יש לנו שינויים בשני העצים.
עכשיו נשתמש ב- git reset --hard ונראה את התוצאה:

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git reset --hard
HEAD is now at 043d64c insert some text into a.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git status
On branch master
nothing to commit, working tree clean
כפי שרואים נמחקו השינויים שהיו ב-staging וב-working directory. כדאי לזכור שאין פקודת git שמסוגלת להחזיר את המצב לקדמותו, ולכן מומלץ מאוד להיזהר.
לא ציינתי לאן לעשות reset ולכן ה-default היה בשימוש שהוא בעצם ה-HEAD. זה אומר שהעצים חוזרים להיות באותו commit שעליו מצביע HEAD.

שימוש ב mixed--

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

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ echo 'some text' > b.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git add b.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ echo 'adding some text' >> a.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        new file:   b.txt

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:   a.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git reset --mixed
Unstaged changes after reset:
M       a.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git status
On branch master
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:   a.txt

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        b.txt

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

במקרה הזה ניתן לראות שהשינויים שהיו ב-staging (הקובץ b.txt) ירדו עכשיו להיות ב-working directory. ואילו השינויים שהיו ב-working directory (שינוי הקובץ a.txt) נשארו עדיין.

שימוש ב soft--

האפשרות של soft משנה רק את המצביעים של ה-HEAD ושל ה-branch הנוכחי, ולא משנה בכלל את עץ ה-staging וה-working directory. 

כדי להבין איך בדיוק soft פועל נשתמש בדוגמה אחרת. נתחיל עם קובץ a.txt ונכניס לתוכו שלוש שורות, כל שורה תהיה ב-commit נפרד:


rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ echo 'one' >> a.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git commit -am "one"
warning: in the working copy of 'a.txt', LF will be replaced by CRLF the next time Git touches it
[master 3faafaf] one
 1 file changed, 1 insertion(+)

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ echo 'two' >> a.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git commit -am "two"
warning: in the working copy of 'a.txt', LF will be replaced by CRLF the next time Git touches it
[master 5dc4b70] two
 1 file changed, 1 insertion(+)

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ echo 'three' >> a.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git commit -am "three"
warning: in the working copy of 'a.txt', LF will be replaced by CRLF the next time Git touches it
[master d3308f2] three
 1 file changed, 1 insertion(+)

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ cat a.txt
one
two
three
נשתמש ב-git log כדי לראות את שלושת ה-commits:

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git log
commit d3308f222fed7811c93d868a3aed9aa5c4fe6736 (HEAD -> master)
Author: Rafael Jan <rafael.jan@inx.co>
Date:   Fri Mar 3 00:55:23 2023 +0200

    three

commit 5dc4b707348e31a12a934120b97e8d684277eee0
Author: Rafael Jan <rafael.jan@inx.co>
Date:   Fri Mar 3 00:55:07 2023 +0200

    two

commit 3faafaffec7c7078db091ac2e87ddf44f6279a4c
Author: Rafael Jan <rafael.jan@inx.co>
Date:   Fri Mar 3 00:54:26 2023 +0200

    one

commit a358dfe0f11dcf512be74a8fe83bf3efb0bb25dd
Author: Rafael Jan <rafael.jan@inx.co>
Date:   Thu Feb 23 10:00:41 2023 +0200

    initial commit
נכניס לקובץ שורה רביעית כדי שיהיה שינוי בעץ ה-working directory:

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ echo 'four' >> a.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git status
On branch master
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:   a.txt

no changes added to commit (use "git add" and/or "git commit -a")
עכשיו נשתמש ב-git reset --soft כדי לחזור ל-commit שבו הכנסנו את השורה שבה כתוב one:

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git reset --soft 3faafaffec7c7078db091ac2e87ddf44f6279a4c
ונבדוק מה הסטטוס:

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git status
On branch master
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   a.txt

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:   a.txt


rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ cat a.txt
one
two
three
four
במבט ראשון זה נראה מוזר. פתאום נהיו לנו שינויים ב-staging. וכשבודקים את התוכן של הקובץ a.txt נראה שהוא לא השתנה למרות שחזרנו ל-commit שהיה בו רק את שורה one.

אז מה בעצם קרה פה?
הפקודה reset החזירה אותנו ל-commit שבו יש רק את שורה one. אבל בגלל שהשתמשנו ב-soft היא לא שינתה את העץ של ה-working directory שבו כבר היה את הקובץ a.txt עם ארבעת השורות. ולכן המצב כרגע הוא שהשורה one היא כבר בעץ ה-commit history. השורה four היא בעץ ה-working directory. ואילו שתי השורות האמצעיות, two, three, הן נמצאות בעץ ה-staging.
אפשר להוכיח את זה ע"י פעולת restore שמבטלת שינויים ב-working directory וע"י פעול restore --staged שמבטלת שינויים ב-staging.

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git restore a.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ cat a.txt
one
two
three
ביטלנו את השינויים ב-working directory ואפשר לראות ששורה four נעלמה.
עכשיו נבטל את השינויים ב-staging:

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git restore --staged a.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git status
On branch master
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:   a.txt

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

rafae@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ cat a.txt
one
two
three
רואים שבקובץ a.txt יש רק שינויים ב-working directory כי הפקודה restore --staged ביטלה את השינויים ב-staging והעבירה אותם ל-working directory. ואם עכשיו שוב נבטל את השינויים ב-working directory נישאר רק עם שורת one:

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git restore a.txt

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git status
On branch master
nothing to commit, working tree clean

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ cat a.txt
one
קצת מורכב, אבל אני חושב שההיגיון ברור.
כמובן שהלוג יראה לנו שה-commits האחרים נעלמו, מה שקורה בכל פעול reset בכל שלושת הסוגים:

rafael@DELL-RAFAELJ MINGW64 /c/learn/git/git_reset (master)
$ git log
commit 3faafaffec7c7078db091ac2e87ddf44f6279a4c (HEAD -> master)
Author: Rafael Jan <rafael.jan@inx.co>
Date:   Fri Mar 3 00:54:26 2023 +0200

    one

commit a358dfe0f11dcf512be74a8fe83bf3efb0bb25dd
Author: Rafael Jan <rafael.jan@inx.co>
Date:   Thu Feb 23 10:00:41 2023 +0200

    initial commit

פעולת reset לעומת פעול revert

פעולת git revert מאפשרת לבטל commit ע"י commit חדש שמחזיר את השינויים שהיו ב-commit אותו אנו רוצים לבטל. בצורה הזו, כל השינויים נשמרים בגיט ויש אפשרות להחזיר אותם אם רוצים.
אם פעולת revert נחשבת לפעולה הבטוחה כשרוצים להחזיר שינויים, אז פעול reset נחשבת לפעולה המסוכנת. אמנם reset לא מוחקת שום commit אבל היא גורמת לכך ש-commits יהפכו להיות מיותמים (orphaned). במילה מיותמים הכוונה היא שה-commits האלו התנתקו משרשרת ה-commits והם אמנם קיימים אבל לא נראה אותם בלוג של שום branch. 
בכל זאת יש פקודה שמאפשרת לראות ולהחזיר commits שהתנתקו והיא פקודת ה-git reflog.
רק שכדאי לדעת שלגיט יש garbage collector שרץ בדיפולט כל 30 יום ומוחק commits שהתנתקו. ואז באמת מאבדים את ה-commits המנותקים. ולכן צריך להכיר טוב את פעולת reset משום שהיא אחת הפעולות היחידות שמאפשרת לאבד לחלוטין commits ללא יכולת חזרה.

המסקנה מכל זה היא ש-revert היא הפעולה הנכונה לשימוש כאשר רוצים לעשות undo ל-commit ואילו פעולת reset היא הפעולה הנכונה לשימוש כשרוצים לבטל שינויים ב-staging וב-working directory.

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

מצד שני, זה בסדר להשתמש ב-reset על commits שעדיין לא פורסמו אלא הם עדיין רק לוקאלים. למשל, אם עשיתי שינוי בקובץ ועשיתי לו commit. ואז עשיתי שוב שינוי באותו קובץ ושוב commit. ועכשיו אני רוצה לבטל את 2 ה-commits, כיון שעדיין לא פירמתי אותם עם push אני יכול בביטחה להשתמש ב-reset. כדי למחוק שתי commits אפשר פשוט להשתמש בכתיבה הנוחה הזו:

$ git reset --hard HEAD~2

סיכום

פעולת git reset היא אחת הפעולות המסוכנות שיש בגיט ואחת היחידות שיכולה לגרום לאיבוד מידע לחלוטין. השימוש בה הוא בעיקר לביטול שינויים בעץ ה-staging וה-working directory, או לביטול commits לוקאלים שעדיין לא פורסמו. 

עבור commits שכבר פורסמו נשתמש תמיד בפקודת revert, שמבטלת את השינויים, תוך כדי יצירת commit נוסף וכך שומרת את כל ההיסטוריה.


יגל יעקב - הלל מנחם



אם אהבתם, תכתבו משהו למטה...


מקורות:

מבוסס בעיקר על המאמר הזה: https://www.atlassian.com/git/tutorials/undoing-changes/git-reset