AI 把 iOS App 改壞了?先保住其他修改,再只還原壞掉的地方

你請 Codex 改一個小功能,結果 App 壞了。你想回到剛才能跑的樣子,但這段時間你自己也改了別的畫面、寫了筆記,不想一起丟掉。這篇寫給正在用 AI、Codex 和 Xcode 做第一個 App 的你。做法是先停手、整包備份,再用 Git 看清楚改了哪裡,只還原確認過的那一處,最後用同一個功能檢查確認真的修好了。文中引用的指令輸出,都來自隔離練習資料夾的實際操作。沒有實測的部分,例如介面操作與 worktree 完整備份,會在文中另外標明。

學域 Realms 編輯團隊17 分鐘

先別急著叫 AI「改回來」

App 壞掉時,最直覺的做法是跟 Codex 說「改回去」,或上網找一行還原指令。這兩種做法都有風險。AI 會在已經壞掉的程式上繼續改,越改越遠。網路上的一行指令,影響範圍常常比你以為的大,可能把你自己還沒存的修改一起蓋掉。

比較安全的順序是:

  1. 停手,把整個專案備份一份
  2. 寫下「正常」應該長什麼樣子
  3. 看清楚改了哪裡、現在是什麼狀態
  4. 只還原核對過的範圍
  5. 用同一個檢查,前後對照驗收

本文引用的輸出,是在 Mac 終端機、隔離的練習資料夾裡實際跑出來的(Git 2.50.1、Swift 6.2.4)。下面這些不在這次實測裡:Xcode 與 Codex 的介面、iPhone。這些部分是依官方文件整理,文中會標明。詳細範圍寫在文末。

先認識這幾個詞

只要懂下面這些詞,就能判斷該做什麼:

  • 工作中的檔案(working tree):你資料夾裡現在的檔案,也就是 Xcode 正在編輯的那些。
  • 暫存區(staged,也叫 index):執行 git add,或在 Xcode、Codex 裡按 Stage 之後,檔案的這個版本會被放進暫存區。下一個 commit 記錄的就是暫存區的內容。暫存區裡的版本可以跟你畫面上的檔案不一樣。
  • commit:一個存檔點。它記錄的是當下暫存區的內容,也就是之前已經存進 Git 的檔案,加上你這次 add 進去的修改。沒有 add 的修改,還有 Git 沒追蹤的檔案,都不會被存進去。
  • HEAD:你目前這條線上最新的 commit。它只代表「最新」,不代表「正常」。
  • branch(分支):另一條存檔線,可以把實驗放在旁邊,不影響主線。注意:分支只保存已經 commit 的內容。還沒 commit 的修改不屬於任何分支。
  • untracked:目前沒有被 Git 追蹤的檔案,例如你剛寫、還沒 add 過的筆記。
  • diff:兩個版本之間逐行的差異。
  • push:把 commit 上傳到 GitHub 之類的遠端。

第一步:停手,整包備份

  • 停掉 Codex 正在跑的任務,先不要再給它新的指示。
  • 在 Xcode 存檔,然後先不要再動任何檔案。
  • 打開終端機,進入專案資料夾,確認這個專案有沒有 Git:
指令

貼上前,先確認路徑、檔名、分支名稱或 commit 編號,必要時換成你自己的

cd ~/你的專案資料夾
git rev-parse --show-toplevel

如果印出一個路徑,就表示有 Git,這個路徑就是 Git 管理的範圍(叫做 repo)。如果它跟你現在的位置不同,先 cd 到那個路徑,後面的指令都在那裡執行。

如果出現 not a git repository,表示這個專案沒有 Git 存檔,Git 沒辦法帶你回到過去。這時一樣先做下面的備份,再看本文後面的「專案沒有 Git 怎麼辦」。

確認 .git 是一般資料夾

指令
ls -ld .git
  • 開頭是 d(例如 drwxr-xr-x):.git 是一般資料夾,存檔紀錄就放在專案裡,整包複製時會一起帶走。本文假設的就是這種情況。
  • 開頭是 -(代表一個檔案)或 l(代表連結):這個專案的 Git 紀錄放在別的地方,例如這個資料夾是一個 worktree。這種情況超出本文範圍,整包複製只會帶走工作中的檔案,不等於備份了完整紀錄。請照樣做下面的整包備份,再用 git rev-parse --git-common-dir 查出紀錄真正的位置,另外備份那裡。不確定的話,先找熟悉 Git 的人確認,再往下做。(這段依 Git 文件整理,本文沒有實測。)

