非互動環境裡,要人停下來看的關卡表現成當機,而繞過它的旗標看起來像環境適配
論述基礎與限制
本卡從一次把共用 skill 庫推壞的事故抽出。那次操作要刪掉兩個已廢除的檔案並同步到九個專案共用的遠端庫,工具在執行前擋了兩次,兩次都被繞過,結果是多刪一個檔案並把另一個專案做過的一整批修正推回舊版。
證據是單一事故,而機制可以逐步指認:關卡怎麼表現、繞過它的動作為什麼看起來合理、警告為什麼被讀在事後。可推廣的是這個形態與處置;不可推廣的是它多常發生,一次事故量不出頻率。
核心原則
互動式確認在沒有 stdin 的環境裡不會說「我需要確認」,它就停在那裡。而停住讀起來是故障——指令跑不完、輸出停在半路、逾時。這個時候手邊正好有一個旗標,名字叫 --force 或 --yes 或 -y,它的功能是跳過確認,而在故障的框架下它被重新讀成「這個環境要用的旗標」。
於是正是那個設計來讓人看一眼的關卡,在自動化環境裡不只失效,還誘導人主動拆掉它。失效的形式是被當成障礙排除,不是被忽略。
同一個旗標常同時承擔兩件事:跳過確認,以及在檢查不通過時強行通過。第一個語意在非互動環境裡確實需要,第二個從來不需要——而它們共用一個名字,所以取得第一個就順帶取得了第二個。
反模式:把「它會不會做我要的事」當成驗證
刪除類的旗標(--prune、--delete、--clean)驗證起來有一個不對稱:問「它會不會刪掉我要刪的」得到「會」,問「它還會刪掉什麼」也得到答案,而只有第二個問題擋得住人。
實例的 --prune 語意是「刪掉只存在於遠端的檔案」。本地要刪的兩個檔案確實符合,所以第一個問題通過了;而遠端另有一個檔案本地一直沒有,它同樣符合,於是一起被刪。那個檔案在事後從別的專案取回,若當時九個專案都沒有它就取不回來了。
同一個事故的第二層更難察覺:工具在輸出第一行印了「雙邊自上次同步後各自改過,這個操作可能丟棄另一側的修改」。那一行是準確的——本地那份停在未清理的舊版,遠端帶著別處做過的清理,推上去把清理整批覆蓋掉。它沒有被忽略,它是被讀在事後:指令送進背景或接了 tail,回來時只看結尾的成功訊息。
修法
在非互動環境裡取預覽,不要取執行。 多數這類指令在確認前會印出完整計畫,所以把「拒絕」餵進去就能拿到計畫而不執行:
1echo "n" | <destructive-command>這一步把關卡從「擋住我」變成「印給我看」,成本是一次額外執行,而它正是原本設計要發生的那件事。看過計畫再決定要不要送。
逐區段讀計畫,而不是找自己預期的那一項。 預覽通常分區(新增/修改/刪除),確認自己要的那一項在裡面之後,剩下的區段才是這一步的價值所在。
警告要在動作之前讀。 背景執行與 | tail 都會讓開頭的警告被結尾的成功訊息蓋過。改動別人也在用的東西時,輸出從頭讀。
旗標的兩個語意分開要。 工具若把「跳過確認」與「強行通過檢查」綁在同一個旗標上,先把檢查真的修乾淨,再用那個旗標——這樣它只剩下前一個語意。實例裡的可攜性違規只需要改一句話,而繞過它會在紀錄裡留下一筆本來不必存在的例外。
核對放在動作之後就只是輸出,不是閘門
「取預覽、看過再送」的另一種失效是把核對與動作串在同一條鏈裡,而核對排在動作後面。一次外部自陳的實例是 git add … && git diff --cached … && git commit …——三步都執行了,核對的輸出也印出來了,而它出現在 commit 已經發出之後:那一步因此是裝飾性的,看到不對也攔不住任何東西。結果是一份留在共用 index 裡的過期快照被掃進提交,把一張已完成的票在歷史裡記成未完成,而磁碟上一直是完成的。
判別一步核對是不是閘門,看它失敗時能不能讓後面的步驟不發生。&& 鏈裡排在動作之前的核對要能以非零退出中止鏈;排在動作之後的,不論它印出什麼都已經來不及,處置是把它移到前面或拆成兩條指令、中間由人讀過。
刪除清單要逐項查來歷,而標籤不會代為判別
「它還會刪掉什麼」這個問題有一個可以直接跑的答案。刪除類旗標鎖定的集合是只存在於遠端,而那個集合有兩種成員:我刪掉而還沒傳播的,以及別人推上去而我從來沒有的。兩者在推送方的視角下完全同形——自己的樹裡都沒有它。
分開兩者只要問一句自己的歷史有沒有刪除紀錄:
1git log --oneline --all --diff-filter=D -- <path>有輸出,是我刪的。零輸出還分兩種,而這條指令分不開它們——它繼承了 tracked 樹的視角,從未追蹤的檔從未被刪除,也會回零。所以查歷史之前先查磁碟:
1test -e <path> && git check-ignore -v <path>檔在磁碟上而被 .gitignore 命中,是我方有但未追蹤,該修的是 ignore pattern、不是清單;磁碟上沒有、歷史裡也沒有刪除紀錄,才是別人的貢獻。順序不能反:前兩種判定都站在 tracked 樹裡,磁碟那一問是唯一跳出這個視角的,先跑它等於先排除視角本身有盲區。一次外部實測連續三版判錯同一個檔的成因——工具說「本地已刪除」、歷史查詢說「他方貢獻」、查磁碟才發現是被 ignore 的本地檔,40004 bytes 與遠端相同——前兩版給出的動作都對(別清),而理由都會導向錯的修法。動作對不等於理由對,只有理由對,修法才會修在對的地方。
這幾條指令要對預覽列出的每一個路徑跑,不是只跑自己想得起來的那幾個。跑之前拿一個已知刪過的路徑驗一次管道,因為零輸出正是這裡要用的判定值。
標籤的措辭決定使用者有沒有機會問這個問題。 一次跨工具的對照:一支工具把那個集合標成 remote-only,另一支標成「本地已刪除但遠端殘留」。把成因寫進標籤的那一種讓讀的人不會去分辨——一個回報者照著它清掉了另一個消費端四天前推上去的檔,並向使用者複述工具給的理由,把別人的貢獻描述成該清的殘留。代理在那裡是被工具預先包裝好的,取代理這個動作本身沒有發生。
而前一種措辭也只買到一半。remote-only 是誠實的,它描述集合而不宣稱成因——誠實的標籤仍然只給一格,分類的動作還是得由使用者帶進來。
所以修法是兩件而不是一件:判定加上歷史查詢(把一格分成兩格),以及措辭不宣稱成因(不把分類責任推回給使用者,又替他答錯)。只改措辭是誠實但不夠,只改判定則會在措辭上把修好的判定又漏掉。
作用域會在兩個位置漏掉,而修好判定不會順便修好措辭。 那條歷史查詢的零輸出,語意限定在跑它的那個 repo——它說的是「不是我刪的」,而不是「沒有人刪過它」。把它接進工具之後,提示文字若寫成「此檔未曾被刪除」,就是用一個過寬的宣稱換掉另一個過寬的宣稱;正確的措辭要把執行的位置帶進去:本專案的歷史裡沒有這個檔的刪除紀錄,它可能來自其他消費端。
這一步容易被跳過,因為修判定的時候人在想集合的成員,修措辭的時候才會想到讀的人站在哪裡。判定管的是分成幾格,措辭管的是每一格的宣稱有多寬,兩者各自可以錯。
判讀徵兆
- 一條會動到共用資源的指令跑不完,而下一個動作是加旗標重跑。
- 手上的旗標叫 force、yes、skip、no-verify、allow,而使用理由是「這個環境不能互動」。
- 對刪除類旗標的驗證只確認過自己要刪的東西在清單裡。
- 指令送進背景或接了
tail,而它會改動的東西不只自己在用。 - 事後回讀輸出時,發現警告在第一行。
這條原則什麼時候不成立
只影響自己的操作。 本地建置、暫存清理、可以重跑的東西,繞過確認的代價是自己重來一次,不值得為它加一道預覽。
真正的環境缺陷。 有些指令在非互動環境裡確實會壞,而不是在等確認。分辨方式是看它停在哪:停在印出計畫之後是關卡,停在計畫之前是故障。
冪等且可還原的操作。 版本控制之內的改動可以退回,這時預覽的價值降到只剩省時間。實例的推送落在版控裡而仍然構成事故,是因為八個消費端會各自 pull,還原的時間窗不由自己決定。
跟其他抽象層原則的關係
#293 撰文脈絡以兩種尺度洩漏 與本卡共用同一個機制在相反的方向上:那裡是完成訊號來自最容易通過的那條路,這裡是阻力被解釋成最容易排除的那個成因。兩者都讓判斷停在第一個合理的解釋上。
#291 掃描指令壞掉時不會報錯,它報一個數字 講的是工具安靜地量到別的東西,本卡講的是工具明確說了話而話被讀在事後。兩者的共同前提是指令的輸出沒有被當成需要判讀的材料,差別在它有沒有出聲。
#292 更新一個可數宣稱時,加法不算重算 的驗算不對稱與本卡的旗標不對稱同型:驗一個總數與驗每個分項、問「它會不會做我要的」與問「它還會做什麼」,前者都便宜而且都會通過。
#227 可重現性只有乾淨機器重跑才驗得出 講的是工作機的非-repo 狀態撐著環境、讓缺口隱形。這次的缺口是反過來的同一件事:那條同步指令寫在標準流程裡,而執行它需要的工具在這個 repo 從來不存在——它在另外七個專案跑得動,所以缺件一直沒有訊號,直到它失敗。而失敗的第一個表現又正好是本卡講的「看起來像環境問題」。
- #301 限制類宣稱的錯誤會自我保存,因為它勸阻的正是唯一能推翻它的那個動作 是本卡被寫下來之後的形態。本卡處理的是當下的誤讀——關卡表現成當機、人主動拆掉它;那張卡處理的是那個誤讀變成一條記錄之後的事:作用範圍從當事人擴大到每個讀到它的人,而它勸阻的正是唯一能推翻它的那次重試。