iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Claude AI

利用 Claude 建置自己各種興趣的 Side Project系列 第 8 篇

Day8 [llm-wiki] 被 iCloud 卡了五個月,換到 Obsidian Sync 才發現它同步不了 .claude

  • 分享至 

  • xImage
  •  

今天要解的問題

前面七天都在做 art-tracking, coffee-review,今天換一個專案:llm-wiki,我的第二大腦。

Day1 盤點時沒把它列進去,但它從四月中就開始長了(最早的設計文件是 04-12,vault 重構的第一筆 log 是 04-15),到今天連 .claude/ 的 skill 和模板一起算是 764 個 markdown,其中 727 個是內容區。所有的設計決策、踩過的坑、讀過的東西都沉在裡面。後天會正式介紹它的設計概念,今天先講一件更急的事:它的同步方式今天整個換掉了。

順序是反的,先講搬家才講它是什麼。但搬家這件事有時效性,而且過程裡的三個坑跟「AI 維護的文件會漂」這個主題直接相關,值得趁熱記下來。

另外先說明分工:今天只講「為什麼搬、怎麼搬」,iCloud 那五個月到底出了哪些事、我怎麼處置,明天會單獨寫一篇攤開講。今天講到事故的地方都只取決策需要的最低限度。

這個 vault 原本放在 iCloud Drive,在 Mac 和 Windows 兩台機器之間同步。翻自己的操作 log,iCloud 的事故從五月就開始了:

2026-05  books/raw 5 個資料夾 + 1 個 wiki 頁全部 I/O error
2026-05  coffee/index.md 缺的 4 個 wiki 頁「目前 iCloud online-only 讀不到」
2026-05  待修:根目錄 log 2.md 為 iCloud 同步衝突檔,需手動合併回 log.md
2026-06  根因修復:settings.json 讀取失敗 = iCloud 逐出本地的 dataless placeholder
2026-07  孤兒衝突副本「進階肌力訓練解剖聖經 1.md」本尊失蹤

歸納起來是三類:檔案被逐出本地只留佔位檔、兩端改同一個檔就分岔出衝突副本、以及對已存在的檔名做 rename 會被雲端還原。每一類的症狀、判讀方式和處置流程明天會逐條展開,今天只需要記住結論:這三類都不報錯,而且沒有版本控制當安全網。

我為了它長出了一套免疫系統

比事故本身更值得記錄的,是 vault 為了對抗 iCloud 長出來的東西:CLAUDE.md 裡一整段每個 session 都會載入的「環境注意(iCloud 跨平台)」、一支每回合結束掃衝突副本檔名的 Stop hook、vault-maintain skill 裡的「iCloud 韌性」章節、以及一條「絕不 rename」的明文規定。

這四樣東西明天會逐條講它們各自擋住什麼、又漏掉什麼。今天只需要這個結論:它們全都不是知識管理,是在繞過儲存層的缺陷——而這正是決定搬家的理由。

