@minonl/dia 0.0.2-beta.0 → 0.0.3

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() {
@@ -440,7 +461,7 @@ cmd_prune_merged() {
440
461
  local -a roots=()
441
462
  [ -n "$dr" ] && [ -d "$dr/repos" ] && roots+=("$dr/repos")
442
463
  [ -d "$rr" ] && roots+=("$rr")
443
- [ ${#roots[@]} -gt 0 ] || die "--all:未找到仓根(数据根 repos/ 与 ${rr})"
464
+ [ ${#roots[@]} -gt 0 ] || { echo "usage: dia wt adopt --all(未找到仓根:${rr})" >&2; exit 2; }
444
465
  local root sub
445
466
  for root in "${roots[@]}"; do
446
467
  for sub in "$root"/*; do
@@ -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
@@ -758,7 +779,7 @@ cmd_check() {
758
779
  rr="${DIA_WT_REPOS_ROOT:-$HOME/repos/leyan}"
759
780
  [ -n "$dr" ] && [ -d "$dr/repos" ] && roots+=("$dr/repos")
760
781
  [ -d "$rr" ] && roots+=("$rr")
761
- [ ${#roots[@]} -gt 0 ] || die "--all:未找到仓根(数据根 repos/ 与 ${rr})"
782
+ [ ${#roots[@]} -gt 0 ] || { echo "usage: dia wt adopt --all(未找到仓根:${rr})" >&2; exit 2; }
762
783
  local root sub
763
784
  for root in "${roots[@]}"; do
764
785
  for sub in "$root"/*; do
@@ -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,385 @@ 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 all=0
1247
+ local -a orig=("$@")
1248
+ while [ $# -gt 0 ]; do case "$1" in
1249
+ --all) all=1; shift ;;
1250
+ --apply) mode="apply"; shift ;;
1251
+ --check) mode="check"; shift ;;
1252
+ --revert) mode="revert"; shift ;;
1253
+ --dry-run) mode="dry"; shift ;;
1254
+ --template) template="$2"; shift 2 ;;
1255
+ --default-branch) def="$2"; shift 2 ;;
1256
+ --json) json=1; shift ;;
1257
+ --force) echo "[dia-wt] --force 已忽略:adopt 幂等、无破坏性默认行为" >&2; shift ;;
1258
+ -h|--help) sed -n '2,6p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
1259
+ -*) die "未知参数:$1" ;;
1260
+ *) repo="$1"; shift ;;
1261
+ esac; done
1262
+ if [ "$all" = 1 ]; then
1263
+ [ -z "$repo" ] || die "--all 不能与 <repo> 同时给(要么单个仓,要么全量)"
1264
+ local -a roots=() repos=()
1265
+ local dr rr
1266
+ dr="$(dia_wt_data_root 2>/dev/null || true)"
1267
+ rr="${DIA_WT_REPOS_ROOT:-$HOME/repos}"
1268
+ [ -n "$dr" ] && [ -d "$dr/repos" ] && roots+=("$dr/repos")
1269
+ [ -d "$rr" ] && roots+=("$rr")
1270
+ [ ${#roots[@]} -gt 0 ] || { echo "usage: dia wt adopt --all(未找到仓根:${rr})" >&2; exit 2; }
1271
+ local root sub gd cd_
1272
+ for root in "${roots[@]}"; do
1273
+ while IFS= read -r -d '' gd; do
1274
+ sub="$(dirname "$gd")"
1275
+ case "$(basename "$sub")" in *-worktree--*) continue ;; esac
1276
+ cd_="$(git -C "$sub" rev-parse --git-common-dir 2>/dev/null || true)"
1277
+ # 统一物理路径(macOS /tmp → /private/tmp),否则等式比较会假失败
1278
+ gd="$(cd "$sub" 2>/dev/null && cd "$gd" 2>/dev/null && pwd -P)"
1279
+ cd_="$(cd "$sub" 2>/dev/null && cd "$cd_" 2>/dev/null && pwd -P)"
1280
+ [ -n "$gd" ] && [ "$gd" = "$cd_" ] || continue # 只处理主 checkout
1281
+ repos+=("$sub")
1282
+ done < <(find "$root" -maxdepth 3 -name .git -type d -print0 2>/dev/null)
1283
+ done
1284
+ [ ${#repos[@]} -gt 0 ] || { echo "usage: dia wt adopt --all(未匹配到任何主 checkout)" >&2; exit 2; }
1285
+ local -a pass=()
1286
+ local a
1287
+ for a in "${orig[@]}"; do [ "$a" = "--all" ] && continue; pass+=("$a"); done
1288
+ local okc=0 badc=0 r
1289
+ for r in "${repos[@]}"; do
1290
+ if cmd_adopt "$r" "${pass[@]}"; then okc=$((okc+1)); else badc=$((badc+1)); echo "FAIL $r" >&2; fi
1291
+ done
1292
+ [ "$json" = 1 ] && python3 -c 'import json,sys;print(json.dumps({"repos":int(sys.argv[1]),"ok":int(sys.argv[2]),"fail":int(sys.argv[3])},separators=(",",":")))' "${#repos[@]}" "$okc" "$badc"
1293
+ [ "$badc" = 0 ] || return 1
1294
+ return 0
1295
+ fi
1296
+ [ -n "$repo" ] || die "用法: dia wt adopt <repo>|--all [--apply|--check|--revert] [--template T] [--default-branch B] [--json]"
1297
+ [ -d "$repo" ] || die "目录不存在:$repo"
1298
+ git -C "$repo" rev-parse --git-dir >/dev/null 2>&1 || die "不是 git 仓库:$repo"
1299
+
1300
+ local common cfgdir cfg hooksdir prev
1301
+ common="$(wt_common_dir "$repo")" || die "无法解析 git-common-dir"
1302
+ cfgdir="$common/dia"; cfg="$cfgdir/config.json"; hooksdir="$cfgdir/hooks"
1303
+ prev="$(git -C "$repo" config --get core.hooksPath 2>/dev/null || true)"
1304
+ [ -n "$template" ] || template='{{parent}}/{{repo}}-worktree--{{feature}}'
1305
+ [ -n "$def" ] || def="$(wt_default_branch "$repo")"
1306
+
1307
+ if [ "$mode" = "revert" ]; then
1308
+ [ -f "$cfg" ] || die "未 adopt(缺 $cfg)"
1309
+ local saved
1310
+ saved="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksPrevious") or "")' "$cfg")"
1311
+ if [ "$prev" != "$hooksdir" ]; then
1312
+ echo "[dia-wt] hooksPath 已被改动($prev),跳过还原并保留 $cfgdir 备份" >&2
1313
+ return 1
1314
+ fi
1315
+ 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
1316
+ rm -rf "$cfgdir"
1317
+ echo "reverted:$cfgdir"
1318
+ return 0
1319
+ fi
1320
+
1321
+ if [ "$mode" = "check" ]; then
1322
+ local bad=0
1323
+ [ -f "$cfg" ] || { echo "FAIL D01 未 adopt(缺 $cfg)"; bad=1; }
1324
+ # 策略 SSOT 是 config.json:默认分支以它为准(不从 remote 重猜,避免与已写入的钩子策略漂移)
1325
+ if [ -f "$cfg" ]; then
1326
+ local cfgDef; cfgDef="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("defaultBranch") or "")' "$cfg" 2>/dev/null || true)"
1327
+ [ -n "$cfgDef" ] && def="$cfgDef"
1328
+ fi
1329
+ case "$prev" in /*) ;; *) echo "FAIL D02 core.hooksPath 不是绝对路径($prev)"; bad=1 ;; esac
1330
+ for h in pre-commit pre-push post-checkout; do
1331
+ [ -x "$hooksdir/$h" ] || { echo "FAIL D02 缺 $h 钩子"; bad=1; }
1332
+ done
1333
+ if [ -n "$def" ] && [ -x "$hooksdir/pre-commit" ]; then
1334
+ grep -q "prot=\" main master develop dev $def \"" "$hooksdir/pre-commit" \
1335
+ || { echo "FAIL D03 钩子未保护默认分支 $def"; bad=1; }
1336
+ if wt_is_main_checkout "$repo" && [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ]; then
1337
+ ( cd "$repo" && DIA_WT_GUARD_PROBE=1 "$hooksdir/pre-commit" >/dev/null 2>&1 ) \
1338
+ && { echo "FAIL D03 守卫探针未拦截主 checkout 的 $def 提交"; bad=1; }
1339
+ fi
1340
+ fi
1341
+ if [ "$json" = 1 ]; then
1342
+ 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"
1343
+ fi
1344
+ return $bad
1345
+ fi
1346
+
1347
+ local nwt
1348
+ nwt="$(git -C "$repo" worktree list --porcelain 2>/dev/null | grep -c '^worktree ' || true)"
1349
+ if [ "$mode" = "dry" ]; then
1350
+ echo "adopt plan:$repo"
1351
+ echo " common=${common}"
1352
+ echo " defaultBranch=${def:-(无法解析,需 --default-branch)}"
1353
+ echo " template=${template}"
1354
+ echo " worktrees=${nwt}"
1355
+ echo " hooksPath=${prev:-(未设置)} → ${hooksdir}"
1356
+ if [ "$json" = 1 ]; then
1357
+ 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"
1358
+ fi
1359
+ return 0
1360
+ fi
1361
+
1362
+ [ -n "$def" ] || die "默认分支不可解析;请 --default-branch main"
1363
+ mkdir -p "$hooksdir"
1364
+ # 保留首次 adopt 记下的 hooksPrevious(重复 apply 时 prev 已是我们自己的 hooksdir)
1365
+ local savedPrev=""
1366
+ [ -f "$cfg" ] && savedPrev="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksPrevious") or "")' "$cfg" 2>/dev/null || true)"
1367
+ [ "$prev" = "$hooksdir" ] && prev="$savedPrev"
1368
+ # 钩子链目标:首次 adopt 前的 hooksPath(husky/.githooks/…);没有则用仓库默认 hooks 目录
1369
+ local chain="$prev"
1370
+ [ -n "$chain" ] || chain="$common/hooks"
1371
+ [ -f "$cfg" ] && cp "$cfg" "$cfgdir/config.json.bak.$(date +%s)"
1372
+ python3 - "$cfg" "$repo" "$def" "$template" "$hooksdir" "$prev" "$chain" <<'PY'
1373
+ import json, sys, datetime
1374
+ cfg, repo, br, tmpl, hooks, prev, chain = sys.argv[1:8]
1375
+ data = {
1376
+ "schema": 1, "repo": repo, "defaultBranch": br, "template": tmpl,
1377
+ "hooksPath": hooks, "hooksPrevious": prev or None, "hooksChain": chain,
1378
+ "adoptedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
1379
+ }
1380
+ json.dump(data, open(cfg, "w"), ensure_ascii=False, indent=1)
1381
+ PY
1382
+ # 提示:simple-git-hooks / husky 的 postinstall 会重写 core.hooksPath 下的钩子,
1383
+ # 每次 `pnpm/npm install` 后可能把守卫冲掉 → 用 `dia wt doctor --fix` 或重跑 adopt --apply 修复。
1384
+ if grep -qs '"simple-git-hooks"\|"husky"\|lefthook' "$repo/package.json" "$repo/.lefthook.yml" 2>/dev/null; then
1385
+ echo "[dia-wt] 注意:该仓使用 hook manager(simple-git-hooks/husky/lefthook),install 后若守卫被重写,跑 dia wt doctor --fix" >&2
1386
+ fi
1387
+ wt_write_hook "$hooksdir" pre-commit "$def" "$chain"
1388
+ wt_write_hook "$hooksdir" pre-push "$def" "$chain"
1389
+ wt_write_hook "$hooksdir" post-checkout "$def" "$chain"
1390
+ git -C "$repo" config --local core.hooksPath "$hooksdir"
1391
+ echo "adopted:$repo(hooksPath=$hooksdir)"
1392
+ }
1393
+
1394
+ cmd_doctor() {
1395
+ local json=0 all=0 fix=0 repos=()
1396
+ while [ $# -gt 0 ]; do case "$1" in
1397
+ --json) json=1; shift ;;
1398
+ --all) all=1; shift ;;
1399
+ --fix) fix=1; shift ;;
1400
+ --strict) shift ;;
1401
+ -*) die "未知参数:$1" ;;
1402
+ *) repos+=("$1"); shift ;;
1403
+ esac; done
1404
+ if [ "$all" = 1 ] && [ ${#repos[@]} -eq 0 ]; then
1405
+ while IFS= read -r -d '' d; do repos+=("$(dirname "$d")"); done < <(find "${HOME}/repos" -maxdepth 3 -name .git -type d -print0 2>/dev/null)
1406
+ fi
1407
+ if [ ${#repos[@]} -eq 0 ]; then
1408
+ echo "usage: dia wt doctor <repo>... | --all(--all 未匹配到任何仓库)" >&2
1409
+ exit 2
1410
+ fi
1411
+ local fails=0
1412
+ for repo in "${repos[@]:-}"; do
1413
+ [ -n "$repo" ] || continue
1414
+ git -C "$repo" rev-parse --git-dir >/dev/null 2>&1 || continue
1415
+ local common cfgdir cfg hooksdir prev def
1416
+ common="$(wt_common_dir "$repo")"; cfgdir="$common/dia"; cfg="$cfgdir/config.json"; hooksdir="$cfgdir/hooks"
1417
+ prev="$(git -C "$repo" config --get core.hooksPath 2>/dev/null || true)"
1418
+ def=""
1419
+ if [ -f "$cfg" ]; then
1420
+ def="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("defaultBranch") or "")' "$cfg" 2>/dev/null || true)"
1421
+ fi
1422
+ [ -n "$def" ] || def="$(wt_default_branch "$repo")"
1423
+ # --fix:无损修复(重写三个守卫钩子 + hooksPath 绝对路径)。只对已 adopt 的仓生效。
1424
+ if [ "$fix" = 1 ] && [ -f "$cfg" ]; then
1425
+ local fixChain=""
1426
+ fixChain="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("hooksChain") or "")' "$cfg" 2>/dev/null || true)"
1427
+ [ -n "$fixChain" ] || fixChain="$common/hooks"
1428
+ mkdir -p "$hooksdir"
1429
+ wt_write_hook "$hooksdir" pre-commit "$def" "$fixChain"
1430
+ wt_write_hook "$hooksdir" pre-push "$def" "$fixChain"
1431
+ wt_write_hook "$hooksdir" post-checkout "$def" "$fixChain"
1432
+ [ "$prev" = "$hooksdir" ] || git -C "$repo" config --local core.hooksPath "$hooksdir"
1433
+ prev="$hooksdir"
1434
+ [ "$json" = 0 ] && echo "FIX $repo 钩子已重装(chain=$fixChain)"
1435
+ fi
1436
+ local bad=0
1437
+ [ -f "$cfg" ] || { echo "FAIL D01 $repo 未 adopt"; bad=1; }
1438
+ case "$prev" in /*) ;; *) echo "FAIL D02 $repo hooksPath 非绝对($prev)"; bad=1 ;; esac
1439
+ for h in pre-commit pre-push post-checkout; do
1440
+ [ -x "$hooksdir/$h" ] || { echo "FAIL D02 $repo 缺 $h"; bad=1; }
1441
+ done
1442
+ if [ -n "$def" ] && [ -x "$hooksdir/pre-commit" ]; then
1443
+ # 静态:钩子里的 protected 列表必须包含 defaultBranch
1444
+ grep -q "prot=\" main master develop dev $def \"" "$hooksdir/pre-commit" \
1445
+ || { echo "FAIL D03 $repo 钩子未保护默认分支 $def"; bad=1; }
1446
+ # 动态:主 checkout 当前就在 def 上时必须被拦
1447
+ if wt_is_main_checkout "$repo" && [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ]; then
1448
+ ( cd "$repo" && DIA_WT_GUARD_PROBE=1 "$hooksdir/pre-commit" >/dev/null 2>&1 ) \
1449
+ && { echo "FAIL D03 $repo 守卫未拦截 $def 提交"; bad=1; }
1450
+ fi
1451
+ fi
1452
+ if wt_is_main_checkout "$repo"; then
1453
+ [ -z "$(git -C "$repo" status --porcelain 2>/dev/null)" ] || echo "WARN D04 $repo 主 checkout 有未提交改动"
1454
+ [ "$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null)" = "$def" ] || echo "WARN D04 $repo 主 checkout 不在 $def"
1455
+ fi
1456
+ local missing=0
1457
+ while IFS= read -r p; do [ -n "$p" ] && [ ! -d "$p" ] && { echo "FAIL D05 $repo worktree 路径缺失:$p"; missing=1; }; done \
1458
+ < <(git -C "$repo" -c core.quotePath=false worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
1459
+ [ "$missing" = 0 ] || bad=1
1460
+ [ "$(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; }
1461
+ [ -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; }
1462
+ [ -n "$def" ] || { echo "FAIL D11 $repo 默认分支不可解析"; bad=1; }
1463
+ if [ "$json" = 1 ]; then
1464
+ python3 -c 'import json,sys;print(json.dumps({"repo":sys.argv[1],"ok":sys.argv[2]=="0"},ensure_ascii=False))' "$repo" "$bad"
1465
+ fi
1466
+ [ "$bad" = 0 ] && [ "$json" = 0 ] && echo "ok $repo"
1467
+ [ "$bad" = 1 ] && fails=$((fails+1))
1468
+ done
1469
+ [ "$fails" = 0 ]
1470
+ }
1471
+
1472
+ cmd_gate() {
1473
+ local repo="" feature=""
1474
+ while [ $# -gt 0 ]; do case "$1" in
1475
+ -*) shift ;;
1476
+ *) if [ -z "$repo" ]; then repo="$1"; elif [ -z "$feature" ]; then feature="$1"; fi; shift ;;
1477
+ esac; done
1478
+ [ -n "$repo" ] && [ -n "$feature" ] || die "用法:dia wt gate <repo> <feature>"
1479
+ echo "== check(R20)"; cmd_check "$repo" || { echo "gate FAIL: check"; return 1; }
1480
+ echo "== doctor"; cmd_doctor "$repo" || { echo "gate FAIL: doctor"; return 1; }
1481
+ local def; def="$(wt_default_branch "$repo")"
1482
+ local wt=""
1483
+ while IFS= read -r p; do
1484
+ [ -n "$p" ] || continue
1485
+ local br; br="$(git -C "$p" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
1486
+ if [ "$br" = "feat/$feature" ] || [ "$(basename "$p")" = "${repo##*/}-worktree--$feature" ]; then wt="$p"; break; fi
1487
+ done < <(git -C "$repo" -c core.quotePath=false worktree list --porcelain 2>/dev/null | sed -n 's/^worktree //p')
1488
+ [ -n "$wt" ] || { echo "gate FAIL: 找不到 feature=$feature 的 worktree"; return 1; }
1489
+ [ -z "$(git -C "$wt" status --porcelain)" ] || { echo "gate FAIL: worktree 有未提交改动"; return 1; }
1490
+ local ahead; ahead="$(git -C "$wt" rev-list --count "origin/${def}..HEAD" 2>/dev/null || echo 0)"
1491
+ [ "$ahead" -gt 0 ] || { echo "gate FAIL: 相对 origin/${def} 没有提交"; return 1; }
1492
+ echo "gate OK:$feature(ahead origin/${def} = $ahead)"
1493
+ }
1494
+
1495
+
1096
1496
  case "${1:-}" in
1097
1497
  create) shift; cmd_create "$@" ;;
1098
1498
  route) shift; cmd_route "$@" ;;
@@ -1101,6 +1501,9 @@ case "${1:-}" in
1101
1501
  list) shift; cmd_list "$@" ;;
1102
1502
  status) shift; cmd_status "$@" ;;
