GitWindowsNode.jsnpm自動化

git worktree 加 junction 的 node_modules:我們怎麼把共用依賴弄壞又怎麼防

· · 20 分鐘閱讀
目錄
  1. 為什麼會想用 junction?
  2. 四次事故
  3. 這週重新實測:哪些指令會穿過 junction?
  4. 被清空之後長什麼樣?
  5. 我們現在的規矩
  6. 檢查腳本
  7. 想照做的話

在 Windows 上,把 git worktree 裡的 node_modules 用 mklink /J 接回主資料夾,可以省掉每個工作樹各裝一次套件。代價是收工時的 git worktree remove 會穿過這個 junction,把主資料夾那份 node_modules 清空。九月初到十月中,我們踩了四次。這週我們在沙盒重新實測,又多發現兩件事:不加 --force 一樣會清空;在 junction 上跑 npm ci,也會把主資料夾清空。現在的規矩是工作樹不建 junction,各自 npm ci;已經有 junction 的,先拆連結、確認主資料夾還在,才移除工作樹。文末附檢查腳本。

為什麼會想用 junction?

我們同時開很多個 Claude Code 工人,每個工人在自己的 git worktree 裡改程式、跑 build(並行工人的規矩寫在這篇)。每個工作樹都要跑 tsc 或 build,就得有 node_modules。

省事的寫法是接回主資料夾:

mklink /J C:\work\wt-feature\frontend\node_modules C:\work\repo\frontend\node_modules

工作樹立刻能跑,不佔空間也不用等安裝。一開始是我們自己在派工指令裡這樣寫,後來還寫進了某條工作線的筆記。問題出在收工。

四次事故

第一次,9 月 3 日。 三個工人各在一個工作樹做搬運,frontend/node_modules 都接到同一個產品 repo 的主資料夾。合併完,用 git worktree remove --force 清工作樹。git 遞迴刪除時走進 junction,主資料夾的前端和 engine 兩份 node_modules 被清空,主資料夾的測試立刻報 MODULE_NOT_FOUND,只能 npm ci 重裝。

第二次,9 月 4 日。 這次知道要先拆連結,在 Git Bash 裡寫了 cmd /c rmdir "C:\..." 2>/dev/null。Git Bash 會把 /c 這種參數當成路徑轉換,cmd 沒收到 /c,直接開成互動模式,印出版本資訊就結束,rmdir 根本沒跑。錯誤輸出又被丟掉,看起來一切正常。接著移除工作樹,主資料夾又被清空一次。

第三、四次,10 月 10 日 00:31 和 10 月 11 日 04:02。 負責官網的那條工作線,在臨時工作樹把 frontend/node_modules 接到另一個工作樹的 node_modules,而那一個本身又是指向主資料夾的 junction,兩層。收工 remove --force 穿過兩層,主資料夾清空,隔天四支夜間排程全部 exit 1。我們讀了當時的對話紀錄才找到病根:那條線的筆記教它用 mklink /J。

這週重新實測:哪些指令會穿過 junction?

我們在暫存資料夾開一個假 repo,主資料夾的 node_modules 放兩個項目,工作樹的 node_modules 用 mklink /J 接過去,每種刪法各開一組重來。環境是 Windows 10、git 2.52.0.windows.1、PowerShell 5.1、npm 10.9.4。

在工作樹執行 主資料夾的 node_modules
git worktree remove(不加 --force) 清空
git worktree remove --force 清空
Git Bash rm -rf 工作樹 完好
PowerShell 5.1 Remove-Item -Recurse -Force 工作樹 完好
cmd rmdir /s /q 工作樹 完好
先拆連結,再 git worktree remove 完好
npm ci 清空,junction 變成實體資料夾
npm install 完好,junction 變成實體資料夾

有兩件事跟我們原本以為的不一樣。

第一,不加 --force 救不了。node_modules 在 .gitignore 裡,git 把工作樹當成乾淨的,照樣移除,照樣穿過 junction。我們之前的筆記寫「先拆連結再 remove(不加 --force)」,好像不加 force 是保險的一環,其實不是,真正有用的只有先拆連結。

第二,我們先前一篇文章寫過 rm -rf 也會穿過 junction。這次在這台機器上測,Git Bash 的 rm -rf、PowerShell 5.1 的 Remove-Item、rmdir /s 都沒有穿過去,這裡更正,那篇也已經改了。我們只測了這個環境,其他版本不保證,所以規則不變:有 junction 一律先拆。

npm ci 這條最容易被忽略。我們讓主資料夾鎖 is-number@7.0.0,另外放一個只存在主資料夾的標記檔;工作樹的 lock 寫 6.0.0。在工作樹跑 npm ci 之後,主資料夾的 node_modules 變成空的,套件和標記檔都沒了;工作樹的 node_modules 變成實體資料夾,裝的是 6.0.0。很多工人指令就寫「npm ci && npm run build」,在有 junction 的工作樹裡,這一行就會清空主資料夾。

