Merge authoritative main into the task branch
Why: include the current integration baseline before verification. Evidence: fetched origin/main=c8eead49c5e68b2e295927c0e82cc57bcb04bad5. Impact: integrate upstream changes without rewriting task history. Refs: task-sync
This commit is contained in:
File diff suppressed because one or more lines are too long
+61
-10
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user