#!/usr/bin/env bash # 任务启动门禁:precheck / start / sync / close / finish(配套 Makefile 同名目标)。 # 规范依据:collaboration.md 共同基线与同步节点、治理清理;delivery.md §1/§5。 set -euo pipefail TARGET_BRANCH="${TASK_TARGET_BRANCH:-main}" die() { echo "ERROR: $*" >&2; exit 1; } need_repo() { git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repository"; } width() { python3 -c 'import sys,unicodedata;print(sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in sys.argv[1]))' "$1"; } cmd_precheck() { need_repo git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true local branch head rmain dirty sub_drift sub_uninit branch=$(git rev-parse --abbrev-ref HEAD) head=$(git rev-parse HEAD) rmain=$(git rev-parse --verify -q "origin/${TARGET_BRANCH}" 2>/dev/null || echo "$head") dirty=$(git status --porcelain | { grep -v '^.. \.zcode/' || true; } | wc -l) sub_drift=$(git submodule status --recursive 2>/dev/null | { grep -cE '^\+|^U' || true; }) sub_uninit=$(git submodule status --recursive 2>/dev/null | { grep -cE '^-' || true; }) local behind ahead behind=$(git rev-list --count "HEAD..origin/${TARGET_BRANCH}" 2>/dev/null || echo 0) ahead=$(git rev-list --count "origin/${TARGET_BRANCH}..HEAD" 2>/dev/null || echo 0) echo "[repo] branch=${branch} head=$(echo "$head" | cut -c1-10)" echo "[remote] origin/${TARGET_BRANCH}=$(echo "$rmain" | cut -c1-10) ahead=${ahead} behind=${behind}" echo "[tree] $([ "$dirty" -eq 0 ] && echo clean || echo "dirty(${dirty})") submodules_drift=${sub_drift} uninit=${sub_uninit}" local wt merged wt=$(git worktree list | tail -n +2 | wc -l) merged=$(git branch --merged "origin/${TARGET_BRANCH}" 2>/dev/null | { grep -vE 'main|master|\*' || true; } | tr -d ' +') [ "${sub_uninit:-0}" -gt 0 ] && echo "[warn] ${sub_uninit} 个子模块未初始化——构建/测试前需物化(不阻断写入)" echo "[worktrees] linked=${wt}" [ -n "$merged" ] && echo "[recycle] merged un-recycled branches: $(echo $merged)" local open_cards="" pc pb pst for pc in .task/*.md; do [ -f "$pc" ] || continue pb=$(card_of_branch "$pc"); [ -n "$pb" ] || continue pst=$(card_status "$pc"); case "$pst" in 已完成*) continue ;; esac card_pending "${pc##*/}" && continue if git log --merges --format='%s' "origin/${TARGET_BRANCH}" 2>/dev/null | grep -qF " from $pb into ${TARGET_BRANCH}"; then open_cards="${open_cards} ${pc##*/}" fi done [ -n "$open_cards" ] && echo "[cards] 已合入未闭合任务卡:$(echo $open_cards)——make task-close 回填归档(主检出运行)" echo "[required-reading] core/baseline.md, workflows/delivery.md §1$([ "$dirty" -gt 0 ] && echo ", standards/collaboration.md(树脏=他人或未收尾现场,不得写入)")" echo "[R0] $(echo "$rmain" | cut -c1-10)(完整:$rmain)" if [ "$dirty" -gt 0 ] || [ "${sub_drift:-0}" -gt 0 ]; then echo "[verdict] SHARED_WORKSPACE_BUSY — 工作树不干净,不得写入;先收尾或经 owner 确认" exit 3 fi if [ "$behind" -gt 0 ] && [ "$branch" = "${TARGET_BRANCH}" ]; then echo "[verdict] SYNC_REQUIRED — 本地 ${TARGET_BRANCH} 落后远端 ${behind} 提交;先 git pull --ff-only" exit 4 fi echo "[verdict] READY — 可建任务工作区:make task-start NAME=feature/your-branch" } cmd_start() { need_repo local name="${1:-}"; [ -n "$name" ] || die "usage: task.sh start /" echo "$name" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "branch name must match (feature|fix|hotfix|release)/" git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true [ "$(git status --porcelain | { grep -v '^.. \.zcode/' || true; } | wc -l)" -eq 0 ] || die "dirty tree — resolve first (precheck verdict BUSY)" [ "$(git submodule status --recursive 2>/dev/null | { grep -cE '^\+|^U' || true; })" -eq 0 ] || die "submodule drift/conflict — resolve first (precheck verdict BUSY)" git merge-base --is-ancestor "origin/${TARGET_BRANCH}" HEAD || die "local ${TARGET_BRANCH} behind origin — pull --ff-only first" local rmain slug wt mainroot rmain=$(git rev-parse "origin/${TARGET_BRANCH}") slug=$(echo "$name" | tr '/' '-') # 落点须为主检出的平级目录:$PWD 推算在子目录(或任务 worktree 内)运行时会算错, # 改用 git-common-dir 的父目录定位主检出(与 cmd_finish 同款惯用法)。 mainroot=$(dirname "$(cd "$(git rev-parse --git-common-dir)" && pwd)") wt="$(dirname "$mainroot")/$(basename "$mainroot")-wt-${slug}" [ -e "$wt" ] && die "worktree path exists: $wt" git worktree add -q "$wt" -b "$name" "$rmain" (cd "$wt" && git submodule update --init --recursive >/dev/null 2>&1 || echo "[warn] submodule init incomplete in worktree") local card="$wt/.task/${slug}.md" mkdir -p "$wt/.task" cat > "$card" <;状态:进行中;owner:$(git config user.name || echo unknown) ## 基线与授权 - 权威仓:$(git -C "$wt" remote get-url origin 2>/dev/null || echo '(local)');目标分支:${TARGET_BRANCH};R0(initial_base_sha)= ${rmain} - 工作区:${wt}(worktree);分支:${name} - 授权与交付终点:<本次允许的动作与终点;未授权事项写明> ## 目标与非目标 <预期结果、受影响入口/模块/契约;明确不做什么。> ## 同步记录 | 节点 | sync_base_sha | tested_head_sha | checked_at | |---|---|---|---| | 启动 | ${rmain} | $(git -C "$wt" rev-parse HEAD) | $(date -u +%FT%TZ) | ## 结果与验证(完成后填写) - 结果: - 验证: - 影响与剩余项: EOF git -C "$wt" add .task && git -C "$wt" commit -q -m "Record the task card for ${slug}" && echo "[card] $card(已随分支提交)" echo "[started] branch=${name} worktree=${wt} R0=$(echo "$rmain" | cut -c1-10)" } cmd_sync() { need_repo git fetch -q origin "${TARGET_BRANCH}" [ "$(git status --porcelain | grep -v '^.. \.zcode/' | wc -l)" -eq 0 ] || die "dirty tree — 先显式提交;不隐式 stash(collaboration.md:11)" git merge --no-edit "origin/${TARGET_BRANCH}" git merge-base --is-ancestor "origin/${TARGET_BRANCH}" HEAD || die "sync incomplete" local base head card base=$(git rev-parse "origin/${TARGET_BRANCH}"); head=$(git rev-parse HEAD) card=$(ls .task/*.md 2>/dev/null | head -1 || true) if [ -n "$card" ]; then printf '| 同步前验收 | %s | %s | %s |\n' "$base" "$head" "$(date -u +%FT%TZ)" >> "$card" git add "$card" git commit -q -m "Record the sync checkpoint" || true fi echo "[synced] sync_base=$(echo "$base" | cut -c1-10) tested_head=$(echo "$head" | cut -c1-10) checked_at=$(date -u +%FT%TZ)" echo "[next] 跑正式验收(make test 等),证据写入任务卡" } # ---- 卡片闭合(防欠账)---- # 依据:delivery.md §5 治理清理;机制化 2026-09-30 治理缺口(11 张已合入卡片未闭合)。 # 检测 .task/*.md 中"分支已合入 origin/ 但状态非已完成"的卡片:回填状态、主记录 # (PR 链接+合并 SHA)与闭合记录,并 git mv 到 .task/archive/。变更落点:主检出运行→ # 新建 feature/card-close-* 分支推送(集成分支受保护,不直推);任务分支内运行须 --fold # (随当前任务 PR 合入)。合并判定用合并提交主题(分支删除后仍可追溯),不依赖分支存活。 card_of_branch() { sed -n 's/^- 工作区:.*分支:\([A-Za-z0-9._/-]\+\).*/\1/p' "$1" | head -1; } card_status() { sed -n 's/^.*;状态:\([^;]*\).*/\1/p' "$1" | head -1; } card_merge() { # $1=branch → "sha|subject"(该分支合入 origin/ 的合并提交) git log --merges --format='%H|%s' "origin/${TARGET_BRANCH}" 2>/dev/null \ | grep -F " from $1 into ${TARGET_BRANCH}" | head -1 } card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/card-close-* 分支待合入 local cb for cb in $(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/feature/card-close-*"); do git cat-file -e "${cb}:.task/archive/$1" 2>/dev/null && return 0 done return 1 } card_backfill() { # $1=card $2=pr_num $3=merge_sha $4=merge_subject $5=web_base(原地改写) python3 - "$1" "$2" "$3" "$4" "$5" <<'PY' import re, sys path, pr, sha, subject, base = sys.argv[1:6] s = open(path, encoding='utf-8').read() s = re.sub(r'状态:[^;\n]*', '状态:已完成(回填)', s, count=1) m = re.search(r'任务主记录:([^;\n]*)', s) if m and 'pulls/' not in m.group(1): s = s.replace(m.group(1), f'PR #{pr} {base}/pulls/{pr}(merge {sha[:10]})', 1) s = s.replace('## 结果与验证(完成后填写)', '## 结果与验证', 1) s = s.rstrip('\n') + f''' ## 闭合记录(card-autoclose 回填) - 依据:merge {sha}(PR #{pr})已包含于 origin/main,分支成果已落盘;本段为合入后回填。 - 合并主题:{subject} - 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。 ''' open(path, 'w', encoding='utf-8').write(s) PY } cmd_close() { need_repo local fold=0 [ "${1:-}" = "--fold" ] && fold=1 git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true # 默认值取远端通告的 Web 面(git push 输出的 PR 建议链接);可用 TASK_CARD_WEB_BASE 覆盖。 # git-panel 前缀已失效(TLS 握手失败),默认值改为实测可用的 git-home(AGENTS.md 访问事实节)。 local web_base="${TASK_CARD_WEB_BASE:-https://git-home.u1680832.nyat.app:30319}" local url owner repo url=$(git remote get-url origin); url="${url%.git}" repo=$(basename "$url"); owner=$(basename "$(dirname "$url")") local branch c b st m cards="" branch=$(git rev-parse --abbrev-ref HEAD) for c in .task/*.md; do [ -f "$c" ] || continue b=$(card_of_branch "$c"); [ -n "$b" ] || continue st=$(card_status "$c"); case "$st" in 已完成*) continue ;; esac if card_pending "${c##*/}"; then echo "[close] ${c##*/} 已有待合入的闭合分支,跳过(等其 PR 合入)" continue fi m=$(card_merge "$b") || m="" [ -n "$m" ] || continue cards="$cards $c|$b|$m" done [ -n "$cards" ] || { echo "[close] 无已合入未闭合的任务卡"; exit 0; } # 落点:主检出→feature/card-close-* 分支(提交过 pre-commit 分支闸);任务分支须显式 --fold local newbranch="" if [ "$branch" = "${TARGET_BRANCH}" ]; then [ -z "$(git status --porcelain | grep -v '^.. \.zcode/' || true)" ] || die "主检出树脏——先收尾再 close(防混入未提交现场)" newbranch="feature/card-close-$(date -u +%Y%m%d-%H%M%SZ)" git rev-parse --verify -q "refs/heads/${newbranch}" >/dev/null && die "分支已存在:${newbranch}" git checkout -q -b "$newbranch" "origin/${TARGET_BRANCH}" elif [ "$fold" != "1" ]; then die "close 在任务分支内须加 --fold(并入当前任务 PR);或到主检出运行(将建 feature/card-close-* 分支+PR)" fi local n=0 sha subject pr closed_list="" while IFS='|' read -r c b m; do [ -n "$c" ] || continue sha="${m%%|*}"; subject="${m#*|}" pr=$(printf '%s' "$subject" | sed -n 's/.*(#\([0-9]\+\)) from.*/\1/p') [ -n "$pr" ] || pr=unknown card_backfill "$c" "$pr" "$sha" "$subject" "${web_base}/${owner}/${repo}" mkdir -p .task/archive git mv "$c" ".task/archive/${c##*/}" closed_list="$closed_list ${c##*/}(PR#${pr})" n=$((n+1)) done < "$msg" git add .task git commit -q -F "$msg"; rm -f "$msg" git push -q -u origin HEAD local headbr pr_url="" headbr=$(git rev-parse --abbrev-ref HEAD) if [ -n "${GITEA_TOKEN:-}" ]; then local resp resp=$(curl -sS -X POST -H "Authorization: token ${GITEA_TOKEN}" -H 'Content-Type: application/json' \ "${web_base}/api/v1/repos/${owner}/${repo}/pulls" \ -d "{\"head\":\"${headbr}\",\"base\":\"${TARGET_BRANCH}\",\"title\":\"Close merged task cards (${headbr})\",\"body\":\"Auto-generated by task.sh close; see commit message for evidence.\"}" 2>/dev/null || true) pr_url=$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("html_url",""))' 2>/dev/null || true) fi if [ -n "$newbranch" ]; then git checkout -q "${TARGET_BRANCH}"; fi if [ -n "$pr_url" ]; then echo "[pr] ${pr_url}(合入权在所有者)" else echo "[next] 建 PR:${web_base}/${owner}/${repo}/pulls/new?head=${headbr}(合入权在所有者)" fi echo "[closed] ${n} 张已合入卡片已回填归档,提交于 ${headbr} 并推送" } cmd_finish() { need_repo local abandon="${1:-}" local branch head common mainrepo branch=$(git rev-parse --abbrev-ref HEAD) head=$(git rev-parse HEAD) common=$(git rev-parse --git-common-dir); common=$(cd "$common" && pwd) mainrepo=$(dirname "$common") # 防护(T0 事故教训):finish 必须在任务 worktree 内运行——本脚本把 $PWD 当 # worktree 回收(rm -rf);从主检出运行会误删主检出。mainrepo==PWD 即主检出。 [ "$(cd "$mainrepo" && pwd)" = "$PWD" ] && die "task-finish 须在任务 worktree 内运行(cd 到 worktree 再执行),防止误删主检出" git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true if [ "$abandon" = "--abandon" ]; then local reason="${2:-}"; [ -n "$reason" ] || die "--abandon 需要原因:task.sh finish --abandon " local untracked; untracked=$(git status --porcelain | grep -c '^??' || true) [ "$untracked" -gt 0 ] && die "存在 ${untracked} 个未跟踪文件 — 先处置或用 task-finish FORCE_ABANDON=1" [ "${FORCE_ABANDON:-0}" = "1" ] || { echo "[confirm] 放弃未合入工作属破坏性动作:重跑 FORCE_ABANDON=1 task.sh finish --abandon \"${reason}\""; exit 5; } echo "[abandoned] branch=${branch} reason=${reason} recorded_at=$(date -u +%FT%TZ)" else git merge-base --is-ancestor "$head" "origin/${TARGET_BRANCH}" || die "分支未合入 origin/${TARGET_BRANCH}——先走 PR 合入;放弃则用 --abandon " echo "[verified] ${branch} 已包含于 origin/${TARGET_BRANCH};成果已落盘" fi # git 对含 gitlink 的 worktree 无条件拒绝 remove(查索引),回收走 rm -rf + prune。 # 此处不得跑 submodule deinit:submodule..url 写在共享的 .git/config 里,deinit # 会把其他检出/worktree 的子模块一并标成未初始化(2026-09-30 实测 finish 后主检出 uninit=12)。 git -C "$mainrepo" worktree remove "$PWD" 2>/dev/null || { rm -rf "$PWD"; git -C "$mainrepo" worktree prune; } git -C "$mainrepo" branch -d "$branch" 2>/dev/null || git -C "$mainrepo" branch -D "$branch" git -C "$mainrepo" worktree prune git push -q --delete origin "$branch" 2>/dev/null || true echo "[recycled] worktree 与分支 ${branch} 已回收(本地+远端,$(date -u +%FT%TZ))" # 合入成功→自动闭合任务卡(含本任务卡;经 feature/card-close-* 分支+PR,合入权在所有者) ( cd "$mainrepo" && bash scripts/task.sh close ) \ || echo "[next] 卡片自动闭合未完成——在主检出运行 make task-close" } case "${1:-}" in precheck) shift; cmd_precheck "$@" ;; start) shift; cmd_start "$@" ;; sync) shift; cmd_sync "$@" ;; close) shift; cmd_close "$@" ;; finish) shift; cmd_finish "$@" ;; *) echo "usage: task.sh [args]"; exit 2 ;; esac