複製到新資料夾

一次只執行一行,看完結果再執行下一行。先建立備份資料夾:

指令

貼上前,先確認路徑、檔名、分支名稱或 commit 編號,必要時換成你自己的

mkdir ~/Desktop/MyApp-backup-1005

如果出現 File exists,表示這個名字已經用過了。先停下來,換一個新名字再執行一次,不要直接執行下一行,否則新的備份會跟舊資料混在一起。

建立成功後,把專案的全部內容(包括隱藏的 .git)複製進去:

指令

貼上前,先確認路徑、檔名、分支名稱或 commit 編號,必要時換成你自己的

cp -R ~/你的專案資料夾/. ~/Desktop/MyApp-backup-1005/
指令

貼上前,先確認路徑、檔名、分支名稱或 commit 編號,必要時換成你自己的

ls -a ~/Desktop/MyApp-backup-1005
  • 來源路徑結尾的 /. 代表「這個資料夾裡的所有東西,包括隱藏檔」。
  • 列出的檔案裡要看得到 .git。
  • 路徑裡有空格時,要用引號把路徑包起來,並把 ~ 改寫成 $HOME,因為 ~ 放進引號裡就不會被展開:
指令

貼上前,先確認路徑、檔名、分支名稱或 commit 編號,必要時換成你自己的

cp -R "$HOME/Documents/My App/." "$HOME/Desktop/MyApp-backup-1005/"

這份備份保留了壞掉當下的狀態,會一起複製到的有:

  • AI 的修改
  • 你自己還沒 commit 的修改
  • Git 沒追蹤的檔案
  • 被 .gitignore 排除的檔案

之後就算還原錯了,也還有退路。這段備份指令已在隔離的一般 Git 資料夾中測過,包含隱藏的 .git、未追蹤檔案與壞掉的版本。worktree 的完整備份則沒有測過。

第二步:先寫下「正常」要長怎樣

還原之前,先寫一張很短的驗收單:

  • 壞掉的功能:做什麼操作,應該看到什麼結果。例如:「完成 8 個步驟後,畫面要顯示還剩 2 步。」
  • 一個你最近改過、現在應該正常的其他功能:用來確認還原時沒有誤傷別的東西。
  • 用哪台模擬器或哪支手機測:前後都要用同一台。

寫完後,先在現在這個壞掉的版本上照著跑一次,記下實際看到的結果。先重現「壞掉」,之後說「修好了」才有東西可以對照,而不只是「看起來好像回去了」。

第三步:看清楚改了什麼

指令
git status --short

練習資料夾裡的實際輸出:

輸出範例(對照用,不需輸入)
 M Budget.swift
 M Title.swift
?? notes.txt

每一行最前面有兩個字元。第一個字元比較的是暫存區和 HEAD,第二個字元比較的是工作中的檔案和暫存區:

  • M(M 在右邊):工作中的檔案跟暫存區不一樣,也就是改了但還沒 stage。
  • M (M 在左邊):暫存區跟 HEAD 不一樣,工作中的檔案跟暫存區相同,也就是改過而且已經 stage。
  • MM:兩邊都有差異,代表 stage 之後又改了一次。
  • ??:目前沒有被 Git 追蹤的檔案。

status 只告訴你哪些檔案在 HEAD、暫存區、工作中的檔案之間有差異。它不會告訴你哪裡壞了。已經 commit 進 HEAD 的修改,它也不會列出來。所以接下來要看實際的內容:

指令

貼上前,先確認路徑、檔名、分支名稱或 commit 編號,必要時換成你自己的

git diff -- Budget.swift
git diff --staged -- Budget.swift

第一個指令看「還沒 stage」的差異,第二個看「已經 stage」的差異。兩個都要看,因為同一個檔案可能兩邊都有修改。

練習裡被 AI 改壞的地方長這樣:

變更示意(對照用,不需輸入)
 func remainingSteps(completed: Int) -> Int {
-    max(0, 10 - completed)
+    10
 }

- 開頭的是原本的程式,+ 開頭的是現在的程式。一眼就能看出,原本的計算被換成了固定的 10。

如果 status 是乾淨的,或者 diff 裡看不到可疑的地方,壞掉的修改可能已經被 commit 了。這時改看存檔紀錄:

指令

貼上前,先確認路徑、檔名、分支名稱或 commit 編號,必要時換成你自己的

git log --oneline -5
git show <commit 編號>

