Merge authoritative main into the task branch

Why: include the current integration baseline before verification.

Evidence: fetched origin/main=e1bc2602e18a89e4d75a4bf6cd80bc7f9b4d2164.

Impact: integrate upstream changes without rewriting task history.

Refs: task-sync
This commit is contained in:
2026-10-14 13:55:13 +00:00
7 changed files with 1588 additions and 12 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+61 -10
View File
@@ -57,9 +57,9 @@ cmd_precheck() {
pb=$(card_of_branch "$pc"); [ -n "$pb" ] || continue
pst=$(card_status "$pc"); case "$pst" in 已完成*) continue ;; esac
card_pending "${pc##*/}" && continue
# Consume the complete producer output: grep -q exits on the first match,
# making git log die with SIGPIPE (141 under pipefail) on long histories.
if git log --merges --format='%s' "origin/${TARGET_BRANCH}" 2>/dev/null | grep -F " from $pb into ${TARGET_BRANCH}" >/dev/null; then
# card_merge 兼容 merge 提交与 squash 落地(squash 走 Gitea API 兜底,无 token 时只认 merge 提交);
# 其内部以完整消费输出取首行,避免 grep -q 早退令 git log 吃 SIGPIPE(141 under pipefail)。
if card_merge "$pb" >/dev/null; then
open_cards="${open_cards} ${pc##*/}"
fi
done
@@ -382,9 +382,61 @@ cmd_sync() {
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/<target> 的合并提交)
git log --merges --format='%H|%s' "origin/${TARGET_BRANCH}" 2>/dev/null \
| grep -F " from $1 into ${TARGET_BRANCH}" | sed -n '1p'
card_merge() { # $1=branch → "sha|subject|pr"(该分支合入 origin/<target> 的记录;兼容 merge 提交与 squash 落地)
local hit pr
hit=$(git log --merges --format='%H|%s' "origin/${TARGET_BRANCH}" 2>/dev/null \
| grep -F " from $1 into ${TARGET_BRANCH}" | sed -n '1p') || hit=""
if [ -n "$hit" ]; then
pr=$(printf '%s' "$hit" | sed -n 's/.*(#\([0-9]\+\)) from.*/\1/p')
printf '%s|%s\n' "$hit" "${pr:-unknown}"
return 0
fi
card_merge_squash "$1"
}
card_merge_squash() { # $1=branch → squash/rebase 落地没有 merge 提交,经 Gitea API 按 head 分支找 merged PR。
# 返回 "merge_commit_sha|PR标题|PR号";sha 必须可核为 origin/<target> 祖先,否则不认(不可核即不闭合)。
# 仅查最近 5 页(250 条):card_merge 的 merge 提交路径仍是主路径,此兜底面向新近任务卡。
local token="${GITEA_TOKEN:-}" web_base url repo owner api payload pg found sha
[ -n "$token" ] || return 1
web_base="${TASK_CARD_WEB_BASE:-https://git-home.u1680832.nyat.app:30319}"
url=$(git remote get-url origin 2>/dev/null) || return 1
url="${url%.git}"
repo=$(basename "$url"); owner=$(basename "$(dirname "$url")")
api="${web_base}/api/v1/repos/${owner}/${repo}/pulls"
found=""
for pg in 1 2 3 4 5; do
payload=$(curl -fsS -m 15 -H "Authorization: token ${token}" "${api}?state=all&limit=50&page=${pg}" 2>/dev/null) || payload=""
[ -n "$payload" ] || break
found=$(printf '%s' "$payload" | CARD_BRANCH="$1" python3 -c '
import json, os, sys
branch = os.environ.get("CARD_BRANCH", "")
try:
entries = json.load(sys.stdin)
except Exception:
entries = []
for p in entries if isinstance(entries, list) else []:
if not isinstance(p, dict):
continue
head = p.get("head") or {}
ref = head.get("ref") or ""
label = head.get("label") or ""
# head 分支被删后 Gitea 把 ref 换成 refs/pull/N/head,原始分支名留在 label
# (同仓为裸分支名,fork 为 owner:branch)。
if branch != ref and branch != label and not label.endswith(":" + branch):
continue
if not p.get("merged"):
continue
sha, title, num = p.get("merge_commit_sha") or "", p.get("title") or "", str(p.get("number") or "")
if sha and title and num:
print(f"{sha}|{title}|{num}")
break
') && [ -n "$found" ] && break
done
[ -n "$found" ] || return 1
sha="${found%%|*}"
git merge-base --is-ancestor "$sha" "origin/${TARGET_BRANCH}" 2>/dev/null || return 1
printf '%s\n' "$found"
}
card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/card-close-* 分支待合入
local cb
@@ -467,9 +519,8 @@ $c|$b|$m"
n=$((n+1))
continue
fi
sha="${m%%|*}"; subject="${m#*|}"
pr=$(printf '%s' "$subject" | sed -n 's/.*(#\([0-9]\+\)) from.*/\1/p')
[ -n "$pr" ] || pr=unknown
sha="${m%%|*}"; rest="${m#*|}"; pr="${rest##*|}"; subject="${rest%|*}"
case "$pr" in "$rest"|''|*[!0-9]*) pr=unknown ;; esac
card_backfill "$c" "$pr" "$sha" "$subject" "${web_base}/${owner}/${repo}"
mkdir -p .task/archive
git mv "$c" ".task/archive/${c##*/}"
@@ -482,7 +533,7 @@ EOF
{ echo "Close ${n} merged task card(s) and archive them"
echo
echo "Why: branches merged into ${TARGET_BRANCH} had left their task cards open; backfill status and archive to prevent bookkeeping debt."
echo "Evidence: $(echo $closed_list) — merges verified via git log --merges against origin/${TARGET_BRANCH}."
echo "Evidence: $(echo $closed_list) — merges verified via git log --merges plus Gitea API squash lookup against origin/${TARGET_BRANCH}."
echo "Impact: .task/*.md moved to .task/archive/ with status backfilled; no code or submodule change."
echo "Refs: task.sh close (card-autoclose-r1)"
} > "$msg"
+84
View File
@@ -802,6 +802,90 @@ class TaskLifecycleTests(unittest.TestCase):
self.assertIn("状态:已完成(回填)", archived)
self.assertIn(actual_merge, archived)
def _stub_gitea(self, get_reply, post_reply=None):
"""Stub Gitea API:GET 按路径返回 get_reply(path),POST 返回建 PR 应答。"""
from http.server import BaseHTTPRequestHandler, HTTPServer
import threading
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def _send(self, obj):
body = json.dumps(obj).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._send(get_reply(self.path))
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length") or 0))
self._send(post_reply or {"html_url": "http://stub.example/pr/1"})
server = HTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=server.serve_forever, daemon=True).start()
self.addCleanup(server.shutdown)
return f"http://127.0.0.1:{server.server_port}", server
def test_close_backfills_card_landed_as_squash(self):
# squash 落地没有 "from <branch> into main" 合并提交:card_merge 须经 Gitea API 识别,
# 否则任务卡永远留在 .task/ 根目录(2026-10-14 #457 实测漏检)。
wt = self.worktree("feature/squashy")
self.record_task_card(wt)
branch = self.git(wt, "branch", "--show-current").stdout.strip()
self.git(wt, "push", "-u", "origin", branch)
self.git(self.repo, "fetch", "origin", branch)
self.git(self.repo, "merge", "--squash", "FETCH_HEAD")
self.git(self.repo, "commit", "-m", "Squash the fixture task")
squash_sha = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
self.git(self.repo, "push", "origin", "main")
base, _ = self._stub_gitea(
lambda path: [{
"number": 41, "title": "Decoy other branch", "state": "closed",
"merged": True, "head": {"ref": "feature/other", "label": "feature/other"},
"merge_commit_sha": squash_sha,
}, {
# head 分支已删的真实形态:ref 换成 pull 引用,原始分支名在 label
"number": 42, "title": "Fixture squash landing", "state": "closed",
"merged": True, "head": {"ref": "refs/pull/42/head", "label": branch},
"merge_commit_sha": squash_sha,
}] if "/pulls" in path else [],
post_reply={"html_url": "http://stub.example/pr/42"})
env = self.env | {"GITEA_TOKEN": "stub-token", "TASK_CARD_WEB_BASE": base}
closed = self.task(self.repo, "close", env=env)
self.assertIn("[closed] 1 张已合入卡片", closed.stdout)
refs = self.git(self.repo, "for-each-ref", "--format=%(refname:short)",
"refs/heads/feature/card-close-*").stdout.splitlines()
self.assertEqual(len(refs), 1)
archived = self.git(self.repo, "show", f"{refs[0]}:.task/archive/feature-squashy.md").stdout
self.assertIn("状态:已完成(回填)", archived)
self.assertIn(squash_sha, archived)
self.assertIn("PR #42", archived)
commit_msg = self.git(self.repo, "log", "-1", "--format=%B", refs[0]).stdout
self.assertIn("Gitea API squash lookup", commit_msg)
def test_close_ignores_squash_claim_when_sha_not_on_main(self):
# API 声称 merged 但 merge_commit_sha 不在 origin/main(伪造或陈旧):不可核即不闭合。
wt = self.worktree("feature/phantom")
self.record_task_card(wt)
branch = self.git(wt, "branch", "--show-current").stdout.strip()
self.git(wt, "push", "-u", "origin", branch)
tip = self.git(wt, "rev-parse", "HEAD").stdout.strip()
base, _ = self._stub_gitea(
lambda path: [{
"number": 43, "title": "Phantom squash", "state": "closed",
"merged": True, "head": {"ref": branch},
"merge_commit_sha": tip,
}] if "/pulls" in path else [])
env = self.env | {"GITEA_TOKEN": "stub-token", "TASK_CARD_WEB_BASE": base}
closed = self.task(self.repo, "close", env=env)
self.assertIn("无已合入未闭合的任务卡", closed.stdout)
def assert_post_merge_only_reminds_owner(self, operation):
expected_head, expected_card = self.publish_merged_task()
self.git(self.repo, "fetch", "origin")