@minonl/dia 0.0.0-stage → 0.0.2-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +253 -2
- package/bin/dia.js +5 -0
- package/dist/cli.js +7408 -0
- package/docs/acp-integration.md +136 -0
- package/docs/architecture.md +166 -0
- package/docs/cli.md +117 -0
- package/docs/dependency-registry.md +105 -0
- package/docs/deps.jsonl +7 -0
- package/docs/ecosystem-integration.md +155 -0
- package/docs/ecosystem-positioning.md +95 -0
- package/docs/entrypoints.md +83 -0
- package/docs/evolution-process.md +298 -0
- package/docs/history/framework-bootstrap.md +109 -0
- package/docs/host-control-surface.md +213 -0
- package/docs/index.md +80 -0
- package/docs/kit-install.md +339 -0
- package/docs/migrations/kb-golden.md +32 -0
- package/docs/migrations/llm-migration.md +49 -0
- package/docs/migrations/no-python.md +52 -0
- package/docs/migrations/yan-agent-current.md +52 -0
- package/docs/migrations/yan-agent-next.md +168 -0
- package/docs/migrations/yan-kb-parity.md +159 -0
- package/docs/migrations/yan-kb.md +120 -0
- package/docs/model-baseline-sidecar.md +220 -0
- package/docs/model-behavior.md +229 -0
- package/docs/monorepo.md +83 -0
- package/docs/overview.md +126 -0
- package/docs/patching.md +91 -0
- package/docs/persona-install.md +264 -0
- package/docs/platforms/index.md +66 -0
- package/docs/platforms/registry.json +54 -0
- package/docs/pricing.md +43 -0
- package/docs/proposals.md +76 -0
- package/docs/research/benchmarks-survey.md +96 -0
- package/docs/research/collaboration-theory.md +85 -0
- package/docs/research/dsh-superagent.md +97 -0
- package/docs/research/frameworks-survey-2.md +75 -0
- package/docs/research/h0/report.md +135 -0
- package/docs/research/h0/sample.json +34 -0
- package/docs/research/model-boundary-patterns.md +99 -0
- package/docs/research/opencode-session-scoping.md +84 -0
- package/docs/review/reuse-review.md +105 -0
- package/docs/review/verification-2026-10-28.md +49 -0
- package/docs/security/audits/2026-11-04.json +44 -0
- package/docs/security/credentials.md +95 -0
- package/docs/security/public-release.md +108 -0
- package/docs/security-sandbox.md +157 -0
- package/docs/status.md +158 -0
- package/docs/team-automation.md +169 -0
- package/docs/team.md +112 -0
- package/docs/versioning.md +237 -0
- package/docs/vision.md +161 -0
- package/package.json +45 -4
- package/packages/job/README.md +98 -0
- package/packages/job/bin/dia-job +1283 -0
- package/packages/job/config.example +5 -0
- package/packages/job/install.sh +37 -0
- package/packages/job/lib/config.sh +18 -0
- package/packages/job/lib/json.mjs +130 -0
- package/packages/job/lib/policy.sh +103 -0
- package/packages/job/package.json +10 -0
- package/packages/job/shim/yan-task +24 -0
- package/packages/job/tests/bootstrap.test.sh +115 -0
- package/packages/job/tests/install.test.sh +30 -0
- package/packages/job/tests/linux.test.sh +130 -0
- package/packages/job/tests/p2.test.sh +46 -0
- package/packages/job/tests/policy.test.sh +38 -0
- package/packages/wt/bin/dia-wt +1148 -0
- package/packages/wt/tests/check.test.sh +126 -0
- package/packages/wt/tests/clean.test.sh +126 -0
- package/packages/wt/tests/prune-merged.test.sh +169 -0
- package/packages/wt/tests/route.test.sh +112 -0
- package/packages/wt/tests/scope.test.sh +69 -0
|
@@ -0,0 +1,1148 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ============================================================================
|
|
3
|
+
# dia wt —— worktree 目录形式选型 + 创建(**唯一实现**,2026-10-12)
|
|
4
|
+
#
|
|
5
|
+
# 为什么需要:
|
|
6
|
+
# R20 要求「任何仓库的改动一律 worktree 隔离」,但目录形式选型
|
|
7
|
+
# (平级 `<repo>-worktree--<feat>` vs 仓库内 `<repo>/.worktrees/<feat>`)
|
|
8
|
+
# 此前**靠人判断**(`workflows/worktree-forms.md`)→ 2026-10-12 实测:
|
|
9
|
+
# agent 在 yan-agent 上**忘了建 worktree**、直接在 main 工作区提交,
|
|
10
|
+
# 还用 `git add -A` 抓走了并行会话的未提交改动。
|
|
11
|
+
# ⇒ 把「选型 + 创建」做成脚本,**固定死**,agent 只需调用它(②③ 脚本化)。
|
|
12
|
+
#
|
|
13
|
+
# 选型规则(**默认平级,白名单才隐藏**;用户 2026-10-12 拍板「拿不准一律平级」):
|
|
14
|
+
# 平级(默认):<parent>/<repo>-worktree--<feature>
|
|
15
|
+
# 隐藏(白名单):<repo>/.worktrees/<feature>
|
|
16
|
+
# ⚠️ 白名单**不硬编码仓名**,而是**按条件检测**(worktree-forms.md §2):
|
|
17
|
+
# ① 无 node_modules ② 无仓库级扫描工具 ③ .gitignore 含 .worktrees/
|
|
18
|
+
# 三条**同时**满足才用隐藏;任一不满足或**检测不确定** → **平级**(兜底)。
|
|
19
|
+
#
|
|
20
|
+
# 用法:
|
|
21
|
+
# bash scripts/dia wt create <repo-path> <feature> [--base <ref>] [--dry-run] [--install]
|
|
22
|
+
# bash scripts/dia wt form <repo-path> # 只看选型结论(不创建)
|
|
23
|
+
# bash scripts/dia wt list [repo-path] # 列 worktree(含形式标注)
|
|
24
|
+
# bash scripts/dia wt check [<repo>...] [--all] [--deep] [--quiet] # ★ node_modules 软链体检(R20)
|
|
25
|
+
# bash scripts/dia wt clean <repo-path> <feature> # 按 R25 清理(合并后)
|
|
26
|
+
#
|
|
27
|
+
# 退出码:0 成功/体检干净;1 失败/体检有违规;2 未知子命令
|
|
28
|
+
#
|
|
29
|
+
# ── 依赖策略(R20)与 GVS 试点记录(2026-10-16)────────────────────────────────
|
|
30
|
+
# 【规则】worktree 内一律**真装**(`pnpm install --frozen-lockfile --prefer-offline`,实测 12~58s);
|
|
31
|
+
# **禁止**把别的 checkout 的 `node_modules` 软链过来 —— pnpm 会按 realpath **改写对方**的顶层链,
|
|
32
|
+
# 删 worktree 后对方整片悬空(2026-09-30 主仓 198 条 / 10-10 两次 99+100 条 / 10-15 一次)。
|
|
33
|
+
# ⚠️ 判据精确版 = 禁「**顶层 `node_modules` 是软链 / 跨 checkout 共享**」,**不是**禁 node_modules 内部有软链:
|
|
34
|
+
# pnpm `isolated` linker 的顶层包链、`link:` 依赖、dsh profile 的 `node_modules/@example/*`、
|
|
35
|
+
# GVS 的 `<storeDir>/links/**` 都是**合法形态**(`check` 已把 store links 列入白名单)。
|
|
36
|
+
# ✅ 落地 = `check`(判据 A/B/C/D + 白名单)+ `create` 提示真装/`--install` + `clean` 拒绝软链 worktree;
|
|
37
|
+
# 巡检任务 `com.leyan.yan-nm-check` 每日 14:10 跑 `check --all --quiet`。
|
|
38
|
+
# 【GVS 试点结论:暂停启用(用户 2026-10-16 拍板)】pnpm 官方的多 worktree 共享方案
|
|
39
|
+
# `virtualStoreType: global`(≥11.23.0;老拼写 `enableGlobalVirtualStore`)在 example-repo 实测:
|
|
40
|
+
# **收益** 新 worktree 安装 **2.9s**(对照 17.5~20.8s)、`node_modules` **1.9M**(对照名义 1.3G)、lockfile 零变更;
|
|
41
|
+
# **硬阻** GVS 把「每个包的依赖图目录」实体化 ⇒ `@types/react` 出现 6 个不同 realpath 副本 ⇒ TS 类型身份分裂
|
|
42
|
+
# ⇒ **CI 同款 `nx typecheck web-hub` 40 错**(单变量对照:同 pnpm 版本关掉 GVS 即通过;pnpm 12.11.2 同样错)。
|
|
43
|
+
# 绕法(tsconfig `paths` 钉 `react`)能救 TS,但被 example-repo 的 `resolve.tsconfigPaths: true` 带到**运行时** ⇒ vitest 挂,故不可用。
|
|
44
|
+
# ⇒ **不改任何仓**,维持「真装 + 禁软链」。完整数据/复现命令/失败尝试见 `knowledge/tools/pnpm.md`「试点结论」;
|
|
45
|
+
# 流程语境见 `knowledge/workflows/worktree-forms.md` §3。
|
|
46
|
+
# ============================================================================
|
|
47
|
+
set -uo pipefail
|
|
48
|
+
|
|
49
|
+
# 自身路径(`check` 的修复指引里要能回指本脚本;被 `bash <path>` 调用时 $0 已是路径)
|
|
50
|
+
# ── 解析真实脚本路径(symlink 安全;macOS readlink 无 -f)────────────────
|
|
51
|
+
_SRC="${BASH_SOURCE[0]:-$0}"
|
|
52
|
+
while [ -L "$_SRC" ]; do
|
|
53
|
+
_D="$(cd -P "$(dirname "$_SRC")" >/dev/null 2>&1 && pwd)"
|
|
54
|
+
_SRC="$(readlink "$_SRC")"
|
|
55
|
+
case "$_SRC" in /*) ;; *) _SRC="$_D/$_SRC" ;; esac
|
|
56
|
+
done
|
|
57
|
+
SELF_DIR="$(cd -P "$(dirname "$_SRC")" >/dev/null 2>&1 && pwd)"
|
|
58
|
+
SELF="$_SRC"
|
|
59
|
+
|
|
60
|
+
# ── SIGPIPE 加固(T8,2026-10-13 实测)─────────────────────────────────────────
|
|
61
|
+
# 现象:`dia wt status | head -3` / `| grep -q …` 会以 **141**(128+SIGPIPE)退出,
|
|
62
|
+
# 且**输出被截断** —— 因为下游提前退出关闭管道后,本脚本仍在 `printf`。
|
|
63
|
+
# 影响面:**用户真实用法**(`| head`、`| less`、`| grep -q`),不只是 e2e 断言。
|
|
64
|
+
# e2e 3b 段 `T4 status 未标出未提交改动` 的失败即由此而来(grep -q 提前退出)。
|
|
65
|
+
# 修法:默认忽略 SIGPIPE ⇒ 写失败不再杀进程;管道下游截断属**预期行为**,退出码归 0。
|
|
66
|
+
# 注:`trap` 只影响本脚本自身,不改变各子命令(git 等)的语义。
|
|
67
|
+
trap '' PIPE
|
|
68
|
+
|
|
69
|
+
say() { printf '\033[1;36m[dia-wt]\033[0m %s\n' "$*"; }
|
|
70
|
+
warn() { printf '\033[1;33m[dia-wt]\033[0m %s\n' "$*" >&2; }
|
|
71
|
+
die() { printf '\033[1;31m[dia-wt]\033[0m %s\n' "$*" >&2; exit 1; }
|
|
72
|
+
|
|
73
|
+
# ── 仓库级扫描工具特征(出现任一 → 平级)────────────────────────────────────────
|
|
74
|
+
# 依据 worktree-forms.md §2 条件②:nx / eslint / tsc / vite / CRA 全仓扫描。
|
|
75
|
+
has_repo_scan_tools() {
|
|
76
|
+
local repo="$1"
|
|
77
|
+
local pats=(nx.json eslint.config.js eslint.config.mjs eslint.config.cjs .eslintrc .eslintrc.js .eslintrc.cjs
|
|
78
|
+
tsconfig.json vite.config.ts vite.config.js webpack.config.js)
|
|
79
|
+
local f
|
|
80
|
+
for f in "${pats[@]}"; do
|
|
81
|
+
[ -e "${repo}/${f}" ] && return 0
|
|
82
|
+
done
|
|
83
|
+
if [ -f "${repo}/package.json" ]; then
|
|
84
|
+
grep -qE '"(nx|eslint|typescript|vite|react-scripts)"' "${repo}/package.json" 2>/dev/null && return 0
|
|
85
|
+
fi
|
|
86
|
+
return 1
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# ── 选型:输出 flat | hidden ────────────────────────────────────────────────────
|
|
90
|
+
pick_form() {
|
|
91
|
+
local repo="$1"
|
|
92
|
+
[ -d "${repo}/.git" ] || { printf 'flat'; return 0; }
|
|
93
|
+
|
|
94
|
+
if has_repo_scan_tools "$repo"; then printf 'flat'; return 0; fi
|
|
95
|
+
if [ -d "${repo}/node_modules" ]; then printf 'flat'; return 0; fi
|
|
96
|
+
if [ -f "${repo}/.gitignore" ] && grep -qE '^/?\.worktrees/?$' "${repo}/.gitignore" 2>/dev/null; then
|
|
97
|
+
printf 'hidden'; return 0
|
|
98
|
+
fi
|
|
99
|
+
printf 'flat'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
form_reason() {
|
|
103
|
+
local repo="$1"
|
|
104
|
+
if has_repo_scan_tools "$repo"; then echo "有仓库级扫描工具(nx/eslint/tsc/vite…)→ 平级"; return; fi
|
|
105
|
+
if [ -d "${repo}/node_modules" ]; then echo "有 node_modules(需真装依赖)→ 平级"; return; fi
|
|
106
|
+
if [ -f "${repo}/.gitignore" ] && grep -qE '^/?\.worktrees/?$' "${repo}/.gitignore" 2>/dev/null; then
|
|
107
|
+
echo "纯文档/脚本仓(无依赖、无扫描工具、gitignore 已备)→ 隐藏子目录可用"; return; fi
|
|
108
|
+
echo "条件不足或不确定 → 平级(兜底,用户 2026-10-12 拍板)"
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# ── 数据根(与 core/AGENTS.md 原则 9 一致;YAN_DATA_ROOT 优先)──────────────────
|
|
112
|
+
dia_wt_data_root() {
|
|
113
|
+
if [ -n "${DIA_WT_DATA_ROOT:-}" ]; then printf '%s' "${DIA_WT_DATA_ROOT%/}"; return; fi
|
|
114
|
+
if [ -n "${DIA_DATA_ROOT_SCOPE:-}" ]; then printf '%s' "${DIA_DATA_ROOT_SCOPE%/}"; return; fi
|
|
115
|
+
# 默认与 yan 数据根同构:~/.yan-agent(persona 用 data_root 声明覆盖)
|
|
116
|
+
printf '%s' "$HOME/.yan-agent"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# ── 授权推导的**输入**:产出 scope.json(设计 §4.5 D0–D6;消费方 = dsh 引擎 / P3 兜底插件)──
|
|
120
|
+
# 为什么在这里产出:迭代入口天然知道"本次任务仓库是哪个",而权限层不知道(这是越权写的根因之一)。
|
|
121
|
+
# ⚠️ 关键判定:**是否 git 仓库** —— 非 git 仓库(如 ~/repos/leyan 根)**不派生** worktree 同级目录,
|
|
122
|
+
# 否则会把兄弟目录(repos/local 等)误算进作用域(用户 2026-10-15 明确)。
|
|
123
|
+
write_scope() { # write_scope <repo-or-worktree-path> [feature] [session]
|
|
124
|
+
local target="$1" feature="${2:-}" session="${3:-}"
|
|
125
|
+
[ -n "$target" ] || die "write_scope 需要路径"
|
|
126
|
+
[ -d "$target" ] || die "路径不存在:$target"
|
|
127
|
+
local abs; abs="$(cd "$target" && pwd)"
|
|
128
|
+
|
|
129
|
+
# 若给的是 worktree(<name>-worktree--<slug>)⇒ 归到主仓;否则按主仓处理
|
|
130
|
+
local base main="$abs" main_checkout=false active="$abs"
|
|
131
|
+
base="$(basename "$abs")"
|
|
132
|
+
if case "$base" in *-worktree--*) true ;; *) false ;; esac; then
|
|
133
|
+
local parent; parent="$(dirname "$abs")"
|
|
134
|
+
local m="$parent/${base%%-worktree--*}"
|
|
135
|
+
if [ -d "$m" ]; then main="$m"; fi
|
|
136
|
+
else
|
|
137
|
+
[ -d "$abs/.git" ] && main_checkout=true # 主 checkout:.git 是目录(worktree 的 .git 是文件)
|
|
138
|
+
[ -f "$abs/.git" ] && main_checkout=false
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
local git_repo=false
|
|
142
|
+
git -C "$main" rev-parse --git-dir >/dev/null 2>&1 && git_repo=true
|
|
143
|
+
|
|
144
|
+
# ⚠️ 2026-10-16 修:此前写**单份** `tasks/scope.json` ⇒ 多会话并发互相覆盖(实测:后 create 的会话
|
|
145
|
+
# 把前一个的 active 覆盖掉 ⇒ 权限层会用**别人的作用域**判定越权)。改为**按 worktree 分文件**:
|
|
146
|
+
# `<数据根>/tasks/scopes/<active 基名>.json`(worktree 目录名唯一 ⇒ 天然隔离;同一 worktree 内重复写=同一任务,覆盖正确)
|
|
147
|
+
local scopes_dir; scopes_dir="$(dia_wt_data_root)/tasks/scopes"
|
|
148
|
+
mkdir -p "$scopes_dir"
|
|
149
|
+
local out="$scopes_dir/$(basename "$active").json"
|
|
150
|
+
node - "$out" "$main" "$git_repo" "$main_checkout" "$active" "$feature" "$session" <<'JS'
|
|
151
|
+
const [out, repo, gitRepo, mainCheckout, active, feature, session] = process.argv.slice(2);
|
|
152
|
+
const path = require('node:path');
|
|
153
|
+
const fs = require('node:fs');
|
|
154
|
+
const parent = path.dirname(repo), name = path.basename(repo);
|
|
155
|
+
const writable = [repo];
|
|
156
|
+
let sibling = null;
|
|
157
|
+
if (gitRepo === 'true') {
|
|
158
|
+
sibling = `${parent}/${name}-worktree--*`;
|
|
159
|
+
writable.push(sibling);
|
|
160
|
+
}
|
|
161
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
162
|
+
const d = new Date();
|
|
163
|
+
const since = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
164
|
+
const doc = {
|
|
165
|
+
task: feature || name, repo, active,
|
|
166
|
+
gitRepo: gitRepo === 'true', mainCheckout: mainCheckout === 'true',
|
|
167
|
+
writable, siblingWorktreeGlob: sibling, session: session || null, since,
|
|
168
|
+
producedBy: 'dia wt scope',
|
|
169
|
+
};
|
|
170
|
+
const tmp = out + '.tmp';
|
|
171
|
+
fs.writeFileSync(tmp, JSON.stringify(doc, null, 1) + '\n');
|
|
172
|
+
fs.renameSync(tmp, out); // 原子替换
|
|
173
|
+
console.log(JSON.stringify(doc, null, 1));
|
|
174
|
+
JS
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
cmd_scope() { # scope <repo-or-worktree> [--feature <slug>] [--session <id>] | scope --show
|
|
178
|
+
local target="" feature="" session="" show=false list=false
|
|
179
|
+
while [ $# -gt 0 ]; do case "$1" in
|
|
180
|
+
--feature) feature="${2:-}"; shift 2 ;;
|
|
181
|
+
--session) session="${2:-}"; shift 2 ;;
|
|
182
|
+
--show) show=true; shift ;;
|
|
183
|
+
--list) list=true; shift ;;
|
|
184
|
+
*) target="$1"; shift ;;
|
|
185
|
+
esac; done
|
|
186
|
+
local scopes; scopes="$(dia_wt_data_root)/tasks/scopes"
|
|
187
|
+
if [ "$list" = true ]; then
|
|
188
|
+
ls -1 "$scopes"/*.json 2>/dev/null | while read -r f; do
|
|
189
|
+
printf '%s\n' "$(basename "$f")"
|
|
190
|
+
done
|
|
191
|
+
[ -d "$scopes" ] && [ -n "$(ls -1 "$scopes"/*.json 2>/dev/null)" ] || warn "尚无作用域(先跑:dia wt scope <repo>)"
|
|
192
|
+
return 0
|
|
193
|
+
fi
|
|
194
|
+
if [ "$show" = true ] || [ -z "$target" ]; then
|
|
195
|
+
# 按 **cwd** 选用:优先匹配 active 前缀最长者;否则回退"当前目录所属 worktree"的 scope 文件
|
|
196
|
+
local cur; cur="$(pwd)"
|
|
197
|
+
local best="" best_len=0 f active
|
|
198
|
+
for f in "$scopes"/*.json; do
|
|
199
|
+
[ -f "$f" ] || continue
|
|
200
|
+
active="$(node -e 'try{console.log(require(require("node:path").resolve(process.argv[1])).active||"")}catch{}' "$f" 2>/dev/null)"
|
|
201
|
+
case "$cur" in "$active"|"$active"/*) [ ${#active} -gt "$best_len" ] && { best="$f"; best_len=${#active}; } ;; esac
|
|
202
|
+
done
|
|
203
|
+
[ -n "$best" ] && { cat "$best"; return 0; }
|
|
204
|
+
[ -f "$(dia_wt_data_root)/tasks/scope.json" ] && { warn "(回退兼容:旧的单份 scope.json)"; cat "$(dia_wt_data_root)/tasks/scope.json"; return 0; }
|
|
205
|
+
warn "当前目录无适用作用域(先跑:dia wt scope <repo>)"; return 1
|
|
206
|
+
fi
|
|
207
|
+
write_scope "$target" "$feature" "$session" >/dev/null
|
|
208
|
+
local written
|
|
209
|
+
written="$(ls -1t "$scopes"/*.json 2>/dev/null | head -1)"
|
|
210
|
+
say "✅ 已写作用域声明:$written"
|
|
211
|
+
node - "$written" <<'JS'
|
|
212
|
+
const d = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
|
|
213
|
+
console.log(` repo=${d.repo}| gitRepo=${d.gitRepo}| writable=` + (d.writable || []).join(', '));
|
|
214
|
+
JS
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
wt_path() {
|
|
218
|
+
local repo="$1" feature="$2" form="$3"
|
|
219
|
+
local parent; parent="$(cd "$(dirname "$repo")" && pwd)"
|
|
220
|
+
local base; base="$(basename "$repo")"
|
|
221
|
+
if [ "$form" = hidden ]; then printf '%s/.worktrees/%s' "$repo" "$feature"
|
|
222
|
+
else printf '%s/%s-worktree--%s' "$parent" "$base" "$feature"; fi
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# ── create ─────────────────────────────────────────────────────────────────────
|
|
226
|
+
cmd_create() {
|
|
227
|
+
local repo="${1:-}" feature="${2:-}"; shift 2 2>/dev/null || true
|
|
228
|
+
[ -n "$repo" ] && [ -n "$feature" ] || die "用法:create <repo-path> <feature> [--base <ref>] [--dry-run]"
|
|
229
|
+
local base="origin/main" dry=0 install=0
|
|
230
|
+
while [ $# -gt 0 ]; do case "$1" in
|
|
231
|
+
--base) base="$2"; shift 2 ;;
|
|
232
|
+
--dry-run) dry=1; shift ;;
|
|
233
|
+
--install) install=1; shift ;;
|
|
234
|
+
*) shift ;;
|
|
235
|
+
esac; done
|
|
236
|
+
|
|
237
|
+
repo="$(cd "$repo" 2>/dev/null && pwd)" || die "仓库不存在:$repo"
|
|
238
|
+
[ -d "${repo}/.git" ] || die "不是 git 仓库:$repo"
|
|
239
|
+
|
|
240
|
+
local form; form="$(pick_form "$repo")"
|
|
241
|
+
local dest; dest="$(wt_path "$repo" "$feature" "$form")"
|
|
242
|
+
local branch="feat/${feature}"
|
|
243
|
+
|
|
244
|
+
say "仓库 : $repo"
|
|
245
|
+
say "形式 : ${form}($(form_reason "$repo"))"
|
|
246
|
+
say "目标 : $dest"
|
|
247
|
+
say "分支 : ${branch}(基于 ${base})"
|
|
248
|
+
|
|
249
|
+
if [ -e "$dest" ]; then
|
|
250
|
+
warn "目标已存在:$dest"
|
|
251
|
+
warn " → 复用请直接 cd;重建请先 dia wt clean '$repo' '$feature'"
|
|
252
|
+
return 1
|
|
253
|
+
fi
|
|
254
|
+
if [ "$dry" = 1 ]; then say "(dry-run)未创建"; return 0; fi
|
|
255
|
+
|
|
256
|
+
git -C "$repo" fetch origin --prune >/dev/null 2>&1 || warn "fetch 失败(继续,用本地 ${base})"
|
|
257
|
+
if ! git -C "$repo" rev-parse --verify "$base" >/dev/null 2>&1; then
|
|
258
|
+
warn "$base 不存在,回退 origin/master"; base="origin/master"
|
|
259
|
+
fi
|
|
260
|
+
|
|
261
|
+
if [ "$form" = hidden ]; then
|
|
262
|
+
if ! grep -qE '^/?\.worktrees/?$' "${repo}/.gitignore" 2>/dev/null; then
|
|
263
|
+
say " 补 .gitignore:.worktrees/"
|
|
264
|
+
printf '\n# worktree 隐藏目录(dia wt)\n.worktrees/\n' >> "${repo}/.gitignore"
|
|
265
|
+
fi
|
|
266
|
+
mkdir -p "${repo}/.worktrees"
|
|
267
|
+
fi
|
|
268
|
+
|
|
269
|
+
git -C "$repo" worktree add "$dest" -b "$branch" "$base" || die "worktree 创建失败"
|
|
270
|
+
say "✅ 已创建:$dest"
|
|
271
|
+
local gd cd_
|
|
272
|
+
gd="$(git -C "$dest" rev-parse --git-dir 2>/dev/null)"
|
|
273
|
+
cd_="$(git -C "$dest" rev-parse --git-common-dir 2>/dev/null)"
|
|
274
|
+
if [ "$gd" = "$cd_" ]; then
|
|
275
|
+
warn "⚠️ 隔离校验异常:git-dir == common-dir(${gd})"
|
|
276
|
+
else
|
|
277
|
+
say "隔离校验 ✓(git-dir ≠ common-dir)"
|
|
278
|
+
fi
|
|
279
|
+
# 迭代入口自动产出作用域声明(供权限层消费;失败不影响 create)
|
|
280
|
+
write_scope "$dest" "$feature" "${DIA_SESSION:-}" >/dev/null 2>&1 && say "作用域声明已写:$(basename "$(dia_wt_data_root)")/tasks/scope.json"
|
|
281
|
+
|
|
282
|
+
# ── 依赖:真装,**不许软链**(R20;2026-10-15 加机械提示 + 可选 --install)──
|
|
283
|
+
# 为什么在创建时就提示:软链省下的 13~34s,换来的是对方 checkout 被 pnpm 改写
|
|
284
|
+
# (10-10 实证 99/100 条)+ 删 worktree 后对方整片悬空(09-30 实证 198 条)。
|
|
285
|
+
# 把「真装」变成创建流程里的默认下一步,而不是靠记性。
|
|
286
|
+
if [ -f "$dest/package.json" ]; then
|
|
287
|
+
local dep_cmd=""
|
|
288
|
+
if [ -f "$dest/pnpm-lock.yaml" ]; then dep_cmd="pnpm install --frozen-lockfile --prefer-offline"
|
|
289
|
+
elif [ -f "$dest/package-lock.json" ]; then dep_cmd="npm ci"
|
|
290
|
+
else dep_cmd="pnpm install"; fi
|
|
291
|
+
if [ -L "$dest/node_modules" ]; then
|
|
292
|
+
warn "依赖 : ⚠️ node_modules 是软链(异常)→ rm -f '$dest/node_modules' 后真装"
|
|
293
|
+
elif [ -d "$dest/node_modules" ]; then
|
|
294
|
+
say "依赖 : node_modules 已存在(真目录)✓"
|
|
295
|
+
elif [ "$install" = 1 ]; then
|
|
296
|
+
say "真装依赖 : $dep_cmd"
|
|
297
|
+
( cd "$dest" && eval "$dep_cmd" ) && say "✅ 依赖就绪" \
|
|
298
|
+
|| warn "依赖安装失败(重跑:cd '$dest' && ${dep_cmd})"
|
|
299
|
+
else
|
|
300
|
+
say "依赖 : 尚未安装 —— 真装:cd '$dest' && $dep_cmd"
|
|
301
|
+
say " (R20:**禁止**软链其他 checkout 的 node_modules;要提速请用 pnpm 官方"
|
|
302
|
+
say " global virtual store,见 knowledge/workflows/worktree-forms.md §3)"
|
|
303
|
+
fi
|
|
304
|
+
[ -L "$repo/node_modules" ] && \
|
|
305
|
+
warn "主仓 node_modules 是软链 → bash '$SELF' check '$repo' 确认是否需要真装重建"
|
|
306
|
+
fi
|
|
307
|
+
say "下一步:cd '$dest' 开发;收尾 dia wt clean '$repo' '$feature'(R25)"
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
cmd_form() {
|
|
311
|
+
local repo="${1:-}"; [ -n "$repo" ] || die "用法:form <repo-path>"
|
|
312
|
+
repo="$(cd "$repo" 2>/dev/null && pwd)" || die "仓库不存在:$repo"
|
|
313
|
+
echo " 仓库: $repo"
|
|
314
|
+
echo " 形式: $(pick_form "$repo")"
|
|
315
|
+
echo " 理由: $(form_reason "$repo")"
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# ── status:跨 worktree 可见性(T4,INV-8)──────────────────────────────────────
|
|
319
|
+
# 为什么需要(2026-10-13 实测,设计 §1.4):
|
|
320
|
+
# `main..HEAD = 0` 且 `git status` 相对 HEAD 干净 —— **两个常规信号都指向"可安全清理"**,
|
|
321
|
+
# 但 worktree 可能持有**唯一工作副本**(未提交改动)。`git worktree remove` 会连它一起删。
|
|
322
|
+
# ⇒ 需要「这个 worktree 能不能清」的**统一判据**。
|
|
323
|
+
#
|
|
324
|
+
# 判据(设计 D6,任一不满足即不可直接清理):
|
|
325
|
+
# ① 未并入默认分支:`<def>..HEAD` > 0 → 有未合并提交
|
|
326
|
+
# ② 未推送:`origin/<branch>..HEAD` > 0 → 提交只在本地
|
|
327
|
+
# ③ 未提交改动 ≠ 空 → **唯一工作副本**
|
|
328
|
+
#
|
|
329
|
+
# ⚠️ **本命令只读**:不写、不删、不切分支(R21 精神)。
|
|
330
|
+
status_of() { # 输出该 worktree 的判定行;置全局 ST_UNSAFE=1 表示不可直接清理
|
|
331
|
+
local p="$1" def="$2"
|
|
332
|
+
local br ahead unpushed dirty_files dirty_n uniq="—"
|
|
333
|
+
ST_UNSAFE=0; ST_FILES=""
|
|
334
|
+
br="$(git -C "$p" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')"
|
|
335
|
+
[ "$br" = "HEAD" ] && br="detached" # E15:detached 明确标注
|
|
336
|
+
ahead="$(git -C "$p" rev-list --count "$def..HEAD" 2>/dev/null || echo 0)"
|
|
337
|
+
if [ "$br" = "detached" ]; then unpushed="?"
|
|
338
|
+
else unpushed="$(git -C "$p" rev-list --count "origin/$br..HEAD" 2>/dev/null || echo 0)"; fi
|
|
339
|
+
dirty_files="$(git -C "$p" status --porcelain 2>/dev/null | sed 's/^...//')"
|
|
340
|
+
dirty_n="$(printf '%s' "$dirty_files" | grep -c . 2>/dev/null)"; [ -n "$dirty_n" ] || dirty_n=0
|
|
341
|
+
# 顺序:未提交 > 未推送 > 未并入(按"先抢救什么"的紧迫度)
|
|
342
|
+
if [ "$dirty_n" -gt 0 ]; then uniq="⚠️ 是(未提交)"; ST_UNSAFE=1; ST_FILES="$dirty_files"
|
|
343
|
+
elif [ "$unpushed" != "0" ] && [ "$unpushed" != "?" ]; then uniq="⚠️ 是(未推送)"; ST_UNSAFE=1
|
|
344
|
+
elif [ "$ahead" != "0" ]; then uniq="⚠️ 是(未并入)"; ST_UNSAFE=1
|
|
345
|
+
fi
|
|
346
|
+
printf ' %-50s %-20s %6s %8s %s\n' "${p/#$HOME/~}" "$br" "$ahead" "${dirty_n}文件" "$uniq"
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
cmd_status() {
|
|
350
|
+
local repo="${1:-.}"
|
|
351
|
+
repo="$(cd "$repo" 2>/dev/null && pwd)" || die "仓库不存在:$repo"
|
|
352
|
+
[ -d "${repo}/.git" ] || die "不是 git 仓库:$repo"
|
|
353
|
+
local def; def="$(git -C "$repo" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
|
|
354
|
+
def="${def#origin/}"; [ -n "$def" ] || def="main"
|
|
355
|
+
|
|
356
|
+
echo "══ dia wt status(仓库: $repo · 默认分支: ${def})══"
|
|
357
|
+
printf ' %-50s %-20s %6s %8s %s\n' "WORKTREE" "分支" "未并入" "改动" "唯一工作副本"
|
|
358
|
+
printf ' %s\n' "──────────────────────────────────────────────────────────────────────────────────"
|
|
359
|
+
|
|
360
|
+
local total=0 unsafe=0 dirty_map=""
|
|
361
|
+
while IFS= read -r p; do
|
|
362
|
+
[ -n "$p" ] || continue
|
|
363
|
+
[ -d "$p" ] || continue
|
|
364
|
+
total=$((total+1))
|
|
365
|
+
status_of "$p" "$def"
|
|
366
|
+
if [ "$ST_UNSAFE" = 1 ]; then
|
|
367
|
+
unsafe=$((unsafe+1))
|
|
368
|
+
[ -n "$ST_FILES" ] && printf '%s\n' "$ST_FILES" | sed 's/^/ · /'
|
|
369
|
+
[ -n "$ST_FILES" ] && dirty_map="${dirty_map}${p}"$'\t'"$(printf '%s' "$ST_FILES" | tr '\n' ',')"$'\n'
|
|
370
|
+
fi
|
|
371
|
+
done < <(git -C "$repo" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
|
|
372
|
+
|
|
373
|
+
# 重叠检测(设计 D6):**同一仓内**两个以上 worktree 改同一**仓内相对路径**。
|
|
374
|
+
# 不跨仓比较 —— 不同仓的同名相对路径属巧合,报出来是噪音。
|
|
375
|
+
local overlaps
|
|
376
|
+
overlaps="$(printf '%s' "$dirty_map" | awk -F'\t' '
|
|
377
|
+
NF<2 {next}
|
|
378
|
+
{ n=split($2, a, ","); for (i=1;i<=n;i++) if (a[i]!="") { cnt[a[i]]++; if (who[a[i]]=="") who[a[i]]=1; else who[a[i]]++ } }
|
|
379
|
+
END { for (f in cnt) if (cnt[f]>1) printf " · %s(%d 个 worktree 同时改)\n", f, cnt[f] }
|
|
380
|
+
' | sort)"
|
|
381
|
+
if [ -n "$overlaps" ]; then
|
|
382
|
+
echo " ⚠️ **改动重叠**(同一文件被多个 worktree 同时修改 → 合并必冲突):"
|
|
383
|
+
printf '%s\n' "$overlaps"
|
|
384
|
+
fi
|
|
385
|
+
|
|
386
|
+
echo " ── $total 个 worktree · $unsafe 个**不可直接清理** ──"
|
|
387
|
+
[ "$unsafe" = 0 ] || echo " (抢救:cd <worktree> && git add -A && git commit -m '…',或 push 分支后再 clean)"
|
|
388
|
+
return 0
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
cmd_list() {
|
|
392
|
+
local repo="${1:-.}"
|
|
393
|
+
repo="$(cd "$repo" 2>/dev/null && pwd)" || die "仓库不存在:$repo"
|
|
394
|
+
echo " 仓库: $repo"
|
|
395
|
+
git -C "$repo" worktree list | while read -r p rest; do
|
|
396
|
+
local mark="平级"
|
|
397
|
+
case "$p" in */.worktrees/*) mark="隐藏";; esac
|
|
398
|
+
printf ' [%s] %s %s\n' "$mark" "$p" "$rest"
|
|
399
|
+
done
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
403
|
+
# prune-merged —— 扫出「已并入 main 的本地分支」并(可选)清理(R25 的定期闸口)
|
|
404
|
+
#
|
|
405
|
+
# 为什么需要(2026-10-19,用户拍板):
|
|
406
|
+
# R25 要求「合并后当场清理分支」,但**没有兜底** —— `clean` 只在有人手动调用时执行。
|
|
407
|
+
# 实证:2026-10-19 一次清出 **16 个**已并入 main 的历史分支(最早 09-30,最晚 10-16),
|
|
408
|
+
# 其中 `fix/dws-hub-*` 是并行会话留下的、挂了 3 天没人管。
|
|
409
|
+
# ⇒ 与 `check`(node_modules 软链)同理:**「写进规则」不够,要有定期扫描的机械闸口**。
|
|
410
|
+
#
|
|
411
|
+
# 安全性(这是**破坏性**操作,比 check 严格):
|
|
412
|
+
# · 只删 **已并入 `--base`(默认 origin/main)** 的分支(`git branch -d` 语义,不是 `-D`);
|
|
413
|
+
# · **跳过**:当前分支、被任何 worktree checkout 的分支、`--protect` 名单(默认 main/master/develop/dev);
|
|
414
|
+
# · 默认 **dry-run**(只列不删);真删要显式 `--apply`;
|
|
415
|
+
# · 删前逐个打印「分支名 + 最后提交日期 + 标题」,可回溯。
|
|
416
|
+
#
|
|
417
|
+
# 用法:prune-merged [<repo>] [--all] [--apply] [--base <ref>] [--protect a,b] [--quiet]
|
|
418
|
+
# --all 扫已知仓根(数据根 repos/、$DIA_WT_REPOS_ROOT 默认 ~/repos/leyan)下的主 checkout
|
|
419
|
+
# 退出码:0 = 无待清理 / 已全部清理;1 = 仍有残留(dry-run 发现待清理时也是 1)
|
|
420
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
421
|
+
cmd_prune_merged() {
|
|
422
|
+
local repo="" apply=0 base="origin/main" quiet=0 all=0
|
|
423
|
+
local protect_csv="main,master,develop,dev"
|
|
424
|
+
while [ $# -gt 0 ]; do case "$1" in
|
|
425
|
+
--apply) apply=1; shift ;;
|
|
426
|
+
--all) all=1; shift ;;
|
|
427
|
+
--base) base="$2"; shift 2 ;;
|
|
428
|
+
--protect) protect_csv="$2"; shift 2 ;;
|
|
429
|
+
--quiet) quiet=1; shift ;;
|
|
430
|
+
-*) die "未知参数:$1(支持 --all / --apply / --base <ref> / --protect a,b / --quiet)" ;;
|
|
431
|
+
*) repo="$1"; shift ;;
|
|
432
|
+
esac; done
|
|
433
|
+
|
|
434
|
+
# 仓库集合:--all 复用与 `check --all` 相同的发现逻辑(含跳过 worktree 目录)
|
|
435
|
+
local -a repos=()
|
|
436
|
+
if [ "$all" = 1 ]; then
|
|
437
|
+
local dr rr
|
|
438
|
+
dr="$(dia_wt_data_root 2>/dev/null || true)"
|
|
439
|
+
rr="${DIA_WT_REPOS_ROOT:-$HOME/repos/leyan}"
|
|
440
|
+
local -a roots=()
|
|
441
|
+
[ -n "$dr" ] && [ -d "$dr/repos" ] && roots+=("$dr/repos")
|
|
442
|
+
[ -d "$rr" ] && roots+=("$rr")
|
|
443
|
+
[ ${#roots[@]} -gt 0 ] || die "--all:未找到仓根(数据根 repos/ 与 ${rr})"
|
|
444
|
+
local root sub
|
|
445
|
+
for root in "${roots[@]}"; do
|
|
446
|
+
for sub in "$root"/*; do
|
|
447
|
+
[ -d "$sub" ] || continue
|
|
448
|
+
case "$(basename "$sub")" in *-worktree--*) continue ;; esac
|
|
449
|
+
[ -d "$sub/.git" ] && repos+=("$sub")
|
|
450
|
+
done
|
|
451
|
+
done
|
|
452
|
+
else
|
|
453
|
+
repos=("${repo:-.}")
|
|
454
|
+
fi
|
|
455
|
+
|
|
456
|
+
local total=0 rc_all=0
|
|
457
|
+
local r
|
|
458
|
+
for r in "${repos[@]}"; do
|
|
459
|
+
prune_one "$r" "$apply" "$base" "$protect_csv" "$quiet" "$all" || rc_all=1
|
|
460
|
+
total=$((total + 1))
|
|
461
|
+
done
|
|
462
|
+
return "$rc_all"
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
# 单仓处理(被 cmd_prune_merged 调用;返回 0=干净/已清完,1=仍有残留)
|
|
466
|
+
prune_one() {
|
|
467
|
+
local repo="$1" apply="$2" base="$3" protect_csv="$4" quiet="$5" all="$6"
|
|
468
|
+
repo="$(cd "$repo" 2>/dev/null && pwd)" || { warn "跳过(非目录):$repo"; return 0; }
|
|
469
|
+
|
|
470
|
+
if ! git -C "$repo" rev-parse --verify --quiet "$base" >/dev/null; then
|
|
471
|
+
# ⚠️ 两种语境语义不同:
|
|
472
|
+
# · 单仓(显式指定)→ **报错**(用户以为能扫,实际基线没 fetch ⇒ 静默空转最坏)
|
|
473
|
+
# · --all(批量巡检)→ **跳过**(很多仓没配 origin/main,不能因此中断整轮)
|
|
474
|
+
if [ "$all" = 1 ]; then
|
|
475
|
+
[ "$quiet" = 1 ] || warn "跳过(无基线 ${base},先 git fetch origin):$repo"
|
|
476
|
+
return 0
|
|
477
|
+
fi
|
|
478
|
+
warn "基线不存在:${base}(先 git fetch origin)—— $repo"
|
|
479
|
+
return 1
|
|
480
|
+
fi
|
|
481
|
+
|
|
482
|
+
# 受保护集合:名单 + 当前分支 + 所有 worktree checkout 的分支
|
|
483
|
+
local -A prot=()
|
|
484
|
+
local IFS=','
|
|
485
|
+
for b in $protect_csv; do [ -n "$b" ] && prot["$b"]=1; done
|
|
486
|
+
unset IFS
|
|
487
|
+
local cur; cur="$(git -C "$repo" branch --show-current 2>/dev/null)"
|
|
488
|
+
[ -n "$cur" ] && prot["$cur"]=1
|
|
489
|
+
while IFS= read -r wtpath; do
|
|
490
|
+
local wb; wb="$(git -C "$wtpath" branch --show-current 2>/dev/null)"
|
|
491
|
+
[ -n "$wb" ] && prot["$wb"]=1
|
|
492
|
+
done < <(git -C "$repo" worktree list --porcelain | sed -n 's/^worktree //p')
|
|
493
|
+
|
|
494
|
+
# 候选:已并入 base 的本地分支,去掉受保护项
|
|
495
|
+
local -a todo=()
|
|
496
|
+
while IFS= read -r b; do
|
|
497
|
+
[ -n "$b" ] || continue
|
|
498
|
+
[ -n "${prot[$b]:-}" ] && continue
|
|
499
|
+
todo+=("$b")
|
|
500
|
+
done < <(git -C "$repo" branch --merged "$base" --format='%(refname:short)')
|
|
501
|
+
|
|
502
|
+
if [ "${#todo[@]}" -eq 0 ]; then
|
|
503
|
+
[ "$quiet" = 1 ] || say "✅ 无已合并的残留分支(基线 ${base})"
|
|
504
|
+
return 0
|
|
505
|
+
fi
|
|
506
|
+
|
|
507
|
+
[ "$quiet" = 1 ] || say "仓库:${repo}(基线 ${base})—— 已并入但未清理的分支 ${#todo[@]} 个:"
|
|
508
|
+
local b
|
|
509
|
+
for b in "${todo[@]}"; do
|
|
510
|
+
if [ "$quiet" = 1 ]; then
|
|
511
|
+
printf '%s\n' "$b"
|
|
512
|
+
else
|
|
513
|
+
local when subj
|
|
514
|
+
when="$(git -C "$repo" log -1 --format='%ad' --date=short "$b" 2>/dev/null)"
|
|
515
|
+
subj="$(git -C "$repo" log -1 --format='%s' "$b" 2>/dev/null | cut -c1-60)"
|
|
516
|
+
printf ' · %-46s %s %s\n' "$b" "$when" "$subj"
|
|
517
|
+
fi
|
|
518
|
+
done
|
|
519
|
+
|
|
520
|
+
if [ "$apply" != 1 ]; then
|
|
521
|
+
[ "$quiet" = 1 ] || {
|
|
522
|
+
printf '\n'
|
|
523
|
+
warn "以上为 **dry-run**(未删除)。确认后执行:"
|
|
524
|
+
warn " bash '$SELF' prune-merged '$repo' --apply"
|
|
525
|
+
}
|
|
526
|
+
return 1
|
|
527
|
+
fi
|
|
528
|
+
|
|
529
|
+
local deleted=0 failed=0
|
|
530
|
+
for b in "${todo[@]}"; do
|
|
531
|
+
# ⚠️⚠️ **必须先临时 unset upstream,否则 `-d` 会误拒**(2026-10-19 查清根因):
|
|
532
|
+
# `git branch -d` 的判据是「**已并入 upstream(若配置了)**」,不是「已并入 HEAD/main」。
|
|
533
|
+
# 当分支配置了 `branch.<b>.remote/merge`(`push -u` 常见),而远端那份**落后于本地**
|
|
534
|
+
# (推送后又提交、或 MR 合并后远端未同步)⇒ git 报:
|
|
535
|
+
# 「not deleting branch 'x' that is not yet merged to 'refs/remotes/origin/x',
|
|
536
|
+
# even though it is merged to HEAD」⇒ **拒绝删除**。
|
|
537
|
+
# 实证(example-repo 2026-10-19):`fix/format-superbook-cli` 的 4 个提交**全部在 origin/main 上**,
|
|
538
|
+
# 仍被 `-d` 拒绝 —— 正是这个原因(当时误判为「squash 合并」)。
|
|
539
|
+
# 修法:本函数**已独立判定过「已并入 ${base}」**(候选集就是 `branch --merged $base`),
|
|
540
|
+
# 故可安全地临时清掉 upstream 让 `-d` 退回 HEAD 判据;判完**原样恢复**(不改用户配置)。
|
|
541
|
+
# ⚠️ 不用 `-D`:`-d` 在 upstream 清空后仍会拒绝「未并入 HEAD」的分支 —— 安全网保留。
|
|
542
|
+
local _bk_remote _bk_merge _had_up=0
|
|
543
|
+
_bk_remote="$(git -C "$repo" config --get "branch.$b.remote" 2>/dev/null || true)"
|
|
544
|
+
_bk_merge="$(git -C "$repo" config --get "branch.$b.merge" 2>/dev/null || true)"
|
|
545
|
+
if [ -n "$_bk_remote" ] || [ -n "$_bk_merge" ]; then
|
|
546
|
+
_had_up=1
|
|
547
|
+
git -C "$repo" config --unset "branch.$b.remote" 2>/dev/null || true
|
|
548
|
+
git -C "$repo" config --unset "branch.$b.merge" 2>/dev/null || true
|
|
549
|
+
fi
|
|
550
|
+
|
|
551
|
+
if git -C "$repo" branch -d "$b" >/dev/null 2>&1; then
|
|
552
|
+
deleted=$((deleted + 1))
|
|
553
|
+
[ "$quiet" = 1 ] || say "✅ 已删 $b"
|
|
554
|
+
else
|
|
555
|
+
failed=$((failed + 1))
|
|
556
|
+
# 恢复 upstream 配置(删失败时用户可能还要用这个分支)
|
|
557
|
+
if [ "$_had_up" = 1 ]; then
|
|
558
|
+
[ -n "$_bk_remote" ] && git -C "$repo" config "branch.$b.remote" "$_bk_remote" 2>/dev/null || true
|
|
559
|
+
[ -n "$_bk_merge" ] && git -C "$repo" config "branch.$b.merge" "$_bk_merge" 2>/dev/null || true
|
|
560
|
+
fi
|
|
561
|
+
warn "⚠️ 未能删除 ${b}(已并入 $base 但 git 仍拒绝 ⇒ 可能 worktree 占用或分支正被 checkout)"
|
|
562
|
+
warn " 人工确认后可强删:git -C '$repo' branch -D $b"
|
|
563
|
+
fi
|
|
564
|
+
done
|
|
565
|
+
[ "$quiet" = 1 ] || say "完成:删除 $deleted 个,未删 $failed 个"
|
|
566
|
+
[ "$failed" -eq 0 ] && return 0 || return 1
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
570
|
+
# check —— node_modules 软链体检(R20 的**机械闸口**)
|
|
571
|
+
#
|
|
572
|
+
# 为什么需要(2026-09-30 起四犯,2026-10-15 用户拍板落地):
|
|
573
|
+
# R20 明令「worktree 内一律真装依赖、禁止软链其他 checkout 的 node_modules」,
|
|
574
|
+
# 但违规反复发生(09-30 主仓 198 条悬空 / 10-10 一天两次被改写 99+100 条 /
|
|
575
|
+
# 10-12 侥幸未损坏 / 10-15 在软链 worktree 里跑 `pnpm nx` 触发整仓重装)——
|
|
576
|
+
# **「强化文字」已被证明无效** ⇒ 把判据做成命令(同 `route` / `status` 的思路)。
|
|
577
|
+
#
|
|
578
|
+
# 判据(4 类 + 1 条白名单;**必须与 pnpm 官方共享方案对齐**,否则一启用就全体误报):
|
|
579
|
+
# A 顶层 `node_modules` 本身是软链 —— 跨 checkout 共享的典型形态(最危险:pnpm 会 realpath 改写对方)
|
|
580
|
+
# B 顶层链解析后**逃出本 checkout** —— 指向别的 checkout(被改写后的现场)
|
|
581
|
+
# C 顶层链**悬空**(指向已删路径) —— 对方被清理后的现场
|
|
582
|
+
# D 深层链逃逸(仅 `--deep`) —— `node_modules/**` 内部的链
|
|
583
|
+
# ✅ 白名单:pnpm **global virtual store** 的共享链(`virtualStoreType: global` ⇒ 顶层链指向
|
|
584
|
+
# `<storeDir>/links/@/…`)。⚠️ 官方共享方案与"软链别人的 checkout"**是两回事**:
|
|
585
|
+
# 前者指向共享 store(删 worktree 不连累他人,实测删后对方链仍可解析),后者指向短命 checkout。
|
|
586
|
+
# 判据不区分 → 一旦启用 GVS 全仓误报;判据不查 → 违规静默破坏。
|
|
587
|
+
#
|
|
588
|
+
# 用法:check [<repo>...] [--all] [--deep] [--quiet]
|
|
589
|
+
# --all 扫已知仓根(`$YAN_DATA_ROOT/repos`、`$DIA_WT_REPOS_ROOT`(默认 `~/repos/leyan`))下的主 checkout
|
|
590
|
+
# (跳过 `*-worktree--*`:它们的 worktree 由主仓 `worktree list` 覆盖)+ 仓根杂散 node_modules 检测
|
|
591
|
+
# --deep 递归扫 node_modules 全树(默认只扫顶层;大仓全树需数秒)
|
|
592
|
+
# --quiet 只输出有问题的 checkout
|
|
593
|
+
# 退出码:0 干净;1 有违规;2 用法错误
|
|
594
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
595
|
+
|
|
596
|
+
# ── 合法外部目标前缀(GVS 共享链):<checkout> → 每行一个 ──────────────────────
|
|
597
|
+
nm_allow_prefixes() {
|
|
598
|
+
local p="$1" nm="$p/node_modules" sd="" vsd=""
|
|
599
|
+
if [ -f "$nm/.modules.yaml" ]; then
|
|
600
|
+
local pair; pair="$(node - "$nm/.modules.yaml" <<'JS' 2>/dev/null
|
|
601
|
+
const fs = require('node:fs');
|
|
602
|
+
let txt = '';
|
|
603
|
+
try { txt = fs.readFileSync(process.argv[2], 'utf8'); } catch { console.log('- -'); process.exit(0); }
|
|
604
|
+
const get = (key) => {
|
|
605
|
+
const m = new RegExp('"' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '"\\s*:\\s*"([^"]*)"').exec(txt);
|
|
606
|
+
return m ? m[1] : '';
|
|
607
|
+
};
|
|
608
|
+
console.log((get('storeDir') || '-') + ' ' + (get('virtualStoreDir') || '-'));
|
|
609
|
+
JS
|
|
610
|
+
)"
|
|
611
|
+
read -r sd vsd <<<"$pair"
|
|
612
|
+
fi
|
|
613
|
+
[ "$sd" = "-" ] && sd=""
|
|
614
|
+
[ "$vsd" = "-" ] && vsd=""
|
|
615
|
+
[ -n "$sd" ] || sd="$(pnpm store path 2>/dev/null | head -1 || true)"
|
|
616
|
+
[ -n "$sd" ] && [ -d "$sd/links" ] && printf '%s\n' "$sd/links"
|
|
617
|
+
# virtualStoreDir 若被显式设成绝对路径(GVS 自定义位置)也算合法外部目标
|
|
618
|
+
case "$vsd" in /*) [ -d "$vsd" ] && printf '%s\n' "$vsd" ;; esac
|
|
619
|
+
return 0
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
# ── 解析 python 体检输出 ──────────────────────────────────────────────────────
|
|
623
|
+
chk_cnt() { awk -F'\t' -v k="$2" '$1=="COUNT" && $2==k {print $3}' <<<"$1" | head -1; }
|
|
624
|
+
chk_top() { awk -F'\t' '$1=="TOP" {print $2}' <<<"$1" | head -1; }
|
|
625
|
+
chk_items() { awk -F'\t' -v k="$2" '$1=="ITEM" && $2==k {print $3"\t"$4}' <<<"$1"; }
|
|
626
|
+
|
|
627
|
+
# ── 单个 checkout 扫描:<checkout> <deep> <is-main 0|1> → 置 CHK_VIOL / CHK_BLOCK ──
|
|
628
|
+
scan_checkout() {
|
|
629
|
+
local p="$1" deep="$2" is_main="$3"
|
|
630
|
+
local nm="$p/node_modules"
|
|
631
|
+
local _ln=""
|
|
632
|
+
CHK_VIOL=0; CHK_BLOCK=""
|
|
633
|
+
{ [ -d "$nm" ] || [ -L "$nm" ]; } || return 0
|
|
634
|
+
local allow=() l
|
|
635
|
+
while IFS= read -r l; do [ -n "$l" ] && allow+=("$l"); done < <(nm_allow_prefixes "$p")
|
|
636
|
+
|
|
637
|
+
local raw; raw="$(node - "$p" "$deep" ${allow[@]+"${allow[@]}"} 2>/dev/null <<'JS'
|
|
638
|
+
const fs = require('node:fs');
|
|
639
|
+
const path = require('node:path');
|
|
640
|
+
const [target, deepFlag, ...allowRaw] = process.argv.slice(2);
|
|
641
|
+
const deep = deepFlag === '1';
|
|
642
|
+
const allow = allowRaw.filter(Boolean).map((a) => fs.realpathSync(a));
|
|
643
|
+
const troot = fs.realpathSync(target);
|
|
644
|
+
const nm = path.join(target, 'node_modules');
|
|
645
|
+
const under = (p, root) => p === root || p.startsWith(root + path.sep);
|
|
646
|
+
|
|
647
|
+
function classify(link) {
|
|
648
|
+
let tgt;
|
|
649
|
+
try { tgt = fs.readlinkSync(link); } catch { return null; }
|
|
650
|
+
const abs = path.isAbsolute(tgt) ? tgt : path.normalize(path.join(path.dirname(link), tgt));
|
|
651
|
+
if (!fs.existsSync(abs)) return ['C', abs];
|
|
652
|
+
const rp = fs.realpathSync(abs);
|
|
653
|
+
if (under(rp, troot) || allow.some((a) => under(rp, a))) return null;
|
|
654
|
+
return ['B', rp];
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const res = { A: [], B: [], C: [], D: [] };
|
|
658
|
+
let topLinks = 0, capped = false;
|
|
659
|
+
let st = null;
|
|
660
|
+
try { st = fs.lstatSync(nm); } catch { st = null; }
|
|
661
|
+
if (st && st.isSymbolicLink()) {
|
|
662
|
+
res.A.push(['node_modules', fs.realpathSync(nm)]);
|
|
663
|
+
} else if (st && st.isDirectory()) {
|
|
664
|
+
for (const name of fs.readdirSync(nm).sort()) {
|
|
665
|
+
const p = path.join(nm, name);
|
|
666
|
+
let ls; try { ls = fs.lstatSync(p); } catch { continue; }
|
|
667
|
+
if (ls.isSymbolicLink()) {
|
|
668
|
+
topLinks++;
|
|
669
|
+
const c = classify(p);
|
|
670
|
+
if (c) res[c[0]].push([name, c[1]]);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
if (deep) {
|
|
674
|
+
let seen = 0;
|
|
675
|
+
const walk = (dir) => {
|
|
676
|
+
let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
677
|
+
for (const e of entries) {
|
|
678
|
+
const p = path.join(dir, e.name);
|
|
679
|
+
seen++;
|
|
680
|
+
if (seen > 400000) { capped = true; return; }
|
|
681
|
+
let ls; try { ls = fs.lstatSync(p); } catch { continue; }
|
|
682
|
+
if (ls.isSymbolicLink()) {
|
|
683
|
+
const c = classify(p);
|
|
684
|
+
if (c) res.D.push([path.relative(target, p), c[1]]);
|
|
685
|
+
} else if (ls.isDirectory()) {
|
|
686
|
+
walk(p);
|
|
687
|
+
if (capped) return;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
walk(nm);
|
|
692
|
+
console.log('DEEP\t' + seen);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
console.log('TOP\t' + topLinks);
|
|
696
|
+
for (const k of ['A', 'B', 'C', 'D']) console.log(`COUNT\t${k}\t${res[k].length}`);
|
|
697
|
+
for (const k of ['A', 'B', 'C', 'D']) for (const [name, tgt] of res[k].slice(0, 5)) console.log(`ITEM\t${k}\t${name}\t${tgt}`);
|
|
698
|
+
console.log(capped ? 'CAPPED\t1' : 'CAPPED\t0');
|
|
699
|
+
JS
|
|
700
|
+
)"
|
|
701
|
+
local A B C D tot
|
|
702
|
+
A="$(chk_cnt "$raw" A)"; B="$(chk_cnt "$raw" B)"; C="$(chk_cnt "$raw" C)"; D="$(chk_cnt "$raw" D)"
|
|
703
|
+
[ -n "$A" ] || A=0; [ -n "$B" ] || B=0; [ -n "$C" ] || C=0; [ -n "$D" ] || D=0
|
|
704
|
+
tot=$((A + B + C + D)); CHK_VIOL=$tot
|
|
705
|
+
|
|
706
|
+
local label="$p" mark=""
|
|
707
|
+
[ "$is_main" = 1 ] && { mark=" · 主仓"; }
|
|
708
|
+
if [ "$tot" = 0 ]; then
|
|
709
|
+
# ⚠️ 用 `printf -v` 而不是 `$(printf …)`:命令替换会**吃掉结尾换行** ⇒ 多个 checkout
|
|
710
|
+
# 的输出会挤成一行(2026-10-15 实测)。
|
|
711
|
+
printf -v CHK_BLOCK ' \033[32m✓\033[0m %s%s 0 违规(顶层链 %s 条)\n' "${p/#$HOME/~}" "$mark" "$(chk_top "$raw")"
|
|
712
|
+
return 0
|
|
713
|
+
fi
|
|
714
|
+
|
|
715
|
+
printf -v CHK_BLOCK ' \033[31m✗\033[0m %s%s 违规 %s\n' "${p/#$HOME/~}" "$mark" "$tot"
|
|
716
|
+
if [ "$A" != 0 ]; then
|
|
717
|
+
printf -v _ln ' A 顶层 node_modules 是软链 → %s\n' "$(chk_items "$raw" A | head -1 | cut -f2)"
|
|
718
|
+
CHK_BLOCK+="$_ln"
|
|
719
|
+
CHK_BLOCK+=" (跨 checkout 共享:pnpm 会按 realpath 把对方的链改写指向本 worktree)"$'\n'
|
|
720
|
+
fi
|
|
721
|
+
local k n
|
|
722
|
+
for k in B C D; do
|
|
723
|
+
eval "n=\$$k"
|
|
724
|
+
[ "$n" = 0 ] && continue
|
|
725
|
+
case "$k" in
|
|
726
|
+
B) printf -v _ln ' B 顶层链指向其他 checkout(%s 条,示例):\n' "$n" ;;
|
|
727
|
+
C) printf -v _ln ' C 顶层链悬空(%s 条,示例):\n' "$n" ;;
|
|
728
|
+
D) printf -v _ln ' D 深层链逃逸(%s 条,示例):\n' "$n" ;;
|
|
729
|
+
esac
|
|
730
|
+
CHK_BLOCK+="$_ln"
|
|
731
|
+
while IFS=$'\t' read -r rel tgt; do
|
|
732
|
+
[ -n "$rel" ] || continue
|
|
733
|
+
printf -v _ln ' · %s → %s\n' "$rel" "$tgt"
|
|
734
|
+
CHK_BLOCK+="$_ln"
|
|
735
|
+
done < <(chk_items "$raw" "$k" | head -3)
|
|
736
|
+
done
|
|
737
|
+
[ "$(awk -F'\t' '$1=="CAPPED" {print $2}' <<<"$raw")" = 1 ] && \
|
|
738
|
+
CHK_BLOCK+=" (深层扫描在 40 万条目处截断)"$'\n'
|
|
739
|
+
return 0
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
cmd_check() {
|
|
743
|
+
local repos=() all=0 deep=0 quiet=0 roots=()
|
|
744
|
+
while [ $# -gt 0 ]; do case "$1" in
|
|
745
|
+
--all) all=1; shift ;;
|
|
746
|
+
--deep) deep=1; shift ;;
|
|
747
|
+
--quiet|-q) quiet=1; shift ;;
|
|
748
|
+
-h|--help)
|
|
749
|
+
printf '用法:check [<repo>...] [--all] [--deep] [--quiet]\n'
|
|
750
|
+
return 0 ;;
|
|
751
|
+
-*) die "未知参数:$1(用法:check [<repo>...] [--all] [--deep] [--quiet])" ;;
|
|
752
|
+
*) repos+=("$1"); shift ;;
|
|
753
|
+
esac; done
|
|
754
|
+
|
|
755
|
+
if [ "$all" = 1 ]; then
|
|
756
|
+
local dr rr
|
|
757
|
+
dr="$(dia_wt_data_root 2>/dev/null || true)"
|
|
758
|
+
rr="${DIA_WT_REPOS_ROOT:-$HOME/repos/leyan}"
|
|
759
|
+
[ -n "$dr" ] && [ -d "$dr/repos" ] && roots+=("$dr/repos")
|
|
760
|
+
[ -d "$rr" ] && roots+=("$rr")
|
|
761
|
+
[ ${#roots[@]} -gt 0 ] || die "--all:未找到仓根(数据根 repos/ 与 ${rr})"
|
|
762
|
+
local root sub
|
|
763
|
+
for root in "${roots[@]}"; do
|
|
764
|
+
for sub in "$root"/*; do
|
|
765
|
+
[ -d "$sub" ] || continue
|
|
766
|
+
case "$(basename "$sub")" in *-worktree--*) continue ;; esac # worktree 由其主仓覆盖
|
|
767
|
+
repos+=("$sub")
|
|
768
|
+
done
|
|
769
|
+
done
|
|
770
|
+
fi
|
|
771
|
+
[ ${#repos[@]} -gt 0 ] || repos=("$PWD")
|
|
772
|
+
|
|
773
|
+
printf '══ dia wt check(node_modules 软链体检 · R20)══\n'
|
|
774
|
+
[ "$deep" = 1 ] && printf ' 模式:--deep(递归扫 node_modules 全树)\n'
|
|
775
|
+
printf ' 判据:A 顶层 node_modules 是软链 · B 顶层链逃出 checkout · C 悬空链 · D 深层链逃逸;\n'
|
|
776
|
+
printf ' 白名单:pnpm global virtual store 的 <storeDir>/links(官方共享方案,不算违规)\n'
|
|
777
|
+
|
|
778
|
+
local R total=0 viol=0 bad=0 strays=0
|
|
779
|
+
for R in "${repos[@]}"; do
|
|
780
|
+
if [ ! -e "$R" ]; then warn "跳过(不存在):$R"; continue; fi
|
|
781
|
+
R="$(cd "$R" 2>/dev/null && pwd)" || { warn "跳过(无法进入):$R"; continue; }
|
|
782
|
+
local checkouts=() p buf="" vsum=0 vcheckouts=0
|
|
783
|
+
if [ -d "$R/.git" ] || [ -f "$R/.git" ]; then
|
|
784
|
+
while IFS= read -r p; do [ -d "$p" ] && checkouts+=("$p"); done \
|
|
785
|
+
< <(git -C "$R" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
|
|
786
|
+
fi
|
|
787
|
+
[ ${#checkouts[@]} -gt 0 ] || checkouts=("$R")
|
|
788
|
+
for p in "${checkouts[@]}"; do
|
|
789
|
+
# ⚠️ 用 `pwd -P` 比较:`git worktree list` 给出的是**登记时**的路径,可能含符号链接
|
|
790
|
+
# (实测 macOS `$TMPDIR` 下登记 `/var/…`,而 `cd` 后是 `/private/var/…`)⇒
|
|
791
|
+
# 直接字符串比较会把主仓误判成普通 worktree(丢「· 主仓」标记)。
|
|
792
|
+
local is_main=0
|
|
793
|
+
[ "$(cd "$p" 2>/dev/null && pwd -P)" = "$(cd "$R" 2>/dev/null && pwd -P)" ] && is_main=1
|
|
794
|
+
scan_checkout "$p" "$deep" "$is_main"
|
|
795
|
+
vsum=$((vsum + CHK_VIOL))
|
|
796
|
+
[ "$CHK_VIOL" != 0 ] && vcheckouts=$((vcheckouts + 1))
|
|
797
|
+
buf+="$CHK_BLOCK"
|
|
798
|
+
done
|
|
799
|
+
total=$((total + ${#checkouts[@]}))
|
|
800
|
+
if [ "$vsum" != 0 ]; then
|
|
801
|
+
viol=$((viol + vsum)); bad=$((bad + vcheckouts))
|
|
802
|
+
printf '\n── %s ──\n%s' "${R/#$HOME/~}" "$buf"
|
|
803
|
+
elif [ "$quiet" != 1 ]; then
|
|
804
|
+
printf '\n── %s ──\n%s' "${R/#$HOME/~}" "$buf"
|
|
805
|
+
fi
|
|
806
|
+
done
|
|
807
|
+
|
|
808
|
+
# ── 仓根杂散 node_modules(--all):非 git 仓的目录里躺着 node_modules → 干扰解析 ──
|
|
809
|
+
local root
|
|
810
|
+
for root in "${roots[@]+"${roots[@]}"}"; do
|
|
811
|
+
[ -e "$root/node_modules" ] || continue
|
|
812
|
+
strays=$((strays + 1)); viol=$((viol + 1)); bad=$((bad + 1))
|
|
813
|
+
printf '\n \033[31m✗\033[0m %s/node_modules 杂散(该目录不是仓库)\n' "${root/#$HOME/~}"
|
|
814
|
+
printf ' → 从该目录运行工具(vite/vitest/nx…)会把缓存/解析落到这里,且不属任何 checkout\n'
|
|
815
|
+
printf ' → 清理:rm -rf %s/node_modules(确认无项目在用;本机实证为跑错 cwd 的残留)\n' "${root/#$HOME/~}"
|
|
816
|
+
done
|
|
817
|
+
|
|
818
|
+
printf '\n── %s 个 checkout · 违规 %s 处 · 涉及 %s 个 checkout%s ──\n' \
|
|
819
|
+
"$total" "$viol" "$bad" "$([ "$strays" != 0 ] && printf '(含杂散 %s)' "$strays")"
|
|
820
|
+
if [ "$viol" = 0 ]; then
|
|
821
|
+
printf ' \033[32m全部干净 ✅\033[0m(A/B/C/D 四类判据均 0)\n'
|
|
822
|
+
return 0
|
|
823
|
+
fi
|
|
824
|
+
cat <<EOF
|
|
825
|
+
修复(对每个违规 checkout 执行;若主仓被改写,就在主仓执行):
|
|
826
|
+
cd <checkout> && rm -f node_modules && pnpm install --frozen-lockfile --prefer-offline
|
|
827
|
+
说明:真装实测 13.8s~57.5s 即可全量重建(0 悬空);成因/现场特征/前三犯实证见
|
|
828
|
+
core/knowledge/rules/worktree.md R20 与 workflows/worktree-forms.md §3
|
|
829
|
+
EOF
|
|
830
|
+
return 1
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
cmd_clean() {
|
|
834
|
+
local repo="${1:-}" feature="${2:-}"
|
|
835
|
+
[ -n "$repo" ] && [ -n "$feature" ] || die "用法:clean <repo-path> <feature>"
|
|
836
|
+
repo="$(cd "$repo" 2>/dev/null && pwd)" || die "仓库不存在:$repo"
|
|
837
|
+
local candidates=(
|
|
838
|
+
"$(wt_path "$repo" "$feature" flat)"
|
|
839
|
+
"$(wt_path "$repo" "$feature" hidden)"
|
|
840
|
+
)
|
|
841
|
+
local target=""
|
|
842
|
+
for c in "${candidates[@]}"; do [ -d "$c" ] && { target="$c"; break; }; done
|
|
843
|
+
[ -n "$target" ] || die "未找到该 feature 的 worktree(试过:${candidates[*]})"
|
|
844
|
+
|
|
845
|
+
# ── R20 守卫:软链 node_modules 的 worktree **不得直接清理** ────────────────
|
|
846
|
+
# 为什么拦:这种 worktree 借用了别的 checkout 的依赖,pnpm 早已可能**把对方的链
|
|
847
|
+
# 改写指向本 worktree**(2026-10-10 实证 99/100 条)——直接 remove 会把唯一的证据
|
|
848
|
+
# (这条软链)连同现场一起删掉,事后无法判断主仓是否已被改写。
|
|
849
|
+
# 所以:先让 check 判定主仓健康度,再手动删链、再清理。
|
|
850
|
+
if [ -L "$target/node_modules" ]; then
|
|
851
|
+
warn "⛔ 拒绝清理:该 worktree 的 node_modules 是软链(违反 R20):"
|
|
852
|
+
warn " $target/node_modules → $(readlink "$target/node_modules")"
|
|
853
|
+
warn " ⚠️ 曾软链共享 ⇒ pnpm 很可能已把**对方 checkout** 的 node_modules 改写/重装"
|
|
854
|
+
warn " → 先体检:bash '$SELF' check '$repo' (0 违规才算安全)"
|
|
855
|
+
warn " → 再清理:rm -f '$target/node_modules' && bash '$SELF' clean '$repo' '$feature'"
|
|
856
|
+
return 1
|
|
857
|
+
fi
|
|
858
|
+
|
|
859
|
+
local dirty; dirty="$(git -C "$target" status --porcelain 2>/dev/null)"
|
|
860
|
+
if [ -n "$dirty" ]; then
|
|
861
|
+
# T4(设计 E11):拒绝行为**原本已有**,真实增量是**列出文件名 + 给抢救命令** ——
|
|
862
|
+
# 原来只说"有未提交改动",用户不知道丢的是什么、也不知道怎么救。
|
|
863
|
+
warn "worktree 有未提交改动,拒绝清理:$target"
|
|
864
|
+
warn " ⚠️ 这些改动是**唯一工作副本**(删掉即永久丢失):"
|
|
865
|
+
printf '%s\n' "$dirty" | sed 's/^/ /' >&2
|
|
866
|
+
warn " → 抢救:cd \"$target\" && git add -A && git commit -m '…'"
|
|
867
|
+
warn " → 或确认弃用后强删:git worktree remove --force \"$target\""
|
|
868
|
+
return 1
|
|
869
|
+
fi
|
|
870
|
+
|
|
871
|
+
# ── 分支名:**反查 worktree 的真实 HEAD**,不靠猜前缀(2026-10-19 修,P0 级静默缺陷)──
|
|
872
|
+
# ⚠️ 原实现写死 `br="feat/${feature}"` —— 而 `create` 接受**任意分支名**
|
|
873
|
+
# (本会话实证:`review/head-gate-and-scope`、`review/cred-from-env`、`fix/wt-clean-branch`)。
|
|
874
|
+
# 名字对不上时 `rev-parse --verify` 失败 ⇒ **整个删分支分支被跳过**,且**不告警**,
|
|
875
|
+
# 最后照样打印「✅ 清理完成」+ 退出码 0 ⇒ **谎报**(R25 要求的「worktree + 分支」只做了一半)。
|
|
876
|
+
# ⇒ 从 worktree 自身取分支名(对称:创建时叫什么,清理时就删什么)。
|
|
877
|
+
local br; br="$(git -C "$target" branch --show-current 2>/dev/null)"
|
|
878
|
+
if [ -z "$br" ]; then
|
|
879
|
+
# detached HEAD(无分支)→ 无可删;但必须**说清楚**,不静默
|
|
880
|
+
warn "worktree 处于 detached HEAD(无关联分支)→ 跳过删分支"
|
|
881
|
+
br=""
|
|
882
|
+
fi
|
|
883
|
+
|
|
884
|
+
say "清理:$target${br:+(分支 ${br})}"
|
|
885
|
+
git -C "$repo" worktree remove "$target" || die "worktree remove 失败"
|
|
886
|
+
|
|
887
|
+
if [ -n "$br" ]; then
|
|
888
|
+
if ! git -C "$repo" rev-parse --verify --quiet "refs/heads/$br" >/dev/null; then
|
|
889
|
+
say "分支 $br 已不存在(可能已删)"
|
|
890
|
+
elif git -C "$repo" branch -d "$br" >/dev/null 2>&1; then
|
|
891
|
+
say "✅ 已删分支 ${br}(已合并)"
|
|
892
|
+
else
|
|
893
|
+
# ⚠️ **未合并 ⇒ 保留分支 + 非 0 退出**(2026-10-19):
|
|
894
|
+
# 以前这里是 `warn` 后照样返回 0 ⇒ 调用方(agent)看到「✅ 清理完成」就以为收工了。
|
|
895
|
+
# R25 的清理是「worktree + 分支」两件,只完成一半**不算成功**。
|
|
896
|
+
warn "⚠️ 分支 $br **未完全合并**,已保留(worktree 已删)"
|
|
897
|
+
warn " 如需强删:git branch -D $br"
|
|
898
|
+
warn " 若要保留(等 MR 合并后自动可删)→ 本次清理**未完成**,退出码 1"
|
|
899
|
+
return 1
|
|
900
|
+
fi
|
|
901
|
+
fi
|
|
902
|
+
|
|
903
|
+
# ── 收尾自检:worktree 必须真的没了(防「说完成但其实还在」)───────────────
|
|
904
|
+
if [ -d "$target" ]; then
|
|
905
|
+
warn "⚠️ worktree 目录仍存在:${target}(清理未生效)"
|
|
906
|
+
return 1
|
|
907
|
+
fi
|
|
908
|
+
|
|
909
|
+
# ── 远端分支提醒(R25 要求「本地 + 远端」都清)──────────────────────────────
|
|
910
|
+
# ⚠️ **不自动删**:`push origin --delete` 是破坏性且影响他人(若该分支已推给同事/正在评审)。
|
|
911
|
+
# 但**必须说出来** —— 否则又是「只做一半 + 说完成」(本函数刚修掉的那类缺陷)。
|
|
912
|
+
if [ -n "$br" ] && git -C "$repo" rev-parse --verify --quiet "refs/remotes/origin/$br" >/dev/null; then
|
|
913
|
+
warn "⚠️ 远端分支仍在:origin/$br"
|
|
914
|
+
warn " 如需清理:git -C '$repo' push origin --delete $br && git -C '$repo' fetch --prune"
|
|
915
|
+
fi
|
|
916
|
+
|
|
917
|
+
say "✅ 清理完成(worktree 已删${br:+,本地分支 $br 已删})"
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
921
|
+
# route —— 【开工前必查】这次改动该走 MR 还是直推 main?(R1.7)
|
|
922
|
+
#
|
|
923
|
+
# 为什么需要(2026-10-12 复发教训):R20 管「别污染 main checkout」(隔离);R1.7 管「要不要评审」——
|
|
924
|
+
# **两件事独立**。agent 做对了 A(隔离),却错误推导出 B(要 MR):在内部仓走 MR 全套打扰同事;
|
|
925
|
+
# 随后写规范优化时**又直接污染 main 主 checkout**。⇒ 做成脚本,把「靠记性」升级为「运行一次」。
|
|
926
|
+
#
|
|
927
|
+
# 为什么改成**配置驱动**(2026-10-16 用户指正):
|
|
928
|
+
# 原先把仓名硬编码在本脚本里(`yan-agent|personal-agent` 内部仓、`example-repo|*-qa|…` 业务仓)⇒
|
|
929
|
+
# ① 「哪些仓是我的/业务仓」是**环境事实**,换个人就全错;② 它含「**我是谁**」成分 ——
|
|
930
|
+
# `fed/yan-agent` 对本人是自持(直推)、对同事是别人的仓(该 MR)⇒ **同一仓在不同人机器上结论不同**;
|
|
931
|
+
# ③ 每加一个仓都要改共享配方库 ⇒ 漂移源。⇒ **规则留在本脚本,数据只落本地**。
|
|
932
|
+
#
|
|
933
|
+
# 判定顺序(**每步都记录来源**、输出可审计;先否决后放行):
|
|
934
|
+
# ① 本地配置 `mr` 名单命中 → 走 MR(显式否决,优先级最高)
|
|
935
|
+
# ② 本地配置 `direct` 名单命中 → 直推
|
|
936
|
+
# ③ remote 命名空间 ∈ `my_namespaces` → 直推("这是我的仓" = 可推导事实,不靠猜仓名)
|
|
937
|
+
# ④ 其余 / 信息不足 → **走 MR**(安全侧兜底)
|
|
938
|
+
#
|
|
939
|
+
# 配置读取(**数据根为准**,可与个人仓基线合并;三个名单键取**并集**:本地只能增、不能删):
|
|
940
|
+
# $YAN_ROUTE_CONFIG 显式指定(最高优先,一般不用)
|
|
941
|
+
# $YAN_DATA_ROOT/route.json 本机主入口 —— **可以是软链**,指向个人仓里的版本化文件
|
|
942
|
+
# $PERSONAL_AGENT_DIR/config/yan-route.json 个人仓基线(跨设备可版本化,默认 ~/repos/leyan/personal-agent)
|
|
943
|
+
# 格式:{ "my_namespaces": ["example-user"], "direct": ["yan-agent"], "mr": [] }
|
|
944
|
+
# ⚠️ `my_namespaces` 缺失时**绝不瞎猜**(不拿 git author 的 email 前缀自动当命名空间):
|
|
945
|
+
# 只在输出里给「推测 + 如何确认」,由人核对(glab auth status)后写进配置。
|
|
946
|
+
#
|
|
947
|
+
# 用法:dia wt route <repo-path>
|
|
948
|
+
# 退出码:0 = 直推;1 = 走 MR(含默认兜底);2 = 信息不足(按 MR 处理,需补配置/remote)
|
|
949
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
950
|
+
|
|
951
|
+
# ── remote 命名空间:`<host>/<ns>/<repo>` 里的 `<ns>`(无 remote → 空)────────
|
|
952
|
+
route_remote_ns() {
|
|
953
|
+
local repo="$1" url=""
|
|
954
|
+
url="$(git -C "$repo" config --get remote.origin.url 2>/dev/null || true)"
|
|
955
|
+
[ -n "$url" ] || return 0
|
|
956
|
+
url="${url#ssh://}"; url="${url#https://}"; url="${url#http://}"; url="${url#git://}"
|
|
957
|
+
url="${url#*@}" # 去 user@
|
|
958
|
+
case "$url" in *:*) url="${url#*:}" ;; esac # scp 形式:git@host:ns/repo.git
|
|
959
|
+
case "$url" in */*/*) url="${url#*/}" ;; esac # https 形式:host/ns/repo.git
|
|
960
|
+
url="${url%.git}"
|
|
961
|
+
printf '%s' "${url%%/*}"
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
# ── 合并配置(个人仓基线 → 数据根主入口;名单并集)→ JSON ─────────────────────
|
|
965
|
+
# 用法:route_config_json <data-root> (data root 由调用方用 `yan_data_root()` 解析后传入,
|
|
966
|
+
# 别在 python 里读 env —— 脚本内部是**算**出来的,环境变量往往不存在 ⇒ 静默读不到配置)
|
|
967
|
+
route_config_json() {
|
|
968
|
+
node - "$1" <<'JS'
|
|
969
|
+
const fs = require('node:fs');
|
|
970
|
+
const os = require('node:os');
|
|
971
|
+
const path = require('node:path');
|
|
972
|
+
const dr = process.argv[2] || '';
|
|
973
|
+
const explicit = process.env.DIA_WT_ROUTE_CONFIG || process.env.YAN_ROUTE_CONFIG;
|
|
974
|
+
const pa = process.env.DIA_WT_PERSONAL_DIR || process.env.PERSONAL_AGENT_DIR || path.join(os.homedir(), 'repos/leyan/personal-agent');
|
|
975
|
+
const cands = explicit ? [explicit] : [path.join(pa, 'config', 'yan-route.json'), path.join(dr, 'route.json')];
|
|
976
|
+
const merged = { my_namespaces: [], direct: [], mr: [] };
|
|
977
|
+
const srcs = [], errs = [];
|
|
978
|
+
for (const p of cands) {
|
|
979
|
+
let raw;
|
|
980
|
+
try { raw = fs.readFileSync(p, 'utf8'); }
|
|
981
|
+
catch (e) { if (e.code !== 'ENOENT') errs.push(`${p}: ${e.message}`); continue; }
|
|
982
|
+
let d;
|
|
983
|
+
try { d = JSON.parse(raw); } catch (e) { errs.push(`${p}: JSON 解析失败(${e.message})`); continue; }
|
|
984
|
+
srcs.push(p);
|
|
985
|
+
if (!d || typeof d !== 'object' || Array.isArray(d)) continue;
|
|
986
|
+
for (const k of Object.keys(merged)) {
|
|
987
|
+
const v = d[k];
|
|
988
|
+
const vals = Array.isArray(v) ? v.map(String) : (typeof v === 'string' && v ? [v] : []);
|
|
989
|
+
for (const x of vals) if (!merged[k].includes(x)) merged[k].push(x);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
console.log(JSON.stringify({ config: merged, sources: srcs, errors: errs }));
|
|
993
|
+
JS
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
cmd_route() {
|
|
997
|
+
local repo="${1:-}"
|
|
998
|
+
[ -n "$repo" ] || die "用法:dia wt route <repo-path>"
|
|
999
|
+
|
|
1000
|
+
# ── 取仓名(关键:worktree 里 show-toplevel 给的是 worktree 路径,不是仓名)──
|
|
1001
|
+
# 用 --git-common-dir 反查主仓 → 那才是仓的身份。
|
|
1002
|
+
local name="" is_git=0
|
|
1003
|
+
if [ -e "$repo" ]; then
|
|
1004
|
+
local common
|
|
1005
|
+
common="$(cd "$repo" 2>/dev/null && git rev-parse --git-common-dir 2>/dev/null)"
|
|
1006
|
+
if [ -n "$common" ]; then
|
|
1007
|
+
is_git=1
|
|
1008
|
+
if [ "${common#/}" = "$common" ]; then
|
|
1009
|
+
common="$(cd "$repo" 2>/dev/null && cd "$(dirname "$common")" 2>/dev/null && pwd)/$(basename "$common")"
|
|
1010
|
+
fi
|
|
1011
|
+
name="$(basename "$(dirname "$common")")"
|
|
1012
|
+
else
|
|
1013
|
+
name="$(basename "$(cd "$repo" 2>/dev/null && pwd)")"
|
|
1014
|
+
fi
|
|
1015
|
+
else
|
|
1016
|
+
name="$(basename "$repo")"
|
|
1017
|
+
fi
|
|
1018
|
+
case "$name" in *-worktree--*) name="${name%%-worktree--*}"; repo="$(dirname "$repo")/$name" ;; esac
|
|
1019
|
+
|
|
1020
|
+
# ── 配置驱动判定(**不再硬编码仓名**;判定来源会被打印出来,可审计)──
|
|
1021
|
+
local cfg ns=""
|
|
1022
|
+
cfg="$(route_config_json "$(dia_wt_data_root 2>/dev/null || true)")"
|
|
1023
|
+
[ "$is_git" = 1 ] && ns="$(route_remote_ns "$repo")"
|
|
1024
|
+
|
|
1025
|
+
# 取三个名单(**必须显式判空**:`grep -qx ""` 会匹配空行 ⇒ 空列表被误判成命中)
|
|
1026
|
+
local _cfg_ns _cfg_direct _cfg_mr
|
|
1027
|
+
_cfg_ns="$(printf '%s' "$cfg" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).config.my_namespaces.join("\n"))}catch{}})' )"
|
|
1028
|
+
_cfg_direct="$(printf '%s' "$cfg" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).config.direct.join("\n"))}catch{}})' )"
|
|
1029
|
+
_cfg_mr="$(printf '%s' "$cfg" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).config.mr.join("\n"))}catch{}})' )"
|
|
1030
|
+
cfg_has() { local list="$1" v="$2"; [ -n "$list" ] && printf '%s\n' "$list" | grep -qx -- "$v"; }
|
|
1031
|
+
|
|
1032
|
+
local verdict="" why=""
|
|
1033
|
+
if cfg_has "$_cfg_mr" "$name"; then
|
|
1034
|
+
verdict=mr; why="配置 mr 名单显式否决"
|
|
1035
|
+
elif cfg_has "$_cfg_direct" "$name"; then
|
|
1036
|
+
verdict=direct; why="配置 direct 名单命中"
|
|
1037
|
+
elif [ -n "$ns" ] && cfg_has "$_cfg_ns" "$ns"; then
|
|
1038
|
+
verdict=direct; why="remote 命名空间「${ns}」∈ my_namespaces(这是我的仓)"
|
|
1039
|
+
elif [ "$is_git" = 0 ]; then
|
|
1040
|
+
verdict=unknown; why="不是 git 仓:无法从命名空间推导"
|
|
1041
|
+
elif [ -z "$ns" ]; then
|
|
1042
|
+
verdict=unknown; why="无 origin remote:无法从命名空间推导"
|
|
1043
|
+
else
|
|
1044
|
+
verdict=mr; why="命名空间「${ns}」不在 my_namespaces(安全侧默认 MR)"
|
|
1045
|
+
fi
|
|
1046
|
+
|
|
1047
|
+
echo "── 仓库路由判定(R1.7 · 配置驱动,不再硬编码仓名)──"
|
|
1048
|
+
echo " 仓库 : $name${ns:+(命名空间 ${ns})}"
|
|
1049
|
+
printf ' 判定来源 : %s\n' "$(printf '%s' "$cfg" | node -e 'let s="";process.stdin.on("data",d=>{s+=d}).on("end",()=>{const d=JSON.parse(s);const srcs=(d.sources&&d.sources.length)?d.sources:["(无配置文件)"];console.log(srcs.join("、")+(d.errors.length?" ⚠️ "+d.errors.join(";"):""))})')"
|
|
1050
|
+
|
|
1051
|
+
case "$verdict" in
|
|
1052
|
+
direct)
|
|
1053
|
+
cat <<EOS
|
|
1054
|
+
结论 : ✅ **直推 main**(不建 MR)|依据:$why
|
|
1055
|
+
正确姿势:
|
|
1056
|
+
git worktree add <平级路径> -b <branch> origin/main
|
|
1057
|
+
# ... 开发 + 自测 ...
|
|
1058
|
+
git push origin HEAD:main # 快进推 main,不建 MR
|
|
1059
|
+
⚠️ 「worktree 隔离」≠「要评审」——两者独立:
|
|
1060
|
+
· worktree = 别污染 main checkout(R20)
|
|
1061
|
+
· 免 MR = 我自持的仓、自己主导,review 为辅(R1.7)
|
|
1062
|
+
⚠️ yan-agent main 拒绝 force push(pre-receive hook)→ 先 rebase 成快进
|
|
1063
|
+
EOS
|
|
1064
|
+
exit 0 ;;
|
|
1065
|
+
mr)
|
|
1066
|
+
cat <<EOS
|
|
1067
|
+
结论 : ✅ **走 MR**(+ LGTM + @bot mergeme)|依据:$why
|
|
1068
|
+
正确姿势:
|
|
1069
|
+
git worktree add <平级路径> -b <分支前缀>/<feat> origin/main
|
|
1070
|
+
# ... 开发 + 自测 + rebase(落后必须为 0)...
|
|
1071
|
+
glab mr create ... && glab mr update <iid> --squash-before-merge
|
|
1072
|
+
# 等 LGTM → @bot mergeme
|
|
1073
|
+
提示 : example-repo 的 docs/** 纯文档改动,用户说「直推」时可直接特权合并(R1.7 常态授权)
|
|
1074
|
+
EOS
|
|
1075
|
+
exit 1 ;;
|
|
1076
|
+
*)
|
|
1077
|
+
cat <<EOS
|
|
1078
|
+
结论 : ⚠️ **信息不足,按 MR 处理**(安全侧)|依据:$why
|
|
1079
|
+
补齐判定(任选):
|
|
1080
|
+
1) 让仓有 origin remote ⇒ 命名空间可推导;
|
|
1081
|
+
2) 写配置(数据根为主入口,可与个人仓基线合并;名单取并集):
|
|
1082
|
+
\$YAN_DATA_ROOT/route.json ← 本机;**可以是软链**(指向个人仓版本化文件)
|
|
1083
|
+
\$PERSONAL_AGENT_DIR/config/yan-route.json ← 个人仓基线(跨设备)
|
|
1084
|
+
{ "my_namespaces": ["<你的 gitlab 账号>"], "direct": ["<我自持的仓名>"], "mr": [] }
|
|
1085
|
+
EOS
|
|
1086
|
+
# 未配置命名空间时给「推测 + 如何确认」,但**绝不自动采用**(不瞎猜"我是谁")
|
|
1087
|
+
if [ -n "$ns" ] && [ -z "$_cfg_ns" ]; then
|
|
1088
|
+
local guess; guess="$(git -C "$repo" config --get user.email 2>/dev/null | sed 's/@.*//' || true)"
|
|
1089
|
+
printf ' 💡 推测(**未采用**):origin 命名空间 =「%s」;%s\n' "$ns" "${guess:+你的 git user.email 前缀 =「${guess}」}"
|
|
1090
|
+
printf ' 两者一致且确属你本人(用 glab auth status 核对)⇒ 写进配置 my_namespaces 后即可自动判定\n'
|
|
1091
|
+
fi
|
|
1092
|
+
exit 2 ;;
|
|
1093
|
+
esac
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
case "${1:-}" in
|
|
1097
|
+
create) shift; cmd_create "$@" ;;
|
|
1098
|
+
route) shift; cmd_route "$@" ;;
|
|
1099
|
+
scope) shift; cmd_scope "$@" ;;
|
|
1100
|
+
form) shift; cmd_form "$@" ;;
|
|
1101
|
+
list) shift; cmd_list "$@" ;;
|
|
1102
|
+
status) shift; cmd_status "$@" ;;
|
|
1103
|
+
check) shift; cmd_check "$@" ;;
|
|
1104
|
+
clean) shift; cmd_clean "$@" ;;
|
|
1105
|
+
prune-merged) shift; cmd_prune_merged "$@" ;;
|
|
1106
|
+
""|help|-h|--help)
|
|
1107
|
+
cat <<'EOF'
|
|
1108
|
+
dia wt —— worktree 目录形式选型 + 创建(唯一实现)
|
|
1109
|
+
|
|
1110
|
+
用法:
|
|
1111
|
+
dia wt create <repo-path> <feature> [--base <ref>] [--dry-run] [--install]
|
|
1112
|
+
dia wt form <repo-path> # 只看选型结论
|
|
1113
|
+
dia wt list [repo-path]
|
|
1114
|
+
dia wt status [repo-path] # ★ 清理前必查:唯一工作副本 + 改动重叠(只读)
|
|
1115
|
+
dia wt check [<repo>...] [--all] [--deep] [--quiet]
|
|
1116
|
+
# ★ 依赖体检(R20):软链/逃逸/悬空;--all 扫全部仓
|
|
1117
|
+
dia wt route <repo-path> # ★ 开工前必查:走 MR 还是直推(R1.7,配置驱动)
|
|
1118
|
+
dia wt scope <repo|worktree> [--feature <slug>] [--session <id>] # ★ 产出作用域声明(设计 §4.5)
|
|
1119
|
+
dia wt scope --show # 看与当前目录匹配的作用域声明
|
|
1120
|
+
dia wt scope --list # 列出全部作用域文件(按 worktree 分文件)
|
|
1121
|
+
dia wt clean <repo-path> <feature> # R25 清理(要求工作区干净 + 非软链 node_modules)
|
|
1122
|
+
# 删 worktree + **其 HEAD 指向的分支**(任意前缀);
|
|
1123
|
+
# 分支未合并/未删净 → 退出码 1(不谎报完成)
|
|
1124
|
+
dia wt prune-merged [<repo>] [--apply] [--base <ref>] [--protect a,b] [--quiet]
|
|
1125
|
+
# ★ R25 定期闸口:扫出「已并入 main 的本地分支」
|
|
1126
|
+
# 默认 dry-run(只列);--apply 才删(用 -d,非 -D)
|
|
1127
|
+
# 跳过当前分支 / 被 worktree 占用 / 保护名单
|
|
1128
|
+
|
|
1129
|
+
选型(默认平级,白名单才隐藏):
|
|
1130
|
+
平级 <parent>/<repo>-worktree--<feature>
|
|
1131
|
+
隐藏 <repo>/.worktrees/<feature> ← 仅当:无 node_modules + 无扫描工具 + gitignore 已备
|
|
1132
|
+
拿不准 → 平级(兜底)
|
|
1133
|
+
|
|
1134
|
+
依赖(R20):
|
|
1135
|
+
worktree 内**一律真装**(create --install 或 cd 后 pnpm install --frozen-lockfile);
|
|
1136
|
+
**禁止**把别的 checkout 的 node_modules 软链过来 —— pnpm 会按 realpath 改写对方,
|
|
1137
|
+
删 worktree 后对方整片悬空。开工前/收工前跑 `check` 判定(0 违规才算干净)。
|
|
1138
|
+
判据 = 禁「顶层 node_modules 是软链 / 跨 checkout 共享」(pnpm isolated / link: / dsh profile /
|
|
1139
|
+
GVS 的 <storeDir>/links 等**内部软链**都是合法的,check 已白名单)。
|
|
1140
|
+
⛔ pnpm 官方共享方案(virtualStoreType: global)**试点结论 = 暂停启用**(2026-10-16,example-repo 实测:
|
|
1141
|
+
安装 2.9s / 1.9M 很香,但打破 TS 类型单例 ⇒ nx typecheck 40 错)→ 详见脚本头部与本脚本 docs 指针。
|
|
1142
|
+
EOF
|
|
1143
|
+
;;
|
|
1144
|
+
# 未知子命令 → **退出码 2**(与文件头第 26 行声明一致;2026-10-13 修:此前误用 die ⇒ 返回 1)。
|
|
1145
|
+
# ⚠️ 与「参数/前置不满足」的 `die`(1) **刻意区分** —— 调用方据码可分辨「我参数写错了」
|
|
1146
|
+
# (1,可重试/改参数) 与「这脚本压根没这个子命令」(2,多为版本不匹配/拼错)。
|
|
1147
|
+
*) printf '\033[1;31m[dia-wt]\033[0m 未知子命令:%s(用 --help)\n' "$1" >&2; exit 2 ;;
|
|
1148
|
+
esac
|