log 會列出最近的存檔點,show 讓你看某一個存檔點改了什麼。

還有一件事要注意:AI 常常一次改好幾個互相依賴的檔案。例如它改了一個函式的名稱,也同時改了所有呼叫這個函式的地方。如果你只還原其中一個檔案,程式可能反而編譯不過。看 diff 時,把「一起被改的」當成同一組來判斷。

第四步:只還原你核對過的檔案

先確認拿來還原的版本是正常的

「還原」就是拿某個版本的檔案蓋掉你現在的檔案。所以動手之前,先看一下你要拿來蓋的那個版本:

指令

貼上前,先確認路徑、檔名、分支名稱或 commit 編號,必要時換成你自己的

git show HEAD:Budget.swift

確認印出來的內容是你要的正常版本。如果在 App 壞掉之後,你或 AI 又 commit 過,那 HEAD 本身可能就是壞的。這種情況下:

  1. 用 git log --oneline -- Budget.swift 找出這個檔案更早的存檔點。
  2. 用 git show <編號>:Budget.swift 確認那個版本的內容。
  3. 確認沒問題後,把下面指令裡的 HEAD 換成那個編號。

本文的實測只測了以 HEAD 為來源的情況。指定其他編號的用法是依照官方文件說明。

檔案還沒 stage 時

指令

貼上前,先確認路徑、檔名、分支名稱或 commit 編號,必要時換成你自己的

git restore --source=HEAD -- Budget.swift
  • --source=HEAD:指定要拿哪個版本來蓋。
  • -- Budget.swift:只蓋這一個檔案。路徑一定要寫清楚。

這個指令會直接覆寫檔案。被蓋掉的內容如果從來沒有 stage 或 commit 過,Git 不保證找得回來。所以第一步要先備份。

實測結果:還原後,功能檢查從 10 回到正確的 2。這時 git status --short 顯示:

輸出範例(對照用,不需輸入)
 M Title.swift
?? notes.txt

Budget.swift 已經跟 HEAD 一樣,所以不再出現在清單裡。Title.swift 裡還沒 commit 的修改,還有 notes.txt,都還在。

陷阱:檔案已經 stage 了

如果 status 顯示 M (M 在左邊),表示壞掉的版本已經被 stage 了。這時直接 restore,實測結果是這樣:

輸出範例(對照用,不需輸入)
$ git restore -- Budget.swift
(重新編譯並執行檢查)
remainingSteps(completed: 8) = 10

指令沒有任何錯誤訊息,但檔案還是壞的。原因是:沒有指定來源時,git restore 會拿暫存區裡的版本來蓋,而暫存區裡放的正是壞掉的版本。結果就是用壞的版本蓋壞的版本。

正確做法要分兩步:

指令

貼上前,先確認路徑、檔名、分支名稱或 commit 編號,必要時換成你自己的

git restore --staged -- Budget.swift
git restore --source=HEAD -- Budget.swift
  • 第一步只是把檔案移出暫存區,實測 status 會變回 M。依照 git-restore 文件,--staged 只會動暫存區,不會改你的檔案內容,所以這時檔案還是壞的。
  • 第二步才真正把檔案換回存檔版本。實測檢查結果回到 2。

如果狀態是 MM,處理方式相同,但第二步會連同你在這個檔案裡的其他修改一起蓋掉。如果你只想退掉其中一段,可以用下面的介面逐段還原,或從備份裡手動複製你要留的部分。

用 Codex 的 review 面板還原

依照 OpenAI 的官方文件,Codex app 的 review 面板可以切換三種範圍:Last turn(上一輪的修改)、Unstaged、Staged。你可以對整份差異、單一檔案或單一段落(hunk)做 stage、unstage 或 revert。文件也特別說明,面板顯示的是整個 Git repo 的狀態。所以裡面也會有你自己的修改,以及其他工作留下、還沒 commit 的修改。

所以這只是同一套原則的另一個操作入口:

  • 一樣先做第一步的備份。
  • 先看清楚你在哪個分頁。Unstaged 和 Staged 就是前面講的兩種狀態。
  • 不要對整份差異按 revert。只對你在 diff 裡核對過的那個檔案或那一段操作。
  • 操作完回到終端機跑 git status --short,確認其他檔案還在。

本文沒有實際操作 Codex 的介面,按鈕的位置和名稱請以你使用的版本為準。我們只找到 Codex app 的官方說明。IDE 擴充或 CLI 有沒有同樣的功能,請查看官方文件。Xcode 也可以查看修改和建立 commit(Apple 文件目前寫的位置是 Integrate > Commit),這部分我們同樣沒有逐一測試各個版本。

