Git checkout

Posted by Adam on August 24, 2022
# git checkout --orphan:開一條「沒有歷史」的分支 ## 什麼時候會需要 幾個常見的情境: - 專案累積了幾年的 commit,裡面混著不該公開的東西,想「從現在這個狀態重新開始」,但又不想丟掉現有檔案。 - 想在同一個 repo 放一份完全獨立的內容,例如網站靜態檔(`gh-pages`)、文件、部署產物,不想讓它跟程式碼共用歷史。 - 想建立一個乾淨的第一個 commit,作為新的起點。 這幾件事共通的需求是:**新分支的第一個 commit 不要有父代。** ## 先建立畫面:commit 是一條鏈,每一環都記得上一環 把 git 的歷史想成一條鏈子,每個 commit 都記著「我的上一個 commit 是誰」(parent)。 ``` c1 ← c2 ← c3 master ``` 一般的 `git checkout -b feature` 是在鏈子的某一環上再接一條新分支,新分支的歷史**繼承**了 master 的整條鏈。 `--orphan` 做的事不一樣:它讓 `HEAD` 指向一個**還不存在**的分支名稱。因為這個分支還沒有任何 commit,你接下來做的第一個 commit,就找不到「上一環」可以記,於是成為一條新鏈的起點(root commit)。 ``` c1 ← c2 master (兩條鏈互不相連) x 新分支的第一個 commit,沒有 parent ``` ## 指令 ```bash git checkout --orphan <新分支名稱> ``` 新版 git 也可以用 `git switch --orphan <新分支名稱>`,行為略有不同,見後面「實驗 4」。 ## 實驗:實際看一次發生什麼 以下都在一個暫存 repo 裡實際執行(git 2.54.0)。起點是 `master` 上有兩個 commit,另外有一個沒被追蹤的檔案 `untracked.log`: ``` b769e99 c2 add b 8caf476 c1 add a ``` ### 實驗 1:下指令之後,狀態長什麼樣 ```bash git checkout --orphan fresh ``` 接著依序檢查: ``` --- branch 清單 master ← 看不到 fresh --- HEAD 指向 refs/heads/fresh ← 但 HEAD 已經指向它了 --- status A a.txt ← 原本的檔案全部變成「待新增」 A b.txt ?? untracked.log --- log 致命錯誤: 您的目前分支 'fresh' 尚無任何提交 --- master 還在嗎 b769e99 ← 完全沒動 ``` 三個重點: 1. **分支名稱此時只存在於 `HEAD`,分支清單看不到**,因為分支本質上是「指向某個 commit 的名字」,沒有 commit 就沒有東西可以指。 2. **檔案都還在**。git 沒有動你的工作目錄,只是把「現在的內容」全部當成要放進第一個 commit 的新檔案(暫存區裡全是 `A`)。 3. **舊分支完全沒被碰**。建立 orphan 只是新增,不會刪任何東西。 ### 實驗 2:做第一個 commit ```bash git commit -m "fresh root" ``` ``` * fresh e640695 fresh root master b769e99 c2 add b ``` 現在分支出現了。檢查這個 commit 是不是真的沒有爸爸: ``` $ git cat-file -p fresh tree 6ffe151c467ccc36a00dacb41acd215d7a0179ed author lab <lab@example.com> ... committer lab <lab@example.com> ... ``` 一般的 commit 在 `tree` 之後會有一行 `parent <hash>`,這裡沒有。再確認兩條歷史完全沒有共同祖先: ``` $ git rev-list --max-parents=0 fresh # 列出所有 root commit e640695 $ git merge-base master fresh # 找共同祖先 (沒有輸出,exit code = 1) ``` ### 實驗 3:兩條不相干的歷史能合併嗎 ``` $ git checkout master && git merge fresh 致命錯誤: 拒絕合併無關的歷史 ``` git 預設不讓兩條沒有共同祖先的歷史合併,因為這通常代表你合錯分支了。真的要合,必須明講: ```bash git merge --allow-unrelated-histories fresh ``` ``` * 1b03b45 merge unrelated |\ | * e640695 fresh root * b769e99 c2 add b * 8caf476 c1 add a ``` 合併之後兩條鏈才接在一起,但 `fresh root` 本身仍然沒有 parent。 ### 實驗 4:想從空白開始,而不是帶著舊檔案 `--orphan` 預設把舊檔案留下來。要清空,補一個 `git rm -rf .`: ```bash git checkout --orphan clean git rm -rf . ``` ``` --- 工作目錄剩什麼 untracked.log ← 沒被追蹤的檔案不會被清掉 --- status ?? untracked.log ``` `git rm` 只處理「被 git 追蹤的檔案」。**沒被追蹤的檔案(包含被 `.gitignore` 擋掉的,例如 `node_modules`、build 輸出)會留在原地**,不會隨著清空消失。要連它們一起清,得另外處理,而這是會刪掉東西的動作,下手前先確認。 清空之後如果想先留一個空的起點: ```bash git commit --allow-empty -m "empty root" ``` 這個 commit 裡的檔案數是 0(`git ls-tree -r HEAD | wc -l` 得到 `0`)。 `git switch --orphan` 則是直接幫你做了「清空被追蹤檔案」這一步: ```bash git switch --orphan clean2 ``` ``` --- 工作目錄剩什麼 untracked.log ← 被追蹤的檔案已清掉,沒被追蹤的仍在 ``` | | 被追蹤的舊檔案 | 沒被追蹤的檔案 | |---|---|---| | `git checkout --orphan` | 保留(放在暫存區) | 保留 | | `git switch --orphan` | 清掉 | 保留 | ### 實驗 5:只開分支、沒 commit 就走人 ```bash git switch master git branch --list 'clean2' # 空的 ``` 分支沒有 commit 就等於不存在,離開之後它就消失了,沒有任何殘留。 ## 常見做法:用 orphan 重建 master,丟掉舊歷史 ```bash git checkout --orphan newroot git add -A git commit -m "squashed start" git branch -D master # 刪掉舊的 master git branch -m master # 把 newroot 改名成 master ``` 實際執行的結果: ``` 已刪除分支 master(曾為 b769e99)。 ← git 會把舊的 hash 印出來 6f23096 squashed start ← 現在的 master 只有一個 commit ``` **舊歷史真的丟了嗎?** 沒有那麼快。分支被刪掉,只是「指向 commit 的名字」不見了,commit 本身還在 repo 裡: ``` $ git cat-file -t b769e99 commit ← 物件還在 $ git reflog | grep b769e99 b769e99 HEAD@{3}: checkout: ... ← reflog 找得到 $ git branch rescued b769e99 ← 重新替它取名字就救回來了 $ git log --oneline rescued b769e99 c2 add b 8caf476 c1 add a ``` 所以風險分兩層: - **本機**:刪掉分支後,在 git 清理(gc)過期之前,可以用 `reflog` 或刪除時印出的 hash 救回。 - **遠端**:如果接著 `git push --force` 覆蓋遠端的 master,別人的複本、CI、PR 都還指著舊歷史,團隊要重新 clone 或 reset。這部分我沒有在實驗裡驗證,但影響面是整個團隊,動手前務必先確認。 ## 成立條件與例外 - 實驗用的是 git 2.54.0。`--orphan` 在 `git checkout` 與 `git switch` 上都有,但 `git switch --orphan` 需要 git 2.23 以上。 - 「檔案會留在暫存區」是 `git checkout --orphan` 的行為;如果你的 git 版本或別名行為不同,先用 `git status` 看一眼再繼續。 - 本筆記的實驗只涵蓋本機 repo;與遠端(`push`、保護分支、PR)互動的情況沒有實驗。 ## 速查 | 想做的事 | 指令 | |---|---| | 開一條沒有歷史、但保留現有檔案的分支 | `git checkout --orphan <名稱>` 然後 `git commit` | | 開一條完全空白的分支 | `git checkout --orphan <名稱>` → `git rm -rf .` → `git commit --allow-empty` | | 同上,但一步清掉被追蹤的檔案 | `git switch --orphan <名稱>` | | 確認某分支是不是獨立歷史 | `git merge-base <分支A> <分支B>`(沒輸出=沒有共同祖先) | | 合併兩條獨立歷史 | `git merge --allow-unrelated-histories <分支>` | | 看某分支的起點 commit | `git rev-list --max-parents=0 <分支>` | | 救回被刪掉的分支 | `git branch <名稱> <刪除時印出的 hash>`,或從 `git reflog` 找 |