@minonl/dia 0.0.2-beta.0 → 0.0.2

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.
@@ -118,7 +118,7 @@ dia_wt_data_root() {
118
118
 
119
119
  # ── 授权推导的**输入**:产出 scope.json(设计 §4.5 D0–D6;消费方 = dsh 引擎 / P3 兜底插件)──
120
120
  # 为什么在这里产出:迭代入口天然知道"本次任务仓库是哪个",而权限层不知道(这是越权写的根因之一)。
121
- # ⚠️ 关键判定:**是否 git 仓库** —— 非 git 仓库(如 ~/repos/leyan 根)**不派生** worktree 同级目录,
121
+ # ⚠️ 关键判定:**是否 git 仓库** —— 非 git 仓库(如 ~/repos/example 根)**不派生** worktree 同级目录,
122
122
  # 否则会把兄弟目录(repos/local 等)误算进作用域(用户 2026-10-15 明确)。
123
123
  write_scope() { # write_scope <repo-or-worktree-path> [feature] [session]
124
124
  local target="$1" feature="${2:-}" session="${3:-}"
@@ -214,8 +214,29 @@ console.log(` repo=${d.repo}| gitRepo=${d.gitRepo}| writable=` + (d.writab
214
214
  JS
215
215
  }
216
216
 
217
+ # adopt 写入的路径模板(<git-common-dir>/dia/config.json 的 template);未 adopt → 空
218
+ wt_repo_template() {
219
+ local repo="$1" common cfg
220
+ common="$(wt_common_dir "$repo" 2>/dev/null)" || return 1
221
+ cfg="$common/dia/config.json"
222
+ [ -f "$cfg" ] || return 1
223
+ python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("template") or "")' "$cfg" 2>/dev/null
224
+ }
225
+ wt_expand_template() {
226
+ local tmpl="$1" repo="$2" feature="$3"
227
+ local parent base
228
+ parent="$(cd "$(dirname "$repo")" && pwd)"; base="$(basename "$repo")"
229
+ printf '%s' "$tmpl" | sed -e "s|{{parent}}|$parent|g" -e "s|{{repo}}|$base|g" \
230
+ -e "s|{{wtRoot}}|$repo/.worktrees|g" -e "s|{{feature}}|$feature|g"
231
+ }
217
232
  wt_path() {
218
- local repo="$1" feature="$2" form="$3"
233
+ local repo="$1" feature="$2" form="$3" tmpl dest
234
+ # 模板优先(adopt 后策略即 SSOT);未 adopt 维持凭 form 推导(默认平级,零迁移)
235
+ if tmpl="$(wt_repo_template "$repo")" && [ -n "$tmpl" ]; then
236
+ dest="$(wt_expand_template "$tmpl" "$repo" "$feature")"
237
+ case "$dest" in /*) ;; *) dest="$repo/$dest" ;; esac
238
+ printf '%s' "$dest"; return 0
239
+ fi
219
240
  local parent; parent="$(cd "$(dirname "$repo")" && pwd)"
220
241
  local base; base="$(basename "$repo")"
221
242
  if [ "$form" = hidden ]; then printf '%s/.worktrees/%s' "$repo" "$feature"
@@ -368,7 +389,7 @@ cmd_status() {
368
389
  [ -n "$ST_FILES" ] && printf '%s\n' "$ST_FILES" | sed 's/^/ · /'
369
390
  [ -n "$ST_FILES" ] && dirty_map="${dirty_map}${p}"$'\t'"$(printf '%s' "$ST_FILES" | tr '\n' ',')"$'\n'
370
391
  fi
371
- done < <(git -C "$repo" worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
392
+ done < <(git -C "$repo" -c core.quotePath=false worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
372
393
 
373
394
  # 重叠检测(设计 D6):**同一仓内**两个以上 worktree 改同一**仓内相对路径**。
374
395
  # 不跨仓比较 —— 不同仓的同名相对路径属巧合,报出来是噪音。
@@ -415,7 +436,7 @@ cmd_list() {
415
436
  # · 删前逐个打印「分支名 + 最后提交日期 + 标题」,可回溯。
416
437
  #
417
438
  # 用法:prune-merged [<repo>] [--all] [--apply] [--base <ref>] [--protect a,b] [--quiet]
418
- # --all 扫已知仓根(数据根 repos/、$DIA_WT_REPOS_ROOT 默认 ~/repos/leyan)下的主 checkout
439
+ # --all 扫已知仓根(数据根 repos/、$DIA_WT_REPOS_ROOT 默认 ~/repos/example)下的主 checkout
419
440
  # 退出码:0 = 无待清理 / 已全部清理;1 = 仍有残留(dry-run 发现待清理时也是 1)
420
441
  # ═══════════════════════════════════════════════════════════════════════════
421
442
  cmd_prune_merged() {
@@ -586,7 +607,7 @@ prune_one() {
586
607
  # 判据不区分 → 一旦启用 GVS 全仓误报;判据不查 → 违规静默破坏。
587
608
  #
588
609
  # 用法:check [<repo>...] [--all] [--deep] [--quiet]
589
- # --all 扫已知仓根(`$YAN_DATA_ROOT/repos`、`$DIA_WT_REPOS_ROOT`(默认 `~/repos/leyan`))下的主 checkout
610
+ # --all 扫已知仓根(`$YAN_DATA_ROOT/repos`、`$DIA_WT_REPOS_ROOT`(默认 `~/repos/example`))下的主 checkout
590
611
  # (跳过 `*-worktree--*`:它们的 worktree 由主仓 `worktree list` 覆盖)+ 仓根杂散 node_modules 检测
591
612
  # --deep 递归扫 node_modules 全树(默认只扫顶层;大仓全树需数秒)
592
613
  # --quiet 只输出有问题的 checkout
@@ -939,7 +960,7 @@ cmd_clean() {
939
960
  # 配置读取(**数据根为准**,可与个人仓基线合并;三个名单键取**并集**:本地只能增、不能删):
940
961
  # $YAN_ROUTE_CONFIG 显式指定(最高优先,一般不用)
941
962
  # $YAN_DATA_ROOT/route.json 本机主入口 —— **可以是软链**,指向个人仓里的版本化文件
942
- # $PERSONAL_AGENT_DIR/config/yan-route.json 个人仓基线(跨设备可版本化,默认 ~/repos/leyan/personal-agent)
963
+ # $PERSONAL_AGENT_DIR/config/yan-route.json 个人仓基线(跨设备可版本化,默认 ~/repos/example/personal-agent)
943
964
  # 格式:{ "my_namespaces": ["example-user"], "direct": ["yan-agent"], "mr": [] }
944
965
  # ⚠️ `my_namespaces` 缺失时**绝不瞎猜**(不拿 git author 的 email 前缀自动当命名空间):
945
966
  # 只在输出里给「推测 + 如何确认」,由人核对(glab auth status)后写进配置。
@@ -1015,7 +1036,7 @@ cmd_route() {
1015
1036
  else
1016
1037
  name="$(basename "$repo")"
1017
1038
  fi
1018
- case "$name" in *-worktree--*) name="${name%%-worktree--*}"; repo="$(dirname "$repo")/$name" ;; esac
1039
+ case "$name" in *-worktree--*) name="${name%%-worktree--*}"; repo="$(dirname "$repo")/$name" ;; esac
1019
1040
 
1020
1041
  # ── 配置驱动判定(**不再硬编码仓名**;判定来源会被打印出来,可审计)──
1021
1042
  local cfg ns=""
@@ -1093,6 +1114,330 @@ EOS
1093
1114
  esac
1094
1115
  }
1095
1116
 
1117
+ # ── adopt / doctor / gate(2026-11-05)──────────────────────────────────────
1118
+ # 设计:core/docs/compose/spec/dia-wt-adopt-design.md
1119
+ # 纪律靠闸不靠 chmod:主 checkout 上 pre-commit/pre-push 拒绝 protected 分支;
1120
+ # 配置落 <git-common-dir>/dia/config.json;默认模板与现网 -worktree-- 布局一致(零迁移)。
1121
+
1122
+ wt_common_dir() {
1123
+ local repo="$1" d
1124
+ d="$(git -C "$repo" rev-parse --git-common-dir 2>/dev/null)" || return 1
1125
+ case "$d" in /*) printf '%s\n' "$d" ;; *) ( cd "$repo" && cd "$d" 2>/dev/null && pwd -P ) ;; esac
1126
+ }
1127
+ wt_is_main_checkout() {
1128
+ local repo="$1" gd cd
1129
+ gd="$(git -C "$repo" rev-parse --git-dir 2>/dev/null)" || return 2
1130
+ cd="$(wt_common_dir "$repo")" || return 2
1131
+ case "$gd" in /*) ;; *) gd="$(cd "$repo" && cd "$gd" 2>/dev/null && pwd -P)" ;; esac
1132
+ [ "$gd" = "$cd" ]
1133
+ }
1134
+ wt_default_branch() {
1135
+ local repo="$1" b=""
1136
+ b="$(git -C "$repo" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##')"
1137
+ # origin/HEAD 可能是**过期**的(远端分支已删,如 grayscale-open 指向 feat/init)→ 必须验证 ref 还在
1138
+ if [ -n "$b" ] && ! git -C "$repo" show-ref --verify --quiet "refs/remotes/origin/$b"; then b=""; fi
1139
+ [ -n "$b" ] || { git -C "$repo" show-ref --verify --quiet refs/heads/main && b="main"; }
1140
+ [ -n "$b" ] || { git -C "$repo" show-ref --verify --quiet refs/heads/master && b="master"; }
1141
+ [ -n "$b" ] || b="$(git -C "$repo" config --get dia.defaultBranch 2>/dev/null || true)"
1142
+ # 注意:**不回落到当前 HEAD**(在 feature 分支上 adopt 会把 feature 当默认分支)
1143
+ printf '%s' "$b"
1144
+ }
1145
+
1146
+ wt_write_hook() {
1147
+ local hooksdir="$1" name="$2" extra="${3:-}" chain="${4:-}"
1148
+ cat > "$hooksdir/$name" <<'HOOK'
1149
+ #!/usr/bin/env bash
1150
+ # dia-wt guard(managed by `dia wt adopt`;勿手改)
1151
+ set -uo pipefail
1152
+ name="$(basename "$0")"
1153
+ top="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
1154
+ common="$(git rev-parse --git-common-dir 2>/dev/null || true)"
1155
+ case "$common" in /*) ;; *) common="$(cd "$top" && cd "$common" 2>/dev/null && pwd -P)" ;; esac
1156
+ gd="$(git rev-parse --git-dir 2>/dev/null || true)"
1157
+ case "$gd" in /*) ;; *) gd="$(cd "$top" && cd "$gd" 2>/dev/null && pwd -P)" ;; esac
1158
+ is_main=0; [ "$gd" = "$common" ] && is_main=1
1159
+ branch="$(git symbolic-ref --short HEAD 2>/dev/null || true)"
1160
+ prot=" main master develop dev "
1161
+ probe="${DIA_WT_GUARD_PROBE:-0}"
1162
+ wt_guard_scan() {
1163
+ local ls="$1" rs="$2" range="" base="" hits=""
1164
+ [ "${DIA_WT_GUARD_SCAN:-1}" = "1" ] || return 0
1165
+ if [ -n "$rs" ] && [ "$rs" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$rs" 2>/dev/null; then
1166
+ range="$rs..$ls"
1167
+ else
1168
+ base="$(git merge-base "$ls" "origin/HEAD" 2>/dev/null || git merge-base "$ls" main 2>/dev/null || true)"
1169
+ if [ -n "$base" ]; then range="$base..$ls"; else range="$ls^..$ls"; fi
1170
+ fi
1171
+ hits="$(git diff -U0 "$range" -- . 2>/dev/null | grep -E '^\+' | grep -vE '^\+\+\+' \
1172
+ | grep -vE '/(Users|home)/(you|user|example|runner|shared|me)(/|$)' \
1173
+ | grep -nE '(/Users/[A-Za-z0-9._-]+|/home/[A-Za-z0-9._-]+|-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})' | head -5)"
1174
+ if [ -n "$hits" ]; then
1175
+ echo "[dia-wt] 推送包含敏感内容(已阻断):" >&2
1176
+ printf '%s\n' "$hits" >&2
1177
+ echo "[dia-wt] 确认误报可临时关闭:DIA_WT_GUARD_SCAN=0 git push ..." >&2
1178
+ return 1
1179
+ fi
1180
+ }
1181
+
1182
+ case "$name" in
1183
+ pre-commit)
1184
+ if [ "$probe" = 1 ]; then
1185
+ { [ "$is_main" = 1 ] && [[ "$prot" == *" $branch "* ]]; } && exit 1 || exit 0
1186
+ fi
1187
+ if [ "$is_main" = 1 ] && [[ "$prot" == *" $branch "* ]]; then
1188
+ echo "[dia-wt] 主 checkout 禁止在 $branch 直接提交;请先:dia wt create <repo> <feature>" >&2
1189
+ exit 1
1190
+ fi ;;
1191
+ pre-push)
1192
+ if [ "$probe" = 1 ]; then
1193
+ [ "$is_main" = 1 ] && exit 1 || exit 0
1194
+ fi
1195
+ if [ "$is_main" = 1 ]; then
1196
+ echo "[dia-wt] 主 checkout 禁止直接 push;请走 worktree 分支 + MR" >&2
1197
+ exit 1
1198
+ fi
1199
+ while read -r _lr ls rr rs; do
1200
+ case "${rr:-}" in
1201
+ refs/heads/main|refs/heads/master|refs/heads/develop|refs/heads/dev)
1202
+ echo "[dia-wt] 禁止直接 push ${rr#refs/heads/};请走 MR" >&2; exit 1 ;;
1203
+ esac
1204
+ wt_guard_scan "$ls" "$rs" || exit 1
1205
+ done ;;
1206
+ post-checkout)
1207
+ [ "$probe" = 1 ] && exit 0
1208
+ if [ "$is_main" = 1 ] && [[ "$prot" != *" $branch "* ]]; then
1209
+ echo "[dia-wt] 提示:主 checkout 当前在 $branch(建议回到默认分支)" >&2
1210
+ fi ;;
1211
+ esac
1212
+ # 钩子链:原 hooksPath(husky / .githooks / simple-git-hooks)优先,其次仓库默认 hooks 目录
1213
+ chain="${DIA_WT_CHAIN_DIR:-}"
1214
+ case "$chain" in
1215
+ "") ;;
1216
+ /*) ;;
1217
+ *) chain="$top/$chain" ;;
1218
+ esac
1219
+ if [ -n "$chain" ] && [ -x "$chain/$name" ] && [ "$chain/$name" != "$0" ]; then exec "$chain/$name" "$@"; fi
1220
+ orig="$common/hooks/$name"
1221
+ if [ -x "$orig" ] && [ "$orig" != "$0" ]; then exec "$orig" "$@"; fi
1222
+ exit 0
1223
+ HOOK
1224
+ if [ -n "$chain" ]; then
1225
+ python3 - "$hooksdir/$name" "$chain" <<'PYCHAIN'
1226
+ import sys
1227
+ p, chain = sys.argv[1], sys.argv[2]
1228
+ s = open(p).read()
1229
+ s = s.replace('chain="${DIA_WT_CHAIN_DIR:-}"', 'chain="${DIA_WT_CHAIN_DIR:-%s}"' % chain.replace('\\', '\\\\').replace('"', '\\"'))
1230
+ open(p, "w").write(s)
1231
+ PYCHAIN
1232
+ fi
1233
+ if [ -n "$extra" ]; then
1234
+ python3 - "$hooksdir/$name" "$extra" <<'PYGT'
1235
+ import sys, re
1236
+ p, extra = sys.argv[1], sys.argv[2]
1237
+ s = open(p).read()
1238
+ s = re.sub(r'^prot=" main master develop dev "$', f'prot=" main master develop dev {extra} "', s, flags=re.M)
1239
+ open(p, "w").write(s)
1240
+ PYGT
1241
+ fi
1242
+ chmod +x "$hooksdir/$name"
1243
+ }
1244
+
1245
+ cmd_adopt() {
1246
+ local repo="" mode="dry" template="" def="" json=0
1247
+ while [ $# -gt 0 ]; do case "$1" in
1248
+ --apply) mode="apply"; shift ;;
1249
+ --check) mode="check"; shift ;;
1250
+ --revert) mode="revert"; shift ;;
1251
+ --dry-run) mode="dry"; shift ;;
1252
+ --template) template="$2"; shift 2 ;;
1253
+ --default-branch) def="$2"; shift 2 ;;
1254
+ --json) json=1; shift ;;
1255
+ --force) echo "[dia-wt] --force 已忽略:adopt 幂等、无破坏性默认行为" >&2; shift ;;
1256
+ -h|--help) sed -n '2,6p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
1257
+ -*) die "未知参数:$1" ;;
1258
+ *) repo="$1"; shift ;;
1259
+ esac; done
1260
+ [ -n "$repo" ] || die "用法: dia wt adopt <repo> [--apply|--check|--revert] [--template T] [--default-branch B] [--json]"
1261
+ [ -d "$repo" ] || die "目录不存在:$repo"
1262
+ git -C "$repo" rev-parse --git-dir >/dev/null 2>&1 || die "不是 git 仓库:$repo"
1263
+
1264
+ local common cfgdir cfg hooksdir prev
1265
+ common="$(wt_common_dir "$repo")" || die "无法解析 git-common-dir"
1266
+ cfgdir="$common/dia"; cfg="$cfgdir/config.json"; hooksdir="$cfgdir/hooks"
1267
+ prev="$(git -C "$repo" config --get core.hooksPath 2>/dev/null || true)"
1268
+ [ -n "$template" ] || template='{{parent}}/{{repo}}-worktree--{{feature}}'
1269
+ [ -n "$def" ] || def="$(wt_default_branch "$repo")"
1270
+
1271
+ if [ "$mode" = "revert" ]; then
1272
+ [ -f "$cfg" ] || die "未 adopt(缺 $cfg)"
1273
+ local saved
1274
+ saved="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksPrevious") or "")' "$cfg")"
1275
+ if [ "$prev" != "$hooksdir" ]; then
1276
+ echo "[dia-wt] hooksPath 已被改动($prev),跳过还原并保留 $cfgdir 备份" >&2
1277
+ return 1
1278
+ fi
1279
+ if [ -n "$saved" ]; then git -C "$repo" config --local core.hooksPath "$saved"; else git -C "$repo" config --local --unset core.hooksPath 2>/dev/null || true; fi
1280
+ rm -rf "$cfgdir"
1281
+ echo "reverted:$cfgdir"
1282
+ return 0
1283
+ fi
1284
+
1285
+ if [ "$mode" = "check" ]; then
1286
+ local bad=0
1287
+ [ -f "$cfg" ] || { echo "FAIL D01 未 adopt(缺 $cfg)"; bad=1; }
1288
+ # 策略 SSOT 是 config.json:默认分支以它为准(不从 remote 重猜,避免与已写入的钩子策略漂移)
1289
+ if [ -f "$cfg" ]; then
1290
+ local cfgDef; cfgDef="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("defaultBranch") or "")' "$cfg" 2>/dev/null || true)"
1291
+ [ -n "$cfgDef" ] && def="$cfgDef"
1292
+ fi
1293
+ case "$prev" in /*) ;; *) echo "FAIL D02 core.hooksPath 不是绝对路径($prev)"; bad=1 ;; esac
1294
+ for h in pre-commit pre-push post-checkout; do
1295
+ [ -x "$hooksdir/$h" ] || { echo "FAIL D02 缺 $h 钩子"; bad=1; }
1296
+ done
1297
+ if [ -n "$def" ] && [ -x "$hooksdir/pre-commit" ]; then
1298
+ grep -q "prot=\" main master develop dev $def \"" "$hooksdir/pre-commit" \
1299
+ || { echo "FAIL D03 钩子未保护默认分支 $def"; bad=1; }
1300
+ if wt_is_main_checkout "$repo" && [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ]; then
1301
+ ( cd "$repo" && DIA_WT_GUARD_PROBE=1 "$hooksdir/pre-commit" >/dev/null 2>&1 ) \
1302
+ && { echo "FAIL D03 守卫探针未拦截主 checkout 的 $def 提交"; bad=1; }
1303
+ fi
1304
+ fi
1305
+ if [ "$json" = 1 ]; then
1306
+ python3 -c 'import json,sys;print(json.dumps({"repo":sys.argv[1],"ok":sys.argv[2]=="0","defaultBranch":sys.argv[3]},ensure_ascii=False,separators=(",",":")))' "$repo" "$bad" "$def"
1307
+ fi
1308
+ return $bad
1309
+ fi
1310
+
1311
+ local nwt
1312
+ nwt="$(git -C "$repo" worktree list --porcelain 2>/dev/null | grep -c '^worktree ' || true)"
1313
+ if [ "$mode" = "dry" ]; then
1314
+ echo "adopt plan:$repo"
1315
+ echo " common=${common}"
1316
+ echo " defaultBranch=${def:-(无法解析,需 --default-branch)}"
1317
+ echo " template=${template}"
1318
+ echo " worktrees=${nwt}"
1319
+ echo " hooksPath=${prev:-(未设置)} → ${hooksdir}"
1320
+ if [ "$json" = 1 ]; then
1321
+ python3 -c 'import json,sys;print(json.dumps({"repo":sys.argv[1],"common":sys.argv[2],"defaultBranch":sys.argv[3],"template":sys.argv[4],"worktrees":int(sys.argv[5]),"hooksPath":sys.argv[6]},ensure_ascii=False,separators=(",",":")))' "$repo" "$common" "$def" "$template" "$nwt" "$prev"
1322
+ fi
1323
+ return 0
1324
+ fi
1325
+
1326
+ [ -n "$def" ] || die "默认分支不可解析;请 --default-branch main"
1327
+ mkdir -p "$hooksdir"
1328
+ # 保留首次 adopt 记下的 hooksPrevious(重复 apply 时 prev 已是我们自己的 hooksdir)
1329
+ local savedPrev=""
1330
+ [ -f "$cfg" ] && savedPrev="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksPrevious") or "")' "$cfg" 2>/dev/null || true)"
1331
+ [ "$prev" = "$hooksdir" ] && prev="$savedPrev"
1332
+ # 钩子链目标:首次 adopt 前的 hooksPath(husky/.githooks/…);没有则用仓库默认 hooks 目录
1333
+ local chain="$prev"
1334
+ [ -n "$chain" ] || chain="$common/hooks"
1335
+ [ -f "$cfg" ] && cp "$cfg" "$cfgdir/config.json.bak.$(date +%s)"
1336
+ python3 - "$cfg" "$repo" "$def" "$template" "$hooksdir" "$prev" "$chain" <<'PY'
1337
+ import json, sys, datetime
1338
+ cfg, repo, br, tmpl, hooks, prev, chain = sys.argv[1:8]
1339
+ data = {
1340
+ "schema": 1, "repo": repo, "defaultBranch": br, "template": tmpl,
1341
+ "hooksPath": hooks, "hooksPrevious": prev or None, "hooksChain": chain,
1342
+ "adoptedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
1343
+ }
1344
+ json.dump(data, open(cfg, "w"), ensure_ascii=False, indent=1)
1345
+ PY
1346
+ wt_write_hook "$hooksdir" pre-commit "$def" "$chain"
1347
+ wt_write_hook "$hooksdir" pre-push "$def" "$chain"
1348
+ wt_write_hook "$hooksdir" post-checkout "$def" "$chain"
1349
+ git -C "$repo" config --local core.hooksPath "$hooksdir"
1350
+ echo "adopted:$repo(hooksPath=$hooksdir)"
1351
+ }
1352
+
1353
+ cmd_doctor() {
1354
+ local json=0 all=0 repos=()
1355
+ while [ $# -gt 0 ]; do case "$1" in
1356
+ --json) json=1; shift ;;
1357
+ --all) all=1; shift ;;
1358
+ --strict|--fix) shift ;;
1359
+ -*) die "未知参数:$1" ;;
1360
+ *) repos+=("$1"); shift ;;
1361
+ esac; done
1362
+ if [ "$all" = 1 ] && [ ${#repos[@]} -eq 0 ]; then
1363
+ while IFS= read -r -d '' d; do repos+=("$(dirname "$d")"); done < <(find "${HOME}/repos" -maxdepth 3 -name .git -type d -print0 2>/dev/null)
1364
+ fi
1365
+ if [ ${#repos[@]} -eq 0 ]; then
1366
+ echo "usage: dia wt doctor <repo>... | --all(--all 未匹配到任何仓库)" >&2
1367
+ exit 2
1368
+ fi
1369
+ local fails=0
1370
+ for repo in "${repos[@]:-}"; do
1371
+ [ -n "$repo" ] || continue
1372
+ git -C "$repo" rev-parse --git-dir >/dev/null 2>&1 || continue
1373
+ local common cfgdir cfg hooksdir prev def
1374
+ common="$(wt_common_dir "$repo")"; cfgdir="$common/dia"; cfg="$cfgdir/config.json"; hooksdir="$cfgdir/hooks"
1375
+ prev="$(git -C "$repo" config --get core.hooksPath 2>/dev/null || true)"
1376
+ def=""
1377
+ if [ -f "$cfg" ]; then
1378
+ def="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("defaultBranch") or "")' "$cfg" 2>/dev/null || true)"
1379
+ fi
1380
+ [ -n "$def" ] || def="$(wt_default_branch "$repo")"
1381
+ local bad=0
1382
+ [ -f "$cfg" ] || { echo "FAIL D01 $repo 未 adopt"; bad=1; }
1383
+ case "$prev" in /*) ;; *) echo "FAIL D02 $repo hooksPath 非绝对($prev)"; bad=1 ;; esac
1384
+ for h in pre-commit pre-push post-checkout; do
1385
+ [ -x "$hooksdir/$h" ] || { echo "FAIL D02 $repo 缺 $h"; bad=1; }
1386
+ done
1387
+ if [ -n "$def" ] && [ -x "$hooksdir/pre-commit" ]; then
1388
+ # 静态:钩子里的 protected 列表必须包含 defaultBranch
1389
+ grep -q "prot=\" main master develop dev $def \"" "$hooksdir/pre-commit" \
1390
+ || { echo "FAIL D03 $repo 钩子未保护默认分支 $def"; bad=1; }
1391
+ # 动态:主 checkout 当前就在 def 上时必须被拦
1392
+ if wt_is_main_checkout "$repo" && [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ]; then
1393
+ ( cd "$repo" && DIA_WT_GUARD_PROBE=1 "$hooksdir/pre-commit" >/dev/null 2>&1 ) \
1394
+ && { echo "FAIL D03 $repo 守卫未拦截 $def 提交"; bad=1; }
1395
+ fi
1396
+ fi
1397
+ if wt_is_main_checkout "$repo"; then
1398
+ [ -z "$(git -C "$repo" status --porcelain 2>/dev/null)" ] || echo "WARN D04 $repo 主 checkout 有未提交改动"
1399
+ [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ] || echo "WARN D04 $repo 主 checkout 不在 $def"
1400
+ fi
1401
+ local missing=0
1402
+ while IFS= read -r p; do [ -n "$p" ] && [ ! -d "$p" ] && { echo "FAIL D05 $repo worktree 路径缺失:$p"; missing=1; }; done \
1403
+ < <(git -C "$repo" -c core.quotePath=false worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
1404
+ [ "$missing" = 0 ] || bad=1
1405
+ [ "$(git -C "$repo" -c core.quotePath=false worktree list --porcelain 2>/dev/null | grep -cE '^(prunable|locked)')" = 0 ] || { echo "FAIL D06 $repo 有 prunable/locked worktree"; bad=1; }
1406
+ [ -z "$(git -C "$repo" -c core.quotePath=false worktree list --porcelain 2>/dev/null | sed -n 's/^branch refs\/heads\///p' | sort | uniq -d)" ] || { echo "FAIL D07 $repo 分支被多个 worktree 占用"; bad=1; }
1407
+ [ -n "$def" ] || { echo "FAIL D11 $repo 默认分支不可解析"; bad=1; }
1408
+ if [ "$json" = 1 ]; then
1409
+ python3 -c 'import json,sys;print(json.dumps({"repo":sys.argv[1],"ok":sys.argv[2]=="0"},ensure_ascii=False))' "$repo" "$bad"
1410
+ fi
1411
+ [ "$bad" = 0 ] && [ "$json" = 0 ] && echo "ok $repo"
1412
+ [ "$bad" = 1 ] && fails=$((fails+1))
1413
+ done
1414
+ [ "$fails" = 0 ]
1415
+ }
1416
+
1417
+ cmd_gate() {
1418
+ local repo="" feature=""
1419
+ while [ $# -gt 0 ]; do case "$1" in
1420
+ -*) shift ;;
1421
+ *) if [ -z "$repo" ]; then repo="$1"; elif [ -z "$feature" ]; then feature="$1"; fi; shift ;;
1422
+ esac; done
1423
+ [ -n "$repo" ] && [ -n "$feature" ] || die "用法:dia wt gate <repo> <feature>"
1424
+ echo "== check(R20)"; cmd_check "$repo" || { echo "gate FAIL: check"; return 1; }
1425
+ echo "== doctor"; cmd_doctor "$repo" || { echo "gate FAIL: doctor"; return 1; }
1426
+ local def; def="$(wt_default_branch "$repo")"
1427
+ local wt=""
1428
+ while IFS= read -r p; do
1429
+ [ -n "$p" ] || continue
1430
+ local br; br="$(git -C "$p" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
1431
+ if [ "$br" = "feat/$feature" ] || [ "$(basename "$p")" = "${repo##*/}-worktree--$feature" ]; then wt="$p"; break; fi
1432
+ done < <(git -C "$repo" -c core.quotePath=false worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
1433
+ [ -n "$wt" ] || { echo "gate FAIL: 找不到 feature=$feature 的 worktree"; return 1; }
1434
+ [ -z "$(git -C "$wt" status --porcelain)" ] || { echo "gate FAIL: worktree 有未提交改动"; return 1; }
1435
+ local ahead; ahead="$(git -C "$wt" rev-list --count "origin/${def}..HEAD" 2>/dev/null || echo 0)"
1436
+ [ "$ahead" -gt 0 ] || { echo "gate FAIL: 相对 origin/${def} 没有提交"; return 1; }
1437
+ echo "gate OK:$feature(ahead origin/${def} = $ahead)"
1438
+ }
1439
+
1440
+
1096
1441
  case "${1:-}" in
1097
1442
  create) shift; cmd_create "$@" ;;
1098
1443
  route) shift; cmd_route "$@" ;;