第五步:壞掉的修改已經 commit 了,用 revert

revert 會建立一個新的 commit,內容剛好是把某個舊 commit 的修改反過來。它不會改寫或刪除既有的紀錄,所以已經 push 出去的 commit 也適合用它處理。不過動手前要知道兩件事:

  • revert 反轉的是整個 commit。如果那個 commit 裡除了壞掉的修改,還有你想保留的功能,兩者會一起被反轉。
  • 如果後來同一段程式又被改過,revert 可能會發生衝突。

先確認要反轉的是哪一個 commit

指令

貼上前,先確認路徑、檔名、分支名稱或 commit 編號,必要時換成你自己的

git log --oneline -5
git show <commit 編號>

用 show 確認那個 commit 裡的確是壞掉的修改,不要直接假設是最新的那一個。如果裡面還混了你想保留的東西,就不適合直接 revert 整個 commit。這時改用第四步的方式,只處理核對過的檔案。

有還沒 commit 的修改時,revert 可能會被擋下

實測時,Budget.swift 已經有一個壞掉的 commit,而工作中的檔案又多了一些還沒 commit 的修改。這時執行 revert:

輸出範例(對照用,不需輸入)
error: Your local changes to the following files would be overwritten by merge:
	Budget.swift
Please commit your changes or stash them before you merge.
Aborting
fatal: revert failed

這是 Git 在保護你。revert 需要改動 Budget.swift,而這個檔案裡還有沒 commit 的修改。這次會被擋下,是因為修改剛好在同一個檔案。如果修改在別的檔案,結果不一定一樣。

處理方式是先把這份修改存到旁邊的分支。開始之前,先跑 git status --short 看清楚目前有哪些修改,再記下你原本在哪個分支:

指令
git branch --show-current

接著(把 main 換成上面印出的分支名稱):

指令

貼上前,先確認路徑、檔名、分支名稱或 commit 編號,必要時換成你自己的

git switch -c saved-attempt
git add Budget.swift
git commit -m 'Preserve unsaved attempt'
git switch main

這幾行依序是:

  1. 開一個新分支並切過去。
  2. 只 add 你要保存的那個檔案。
  3. 在新分支上 commit。
  4. 切回原本的分支。

這份修改已經 commit 在 saved-attempt 上了,所以切回 main 之後,Budget.swift 會變回 main 上的版本。

其他沒有 add 的修改不屬於任何分支。依照 git-switch 文件,切換時如果不會覆蓋到它們,它們通常會跟著留在你的工作中檔案裡。如果會被覆蓋,Git 會拒絕切換,並列出是哪些檔案。這時請照提示處理,不要加強制參數硬切。

然後執行 revert:

指令

貼上前,先確認路徑、檔名、分支名稱或 commit 編號,必要時換成你自己的

git revert --no-edit <壞掉的 commit 編號>

--no-edit 的意思是直接使用預設的說明文字。實測輸出:

輸出範例(對照用,不需輸入)
[main 1b965b7] Revert "Broken calculation"
 Date: Mon Oct 5 09:11:14 2026 +0800
 1 file changed, 1 insertion(+), 1 deletion(-)

檢查結果回到 2。這時 git log --oneline -3 顯示:

輸出範例(對照用,不需輸入)
1b965b7 Revert "Broken calculation"
baff1fe Broken calculation
b8ff7ec Working baseline

壞掉的 commit 還留在紀錄裡,上面多了一個把它反轉的新 commit。先前存到 saved-attempt 的內容也還在,可以用 git show saved-attempt:Budget.swift 查看。

出現衝突就先停下來

如果在壞掉的 commit 之後,同一段程式又被改過,revert 可能會出現衝突(conflict),Git 會停下來等你處理。這時候:

  • 不要為了讓錯誤訊息消失,就刪掉檔案或整段程式。
  • 不要叫 AI「把衝突全部解掉」。
  • 先跑 git status 看是哪些檔案衝突。如果沒把握,就執行 git revert --abort 回到 revert 之前的狀態,再回頭用 diff 慢慢判斷。

已經 push 到 GitHub 的情況

一樣用 revert,然後照平常的方式 git push。不要用 force push(--force)去覆蓋遠端的紀錄。

