@ikon85/agent-workflow-kit 0.43.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/grill-me/SKILL.md +3 -1
- package/.agents/skills/grill-with-docs/SKILL.md +3 -1
- package/.agents/skills/kit-update/SKILL.md +9 -11
- package/.agents/skills/orchestrate-wave/SKILL.md +8 -8
- package/.agents/skills/setup-workflow/SKILL.md +20 -18
- package/.agents/skills/setup-workflow/board-sync.md +4 -1
- package/.agents/skills/setup-workflow/workflow-advisories.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +36 -31
- package/.agents/skills/to-issues/SKILL.md +43 -0
- package/.agents/skills/wrapup/SKILL.md +98 -54
- package/.claude/skills/grill-me/SKILL.md +3 -1
- package/.claude/skills/grill-me-codex/SKILL.md +3 -1
- package/.claude/skills/grill-with-docs/SKILL.md +3 -1
- package/.claude/skills/grill-with-docs-codex/SKILL.md +3 -1
- package/.claude/skills/kit-update/SKILL.md +9 -11
- package/.claude/skills/orchestrate-wave/SKILL.md +8 -8
- package/.claude/skills/setup-workflow/SKILL.md +20 -18
- package/.claude/skills/setup-workflow/board-sync.md +4 -1
- package/.claude/skills/setup-workflow/workflow-advisories.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +36 -31
- package/.claude/skills/to-issues/SKILL.md +43 -0
- package/.claude/skills/wrapup/SKILL.md +98 -54
- package/README.md +249 -7
- package/agent-workflow-kit.package.json +42 -42
- package/package.json +16 -12
- package/scripts/marker_lib.py +3 -0
- package/scripts/profile_globs.py +26 -45
- package/scripts/release-delta-guard.mjs +26 -9
- package/scripts/worktree-lifecycle/README.md +57 -160
- package/scripts/worktree-lifecycle/capabilities.json +2 -3
- package/scripts/worktree-lifecycle/classify.py +480 -0
- package/scripts/worktree-lifecycle/cleanup.py +9 -29
- package/scripts/worktree-lifecycle/core.py +68 -1429
- package/scripts/worktree-lifecycle/ignore_seed.py +1 -1
- package/scripts/worktree-lifecycle/profile.py +46 -29
- package/scripts/worktree-lifecycle/setup.py +7 -19
- package/scripts/wrapup-land.py +1292 -545
- package/src/consumer-migrations.json +1 -16
- package/src/lib/bundle.mjs +31 -1
- package/docs/adr/0001-consumer-divergence-policy.md +0 -53
- package/docs/adr/0002-capability-gated-orchestration.md +0 -70
- package/docs/adr/0003-kit-core-and-project-extension-lifecycle.md +0 -63
- package/docs/adr/0004-release-intent-is-a-version-tag.md +0 -64
- package/docs/adr/0005-to-issues-is-the-planning-facade.md +0 -42
- package/docs/adr/0006-routing-knowledge-access-and-policy-are-separate.md +0 -95
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +0 -88
- package/docs/adr/0008-planning-ignore-rules-are-offered-never-installed.md +0 -84
- package/docs/adr/0009-teardown-authority-is-stateless-repository-classification.md +0 -101
- package/docs/adr/0010-model-roster-replaces-the-optimization-dial.md +0 -87
- package/docs/agents/board-sync.md +0 -74
- package/docs/agents/code-review.md +0 -14
- package/docs/agents/workflow-capabilities.json +0 -28
- package/docs/research/agent-task-taxonomy-benchmark-coverage.md +0 -451
- package/docs/research/benchlm-routing-source.md +0 -198
- package/docs/research/consumer-owned-protocol-files.md +0 -238
- package/docs/research/frontend-agent-benchmarks.md +0 -282
- package/docs/research/model-effort-routing-benchmarks.md +0 -261
- package/docs/research/provider-neutral-agent-routing.md +0 -207
- package/docs/research/wave-152-consumer-acceptance.md +0 -98
- package/docs/research/wave-43-script-hook-census.md +0 -128
- package/scripts/build-kit.mjs +0 -117
- package/scripts/build-kit.test.mjs +0 -207
- package/scripts/census/census.test.mjs +0 -252
- package/scripts/census/state.test.mjs +0 -15
- package/scripts/census/transaction.test.mjs +0 -103
- package/scripts/census-contract.test.mjs +0 -227
- package/scripts/check-kit-staleness.mjs +0 -45
- package/scripts/check-kit-staleness.test.mjs +0 -19
- package/scripts/codex-exec-scenarios/fake-codex.mjs +0 -165
- package/scripts/codex-exec.test.mjs +0 -871
- package/scripts/grill-census-wiring-guard.mjs +0 -87
- package/scripts/grill-census-wiring-guard.test.mjs +0 -107
- package/scripts/kit-release.test.mjs +0 -273
- package/scripts/kit-update-pr.test.mjs +0 -151
- package/scripts/lib/audit-refs.mjs +0 -146
- package/scripts/lib/scrub.mjs +0 -113
- package/scripts/lib/scrub.test.mjs +0 -187
- package/scripts/memory-lifecycle/memory-lifecycle.test.mjs +0 -170
- package/scripts/portability_profile_scan.py +0 -53
- package/scripts/release-delta-guard.test.mjs +0 -174
- package/scripts/release-parity.test.mjs +0 -90
- package/scripts/release-state.test.mjs +0 -360
- package/scripts/test_anchor_table.py +0 -69
- package/scripts/test_board_bootstrap.py +0 -348
- package/scripts/test_board_sync.py +0 -208
- package/scripts/test_board_sync_create_idempotency.py +0 -117
- package/scripts/test_board_sync_wave_title.py +0 -120
- package/scripts/test_census_backstop.py +0 -712
- package/scripts/test_census_forward_contract.py +0 -91
- package/scripts/test_census_update_contract.test.mjs +0 -454
- package/scripts/test_codex_adapter_sync_contract.py +0 -169
- package/scripts/test_dist_kit_smoke.py +0 -220
- package/scripts/test_drift_guard_diagnostics.py +0 -295
- package/scripts/test_issue_claim_contract.py +0 -169
- package/scripts/test_kit_docs_language_census.py +0 -178
- package/scripts/test_marker_lib.py +0 -162
- package/scripts/test_orchestrate_wave_contract.py +0 -381
- package/scripts/test_pr_body_check.py +0 -264
- package/scripts/test_profile_globs.py +0 -280
- package/scripts/test_program_planning_contract.py +0 -133
- package/scripts/test_release_authorization_contract.py +0 -101
- package/scripts/test_render_anchor.py +0 -267
- package/scripts/test_retro_wrapup_contract.py +0 -153
- package/scripts/test_skill_code_review_seed.py +0 -89
- package/scripts/test_skill_codex_exec_lifecycle.py +0 -123
- package/scripts/test_skill_frontmatter_lint.py +0 -234
- package/scripts/test_skill_gh_lint.py +0 -124
- package/scripts/test_skill_language_census.py +0 -254
- package/scripts/test_skill_optional_readiness.py +0 -171
- package/scripts/test_skill_portability_lint.py +0 -866
- package/scripts/test_skill_precommit_template.py +0 -30
- package/scripts/test_skill_publish_audit.py +0 -358
- package/scripts/test_skill_readiness_contract.py +0 -264
- package/scripts/test_skill_readiness_preflight.py +0 -180
- package/scripts/test_skill_required_readiness.py +0 -233
- package/scripts/test_skill_selfcontainment_lint.py +0 -255
- package/scripts/test_skill_setup_workflow_seeds.py +0 -691
- package/scripts/test_skill_stale_name_lint.py +0 -126
- package/scripts/test_skill_surface_refs.py +0 -300
- package/scripts/test_skill_trailing_artifact_lint.py +0 -71
- package/scripts/test_tdd_contract.py +0 -78
- package/scripts/test_worktree_ignore_seed.py +0 -320
- package/scripts/test_worktree_setup_base_guard.py +0 -140
- package/scripts/test_worktree_wrapup_contract.py +0 -1737
- package/scripts/test_wrapup_land.py +0 -428
- package/scripts/worktree-lifecycle/session.py +0 -1857
package/scripts/wrapup-land.py
CHANGED
|
@@ -7,14 +7,32 @@ git/gh step of landing a slice runs here deterministically; judgment
|
|
|
7
7
|
sibling propagation) stays with the calling agent.
|
|
8
8
|
|
|
9
9
|
Subcommands
|
|
10
|
-
preflight
|
|
11
|
-
commit
|
|
12
|
-
land
|
|
13
|
-
|
|
10
|
+
preflight read-only context report (run in the worktree being landed)
|
|
11
|
+
commit .env hard block + secret grep + git commit (run in the worktree)
|
|
12
|
+
land push → PR → body-check → merge → teardown → sweeps → anchor-sync
|
|
13
|
+
(run FROM the main tree; refuses to run inside the worktree)
|
|
14
|
+
content-claim read-only: infer the durable content a planning session left
|
|
15
|
+
dirty in the main checkout, each path with its blob hash
|
|
16
|
+
content-commit land a confirmed claim of that content on a collision-checked
|
|
17
|
+
issue-less branch — explicit pathspecs only, bystanders
|
|
18
|
+
untouched, no teardown half (run in the main checkout)
|
|
19
|
+
|
|
20
|
+
Any born, attached worktree is first-class: a direct /wrapup invocation is the
|
|
21
|
+
teardown authorization, including for worktrees an external tool created under
|
|
22
|
+
a foreign name and path. There is no naming or location gate, no persisted
|
|
23
|
+
attempt state, and no recovery flag — an interrupted landing is resumed by
|
|
24
|
+
re-running it, because every step verifies present state and skips what is
|
|
25
|
+
already done.
|
|
26
|
+
|
|
27
|
+
Branch retirement is authorized, never assumed: ancestry against the freshly
|
|
28
|
+
fetched integration branch deletes normally, and only the platform's own PR
|
|
29
|
+
record — the full tuple, head SHA equal to the tip re-read immediately before
|
|
30
|
+
the deletion — force-deletes.
|
|
14
31
|
|
|
15
32
|
Output: one JSON report on stdout. Exit 0 = ok, 1 = STOP (reason in JSON),
|
|
16
|
-
2 = usage/context error. On STOP nothing is forced — no --force, no
|
|
17
|
-
|
|
33
|
+
2 = usage/context error. On STOP nothing is forced — no --force, no
|
|
34
|
+
--no-verify, and no branch deletion the authority above did not clear; the
|
|
35
|
+
caller diagnoses.
|
|
18
36
|
"""
|
|
19
37
|
|
|
20
38
|
from __future__ import annotations
|
|
@@ -24,21 +42,27 @@ import importlib.util
|
|
|
24
42
|
import json
|
|
25
43
|
import os
|
|
26
44
|
import re
|
|
45
|
+
import signal
|
|
46
|
+
import stat
|
|
27
47
|
import subprocess
|
|
28
48
|
import sys
|
|
49
|
+
import tempfile
|
|
29
50
|
import time
|
|
30
|
-
from
|
|
51
|
+
from collections import Counter
|
|
52
|
+
from pathlib import Path, PurePosixPath
|
|
53
|
+
from typing import NamedTuple
|
|
31
54
|
from urllib.parse import quote
|
|
32
55
|
|
|
33
56
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
34
57
|
|
|
58
|
+
from marker_lib import marker_value # noqa: E402
|
|
59
|
+
|
|
35
60
|
# Secret pattern mirrors the historical /wrapup Step-0a grep (era).
|
|
36
61
|
SECRET_RE = re.compile(
|
|
37
62
|
r"BEGIN [A-Z ]*PRIVATE KEY|(api[_-]?key|secret|password|access[_-]?token|bearer)\s*[:=]",
|
|
38
63
|
re.IGNORECASE,
|
|
39
64
|
)
|
|
40
65
|
ENV_PATH_RE = re.compile(r"(^|/)\.env(\.[^/]*)?$")
|
|
41
|
-
ISSUE_BRANCH_RE = re.compile(r"^(feat|fix|chore|docs)/(\d+)-")
|
|
42
66
|
# ANNAHMEN.md drift-log line: "- #<n>: text" or "- #<n> §<section>: text"
|
|
43
67
|
DRIFT_LINE_RE = re.compile(r"^-\s*#(\d+)(?:\s*§([^:]+?))?\s*:\s*(.+)$")
|
|
44
68
|
RETRO_LINE_RE = re.compile(r"^\*\*Retro:\*\*", re.MULTILINE)
|
|
@@ -71,11 +95,36 @@ INFRA_FAILURE_RE = re.compile(
|
|
|
71
95
|
)
|
|
72
96
|
|
|
73
97
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
98
|
+
LIFECYCLE_PROFILE = "docs/agents/workflow-capabilities.json"
|
|
99
|
+
CLASSIFY_MODULE = "_wrapup_teardown_classify"
|
|
100
|
+
LIFECYCLE_PROFILE_MODULE = "_wrapup_lifecycle_profile"
|
|
101
|
+
|
|
102
|
+
# Census freshness is consumed read-only from the drift guard; this file owns no
|
|
103
|
+
# census logic of its own.
|
|
104
|
+
CENSUS_HOOK = ".claude/hooks/drift-guard.py"
|
|
105
|
+
CENSUS_STALE = "refresh_required"
|
|
106
|
+
CENSUS_ABSENT = "no_census"
|
|
107
|
+
CENSUS_UNREADABLE = "unreadable"
|
|
108
|
+
CENSUS_ISSUE_LIMIT = 1000
|
|
109
|
+
CENSUS_TRACKING_KIND = "census-refresh-source"
|
|
110
|
+
CENSUS_TRACKING_SLUG = "census-refresh"
|
|
111
|
+
CENSUS_TRACKING_TITLE = "census: the activated census needs a refresh"
|
|
112
|
+
CENSUS_CHECKOUT_NOTE = (
|
|
113
|
+
"a census describes the tree it was scanned in — a refresh committed in a "
|
|
114
|
+
"worktree is visible in that working tree only"
|
|
115
|
+
)
|
|
116
|
+
CENSUS_RECOVERY = (
|
|
117
|
+
"run `$census-update` in the evaluated checkout and land the refresh as a "
|
|
118
|
+
"dedicated pull request of its own — never mirror the census file from "
|
|
119
|
+
"another checkout"
|
|
120
|
+
)
|
|
121
|
+
CENSUS_NOT_A_GATE = (
|
|
122
|
+
"this finding never blocks a landing: topology drift is repo-wide and is "
|
|
123
|
+
"usually not caused by the pull request that just merged"
|
|
124
|
+
)
|
|
125
|
+
CENSUS_OVERRIDE_NOTE = (
|
|
126
|
+
"a change-local override greened the handoff gate; the verdict itself still "
|
|
127
|
+
"asks for a refresh"
|
|
79
128
|
)
|
|
80
129
|
|
|
81
130
|
|
|
@@ -85,16 +134,18 @@ class Stop(Exception):
|
|
|
85
134
|
self.step, self.reason, self.detail = step, reason, detail
|
|
86
135
|
|
|
87
136
|
|
|
88
|
-
def run(cmd: list[str], cwd: str | None = None, check: bool = False
|
|
89
|
-
|
|
137
|
+
def run(cmd: list[str], cwd: str | None = None, check: bool = False,
|
|
138
|
+
env: dict | None = None) -> subprocess.CompletedProcess:
|
|
139
|
+
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env)
|
|
90
140
|
if check and p.returncode != 0:
|
|
91
141
|
raise Stop(cmd[0], f"command failed: {' '.join(cmd)}",
|
|
92
142
|
(p.stderr or p.stdout).strip()[-2000:])
|
|
93
143
|
return p
|
|
94
144
|
|
|
95
145
|
|
|
96
|
-
def git(args: list[str], cwd: str | None = None, check: bool = False
|
|
97
|
-
|
|
146
|
+
def git(args: list[str], cwd: str | None = None, check: bool = False,
|
|
147
|
+
env: dict | None = None) -> subprocess.CompletedProcess:
|
|
148
|
+
return run(["git", *args], cwd=cwd, check=check, env=env)
|
|
98
149
|
|
|
99
150
|
|
|
100
151
|
# ---------- PR check gate ----------
|
|
@@ -419,8 +470,20 @@ def worktree_map(cwd: str | None = None) -> tuple[str, dict[str, str]]:
|
|
|
419
470
|
|
|
420
471
|
|
|
421
472
|
def issue_from_branch(branch: str) -> str | None:
|
|
422
|
-
|
|
423
|
-
|
|
473
|
+
"""Derive the issue anchor from the branch — or None, which is first-class.
|
|
474
|
+
|
|
475
|
+
Branch prefixes are a project convention read from the board profile, never
|
|
476
|
+
inlined. A branch that carries no issue number (an externally created
|
|
477
|
+
worktree, a spike) lands without an anchor instead of being refused.
|
|
478
|
+
"""
|
|
479
|
+
prefixes = load_profile().get("branchPrefixes") or ()
|
|
480
|
+
if not prefixes:
|
|
481
|
+
return None
|
|
482
|
+
pattern = re.compile(
|
|
483
|
+
r"^(?:" + "|".join(re.escape(prefix) for prefix in prefixes) + r")/(\d+)-"
|
|
484
|
+
)
|
|
485
|
+
match = pattern.match(branch)
|
|
486
|
+
return match.group(1) if match else None
|
|
424
487
|
|
|
425
488
|
|
|
426
489
|
def declared_close_targets(body: str) -> list:
|
|
@@ -443,304 +506,113 @@ def load_profile() -> dict:
|
|
|
443
506
|
return {}
|
|
444
507
|
|
|
445
508
|
|
|
446
|
-
def
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
509
|
+
def load_module(name: str, path: Path, step: str):
|
|
510
|
+
"""Load one shipped helper module exactly once per process.
|
|
511
|
+
|
|
512
|
+
Re-executing it would mint a second set of classes, so an assessment
|
|
513
|
+
produced by one load could not be raised or matched against the other.
|
|
514
|
+
"""
|
|
515
|
+
existing = sys.modules.get(name)
|
|
516
|
+
if existing is not None:
|
|
517
|
+
return existing
|
|
518
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
452
519
|
if spec is None or spec.loader is None:
|
|
453
|
-
raise Stop(
|
|
520
|
+
raise Stop(step, f"cannot load the shipped helper module from {path}")
|
|
454
521
|
module = importlib.util.module_from_spec(spec)
|
|
455
|
-
sys.modules[
|
|
522
|
+
sys.modules[name] = module
|
|
456
523
|
spec.loader.exec_module(module)
|
|
457
524
|
return module
|
|
458
525
|
|
|
459
526
|
|
|
460
|
-
def
|
|
461
|
-
"""
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
"git", "show",
|
|
465
|
-
"HEAD:docs/agents/workflow-capabilities.json",
|
|
466
|
-
],
|
|
467
|
-
cwd=Path(wt),
|
|
468
|
-
check=False,
|
|
469
|
-
)
|
|
470
|
-
if result.returncode != 0:
|
|
471
|
-
detail = (result.stderr or result.stdout).strip()
|
|
472
|
-
raise core.LifecycleError(
|
|
473
|
-
f"committed landing profile cannot be read from worktree HEAD: {detail}"
|
|
474
|
-
)
|
|
475
|
-
candidate = core.load_profile_text(result.stdout)
|
|
476
|
-
if not candidate.landing_generated_artifact_policy_configured:
|
|
477
|
-
raise core.LifecycleError(
|
|
478
|
-
"worktree landing artifact policy is not configured; "
|
|
479
|
-
"run setup-workflow and commit the explicit policy decision first"
|
|
480
|
-
)
|
|
481
|
-
return candidate
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
def load_merged_canonical_profile(core, main_tree: str):
|
|
485
|
-
"""Read the merged canonical cleanup policy from origin/main."""
|
|
486
|
-
result = core.run(
|
|
487
|
-
[
|
|
488
|
-
"git", "show",
|
|
489
|
-
"origin/main:docs/agents/workflow-capabilities.json",
|
|
490
|
-
],
|
|
491
|
-
cwd=Path(main_tree),
|
|
492
|
-
check=False,
|
|
493
|
-
)
|
|
494
|
-
if result.returncode != 0:
|
|
495
|
-
detail = (result.stderr or result.stdout).strip()
|
|
496
|
-
raise core.LifecycleError(
|
|
497
|
-
f"canonical landing profile cannot be read from origin/main: {detail}"
|
|
498
|
-
)
|
|
499
|
-
canonical = core.load_profile_text(result.stdout)
|
|
500
|
-
if not canonical.landing_generated_artifact_policy_configured:
|
|
501
|
-
raise core.LifecycleError(
|
|
502
|
-
"merged canonical landing artifact policy is not configured"
|
|
503
|
-
)
|
|
504
|
-
return canonical
|
|
527
|
+
def load_teardown_classifier():
|
|
528
|
+
"""The one stateless teardown core — never a second copy here."""
|
|
529
|
+
path = Path(__file__).resolve().parent / "worktree-lifecycle" / "classify.py"
|
|
530
|
+
return load_module(CLASSIFY_MODULE, path, "4 teardown")
|
|
505
531
|
|
|
506
532
|
|
|
507
|
-
def
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
canonical = load_merged_canonical_profile(core, main_tree)
|
|
511
|
-
if (
|
|
512
|
-
candidate.landing_generated_artifact_patterns
|
|
513
|
-
!= canonical.landing_generated_artifact_patterns
|
|
514
|
-
or candidate.scratch_patterns != canonical.scratch_patterns
|
|
515
|
-
):
|
|
516
|
-
raise core.LifecycleError(
|
|
517
|
-
"worktree cleanup policy differs from merged canonical origin/main"
|
|
518
|
-
f"; {CANONICAL_DRIFT_RECOVERY_HINT}"
|
|
519
|
-
)
|
|
520
|
-
if not core.landing_attempt_exists(core.landing_attempt_path(Path(wt))):
|
|
521
|
-
raise core.LifecycleError(
|
|
522
|
-
"landing attempt is missing before canonical cleanup authorization"
|
|
523
|
-
)
|
|
524
|
-
attempt = core.landing_start_artifact_inventory(candidate, Path(wt))
|
|
525
|
-
if (
|
|
526
|
-
attempt["policyDigest"]
|
|
527
|
-
!= core.landing_cleanup_policy_digest(canonical)
|
|
528
|
-
):
|
|
529
|
-
raise core.LifecycleError(
|
|
530
|
-
"landing attempt cleanup policy differs from merged canonical origin/main"
|
|
531
|
-
f"; {CANONICAL_DRIFT_RECOVERY_HINT}"
|
|
532
|
-
)
|
|
533
|
-
return canonical
|
|
533
|
+
def load_lifecycle_profile_module():
|
|
534
|
+
path = Path(__file__).resolve().parent / "worktree-lifecycle" / "profile.py"
|
|
535
|
+
return load_module(LIFECYCLE_PROFILE_MODULE, path, "profile")
|
|
534
536
|
|
|
535
537
|
|
|
536
|
-
def
|
|
537
|
-
"""
|
|
538
|
+
def lifecycle_settings(repo_root: str) -> dict:
|
|
539
|
+
"""The consumer's Worktree Lifecycle block, or {} when there is none.
|
|
538
540
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
the merged canonical policy instead.
|
|
541
|
+
Read raw rather than through the module's loader: these structural facts —
|
|
542
|
+
branch names, branch templates — are needed whether or not the consumer
|
|
543
|
+
enabled the worktree lifecycle itself.
|
|
543
544
|
"""
|
|
544
|
-
canonical = load_merged_canonical_profile(core, main_tree)
|
|
545
|
-
core.canonical_recovery_evidence(canonical, Path(wt))
|
|
546
|
-
return canonical
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
def landing_start_artifact_inventory(wt: str, main_tree: str) -> dict:
|
|
550
|
-
core = load_worktree_cleanup_core()
|
|
551
|
-
try:
|
|
552
|
-
profile = load_candidate_landing_profile(core, wt)
|
|
553
|
-
return core.landing_start_artifact_inventory(profile, Path(wt))
|
|
554
|
-
except core.LegacyLandingAttempt as error:
|
|
555
|
-
# Legacy, not damage: report the classification and the safe route out
|
|
556
|
-
# verbatim, without the corruption framing of a guard failure.
|
|
557
|
-
raise Stop("cleanup", str(error)) from error
|
|
558
|
-
except core.LifecycleError as error:
|
|
559
|
-
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
def landing_verified_scratch_evidence(
|
|
563
|
-
wt: str,
|
|
564
|
-
main_tree: str,
|
|
565
|
-
*,
|
|
566
|
-
expected_baseline_digest: str | None = None,
|
|
567
|
-
landing_start_files: tuple[str, ...] = (),
|
|
568
|
-
) -> tuple[dict, ...]:
|
|
569
|
-
core = load_worktree_cleanup_core()
|
|
570
545
|
try:
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
profile,
|
|
574
|
-
Path(wt),
|
|
575
|
-
expected_baseline_digest=expected_baseline_digest,
|
|
576
|
-
landing_start_files=landing_start_files,
|
|
546
|
+
document = json.loads(
|
|
547
|
+
(Path(repo_root) / LIFECYCLE_PROFILE).read_text(encoding="utf-8")
|
|
577
548
|
)
|
|
578
|
-
|
|
579
|
-
|
|
549
|
+
candidate = document.get("worktreeLifecycle")
|
|
550
|
+
except (OSError, json.JSONDecodeError, AttributeError, TypeError):
|
|
551
|
+
return {}
|
|
552
|
+
return candidate if isinstance(candidate, dict) else {}
|
|
580
553
|
|
|
581
554
|
|
|
582
|
-
def
|
|
583
|
-
|
|
584
|
-
main_tree: str,
|
|
585
|
-
*,
|
|
586
|
-
push_succeeded: bool,
|
|
587
|
-
) -> tuple[dict, ...]:
|
|
588
|
-
core = load_worktree_cleanup_core()
|
|
589
|
-
try:
|
|
590
|
-
profile = load_candidate_landing_profile(core, wt)
|
|
591
|
-
return core.freeze_landing_artifact_evidence(
|
|
592
|
-
profile,
|
|
593
|
-
Path(wt),
|
|
594
|
-
push_succeeded=push_succeeded,
|
|
595
|
-
)
|
|
596
|
-
except core.LifecycleError as error:
|
|
597
|
-
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
555
|
+
def branch_policy(repo_root: str) -> tuple[str, tuple[str, ...]]:
|
|
556
|
+
"""Return (integration branch, protected branches) from the consumer profile.
|
|
598
557
|
|
|
558
|
+
The integration branch is never named inline. An absent or malformed
|
|
559
|
+
profile falls back to the Worktree Lifecycle profile's own documented
|
|
560
|
+
default, which is the single place in the kit that names a branch at all.
|
|
561
|
+
"""
|
|
562
|
+
defaults = load_lifecycle_profile_module().DEFAULT_MAIN_BRANCHES
|
|
563
|
+
raw = lifecycle_settings(repo_root)
|
|
564
|
+
integration = tuple(raw.get("mainBranches") or defaults)
|
|
565
|
+
protected = tuple(raw.get("protectedBranches") or integration)
|
|
566
|
+
return integration[0], protected
|
|
599
567
|
|
|
600
|
-
def reopen_frozen_landing_attempt(wt: str, main_tree: str) -> tuple[dict, ...]:
|
|
601
|
-
core = load_worktree_cleanup_core()
|
|
602
|
-
try:
|
|
603
|
-
profile = load_candidate_landing_profile(core, wt)
|
|
604
|
-
return core.reopen_frozen_landing_attempt(profile, Path(wt))
|
|
605
|
-
except core.LifecycleError as error:
|
|
606
|
-
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
607
568
|
|
|
569
|
+
def require_landable_head(step: str, cwd: str) -> str:
|
|
570
|
+
"""Return the worktree's branch, or STOP with the exact reason it has none.
|
|
608
571
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
572
|
+
Detached and unborn are the two states a landing can never repair on its
|
|
573
|
+
own, so each is a named refusal that says what the user does about it —
|
|
574
|
+
never a silent skip.
|
|
575
|
+
"""
|
|
576
|
+
symbolic = git(["symbolic-ref", "--quiet", "HEAD"], cwd=cwd)
|
|
577
|
+
if symbolic.returncode != 0:
|
|
578
|
+
raise Stop(
|
|
579
|
+
step,
|
|
580
|
+
"detached HEAD — /wrapup lands a branch and this worktree is on none",
|
|
581
|
+
f"{cwd}: attach one here (`git switch -c <branch>` keeps the work on a "
|
|
582
|
+
"new branch, `git switch <branch>` moves to an existing one), then "
|
|
583
|
+
"re-run /wrapup",
|
|
584
|
+
)
|
|
585
|
+
branch = symbolic.stdout.strip().removeprefix("refs/heads/")
|
|
586
|
+
if git(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], cwd=cwd).returncode != 0:
|
|
587
|
+
raise Stop(
|
|
588
|
+
step,
|
|
589
|
+
f"unborn branch {branch} — it has no commits yet, so there is nothing to land",
|
|
590
|
+
f"{cwd}: make the first commit (wrapup's `commit` step does it), then "
|
|
591
|
+
"re-run /wrapup",
|
|
592
|
+
)
|
|
593
|
+
return branch
|
|
615
594
|
|
|
616
595
|
|
|
617
|
-
def
|
|
618
|
-
"""
|
|
619
|
-
|
|
596
|
+
def assess_teardown(wt: str, main_tree: str):
|
|
597
|
+
"""Classify the worktree's current state — the only teardown authority."""
|
|
598
|
+
classify = load_teardown_classifier()
|
|
620
599
|
try:
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
600
|
+
return classify.assess(Path(wt), Path(main_tree))
|
|
601
|
+
except classify.ClassificationError as error:
|
|
602
|
+
raise Stop("4 teardown", "teardown cannot be classified", str(error)) from error
|
|
625
603
|
|
|
626
604
|
|
|
627
|
-
def
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
*,
|
|
631
|
-
expected_baseline_digest: str | None = None,
|
|
632
|
-
) -> tuple[str, ...]:
|
|
633
|
-
core = load_worktree_cleanup_core()
|
|
605
|
+
def remove_teardown_scratch(assessment) -> list[str]:
|
|
606
|
+
"""Delete exactly what the given assessment cleared, re-checked entry by entry."""
|
|
607
|
+
classify = load_teardown_classifier()
|
|
634
608
|
try:
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
Path(wt),
|
|
639
|
-
expected_baseline_digest=expected_baseline_digest,
|
|
640
|
-
)
|
|
641
|
-
except core.LifecycleError as error:
|
|
642
|
-
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
609
|
+
return list(classify.remove_scratch(assessment))
|
|
610
|
+
except classify.ClassificationError as error:
|
|
611
|
+
raise Stop("4 teardown", "teardown is blocked", str(error)) from error
|
|
643
612
|
|
|
644
613
|
|
|
645
|
-
def
|
|
646
|
-
|
|
647
|
-
main_tree: str,
|
|
648
|
-
*,
|
|
649
|
-
verified_scratch_files: tuple[str, ...] = (),
|
|
650
|
-
verified_scratch_evidence: tuple[dict, ...] = (),
|
|
651
|
-
profile_loader=None,
|
|
652
|
-
):
|
|
653
|
-
core = load_worktree_cleanup_core()
|
|
654
|
-
# Resolved at call time so the module attribute stays the single seam.
|
|
655
|
-
loader = profile_loader or load_canonical_landing_profile
|
|
656
|
-
try:
|
|
657
|
-
profile = loader(core, wt, main_tree)
|
|
658
|
-
kwargs = {"merge_target": "origin/main"}
|
|
659
|
-
paths = (
|
|
660
|
-
tuple(item["path"] for item in verified_scratch_evidence)
|
|
661
|
-
or verified_scratch_files
|
|
662
|
-
)
|
|
663
|
-
if paths:
|
|
664
|
-
kwargs["verified_scratch_files"] = paths
|
|
665
|
-
if verified_scratch_evidence:
|
|
666
|
-
kwargs["verified_scratch_evidence"] = verified_scratch_evidence
|
|
667
|
-
assessment = core.cleanup_assessment(
|
|
668
|
-
profile, Path(main_tree), Path(wt), **kwargs
|
|
669
|
-
)
|
|
670
|
-
except core.LifecycleError as error:
|
|
671
|
-
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
672
|
-
if assessment.reasons:
|
|
673
|
-
raise Stop(
|
|
674
|
-
"cleanup",
|
|
675
|
-
"shared cleanup guard refused worktree removal",
|
|
676
|
-
"; ".join(assessment.reasons),
|
|
677
|
-
)
|
|
678
|
-
return assessment
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
def remove_verified_worktree_scratch(
|
|
682
|
-
wt: str,
|
|
683
|
-
main_tree: str,
|
|
684
|
-
assessment,
|
|
685
|
-
*,
|
|
686
|
-
verified_scratch_files: tuple[str, ...] = (),
|
|
687
|
-
verified_scratch_evidence: tuple[dict, ...] = (),
|
|
688
|
-
profile_loader=None,
|
|
689
|
-
):
|
|
690
|
-
"""Re-verify and delete only assessed regular scratch files."""
|
|
691
|
-
core = load_worktree_cleanup_core()
|
|
692
|
-
loader = profile_loader or load_canonical_landing_profile
|
|
693
|
-
try:
|
|
694
|
-
profile = loader(core, wt, main_tree)
|
|
695
|
-
evidence = verified_scratch_evidence
|
|
696
|
-
paths = tuple(item["path"] for item in evidence) or verified_scratch_files
|
|
697
|
-
latest = core.cleanup_assessment(
|
|
698
|
-
profile,
|
|
699
|
-
Path(main_tree),
|
|
700
|
-
Path(wt),
|
|
701
|
-
merge_target="origin/main",
|
|
702
|
-
verified_scratch_files=paths,
|
|
703
|
-
verified_scratch_evidence=evidence,
|
|
704
|
-
)
|
|
705
|
-
if latest.reasons:
|
|
706
|
-
raise core.LifecycleError(
|
|
707
|
-
"cleanup changed before removal: " + "; ".join(latest.reasons)
|
|
708
|
-
)
|
|
709
|
-
if (
|
|
710
|
-
latest.branch != assessment.branch
|
|
711
|
-
or latest.scratch_files != assessment.scratch_files
|
|
712
|
-
or latest.assumptions != assessment.assumptions
|
|
713
|
-
or latest.root_device != assessment.root_device
|
|
714
|
-
or latest.root_inode != assessment.root_inode
|
|
715
|
-
or latest.scratch_evidence != assessment.scratch_evidence
|
|
716
|
-
):
|
|
717
|
-
raise core.LifecycleError(
|
|
718
|
-
"cleanup changed before removal: inventory no longer matches preview"
|
|
719
|
-
)
|
|
720
|
-
with core.verified_worktree_root(
|
|
721
|
-
latest.worktree,
|
|
722
|
-
latest.root_device,
|
|
723
|
-
latest.root_inode,
|
|
724
|
-
) as root_descriptor:
|
|
725
|
-
core.remove_authorized_scratch(
|
|
726
|
-
profile,
|
|
727
|
-
root_descriptor,
|
|
728
|
-
latest.scratch_files,
|
|
729
|
-
latest.scratch_evidence,
|
|
730
|
-
)
|
|
731
|
-
final = core.cleanup_assessment(
|
|
732
|
-
profile,
|
|
733
|
-
Path(main_tree),
|
|
734
|
-
Path(wt),
|
|
735
|
-
merge_target="origin/main",
|
|
736
|
-
)
|
|
737
|
-
if final.reasons:
|
|
738
|
-
raise core.LifecycleError(
|
|
739
|
-
"cleanup changed after scratch removal: " + "; ".join(final.reasons)
|
|
740
|
-
)
|
|
741
|
-
return final
|
|
742
|
-
except core.LifecycleError as error:
|
|
743
|
-
raise Stop("cleanup", f"shared cleanup guard failed: {error}") from error
|
|
614
|
+
def teardown_report(assessment) -> str:
|
|
615
|
+
return load_teardown_classifier().render_report(assessment)
|
|
744
616
|
|
|
745
617
|
|
|
746
618
|
# ---------- drift log (ANNAHMEN.md) ----------
|
|
@@ -827,33 +699,102 @@ def self_ancestry() -> set[int]:
|
|
|
827
699
|
return pids
|
|
828
700
|
|
|
829
701
|
|
|
702
|
+
def process_identity(pid: int) -> tuple[str, str] | None:
|
|
703
|
+
"""The pair that tells one PID apart from its recycled reuse: start time + cwd."""
|
|
704
|
+
try:
|
|
705
|
+
with open(f"/proc/{pid}/stat", "rb") as handle:
|
|
706
|
+
fields = handle.read().rsplit(b")", 1)[1].split()
|
|
707
|
+
started = fields[19].decode()
|
|
708
|
+
return started, os.path.realpath(f"/proc/{pid}/cwd")
|
|
709
|
+
except (OSError, IndexError):
|
|
710
|
+
return None
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def signal_listener(port: str, pid: int, identity: tuple[str, str]) -> str:
|
|
714
|
+
"""SIGKILL one attributed listener without ever hitting a recycled PID.
|
|
715
|
+
|
|
716
|
+
`pidfd_open` (Linux 5.3+) pins the process, so the signal cannot land on a
|
|
717
|
+
PID some other process inherited between the lookup and the kill. Without
|
|
718
|
+
it the identity is re-read immediately before signalling — a narrower but
|
|
719
|
+
honest guarantee, and still a refusal rather than a blind kill.
|
|
720
|
+
"""
|
|
721
|
+
opener = getattr(os, "pidfd_open", None)
|
|
722
|
+
sender = getattr(signal, "pidfd_send_signal", None)
|
|
723
|
+
if opener is None or sender is None:
|
|
724
|
+
if process_identity(pid) != identity:
|
|
725
|
+
raise Stop(
|
|
726
|
+
"2 process-kill",
|
|
727
|
+
f"pid {pid} changed identity before it could be signalled",
|
|
728
|
+
"the PID was recycled; re-run land once the ports are quiet",
|
|
729
|
+
)
|
|
730
|
+
try:
|
|
731
|
+
os.kill(pid, signal.SIGKILL)
|
|
732
|
+
except ProcessLookupError:
|
|
733
|
+
return ""
|
|
734
|
+
return f"port {port} pid {pid} (recheck)"
|
|
735
|
+
try:
|
|
736
|
+
descriptor = opener(pid)
|
|
737
|
+
except ProcessLookupError:
|
|
738
|
+
return ""
|
|
739
|
+
except OSError as error:
|
|
740
|
+
raise Stop(
|
|
741
|
+
"2 process-kill", f"cannot pin pid {pid} before signalling it", str(error)
|
|
742
|
+
) from error
|
|
743
|
+
try:
|
|
744
|
+
if process_identity(pid) != identity:
|
|
745
|
+
raise Stop(
|
|
746
|
+
"2 process-kill",
|
|
747
|
+
f"pid {pid} changed identity before it could be signalled",
|
|
748
|
+
"the PID was recycled; re-run land once the ports are quiet",
|
|
749
|
+
)
|
|
750
|
+
signal.pidfd_send_signal(descriptor, signal.SIGKILL)
|
|
751
|
+
except ProcessLookupError:
|
|
752
|
+
return ""
|
|
753
|
+
finally:
|
|
754
|
+
os.close(descriptor)
|
|
755
|
+
return f"port {port} pid {pid} (pidfd)"
|
|
756
|
+
|
|
757
|
+
|
|
830
758
|
def kill_worktree_processes(wt: str) -> list[str]:
|
|
831
|
-
"""
|
|
759
|
+
"""Signal only the listeners on this worktree's own declared `.dev-ports`.
|
|
832
760
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
761
|
+
Never signal on doubt. A listener whose working directory is not inside
|
|
762
|
+
this worktree — or whose identity cannot be read at all — is a named STOP,
|
|
763
|
+
not a kill: matching foreign processes by command name across the whole
|
|
764
|
+
machine is how a wrapup run takes down someone else's server. Own shell
|
|
765
|
+
ancestry stays excluded (the self-kill trap).
|
|
766
|
+
"""
|
|
836
767
|
dev_ports = Path(wt) / ".dev-ports"
|
|
837
|
-
if dev_ports.is_file():
|
|
838
|
-
|
|
768
|
+
if not dev_ports.is_file():
|
|
769
|
+
return []
|
|
770
|
+
ports = parse_dev_ports(dev_ports.read_text())
|
|
771
|
+
protected = self_ancestry()
|
|
772
|
+
worktree_root = os.path.realpath(wt)
|
|
773
|
+
killed, unattributed = [], []
|
|
839
774
|
for port in ports:
|
|
840
|
-
|
|
841
|
-
for
|
|
842
|
-
if
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
775
|
+
listing = run(["lsof", "-ti", f":{port}"])
|
|
776
|
+
for raw in listing.stdout.split():
|
|
777
|
+
if not raw.isdigit() or int(raw) in protected:
|
|
778
|
+
continue
|
|
779
|
+
pid = int(raw)
|
|
780
|
+
identity = process_identity(pid)
|
|
781
|
+
if identity is None:
|
|
782
|
+
unattributed.append(f"port {port} pid {pid}: identity unreadable")
|
|
783
|
+
continue
|
|
784
|
+
cwd = identity[1]
|
|
785
|
+
if cwd != worktree_root and not cwd.startswith(worktree_root + os.sep):
|
|
786
|
+
unattributed.append(f"port {port} pid {pid}: cwd {cwd}")
|
|
787
|
+
continue
|
|
788
|
+
entry = signal_listener(port, pid, identity)
|
|
789
|
+
if entry:
|
|
790
|
+
killed.append(entry)
|
|
791
|
+
if unattributed:
|
|
792
|
+
raise Stop(
|
|
793
|
+
"2 process-kill",
|
|
794
|
+
"a process on this worktree's declared ports cannot be attributed to it "
|
|
795
|
+
"— never signal on doubt",
|
|
796
|
+
"; ".join(unattributed),
|
|
797
|
+
)
|
|
857
798
|
return killed
|
|
858
799
|
|
|
859
800
|
|
|
@@ -874,8 +815,13 @@ def anchor_complete_from_body(body: str) -> bool | None:
|
|
|
874
815
|
|
|
875
816
|
# ---------- remote sweep set ----------
|
|
876
817
|
|
|
877
|
-
def stale_remote_set(
|
|
878
|
-
|
|
818
|
+
def stale_remote_set(
|
|
819
|
+
merged: set[str],
|
|
820
|
+
remotes: set[str],
|
|
821
|
+
open_prs: set[str],
|
|
822
|
+
protected: set[str],
|
|
823
|
+
) -> list[str]:
|
|
824
|
+
return sorted((merged & remotes) - open_prs - protected)
|
|
879
825
|
|
|
880
826
|
|
|
881
827
|
# ---------- subcommands ----------
|
|
@@ -883,11 +829,18 @@ def stale_remote_set(merged: set[str], remotes: set[str], open_prs: set[str]) ->
|
|
|
883
829
|
def cmd_preflight(args) -> dict:
|
|
884
830
|
cwd = os.getcwd()
|
|
885
831
|
main_tree, _ = worktree_map(cwd)
|
|
886
|
-
branch = git(["branch", "--show-current"], check=True).stdout.strip()
|
|
887
832
|
wt = git(["rev-parse", "--show-toplevel"], check=True).stdout.strip()
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
833
|
+
branch = require_landable_head("preflight", cwd)
|
|
834
|
+
_, protected = branch_policy(wt)
|
|
835
|
+
# No naming or location gate: any born, attached worktree is first-class,
|
|
836
|
+
# including one an external tool created under a foreign name and path.
|
|
837
|
+
# Only the main checkout and a protected branch are refused.
|
|
838
|
+
if os.path.realpath(wt) == os.path.realpath(main_tree):
|
|
839
|
+
raise Stop("preflight", "run /wrapup in the worktree it should land and tear down",
|
|
840
|
+
f"wt={wt} is the main checkout — /wrapup never tears that down")
|
|
841
|
+
if branch in protected:
|
|
842
|
+
raise Stop("preflight", f"{branch} is a protected branch",
|
|
843
|
+
f"wt={wt} — /wrapup lands a slice branch, never the integration branch")
|
|
891
844
|
|
|
892
845
|
porcelain = git(["status", "--porcelain"], check=True).stdout
|
|
893
846
|
profile = load_profile()
|
|
@@ -941,9 +894,16 @@ def cmd_preflight(args) -> dict:
|
|
|
941
894
|
|
|
942
895
|
|
|
943
896
|
def cmd_commit(args) -> dict:
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
897
|
+
cwd = os.getcwd()
|
|
898
|
+
root = git(["rev-parse", "--show-toplevel"], check=True).stdout.strip()
|
|
899
|
+
_, protected = branch_policy(root)
|
|
900
|
+
symbolic = git(["symbolic-ref", "--quiet", "HEAD"], cwd=cwd)
|
|
901
|
+
branch = symbolic.stdout.strip().removeprefix("refs/heads/") if symbolic.returncode == 0 else ""
|
|
902
|
+
if not branch:
|
|
903
|
+
raise Stop("commit", "detached HEAD — refusing to commit onto no branch",
|
|
904
|
+
f"{root}: attach a branch here (`git switch -c <branch>`), then re-run")
|
|
905
|
+
if branch in protected:
|
|
906
|
+
raise Stop("commit", f"on protected branch {branch} — refusing to commit")
|
|
947
907
|
porcelain = git(["status", "--porcelain"], check=True).stdout
|
|
948
908
|
if not porcelain.strip():
|
|
949
909
|
return {"committed": False, "reason": "clean tree — nothing to commit"}
|
|
@@ -968,107 +928,901 @@ def cmd_commit(args) -> dict:
|
|
|
968
928
|
return {"committed": True, "sha": sha, "allowed_matches": bool(hits)}
|
|
969
929
|
|
|
970
930
|
|
|
971
|
-
|
|
972
|
-
|
|
931
|
+
# ---------- Content route (durable content, no worktree) ----------
|
|
932
|
+
#
|
|
933
|
+
# A planning session has no worktree and no slice. What it produced — an ADR, a
|
|
934
|
+
# glossary update, a research note — sits dirty in the main checkout on the
|
|
935
|
+
# protected branch, and this is its landing door. Inference proposes; the user's
|
|
936
|
+
# explicit, hash-carrying claim decides; every claimed path is re-read
|
|
937
|
+
# immediately before staging; everything else in that tree is a bystander and
|
|
938
|
+
# comes out untouched. There is no teardown half here: the route lands and
|
|
939
|
+
# stops. It is invoked, never chained — no other route falls back into it.
|
|
940
|
+
|
|
941
|
+
CONTENT_CLAIM_STEP = "content claim"
|
|
942
|
+
CONTENT_STEP = "content commit"
|
|
943
|
+
CONTENT_RETURN_STEP = "content return"
|
|
944
|
+
CONTENT_CANDIDATE_LIMIT = 200
|
|
945
|
+
CONTENT_TOP_DIRECTORY_LIMIT = 5
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
class Claimed(NamedTuple):
|
|
949
|
+
"""One path with the blob identity it carried when it was read."""
|
|
950
|
+
|
|
951
|
+
path: str
|
|
952
|
+
oid: str
|
|
953
|
+
mode: str
|
|
954
|
+
|
|
955
|
+
def as_record(self) -> dict:
|
|
956
|
+
return {"path": self.path, "oid": self.oid, "mode": self.mode}
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def content_context(step: str) -> tuple[str, str, tuple[str, ...]]:
|
|
960
|
+
"""The route's three preconditions, each a named refusal.
|
|
961
|
+
|
|
962
|
+
It lands what a planning session left in the main checkout, so a worktree
|
|
963
|
+
and an unprotected branch both belong to the ordinary route, and a detached
|
|
964
|
+
or unborn HEAD is a state no landing can repair for you.
|
|
965
|
+
"""
|
|
966
|
+
main_tree, _ = worktree_map()
|
|
967
|
+
here = git(["rev-parse", "--show-toplevel"], check=True).stdout.strip()
|
|
968
|
+
if os.path.realpath(here) != os.path.realpath(main_tree):
|
|
969
|
+
raise Stop(
|
|
970
|
+
step, "the Content route lands from the main checkout",
|
|
971
|
+
f"cwd={here} is a worktree — a slice lands through preflight/commit/land",
|
|
972
|
+
)
|
|
973
|
+
branch = require_landable_head(step, here)
|
|
974
|
+
_, protected = branch_policy(main_tree)
|
|
975
|
+
if branch not in protected:
|
|
976
|
+
raise Stop(
|
|
977
|
+
step, f"{branch} is not a protected branch",
|
|
978
|
+
"the Content route lands what a planning session left on the protected "
|
|
979
|
+
"branch; this branch lands through the ordinary route",
|
|
980
|
+
)
|
|
981
|
+
return main_tree, branch, protected
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _zsplit(payload: str) -> list[str]:
|
|
985
|
+
return [part for part in payload.split("\0") if part]
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def content_sources(main_checkout: str, step: str) -> tuple[list[str], list[str]]:
|
|
989
|
+
"""Split the dirty tree into claimable paths and named unclaimable ones.
|
|
990
|
+
|
|
991
|
+
Teardown classification lives in worktree-lifecycle/classify.py and stays
|
|
992
|
+
there. This asks a different question — what durable content is dirty — and
|
|
993
|
+
reads the two plumbing lists that answer it directly, so the kit keeps
|
|
994
|
+
exactly one porcelain-status parser and this is not a second one.
|
|
995
|
+
"""
|
|
996
|
+
paths, unclaimable = [], []
|
|
997
|
+
fields = iter(_zsplit(
|
|
998
|
+
git(["diff", "--name-status", "-z", "HEAD"], cwd=main_checkout, check=True).stdout
|
|
999
|
+
))
|
|
1000
|
+
for letter in fields:
|
|
1001
|
+
path = next(fields, "")
|
|
1002
|
+
if not path:
|
|
1003
|
+
raise Stop(step, "malformed git diff record", letter)
|
|
1004
|
+
if letter[:1] in {"R", "C"}:
|
|
1005
|
+
renamed = next(fields, "")
|
|
1006
|
+
unclaimable.append(
|
|
1007
|
+
f"{path} → {renamed} — a staged rename lands through the ordinary route"
|
|
1008
|
+
)
|
|
1009
|
+
elif letter[:1] == "D":
|
|
1010
|
+
unclaimable.append(f"{path} — a deletion is not durable content this route lands")
|
|
1011
|
+
else:
|
|
1012
|
+
paths.append(path)
|
|
1013
|
+
paths.extend(_zsplit(
|
|
1014
|
+
git(["ls-files", "--others", "--exclude-standard", "-z"],
|
|
1015
|
+
cwd=main_checkout, check=True).stdout
|
|
1016
|
+
))
|
|
1017
|
+
return paths, unclaimable
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def content_oid(main_checkout: str, path: str, *, write: bool = False) -> str:
|
|
1021
|
+
"""The blob OID git itself would record for this path, filters included."""
|
|
1022
|
+
args = ["hash-object", *(["-w"] if write else []), "--", path]
|
|
1023
|
+
result = git(args, cwd=main_checkout)
|
|
1024
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _blob_mode(metadata) -> str:
|
|
1028
|
+
return "100755" if metadata.st_mode & 0o111 else "100644"
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _content_candidate(main_checkout: str, path: str) -> tuple[Claimed | None, str]:
|
|
1032
|
+
"""Judge one dirty path: a claimable candidate, or the reason it is not."""
|
|
1033
|
+
if ENV_PATH_RE.search(path):
|
|
1034
|
+
return None, "a .env* file never lands through any route"
|
|
1035
|
+
try:
|
|
1036
|
+
metadata = os.lstat(Path(main_checkout) / path)
|
|
1037
|
+
except OSError as error:
|
|
1038
|
+
return None, f"cannot be read ({error.strerror})"
|
|
1039
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
1040
|
+
return None, "a symlink is not durable content this route lands"
|
|
1041
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
1042
|
+
return None, "not a regular file"
|
|
1043
|
+
oid = content_oid(main_checkout, path)
|
|
1044
|
+
if not oid:
|
|
1045
|
+
return None, "git cannot hash it"
|
|
1046
|
+
return Claimed(path, oid, _blob_mode(metadata)), ""
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _content_summary(paths: list[str]) -> str:
|
|
1050
|
+
"""Bounded: the count plus the top directories, never a path dump."""
|
|
1051
|
+
counts = Counter(str(PurePosixPath(path).parent) for path in paths)
|
|
1052
|
+
top = sorted(counts.items(), key=lambda pair: (-pair[1], pair[0]))
|
|
1053
|
+
listed = ", ".join(
|
|
1054
|
+
f"{'./' if name == '.' else name + '/'} ({count} files)"
|
|
1055
|
+
for name, count in top[:CONTENT_TOP_DIRECTORY_LIMIT]
|
|
1056
|
+
)
|
|
1057
|
+
return (
|
|
1058
|
+
f"{len(paths)} dirty paths in {len(counts)} directories: {listed} — ignore or "
|
|
1059
|
+
"remove the bulk, then run the claim again"
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def content_candidates(main_checkout: str, step: str) -> tuple[list[Claimed], list[str]]:
|
|
1064
|
+
"""Infer the durable content a session left dirty. Inference only proposes."""
|
|
1065
|
+
paths, unclaimable = content_sources(main_checkout, step)
|
|
1066
|
+
if len(paths) > CONTENT_CANDIDATE_LIMIT:
|
|
1067
|
+
raise Stop(
|
|
1068
|
+
step, "the dirty tree is too large to infer durable content from",
|
|
1069
|
+
_content_summary(paths),
|
|
1070
|
+
)
|
|
1071
|
+
candidates = []
|
|
1072
|
+
for path in sorted(paths):
|
|
1073
|
+
entry, problem = _content_candidate(main_checkout, path)
|
|
1074
|
+
if entry is None:
|
|
1075
|
+
unclaimable.append(f"{path} — {problem}")
|
|
1076
|
+
else:
|
|
1077
|
+
candidates.append(entry)
|
|
1078
|
+
return candidates, sorted(unclaimable)
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def load_claim(path: str) -> list[Claimed]:
|
|
1082
|
+
"""Read the confirmed claim: concrete paths, each with the hash it carried.
|
|
1083
|
+
|
|
1084
|
+
The recorded hash is what makes the claim verifiable, so a record without
|
|
1085
|
+
one is a refusal — re-hashing at staging time would confirm nothing. A
|
|
1086
|
+
record's `mode` travels for a lossless round-trip of the claim report and
|
|
1087
|
+
is never the identity: content is, and the file lands with the mode it has.
|
|
1088
|
+
"""
|
|
1089
|
+
try:
|
|
1090
|
+
document = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
1091
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
1092
|
+
raise Stop(CONTENT_STEP, "the claim file cannot be read", str(error)) from error
|
|
1093
|
+
records = document.get("claimed") if isinstance(document, dict) else document
|
|
1094
|
+
if not isinstance(records, list) or not records:
|
|
1095
|
+
raise Stop(CONTENT_STEP, "the claim names no path",
|
|
1096
|
+
'expected {"claimed": [{"path": ..., "oid": ...}, ...]}')
|
|
1097
|
+
claim = []
|
|
1098
|
+
for record in records:
|
|
1099
|
+
path_value = record.get("path") if isinstance(record, dict) else None
|
|
1100
|
+
oid_value = record.get("oid") if isinstance(record, dict) else None
|
|
1101
|
+
if not isinstance(path_value, str) or not isinstance(oid_value, str) or not oid_value:
|
|
1102
|
+
raise Stop(
|
|
1103
|
+
CONTENT_STEP, "every claimed path needs the hash it was claimed with",
|
|
1104
|
+
json.dumps(record, ensure_ascii=False)[:200],
|
|
1105
|
+
)
|
|
1106
|
+
claim.append(Claimed(path_value, oid_value, str(record.get("mode") or "")))
|
|
1107
|
+
return claim
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _is_ignored(main_checkout: str, path: str) -> bool:
|
|
1111
|
+
"""Git's own exclude sources decide what is Scratch."""
|
|
1112
|
+
return git(["check-ignore", "-q", "--", path], cwd=main_checkout).returncode == 0
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def verify_claim(main_checkout: str, claim: list[Claimed], candidates: list[Claimed]):
|
|
1116
|
+
"""Match the claim against the tree as it is right now.
|
|
1117
|
+
|
|
1118
|
+
A hard block stops the whole route. A path whose content moved since the
|
|
1119
|
+
claim is dropped and named instead — a claim confirms exactly what the user
|
|
1120
|
+
read, so a changed file was never confirmed, and the rest still lands.
|
|
1121
|
+
"""
|
|
1122
|
+
inferred = {entry.path: entry for entry in candidates}
|
|
1123
|
+
survivors, dropped, seen = [], [], set()
|
|
1124
|
+
for entry in claim:
|
|
1125
|
+
if ENV_PATH_RE.search(entry.path):
|
|
1126
|
+
raise Stop(CONTENT_STEP, "a .env* path was claimed — never commit one",
|
|
1127
|
+
entry.path)
|
|
1128
|
+
if entry.path in seen:
|
|
1129
|
+
continue
|
|
1130
|
+
seen.add(entry.path)
|
|
1131
|
+
current = inferred.get(entry.path)
|
|
1132
|
+
if current is None:
|
|
1133
|
+
if _is_ignored(main_checkout, entry.path):
|
|
1134
|
+
raise Stop(
|
|
1135
|
+
CONTENT_STEP,
|
|
1136
|
+
"an ignored path was claimed — ignored content is scratch, never "
|
|
1137
|
+
"durable content",
|
|
1138
|
+
entry.path,
|
|
1139
|
+
)
|
|
1140
|
+
dropped.append(f"{entry.path} — no longer dirty durable content here")
|
|
1141
|
+
elif current.oid != entry.oid:
|
|
1142
|
+
dropped.append(
|
|
1143
|
+
f"{entry.path} — changed between the claim and staging "
|
|
1144
|
+
f"({entry.oid[:7]} → {current.oid[:7]})"
|
|
1145
|
+
)
|
|
1146
|
+
else:
|
|
1147
|
+
survivors.append(current)
|
|
1148
|
+
if not survivors:
|
|
1149
|
+
raise Stop(CONTENT_STEP, "nothing left to land — every claimed path dropped",
|
|
1150
|
+
"\n".join(dropped))
|
|
1151
|
+
return survivors, dropped
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def stage_content(main_checkout: str, claimed: list[Claimed]) -> str:
|
|
1155
|
+
"""Write exactly the claimed paths into a private index; return the tree.
|
|
1156
|
+
|
|
1157
|
+
Neither the repository index nor the working tree is touched here: the index
|
|
1158
|
+
is a throwaway file seeded from HEAD, and each entry is written by the blob
|
|
1159
|
+
OID the claim was verified against. No pathspec walk exists to widen, so
|
|
1160
|
+
`git add -A`, `git add .` and `git commit -a` are unreachable from this
|
|
1161
|
+
route rather than merely unused.
|
|
1162
|
+
"""
|
|
1163
|
+
with tempfile.TemporaryDirectory() as scratch:
|
|
1164
|
+
environment = dict(os.environ, GIT_INDEX_FILE=str(Path(scratch) / "index"))
|
|
1165
|
+
git(["read-tree", "HEAD"], cwd=main_checkout, check=True, env=environment)
|
|
1166
|
+
for entry in claimed:
|
|
1167
|
+
written = content_oid(main_checkout, entry.path, write=True)
|
|
1168
|
+
if written != entry.oid:
|
|
1169
|
+
raise Stop(
|
|
1170
|
+
CONTENT_STEP, f"{entry.path} changed while it was being staged",
|
|
1171
|
+
f"{entry.oid[:7]} → {written[:7] or 'unreadable'}; run the claim again",
|
|
1172
|
+
)
|
|
1173
|
+
git(["update-index", "--add", "--cacheinfo",
|
|
1174
|
+
f"{entry.mode},{entry.oid},{entry.path}"],
|
|
1175
|
+
cwd=main_checkout, check=True, env=environment)
|
|
1176
|
+
return git(["write-tree"], cwd=main_checkout, check=True,
|
|
1177
|
+
env=environment).stdout.strip()
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def verify_staged_tree(main_checkout: str, tree: str, claimed: list[Claimed]) -> None:
|
|
1181
|
+
"""Assert the staged tree carries exactly the claim — by name and by OID."""
|
|
1182
|
+
changed = sorted(_zsplit(
|
|
1183
|
+
git(["diff-tree", "-r", "--name-only", "-z", "HEAD", tree],
|
|
1184
|
+
cwd=main_checkout, check=True).stdout
|
|
1185
|
+
))
|
|
1186
|
+
expected = sorted(entry.path for entry in claimed)
|
|
1187
|
+
if changed != expected:
|
|
1188
|
+
raise Stop(CONTENT_STEP, "the staged tree is not exactly the claim",
|
|
1189
|
+
f"staged {changed} against the claim {expected}")
|
|
1190
|
+
for entry in claimed:
|
|
1191
|
+
listed = git(["ls-tree", "--full-name", tree, "--", entry.path],
|
|
1192
|
+
cwd=main_checkout, check=True).stdout.split()
|
|
1193
|
+
if listed[:3] != [entry.mode, "blob", entry.oid]:
|
|
1194
|
+
raise Stop(CONTENT_STEP, f"{entry.path} is staged as a different object",
|
|
1195
|
+
" ".join(listed[:3]) or "absent from the staged tree")
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def content_secret_gate(main_checkout: str, tree: str, allow_matches: bool) -> list[str]:
|
|
1199
|
+
"""The ordinary secret scan, run on exactly the diff this route will commit."""
|
|
1200
|
+
patch = git(["diff-tree", "-p", "HEAD", tree], cwd=main_checkout, check=True).stdout
|
|
1201
|
+
hits = secret_hits_in(patch)
|
|
1202
|
+
if hits and not allow_matches:
|
|
1203
|
+
raise Stop(
|
|
1204
|
+
CONTENT_STEP,
|
|
1205
|
+
"possible secrets in the claimed content — review; false positive → re-run "
|
|
1206
|
+
"with --allow-matches, real secret → resolve first",
|
|
1207
|
+
"\n".join(hits[:40]),
|
|
1208
|
+
)
|
|
1209
|
+
return hits
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def content_branch_name(main_checkout: str, slug: str, branch_type: str) -> str:
|
|
1213
|
+
"""Render the issue-less branch from the profile's content template."""
|
|
1214
|
+
module = load_lifecycle_profile_module()
|
|
1215
|
+
template = (lifecycle_settings(main_checkout).get("contentBranchTemplate")
|
|
1216
|
+
or module.DEFAULT_CONTENT_BRANCH_TEMPLATE)
|
|
1217
|
+
try:
|
|
1218
|
+
branch = module.render_content_branch(template, slug, branch_type)
|
|
1219
|
+
except module.LifecycleError as error:
|
|
1220
|
+
raise Stop(CONTENT_STEP, "the content branch template cannot be rendered",
|
|
1221
|
+
str(error)) from error
|
|
1222
|
+
if git(["check-ref-format", "--branch", branch], cwd=main_checkout).returncode != 0:
|
|
1223
|
+
raise Stop(CONTENT_STEP, f"{branch} is not a valid branch name",
|
|
1224
|
+
"choose another slug or branch type")
|
|
1225
|
+
return branch
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def check_content_collision(main_checkout: str, branch: str) -> list[str]:
|
|
1229
|
+
"""A branch that exists anywhere is a collision — never reuse, never overwrite."""
|
|
1230
|
+
advice = "the Content route never reuses or overwrites a branch — choose another slug"
|
|
1231
|
+
if branch_tip(main_checkout, branch):
|
|
1232
|
+
raise Stop(CONTENT_STEP, f"branch {branch} already exists locally", advice)
|
|
1233
|
+
listed = git(["ls-remote", "--heads", "origin", branch], cwd=main_checkout)
|
|
1234
|
+
if listed.returncode != 0:
|
|
1235
|
+
return [
|
|
1236
|
+
f"the remote could not be queried, so the collision check for {branch} was "
|
|
1237
|
+
f"local only: {sanitize_external_detail(listed.stderr or listed.stdout)}"
|
|
1238
|
+
]
|
|
1239
|
+
if listed.stdout.strip():
|
|
1240
|
+
raise Stop(CONTENT_STEP, f"branch {branch} already exists on the remote", advice)
|
|
1241
|
+
return []
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def cut_content_branch(main_checkout: str, branch: str, tree: str, message: str,
|
|
1245
|
+
claimed: list[Claimed]) -> str:
|
|
1246
|
+
"""Cut the branch onto the verified tree, refreshing only the claimed paths."""
|
|
1247
|
+
paths = [entry.path for entry in claimed]
|
|
1248
|
+
parent = git(["rev-parse", "HEAD"], cwd=main_checkout, check=True).stdout.strip()
|
|
1249
|
+
commit = git(["commit-tree", tree, "-p", parent, "-m", message],
|
|
1250
|
+
cwd=main_checkout, check=True).stdout.strip()
|
|
1251
|
+
git(["switch", "-c", branch], cwd=main_checkout, check=True)
|
|
1252
|
+
git(["update-ref", "-m", f"wrapup content route: {branch}", "HEAD", commit, parent],
|
|
1253
|
+
cwd=main_checkout, check=True)
|
|
1254
|
+
git(["reset", "-q", "HEAD", "--", *paths], cwd=main_checkout, check=True)
|
|
1255
|
+
if git(["rev-parse", "HEAD"], cwd=main_checkout, check=True).stdout.strip() != commit:
|
|
1256
|
+
raise Stop(CONTENT_STEP, f"{branch} does not carry the verified commit", commit)
|
|
1257
|
+
if git(["diff", "--quiet", "HEAD", "--", *paths], cwd=main_checkout).returncode != 0:
|
|
1258
|
+
raise Stop(CONTENT_STEP, "the claimed paths are still dirty after the commit",
|
|
1259
|
+
f"{branch} at {commit[:7]} — inspect before running the claim again")
|
|
1260
|
+
return commit
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def return_to_protected(main_checkout: str, protected_branch: str, branch: str,
|
|
1264
|
+
commit: str) -> str:
|
|
1265
|
+
"""Return the checkout to the protected branch, or stop naming the blocker."""
|
|
1266
|
+
result = git(["switch", protected_branch], cwd=main_checkout)
|
|
1267
|
+
if result.returncode != 0:
|
|
1268
|
+
raise Stop(
|
|
1269
|
+
CONTENT_RETURN_STEP,
|
|
1270
|
+
f"cannot return the main checkout to {protected_branch} — nothing was "
|
|
1271
|
+
"forced and nothing was stashed",
|
|
1272
|
+
f"the content is safe on {branch} at {commit[:7]}, and the main checkout is "
|
|
1273
|
+
f"still on {branch}: " + (result.stderr or result.stdout).strip()[-1000:],
|
|
1274
|
+
)
|
|
1275
|
+
return protected_branch
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def content_pr_reference(anchor: str | None, body_file: str | None):
|
|
1279
|
+
"""`Part of` the anchor — a planning session's content never closes one."""
|
|
1280
|
+
warnings: list[str] = []
|
|
1281
|
+
if body_file:
|
|
1282
|
+
try:
|
|
1283
|
+
body = Path(body_file).read_text(encoding="utf-8")
|
|
1284
|
+
except OSError as error:
|
|
1285
|
+
raise Stop(CONTENT_STEP, "the PR body file cannot be read", str(error)) from error
|
|
1286
|
+
targets = declared_close_targets(body)
|
|
1287
|
+
if targets:
|
|
1288
|
+
raise Stop(
|
|
1289
|
+
CONTENT_STEP,
|
|
1290
|
+
"the PR body declares a close keyword — a planning session's content "
|
|
1291
|
+
"never closes its anchor",
|
|
1292
|
+
"declared: " + ", ".join(f"#{target}" for target in targets),
|
|
1293
|
+
)
|
|
1294
|
+
if not anchor:
|
|
1295
|
+
return None, warnings
|
|
1296
|
+
marker = (load_profile().get("prMarkers") or {}).get("partOf")
|
|
1297
|
+
if not marker:
|
|
1298
|
+
warnings.append("the board profile names no partOf marker, so no anchor "
|
|
1299
|
+
"reference was rendered")
|
|
1300
|
+
return None, warnings
|
|
1301
|
+
return f"{marker} #{anchor}", warnings
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def cmd_content_claim(args) -> dict:
|
|
1305
|
+
"""Read-only: propose the durable content, each path with its hash."""
|
|
1306
|
+
main_checkout, branch, _ = content_context(CONTENT_CLAIM_STEP)
|
|
1307
|
+
candidates, unclaimable = content_candidates(main_checkout, CONTENT_CLAIM_STEP)
|
|
1308
|
+
return {
|
|
1309
|
+
"main_checkout": main_checkout,
|
|
1310
|
+
"branch": branch,
|
|
1311
|
+
"candidates": [entry.as_record() for entry in candidates],
|
|
1312
|
+
"unclaimable": unclaimable,
|
|
1313
|
+
"next": "confirm the durable paths with the user, then run content-commit "
|
|
1314
|
+
"--claim-file with exactly those records",
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def cmd_content_commit(args) -> dict:
|
|
1319
|
+
"""Land the confirmed claim: verify, cut, commit, return. No teardown half."""
|
|
1320
|
+
main_checkout, protected_branch, _ = content_context(CONTENT_STEP)
|
|
1321
|
+
claim = load_claim(args.claim_file)
|
|
1322
|
+
candidates, _ = content_candidates(main_checkout, CONTENT_STEP)
|
|
1323
|
+
claimed, dropped = verify_claim(main_checkout, claim, candidates)
|
|
1324
|
+
tree = stage_content(main_checkout, claimed)
|
|
1325
|
+
verify_staged_tree(main_checkout, tree, claimed)
|
|
1326
|
+
hits = content_secret_gate(main_checkout, tree, args.allow_matches)
|
|
1327
|
+
reference, warnings = content_pr_reference(args.anchor, args.body_file)
|
|
1328
|
+
branch = content_branch_name(main_checkout, args.slug, args.type)
|
|
1329
|
+
warnings += check_content_collision(main_checkout, branch)
|
|
1330
|
+
commit = cut_content_branch(main_checkout, branch, tree, args.message, claimed)
|
|
1331
|
+
returned = return_to_protected(main_checkout, protected_branch, branch, commit)
|
|
1332
|
+
return {
|
|
1333
|
+
"branch": branch,
|
|
1334
|
+
"commit": commit,
|
|
1335
|
+
"returned_to": returned,
|
|
1336
|
+
"claimed": [entry.path for entry in claimed],
|
|
1337
|
+
"dropped": dropped,
|
|
1338
|
+
"allowed_matches": bool(hits),
|
|
1339
|
+
"pr_reference": reference,
|
|
1340
|
+
"warnings": warnings,
|
|
1341
|
+
"next": f"open and merge the PR with `land --branch {branch}` — it finds no "
|
|
1342
|
+
"worktree and tears nothing down",
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
# ---------- branch deletion authority ----------
|
|
1347
|
+
|
|
1348
|
+
BRANCH_PR_RECORD = "pr-record"
|
|
1349
|
+
BRANCH_ANCESTRY = "ancestry"
|
|
1350
|
+
BRANCH_RETAINED = "retained"
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
class BranchAuthority(NamedTuple):
|
|
1354
|
+
"""What this repository and the platform allow for exactly this branch tip."""
|
|
1355
|
+
|
|
1356
|
+
branch: str
|
|
1357
|
+
decision: str
|
|
1358
|
+
tip: str
|
|
1359
|
+
reason: str
|
|
1360
|
+
pr: str | None = None
|
|
1361
|
+
degraded: bool = False
|
|
1362
|
+
|
|
1363
|
+
def as_report(self) -> dict:
|
|
1364
|
+
return {
|
|
1365
|
+
"decision": self.decision,
|
|
1366
|
+
"pr": self.pr,
|
|
1367
|
+
"tip": self.tip,
|
|
1368
|
+
"reason": self.reason,
|
|
1369
|
+
"degraded": self.degraded,
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def branch_tip(main_tree: str, branch: str) -> str:
|
|
1374
|
+
"""The branch's current tip OID, or "" when the branch does not exist."""
|
|
1375
|
+
result = git(
|
|
1376
|
+
["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}^{{commit}}"],
|
|
1377
|
+
cwd=main_tree,
|
|
1378
|
+
)
|
|
1379
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def refresh_integration_branch(main_tree: str, integration: str) -> str:
|
|
1383
|
+
"""Fetch the configured branch: a stale remote-tracking ref makes ancestry lie.
|
|
1384
|
+
|
|
1385
|
+
Ancestry is read against a *freshly fetched* protected branch, and a fetch
|
|
1386
|
+
that fails stops rather than guesses.
|
|
1387
|
+
"""
|
|
1388
|
+
tracking = f"refs/remotes/origin/{integration}"
|
|
1389
|
+
result = git(
|
|
1390
|
+
["fetch", "origin", f"+refs/heads/{integration}:{tracking}"], cwd=main_tree
|
|
1391
|
+
)
|
|
1392
|
+
if result.returncode != 0:
|
|
1393
|
+
raise Stop(
|
|
1394
|
+
"5 branch-authority",
|
|
1395
|
+
f"cannot fetch {integration} — a stale ancestry check stops rather than guesses",
|
|
1396
|
+
(result.stderr or result.stdout).strip()[-1000:],
|
|
1397
|
+
)
|
|
1398
|
+
return tracking
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def platform_json(command_runner, command: list[str]):
|
|
1402
|
+
"""Read one read-only platform command's JSON; (None, why) when it cannot be."""
|
|
1403
|
+
try:
|
|
1404
|
+
result = command_runner(command)
|
|
1405
|
+
except OSError as error: # the platform CLI is not installed at all
|
|
1406
|
+
return None, sanitize_external_detail(str(error))
|
|
1407
|
+
if result.returncode != 0:
|
|
1408
|
+
return None, sanitize_external_detail(result.stderr or result.stdout)
|
|
1409
|
+
try:
|
|
1410
|
+
return json.loads(result.stdout), ""
|
|
1411
|
+
except json.JSONDecodeError as error:
|
|
1412
|
+
return None, sanitize_external_detail(str(error))
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
def platform_repository(command_runner) -> tuple[str, str]:
|
|
1416
|
+
payload, error = platform_json(
|
|
1417
|
+
command_runner, ["gh", "repo", "view", "--json", "nameWithOwner"]
|
|
1418
|
+
)
|
|
1419
|
+
name = payload.get("nameWithOwner") if isinstance(payload, dict) else None
|
|
1420
|
+
if not isinstance(name, str) or "/" not in name:
|
|
1421
|
+
return "", error or "the platform did not name this repository"
|
|
1422
|
+
return name, ""
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def head_pull_requests(repository: str, branch: str, command_runner):
|
|
1426
|
+
"""Every pull request ever opened from this head ref, in any state.
|
|
1427
|
+
|
|
1428
|
+
The historical query survives deleting the branch on the remote *and*
|
|
1429
|
+
locally, which is exactly the moment a landing needs it — measured against
|
|
1430
|
+
this platform, not assumed.
|
|
1431
|
+
"""
|
|
1432
|
+
owner = repository.split("/", 1)[0]
|
|
1433
|
+
head = quote(f"{owner}:{branch}", safe=":")
|
|
1434
|
+
path = f"repos/{repository}/pulls?state=all&head={head}&per_page=100"
|
|
1435
|
+
records, error = platform_json(command_runner, ["gh", "api", path])
|
|
1436
|
+
if not isinstance(records, list):
|
|
1437
|
+
return None, error or "the platform returned no pull request list"
|
|
1438
|
+
return records, ""
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def pull_request_by_number(repository: str, number: str, command_runner):
|
|
1442
|
+
"""The one pull request `--pr` names — selected for the check, not exempt."""
|
|
1443
|
+
record, error = platform_json(
|
|
1444
|
+
command_runner, ["gh", "api", f"repos/{repository}/pulls/{quote(number, safe='')}"]
|
|
1445
|
+
)
|
|
1446
|
+
if not isinstance(record, dict):
|
|
1447
|
+
return None, error or f"pull request #{number} cannot be read"
|
|
1448
|
+
return record, ""
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def _full_name(node) -> str:
|
|
1452
|
+
return str(node.get("full_name") or "") if isinstance(node, dict) else ""
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def is_merged_record(record: dict) -> bool:
|
|
1456
|
+
"""Merged state is `merged_at`, never `merged`.
|
|
1457
|
+
|
|
1458
|
+
The list endpoint sends `merged: null` even for a genuinely merged pull
|
|
1459
|
+
request — measured on a live merged pull request — so reading `merged`
|
|
1460
|
+
there would classify every merged PR as unmerged and silently retain
|
|
1461
|
+
every branch.
|
|
1462
|
+
"""
|
|
1463
|
+
return bool(record.get("merged_at"))
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def matches_pr_tuple(record: dict, *, repository, branch, integration, tip) -> bool:
|
|
1467
|
+
"""The full pull-request tuple — the head SHA carries the uniqueness.
|
|
1468
|
+
|
|
1469
|
+
A reused head ref resolves to several pull requests, so the ref never
|
|
1470
|
+
establishes uniqueness on its own.
|
|
1471
|
+
"""
|
|
1472
|
+
head = record.get("head") or {}
|
|
1473
|
+
base = record.get("base") or {}
|
|
1474
|
+
return (
|
|
1475
|
+
_full_name(base.get("repo")) == repository
|
|
1476
|
+
and _full_name(head.get("repo")) == repository
|
|
1477
|
+
and str(head.get("ref") or "") == branch
|
|
1478
|
+
and str(base.get("ref") or "") == integration
|
|
1479
|
+
and is_merged_record(record)
|
|
1480
|
+
and str(head.get("sha") or "") == tip
|
|
1481
|
+
)
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def open_pull_requests_on_head(records, *, repository: str, branch: str) -> list:
|
|
1485
|
+
open_prs = []
|
|
1486
|
+
for record in records:
|
|
1487
|
+
head = record.get("head") or {}
|
|
1488
|
+
if (
|
|
1489
|
+
str(record.get("state") or "").lower() == "open"
|
|
1490
|
+
and str(head.get("ref") or "") == branch
|
|
1491
|
+
and _full_name(head.get("repo")) == repository
|
|
1492
|
+
):
|
|
1493
|
+
open_prs.append(record)
|
|
1494
|
+
return open_prs
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def _pr_numbers(records) -> str:
|
|
1498
|
+
return ", ".join(f"#{record.get('number')}" for record in records)
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
def _ancestry_only(branch: str, tip: str, integration: str, error: str) -> BranchAuthority:
|
|
1502
|
+
"""Honest degradation: say that the platform was unreachable, never imply it agreed."""
|
|
1503
|
+
detail = f" ({error})" if error else ""
|
|
1504
|
+
return BranchAuthority(
|
|
1505
|
+
branch,
|
|
1506
|
+
BRANCH_RETAINED,
|
|
1507
|
+
tip,
|
|
1508
|
+
f"no platform access, so authority degrades to ancestry only — and {branch} "
|
|
1509
|
+
f"is not merged into {integration}{detail}",
|
|
1510
|
+
degraded=True,
|
|
1511
|
+
)
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
def authorize_branch_deletion(
|
|
1515
|
+
main_tree: str,
|
|
1516
|
+
branch: str,
|
|
1517
|
+
*,
|
|
1518
|
+
integration: str,
|
|
1519
|
+
pr: str | None = None,
|
|
1520
|
+
command_runner=None,
|
|
1521
|
+
) -> BranchAuthority:
|
|
1522
|
+
"""Decide what this branch's own state and the platform record allow.
|
|
1523
|
+
|
|
1524
|
+
Ancestry against the freshly fetched integration branch deletes normally.
|
|
1525
|
+
Otherwise exactly one pull request matching the full tuple — this
|
|
1526
|
+
repository as base repo, the head repository equal to it, this head ref,
|
|
1527
|
+
the configured base ref, merged, head SHA equal to the branch tip —
|
|
1528
|
+
authorizes force deletion. Zero matches, several matches, an open pull
|
|
1529
|
+
request on the same head, or no platform access keep the branch.
|
|
1530
|
+
"""
|
|
1531
|
+
command_runner = run if command_runner is None else command_runner
|
|
1532
|
+
tracking = refresh_integration_branch(main_tree, integration)
|
|
1533
|
+
tip = branch_tip(main_tree, branch)
|
|
1534
|
+
if not tip:
|
|
1535
|
+
return BranchAuthority(
|
|
1536
|
+
branch, BRANCH_RETAINED, "", "the local branch is already absent"
|
|
1537
|
+
)
|
|
1538
|
+
if git(["merge-base", "--is-ancestor", tip, tracking], cwd=main_tree).returncode == 0:
|
|
1539
|
+
return BranchAuthority(
|
|
1540
|
+
branch, BRANCH_ANCESTRY, tip,
|
|
1541
|
+
f"merged into the freshly fetched {integration}",
|
|
1542
|
+
)
|
|
1543
|
+
repository, error = platform_repository(command_runner)
|
|
1544
|
+
if not repository:
|
|
1545
|
+
return _ancestry_only(branch, tip, integration, error)
|
|
1546
|
+
records, error = head_pull_requests(repository, branch, command_runner)
|
|
1547
|
+
if records is None:
|
|
1548
|
+
return _ancestry_only(branch, tip, integration, error)
|
|
1549
|
+
open_prs = open_pull_requests_on_head(records, repository=repository, branch=branch)
|
|
1550
|
+
if open_prs:
|
|
1551
|
+
return BranchAuthority(
|
|
1552
|
+
branch, BRANCH_RETAINED, tip,
|
|
1553
|
+
f"an open pull request shares this head: {_pr_numbers(open_prs)}",
|
|
1554
|
+
)
|
|
1555
|
+
if pr is not None:
|
|
1556
|
+
record, error = pull_request_by_number(repository, pr, command_runner)
|
|
1557
|
+
if record is None:
|
|
1558
|
+
return BranchAuthority(branch, BRANCH_RETAINED, tip, error)
|
|
1559
|
+
records = [record]
|
|
1560
|
+
matching = [
|
|
1561
|
+
record for record in records
|
|
1562
|
+
if matches_pr_tuple(
|
|
1563
|
+
record, repository=repository, branch=branch,
|
|
1564
|
+
integration=integration, tip=tip,
|
|
1565
|
+
)
|
|
1566
|
+
]
|
|
1567
|
+
if len(matching) == 1:
|
|
1568
|
+
return BranchAuthority(
|
|
1569
|
+
branch, BRANCH_PR_RECORD, tip,
|
|
1570
|
+
f"merged pull request #{matching[0].get('number')} matches this tip",
|
|
1571
|
+
pr=str(matching[0].get("number")),
|
|
1572
|
+
)
|
|
1573
|
+
if not matching:
|
|
1574
|
+
return BranchAuthority(
|
|
1575
|
+
branch, BRANCH_RETAINED, tip,
|
|
1576
|
+
f"no merged pull request in {repository} matches this branch tip "
|
|
1577
|
+
f"{tip[:7]} on {branch}",
|
|
1578
|
+
)
|
|
1579
|
+
return BranchAuthority(
|
|
1580
|
+
branch, BRANCH_RETAINED, tip,
|
|
1581
|
+
f"several merged pull requests match this tip ({_pr_numbers(matching)}) — "
|
|
1582
|
+
"ambiguous; name the one that authorizes deletion with --pr <number>",
|
|
1583
|
+
)
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
def delete_authorized_branch(main_tree: str, authority: BranchAuthority) -> tuple[bool, str]:
|
|
1587
|
+
"""Delete exactly what the authority cleared, re-reading the tip last.
|
|
1588
|
+
|
|
1589
|
+
The window between reading the platform record and deleting the branch is
|
|
1590
|
+
the whole point of the re-read: a tip that moved inside it keeps the branch.
|
|
1591
|
+
Ancestry deletes with git's own `-d` safety semantics; only the PR record
|
|
1592
|
+
authorizes the force flag.
|
|
1593
|
+
"""
|
|
1594
|
+
if authority.decision == BRANCH_RETAINED:
|
|
1595
|
+
return False, authority.reason
|
|
1596
|
+
current = branch_tip(main_tree, authority.branch)
|
|
1597
|
+
if current != authority.tip:
|
|
1598
|
+
return False, (
|
|
1599
|
+
"the branch tip moved between authorization and deletion "
|
|
1600
|
+
f"({authority.tip[:7]} → {current[:7] or 'absent'})"
|
|
1601
|
+
)
|
|
1602
|
+
forced = authority.decision == BRANCH_PR_RECORD
|
|
1603
|
+
result = git(["branch", "-D" if forced else "-d", authority.branch], cwd=main_tree)
|
|
1604
|
+
if result.returncode != 0:
|
|
1605
|
+
return False, (result.stderr or result.stdout).strip()[:200]
|
|
1606
|
+
return True, authority.reason
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
def retire_local_branch(
|
|
1610
|
+
branch: str,
|
|
1611
|
+
main_tree: str,
|
|
1612
|
+
integration: str,
|
|
1613
|
+
report: dict,
|
|
1614
|
+
*,
|
|
1615
|
+
pr: str | None = None,
|
|
1616
|
+
) -> None:
|
|
1617
|
+
"""Fast-forward the integration branch, then retire the branch its authority
|
|
1618
|
+
clears. Already absent is a completed step, not a failure."""
|
|
973
1619
|
git(["fetch", "origin", "--prune"], cwd=main_tree)
|
|
974
|
-
git(["checkout",
|
|
1620
|
+
git(["checkout", integration], cwd=main_tree)
|
|
975
1621
|
p = git(["pull", "--ff-only"], cwd=main_tree)
|
|
976
1622
|
if p.returncode != 0:
|
|
977
|
-
raise Stop("5
|
|
1623
|
+
raise Stop("5 integration-ff",
|
|
1624
|
+
f"no fast-forward possible — a diverged {integration} is an anomaly",
|
|
978
1625
|
(p.stderr or p.stdout).strip()[-1000:])
|
|
979
|
-
if
|
|
980
|
-
cwd=main_tree).returncode != 0:
|
|
1626
|
+
if not branch_tip(main_tree, branch):
|
|
981
1627
|
report["branch_retired"] = "already absent"
|
|
1628
|
+
report["skipped"].append("branch retire: local branch already absent")
|
|
982
1629
|
return
|
|
983
1630
|
if branch in worktree_map(main_tree)[1]:
|
|
984
1631
|
report["branch_retired"] = "refused: still checked out"
|
|
985
1632
|
report["warnings"].append(
|
|
986
|
-
f"branch
|
|
1633
|
+
f"branch retire refused: {branch} is still checked out"
|
|
987
1634
|
)
|
|
988
1635
|
return
|
|
989
|
-
|
|
990
|
-
|
|
1636
|
+
authority = authorize_branch_deletion(
|
|
1637
|
+
main_tree, branch, integration=integration, pr=pr
|
|
1638
|
+
)
|
|
1639
|
+
report["branch_authority"] = authority.as_report()
|
|
1640
|
+
deleted, detail = delete_authorized_branch(main_tree, authority)
|
|
1641
|
+
report["branch_retired"] = deleted
|
|
1642
|
+
if not deleted:
|
|
1643
|
+
report["warnings"].append(f"branch {branch} retained: {detail}")
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
def pull_request_snapshot(branch: str) -> dict | None:
|
|
1647
|
+
"""Read the platform's PR record — the resume authority for a re-run.
|
|
1648
|
+
|
|
1649
|
+
There is no persisted attempt state. Whether the push, the PR
|
|
1650
|
+
and the merge already happened is answered by looking, every single time.
|
|
1651
|
+
"""
|
|
1652
|
+
p = run(["gh", "pr", "view", branch, "--json", "number,state,body"])
|
|
991
1653
|
if p.returncode != 0:
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
)
|
|
1654
|
+
return None
|
|
1655
|
+
try:
|
|
1656
|
+
return json.loads(p.stdout)
|
|
1657
|
+
except json.JSONDecodeError as error:
|
|
1658
|
+
raise Stop("0c pr", "invalid PR response", str(error)) from error
|
|
995
1659
|
|
|
996
1660
|
|
|
997
|
-
def
|
|
998
|
-
branch
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1661
|
+
def remote_branch_tip(main_tree: str, branch: str) -> str:
|
|
1662
|
+
result = git(["ls-remote", "--heads", "origin", branch], cwd=main_tree)
|
|
1663
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
1664
|
+
return ""
|
|
1665
|
+
return result.stdout.split()[0]
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
# ---------- census freshness (session-end finding, never a gate) ----------
|
|
1669
|
+
|
|
1670
|
+
def census_status(checkout: str) -> dict:
|
|
1671
|
+
"""Read ONE named checkout's census freshness verdict, read-only.
|
|
1672
|
+
|
|
1673
|
+
A census describes the tree it was scanned in, and the drift guard resolves
|
|
1674
|
+
its census root from the working directory it is called in. The checkout is
|
|
1675
|
+
therefore named here rather than inherited: the verdict that matters at
|
|
1676
|
+
session end belongs to the tree the next session starts from, and a
|
|
1677
|
+
worktree-green verdict must never stand in for it.
|
|
1678
|
+
|
|
1679
|
+
A checkout without the hook, or without a census, answers `no_census` — the
|
|
1680
|
+
kit's ordinary silent degradation. Only an unreadable answer is
|
|
1681
|
+
`unavailable`, which the caller reports without ever acting on it.
|
|
1013
1682
|
"""
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
"
|
|
1017
|
-
|
|
1683
|
+
hook = Path(checkout) / CENSUS_HOOK
|
|
1684
|
+
if not hook.is_file():
|
|
1685
|
+
return {"state": CENSUS_ABSENT, "reasons": []}
|
|
1686
|
+
completed = run([sys.executable, str(hook), "--census-status"], cwd=checkout)
|
|
1687
|
+
if completed.returncode != 0:
|
|
1688
|
+
return {"state": CENSUS_UNREADABLE,
|
|
1689
|
+
"reasons": [(completed.stderr or completed.stdout).strip()[-300:]]}
|
|
1690
|
+
try:
|
|
1691
|
+
verdict = json.loads(completed.stdout)
|
|
1692
|
+
except json.JSONDecodeError as error:
|
|
1693
|
+
return {"state": CENSUS_UNREADABLE, "reasons": [str(error)]}
|
|
1694
|
+
if not isinstance(verdict, dict):
|
|
1695
|
+
return {"state": CENSUS_UNREADABLE, "reasons": ["verdict is not an object"]}
|
|
1696
|
+
return verdict
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
def render_census_finding(verdict: dict, checkout: str) -> str:
|
|
1700
|
+
"""State the verdict, its cause, the checkout it describes, and the route out."""
|
|
1701
|
+
reasons = ", ".join(str(reason) for reason in verdict.get("reasons") or [])
|
|
1702
|
+
lines = [
|
|
1703
|
+
f"CENSUS — session-end finding ({verdict.get('state')}):",
|
|
1704
|
+
f" · {reasons or 'the activated census is stale'}",
|
|
1705
|
+
f" · evaluated checkout: {checkout}",
|
|
1706
|
+
f" ({CENSUS_CHECKOUT_NOTE})",
|
|
1707
|
+
f" · recovery: {CENSUS_RECOVERY}",
|
|
1708
|
+
f" · {CENSUS_NOT_A_GATE}",
|
|
1709
|
+
]
|
|
1710
|
+
if verdict.get("override_applied"):
|
|
1711
|
+
lines.append(f" · {CENSUS_OVERRIDE_NOTE}")
|
|
1712
|
+
return "\n".join(lines)
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
def census_tracking_issues(main_tree: str) -> tuple[list[int], str]:
|
|
1716
|
+
"""Every OPEN issue carrying the tracking marker, plus a lookup error.
|
|
1717
|
+
|
|
1718
|
+
Identity is the marker, never the title. Closed issues are history: a
|
|
1719
|
+
refresh that was already resolved must not wedge the next one out of a
|
|
1720
|
+
tracker of its own.
|
|
1721
|
+
"""
|
|
1722
|
+
listed = run(["gh", "issue", "list", "--state", "open",
|
|
1723
|
+
"--limit", str(CENSUS_ISSUE_LIMIT), "--json", "number,body"],
|
|
1724
|
+
cwd=main_tree)
|
|
1725
|
+
if listed.returncode != 0:
|
|
1726
|
+
return [], (listed.stderr or listed.stdout).strip()[-500:]
|
|
1727
|
+
try:
|
|
1728
|
+
payload = json.loads(listed.stdout)
|
|
1729
|
+
except json.JSONDecodeError as error:
|
|
1730
|
+
return [], f"unreadable issue list: {error}"
|
|
1731
|
+
if not isinstance(payload, list):
|
|
1732
|
+
return [], "unreadable issue list: not an array"
|
|
1733
|
+
return [
|
|
1734
|
+
int(issue["number"]) for issue in payload
|
|
1735
|
+
if marker_value(issue.get("body") or "", CENSUS_TRACKING_KIND)
|
|
1736
|
+
== CENSUS_TRACKING_SLUG
|
|
1737
|
+
], ""
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
def track_census_finding(main_tree: str, finding: str) -> dict:
|
|
1741
|
+
"""Open or refresh the one tracking issue so the finding outlives the session.
|
|
1742
|
+
|
|
1743
|
+
Idempotent by identity: no open match creates one through the board command,
|
|
1744
|
+
exactly one match is rewritten with the current verdict, and several matches
|
|
1745
|
+
write nothing and name them — guessing which one is the tracker is how a
|
|
1746
|
+
duplicate becomes permanent.
|
|
1747
|
+
"""
|
|
1748
|
+
numbers, error = census_tracking_issues(main_tree)
|
|
1749
|
+
if error:
|
|
1750
|
+
return {"action": "none", "issue": None, "ok": False, "error": error}
|
|
1751
|
+
if len(numbers) > 1:
|
|
1752
|
+
named = ", ".join(f"#{number}" for number in numbers)
|
|
1753
|
+
return {"action": "none", "issue": None, "ok": False,
|
|
1754
|
+
"error": f"several open issues carry the tracking marker: {named}"}
|
|
1755
|
+
body = (f"<!-- {CENSUS_TRACKING_KIND}: {CENSUS_TRACKING_SLUG} -->\n\n"
|
|
1756
|
+
f"{finding}\n")
|
|
1757
|
+
with tempfile.NamedTemporaryFile(
|
|
1758
|
+
"w", suffix=".md", delete=False, encoding="utf-8"
|
|
1759
|
+
) as handle:
|
|
1760
|
+
handle.write(body)
|
|
1761
|
+
body_file = handle.name
|
|
1762
|
+
try:
|
|
1763
|
+
if numbers:
|
|
1764
|
+
written = run(["gh", "issue", "edit", str(numbers[0]),
|
|
1765
|
+
"--body-file", body_file], cwd=main_tree)
|
|
1766
|
+
return _census_tracking_result("updated", numbers[0], written)
|
|
1767
|
+
created = run([sys.executable, str(Path(__file__).parent / "board-sync.py"),
|
|
1768
|
+
"create", "--title", CENSUS_TRACKING_TITLE,
|
|
1769
|
+
"--body-file", body_file], cwd=main_tree)
|
|
1770
|
+
return _census_tracking_result(
|
|
1771
|
+
"created", _created_issue_number(created.stdout), created)
|
|
1772
|
+
finally:
|
|
1773
|
+
Path(body_file).unlink(missing_ok=True)
|
|
1774
|
+
|
|
1775
|
+
|
|
1776
|
+
def _census_tracking_result(action: str, issue, completed) -> dict:
|
|
1777
|
+
ok = completed.returncode == 0
|
|
1778
|
+
return {
|
|
1779
|
+
"action": action if ok else "none",
|
|
1780
|
+
"issue": issue,
|
|
1781
|
+
"ok": ok,
|
|
1782
|
+
"error": None if ok else (completed.stderr or completed.stdout).strip()[-500:],
|
|
1018
1783
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
def _created_issue_number(stdout: str):
|
|
1787
|
+
"""Read the number back out of the board command's own `#<n> <url>` line."""
|
|
1788
|
+
for line in stdout.splitlines():
|
|
1789
|
+
head = line.strip().split(" ", 1)[0]
|
|
1790
|
+
if head.startswith("#") and head[1:].isdigit():
|
|
1791
|
+
return int(head[1:])
|
|
1792
|
+
return None
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def census_step(main_tree: str, profile: dict, report: dict) -> None:
|
|
1796
|
+
"""Give the freshness verdict a home at session end.
|
|
1797
|
+
|
|
1798
|
+
`current` and `no_census` leave no trace at all — the same silent
|
|
1799
|
+
degradation the rest of the kit practises. Only `refresh_required` speaks,
|
|
1800
|
+
and it speaks as a finding: the landing has already happened and topology
|
|
1801
|
+
drift is repo-wide, so blocking here would punish an unrelated change.
|
|
1802
|
+
"""
|
|
1803
|
+
verdict = census_status(main_tree)
|
|
1804
|
+
state = verdict.get("state")
|
|
1805
|
+
if state == CENSUS_UNREADABLE:
|
|
1806
|
+
detail = "; ".join(str(reason) for reason in verdict.get("reasons") or [])
|
|
1807
|
+
report["warnings"].append(
|
|
1808
|
+
f"census status unreadable for {main_tree}: {detail}")
|
|
1809
|
+
return
|
|
1810
|
+
if state != CENSUS_STALE:
|
|
1811
|
+
return
|
|
1812
|
+
finding = {
|
|
1813
|
+
"state": state,
|
|
1814
|
+
"evaluated_checkout": main_tree,
|
|
1815
|
+
"reasons": list(verdict.get("reasons") or []),
|
|
1816
|
+
"blocking": False,
|
|
1817
|
+
"finding": render_census_finding(verdict, main_tree),
|
|
1048
1818
|
}
|
|
1049
|
-
report["
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
main_tree,
|
|
1053
|
-
assessment,
|
|
1054
|
-
verified_scratch_evidence=evidence,
|
|
1055
|
-
profile_loader=load_canonical_recovery_profile,
|
|
1056
|
-
)
|
|
1057
|
-
p = git(["worktree", "remove", wt], cwd=main_tree)
|
|
1058
|
-
if p.returncode != 0:
|
|
1059
|
-
raise Stop("recover-cleanup", "git worktree remove refused — no --force; "
|
|
1060
|
-
"check for surviving processes (lsof/pgrep)",
|
|
1061
|
-
(p.stderr or p.stdout).strip()[-1000:])
|
|
1062
|
-
git(["worktree", "prune"], cwd=main_tree)
|
|
1063
|
-
report["worktree_removed"] = wt
|
|
1064
|
-
retire_local_branch(branch, main_tree, report)
|
|
1065
|
-
report["main_sha"] = git(["log", "--oneline", "-1"], cwd=main_tree,
|
|
1066
|
-
check=True).stdout.strip()
|
|
1067
|
-
return report
|
|
1819
|
+
report["census"] = finding
|
|
1820
|
+
if profile.get("wrapup", {}).get("censusTrackingIssue"):
|
|
1821
|
+
finding["tracking"] = track_census_finding(main_tree, finding["finding"])
|
|
1068
1822
|
|
|
1069
1823
|
|
|
1070
1824
|
def cmd_land(args) -> dict:
|
|
1071
|
-
report: dict = {"stops": [], "warnings": []}
|
|
1825
|
+
report: dict = {"stops": [], "warnings": [], "skipped": []}
|
|
1072
1826
|
main_tree, branches = worktree_map()
|
|
1073
1827
|
here = os.path.realpath(os.getcwd())
|
|
1074
1828
|
if here != os.path.realpath(main_tree):
|
|
@@ -1076,48 +1830,21 @@ def cmd_land(args) -> dict:
|
|
|
1076
1830
|
f"cwd={here} main={main_tree} — the in-worktree shell would survive "
|
|
1077
1831
|
"teardown and the process-kill step")
|
|
1078
1832
|
branch = args.branch
|
|
1833
|
+
integration, protected = branch_policy(main_tree)
|
|
1834
|
+
if branch in protected:
|
|
1835
|
+
raise Stop("land", f"{branch} is a protected branch",
|
|
1836
|
+
"/wrapup lands a slice branch, never the integration branch")
|
|
1079
1837
|
wt = branches.get(branch)
|
|
1080
1838
|
wt_exists = wt is not None and Path(wt).is_dir()
|
|
1081
|
-
if getattr(args, "recover_canonical_cleanup", False):
|
|
1082
|
-
return recover_canonical_cleanup(branch, wt, wt_exists, main_tree)
|
|
1083
1839
|
profile = load_profile()
|
|
1084
1840
|
default_section = profile.get("headings", {}).get("vorBau", "Vor Bau zu klären")
|
|
1085
1841
|
|
|
1086
1842
|
# drift markers from the build-time log — mechanical, no gate
|
|
1087
1843
|
markers: list[dict] = []
|
|
1088
|
-
landing_attempt: dict | None = None
|
|
1089
|
-
generated_evidence: tuple[dict, ...] = ()
|
|
1090
1844
|
if wt_exists:
|
|
1845
|
+
require_landable_head("land", wt)
|
|
1091
1846
|
if git(["status", "--porcelain"], cwd=wt, check=True).stdout.strip():
|
|
1092
1847
|
raise Stop("land", "worktree dirty — run `commit` first", wt)
|
|
1093
|
-
if getattr(args, "abandon_unfinished_attempt", False):
|
|
1094
|
-
return {
|
|
1095
|
-
"landing_attempt_abandoned": abandon_unfinished_landing_attempt(
|
|
1096
|
-
wt, main_tree
|
|
1097
|
-
),
|
|
1098
|
-
"files_removed": [],
|
|
1099
|
-
"next": (
|
|
1100
|
-
"current files remain protected; classify or move blockers, "
|
|
1101
|
-
"then rerun land"
|
|
1102
|
-
),
|
|
1103
|
-
}
|
|
1104
|
-
landing_attempt = landing_start_artifact_inventory(wt, main_tree)
|
|
1105
|
-
if landing_attempt["state"] == "started" and not landing_attempt["newAttempt"]:
|
|
1106
|
-
raise Stop(
|
|
1107
|
-
"cleanup",
|
|
1108
|
-
"unfinished landing generator attempt has no frozen output evidence; "
|
|
1109
|
-
f"classify its files, then rerun with {ABANDON_ATTEMPT_FLAG}",
|
|
1110
|
-
)
|
|
1111
|
-
# Retained structural backstop: a journal on the active contract always
|
|
1112
|
-
# carries an empty `generatedFiles`, because a landing-start blocker is
|
|
1113
|
-
# refused before the journal is written. A non-empty list therefore means
|
|
1114
|
-
# tampered or migrated evidence — never a path this run may claim.
|
|
1115
|
-
if landing_attempt["generatedFiles"]:
|
|
1116
|
-
raise Stop(
|
|
1117
|
-
"cleanup",
|
|
1118
|
-
"landing-start generated paths are consumer-owned and protected: "
|
|
1119
|
-
+ ", ".join(landing_attempt["generatedFiles"]),
|
|
1120
|
-
)
|
|
1121
1848
|
annahmen = Path(wt) / "ANNAHMEN.md"
|
|
1122
1849
|
if annahmen.is_file():
|
|
1123
1850
|
markers, malformed = parse_annahmen(annahmen.read_text(), default_section)
|
|
@@ -1127,82 +1854,82 @@ def cmd_land(args) -> dict:
|
|
|
1127
1854
|
"\n".join(malformed))
|
|
1128
1855
|
report["drift_markers"] = markers
|
|
1129
1856
|
|
|
1130
|
-
# Step
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1857
|
+
# Step 0c-a — read the PR record first: a merged PR means push, body and
|
|
1858
|
+
# merge are done, and this run resumes at teardown.
|
|
1859
|
+
snapshot = pull_request_snapshot(branch)
|
|
1860
|
+
already_merged = snapshot is not None and snapshot.get("state") == "MERGED"
|
|
1861
|
+
|
|
1862
|
+
# Step 0b — push, unless the remote already carries this commit
|
|
1863
|
+
if wt_exists and already_merged:
|
|
1864
|
+
report["skipped"].append("push: the PR for this branch is already merged")
|
|
1865
|
+
elif wt_exists:
|
|
1866
|
+
local_tip = git(["rev-parse", "HEAD"], cwd=wt, check=True).stdout.strip()
|
|
1867
|
+
if remote_branch_tip(main_tree, branch) == local_tip:
|
|
1868
|
+
report["skipped"].append(f"push: origin/{branch} already at {local_tip[:7]}")
|
|
1137
1869
|
else:
|
|
1138
|
-
if landing_attempt["state"] == "frozen":
|
|
1139
|
-
# Validate every previously frozen identity before a retry can
|
|
1140
|
-
# invoke the generator-capable pre-push hook.
|
|
1141
|
-
reopen_frozen_landing_attempt(wt, main_tree)
|
|
1142
1870
|
p = git(["push", "-u", "origin", branch], cwd=wt)
|
|
1143
|
-
generated_evidence = freeze_landing_artifact_evidence(
|
|
1144
|
-
wt, main_tree, push_succeeded=p.returncode == 0
|
|
1145
|
-
)
|
|
1146
1871
|
if p.returncode != 0:
|
|
1147
|
-
raise Stop(
|
|
1148
|
-
|
|
1149
|
-
(p.stderr or p.stdout).strip()[-2000:],
|
|
1150
|
-
)
|
|
1872
|
+
raise Stop("0b push", "push rejected",
|
|
1873
|
+
(p.stderr or p.stdout).strip()[-2000:])
|
|
1151
1874
|
|
|
1152
1875
|
# Step 0c — ensure PR + final body
|
|
1153
|
-
p = run(["gh", "pr", "view", branch, "--json", "number,state,body"])
|
|
1154
1876
|
pr_body = ""
|
|
1155
|
-
if
|
|
1156
|
-
|
|
1157
|
-
pr, pr_state, pr_body = str(d["number"]), d["state"], d.get("body") or ""
|
|
1877
|
+
if snapshot is not None:
|
|
1878
|
+
pr, pr_body = str(snapshot["number"]), snapshot.get("body") or ""
|
|
1158
1879
|
report["pr_reused"] = True
|
|
1159
1880
|
elif args.body_file:
|
|
1160
1881
|
if not args.title:
|
|
1161
1882
|
raise Stop("0c pr", "--title required to create a PR")
|
|
1162
|
-
run(["gh", "pr", "create", "--base",
|
|
1883
|
+
run(["gh", "pr", "create", "--base", integration, "--head", branch,
|
|
1163
1884
|
"--title", args.title, "--body-file", args.body_file], check=True)
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1885
|
+
created = pull_request_snapshot(branch)
|
|
1886
|
+
if created is None:
|
|
1887
|
+
raise Stop("0c pr", "PR was created but cannot be read back")
|
|
1888
|
+
pr, pr_body = str(created["number"]), created.get("body") or ""
|
|
1167
1889
|
report["pr_reused"] = False
|
|
1168
1890
|
else:
|
|
1169
1891
|
raise Stop("0c pr", "no open PR and no --body-file to create one")
|
|
1170
1892
|
report["pr"] = pr
|
|
1171
1893
|
|
|
1172
|
-
if
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
final = merge_markers_into_body(pr_body, markers)
|
|
1180
|
-
if final != pr_body:
|
|
1181
|
-
tmp = Path(f"/tmp/wrapup-body-{pr}.md")
|
|
1894
|
+
if already_merged:
|
|
1895
|
+
report["skipped"].append("PR body + body-check: the PR is already merged")
|
|
1896
|
+
else:
|
|
1897
|
+
if args.body_file and report["pr_reused"]:
|
|
1898
|
+
pr_body = Path(args.body_file).read_text()
|
|
1899
|
+
final = merge_markers_into_body(pr_body, markers)
|
|
1900
|
+
tmp = Path(args.body_file).with_suffix(".final.md")
|
|
1182
1901
|
tmp.write_text(final)
|
|
1183
1902
|
run(["gh", "pr", "edit", pr, "--body-file", str(tmp)], check=True)
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1903
|
+
elif markers:
|
|
1904
|
+
final = merge_markers_into_body(pr_body, markers)
|
|
1905
|
+
if final != pr_body:
|
|
1906
|
+
tmp = Path(f"/tmp/wrapup-body-{pr}.md")
|
|
1907
|
+
tmp.write_text(final)
|
|
1908
|
+
run(["gh", "pr", "edit", pr, "--body-file", str(tmp)], check=True)
|
|
1909
|
+
|
|
1910
|
+
# body-convention check: closes-vs-Part-of + **Retro:** line
|
|
1911
|
+
p = run([sys.executable, str(Path(__file__).parent / "pr-body-check.py"),
|
|
1912
|
+
"--branch", branch])
|
|
1913
|
+
report["body_check_exit"] = p.returncode
|
|
1914
|
+
if p.returncode == 1:
|
|
1915
|
+
raise Stop("0c body-check", "pr-body-check exit 1 — fix the body, never merge red",
|
|
1916
|
+
(p.stdout + p.stderr).strip()[-2000:])
|
|
1917
|
+
if p.returncode == 2:
|
|
1918
|
+
report["warnings"].append("pr-body-check exit 2 (fail-open): "
|
|
1919
|
+
+ (p.stdout + p.stderr).strip()[:300])
|
|
1920
|
+
|
|
1921
|
+
# merge gate — wait boundedly for fresh-PR checks; already-MERGED resumes
|
|
1922
|
+
# directly at teardown. Progress stays on stderr so stdout remains one JSON.
|
|
1923
|
+
already_merged = wait_for_merge_gate(pr)
|
|
1199
1924
|
|
|
1200
1925
|
# Step 1 — merge (= prod deploy; authorization = the user's /wrapup invocation).
|
|
1201
1926
|
# PR state is the authority, not gh's exit code: `--delete-branch` also tries
|
|
1202
1927
|
# to delete the LOCAL branch, which fails while a worktree holds it — the
|
|
1203
1928
|
# remote merge is through anyway (dogfood run PR).
|
|
1204
1929
|
merge_err = ""
|
|
1205
|
-
if
|
|
1930
|
+
if already_merged:
|
|
1931
|
+
report["skipped"].append("merge: the PR is already MERGED")
|
|
1932
|
+
else:
|
|
1206
1933
|
p = run(["gh", "pr", "merge", pr, "--merge", "--delete-branch"], cwd=main_tree)
|
|
1207
1934
|
if p.returncode != 0:
|
|
1208
1935
|
merge_err = (p.stderr or p.stdout).strip()[-2000:]
|
|
@@ -1215,27 +1942,23 @@ def cmd_land(args) -> dict:
|
|
|
1215
1942
|
report["warnings"].append(f"gh pr merge exited non-zero but PR is MERGED: {merge_err[:300]}")
|
|
1216
1943
|
report["merged"] = True
|
|
1217
1944
|
|
|
1218
|
-
# Step 2 —
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
verified_scratch_files=generated,
|
|
1226
|
-
verified_scratch_evidence=generated_evidence,
|
|
1227
|
-
)
|
|
1228
|
-
report["cleanup_guard"] = {
|
|
1229
|
-
"assumptions_read": bool(cleanup.assumptions),
|
|
1230
|
-
"landing_generated_files": list(generated),
|
|
1231
|
-
}
|
|
1945
|
+
# Step 2 — quiesce this worktree's own dev servers, then Step 4 — teardown.
|
|
1946
|
+
# Teardown always runs: a direct /wrapup invocation is its authorization,
|
|
1947
|
+
# and the classifier's four rules are the only protection.
|
|
1948
|
+
if not wt_exists:
|
|
1949
|
+
report["skipped"].append("teardown: the worktree is already removed")
|
|
1950
|
+
else:
|
|
1951
|
+
git(["fetch", "origin", integration], cwd=main_tree, check=True)
|
|
1232
1952
|
report["killed_processes"] = kill_worktree_processes(wt)
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1953
|
+
assessment = assess_teardown(wt, main_tree)
|
|
1954
|
+
rendered = teardown_report(assessment)
|
|
1955
|
+
report["teardown"] = {
|
|
1956
|
+
"assumptions_read": (Path(wt) / "ANNAHMEN.md").is_file(),
|
|
1957
|
+
"report": rendered,
|
|
1958
|
+
}
|
|
1959
|
+
if not assessment.removable:
|
|
1960
|
+
raise Stop("4 teardown", "teardown is blocked", rendered)
|
|
1961
|
+
report["scratch_removed"] = remove_teardown_scratch(assessment)
|
|
1239
1962
|
p = git(["worktree", "remove", wt], cwd=main_tree)
|
|
1240
1963
|
if p.returncode != 0:
|
|
1241
1964
|
raise Stop("4 worktree-remove", "git worktree remove refused — no --force; "
|
|
@@ -1243,19 +1966,9 @@ def cmd_land(args) -> dict:
|
|
|
1243
1966
|
(p.stderr or p.stdout).strip()[-1000:])
|
|
1244
1967
|
git(["worktree", "prune"], cwd=main_tree)
|
|
1245
1968
|
report["worktree_removed"] = wt
|
|
1246
|
-
git(["fetch", "origin", "--prune"], cwd=main_tree)
|
|
1247
1969
|
|
|
1248
|
-
# Step 5 —
|
|
1249
|
-
|
|
1250
|
-
p = git(["pull", "--ff-only"], cwd=main_tree)
|
|
1251
|
-
if p.returncode != 0:
|
|
1252
|
-
raise Stop("5 main-ff", "no fast-forward possible — diverged main is an anomaly",
|
|
1253
|
-
(p.stderr or p.stdout).strip()[-1000:])
|
|
1254
|
-
p = git(["branch", "-d", branch], cwd=main_tree)
|
|
1255
|
-
if p.returncode != 0 and branch in worktree_map()[1]:
|
|
1256
|
-
report["warnings"].append(f"branch -d {branch} refused (still checked out?) — never -D")
|
|
1257
|
-
elif p.returncode != 0:
|
|
1258
|
-
report["warnings"].append(f"branch -d {branch} refused: {(p.stderr or '').strip()[:200]}")
|
|
1970
|
+
# Step 5 — integration ff + branch retirement by authority (after the pull)
|
|
1971
|
+
retire_local_branch(branch, main_tree, integration, report, pr=args.pr)
|
|
1259
1972
|
|
|
1260
1973
|
# Step 5b — verify declared auto-closes (backtick-swallowed `closes`
|
|
1261
1974
|
# misses). Targets come from the merged PR body's close keywords, never
|
|
@@ -1278,11 +1991,11 @@ def cmd_land(args) -> dict:
|
|
|
1278
1991
|
states.append(f"#{t} already closed")
|
|
1279
1992
|
report["issue_close"] = " · ".join(states)
|
|
1280
1993
|
|
|
1281
|
-
# Step 5c — local merged-branch sweep (-d only: unreachable-from-
|
|
1994
|
+
# Step 5c — local merged-branch sweep (-d only: unreachable-from-integration is refused)
|
|
1282
1995
|
swept = []
|
|
1283
|
-
p = git(["branch", "--merged",
|
|
1996
|
+
p = git(["branch", "--merged", integration, "--format=%(refname:short)"], cwd=main_tree)
|
|
1284
1997
|
for b in p.stdout.split():
|
|
1285
|
-
if b in
|
|
1998
|
+
if b in protected or b == branch or not b:
|
|
1286
1999
|
continue
|
|
1287
2000
|
if git(["branch", "-d", b], cwd=main_tree).returncode == 0:
|
|
1288
2001
|
swept.append(b)
|
|
@@ -1300,7 +2013,7 @@ def cmd_land(args) -> dict:
|
|
|
1300
2013
|
for line in git(["ls-remote", "--heads", "origin"], cwd=main_tree, check=True).stdout.splitlines():
|
|
1301
2014
|
if "refs/heads/" in line:
|
|
1302
2015
|
remote_heads.add(line.split("refs/heads/", 1)[1].strip())
|
|
1303
|
-
stale = stale_remote_set(merged_heads, remote_heads, open_heads)
|
|
2016
|
+
stale = stale_remote_set(merged_heads, remote_heads, open_heads, set(protected))
|
|
1304
2017
|
if not profile.get("wrapup", {}).get("remoteBranchSweep"):
|
|
1305
2018
|
report["swept_remote"] = {"enabled": False, "stale_count": len(stale)}
|
|
1306
2019
|
elif stale:
|
|
@@ -1311,14 +2024,19 @@ def cmd_land(args) -> dict:
|
|
|
1311
2024
|
else:
|
|
1312
2025
|
report["swept_remote"] = {"enabled": True, "deleted": []}
|
|
1313
2026
|
|
|
1314
|
-
# Step 5e.1 — anchor tracker sync + completeness; 5e.3 land sanity
|
|
2027
|
+
# Step 5e.1 — anchor tracker sync + completeness; 5e.3 land sanity.
|
|
2028
|
+
# The anchor is optional: a branch that carries no issue number lands
|
|
2029
|
+
# without one instead of being refused.
|
|
1315
2030
|
anchor = args.anchor
|
|
1316
2031
|
if not anchor and issue:
|
|
1317
2032
|
p = run([sys.executable, str(Path(__file__).parent / "board-sync.py"),
|
|
1318
2033
|
"parent-of", issue])
|
|
1319
2034
|
out = p.stdout.strip()
|
|
1320
2035
|
anchor = out if p.returncode == 0 and out.isdigit() else None
|
|
1321
|
-
if anchor:
|
|
2036
|
+
if not anchor:
|
|
2037
|
+
report["anchor_sync"] = {"anchor": None,
|
|
2038
|
+
"skipped": "branch carries no issue anchor"}
|
|
2039
|
+
else:
|
|
1322
2040
|
bs = str(Path(__file__).parent / "board-sync.py")
|
|
1323
2041
|
dry = run([sys.executable, bs, "anchor-sync", str(anchor), "--dry-run"])
|
|
1324
2042
|
wet = run([sys.executable, bs, "anchor-sync", str(anchor)])
|
|
@@ -1354,12 +2072,29 @@ def cmd_land(args) -> dict:
|
|
|
1354
2072
|
else (pwet.stderr or pwet.stdout).strip()[-1000:],
|
|
1355
2073
|
}
|
|
1356
2074
|
|
|
2075
|
+
# Step 5f — census freshness. The verdict is read for the main checkout,
|
|
2076
|
+
# because that is the tree the next session starts from; a stale one becomes
|
|
2077
|
+
# a named finding (and optionally durable work) instead of dying with this
|
|
2078
|
+
# session. It is a diagnostic, so nothing it can do may stop a landing that
|
|
2079
|
+
# already merged — hence the blanket catch.
|
|
2080
|
+
try:
|
|
2081
|
+
census_step(main_tree, profile, report)
|
|
2082
|
+
except Exception as error: # noqa: BLE001 — a diagnostic never gates a landing
|
|
2083
|
+
report["warnings"].append(f"census step skipped: {error}")
|
|
2084
|
+
|
|
1357
2085
|
report["main_sha"] = git(["log", "--oneline", "-1"], cwd=main_tree,
|
|
1358
2086
|
check=True).stdout.strip()
|
|
1359
2087
|
return report
|
|
1360
2088
|
|
|
1361
2089
|
|
|
1362
|
-
def
|
|
2090
|
+
def pull_request_number(value: str) -> str:
|
|
2091
|
+
"""`--pr` names which pull request is checked — it never skips the check."""
|
|
2092
|
+
if not value.isdigit():
|
|
2093
|
+
raise argparse.ArgumentTypeError("--pr expects a pull request number")
|
|
2094
|
+
return value
|
|
2095
|
+
|
|
2096
|
+
|
|
2097
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1363
2098
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
1364
2099
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
1365
2100
|
sub.add_parser("preflight", help="read-only context report (run in the worktree)")
|
|
@@ -1372,29 +2107,41 @@ def main() -> int:
|
|
|
1372
2107
|
l.add_argument("--title", help="PR title (create path)")
|
|
1373
2108
|
l.add_argument("--body-file", help="final PR body (create or overwrite)")
|
|
1374
2109
|
l.add_argument("--anchor", help="wave-anchor issue # (derived via parent-of when omitted)")
|
|
2110
|
+
l.add_argument("--pr", type=pull_request_number,
|
|
2111
|
+
help="the PR that authorizes deleting this branch when a reused "
|
|
2112
|
+
"head ref makes the record ambiguous (still validated "
|
|
2113
|
+
"against the full tuple)")
|
|
1375
2114
|
l.add_argument("--skip-malformed-drift", action="store_true")
|
|
1376
|
-
recovery
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
)
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
)
|
|
1394
|
-
args = ap.parse_args()
|
|
2115
|
+
# No recovery flag exists: an interrupted landing is resumed by re-running
|
|
2116
|
+
# `land`, which re-checks present state at every step.
|
|
2117
|
+
sub.add_parser("content-claim",
|
|
2118
|
+
help="read-only: infer durable content in the main checkout")
|
|
2119
|
+
c2 = sub.add_parser("content-commit",
|
|
2120
|
+
help="land a confirmed content claim (run in the main checkout)")
|
|
2121
|
+
c2.add_argument("--claim-file", required=True,
|
|
2122
|
+
help="the confirmed claim: the content-claim records the user picked")
|
|
2123
|
+
c2.add_argument("-m", "--message", required=True)
|
|
2124
|
+
c2.add_argument("--slug", required=True, help="branch slug for the content branch")
|
|
2125
|
+
c2.add_argument("--type", required=True, help="branch type for the content branch")
|
|
2126
|
+
c2.add_argument("--anchor", help="anchor issue # the content belongs to")
|
|
2127
|
+
c2.add_argument("--body-file", help="PR body to check (a close keyword is refused)")
|
|
2128
|
+
c2.add_argument("--allow-matches", action="store_true",
|
|
2129
|
+
help="proceed despite secret-grep matches (after human review)")
|
|
2130
|
+
return ap
|
|
2131
|
+
|
|
1395
2132
|
|
|
2133
|
+
def main() -> int:
|
|
2134
|
+
args = build_parser().parse_args()
|
|
2135
|
+
|
|
2136
|
+
handlers = {
|
|
2137
|
+
"preflight": cmd_preflight,
|
|
2138
|
+
"commit": cmd_commit,
|
|
2139
|
+
"land": cmd_land,
|
|
2140
|
+
"content-claim": cmd_content_claim,
|
|
2141
|
+
"content-commit": cmd_content_commit,
|
|
2142
|
+
}
|
|
1396
2143
|
try:
|
|
1397
|
-
result =
|
|
2144
|
+
result = handlers[args.cmd](args)
|
|
1398
2145
|
print(json.dumps({"ok": True, "cmd": args.cmd, **result}, ensure_ascii=False, indent=2))
|
|
1399
2146
|
return 0
|
|
1400
2147
|
except Stop as s:
|