@@ -1101,6 +1446,9 @@ case "${1:-}" in
1101
1446
  list) shift; cmd_list "$@" ;;
1102
1447
  status) shift; cmd_status "$@" ;;
1103
1448
  check) shift; cmd_check "$@" ;;
1449
+ adopt) shift; cmd_adopt "$@" ;;
1450
+ doctor) shift; cmd_doctor "$@" ;;
1451
+ gate) shift; cmd_gate "$@" ;;
1104
1452
  clean) shift; cmd_clean "$@" ;;
1105
1453
  prune-merged) shift; cmd_prune_merged "$@" ;;
1106
1454
  ""|help|-h|--help)
@@ -1114,6 +1462,11 @@ dia wt —— worktree 目录形式选型 + 创建(唯一实现)
1114
1462
  dia wt status [repo-path] # ★ 清理前必查:唯一工作副本 + 改动重叠(只读)
1115
1463
  dia wt check [<repo>...] [--all] [--deep] [--quiet]
1116
1464
  # ★ 依赖体检(R20):软链/逃逸/悬空;--all 扫全部仓
1465
+ dia wt adopt <repo> [--apply|--check|--revert] [--template T] [--default-branch B] [--json]
1466
+ # 配置路径模板 + 守卫钩子(默认 dry-run)
1467
+ # 守卫含推送前敏感扫描(DIA_WT_GUARD_SCAN=0 可临时关闭)
1468
+ dia wt doctor [<repo>...] [--all] [--json] # 巡检:配置/钩子/探针/主 checkout/worktree 卫生
1469
+ dia wt gate <repo> <feature> # 提交 MR 前:check + doctor + 干净 + ahead>0
1117
1470
  dia wt route <repo-path> # ★ 开工前必查:走 MR 还是直推(R1.7,配置驱动)