我們在本機建了一個模擬的遠端(不是 GitHub)來測試這個流程:push 壞掉的版本 → revert → push → 另外重新下載(clone)一份。新下載的那份檢查結果是 2,紀錄共 3 筆,壞掉的 commit 仍然留在紀錄裡。

另外要注意:已經上傳到 TestFlight 或 App Store 的 build,不會因為你 revert 程式碼而改變。要修正的話,得重新建置並上傳新版本,流程可以參考 TestFlight 測試與更新。

第六步:同一個檢查,前後對照驗收

拿出第二步寫的驗收單,用同一台模擬器或手機、同樣的操作再跑一次:

  • 壞掉的功能有沒有回到預期的結果?
  • 你想保留的其他修改(例如另一個畫面)還在嗎?功能正常嗎?
  • git status --short 裡,還看得到你想保留的檔案嗎?

練習裡的對照結果是:原本 2 → AI 改壞後變成 10 → 還原後回到 2。不過這個例子是在 Mac 終端機上跑的純 Swift 計算,只能證明 Git 操作的效果。你的 App 還是要在 Xcode 重新建置,先在模擬器、最後在實機上照同樣的步驟確認。

驗收通過後,要不要再 commit?

如果你是用 git restore --source=HEAD 還原的,這個檔案現在跟最新的 commit 完全一樣,git status --short 也不會再列出它。這時就算執行 git add Budget.swift 再 commit,Git 也只會告訴你沒有可提交的變更。這是正常的,表示沒有新東西要存,原本那個正常的 commit 本來就在。不需要為了「存檔」硬要 commit。

需要 commit 的情況是:驗收後你又做了新的修正,或是你從 HEAD 以前的某個編號還原,讓檔案跟 HEAD 不一樣。這時先看狀態,再只 add 這次驗收過的檔案:

指令

貼上前,先確認路徑、檔名、分支名稱或 commit 編號,必要時換成你自己的

git status --short
git add Budget.swift
git commit -m 'Restore remaining steps calculation'

Title.swift、notes.txt 這些還在進行中的修改,不要順手一起提交。(這兩種情況是依 git-commit 文件整理的,本文沒有實測。)

Git 不一定救得回來的東西

  • **從來沒 commit、又被覆寫的內容。**Git 不保證找得回來。如果它曾經被 stage、存進 stash,或你的編輯器有本機的歷史紀錄,也許還有機會。但如果沒有任何保存過的版本或備份,就沒有保證。所以第一步一定要先備份。

  • **App 在裝置上或遠端的資料。**依照 SwiftData 文件,程式碼裡的 model 類別定義的是 schema,ModelContainer 再依照它管理資料的 persistent storage(持久化儲存)。程式碼可以用 Git 還原,但下面這些資料不會因為你執行 git restore 或 revert 就自動退回:

    • 模擬器或手機上的 persistent store
    • 使用者設定
    • 遠端資料庫裡的資料

    如果壞掉的版本已經寫入了錯誤的資料,還原程式之後畫面可能還是怪怪的。想用乾淨的資料測試,可以換一台模擬器。刪除 App 會連資料一起刪掉,動手前先確認裡面沒有要留的東西。這段是依原理推論的,本文沒有測試資料庫或實機。

  • 被 .gitignore 排除、又沒被追蹤的檔案。.gitignore 只對目前沒被追蹤的檔案有效。這類檔案,Git 不會保存它們的舊版本,所以也沒辦法用 Git 還原。已經被追蹤的檔案,就算後來寫進 .gitignore,Git 還是照常追蹤它,restore、revert、切換分支都可能改動它。練習中,一個被排除的假資料檔在那次切換分支後內容沒變。但那只是那一次的結果,不代表被排除的檔案在任何操作下都不會被動到。

看到這兩種指令,先停一下

網路上常看到號稱「一行搞定」的還原指令:

  • git reset --hard 會把 Git 追蹤的檔案退回指定的版本,丟掉這些檔案裡所有還沒 commit 的修改。Git 沒在追蹤的檔案如果擋到路,也可能被刪除。
  • git restore . 的 . 代表目前資料夾底下的所有路徑。它會用來源版本覆寫所有符合的追蹤檔案,但不會清掉暫存區,所以可能又掉進前面提到的 staged 陷阱。它不會刪除 Git 沒在追蹤的檔案。

這兩個指令的共同問題是:影響範圍比你想的大。你只想修一個檔案,結果其他還沒存的修改也一起被蓋掉了。本文不提供這兩種指令的操作步驟。在你自己的專案上,照前面的單一檔案流程處理就夠了。