被清空之後長什麼樣?

不好發現。實測裡被 git 清空的主資料夾,node_modules 這個資料夾本身還在,只是裡面是空的。所以「有沒有 node_modules」這種檢查會通過,排程腳本也不會在一開始就報錯,要等它真的 require 某個套件才炸。這也符合第三、四次的現象:清空發生在半夜收工時,四支夜間排程到隔天才一起 exit 1。

所以收工之後要檢查的不是資料夾在不在,而是裡面的項目數有沒有掉。下面的腳本拆連結時,就是拆前拆後各數一次。

我們現在的規矩

  1. 工作樹不建 junction,自己 npm ci。 寫這篇用的官網工作樹,npm ci 裝 1,284 個套件花 46 秒。這點時間換主資料夾安全,划算。
  2. 收工順序固定:先刪工作樹自己的 node_modules,再 git worktree remove。移除失敗就看 git status 是什麼,別為了過關直接加 --force。
  3. 已經有 junction 的,先跑檢查腳本,拆掉連結、確認另一頭的檔案數沒變,才移除工作樹。
  4. 拆連結一律用不遞迴的方式:PowerShell [IO.Directory]::Delete('路徑', $false)、cmd rmdir(不加 /s)、Node fs.rmdirSync。在 Git Bash 叫 cmd 要寫 cmd //c。
  5. 防呆 hook:10 月 12 日起,我們在 Claude Code 裝了一個 PreToolUse hook,偵測到要移除的工作樹裡有 junction 或 symlink 就擋下,並印出安全拆法;hook 自己出錯時放行,不會卡住工作。它的限制是只看指令文字,攔不到 npm ci,所以第 1 條才是主防線。

10 月 26 日我們用這套順序一次清掉 112 個工作樹,裡面有 61 個 node_modules 連結,全部先拆再移除,主資料夾全部完好,C 槽可用空間從 16.83 GB 變成 40.40 GB。這週有三份工人收工報告都記了先拆 junction、再確認主資料夾完好;另外兩個工人直接在工作樹自己 npm ci,沒建 junction。也就是說,還是有工人照舊習慣建 junction,所以 hook 和下面的檢查腳本都還需要。

檢查腳本

放在 repo 根目錄執行。不帶參數會列出每個工作樹裡的 junction 和 symlink,連鎖的會追到最終目標,指回主資料夾的特別標出來;帶 --unlink <工作樹> 只拆那個工作樹的連結,拆前拆後各數一次目標的項目數,對不上就停。

#!/usr/bin/env node
// wt-links.mjs:移除 git worktree 前,先列出裡面的 junction/symlink
//   node wt-links.mjs                 掃描目前 repo 的所有工作樹
//   node wt-links.mjs --unlink <wt>   只拆 <wt> 裡的連結,並確認目標沒少東西
// exit 0=沒有連結(可以 git worktree remove),1=有連結,2=錯誤或目標被動到
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';

const MAX_DEPTH = 4;
const SKIP = new Set(['.git', 'node_modules']);   // 不走進實體 node_modules,避免列出套件內部的連結

function worktrees() {
  const out = execFileSync('git', ['worktree', 'list', '--porcelain'], { encoding: 'utf8' });
  return out.split('\n').filter(l => l.startsWith('worktree ')).map(l => path.resolve(l.slice(9).trim()));
}

// 走訪時絕不走進連結;Windows 上 Node 的 lstat 會把 junction 當成 symlink
function findLinks(root) {
  const found = [];
  const stack = [[root, 0]];
  while (stack.length) {
    const [dir, depth] = stack.pop();
    let entries;
    try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
    for (const e of entries) {
      const p = path.join(dir, e.name);
      let st;
      try { st = fs.lstatSync(p); } catch { continue; }
      if (st.isSymbolicLink()) {
        let target = '?', real = '?';
        try { target = path.resolve(dir, fs.readlinkSync(p)); } catch {}
        try { real = fs.realpathSync(p); } catch {}   // 追到底:連結 -> 連結 -> 實體資料夾
        found.push({ link: p, target, real });
      } else if (st.isDirectory() && !SKIP.has(e.name) && depth < MAX_DEPTH) {
        stack.push([p, depth + 1]);
      }
    }
  }
  return found;
}

function count(dir) {
  try { return fs.readdirSync(dir).length; } catch { return -1; }
}

const args = process.argv.slice(2);

if (args[0] === '--unlink') {
  const wt = path.resolve(args[1] || '');
  if (!args[1] || !fs.existsSync(wt)) { console.error('usage: --unlink <worktree path>'); process.exit(2); }
  const links = findLinks(wt);
  if (!links.length) { console.log(`no links in ${wt}`); process.exit(0); }
  let bad = 0;
  for (const { link, target } of links) {
    const before = count(target);
    fs.rmdirSync(link);            // 不遞迴:只刪連結本身,不碰目標裡的檔案
    const after = count(target);
    const ok = before === after && after >= 0;
    if (!ok) bad++;
    console.log(`${ok ? 'OK  ' : 'BAD '} unlinked ${link}  (target ${target}: ${before} -> ${after} entries)`);
  }
  const left = findLinks(wt);
  if (bad || left.length) { console.error(`stop: ${bad} target(s) changed, ${left.length} link(s) left`); process.exit(2); }
  console.log(`done. now run: git worktree remove "${wt}"`);
  process.exit(0);
}

let total = 0;
const all = worktrees();
const main = all[0];
for (const wt of all) {
  const links = findLinks(wt);
  total += links.length;
  for (const { link, target, real } of links) {
    const intoMain = real.toLowerCase().startsWith(main.toLowerCase() + path.sep);
    const chain = real !== target ? ` => ${real}` : '';
    console.log(`${wt === main ? 'MAIN' : 'WT  '} ${link} -> ${target}${chain}${intoMain ? '   [ends in main checkout]' : ''}`);
  }
}
console.log(`${all.length} worktree(s), ${total} link(s)`);
process.exit(total ? 1 : 0);

我們在沙盒做了跟第三次事故一樣的兩層 junction,跑出來長這樣:

WT   C:\tmp\jx-lab\T\wt1\frontend\node_modules -> C:\tmp\jx-lab\T\main\frontend\node_modules   [ends in main checkout]
WT   C:\tmp\jx-lab\T\wt2\frontend\node_modules -> C:\tmp\jx-lab\T\wt1\frontend\node_modules => C:\tmp\jx-lab\T\main\frontend\node_modules   [ends in main checkout]
3 worktree(s), 2 link(s)

兩個工作樹各跑一次 --unlink,目標都是 2 → 2;再掃一次是 0 個連結,然後 git worktree remove 兩個工作樹,主資料夾的 node_modules 還是 2 個項目。

掃描深度預設 4 層,frontend/node_modules 這種位置都掃得到;你的 repo 如果把套件放得更深,改 MAX_DEPTH。實體的 node_modules 不會走進去,所以套件內部的連結(例如 npm workspaces)不會被列出。我們只在 Windows 的 junction 上驗過,Linux 和 macOS 的 symlink 沒測。腳本只拆連結,不會幫你執行 git worktree remove,那一步留給人看完輸出再按。

想照做的話

  1. 先在主資料夾跑 node wt-links.mjs,看你現在有多少工作樹接著 junction。
  2. 以後開工作樹就 npm ci,別再建 junction。
  3. 要清舊的,一個一個 --unlink,看到 OK 再 git worktree remove。
  4. 如果你也用 AI 工人,把「先拆連結再移除」寫進派工指令最後一段,別只寫在筆記裡。我們第三、四次就是筆記本身教錯了。

常見問題

git worktree remove 不加 --force,就不會刪到 junction 另一頭嗎?

不會比較安全。我們在 git 2.52.0.windows.1 實測,node_modules 在 .gitignore 裡,git 把工作樹當成乾淨的,不加 --force 也照樣移除,而且一樣穿過 junction 把主資料夾的 node_modules 清空。加不加 --force 只差在有未提交改動時會不會擋,跟 junction 無關。

在 junction 接過來的 node_modules 上跑 npm ci 會怎樣?

主資料夾的 node_modules 會被清空。我們用 npm 10.9.4 實測:npm ci 跑完,主資料夾那份裡的套件和我們放的標記檔全部不見,工作樹的 node_modules 則從 junction 變成一個實體資料夾,裝的是工作樹自己 lock 檔的版本。同樣情境改跑 npm install,主資料夾完好,只是 junction 也被換成實體資料夾。

怎麼只刪 junction 本身,不刪到另一頭的檔案?

用不遞迴的刪除。PowerShell 寫 [IO.Directory]::Delete('路徑', $false),第二個參數 false 代表不遞迴;cmd 用 rmdir 不加 /s;Node 用 fs.rmdirSync。在 Git Bash 裡叫 cmd 要寫 cmd //c,寫成 cmd /c 會被當成路徑轉換,cmd 直接開成互動模式,rmdir 根本沒跑。刪完先數一下另一頭的檔案還在,再 remove 工作樹。

每週 AI 自動化實戰筆記

不廢話,只有能直接用的東西。Prompt 模板、自動化 SOP、技術拆解。

加入一人公司實驗室

免費資源包、每日建造日誌、可以對話的 AI Agent。一群用 AI 武裝自己的獨立開發者社群。

想自己動手試試?

UltraProbe 免費、免註冊,掃一次網站就知道 Google 與 AI 找不找得到你。