Make development gates preserve work and verifiable evidence
Why: cleanup, component synchronization, receipt recording and credential handling could contradict the documented development boundaries. Evidence: SELF 75 isolated governance tests and source checks are captured in .task/feature-governance-reliability.md; combination and CI verification follow. Impact: hardens local task and CI tooling, aligns policy and component documentation, and adds continuous regression tests without changing product behavior. Refs: .task/feature-governance-reliability.md; policy/governance.md; policy/platform-admission.md
This commit is contained in:
+119
-45
@@ -4,24 +4,36 @@
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_BRANCH="${TASK_TARGET_BRANCH:-main}"
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
|
||||
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"; }
|
||||
fetch_target() {
|
||||
git check-ref-format "refs/heads/${TARGET_BRANCH}" >/dev/null || die "invalid target branch"
|
||||
if ! git fetch -q origin "refs/heads/${TARGET_BRANCH}:refs/remotes/origin/${TARGET_BRANCH}"; then
|
||||
echo "[verdict] REMOTE_UNVERIFIED — cannot refresh authoritative origin/${TARGET_BRANCH}; preserve local work and retry" >&2
|
||||
return 6
|
||||
fi
|
||||
git rev-parse --verify "refs/remotes/origin/${TARGET_BRANCH}^{commit}" >/dev/null \
|
||||
|| { echo "[verdict] REMOTE_UNVERIFIED — target commit missing" >&2; return 6; }
|
||||
}
|
||||
check_worktree() { python3 "${SCRIPT_DIR}/check-worktree.py" "$1"; }
|
||||
main_worktree_root() { git worktree list --porcelain -z | python3 -c 'import os,sys; print(os.fsdecode(sys.stdin.buffer.read().split(bytes([0]),1)[0][9:]))'; }
|
||||
|
||||
cmd_precheck() {
|
||||
need_repo
|
||||
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
|
||||
fetch_target
|
||||
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")
|
||||
rmain=$(git rev-parse --verify "origin/${TARGET_BRANCH}")
|
||||
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)
|
||||
behind=$(git rev-list --count "HEAD..origin/${TARGET_BRANCH}")
|
||||
ahead=$(git rev-list --count "origin/${TARGET_BRANCH}..HEAD")
|
||||
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}"
|
||||
@@ -93,8 +105,12 @@ cmd_precheck() {
|
||||
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"
|
||||
if ! check_worktree before-sync; then
|
||||
echo "[verdict] SHARED_WORKSPACE_BUSY — component or active operation check failed; preserve the workspace"
|
||||
exit 3
|
||||
fi
|
||||
if [ "$behind" -gt 0 ]; then
|
||||
echo "[verdict] SYNC_REQUIRED — ${branch} 缺少 origin/${TARGET_BRANCH} 的 ${behind} 个提交;主检出 pull --ff-only,任务分支 make task-sync"
|
||||
exit 4
|
||||
fi
|
||||
echo "[verdict] READY — 可建任务工作区:make task-start NAME=feature/your-branch"
|
||||
@@ -104,19 +120,34 @@ cmd_start() {
|
||||
need_repo
|
||||
local name="${1:-}"; [ -n "$name" ] || die "usage: task.sh start <feature|fix|hotfix>/<slug>"
|
||||
echo "$name" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "branch name must match (feature|fix|hotfix|release)/<slug>"
|
||||
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
|
||||
fetch_target
|
||||
[ -n "${PRECHECK_RECEIPT:-}" ] || die "PRECHECK_RECEIPT required: capture RECEIPT=/tmp/task-precheck.json CMD='make precheck' make evidence first"
|
||||
PRECHECK_RECEIPT=$(realpath -- "$PRECHECK_RECEIPT")
|
||||
VALIDATE_RECEIPT="$PRECHECK_RECEIPT" bash "${SCRIPT_DIR}/evidence.sh"
|
||||
check_worktree before-sync
|
||||
[ "$(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
|
||||
local rmain slug wt mainroot start_ref parent_head="" parent_note=""
|
||||
rmain=$(git rev-parse "origin/${TARGET_BRANCH}")
|
||||
start_ref=$rmain
|
||||
if [ -n "${TASK_PARENT_BRANCH:-}${TASK_PARENT_PR:-}" ]; then
|
||||
[ -n "${TASK_PARENT_BRANCH:-}" ] && [ -n "${TASK_PARENT_PR:-}" ] || die "stacked task requires TASK_PARENT_BRANCH and TASK_PARENT_PR"
|
||||
echo "$TASK_PARENT_BRANCH" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "invalid parent task branch"
|
||||
echo "$TASK_PARENT_PR" | grep -qE '^https?://[^[:space:]]+/pulls/[0-9]+$' || die "TASK_PARENT_PR must be a parent PR URL"
|
||||
case "${name#*/}" in "${TASK_PARENT_BRANCH#*/}"-?*) ;; *) die "stacked child slug must start with parent slug plus '-'" ;; esac
|
||||
parent_head=$(git rev-parse --verify "refs/heads/${TASK_PARENT_BRANCH}^{commit}") || die "parent branch missing locally"
|
||||
git merge-base --is-ancestor "$rmain" "$parent_head" || die "parent branch does not include current origin/${TARGET_BRANCH}; sync it first"
|
||||
start_ref=$parent_head
|
||||
parent_note="- 堆叠父任务:${TASK_PARENT_BRANCH};父 PR:${TASK_PARENT_PR};parent_head_sha=${parent_head};父合入后 task-sync 并将 PR base 改回 ${TARGET_BRANCH}"
|
||||
fi
|
||||
slug=$(echo "$name" | tr '/' '-')
|
||||
# 落点须为主检出的平级目录:$PWD 推算在子目录(或任务 worktree 内)运行时会算错,
|
||||
# 改用 git-common-dir 的父目录定位主检出(与 cmd_finish 同款惯用法)。
|
||||
mainroot=$(dirname "$(cd "$(git rev-parse --git-common-dir)" && pwd)")
|
||||
# 以 Git 登记表定位主检出,兼容 linked worktree 与独立 git-dir。
|
||||
mainroot=$(main_worktree_root)
|
||||
wt="$(dirname "$mainroot")/$(basename "$mainroot")-wt-${slug}"
|
||||
[ -e "$wt" ] && die "worktree path exists: $wt"
|
||||
git worktree add -q "$wt" -b "$name" "$rmain"
|
||||
git worktree add -q "$wt" -b "$name" "$start_ref"
|
||||
# 物化后按 submodule status 实际状态核对,不只信退出码:网络抖动或嵌套指针
|
||||
# 漂移时 update 可能静默留坑(2026-09-30 实测深嵌套层带病通过,构建期才炸)。
|
||||
(cd "$wt" && git submodule update --init --recursive >/dev/null 2>&1) \
|
||||
@@ -130,12 +161,13 @@ cmd_start() {
|
||||
cat > "$card" <<EOF
|
||||
# ${name} 任务卡
|
||||
|
||||
任务主记录:<PR 链接,task-finish 时填写>;状态:进行中;owner:$(git config user.name || echo unknown)
|
||||
任务主记录:<PR 链接,交付前填写>;状态:进行中;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}
|
||||
${parent_note}
|
||||
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
|
||||
|
||||
## 目标与非目标
|
||||
@@ -160,16 +192,31 @@ cmd_start() {
|
||||
- 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表>
|
||||
- 影响与剩余项:
|
||||
EOF
|
||||
git -C "$wt" add .task && git -C "$wt" commit -q -m "Record the task card for ${slug}" && echo "[card] $card(已随分支提交)"
|
||||
(cd "$wt" && IMPORT_RECEIPT="$PRECHECK_RECEIPT" bash "${SCRIPT_DIR}/evidence.sh")
|
||||
git -C "$wt" add .task
|
||||
git -C "$wt" commit -q -m "Record the task card for ${slug}" \
|
||||
-m "Why: establish the authorized task workspace and its initial baseline." \
|
||||
-m "Evidence: preserved precheck receipt in .task/${slug}.md; R0=${rmain}." \
|
||||
-m "Impact: task metadata only; the feature workspace starts at ${start_ref}." \
|
||||
-m "Refs: .task/${slug}.md"
|
||||
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}"
|
||||
fetch_target
|
||||
check_worktree before-sync
|
||||
git merge --no-edit "origin/${TARGET_BRANCH}" \
|
||||
-m "Merge authoritative ${TARGET_BRANCH} into the task branch" \
|
||||
-m "Why: include the current integration baseline before verification." \
|
||||
-m "Evidence: fetched origin/${TARGET_BRANCH}=$(git rev-parse "origin/${TARGET_BRANCH}")." \
|
||||
-m "Impact: integrate upstream changes without rewriting task history." \
|
||||
-m "Refs: task-sync"
|
||||
git merge-base --is-ancestor "origin/${TARGET_BRANCH}" HEAD || die "sync incomplete"
|
||||
check_worktree before-update
|
||||
check_worktree materialize
|
||||
check_worktree after-sync
|
||||
local base head card branch slug pc
|
||||
base=$(git rev-parse "origin/${TARGET_BRANCH}"); head=$(git rev-parse HEAD)
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
@@ -188,7 +235,10 @@ cmd_sync() {
|
||||
if [ -n "$card" ] && [ -f "$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
|
||||
git commit -q -m "Record the sync checkpoint" \
|
||||
-m "Why: bind the task validation checkpoint to the latest integration baseline." \
|
||||
-m "Evidence: SELF recursive component checks; sync_base=${base}; candidate=${head}." \
|
||||
-m "Impact: task metadata only." -m "Refs: ${card}"
|
||||
# [evidence] 同步改变了被测对象:卡片证据表中旧 HEAD 的行,其后若有非 .task 变更则失效须重跑。
|
||||
local eh stale_ev="" short_head changed
|
||||
short_head=$(echo "$head" | cut -c1-10)
|
||||
@@ -252,7 +302,7 @@ cmd_close() {
|
||||
need_repo
|
||||
local fold=0
|
||||
[ "${1:-}" = "--fold" ] && fold=1
|
||||
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
|
||||
fetch_target
|
||||
# 默认值取远端通告的 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}"
|
||||
@@ -358,37 +408,61 @@ for p in json.load(sys.stdin):
|
||||
|
||||
cmd_finish() {
|
||||
need_repo
|
||||
local abandon="${1:-}"
|
||||
local branch head common mainrepo
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
local abandon="${1:-}" branch head root mainrepo remote_head="" remote_status
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
check_worktree finish
|
||||
[ "${TASK_WRITERS_STOPPED:-}" = 1 ] || die "TASK_WRITERS_STOPPED=1 required: the workspace owner must first confirm all writers have stopped"
|
||||
branch=$(git symbolic-ref --quiet --short HEAD) || die "detached task worktree must be preserved"
|
||||
case "$branch" in feature/*|fix/*|hotfix/*|release/*) ;; *) die "refuse to recycle non-task branch ${branch}" ;; esac
|
||||
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
|
||||
mainrepo=$(main_worktree_root)
|
||||
fetch_target
|
||||
if [ "$abandon" = "--abandon" ]; then
|
||||
local reason="${2:-}"; [ -n "$reason" ] || die "--abandon 需要原因:task.sh finish --abandon <reason>"
|
||||
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)"
|
||||
local reason="${2:-}"; [ -n "$reason" ] || die "--abandon requires a reason"
|
||||
# An explicit abandon removes only the clean checkout; commits stay reachable.
|
||||
git notes --ref=refs/notes/task-abandon append -m "Task ${branch} abandoned at $(date -u +%FT%TZ): ${reason}" "$head"
|
||||
echo "[abandoned] branch=${branch}; commits retained; reason recorded in refs/notes/task-abandon"
|
||||
elif [ -n "$abandon" ]; then
|
||||
die "usage: task.sh finish [--abandon <reason>]"
|
||||
else
|
||||
git merge-base --is-ancestor "$head" "origin/${TARGET_BRANCH}" || die "分支未合入 origin/${TARGET_BRANCH}——先走 PR 合入;放弃则用 --abandon <reason>"
|
||||
echo "[verified] ${branch} 已包含于 origin/${TARGET_BRANCH};成果已落盘"
|
||||
git merge-base --is-ancestor "$head" "origin/${TARGET_BRANCH}" || die "branch has not merged into origin/${TARGET_BRANCH}; preserve it or explicitly abandon the checkout"
|
||||
remote_status=$(git ls-remote --heads origin "refs/heads/${branch}") || die "cannot verify remote task branch; preserve workspace"
|
||||
if [ -n "$remote_status" ]; then
|
||||
# Refresh its object, then use that exact observation as a deletion lease.
|
||||
git fetch -q origin "refs/heads/${branch}" || die "cannot fetch remote task branch; preserve workspace"
|
||||
remote_head=$(git rev-parse --verify FETCH_HEAD)
|
||||
git merge-base --is-ancestor "$remote_head" "origin/${TARGET_BRANCH}" || die "remote task branch has unmerged work; preserve workspace and remote branch"
|
||||
fi
|
||||
echo "[verified] ${branch} is included in origin/${TARGET_BRANCH}"
|
||||
fi
|
||||
# git 对含 gitlink 的 worktree 无条件拒绝 remove(查索引),回收走 rm -rf + prune。
|
||||
# 此处不得跑 submodule deinit:submodule.<name>.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"
|
||||
# Re-check after fetch. --force only compensates Git's gitlink limitation after all
|
||||
# registered-root, owner, lock, recursive dirt and ignored-file checks have passed.
|
||||
check_worktree finish
|
||||
local -a removal=(worktree remove)
|
||||
if git ls-files --stage | grep -q '^160000 '; then
|
||||
removal+=(--force)
|
||||
fi
|
||||
if ! git -C "$mainrepo" "${removal[@]}" "$root"; then
|
||||
die "Git refused worktree removal; workspace and branch retained. Owner must inspect the refusal; no rm fallback or additional force level is permitted."
|
||||
fi
|
||||
cd "$mainrepo"
|
||||
if [ "$abandon" = "--abandon" ]; then
|
||||
echo "[recycled] worktree removed; local branch=${branch} retained; remote=NOT_RUN"
|
||||
elif git branch -d "$branch"; then
|
||||
if [ -z "$remote_head" ]; then
|
||||
echo "[recycled] worktree and local branch=${branch} removed; remote=ALREADY_ABSENT"
|
||||
elif git push -q "--force-with-lease=refs/heads/${branch}:${remote_head}" origin ":refs/heads/${branch}"; then
|
||||
echo "[recycled] worktree and local branch=${branch} removed; remote=DELETED observed_head=${remote_head}"
|
||||
else
|
||||
echo "[partial] local worktree/branch removed; remote=${branch} retained or result unknown; query remote before retrying" >&2
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
echo "[partial] worktree removed; local branch=${branch} retained because safe branch deletion was refused; remote=NOT_RUN" >&2
|
||||
return 1
|
||||
fi
|
||||
# Keep card closure separate: its push/PR side effects are not implied by local disposal.
|
||||
echo "[next] inspect task-card closure with make task-close in the main checkout when authorized"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
|
||||
Reference in New Issue
Block a user