練習:在全新資料夾從頭做一次

第一次做最好先練習。下面的指令會建立和覆寫檔案,只能在新建的練習資料夾裡執行,不要在你的 App 專案裡執行。一次貼一段,看完輸出再往下。任何一步出錯(例如 mkdir 回報 File exists),就停下來,不要繼續貼後面的指令。

說明:這段練習是依照實測流程整理的教學版。Budget.swift 的正常版本和壞掉版本都跟實測相同。不過 Title.swift、main.swift 的內容和部分 commit 訊息,跟原本的實測資料夾不完全一樣,發布前已把路徑換成隔離的練習目錄,重跑本節的檔案建立、備份與兩種還原:未 stage 時的檢查為 2 → 10 → 2,staged 情境為 10 → 10 → 2,Title.swift 和 notes.txt 都保留。本文其他地方引用的輸出,都來自原本的實測資料夾。

建立練習資料夾

指令
mkdir ~/git-recovery-practice

如果出現 File exists,代表這個資料夾已經存在,可能是上次的練習,也可能是別的東西。不要繼續。換一個名字(例如 ~/git-recovery-practice-2),後面指令裡的路徑也要一起換掉。

建立成功後,再進入資料夾:

指令
cd ~/git-recovery-practice
指令
git init -b main

後面 commit 時,如果 Git 要求你設定 user.name 或 user.email,請照 Git 的提示設定你的 Git 身分,而且只設定在這個練習 repo(不要加 --global)。不要在終端機或任何地方貼上密碼、token 這類機密資料。平常已經在用 Git 的讀者,通常沿用既有的身分就可以,不需要這一步。

建立正常版本

指令
cat > Budget.swift <<'EOF'
func remainingSteps(completed: Int) -> Int {
    max(0, 10 - completed)
}
EOF
指令
cat > main.swift <<'EOF'
import Foundation

let result = remainingSteps(completed: 8)
print("remainingSteps(completed: 8) =", result)
exit(result == 2 ? 0 : 1)
EOF
指令
echo 'let dailyGoal = 10' > Title.swift
echo 'local-data.json' > .gitignore
echo 'check' >> .gitignore
指令
git add Budget.swift main.swift Title.swift .gitignore
git commit -m 'Working baseline'

執行功能檢查:

指令
xcrun swiftc Budget.swift main.swift -o check && ./check

應該會看到 remainingSteps(completed: 8) = 2。

練習一:模擬 AI 改壞,同時你也改了別的檔案

指令
cat > Budget.swift <<'EOF'
func remainingSteps(completed: Int) -> Int {
    10
}
EOF
指令
echo 'let dailyGoal = 12' > Title.swift
echo 'my notes' > notes.txt

接著照本文的步驟操作:

  1. 再跑一次功能檢查,確認結果變成 10(記下「壞掉」的樣子)。
  2. 備份:先執行 mkdir ~/git-recovery-practice-backup。如果出現 File exists 就停下來換名字。成功後再執行 cp -R ~/git-recovery-practice/. ~/git-recovery-practice-backup/。
  3. git status --short、git diff -- Budget.swift
  4. git show HEAD:Budget.swift,確認來源版本是正常的。
  5. git restore --source=HEAD -- Budget.swift
  6. 再跑一次功能檢查,應該回到 2。
  7. git status --short 裡應該還看得到 Title.swift 和 notes.txt。

練習二:體驗 staged 陷阱

把 Budget.swift 再改壞一次(重跑上面那段 cat > Budget.swift),然後:

  1. git add Budget.swift。這時 status 裡 Budget.swift 那一行會是 M 。
  2. git restore -- Budget.swift,然後跑檢查:結果還是 10。
  3. git restore --staged -- Budget.swift,然後跑檢查:結果應該仍然是 10,因為 --staged 只動暫存區。
  4. git restore --source=HEAD -- Budget.swift,然後跑檢查:結果回到 2。

練習三:壞掉的版本已經 commit 了

這個練習接著在同一個練習資料夾做,前提是你已經做完練習一和練習二。如果你建立資料夾時換過名字,下面的路徑也要一起換掉。

開始前先確認狀態

指令
cd ~/git-recovery-practice
xcrun swiftc Budget.swift main.swift -o check && ./check

應該看到 remainingSteps(completed: 8) = 2。如果不是 2,先回到練習二的第 4 步。

指令
git status --short

應該只看到這兩行:

輸出範例(對照用,不需輸入)
 M Title.swift
?? notes.txt

M(M 前面有空格)表示有修改但還沒暫存,?? 表示 Git 還沒追蹤這個檔案。如果還看到別的檔案,或任何一行的字母出現在最前面(例如 M 、A ,代表那個檔案已經暫存),就停下來,不要繼續。最簡單的做法是另開一個新的練習資料夾從頭做。不要為了讓畫面跟這裡一樣,就去 unstage 或刪除你不確定的檔案。

第 1 步:把壞掉的版本 commit 進去

先把 Budget.swift 改壞,內容和練習一一樣:

指令
cat > Budget.swift <<'EOF'
func remainingSteps(completed: Int) -> Int {
    10
}
EOF

只把 Budget.swift 加進暫存區,然後確認暫存區裡只有它:

指令
git add Budget.swift
git diff --cached --name-only

應該只印出 Budget.swift。git add Budget.swift 只會加入這個檔案,但不會把之前已經暫存的其他檔案拿出來。只要暫存區裡還有別的檔案,下一步的 commit 就會把它們一起存進去。如果這裡印出 Budget.swift 以外的檔案,就停下來,不要 commit。另外,也不要用 git add .,否則 Title.swift 和 notes.txt 也會被加進去。

確認無誤後再 commit:

指令
git commit -m 'Break remainingSteps (practice)'

再跑一次功能檢查,結果是 10。

第 2 步:確認 restore 已經救不回來

指令
git log --oneline -3
git show HEAD:Budget.swift

log 最上面一筆是 Break remainingSteps (practice),下面是 Working baseline。show 印出的函式內容是 10。也就是說,HEAD 本身就是壞掉的版本,這時再用 git restore --source=HEAD 只會還原出壞掉的內容。

這裡壞掉的 commit 是你剛剛自己做的,所以可以直接用 HEAD。在真正的專案裡,請照第五步先用 git show <commit 編號> 確認內容,不要假設最新的那一筆就是壞掉的。

第 3 步:用 revert 反轉那一筆 commit

指令
git revert --no-edit HEAD

revert 會新增一筆 commit,把指定 commit 的修改反向做回去,原本的歷史一筆都不會刪。--no-edit 的意思是直接使用 Git 預設的 commit 訊息。少了它,Git 會打開文字編輯器,等你確認訊息。

第 4 步:驗收

先跑功能檢查,結果應該回到 2。接著:

指令
git log --oneline -3
git status --short
cat Title.swift
  1. log 共三筆。由上到下是 Revert "Break remainingSteps (practice)"、Break remainingSteps (practice)、Working baseline。壞掉的那筆仍然留在歷史裡。
  2. status 仍然只有 M Title.swift 和 ?? notes.txt 兩行。
  3. Title.swift 的內容仍然是 let dailyGoal = 12。

這代表 revert 只動到那一筆 commit 改過的 Budget.swift。

如果 revert 沒有照預期完成

先看訊息屬於哪一種。不同情況的處理方式不一樣:

  • 還沒開始就被拒絕:例如出現 error: Your local changes to the following files would be overwritten by merge:,最後一行是 fatal: revert failed。這表示 Git 根本沒有開始 revert,沒有新增 commit,檔案也沒被改動。這時不需要、也不能執行 git revert --abort:因為沒有進行中的 revert,Git 會拒絕這個指令。在這個練習裡看到這個訊息,通常代表 Budget.swift 又有了沒 commit 的修改。請先跑 git status --short 看是哪個檔案,再回頭參考第五步「有還沒 commit 的修改時」的處理方式。
  • 已經開始,但停在衝突:畫面出現 CONFLICT。這表示 revert 做到一半,Git 正在等你處理。照第五步的原則:不要刪檔案,也不要叫 AI 把衝突全部解掉。先跑 git status 確認狀況,沒把握就執行 git revert --abort,回到 revert 之前的狀態。照本練習的步驟做,正常不會遇到衝突。
  • 其他看不懂的訊息:停下來,不要繼續貼後面的指令,先跑 git status 看目前的狀態。只有在 git status 顯示 revert 還在進行中時,才用 git revert --abort。

這段測了什麼、沒測什麼

10/6 在隔離的練習目錄測過這個情境,環境是 macOS、git 2.50.1(Apple Git-155)、Apple Swift 6.2.4。結果如下:

  • 檢查結果依序是 2 → 10 → 2。
  • 紀錄共 3 筆,壞掉的 commit 仍然保留。
  • Title.swift(內容 12)和 notes.txt 都保留下來。
  • 即使把文字編輯器停用,--no-edit 也能順利完成。