而且查資料時發現,「AI 高頻編輯 + 雲端佔位檔」這個組合的風險比我以為的高。Claude Code 官方 repo 有一個帶 data-loss 標籤、附可重現步驟的 issue(#32637)。情境是 macOS 把檔案卸載成 0-byte 佔位檔之後,AI 讀到空檔、複製出空的副本,然後對原資料夾下 rm -rf;刪除再經由 iCloud 傳播到另一端,最後靠 Time Machine 才救回來。那個 issue 現在已經關閉,但它說明我那些 I/O error 不是無害的雜訊:同一份佔位檔,人讀到會報錯,程式讀到可能是一個看起來合法的空檔案。

所以決定把 vault 搬出 iCloud,改用 Obsidian Sync。

換掉一個問題,換來另一個

搬完之後檢查 Claude Code 設定還在不在,新位置底下只有內容資料夾和 .obsidian。.claude/ 整個沒跟過來——15 個 skill、4 支 hook、模板、兩份 settings,45 個檔案一個都沒有。

同一個 vault,三種同步方式帶走的東西不一樣

這不是「少了點方便功能」。這個 vault 的內容規範(wiki 頁必須有 frontmatter、根目錄不准放腳本、log 不准分岔)是靠 PreToolUse hook 在物理層擋下來的,不是靠自律。.claude/ 不在,等於 15 個自訂指令全部失效、護欄全部消失,而且不會有任何錯誤訊息——它只是安靜地什麼都不做。

苦惱就在這裡:iCloud 會把檔案弄壞,但至少什麼都同步;Obsidian Sync 不會弄壞檔案,但有一整類檔案它根本不碰。

想法與取捨

先確認這是不是硬限制

第一個要回答的問題是:Obsidian Sync 能不能被設定成同步 .claude?

官方文件沒有模糊空間:以 . 開頭的檔案與資料夾一律被當成隱藏檔排除在同步之外,唯一的例外是 vault 自己的設定資料夾 .obsidian。同樣被排除的還有 .git、.vscode、.idea。Selective sync 能調的只有「哪些檔案類型」和「排除哪些資料夾」,沒有任何開關可以把被排除的 dot 資料夾加回來。論壇上「Sync hidden files and folders as well」是一條長年的 feature request,至今未實作。

所以這是硬限制,不是我漏找設定。先確認這件事很重要——如果只是個 toggle,後面三個方案全部不用想。

(查資料時看到有人回報 .docx 開了「Show all file types」還是不同步。追下去發現那是另一回事:Show all file types 是 Obsidian 的顯示層設定,跟 Sync 傳不傳無關,真正要開的是 Sync 設定裡的 Sync all other types。兩個名字很像的設定,管的是完全不同的東西。)

三個方向

方案 A:真檔改非 dot 名 + 本地 symlink

llm-wiki/
  _claude/            ← 真實檔案,非 dot,Obsidian Sync 正常帶走
  .claude -> _claude/ ← 每台機器「本地」各自建,不同步

Obsidian 同步 _claude/ 的內容,.claude 只是每台機器自己的指標。Claude Code 是檔案系統層級讀取,吃 symlink 沒問題。社群插件 Claude Skill Sync 就是把這個模式自動化的,所以這條路走得通。

代價是 _claude/ 變成一個普通的 vault 資料夾,15 個 SKILL.md 會進 Obsidian 的搜尋、graph、quick switcher。.sh / .py / .ps1 / .json 不會出現(Obsidian 預設不顯示未知副檔名),所以污染只有 markdown,大約 23 個檔。可以用 Excluded files 擋掉搜尋和 graph,但檔案總管裡還是看得到——沒有乾淨解。

還有一個沒驗證過的變體:把真檔藏進 .obsidian/claude/。.obsidian 是唯一會同步的 dot 資料夾,而 Obsidian 不會把它當筆記索引,理論上零污染。但 Vault configuration sync 是分項 toggle,未知子資料夾可能一項都不屬於,我沒辦法從單機驗證,所以只能列為「要做一次真實兩機測試才能信」。

方案 B:只有 .claude 走 git

.claude/.git 是 dot 資料夾裡的 dot 資料夾,Obsidian 完全看不到,所以跟 Obsidian Sync 零衝突。skill 是 code 不是 notes,有 diff 可看、改壞可 revert,這個場景 git 划算。

但有一個一定會爆的地雷:.claude/settings.local.json 是 Claude Code 會自動寫入的檔案——每按一次「允許」某個權限它就長一行。兩台機器各自累積,就是每次 pull 都在解一個你從來沒手動編輯過的檔案的衝突。

方案 C:整個 vault 改用 git、停用 Obsidian Sync

這個做法我一開始是反對的,理由是 git 和 Obsidian Sync 會同時改工作目錄:Sync 在背景即時寫檔,git 在前景做 checkout / reset,兩邊都在改檔案而彼此不知道對方存在。典型災難是 git checkout 改了 50 個檔,Sync 同時把其中一半的舊版推回來,兩套的衝突解決機制都認為自己贏了。

這點 Obsidian 官方也明文講過,只是對象是雲端硬碟——它不建議把 Obsidian Sync 和 iCloud、Dropbox、OneDrive、Google Drive 這類服務疊在一起用,理由正是會互相製造衝突。

但那個反對只在兩者並存時成立。一旦決定把 Obsidian Sync 整個關掉,前提就不在了,反對本身也跟著失效。

選 C 的理由

三個目標,只有一個方案全中

關鍵不是 git 比較好,而是它讓整個問題消失:git 不特別對待 dot 資料夾。.claude 就是一個普通目錄,不需要改名、不需要 symlink、不需要處理索引污染、不需要研究檔案類型 toggle、不需要煩惱 .obsidian/claude/ 到底會不會同步。方案 A 的所有髒東西一次歸零。

git 唯一輸的地方是手機。iOS 沙盒不給背景 daemon,Obsidian 在手機上官方只支援 Obsidian Sync 與 iCloud Drive 兩種同步,git 要走 Working Copy 或官方標註 highly unstable 的行動版外掛。確認沒有手機讀寫需求之後,這個弱點就不算數了——但如果哪天要加 iPhone,這會是整個架構最先被迫重新設計的地方。

而且這個 vault 的體質剛好適合 git:48M、784 個檔案,其中 764 個是 markdown(內容區佔 727 個)。最大宗的 social-cards/ 佔 26M,但那是圖卡產生器的輸出,CLAUDE.md 自己就寫「不視為 vault 內容,可重跑」——完美的 gitignore 候選,砍掉之後 repo 只剩 22M。

一個附帶的決定:不開 auto-commit

選了 git 之後還有一個取捨:要不要讓 Obsidian Git 自動 commit?

結論是只開 auto-pull,不開 auto-commit。理由不是怕 commit 太多,而是這個 vault 經常由 Claude 編修——git 在這個情境最大的價值就是「commit 前先看 diff」這道審閱關卡。開了 auto-commit,AI 的修改不經審閱就進歷史,等於把剛換來的好處自己丟掉。

代價是「要記得 commit」變成人工紀律。auto-pull 補掉了另一半(忘記 pull 就在舊版上改),那是兩者之中比較難補救的一半。

實作

先放 .gitattributes,再 git add

順序很重要:

* text=auto eol=lf

*.ps1 text eol=crlf
*.bat text eol=crlf
*.cmd text eol=crlf

hook 的 dispatcher 是 #!/bin/sh 開頭的 shell script。Windows 上 git 預設 core.autocrlf=true,checkout 時會把 LF 轉成 CRLF,shebang 就變成 #!/bin/sh\r,這個檔案拉回 macOS 執行會直接報 bad interpreter: /bin/sh^M。

.gitattributes 的優先級高於 core.autocrlf,所以鎖在 repo 裡比叫每台機器各自去設 git config 可靠得多——新機器 clone 下來就是對的,不需要任何人記得做什麼。

.ps1 要反過來留 CRLF。那兩支腳本的檔頭有一行註解說明為什麼它們刻意只用 ASCII:Windows PowerShell 5.1 mis-decodes non-ASCII .ps1 without a BOM。編碼這塊本來就脆,git 再疊一層換行轉換,不明確鎖住遲早出事。

.gitignore 的四類

# 可重跑的產出物
social-cards/

# 機器本地狀態
.claude/settings.local.json
.obsidian/workspace*.json

# 密鑰
.env.local

# 依賴/暫存
node_modules/
.venv/

第二類是重點。settings.local.json 前面講過;.obsidian/workspace.json 則是每次調整版面就會變,純粹是機器本地的視窗狀態,不 ignore 的話兩台機器會互相覆蓋對方的 UI 佈局。

另外這個 repo 含 finance/ 的真實資產數字和 personal/,所以 GitHub 上必須是 private,沒有例外。

進 git 前先清 iCloud 留下的屍體

git init 之前掃了一次衝突副本,.obsidian/ 裡有 89 個 workspace N.json,全是 iCloud 時代同步衝突的殘骸。這些如果直接 commit 就永遠留在 git 歷史裡了。清掉之後 .obsidian/ 從 1.9M 降到 288K。

好消息是內容區那 727 個 markdown 掃出來 0 個衝突副本,遷移本身是乾淨的。

順手拆掉免疫系統

既然 iCloud 走了,為它長出來的東西也該收掉。vault-maintain 的自動修復清單原本有這一項:

dehydrated → 回拉(macOS brctl download;Windows attrib +P -U)

整個「iCloud 韌性」章節改寫成「git 韌性」,內容從「讀檔遇 I/O error 先回拉再讀」變成「讀檔報 I/O error 不再是預期行為,當成真的錯誤查」。這個反轉很重要——留著舊規則的話,未來真的出現 I/O error 時會被當成例行公事繞過去,而不是當成 bug 追。

「絕不 rename」那條也拿掉了。git 認得 rename,檔案可以直接改名。

但有一樣東西留著:Stop hook 掃 name N.md 的那段。git 不會產生這種檔案,所以掃到就代表有非 git 來源的副本混進來——成本極低,繼續當安全網。拆免疫系統跟留一個哨兵不衝突。

log.md 的結構性弱點

還有一個切到 git 之後才會暴露的問題:根目錄的 log.md 是一個 97KB、195 筆、append-only 的單一大檔。兩台機器都往檔尾 append,在 git 裡就是每次都撞在同一行。這不是使用習慣問題,是檔案結構本身的弱點。

所以切成月檔,log/2026-04.md 到 log/2026-09.md 六個檔。切完之後只有當月檔會被寫,歷史月檔實質凍結。

切檔腳本沒有只比對行數,而是逐筆確認每個 entry 的原始文字都出現在輸出裡:

joined = "\n".join((outdir / f"{m}.md").read_text() for m in sorted(by_month))
missing = [b[:60] for _, _, b in entries if b not in joined]
assert not missing

順帶發現原檔的排序是亂的——前半段新到舊、後半段舊到新,兩種方向混在同一個檔案裡。月檔統一成升冪,跟 append-only 的語意對齊。

切檔要連帶改的東西比想像中多:CLAUDE.md 的 Log 格式段、根目錄允許清單、10 個 skill 的 log 寫入規則,以及 hook 新增一條規則擋下「重建根目錄 log.md」。

踩到的坑

一、文件說它可攜,程式碼說它不是

搬家前的 CLAUDE.md 裡有這麼一句:「四支守門腳本都從自身位置推導 vault root(parents[2] / $PSScriptRoot 往上兩層),搬家不需再改。」

這句話寫得很有自信,而且合理——它描述的正是一個可攜設計該有的樣子。我一開始相信了,準備直接往下做。

文件說四支都可攜,實際只有兩支

打開檔案才發現不是這樣:兩支 .ps1 都是寫死的絕對路徑,$PSScriptRoot 在整個檔案裡一次都沒有出現。

而且這個錯誤的後果是靜默的。Windows 端一開 session,Stop hook 會去掃那個已經被 CLAUDE.md 明文宣告「已凍結、永遠不要讀寫」的舊目錄;PreToolUse 的根目錄規則則因為 $parent -ieq $VAULT_ROOT 永遠不成立而完全不擋——看起來一切正常,實際上護欄是關的。

修法很短:

$VAULT_ROOT = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent

真正的教訓不在這一行。這句 CLAUDE.md 是前一次 session 寫下的,描述的是意圖而不是現況——當時大概真的打算四支都改,Python 那兩支改了,PowerShell 那兩支沒有。文件和程式碼之間沒有任何機制保證一致,而 AI 寫的文件又特別流暢、特別像真的,不打開檔案就不會發現。

二、dispatcher 會 fail-silent

準備 Windows 端接手的時候,回頭看了一次 dispatcher:

case "$(uname -s)" in
  Darwin) exec python3 "$DIR/write_guard.py" ;;
  MINGW*|MSYS*|CYGWIN*) exec powershell ... ;;
  *) exit 0 ;;