1118
1471
  dia wt scope <repo|worktree> [--feature <slug>] [--session <id>] # ★ 产出作用域声明(设计 §4.5)
1119
1472
  dia wt scope --show # 看与当前目录匹配的作用域声明
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env bash
2
+ # adopt / doctor / gate 回归测试(2026-11-05)
3
+ # 覆盖:dry-run 计划、apply 幂等、check 通过、主 checkout 守卫、doctor、revert。
4
+ set -uo pipefail
5
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ WT="$HERE/../bin/dia-wt"
7
+ TD="$(mktemp -d)"; trap 'rm -rf "$TD"' EXIT
8
+ PASS=0; FAIL=0
9
+ ok(){ echo " ✓ $1"; PASS=$((PASS+1)); }; bad(){ echo " ✗ $1"; FAIL=$((FAIL+1)); }
10
+
11
+ R="$TD/repo"; mkdir -p "$R"; git -C "$R" init -q -b main
12
+ git -C "$R" config user.email t@example.com; git -C "$R" config user.name t
13
+ echo a > "$R/a.txt"; git -C "$R" add -A; git -C "$R" commit -qm init --no-verify
14
+
15
+ out="$(bash "$WT" adopt "$R" --json)"
16
+ printf '%s' "$out" | grep -q '"defaultBranch":"main"' && ok "dry-run 识别默认分支" || bad "dry-run: $out"
17
+ [ ! -f "$R/.git/dia/config.json" ] && ok "dry-run 不落盘" || bad "dry-run 落盘了"
18
+
19
+ bash "$WT" adopt "$R" --apply >/dev/null 2>&1 || bad "apply 失败"
20
+ [ -f "$R/.git/dia/config.json" ] && ok "config 已写" || bad "缺 config"
21
+ hp="$(git -C "$R" config --get core.hooksPath)"
22
+ case "$hp" in /*) ok "hooksPath 是绝对路径" ;; *) bad "hooksPath=$hp" ;; esac
23
+ [ -x "$R/.git/dia/hooks/pre-commit" ] && ok "pre-commit 可执行" || bad "缺 pre-commit"
24
+
25
+ md5_before="$(md5 -q "$R/.git/dia/config.json" 2>/dev/null || md5sum "$R/.git/dia/config.json" | cut -d' ' -f1)"
26
+ sleep 1
27
+ bash "$WT" adopt "$R" --apply >/dev/null 2>&1
28
+ md5_after="$(md5 -q "$R/.git/dia/config.json" 2>/dev/null || md5sum "$R/.git/dia/config.json" | cut -d' ' -f1)"
29
+ [ "$md5_before" != "$md5_after" ] && ok "重复 apply 会刷新时间戳(幂等语义:不重复建目录/钩子)" || bad "重复 apply 无变化"
30
+
31
+ bash "$WT" adopt "$R" --check >/dev/null 2>&1 && ok "check 通过" || bad "check 失败"
32
+ bash "$WT" doctor "$R" >/dev/null 2>&1 && ok "doctor 通过" || bad "doctor 失败"
33
+
34
+ echo b > "$R/b.txt"; git -C "$R" add b.txt
35
+ if git -C "$R" commit -qm should-block 2>/dev/null; then bad "主 checkout main 提交未被拦截"; else ok "主 checkout main 提交被拦截"; fi
36
+ git -C "$R" reset -q
37
+
38
+ # ── 推送前敏感扫描(PII/凭据形态)──
39
+ BR="$TD/remote.git"; git init -q --bare "$BR"
40
+ git -C "$R" remote add origin "$BR" 2>/dev/null || true
41
+ git -C "$R" push -q --no-verify -u origin main 2>/dev/null || true
42
+ git -C "$R" worktree add -q "$TD/wt" -b feat/scan 2>/dev/null || true
43
+ echo "/Users/alice/secret" > "$TD/wt/bad.txt"
44
+ git -C "$TD/wt" add -A && git -C "$TD/wt" commit -qm bad --no-verify
45
+ if git -C "$TD/wt" push -q origin feat/scan 2>/dev/null; then bad "敏感推送未被拦截"; else ok "敏感推送被拦截"; fi
46
+ echo ok > "$TD/wt/bad.txt" && git -C "$TD/wt" add -A && git -C "$TD/wt" commit -qm fix --no-verify
47
+ git -C "$TD/wt" push -q origin feat/scan 2>/dev/null && ok "干净推送放行" || bad "干净推送被拒"
48
+
49
+ bash "$WT" adopt "$R" --revert >/dev/null 2>&1 || bad "revert 失败"
50
+ [ -z "$(git -C "$R" config --get core.hooksPath || true)" ] && ok "revert 还原 hooksPath" || bad "revert 未还原"
51
+ [ ! -d "$R/.git/dia" ] && ok "revert 删除 dia 目录" || bad "dia 目录残留"
52
+
53
+ # ── 原 hooksPath 保留 + 二次 apply 不改写 hooksPrevious(review P2-7)──────────
54
+ R2="$TD/repo2"; mkdir -p "$R2" "$TD/orighooks"; git -C "$R2" init -q -b main
55
+ git -C "$R2" config user.email t@example.com; git -C "$R2" config user.name t
56
+ echo a > "$R2/a.txt"; git -C "$R2" add -A; git -C "$R2" commit -qm init --no-verify
57
+ printf '#!/bin/sh\nexit 0\n' > "$TD/orighooks/pre-commit"; chmod +x "$TD/orighooks/pre-commit"
58
+ git -C "$R2" config --local core.hooksPath "$TD/orighooks"
59
+ bash "$WT" adopt "$R2" --apply >/dev/null 2>&1 || bad "R2 apply 失败"
60
+ hp1="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksPrevious") or "")' "$R2/.git/dia/config.json")"
61
+ [ "$hp1" = "$TD/orighooks" ] && ok "首次 apply 记录 hooksPrevious" || bad "hooksPrevious=$hp1"
62
+ bash "$WT" adopt "$R2" --apply >/dev/null 2>&1
63
+ hp2="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksPrevious") or "")' "$R2/.git/dia/config.json")"
64
+ [ "$hp2" = "$TD/orighooks" ] && ok "二次 apply 不覆盖 hooksPrevious" || bad "hooksPrevious 被改成 $hp2"
65
+ bash "$WT" adopt "$R2" --revert >/dev/null 2>&1 || bad "R2 revert 失败"
66
+ [ "$(git -C "$R2" config --get core.hooksPath)" = "$TD/orighooks" ] && ok "revert 还原原 hooksPath" || bad "revert 未还原原 hooksPath"
67
+ # hooksPath 被第三方改动 → revert 拒绝(不抢配置)
68
+ bash "$WT" adopt "$R2" --apply >/dev/null 2>&1
69
+ git -C "$R2" config --local core.hooksPath "$TD/orighooks"
70
+ if bash "$WT" adopt "$R2" --revert >/dev/null 2>&1; then bad "hooksPath 被改动后 revert 仍执行"; else ok "hooksPath 被改动后 revert 拒绝"; fi
71
+ [ -f "$R2/.git/dia/config.json" ] && ok "拒绝 revert 时保留 config 备份" || bad "拒绝 revert 却删了 config"
72
+ git -C "$R2" config --local core.hooksPath "$R2/.git/dia/hooks"; bash "$WT" adopt "$R2" --revert >/dev/null 2>&1
73
+
74
+ # ── 自定义默认分支受保护(review P1-1 + P1-2)────────────────────────────────
75
+ R3="$TD/repo3"; mkdir -p "$R3"; git -C "$R3" init -q -b trunk
76
+ git -C "$R3" config user.email t@example.com; git -C "$R3" config user.name t
77
+ echo a > "$R3/a.txt"; git -C "$R3" add -A; git -C "$R3" commit -qm init --no-verify
78
+ bash "$WT" adopt "$R3" --apply --default-branch trunk >/dev/null 2>&1 || bad "自定义分支 apply 失败"
79
+ bash "$WT" adopt "$R3" --check >/dev/null 2>&1 && ok "自定义默认分支 check 通过" || bad "自定义默认分支 check 失败"
80
+ bash "$WT" doctor "$R3" >/dev/null 2>&1 && ok "自定义默认分支 doctor 通过" || bad "自定义默认分支 doctor 失败"
81
+ echo b > "$R3/b.txt"; git -C "$R3" add b.txt
82
+ if git -C "$R3" commit -qm should-block 2>/dev/null; then bad "trunk 提交未被拦截"; else ok "自定义默认分支 trunk 提交被拦截"; fi
83
+ git -C "$R3" reset -q
84
+
85
+ # ── doctor:未 adopt / 无参数(review P1-4)──────────────────────────────────
86
+ bash "$WT" doctor "$R3" >/dev/null 2>&1 || true
87
+ R4="$TD/repo4"; mkdir -p "$R4"; git -C "$R4" init -q -b main
88
+ git -C "$R4" config user.email t@example.com; git -C "$R4" config user.name t
89
+ echo a > "$R4/a.txt"; git -C "$R4" add -A; git -C "$R4" commit -qm init --no-verify
90
+ if bash "$WT" doctor "$R4" >/dev/null 2>&1; then bad "未 adopt 的仓 doctor 误报通过"; else ok "未 adopt 的仓 doctor 失败"; fi
91
+ bash "$WT" doctor >/dev/null 2>&1; [ $? = 2 ] && ok "doctor 无参数 exit 2" || bad "doctor 无参数未 exit 2"
92
+
93
+ # ── pre-push 守卫(主 checkout 拒绝 / worktree 放行)──────────────────────────
94
+ R5="$TD/repo5"; ORIGIN5="$TD/origin5.git"; git init -q --bare "$ORIGIN5"
95
+ mkdir -p "$R5"; git -C "$R5" init -q -b main
96
+ git -C "$R5" config user.email t@example.com; git -C "$R5" config user.name t
97
+ echo a > "$R5/a.txt"; git -C "$R5" add -A; git -C "$R5" commit -qm init --no-verify
98
+ git -C "$R5" remote add origin "$ORIGIN5"; git -C "$R5" push -q --no-verify origin main
99
+ git -C "$R5" fetch -q origin
100
+ git -C "$R5" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main
101
+ bash "$WT" adopt "$R5" --apply >/dev/null 2>&1 || bad "R5 apply 失败"
102
+ echo x >> "$R5/a.txt"; git -C "$R5" commit -qam dirty-main --no-verify 2>/dev/null
103
+ if git -C "$R5" push origin main >/dev/null 2>&1; then bad "主 checkout push 未被拦截"; else ok "主 checkout push 被拦截"; fi
104
+ git -C "$R5" reset -q --hard origin/main
105
+ git -C "$R5" worktree add -q -b feat/x "$TD/repo5-worktree--x" main
106
+ echo b > "$TD/repo5-worktree--x/b.txt"; git -C "$TD/repo5-worktree--x" add b.txt
107
+ git -C "$TD/repo5-worktree--x" commit -qm feat-x --no-verify 2>/dev/null
108
+ if git -C "$TD/repo5-worktree--x" push -q origin feat/x >/dev/null 2>&1; then ok "worktree push 放行"; else bad "worktree push 被误拦"; fi
109
+
110
+ # ── template 生效(P2-6):adopt 后 create 按 config.template 推导路径 ─────────
111
+ out="$(bash "$WT" create "$R5" t1 --dry-run 2>&1 || true)"
112
+ printf '%s' "$out" | grep -q "repo5-worktree--t1" && ok "默认模板 create 路径" || bad "默认模板 create: $out"
113
+ bash "$WT" adopt "$R5" --apply --template '{{parent}}/{{repo}}.wt.{{feature}}' >/dev/null 2>&1 || bad "template apply 失败"
114
+ out="$(bash "$WT" create "$R5" t1 --dry-run 2>&1 || true)"
115
+ printf '%s' "$out" | grep -q "repo5.wt.t1" && ok "自定义模板 create 路径生效" || bad "自定义模板: $out"
116
+ bash "$WT" adopt "$R5" --apply >/dev/null 2>&1
117
+
118
+ # ── 过期 origin/HEAD(远端分支已删)不误判默认分支 ──────────────────────────
119
+ R6="$TD/repo6"; mkdir -p "$R6"; git -C "$R6" init -q -b main
120
+ git -C "$R6" config user.email t@example.com; git -C "$R6" config user.name t
121
+ echo a > "$R6/a.txt"; git -C "$R6" add -A; git -C "$R6" commit -qm init --no-verify
122
+ git -C "$R6" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/gone
123
+ out="$(bash "$WT" adopt "$R6" --json)"
124
+ printf '%s' "$out" | grep -q '"defaultBranch":"main"' && ok "过期 origin/HEAD 回落到 main" || bad "过期 origin/HEAD: $out"
125
+
126
+ # ── 钩子链:原 hooksPath(.githooks / husky)不被吞掉 ─────────────────────────
127
+ R7="$TD/repo7"; mkdir -p "$R7/.githooks"; git -C "$R7" init -q -b main
128
+ git -C "$R7" config user.email t@example.com; git -C "$R7" config user.name t
129
+ printf '#!/bin/sh\necho chained >> "$(git rev-parse --show-toplevel)/chain.log"\n' > "$R7/.githooks/pre-commit"
130
+ chmod +x "$R7/.githooks/pre-commit"
131
+ echo a > "$R7/a.txt"; git -C "$R7" add -A; git -C "$R7" commit -qm init --no-verify
132
+ git -C "$R7" config --local core.hooksPath .githooks
133
+ bash "$WT" adopt "$R7" --apply >/dev/null 2>&1 || bad "R7 apply 失败"
134
+ python3 -c 'import json,sys;d=json.load(open(sys.argv[1]));sys.exit(0 if d.get("hooksChain")==".githooks" else 1)' "$R7/.git/dia/config.json" \
135
+ && ok "config 记录 hooksChain" || bad "hooksChain 未记录"
136
+ git -C "$R7" worktree add -q -b feat/y "$TD/repo7-worktree--y" main
137
+ echo b > "$TD/repo7-worktree--y/b.txt"; git -C "$TD/repo7-worktree--y" add b.txt
138
+ git -C "$TD/repo7-worktree--y" commit -qm feat-y >/dev/null 2>&1
139
+ [ -f "$TD/repo7-worktree--y/chain.log" ] && ok "原 pre-commit 被链式执行" || bad "原 pre-commit 未执行(钩子被吞)"
140
+ bash "$WT" adopt "$R7" --revert >/dev/null 2>&1
141
+ [ "$(git -C "$R7" config --get core.hooksPath)" = ".githooks" ] && ok "revert 还原原 hooksPath(相对)" || bad "revert 未还原 .githooks"
142
+
143
+ # ── gate:成功 / 失败(P1-3 精确匹配)────────────────────────────────────────
144
+ bash "$WT" gate "$R5" x >/dev/null 2>&1 && ok "gate 成功" || bad "gate 失败"
145
+ if bash "$WT" gate "$R5" xb >/dev/null 2>&1; then bad "gate 子串误匹配(x 命中 xb)"; else ok "gate 精确匹配 feature"; fi
146
+ echo dirty >> "$TD/repo5-worktree--x/b.txt"
147
+ if bash "$WT" gate "$R5" x >/dev/null 2>&1; then bad "worktree 脏时 gate 通过"; else ok "worktree 脏时 gate 失败"; fi
148
+ git -C "$TD/repo5-worktree--x" checkout -q -- b.txt
149
+
150
+ echo; echo "── PASS $PASS · FAIL $FAIL ──"
151
+ [ "$FAIL" = 0 ]