1103
1503
  check) shift; cmd_check "$@" ;;
1504
+ adopt) shift; cmd_adopt "$@" ;;
1505
+ doctor) shift; cmd_doctor "$@" ;;
1506
+ gate) shift; cmd_gate "$@" ;;
1104
1507
  clean) shift; cmd_clean "$@" ;;
1105
1508
  prune-merged) shift; cmd_prune_merged "$@" ;;
1106
1509
  ""|help|-h|--help)
@@ -1114,6 +1517,12 @@ dia wt —— worktree 目录形式选型 + 创建(唯一实现)
1114
1517
  dia wt status [repo-path] # ★ 清理前必查:唯一工作副本 + 改动重叠(只读)
1115
1518
  dia wt check [<repo>...] [--all] [--deep] [--quiet]
1116
1519
  # ★ 依赖体检(R20):软链/逃逸/悬空;--all 扫全部仓
1520
+ dia wt adopt <repo>|--all [--apply|--check|--revert] [--template T] [--default-branch B] [--json]
1521
+ # 配置路径模板 + 守卫钩子(默认 dry-run;--all 扫仓根)
1522
+ # 守卫含推送前敏感扫描(DIA_WT_GUARD_SCAN=0 可临时关闭)
1523
+ dia wt doctor [<repo>...] [--all] [--json] [--fix]
1524
+ # 巡检:配置/钩子/探针/主 checkout/worktree 卫生;--fix 重装守卫钩子
1525
+ dia wt gate <repo> <feature> # 提交 MR 前:check + doctor + 干净 + ahead>0
1117
1526
  dia wt route <repo-path> # ★ 开工前必查:走 MR 还是直推(R1.7,配置驱动)
1118
1527
  dia wt scope <repo|worktree> [--feature <slug>] [--session <id>] # ★ 产出作用域声明(设计 §4.5)
1119
1528
  dia wt scope --show # 看与当前目录匹配的作用域声明