From 03cbeac8e610657b2a5b5f6997de36d55efb0304 Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 07:05:06 +0000 Subject: [PATCH] Avoid new commits when the task already contains the integration baseline Why: repeated task-sync calls changed HEAD and invalidated CI even when main had not advanced. Evidence: SELF red-green regression and 58 task lifecycle tests recorded through make evidence in the task card. Impact: sync still fetches and validates components, creates records only for an actual merge, and reports the complete final source SHA. Refs: U1; policy/merge-queue.md; .task/feature-merge-queue-policy-draft.md --- scripts/task.sh | 43 ++++++++++++++--------- tests/test_task.py | 85 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 16 deletions(-) diff --git a/scripts/task.sh b/scripts/task.sh index 7fdaeeda..0f6bdff6 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -278,18 +278,26 @@ cmd_sync() { need_repo 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" + local base head card branch slug pc changed=0 ancestry_status + base=$(git rev-parse "origin/${TARGET_BRANCH}") + if git merge-base --is-ancestor "$base" HEAD; then + : + else + ancestry_status=$? + [ "$ancestry_status" -eq 1 ] || die "cannot verify target ancestry" + git merge --no-edit "$base" \ + -m "Merge authoritative ${TARGET_BRANCH} into the task branch" \ + -m "Why: include the current integration baseline before verification." \ + -m "Evidence: fetched origin/${TARGET_BRANCH}=${base}." \ + -m "Impact: integrate upstream changes without rewriting task history." \ + -m "Refs: task-sync" + changed=1 + fi + git merge-base --is-ancestor "$base" 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) + head=$(git rev-parse HEAD) branch=$(git rev-parse --abbrev-ref HEAD) slug=$(echo "$branch" | tr '/' '-') card=".task/${slug}.md" @@ -304,18 +312,21 @@ cmd_sync() { done fi 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" \ - -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}" + if [ "$changed" -eq 1 ]; then + printf '| 同步(记录前对象) | %s | %s | %s |\n' "$base" "$head" "$(date -u +%FT%TZ)" >> "$card" + git add "$card" + git commit -q -m "Record the sync checkpoint" \ + -m "Why: bind the task validation checkpoint to the integration baseline." \ + -m "Evidence: SELF recursive component checks; sync_base=${base}; before_record=${head}." \ + -m "Impact: task metadata only." -m "Refs: ${card}" -- "$card" + fi # JSON receipts carry before/after state; older table rows remain readable. python3 "${SCRIPT_DIR}/check-evidence.py" "$card" else echo "[warn] 找不到分支 ${branch} 的任务卡,同步记录未写入" >&2 fi - echo "[synced] sync_base=$(echo "$base" | cut -c1-10) tested_head=$(echo "$head" | cut -c1-10) checked_at=$(date -u +%FT%TZ)" + head=$(git rev-parse HEAD) + echo "[synced] sync_base=${base} tested_head=${head} checked_at=$(date -u +%FT%TZ) changed=${changed}" echo "[next] 跑正式验收(make test 等),证据写入任务卡" } diff --git a/tests/test_task.py b/tests/test_task.py index 28927761..f94ae827 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -69,6 +69,20 @@ class TaskLifecycleTests(unittest.TestCase): self.git(self.repo, "commit", "-m", "Advance main") self.git(self.repo, "push", "origin", "main") + def record_task_card(self, wt): + branch = self.git(wt, "branch", "--show-current").stdout.strip() + r0 = self.git(wt, "rev-parse", "origin/main").stdout.strip() + card = wt / ".task" / (branch.replace("/", "-") + ".md") + card.parent.mkdir(exist_ok=True) + card.write_text( + f"# {branch}\n任务主记录:fixture;状态:进行中;owner:Lifecycle Test\n" + f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= {r0}\n" + f"- 工作区:{wt}(worktree);分支:{branch}\n" + ) + self.git(wt, "add", str(card.relative_to(wt))) + self.git(wt, "commit", "-m", "Record fixture task") + return card + def add_submodule(self): child = self.root / "component" self.git(self.root, "init", "--initial-branch=main", str(child)) @@ -105,6 +119,76 @@ class TaskLifecycleTests(unittest.TestCase): reading = next(line for line in result.stdout.splitlines() if line.startswith("[required-reading]")) self.assertIn("policy/delivery.md", reading) + def test_sync_same_base_preserves_head_and_card(self): + wt = self.worktree() + card = self.record_task_card(wt) + head = self.git(wt, "rev-parse", "HEAD").stdout.strip() + content = card.read_bytes() + index = self.git(wt, "ls-files", "--stage").stdout + for _ in range(2): + result = self.task(wt, "sync") + self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head) + self.assertEqual(card.read_bytes(), content) + self.assertEqual(self.git(wt, "ls-files", "--stage").stdout, index) + self.assertEqual(self.git(wt, "status", "--porcelain").stdout, "") + self.assertIn("changed=0", result.stdout) + self.assertIn(f"tested_head={head}", result.stdout) + + def test_sync_new_base_reports_actual_final_head(self): + wt = self.worktree() + card = self.record_task_card(wt) + self.advance_remote() + base = self.git(self.repo, "rev-parse", "HEAD").stdout.strip() + result = self.task(wt, "sync") + head = self.git(wt, "rev-parse", "HEAD").stdout.strip() + self.git(wt, "merge-base", "--is-ancestor", base, head) + self.assertIn(f"sync_base={base}", result.stdout) + self.assertIn(f"tested_head={head}", result.stdout) + self.assertIn("changed=1", result.stdout) + content = card.read_bytes() + self.task(wt, "sync") + self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head) + self.assertEqual(card.read_bytes(), content) + + def test_sync_preserves_dirty_parent_and_staged_changes(self): + for staged in (False, True): + with self.subTest(staged=staged): + wt = self.worktree(name="feature/staged" if staged else "feature/dirty") + head = self.git(wt, "rev-parse", "HEAD").stdout.strip() + (wt / "tracked").write_text("keep owner work\n") + if staged: + self.git(wt, "add", "tracked") + index = self.git(wt, "ls-files", "--stage").stdout + result = self.task(wt, "sync", check=False) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head) + self.assertEqual(self.git(wt, "ls-files", "--stage").stdout, index) + self.assertEqual((wt / "tracked").read_text(), "keep owner work\n") + self.git(self.repo, "worktree", "remove", "--force", str(wt)) + + def test_sync_rejects_unreachable_target_without_changing_head(self): + wt = self.worktree() + self.record_task_card(wt) + head = self.git(wt, "rev-parse", "HEAD").stdout.strip() + self.git(wt, "remote", "set-url", "origin", str(self.root / "missing.git")) + result = self.task(wt, "sync", check=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("REMOTE_UNVERIFIED", result.stdout) + self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head) + + def test_sync_conflict_is_not_reported_as_synced(self): + wt = self.worktree() + self.record_task_card(wt) + (wt / "tracked").write_text("task choice\n") + self.git(wt, "commit", "-am", "Task edit") + (self.repo / "tracked").write_text("target choice\n") + self.git(self.repo, "commit", "-am", "Target edit") + self.git(self.repo, "push", "origin", "main") + result = self.task(wt, "sync", check=False) + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("[synced]", result.stdout) + self.assertIn("UU tracked", self.git(wt, "status", "--porcelain").stdout) + def test_sync_materializes_updated_component(self): child = self.add_submodule() wt = self.worktree() @@ -396,6 +480,7 @@ class TaskLifecycleTests(unittest.TestCase): (wt / ".task/feature-test.md").write_text("# Task\n") self.git(wt, "add", ".task") self.git(wt, "commit", "-m", "Create task card") + self.advance_remote() hook = self.repo / ".git/hooks/pre-commit" hook.write_text("#!/bin/sh\nexit 1\n") hook.chmod(0o755)