esac

最後那行 *) exit 0 的意思是:uname -s 認不得的平台,什麼都不做,安靜地成功。WSL 回傳 Linux、環境換個 shell、Git Bash 沒裝——任何一種都會讓 hook 看起來裝好了但實際上完全沒作用。而新機器正是最容易撞到這個的場景。

改法是補上 Linux 分支、Windows 分支加 pwsh 與 Python 退路,最後認不得的平台改成在 stderr 印警告:

echo "[vault-guard] no usable interpreter for $(uname -s); write guard SKIPPED" >&2
exit 0

原則是 fail-open 但絕不 fail-silent:guard 自己壞掉時不該擋住使用者寫檔(所以還是 exit 0),但必須講出來。

這跟第一個坑其實是同一件事的兩個面向——護欄類工具最糟的失效模式不是壞掉,是看起來還在運作。

三、我自己算錯的 Windows 路徑長度

Windows 的 MAX_PATH 是 260,而這個 vault 有大量中文檔名,所以 clone 之前先量了一下:

git ls-files | awk '{print length($0), $0}' | sort -rn | head -5

結果最長 616,加上 clone 前綴就是 640——遠遠爆掉。當下的判斷是要開 core.longpaths。

但數字大得不太對勁。回頭看 git ls-files 的輸出,非 ASCII 路徑是被八進位轉義印出來的:

"coffee/raw/\346\257\217\345\200\213\344\272\272..."

一個中文字被展開成 \346\257\217 這樣 12 個字元。616 量的是轉義後的表示法,不是真實檔名長度。改用 core.quotepath=false 再數實際字元,真實最長是 99,加上前綴共 125,離 260 還很遠。完全不需要 longpaths。

同一個坑還讓我誤報了第二件事:檢查檔名有沒有 Windows 非法字元時,grep " 命中了一堆檔案——那些引號是 git 自己加的,不是檔名的一部分。真實結果是 0 個。

教訓是 git ls-files 的輸出不是原始檔名。而且錯的方向剛好是「看起來更危險」,很容易順著它去做一個不必要的修復。

小結

vault 從 iCloud 經過 Obsidian Sync,最後落在 git 單一同步來源(GitHub private repo)。.claude/ 那 45 個檔除了機器本地的 settings.local.json 之外全進了版控,兩支 .ps1 的寫死路徑改成 $PSScriptRoot 推導,dispatcher 不再 fail-silent,log.md 切成月檔,Obsidian Git 裝好並設定成「只自動 pull、不自動 commit」。

為 iCloud 長出來的免疫系統也拆掉了大半——brctl download、attrib +P -U、「絕不 rename」、「備份→刪→重建」全部作廢,只留下 Stop hook 掃衝突副本那一段當哨兵。

Mac 端的 hook 跑過實測:根目錄寫 .py 擋下、重建 log.md 擋下、log 2.md 擋下、正常 raw 檔放行。

明天先把今天一直往後推的那筆帳結掉:iCloud 那五個月到底怎麼咬人、我為它長出的三層防線各自攔在哪個時間點、以及哪一次差點真的刪錯檔。後天才輪到正式介紹 llm-wiki 本身——它為什麼長成現在這樣、raw 到 wiki 的反壓縮紀律、以及為什麼規範要用 hook 擋而不是寫在文件裡(今天的第一個坑就是答案的一部分)。


本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day08-sync-dotfolder-icloud-to-git/


上一篇
Day7 [coffee-review] 測試全綠,功能卻永遠是空的:查詢沒 select 的欄位
下一篇
Day9 [llm-wiki] iCloud 咬人的三種方式,和我為它長出的三層防線
系列文
利用 Claude 建置自己各種興趣的 Side Project 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言