同一天也測了兩種出錯的情況:

  • 要反轉的檔案本身有未 commit 的修改時,revert 在開始前就被拒絕,沒有留下進行中的 revert。這時執行 git revert --abort 也會被拒絕,檔案不變。
  • 真的發生衝突時,git revert --abort 能回到 revert 之前的狀態。

沒有測過的範圍:Xcode 或 Codex 介面裡的操作、iPhone 實機、GitHub、其他 Git 版本,以及一筆 commit 同時改了多個檔案的情況。

「無關的檔案有未 commit 修改時仍然可以 revert」,只在這次的 Git 版本和單一檔案 commit 上確認過,不代表任何狀態下都能成功。git-revert 官方文件寫的是,應該在沒有未提交修改的乾淨狀態下執行 revert。這個練習刻意留著 Title.swift 和 notes.txt,是為了讓你看見 revert 只動那一筆 commit 改過的檔案。在實際專案裡,比較穩的做法仍是先照第五步把修改存好,再執行 revert。

專案沒有 Git 怎麼辦

如果第一步出現 not a git repository,Git 沒辦法帶你回到過去。這時:

  1. 照第一步整包備份,保留目前的狀態。
  2. 把錯誤訊息和你的驗收單交給 AI,請它先說明改了哪裡,再做最小範圍的修正。錯誤回報的寫法可以參考路線圖文章。
  3. 等到 App 回到正常狀態、驗收也通過了,就開始用 Git 存檔(Xcode 的做法見文末的 Apple 官方文件)。這樣下次就有正常版本可以回去。

下次叫 AI 改之前,先做這幾件事

  • **每確認一個功能正常,就 commit 一次。**這樣你永遠有一個已知正常的存檔點。

  • 要請 Codex 做比較大的修改時,照這個順序來:

    1. 先把目前正常的版本 commit。
    2. 開一個分支,例如 git switch -c ai-try-budget,再讓 Codex 在這個分支上改。
    3. 改完先檢查。想保留這次嘗試,就在這個分支上 commit(只 add 相關的檔案)。
    4. 切回主線:git switch main。

    只開分支、不 commit 是不夠的。沒有 commit 的修改不屬於任何分支,切回 main 時通常會跟著你過去。實測(分支名稱是 ai-attempt,AI 的嘗試已經在分支上 commit):分支上的檢查結果是 10,切回 main 後是 2,而且 git show ai-attempt:Budget.swift 仍然看得到那次嘗試。

  • **一次只請 AI 改一個功能。**這樣 diff 才看得懂。

  • **App 壞掉時,把錯誤訊息和 diff 一起交給 AI。**請它說明自己改了什麼,而不是直接叫它「改回來」。

測試範圍與來源

實際測過的:macOS 終端機、Git 2.50.1(Apple Git-155)、Swift 6.2.4(xcrun swiftc)。所有測試都在隔離的練習資料夾和本機模擬遠端中進行,只使用假資料。測過的流程包括:

  • 未 stage 時只還原單一檔案,並保留其他修改

  • 文末教學版練習(路徑改成隔離目錄)、一般 .git 資料夾的完整複製、含空格路徑的備份

  • 還原回 HEAD 後,Budget.swift 沒有新差異可供 commit

  • staged 陷阱

  • revert 被擋下後,先存到分支再反轉

  • 先 commit 正常版本、再在分支上 commit AI 的嘗試,然後切回主線

  • push 到本機模擬遠端後再 revert,並重新下載確認

  • 練習三(已 commit 的 revert)於 10/6 在隔離的假 repo 與純 Swift 檢查中測試,環境為 git 2.50.1(Apple Git-155)、Apple Swift 6.2.4(arm64 macOS)。結果:檢查 2 → 10 → 2,紀錄 3 筆且壞掉的 commit 保留,Title.swift 與 notes.txt 保留,停用編輯器時 --no-edit 仍可完成;目標檔案有未 commit 修改時,revert 在開始前即被拒絕,此時 --abort 也被拒絕且檔案不變;真衝突時 --abort 可回到 revert 之前。未測 Xcode/Codex 介面、iPhone、GitHub、其他 Git 版本。

另外,練習中一個被 .gitignore 排除的假資料檔在切換分支後內容沒變,這只是單次觀察。

沒有測過的: