今天要解的問題
昨天講了把這個筆記庫搬離 iCloud、改用 git 的決定,也拆掉了一大堆為 iCloud 長出來的規則。但搬家之前那五個月到底發生了什麼,只帶過一句「事故從五月就開始了」。
今天補完整。動機是很實際的一件事:拆掉規則不等於丟掉經驗。 那些規則作廢之後如果繼續留在每個 session 都會載入的規則檔裡,只會誤導;但直接刪掉,五個月就真的白踩了。所以在動手拆之前,得先把它寫成一份可以查、有脈絡、但不會被當成現行規則的東西。
而且這些症狀不是 iCloud 專屬的。任何「雲端佔位檔 + 多台機器 + 高頻自動編輯」的組合都會遇到,Dropbox、OneDrive、Google Drive 都一樣。
問題的形狀
這個筆記庫當時同時活在兩台機器上,一台 Mac、一台 Windows,中間沒有 git。
沒有 git 是刻意的決定,不是懶。這一點後面會展開,但先講它的代價,因為那是後面所有事情的前提:iCloud 是唯一的同步機制,同時也是唯一的失敗點。 沒有版本控制當安全網,iCloud 的每一次誤動作都是直接的資料事件,不是「回滾一下就好」。

五個月下來,它咬人的方式可以分成三類。
第一類:檔案脫水。 iCloud 會把較少用的檔案逐出本地,只留一個佔位檔。檔案在 ls 裡看得到、大小看起來正常,但一讀就炸——macOS 丟 I/O error,Windows 丟 EUNKNOWN 或「The cloud operation is invalid」。
這是最容易誤判的一類,因為它偽裝成「檔案壞了」。第一次遇到的時候我是當成損毀在查的,查了一圈才發現那些檔案好好的,只是不在本地。
第二類:對已知檔名做 rename,雲端會把它還原。 這是最反直覺的。改完幾秒內檔名被改回去;如果同時新建了一個同名的檔,新檔會被彈成衝突副本。換句話說:在這個環境裡,rename 不是一個可用的動作。
第三類:靜默衝突副本。 兩端改同一個檔,iCloud 判定衝突時會 fork 出一份副本,命名有兩種簽名:name 2.md(空格加數字,最常見)和 name(1).md(括號數字)。
第三類最危險,因為它不報錯。而且真正的問題不是多一個檔,是本尊可能消失——後面會講到一次最典型的。
想法與取捨
當初為什麼不用 git
既然結局是搬到 git,那一開始為什麼不?
三個理由,都跟「筆記庫不是 codebase」有關:
- 大量中文檔名的小檔,在 commit 噪音裡沒有可讀性。一次整理動十幾個檔,diff 看起來全是搬移
- 真正的編輯者有兩個——我在編輯器裡手改,LLM 透過指令改——不是一條乾淨的分支線
- 筆記的價值在於隨手就能寫,中間插一道 commit 會讓「隨手」消失
這些理由在當時都成立,現在回頭看也還算成立。真正讓它們失效的不是理由本身變弱,是代價變大了:五個月累積的事故讓「沒有安全網」這件事的權重超過了那三條的總和。
我覺得值得記下來的是,這不是一個「當初判斷錯了」的故事。是一個判斷在條件改變之後過期的故事,而過期的訊號是事故頻率,不是想法。
為什麼改名一律走「備份 → 刪 → 重建」
發現 rename 會被還原之後,直覺的方向是「想辦法讓 rename 生效」——先關同步、改完再開之類的。
沒有走這條。理由是它把一個確定會出事的操作變成一個有時候會出事的操作,而後者更糟:出事頻率降低,但你會開始信任它,於是真的出事那次你沒在看。
所以改成一條完全繞開 rename 的路:
備份到 vault 外 → 刪原檔 → 以新檔名重建 → 等待確認未被彈回 → 刪備份
代價是改個名要五個步驟、很煩。但它的失敗模式是「煩」,不是「偶爾靜默掉資料」。這兩者不同量級。
「備份必須在筆記庫外面」這條看起來多餘,直到你把備份放進去,然後備份自己也被彈成副本。
為什麼要三層,一層不夠嗎

三層的差別在於錯誤被攔下來的時間點:寫之前提醒、寫的當下擋、寫之後掃。
一開始只有第一層,也就是把規則寫進那份每個 session 都會自動載入的規則檔。它的涵蓋面最好——所有動作都吃得到,包括在終端機直接 mv、cp——但強制力是零。
跑一陣子就會發現問題:有幾類錯誤反覆發生。最有代表性的是衝突副本的檔名,它在規則檔裡被明文禁止過,還是出現了。
於是第二層:把最常犯的那幾條寫成寫入前的判斷式,物理擋下。
但第二層有一個結構性的缺口:它只擋得到 agent。 我在編輯器裡手動改名,它一次都不會觸發;同步碟自己在背景做的事,它更是完全看不到。
所以第三層——定期巡檢——不能省。它是唯一能抓到「不是任何人主動做、但檔案就是變了」這類情況的一層。
三層的分工不是冗餘,是因為它們各自攔不到的東西剛好不一樣。
實作
第一層:慣例
規則檔裡有一整段「環境注意」,因為每個 session 都會載入,它等同持久記憶。內容就是上面三類行為的操作版。
兩台機器的對照表長這樣:
| 情境 | macOS | Windows |
|---|---|---|
讀檔報 I/O error 或 EUNKNOWN | brctl download "<path>",等 1–2 秒 | attrib +P -U "<path>",等 1–2 秒 |
| 預防常用檔脫水 | 對整棵樹跑 brctl download | attrib +P -U ... /s 遞迴釘選 |
| 改檔名 | 一律備份 → 刪 → 重建 | 同左 |
macOS 的 brctl 是 CloudDocs daemon 的控制介面,排查時另外三個子命令也有用:brctl evict <path>(反向操作,主動逐出,用來重現問題)、brctl log(看同步日誌,可用 -f 過濾)、brctl diagnose(收集診斷)。
Windows 端的 attrib 兩個旗標分別是 +P(Pinned,釘選在本地)和 -U(取消 Unpinned)。
治本的做法是釘選會反覆用到的整棵樹。其中最該釘的是放置那些自動化腳本的資料夾——理由有點繞:那些腳本本身就是第二層的護欄,一旦脫水,下一個 session 啟動時連護欄都讀不到。護欄被它要防的東西弄壞,是這個架構裡最尷尬的失效模式。
第二層:寫入前的判斷式
擋衝突副本檔名的規則很短:
# Rule 2: log conflict-copy guard (iCloud spawns 'log 2.md' etc.)
if re.match(r"(?i)^log \d+\.md$", leaf):
deny(
f"Refusing to write iCloud conflict-copy name '{leaf}'. "
"Write to log.md instead (do not fork the log)."
)
deny() 走 exit code 2,在這個執行環境的 hook 契約裡代表阻斷,而 stderr 的訊息會直接回給 agent 讓它自己修正。所以這不只是擋下來,是擋下來並且告訴它該怎麼做——這件事讓阻斷的成本從「使用者要介入」降到接近零。
有一個設計選擇值得說明:解析失敗一律放行。
except Exception:
sys.exit(0) # never block on parse failure
護欄的目的是擋掉已知的壞動作,不是把不確定的情況全部鎖死。這些規則擋的東西(檔名長得像副本、根目錄放一次性腳本)都是「會讓筆記庫變醜」等級,不是「會出事」等級;為了它們而在 guard 自己壞掉時讓整個筆記庫變成唯讀,代價和收益不成比例。
第三層:事後掃描
每回合結束掃一次衝突副本,非阻斷,只用 stderr 提醒:
# iCloud conflict-copy signature: "<something><space><digits>.<ext>"
PATTERN = re.compile(r"(?<=\S) \d+\.(md|json|yaml|yml)$")
SKIP_DIRS = re.compile(
r"(?i)/(\.venv|node_modules|__pycache__|\.pytest_cache|\.git|\.obsidian|"
r"_attachments|social-cards|...)/" # ... 其餘為圖片與可重跑產出夾
)
(?<=\S) 這個 lookbehind 是為了確保數字前面那個空格真的是分隔符,而不是檔名開頭。命中時列前 15 筆並顯示總數,超過的折成 ... +N more。
這支腳本的註解直接寫著它的來歷:this is how log.md became 'log 2.md'。護欄的註解寫「它是為了哪次事故而生的」,比寫它在做什麼有用得多——因為前者能回答「這條還需不需要留著」。
排除清單存在的原因是誤判。掃描時要排掉兩種:原生檔名本來就含 -N 時間戳的不是副本;圖片附件與可重跑的產出資料夾不納入。
更完整的定期巡檢則把處置分成兩類:
- 可自動修:脫水檔(重新下載)、與原檔內容完全相同的衝突副本(刪副本)
- 交回人工:內容已經分岔的副本——這個必須由人決定留哪邊,因為「誰比較新」沒有機械依據
自動修完之後還有一道驗證:另外派一個角色去對整批做對抗式檢查,指令是「預設懷疑、傾向判失敗、真的去讀檔、別信宣稱」,任一筆沒過就回滾該筆。
處置衝突副本的五步
1. 複製到筆記庫外的備份目錄,用 cmp 或 hash 確認一致
2. 刪掉庫內的原檔(副本或舊檔名)
3. 以目標檔名重建,內容取自備份
4. 等待,確認沒有被彈回或再生一份副本
5. 確認無誤後刪掉衝突副本,備份保留
第 4 步是最常被跳過、也最常出事的一步。
踩到的坑
一、以為檔案壞了,其實只是不在本地
第一次的健康巡檢,兩個檔案讀取失敗:一份設定檔,和一個索引頁。當下判定是檔案損毀,開始查怎麼救。
查了一圈才發現兩個檔都好好的,只是被逐出本地。同一輪往下掃,另外五個資料夾和一個筆記頁也是一樣的症狀。
這個坑的代價不是那次浪費的時間,是它建立了一條後來一直用的判讀順序:讀不到,先想脫水,再想損毀。 順序反過來的話,每次都要先走一遍「救援」的死路。
二、副本才是新版,差點刪錯的那次
最嚴重的一次:批次修改一批頁面的標籤之後,四個頁面全部被彈成 X 2.md,而本尊不見了。
當下的直覺反應是「刪掉副本、留下本尊」——這是對衝突副本最自然的處置。但本尊根本不在,而且打開副本一看,副本的內容才是修改後的完整版。如果照直覺刪掉副本,丟掉的是新版本,而且不會有任何錯誤訊息告訴你。
處置流程因此固定成「先備份、再三方比對、最後才刪」:
# 三方比對:副本 / 備份 / 重建檔 byte-identical 才算過關
cmp "<副本>" "<vault 外的備份>"
三方是指副本、備份、以及用目標檔名重建出來的檔。三個 byte-identical 才算過關,少一方都可能在某個環節悄悄換掉內容。
另外一次類似的,是一個孤兒副本、本尊失蹤,而且整個筆記庫沒有任何一頁連到它——沒有入鏈的檔案就算不見了也沒人會發現,那次是巡檢掃出來的,不是我發現的。
三、觸發源是批次操作,不是時間
把事故簿排開來看,一個模式很明顯:最嚴重的幾次都發生在批次操作之後。
上面那次四個頁面被彈,發生在批次改標籤之後;另一次孤兒副本也是批次操作的殘留。單檔慢慢改很少出事,一次動十幾個檔就容易撞上同步視窗。
這個觀察後來改變了自動化的架構,而且不只是為了 iCloud:所有並行的掃描工作一律唯讀,寫入只由一個角色執行。原本這條規則的理由是「責任單一、比較好除錯」,踩過這幾次之後它多了一個更實際的理由——並行寫入會讓同步碟的衝突視窗撞得更頻繁。
小結
三類咬人行為、三層防線、五步處置流程、兩台機的操作對照,加上一份九筆的事故簿(每筆記日期、事件、處置、結果),現在都寫成一頁存檔了。寫它的時候才意識到一件事:這五個月累積的規則,有一半根本不是知識管理,是在繞過儲存層的缺陷。
搬到 git 之後,大部分都作廢了。brctl download、attrib +P -U、「絕不 rename」、「備份 → 刪 → 重建」全部不再需要,因為 git 不會把檔案逐出本地,也不會把 rename 還原。
但有一樣留著:每回合結束掃衝突副本檔名的那段。git 不產生這種檔案,所以掃到就代表有非 git 來源的東西混進來了。成本極低,繼續當哨兵——拆掉免疫系統跟留一個哨兵不衝突。
至於那些觀察,有一條跟儲存層完全無關,但後來影響最大:
護欄要長在最靠近錯誤發生的地方。同一條規則寫在規則檔裡被違反過,改寫成寫入前的判斷式之後就再也沒出現。差別不在規則本身,在於執行規則的是人的注意力還是程式。
明天要正式介紹這個筆記庫本身——它為什麼長成現在這樣、七百多個 markdown 怎麼被管住、為什麼查一個概念只需要讀兩到四個檔。上面那句觀察是它的設計主軸之一,明天會從架構的角度再講一次。
本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day09-icloud-three-bites/
