@deftai/directive-content 0.55.2 → 0.56.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/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +1 -1
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""resolve_changelog_unreleased.py -- union-merge CHANGELOG [Unreleased] conflicts (#911).
|
|
3
|
+
|
|
4
|
+
Pure stdlib, cross-platform. Invoked from:
|
|
5
|
+
|
|
6
|
+
- ``task changelog:resolve-unreleased`` (Taskfile target wraps this script)
|
|
7
|
+
- ``uv run python scripts/resolve_changelog_unreleased.py [--changelog-path PATH]``
|
|
8
|
+
- Manually as a swarm cascade Phase 6 Step 1 helper, replacing the older
|
|
9
|
+
HEAD-take-and-discard pattern that silently dropped the rebasing branch's
|
|
10
|
+
CHANGELOG entry on every cascade rebase.
|
|
11
|
+
|
|
12
|
+
Recurrence record (the bug this script closes):
|
|
13
|
+
|
|
14
|
+
The 2026-05-04 v0.25.1 swarm cascade (4 PRs: #909 -> #907 -> #908 -> #906)
|
|
15
|
+
honoured the swarm-skill Phase 6 Step 1 rules ("use ``edit_files`` not shell
|
|
16
|
+
regex; verify structural integrity post-resolve") and the structural integrity
|
|
17
|
+
check passed. But the resolution PATTERN used (taking only the HEAD side of
|
|
18
|
+
each ``[Unreleased]``-section conflict) silently dropped the rebasing branch's
|
|
19
|
+
new CHANGELOG entry on every rebase after the first. Net effect: PR #908
|
|
20
|
+
squash-merged WITHOUT its CHANGELOG entry for #900; PR #906 squash-merged
|
|
21
|
+
WITHOUT its CHANGELOG entry for #901.
|
|
22
|
+
|
|
23
|
+
The correct resolution is a **union merge**: keep ALL HEAD entries (they are
|
|
24
|
+
the prior PRs' contributions that already landed on master) AND prepend each
|
|
25
|
+
branch entry that is not already in HEAD by ``(#NNN)`` issue-number heuristic.
|
|
26
|
+
|
|
27
|
+
Algorithm (per the #911 vBRIEF Overview):
|
|
28
|
+
|
|
29
|
+
1. Read CHANGELOG.md (UTF-8, atomic).
|
|
30
|
+
2. Locate the ``## [Unreleased]`` section and the next top-level ``## [...]``
|
|
31
|
+
section header (or EOF).
|
|
32
|
+
3. Within those bounds, locate each conflict block delimited by
|
|
33
|
+
``<<<<<<< HEAD`` / ``=======`` / ``>>>>>>> <sha>``.
|
|
34
|
+
4. For each conflict block:
|
|
35
|
+
- Determine the ambient ``### <subsection>`` (the most recent ``### header``
|
|
36
|
+
between the start of ``[Unreleased]`` and the conflict marker).
|
|
37
|
+
- Parse HEAD side and branch side as ``### <subsection>`` -> entries
|
|
38
|
+
mappings; entries that appear before any ``###`` header are attached to
|
|
39
|
+
the ambient subsection.
|
|
40
|
+
- Union-merge: keep ALL HEAD entries; for each branch entry, if its
|
|
41
|
+
``(#NNN)`` issue-number set does not overlap any HEAD entry in the same
|
|
42
|
+
subsection, PREPEND it under that subsection. Subsections that exist
|
|
43
|
+
only in the branch side are appended.
|
|
44
|
+
5. Atomic write back via ``tempfile.NamedTemporaryFile`` + ``os.replace``;
|
|
45
|
+
verify no ``<<<<<<<`` / ``=======`` / ``>>>>>>>`` markers remain
|
|
46
|
+
post-resolve. If markers remain (e.g. conflict outside [Unreleased]),
|
|
47
|
+
exit 1.
|
|
48
|
+
|
|
49
|
+
Three-state exit (mirrors ``scripts/preflight_branch.py`` / ``scripts/verify_encoding.py``):
|
|
50
|
+
|
|
51
|
+
- ``0`` -- resolved (or no-op when no conflict markers were present).
|
|
52
|
+
- ``1`` -- unresolvable: corrupted / mismatched / nested markers, or markers
|
|
53
|
+
remained after the resolve pass.
|
|
54
|
+
- ``2`` -- config error: ``--changelog-path`` does not exist, file unreadable,
|
|
55
|
+
or unrecognised CLI shape.
|
|
56
|
+
|
|
57
|
+
Out of scope (documented, NOT worked around):
|
|
58
|
+
|
|
59
|
+
- Conflicts INSIDE released sections (``## [0.X.Y]``) are NOT resolved here;
|
|
60
|
+
the script reports them as exit 1 unresolvable so the operator falls back
|
|
61
|
+
to ``edit_files`` (the manual fallback path documented in
|
|
62
|
+
``skills/deft-directive-swarm/SKILL.md`` Phase 6 Step 1).
|
|
63
|
+
- Multi-line entries with non-bullet continuation lines are preserved when
|
|
64
|
+
the continuation is indented (leading whitespace) but a bare blank line
|
|
65
|
+
ends the entry block. This matches the dominant CHANGELOG.md style.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
from __future__ import annotations
|
|
69
|
+
|
|
70
|
+
import argparse
|
|
71
|
+
import contextlib
|
|
72
|
+
import os
|
|
73
|
+
import re
|
|
74
|
+
import sys
|
|
75
|
+
import tempfile
|
|
76
|
+
from pathlib import Path
|
|
77
|
+
|
|
78
|
+
#: Top-level section header (``## [Unreleased]`` or ``## [0.26.0] - ...``).
|
|
79
|
+
SECTION_HEADER_RE = re.compile(r"^##\s+\[([^\]]+)\]")
|
|
80
|
+
|
|
81
|
+
#: Subsection header (``### Added`` / ``### Fixed`` / ...).
|
|
82
|
+
SUBSECTION_HEADER_RE = re.compile(r"^###\s+(.+?)\s*$")
|
|
83
|
+
|
|
84
|
+
#: Bullet entry start (`- entry text` -- leading whitespace tolerated for
|
|
85
|
+
#: indented sublists).
|
|
86
|
+
ENTRY_BULLET_RE = re.compile(r"^\s*-\s")
|
|
87
|
+
|
|
88
|
+
#: Issue-number reference (``(#911)`` / ``(#1234)``). The dedup heuristic
|
|
89
|
+
#: extracts the SET of all issue numbers in an entry; two entries are
|
|
90
|
+
#: considered duplicates iff their sets share at least one number.
|
|
91
|
+
ISSUE_NUM_RE = re.compile(r"\(#(\d+)\)")
|
|
92
|
+
|
|
93
|
+
#: Entry-start with an opening bold marker (``- **`` / ``* **``). A deft
|
|
94
|
+
#: CHANGELOG entry canonically opens ``- **<conventional-commit subject>** --``;
|
|
95
|
+
#: a *truncated* header keeps the opening ``**`` but loses the closing ``**``
|
|
96
|
+
#: (and the trailing ``(#NNN)``), which is the orphan-stub shape #1003 fixes.
|
|
97
|
+
ENTRY_BOLD_OPEN_RE = re.compile(r"^\s*[-*]\s+\*\*")
|
|
98
|
+
|
|
99
|
+
#: Number of normalized leading characters used by the content-prefix dedup
|
|
100
|
+
#: fallback for entries that carry no ``(#NNN)`` reference (#1003).
|
|
101
|
+
CONTENT_PREFIX_LEN = 60
|
|
102
|
+
|
|
103
|
+
#: Conflict markers (the three-state union of git's standard merge markers).
|
|
104
|
+
CONFLICT_HEAD_PREFIX = "<<<<<<< "
|
|
105
|
+
CONFLICT_SEP = "======="
|
|
106
|
+
CONFLICT_TAIL_PREFIX = ">>>>>>> "
|
|
107
|
+
|
|
108
|
+
#: Sentinel for "no ambient subsection" (entries directly under ``[Unreleased]``
|
|
109
|
+
#: with no ``###`` header above them). Empty string is used internally; on
|
|
110
|
+
#: render we emit entries-only without re-emitting any header.
|
|
111
|
+
AMBIENT_NONE = ""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _self_reconfigure_utf8() -> None:
|
|
115
|
+
"""Force UTF-8 stdout/stderr at script entry per #814.
|
|
116
|
+
|
|
117
|
+
Mirrors the block at the top of :mod:`scripts.preflight_branch` and
|
|
118
|
+
:mod:`scripts.verify_encoding`. Windows Python defaults stdout to cp1252
|
|
119
|
+
(or cp437) when invoked from a hook or Taskfile target, neither of which
|
|
120
|
+
has glyphs for the diagnostic messages this script may emit (``->``,
|
|
121
|
+
``check``, etc.). Without this reconfigure, the script can crash with
|
|
122
|
+
``UnicodeEncodeError`` AFTER the resolve has already succeeded, leaving
|
|
123
|
+
the operator unsure whether the file was rewritten. ``errors='replace'``
|
|
124
|
+
is a belt-and-suspenders fallback for the rare environment that still
|
|
125
|
+
cannot render UTF-8.
|
|
126
|
+
"""
|
|
127
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
128
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
129
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
130
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def find_unreleased_bounds(lines: list[str]) -> tuple[int, int] | tuple[None, None]:
|
|
134
|
+
"""Return ``(start, end)`` line indices of the ``[Unreleased]`` section.
|
|
135
|
+
|
|
136
|
+
``start`` is the index of the ``## [Unreleased]`` header line; ``end`` is
|
|
137
|
+
the index of the NEXT ``## [...]`` header (or ``len(lines)`` when
|
|
138
|
+
``[Unreleased]`` is the last section). When no ``[Unreleased]`` header
|
|
139
|
+
exists, returns ``(None, None)``.
|
|
140
|
+
"""
|
|
141
|
+
start: int | None = None
|
|
142
|
+
for i, line in enumerate(lines):
|
|
143
|
+
m = SECTION_HEADER_RE.match(line)
|
|
144
|
+
if m and m.group(1).strip().lower() == "unreleased":
|
|
145
|
+
start = i
|
|
146
|
+
break
|
|
147
|
+
if start is None:
|
|
148
|
+
return None, None
|
|
149
|
+
end = len(lines)
|
|
150
|
+
for j in range(start + 1, len(lines)):
|
|
151
|
+
if SECTION_HEADER_RE.match(lines[j]):
|
|
152
|
+
end = j
|
|
153
|
+
break
|
|
154
|
+
return start, end
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def find_conflict_blocks(
|
|
158
|
+
lines: list[str], start: int, end: int
|
|
159
|
+
) -> list[tuple[int, int, int]] | None:
|
|
160
|
+
"""Find conflict blocks within ``lines[start:end]``.
|
|
161
|
+
|
|
162
|
+
Returns a list of ``(head_idx, sep_idx, tail_idx)`` triples (inclusive line
|
|
163
|
+
indices). Returns ``None`` on any structural error -- nested marker, missing
|
|
164
|
+
separator, missing tail, or sep/tail before head -- so the caller can
|
|
165
|
+
surface exit 1 with a clear unresolvable diagnostic.
|
|
166
|
+
"""
|
|
167
|
+
blocks: list[tuple[int, int, int]] = []
|
|
168
|
+
i = start
|
|
169
|
+
while i < end:
|
|
170
|
+
line = lines[i]
|
|
171
|
+
if line.startswith(CONFLICT_HEAD_PREFIX):
|
|
172
|
+
head_idx = i
|
|
173
|
+
sep_idx: int | None = None
|
|
174
|
+
tail_idx: int | None = None
|
|
175
|
+
j = i + 1
|
|
176
|
+
while j < end:
|
|
177
|
+
inner = lines[j]
|
|
178
|
+
if inner.startswith(CONFLICT_HEAD_PREFIX):
|
|
179
|
+
# Nested conflict head before a tail closes the prior --
|
|
180
|
+
# not supported here. Bail to manual fallback.
|
|
181
|
+
return None
|
|
182
|
+
if inner == CONFLICT_SEP and sep_idx is None:
|
|
183
|
+
sep_idx = j
|
|
184
|
+
elif inner.startswith(CONFLICT_TAIL_PREFIX) and sep_idx is not None:
|
|
185
|
+
tail_idx = j
|
|
186
|
+
break
|
|
187
|
+
j += 1
|
|
188
|
+
if sep_idx is None or tail_idx is None:
|
|
189
|
+
return None
|
|
190
|
+
blocks.append((head_idx, sep_idx, tail_idx))
|
|
191
|
+
i = tail_idx + 1
|
|
192
|
+
elif line == CONFLICT_SEP or line.startswith(CONFLICT_TAIL_PREFIX):
|
|
193
|
+
# Stray separator/tail without a preceding head -- malformed.
|
|
194
|
+
return None
|
|
195
|
+
else:
|
|
196
|
+
i += 1
|
|
197
|
+
return blocks
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def find_ambient_subsection(
|
|
201
|
+
lines: list[str], conflict_start: int, unreleased_start: int
|
|
202
|
+
) -> str:
|
|
203
|
+
"""Walk back from ``conflict_start - 1`` to find the most recent ``### header``.
|
|
204
|
+
|
|
205
|
+
Stops at the ``[Unreleased]`` header to avoid matching subsections inside
|
|
206
|
+
a previously-rendered released section. Returns the subsection name when
|
|
207
|
+
found, else :data:`AMBIENT_NONE` (``""``).
|
|
208
|
+
"""
|
|
209
|
+
for i in range(conflict_start - 1, unreleased_start, -1):
|
|
210
|
+
m = SUBSECTION_HEADER_RE.match(lines[i])
|
|
211
|
+
if m:
|
|
212
|
+
return m.group(1).strip()
|
|
213
|
+
return AMBIENT_NONE
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def parse_side(
|
|
217
|
+
side_lines: list[str], ambient_subsection: str
|
|
218
|
+
) -> list[tuple[str, list[str]]]:
|
|
219
|
+
"""Parse one side of a conflict into ``[(subsection_name, entries)]``.
|
|
220
|
+
|
|
221
|
+
Each entry is the joined text (with embedded ``\\n``) of one bullet block,
|
|
222
|
+
including any indented continuation lines. Lines that are neither bullets
|
|
223
|
+
nor ``###`` headers are dropped between entry blocks (they are blank
|
|
224
|
+
separators that the renderer regenerates).
|
|
225
|
+
|
|
226
|
+
The ambient subsection collects entries that appear before the first
|
|
227
|
+
``###`` header in the side. Subsequent ``###`` headers introduce new
|
|
228
|
+
subsections in the order they appear.
|
|
229
|
+
"""
|
|
230
|
+
sections: list[tuple[str, list[str]]] = []
|
|
231
|
+
current_name = ambient_subsection
|
|
232
|
+
current_entries: list[str] = []
|
|
233
|
+
current_entry_lines: list[str] = []
|
|
234
|
+
|
|
235
|
+
def flush_entry() -> None:
|
|
236
|
+
nonlocal current_entry_lines
|
|
237
|
+
if current_entry_lines:
|
|
238
|
+
current_entries.append("\n".join(current_entry_lines))
|
|
239
|
+
current_entry_lines = []
|
|
240
|
+
|
|
241
|
+
def flush_section() -> None:
|
|
242
|
+
nonlocal current_entries
|
|
243
|
+
flush_entry()
|
|
244
|
+
# Drop empty ambient sections so the renderer does not emit empty
|
|
245
|
+
# subsection blocks for sides that contained zero entries above the
|
|
246
|
+
# first ``###`` header.
|
|
247
|
+
if current_entries or current_name != AMBIENT_NONE:
|
|
248
|
+
sections.append((current_name, current_entries))
|
|
249
|
+
current_entries = []
|
|
250
|
+
|
|
251
|
+
for raw_line in side_lines:
|
|
252
|
+
line = raw_line.rstrip("\n")
|
|
253
|
+
sub_m = SUBSECTION_HEADER_RE.match(line)
|
|
254
|
+
if sub_m:
|
|
255
|
+
flush_section()
|
|
256
|
+
current_name = sub_m.group(1).strip()
|
|
257
|
+
continue
|
|
258
|
+
if ENTRY_BULLET_RE.match(line):
|
|
259
|
+
flush_entry()
|
|
260
|
+
current_entry_lines = [line]
|
|
261
|
+
continue
|
|
262
|
+
if current_entry_lines:
|
|
263
|
+
stripped = line.strip()
|
|
264
|
+
if stripped == "":
|
|
265
|
+
# Blank line ends the current entry block.
|
|
266
|
+
flush_entry()
|
|
267
|
+
elif line.startswith((" ", "\t")):
|
|
268
|
+
# Indented continuation of the current entry.
|
|
269
|
+
current_entry_lines.append(line)
|
|
270
|
+
else:
|
|
271
|
+
# Non-bullet, non-indented, non-blank line: ends the entry,
|
|
272
|
+
# otherwise discarded as inter-entry prose.
|
|
273
|
+
flush_entry()
|
|
274
|
+
# Lines outside any entry are blank separators; the renderer
|
|
275
|
+
# regenerates them, so we drop them on parse.
|
|
276
|
+
|
|
277
|
+
flush_section()
|
|
278
|
+
return sections
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def issue_numbers(entry_text: str) -> set[str]:
|
|
282
|
+
"""Return the SET of ``#NNN`` issue numbers referenced in an entry."""
|
|
283
|
+
return set(ISSUE_NUM_RE.findall(entry_text))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def is_orphan_header(entry_text: str) -> bool:
|
|
287
|
+
"""Return ``True`` when ``entry_text`` is a truncated orphan header (#1003).
|
|
288
|
+
|
|
289
|
+
A deft CHANGELOG entry canonically opens
|
|
290
|
+
``- **<subject>** -- <body> (#NNN)``. A cascade rebase can splice a
|
|
291
|
+
*truncated* header that keeps the opening ``**`` but loses the closing
|
|
292
|
+
``**`` AND the ``(#NNN)`` reference, e.g.::
|
|
293
|
+
|
|
294
|
+
- **feat(scripts): `gh_rest.py` REST-fallback helpers
|
|
295
|
+
|
|
296
|
+
Such a stub has no ``(#NNN)`` dedup key, so the union-merge helper used to
|
|
297
|
+
preserve a fresh copy on every rebase -- two duplicate stubs shipped in
|
|
298
|
+
v0.26.2. An orphan header is detected as an entry whose first line opens a
|
|
299
|
+
bold span (``- **`` / ``* **``) but does NOT close it on that line
|
|
300
|
+
(fewer than two ``**`` markers), AND carries no ``(#NNN)`` reference
|
|
301
|
+
anywhere in the entry.
|
|
302
|
+
"""
|
|
303
|
+
first_line = entry_text.split("\n", 1)[0]
|
|
304
|
+
if not ENTRY_BOLD_OPEN_RE.match(first_line):
|
|
305
|
+
return False
|
|
306
|
+
# A well-formed header closes its bold span on the same line (>= 2 ``**``).
|
|
307
|
+
if first_line.count("**") >= 2:
|
|
308
|
+
return False
|
|
309
|
+
# A trailing issue reference is a valid dedup key -- not an orphan.
|
|
310
|
+
return not issue_numbers(entry_text)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def content_prefix(entry_text: str) -> str:
|
|
314
|
+
"""Return a normalized leading-content key for prefix-based dedup (#1003).
|
|
315
|
+
|
|
316
|
+
Entries that carry no ``(#NNN)`` reference have no issue-number dedup key.
|
|
317
|
+
To stop issue-numberless duplicates from accumulating across cascade
|
|
318
|
+
rebases, the helper falls back to a normalized content prefix: the first
|
|
319
|
+
line with its bullet marker, bold markers, and any ``(#NNN)`` references
|
|
320
|
+
stripped, whitespace collapsed, lowercased, and truncated to
|
|
321
|
+
:data:`CONTENT_PREFIX_LEN` chars. Dropping the ``(#NNN)`` token lets a
|
|
322
|
+
cross-parity duplicate collapse -- a HEAD entry that carries the issue
|
|
323
|
+
reference and an otherwise-identical branch entry that does not still
|
|
324
|
+
share a prefix.
|
|
325
|
+
"""
|
|
326
|
+
first_line = entry_text.split("\n", 1)[0]
|
|
327
|
+
stripped = re.sub(r"^\s*[-*]\s+", "", first_line, count=1)
|
|
328
|
+
stripped = stripped.replace("**", "")
|
|
329
|
+
stripped = ISSUE_NUM_RE.sub("", stripped)
|
|
330
|
+
stripped = " ".join(stripped.split())
|
|
331
|
+
return stripped[:CONTENT_PREFIX_LEN].lower()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def union_merge(
|
|
335
|
+
head_sections: list[tuple[str, list[str]]],
|
|
336
|
+
branch_sections: list[tuple[str, list[str]]],
|
|
337
|
+
*,
|
|
338
|
+
warnings: list[str] | None = None,
|
|
339
|
+
) -> list[tuple[str, list[str]]]:
|
|
340
|
+
"""Union-merge branch entries into HEAD's section structure.
|
|
341
|
+
|
|
342
|
+
Per the #911 contract:
|
|
343
|
+
- All HEAD entries are kept verbatim, in HEAD's order.
|
|
344
|
+
- For each branch entry, if its ``(#NNN)`` set does not overlap any
|
|
345
|
+
HEAD entry in the SAME subsection, the branch entry is PREPENDED
|
|
346
|
+
under that subsection.
|
|
347
|
+
- Subsections that exist only in the branch side are appended after
|
|
348
|
+
all HEAD subsections in the order they first appear in the branch.
|
|
349
|
+
|
|
350
|
+
Two #1003 safeguards stop truncated / issue-numberless stubs from
|
|
351
|
+
accumulating across cascade rebases:
|
|
352
|
+
|
|
353
|
+
- **Orphan-header drop.** A truncated orphan header (see
|
|
354
|
+
:func:`is_orphan_header`) has no dedup key, so it used to be prepended
|
|
355
|
+
fresh on every rebase. Such stubs are now DROPPED from BOTH sides and
|
|
356
|
+
never dedup against valid entries; each drop is recorded in
|
|
357
|
+
``warnings`` (when supplied) so the caller can surface a stderr WARN.
|
|
358
|
+
- **Content-prefix fallback.** A branch entry with NO ``(#NNN)``
|
|
359
|
+
reference is deduplicated against HEAD by a normalized content prefix
|
|
360
|
+
(see :func:`content_prefix`); when no HEAD entry shares its prefix it is
|
|
361
|
+
still prepended, so a genuinely new issue-numberless entry survives.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
def _warn(side: str, name: str, entry_text: str) -> None:
|
|
365
|
+
if warnings is None:
|
|
366
|
+
return
|
|
367
|
+
first_line = entry_text.split("\n", 1)[0]
|
|
368
|
+
subsection = name or "(ambient)"
|
|
369
|
+
warnings.append(
|
|
370
|
+
f"dropped truncated orphan header from {side} side under "
|
|
371
|
+
f"'{subsection}': {first_line!r}"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
head_dict: dict[str, list[str]] = {}
|
|
375
|
+
head_order: list[str] = []
|
|
376
|
+
for name, entries in head_sections:
|
|
377
|
+
kept: list[str] = []
|
|
378
|
+
for e in entries:
|
|
379
|
+
if is_orphan_header(e):
|
|
380
|
+
_warn("HEAD", name, e)
|
|
381
|
+
continue
|
|
382
|
+
kept.append(e)
|
|
383
|
+
if name in head_dict:
|
|
384
|
+
head_dict[name].extend(kept)
|
|
385
|
+
else:
|
|
386
|
+
head_dict[name] = list(kept)
|
|
387
|
+
head_order.append(name)
|
|
388
|
+
|
|
389
|
+
for name, entries in branch_sections:
|
|
390
|
+
if name not in head_dict:
|
|
391
|
+
head_dict[name] = []
|
|
392
|
+
head_order.append(name)
|
|
393
|
+
existing_nums: set[str] = set()
|
|
394
|
+
existing_prefixes: set[str] = set()
|
|
395
|
+
for e in head_dict[name]:
|
|
396
|
+
existing_nums |= issue_numbers(e)
|
|
397
|
+
existing_prefixes.add(content_prefix(e))
|
|
398
|
+
new_entries: list[str] = []
|
|
399
|
+
for e in entries:
|
|
400
|
+
if is_orphan_header(e):
|
|
401
|
+
_warn("branch", name, e)
|
|
402
|
+
continue
|
|
403
|
+
nums = issue_numbers(e)
|
|
404
|
+
if nums and nums & existing_nums:
|
|
405
|
+
continue
|
|
406
|
+
if not nums and content_prefix(e) in existing_prefixes:
|
|
407
|
+
# Content-prefix fallback: an issue-numberless entry whose
|
|
408
|
+
# normalized prefix already exists in HEAD is a duplicate.
|
|
409
|
+
continue
|
|
410
|
+
new_entries.append(e)
|
|
411
|
+
existing_nums |= nums
|
|
412
|
+
existing_prefixes.add(content_prefix(e))
|
|
413
|
+
# Prepend in branch-side order so the leftmost branch entry ends up
|
|
414
|
+
# at the top of the resolved section.
|
|
415
|
+
head_dict[name] = new_entries + head_dict[name]
|
|
416
|
+
|
|
417
|
+
return [(name, head_dict[name]) for name in head_order]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def render_resolved(
|
|
421
|
+
merged: list[tuple[str, list[str]]], ambient_subsection: str
|
|
422
|
+
) -> list[str]:
|
|
423
|
+
"""Render merged sections as a list of lines (no trailing newline).
|
|
424
|
+
|
|
425
|
+
The ambient subsection is rendered without a header (its ``###`` line
|
|
426
|
+
is already in the file ABOVE the conflict block). Other subsections are
|
|
427
|
+
rendered with their ``###`` header followed by a blank line, matching
|
|
428
|
+
the existing CHANGELOG.md house style.
|
|
429
|
+
"""
|
|
430
|
+
out: list[str] = []
|
|
431
|
+
for name, entries in merged:
|
|
432
|
+
if name != ambient_subsection:
|
|
433
|
+
if out and out[-1] != "":
|
|
434
|
+
out.append("")
|
|
435
|
+
out.append(f"### {name}")
|
|
436
|
+
out.append("")
|
|
437
|
+
for entry in entries:
|
|
438
|
+
for entry_line in entry.split("\n"):
|
|
439
|
+
out.append(entry_line)
|
|
440
|
+
# Trailing blank between non-ambient subsections is added on the
|
|
441
|
+
# next iteration's leading-blank insertion above. The final
|
|
442
|
+
# subsection gets no trailing blank here; the surrounding file
|
|
443
|
+
# context provides the spacing.
|
|
444
|
+
return out
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def resolve_changelog(content: str) -> tuple[str | None, str]:
|
|
448
|
+
"""Pure function: take CHANGELOG content, return (new_content, message).
|
|
449
|
+
|
|
450
|
+
Returns ``(new_content, "resolved" message)`` on a successful merge,
|
|
451
|
+
``(content, "no-op" message)`` when no conflicts were found, or
|
|
452
|
+
``(None, error message)`` when the content is unresolvable. Separated
|
|
453
|
+
from :func:`main` so tests can drive every branch without temp files.
|
|
454
|
+
"""
|
|
455
|
+
# Preserve trailing-newline behaviour: split keeps everything line-by-line
|
|
456
|
+
# and we re-join with ``\n`` then add a trailing newline iff the original
|
|
457
|
+
# had one. This mirrors the round-trip behaviour that ``edit_files``
|
|
458
|
+
# produces and matches tools/git's expectation.
|
|
459
|
+
had_trailing_newline = content.endswith("\n")
|
|
460
|
+
lines = content.split("\n")
|
|
461
|
+
# Drop the synthetic empty final element introduced by split() when the
|
|
462
|
+
# input ends with a newline. We re-introduce it on render.
|
|
463
|
+
if had_trailing_newline and lines and lines[-1] == "":
|
|
464
|
+
lines = lines[:-1]
|
|
465
|
+
|
|
466
|
+
unreleased_start, unreleased_end = find_unreleased_bounds(lines)
|
|
467
|
+
if unreleased_start is None:
|
|
468
|
+
# No [Unreleased] section -- check if any conflict markers exist
|
|
469
|
+
# anywhere; if so, fail unresolvable. Otherwise no-op.
|
|
470
|
+
if any(
|
|
471
|
+
line.startswith((CONFLICT_HEAD_PREFIX, CONFLICT_TAIL_PREFIX))
|
|
472
|
+
or line == CONFLICT_SEP
|
|
473
|
+
for line in lines
|
|
474
|
+
):
|
|
475
|
+
return None, (
|
|
476
|
+
"unresolvable: conflict markers present but no [Unreleased] "
|
|
477
|
+
"section found"
|
|
478
|
+
)
|
|
479
|
+
return content, "no-op: no [Unreleased] section, no conflict markers"
|
|
480
|
+
|
|
481
|
+
blocks = find_conflict_blocks(lines, unreleased_start, unreleased_end)
|
|
482
|
+
if blocks is None:
|
|
483
|
+
return None, (
|
|
484
|
+
"unresolvable: malformed conflict markers (nested / missing "
|
|
485
|
+
"separator / orphan tail) inside [Unreleased]"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# Detect conflicts OUTSIDE [Unreleased]: scan the rest of the file.
|
|
489
|
+
has_outside_marker = any(
|
|
490
|
+
(
|
|
491
|
+
line.startswith((CONFLICT_HEAD_PREFIX, CONFLICT_TAIL_PREFIX))
|
|
492
|
+
or line == CONFLICT_SEP
|
|
493
|
+
)
|
|
494
|
+
for i, line in enumerate(lines)
|
|
495
|
+
if i < unreleased_start or i >= unreleased_end
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
if not blocks:
|
|
499
|
+
if has_outside_marker:
|
|
500
|
+
return None, (
|
|
501
|
+
"unresolvable: conflict markers present outside [Unreleased] "
|
|
502
|
+
"section -- resolve manually with edit_files"
|
|
503
|
+
)
|
|
504
|
+
return content, "no-op: no conflict markers in [Unreleased]"
|
|
505
|
+
|
|
506
|
+
# Resolve each conflict block, walking back-to-front so earlier indices
|
|
507
|
+
# remain valid as we splice replacement lines in.
|
|
508
|
+
new_lines = list(lines)
|
|
509
|
+
warnings: list[str] = []
|
|
510
|
+
for head_idx, sep_idx, tail_idx in reversed(blocks):
|
|
511
|
+
# Sides are sliced exclusive of the markers themselves.
|
|
512
|
+
head_side = new_lines[head_idx + 1 : sep_idx]
|
|
513
|
+
branch_side = new_lines[sep_idx + 1 : tail_idx]
|
|
514
|
+
ambient = find_ambient_subsection(new_lines, head_idx, unreleased_start)
|
|
515
|
+
head_parsed = parse_side(head_side, ambient)
|
|
516
|
+
branch_parsed = parse_side(branch_side, ambient)
|
|
517
|
+
merged = union_merge(head_parsed, branch_parsed, warnings=warnings)
|
|
518
|
+
rendered = render_resolved(merged, ambient)
|
|
519
|
+
new_lines[head_idx : tail_idx + 1] = rendered
|
|
520
|
+
|
|
521
|
+
# Verify no markers remain anywhere in the file.
|
|
522
|
+
for line in new_lines:
|
|
523
|
+
if (
|
|
524
|
+
line.startswith((CONFLICT_HEAD_PREFIX, CONFLICT_TAIL_PREFIX))
|
|
525
|
+
or line == CONFLICT_SEP
|
|
526
|
+
):
|
|
527
|
+
if has_outside_marker:
|
|
528
|
+
return None, (
|
|
529
|
+
"unresolvable: conflict markers remain outside "
|
|
530
|
+
"[Unreleased] -- resolve manually with edit_files"
|
|
531
|
+
)
|
|
532
|
+
return None, (
|
|
533
|
+
"unresolvable: conflict markers remain after resolve "
|
|
534
|
+
"(internal error -- please file an issue)"
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Surface any dropped orphan stubs on stderr so the operator can recover
|
|
538
|
+
# the canonical entry manually if the drop was unexpected (#1003 AC-1).
|
|
539
|
+
for warning in warnings:
|
|
540
|
+
print(f"WARN resolve_changelog: {warning}", file=sys.stderr)
|
|
541
|
+
|
|
542
|
+
new_content = "\n".join(new_lines)
|
|
543
|
+
if had_trailing_newline:
|
|
544
|
+
new_content += "\n"
|
|
545
|
+
return new_content, f"resolved: union-merged {len(blocks)} conflict block(s)"
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def atomic_write(path: Path, content: str) -> None:
|
|
549
|
+
"""Write ``content`` to ``path`` atomically via tempfile + ``os.replace``.
|
|
550
|
+
|
|
551
|
+
The temp file is created in the SAME directory as the target so the
|
|
552
|
+
rename is on the same filesystem (``os.replace`` is atomic only within a
|
|
553
|
+
single filesystem on POSIX; on Windows it requires same-volume too).
|
|
554
|
+
UTF-8 encoding mandated per #798 root-cause rule -- no PowerShell-side
|
|
555
|
+
string round-trip ever touches the bytes.
|
|
556
|
+
"""
|
|
557
|
+
parent = path.parent
|
|
558
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
559
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
560
|
+
prefix=f".{path.name}.", suffix=".tmp", dir=str(parent)
|
|
561
|
+
)
|
|
562
|
+
tmp_path = Path(tmp_name)
|
|
563
|
+
try:
|
|
564
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
|
|
565
|
+
fh.write(content)
|
|
566
|
+
os.replace(str(tmp_path), str(path))
|
|
567
|
+
except Exception:
|
|
568
|
+
# Best-effort cleanup of the temp file on any failure path.
|
|
569
|
+
with contextlib.suppress(OSError):
|
|
570
|
+
tmp_path.unlink()
|
|
571
|
+
raise
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def evaluate(
|
|
575
|
+
changelog_path: Path, *, dry_run: bool = False
|
|
576
|
+
) -> tuple[int, str]:
|
|
577
|
+
"""Pure function returning ``(exit_code, human_message)``.
|
|
578
|
+
|
|
579
|
+
Separated from :func:`main` so tests can drive every state without
|
|
580
|
+
``capsys`` plumbing or ``argparse`` round-tripping.
|
|
581
|
+
"""
|
|
582
|
+
if not changelog_path.exists():
|
|
583
|
+
return 2, (
|
|
584
|
+
f"config error: CHANGELOG path does not exist: {changelog_path}\n"
|
|
585
|
+
" Recovery: pass --changelog-path pointing at an existing file."
|
|
586
|
+
)
|
|
587
|
+
if not changelog_path.is_file():
|
|
588
|
+
return 2, (
|
|
589
|
+
f"config error: CHANGELOG path is not a regular file: {changelog_path}"
|
|
590
|
+
)
|
|
591
|
+
try:
|
|
592
|
+
content = changelog_path.read_text(encoding="utf-8")
|
|
593
|
+
except OSError as exc:
|
|
594
|
+
return 2, f"config error: cannot read {changelog_path}: {exc}"
|
|
595
|
+
|
|
596
|
+
new_content, message = resolve_changelog(content)
|
|
597
|
+
if new_content is None:
|
|
598
|
+
# Greptile P2 (PR #999): inner messages from resolve_changelog already
|
|
599
|
+
# carry the canonical ``unresolvable: ...`` prefix; do not re-prefix
|
|
600
|
+
# here or operators see ``unresolvable: unresolvable: ...`` on stderr.
|
|
601
|
+
return 1, f"{message}\n Path: {changelog_path}"
|
|
602
|
+
|
|
603
|
+
if new_content == content:
|
|
604
|
+
return 0, f"OK {changelog_path}: {message}"
|
|
605
|
+
|
|
606
|
+
if dry_run:
|
|
607
|
+
return 0, f"OK (dry-run) {changelog_path}: {message}"
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
atomic_write(changelog_path, new_content)
|
|
611
|
+
except OSError as exc:
|
|
612
|
+
return 2, f"config error: cannot write {changelog_path}: {exc}"
|
|
613
|
+
|
|
614
|
+
return 0, f"OK {changelog_path}: {message}"
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
618
|
+
parser = argparse.ArgumentParser(
|
|
619
|
+
prog="resolve_changelog_unreleased.py",
|
|
620
|
+
description=(
|
|
621
|
+
"Union-merge CHANGELOG.md [Unreleased] conflicts (#911). "
|
|
622
|
+
"Replaces the HEAD-take-and-discard pattern that silently "
|
|
623
|
+
"dropped the rebasing branch's CHANGELOG entry on swarm "
|
|
624
|
+
"cascade rebase. Three-state exit: 0 resolved (or no-op), "
|
|
625
|
+
"1 unresolvable, 2 config error."
|
|
626
|
+
),
|
|
627
|
+
)
|
|
628
|
+
parser.add_argument(
|
|
629
|
+
"--changelog-path",
|
|
630
|
+
default="CHANGELOG.md",
|
|
631
|
+
help=(
|
|
632
|
+
"Path to CHANGELOG.md (default: ./CHANGELOG.md relative to "
|
|
633
|
+
"the working directory)."
|
|
634
|
+
),
|
|
635
|
+
)
|
|
636
|
+
parser.add_argument(
|
|
637
|
+
"--dry-run",
|
|
638
|
+
action="store_true",
|
|
639
|
+
help=(
|
|
640
|
+
"Compute the resolution and report what would change without "
|
|
641
|
+
"writing the file. Useful for review-cycle preview."
|
|
642
|
+
),
|
|
643
|
+
)
|
|
644
|
+
parser.add_argument(
|
|
645
|
+
"--quiet",
|
|
646
|
+
action="store_true",
|
|
647
|
+
help="Suppress the OK message (errors still print).",
|
|
648
|
+
)
|
|
649
|
+
return parser
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def main(argv: list[str] | None = None) -> int:
|
|
653
|
+
_self_reconfigure_utf8()
|
|
654
|
+
parser = _build_parser()
|
|
655
|
+
args = parser.parse_args(argv)
|
|
656
|
+
changelog_path = Path(args.changelog_path).resolve()
|
|
657
|
+
code, msg = evaluate(changelog_path, dry_run=args.dry_run)
|
|
658
|
+
if code == 0:
|
|
659
|
+
if not args.quiet:
|
|
660
|
+
print(msg)
|
|
661
|
+
else:
|
|
662
|
+
print(msg, file=sys.stderr)
|
|
663
|
+
return code
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
if __name__ == "__main__":
|
|
667
|
+
sys.exit(main())
|