cctally 1.8.2 → 1.9.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/CHANGELOG.md CHANGED
@@ -5,6 +5,15 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.9.0] - 2026-05-19
9
+
10
+ ### Removed
11
+ - `cctally release` subcommand — maintainer-only release automation moved to private `bin/cctally-release`. End-user npm/brew installs no longer carry release tooling, and `cctally setup` no longer expects a `cctally-release` symlink (stale symlinks from prior versions are auto-cleaned by `cctally setup --install`). The actual release process is unchanged for the maintainer; see `docs/RELEASE.md`.
12
+
13
+ ### Fixed
14
+ - Maintainer-only release tooling: recovery hints in `bin/_cctally_release.py` (Phase 4 `gh auth` fallback, Phase 5 npm poll timeout, Phase 6 brew dirty-clone refusal) now tell the operator to re-run `bin/cctally-release --resume` from the cctally-dev checkout — bare `cctally-release` is not on `~/.local/bin/` after the release-command split (`cctally setup` no longer auto-symlinks it). Round 1 of the split's review accumulator updated these hints from the removed `cctally release --resume` to `cctally-release --resume`; round 3 refines them to the path-relative form so an operator following the recovery instructions verbatim doesn't hit `command not found` unless they manually created an alias. The pre-existing docs convention (bare name in `docs/RELEASE.md` / `.claude/skills/release-cctally/SKILL.md` examples with an explicit "substitute `bin/cctally-release` (or your alias)" note) is unchanged.
15
+ - `cctally setup` and `cctally setup --uninstall` retire legacy `~/.local/bin/cctally-release` symlinks left behind by pre-v1.9.0 brew/npm installs. Previously the round-2 "stale" predicate required the symlink target to be *dangling*, which preserved a maintainer's manual link to the current checkout's still-shipped `bin/cctally-release` (intentional) but also preserved a legacy auto-installed link whose target points at an OLD homebrew keg / `node_modules/cctally/` tree that lingers on disk until `brew cleanup` (unintentional — leaves the retired command on PATH across upgrades). New `_setup_symlink_is_retired` adds a foreign-install-root branch alongside the dangling branch: a target whose path contains `/Cellar/cctally/` or `/node_modules/cctally/` AND that token is NOT in the current install's `repo_root` is classified as retired and removed. Both the install-time cleanup and the read-only `--status` detection route through it (so they agree on what's stale); the uninstall site now composes `target == expected OR is_retired(...)` for symmetric coverage. A maintainer's hand-rolled link to the current cctally-dev checkout (`~/.local/bin/cctally-release -> <checkout>/bin/cctally-release`) is still preserved — that target is neither dangling nor under a foreign install root. Regression: `tests/test_setup_stale_symlinks.py` adds four new scenarios — brew-keg/npm-modules cleanup, brew-keg `--status` detection, brew-keg uninstall.
16
+
8
17
  ## [1.8.2] - 2026-05-18
9
18
 
10
19
  ### Added
@@ -242,10 +242,22 @@ def _setup_local_bin_dir() -> pathlib.Path:
242
242
  @dataclasses.dataclass
243
243
  class _SetupSymlinkResult:
244
244
  name: str
245
- status: str # "created" | "already" | "replaced" | "failed"
245
+ status: str # "created" | "already" | "replaced" | "failed" | "removed-stale"
246
246
  detail: str = ""
247
247
 
248
248
 
249
+ # Symlink names cctally USED to install but no longer does. We keep
250
+ # cleaning them up for one major-version's worth of upgraders so
251
+ # `~/.local/bin/` doesn't accumulate dangling symlinks pointing at
252
+ # scripts the current cctally checkout doesn't ship. Removal is
253
+ # conservative: we only unlink symlinks whose `readlink()` target's
254
+ # basename matches the stale name — a user's hand-rolled symlink with
255
+ # the same name pointing elsewhere is left alone.
256
+ _SETUP_STALE_SYMLINK_NAMES = (
257
+ "cctally-release", # Removed in v1.9.0; release tooling went private.
258
+ )
259
+
260
+
249
261
  def _setup_resolve_symlink_source(repo_root: pathlib.Path, name: str) -> pathlib.Path:
250
262
  """Resolve the symlink target for a given PATH-name.
251
263
 
@@ -326,6 +338,148 @@ def _setup_create_symlinks(
326
338
  return results
327
339
 
328
340
 
341
+ def _setup_cleanup_stale_symlinks(
342
+ dst_dir: pathlib.Path,
343
+ ) -> list[_SetupSymlinkResult]:
344
+ """Unlink stale `cctally-*` symlinks left over from prior versions.
345
+
346
+ For each entry in :data:`_SETUP_STALE_SYMLINK_NAMES`, removes the
347
+ matching symlink in ``dst_dir`` when its readlink target basename
348
+ matches the stale name AND the target points at a "retired" cctally
349
+ install — meaning either (a) the target is *dangling* (no longer
350
+ resolves to a real file, e.g. an old checkout that was deleted) OR
351
+ (b) the target lives under a *foreign* cctally install root
352
+ (Homebrew keg, npm ``node_modules/cctally/``), i.e. an install root
353
+ other than the one currently running ``cctally setup``.
354
+
355
+ The foreign-root clause is what handles the common upgrade path:
356
+ after ``brew upgrade cctally`` or ``npm i -g cctally@<new>``, the
357
+ *prior* keg / module dir often lingers on disk until ``brew
358
+ cleanup`` (or the next npm install GC), so a legacy
359
+ ``~/.local/bin/cctally-release`` symlink from a pre-v1.9.0 ``cctally
360
+ setup`` still resolves to an existing file under the *old* install
361
+ root. The dangling-only predicate would skip it, leaving the retired
362
+ command on PATH; the foreign-root check retires it instead.
363
+
364
+ Both clauses still preserve the maintainer's intentional manual
365
+ link to the *current* checkout's still-shipped retired tooling
366
+ (e.g. ``~/.local/bin/cctally-release ->
367
+ <cctally-dev>/bin/cctally-release``) — that target is neither
368
+ dangling nor foreign, so it's left alone. Likewise a hand-rolled
369
+ link pointing somewhere unrelated (``~/scripts/...``) survives.
370
+
371
+ Returns a list of :class:`_SetupSymlinkResult` entries for the
372
+ actions taken (so callers can fold them into install output).
373
+ """
374
+ results: list[_SetupSymlinkResult] = []
375
+ repo_root = _setup_resolve_repo_root()
376
+ for name in _SETUP_STALE_SYMLINK_NAMES:
377
+ dst = dst_dir / name
378
+ if not _setup_symlink_is_retired(dst, name, repo_root):
379
+ continue
380
+ try:
381
+ dst.unlink()
382
+ results.append(
383
+ _SetupSymlinkResult(name, "removed-stale", "stale (from prior version)")
384
+ )
385
+ except OSError as exc:
386
+ results.append(
387
+ _SetupSymlinkResult(name, "failed", f"unlink failed: {exc}")
388
+ )
389
+ return results
390
+
391
+
392
+ # Path tokens that identify a "foreign" cctally install root — i.e. a
393
+ # directory tree managed by a different distribution channel (or a
394
+ # different version of the same channel) than the one currently running
395
+ # ``cctally setup``. A symlink whose target sits under one of these is
396
+ # almost certainly a legacy auto-installed link from a prior version
397
+ # whose install root the current run does NOT manage. Pre-resolved (no
398
+ # trailing slash) so substring matching works on either UNIX or
399
+ # resolved forms.
400
+ _SETUP_FOREIGN_INSTALL_ROOT_TOKENS = (
401
+ # Homebrew keeps every installed version under
402
+ # ``<prefix>/Cellar/cctally/<version>/``. A target inside any of
403
+ # these directories points at a brew install — either the current
404
+ # one (when ``cctally setup`` runs from a brew install — possible
405
+ # but rare) or an older keg still on disk pending ``brew cleanup``.
406
+ "/Cellar/cctally/",
407
+ # npm globals land at ``<prefix>/lib/node_modules/cctally/``; npm
408
+ # locals at ``<project>/node_modules/cctally/``. Either way the
409
+ # ``/node_modules/cctally/`` segment is the discriminator. pnpm and
410
+ # yarn variants keep the segment, so this catches them too.
411
+ "/node_modules/cctally/",
412
+ )
413
+
414
+
415
+ def _setup_symlink_is_retired(
416
+ dst: pathlib.Path, name: str, repo_root: pathlib.Path,
417
+ ) -> bool:
418
+ """Detection predicate for retired auto-symlinks at install time.
419
+
420
+ Returns True iff ``dst`` is a symlink, its readlink target basename
421
+ matches ``name``, AND the target points at a "retired" cctally
422
+ install — i.e. one the current ``cctally setup`` run does not
423
+ manage. Two retirement classes:
424
+
425
+ 1. **Dangling target** — readlink target does not resolve to an
426
+ existing filesystem entry. Covers the deleted-checkout case.
427
+ 2. **Foreign install root** — target path contains one of
428
+ :data:`_SETUP_FOREIGN_INSTALL_ROOT_TOKENS` (``/Cellar/cctally/``
429
+ or ``/node_modules/cctally/``) AND that token does NOT also
430
+ appear in the current ``repo_root``. The second clause is what
431
+ keeps the (rare) case of running ``cctally setup`` *from* an
432
+ npm/brew install correctly — a symlink at the same install root
433
+ is NOT foreign, so it's left to the active-symlinks loop in
434
+ :func:`_setup_install` / :func:`_setup_uninstall`.
435
+
436
+ Targets that exist but live outside any recognized install root
437
+ (e.g. a maintainer's manual link to ``<checkout>/bin/cctally-release``
438
+ or a hand-rolled link at ``~/scripts/cctally-release``) are
439
+ preserved — they're either explicit operator setups or genuine
440
+ user-managed scripts that happen to share the name.
441
+
442
+ Shared by the cleanup site (``_setup_cleanup_stale_symlinks``) and
443
+ the read-only detection site (``_setup_detect_stale_symlinks``) so
444
+ ``--status`` and ``setup`` agree on what they call "stale".
445
+ """
446
+ if not dst.is_symlink():
447
+ return False
448
+ try:
449
+ target = os.readlink(dst)
450
+ except OSError:
451
+ return False
452
+ if pathlib.Path(target).name != name:
453
+ # User-managed symlink that happens to share the name; leave alone.
454
+ return False
455
+ # Resolve target relative to the symlink's parent so relative
456
+ # readlinks (rare for cctally setup-installed links, but possible
457
+ # for hand-rolled ones) classify correctly. Use lexists()-style
458
+ # check via Path.exists() — broken links return False, which is
459
+ # the "dangling, treat as stale" branch we want.
460
+ target_path = pathlib.Path(target)
461
+ if not target_path.is_absolute():
462
+ target_path = dst.parent / target_path
463
+ try:
464
+ target_exists = target_path.exists()
465
+ except OSError:
466
+ # Permission errors etc. — be conservative and don't remove.
467
+ return False
468
+ if not target_exists:
469
+ return True
470
+ # Target exists — retire only if it lives under a *foreign* install
471
+ # root (a different brew keg / npm module dir than the one running
472
+ # this setup). Compare against ``repo_root`` so a setup running
473
+ # *from* an npm/brew install doesn't classify its own siblings as
474
+ # foreign — that's the active-symlinks loop's job.
475
+ target_str = str(target_path)
476
+ repo_root_str = str(repo_root)
477
+ for token in _SETUP_FOREIGN_INSTALL_ROOT_TOKENS:
478
+ if token in target_str and token not in repo_root_str:
479
+ return True
480
+ return False
481
+
482
+
329
483
  def _setup_path_includes_local_bin() -> bool:
330
484
  local_bin = str(_setup_local_bin_dir())
331
485
  return local_bin in os.environ.get("PATH", "").split(os.pathsep)
@@ -816,12 +970,38 @@ def _setup_compute_symlink_state(
816
970
  return out
817
971
 
818
972
 
973
+ def _setup_detect_stale_symlinks(dst_dir: pathlib.Path) -> list[str]:
974
+ """Return names of stale-but-still-present cctally symlinks in ``dst_dir``.
975
+
976
+ Mirrors :func:`_setup_cleanup_stale_symlinks` detection (without
977
+ removing): a symlink is "stale" when its name appears in
978
+ :data:`_SETUP_STALE_SYMLINK_NAMES`, its readlink target basename
979
+ matches that name, AND the target is "retired" — either dangling
980
+ OR pointing at a foreign cctally install root (see
981
+ :func:`_setup_symlink_is_retired`). Routing through the same
982
+ predicate keeps ``--status`` and ``setup`` in lockstep so a
983
+ manually-maintained link to a still-shipped retired tool
984
+ (``~/.local/bin/cctally-release -> <checkout>/bin/cctally-release``)
985
+ is neither reported as stale nor removed, while a legacy link left
986
+ over from a pre-v1.9.0 brew/npm install (target still resolves into
987
+ the old keg / ``node_modules`` tree) IS retired.
988
+ """
989
+ found: list[str] = []
990
+ repo_root = _setup_resolve_repo_root()
991
+ for name in _SETUP_STALE_SYMLINK_NAMES:
992
+ dst = dst_dir / name
993
+ if _setup_symlink_is_retired(dst, name, repo_root):
994
+ found.append(name)
995
+ return found
996
+
997
+
819
998
  def _setup_status(args: argparse.Namespace) -> int:
820
999
  c = _cctally()
821
1000
  repo_root = _setup_resolve_repo_root()
822
1001
  dst_dir = _setup_local_bin_dir()
823
1002
  sym_state = _setup_compute_symlink_state(repo_root, dst_dir)
824
1003
  sym_ok = sum(1 for _, s in sym_state if s == "ok")
1004
+ stale_syms = _setup_detect_stale_symlinks(dst_dir)
825
1005
  on_path = _setup_path_includes_local_bin()
826
1006
  try:
827
1007
  settings = c._load_claude_settings()
@@ -842,6 +1022,7 @@ def _setup_status(args: argparse.Namespace) -> int:
842
1022
  "install": {
843
1023
  "symlinks_present": sym_ok,
844
1024
  "symlinks_total": len(c.SETUP_SYMLINK_NAMES),
1025
+ "symlinks_stale": stale_syms,
845
1026
  "path_includes": on_path,
846
1027
  },
847
1028
  "hooks": {ev: hook_counts[ev] for ev in c.SETUP_HOOK_EVENTS},
@@ -869,6 +1050,11 @@ def _setup_status(args: argparse.Namespace) -> int:
869
1050
  out.append("Install")
870
1051
  sym_marker = "✓" if sym_ok == len(c.SETUP_SYMLINK_NAMES) else "✗"
871
1052
  out.append(f" Symlinks {sym_ok}/{len(c.SETUP_SYMLINK_NAMES)} present at {dst_dir}/ {sym_marker}")
1053
+ if stale_syms:
1054
+ out.append(
1055
+ f" Stale symlinks {len(stale_syms)} from prior version: {', '.join(stale_syms)} ⚠"
1056
+ )
1057
+ out.append(" run `cctally setup` to remove")
872
1058
  out.append(f" PATH includes {'yes' if on_path else 'no'} "
873
1059
  f"{'✓' if on_path else '⚠'}")
874
1060
  out.append(f"Hooks ({c.CLAUDE_SETTINGS_PATH})")
@@ -950,6 +1136,50 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
950
1136
  sym_removed += 1
951
1137
  except OSError as exc:
952
1138
  eprint(f"setup: failed to remove {dst}: {exc}")
1139
+ # Also clean up legacy symlinks that older cctally versions used to
1140
+ # install but the current version no longer manages (see
1141
+ # :data:`_SETUP_STALE_SYMLINK_NAMES`). Without this loop, an
1142
+ # upgrader who ran ``cctally setup`` on a prior version and now runs
1143
+ # ``cctally setup --uninstall`` would keep e.g.
1144
+ # ``~/.local/bin/cctally-release`` on PATH.
1145
+ #
1146
+ # Two removal predicates compose here:
1147
+ # 1. ``target == _setup_resolve_symlink_source(repo_root, name)``
1148
+ # — the legacy auto-installed link points at *this* install
1149
+ # root's binary (the common case for git-checkout upgraders
1150
+ # who ran ``cctally setup`` on a prior version, then ``git
1151
+ # pull``ed; symmetric with how the active-symlink loop above
1152
+ # treats user-pointed `cctally` itself).
1153
+ # 2. ``_setup_symlink_is_retired`` — target is dangling OR lives
1154
+ # under a *foreign* cctally install root (old brew keg /
1155
+ # ``node_modules/cctally/`` left behind by ``brew upgrade``
1156
+ # or ``npm i -g cctally@<new>``). Without this, the common
1157
+ # brew/npm upgrade-then-uninstall path leaves the legacy
1158
+ # symlink in place because the prior keg's binary still
1159
+ # exists on disk (``target != expected``).
1160
+ #
1161
+ # A maintainer's hand-rolled link pointing somewhere unrelated
1162
+ # (``~/scripts/...``) is preserved by both predicates.
1163
+ for name in _SETUP_STALE_SYMLINK_NAMES:
1164
+ dst = dst_dir / name
1165
+ if not dst.is_symlink():
1166
+ continue
1167
+ try:
1168
+ target = pathlib.Path(os.readlink(dst))
1169
+ except OSError:
1170
+ continue
1171
+ expected = _setup_resolve_symlink_source(repo_root, name)
1172
+ should_remove = (
1173
+ target == expected
1174
+ or _setup_symlink_is_retired(dst, name, repo_root)
1175
+ )
1176
+ if not should_remove:
1177
+ continue
1178
+ try:
1179
+ dst.unlink()
1180
+ sym_removed += 1
1181
+ except OSError as exc:
1182
+ eprint(f"setup: failed to remove {dst}: {exc}")
953
1183
  out.append(f"Removed {sym_removed} symlinks from {dst_dir}/")
954
1184
 
955
1185
  legacy = _setup_detect_legacy_snippet()
@@ -1336,6 +1566,13 @@ def _setup_install(args: argparse.Namespace) -> int:
1336
1566
  eprint(f"setup: {exc}")
1337
1567
  return 1
1338
1568
 
1569
+ # Clean up symlinks left behind by prior cctally versions whose
1570
+ # subcommand surface has changed (e.g. v1.9.0 retired
1571
+ # `cctally-release` when release tooling went private). Only unlinks
1572
+ # symlinks whose target's basename matches the stale name; never
1573
+ # disturbs a user's hand-rolled symlink sharing the name.
1574
+ stale_results = _setup_cleanup_stale_symlinks(dst_dir)
1575
+
1339
1576
  sym_results = _setup_create_symlinks(repo_root, dst_dir)
1340
1577
  failed = [r for r in sym_results if r.status == "failed"]
1341
1578
  if failed:
@@ -1354,6 +1591,16 @@ def _setup_install(args: argparse.Namespace) -> int:
1354
1591
  detail_parts.append(f"{repl_count} re-pointed")
1355
1592
  detail = ", ".join(detail_parts) or "no changes"
1356
1593
  out.append(f"✓ Symlinks at {dst_dir}/: {len(sym_results)}/{len(sym_results)} ({detail})")
1594
+ removed_stale = [r for r in stale_results if r.status == "removed-stale"]
1595
+ failed_stale = [r for r in stale_results if r.status == "failed"]
1596
+ if removed_stale:
1597
+ out.append(
1598
+ "✓ Cleaned up stale symlink(s) from prior version: "
1599
+ + ", ".join(r.name for r in removed_stale)
1600
+ )
1601
+ for r in failed_stale:
1602
+ out.append(f"⚠ Could not remove stale {r.name}: {r.detail}")
1603
+ warnings += 1
1357
1604
 
1358
1605
  if not _setup_path_includes_local_bin():
1359
1606
  warnings += 1
@@ -116,10 +116,16 @@ What stays in bin/cctally:
116
116
  write surface in cmd_dashboard works unchanged; moved code
117
117
  reads via ``c.X``.
118
118
  - ``eprint``, ``_now_utc`` (used by moved code via shim/accessor),
119
- ``_release_read_latest_release_version`` (stays in cctally per
120
- spec §6.7 — 6+ external callers, file I/O over CHANGELOG.md),
119
+ ``_release_read_latest_release_version`` (impl moved to
120
+ ``_lib_changelog._read_latest_changelog_version``; cctally
121
+ re-exports the historical name. The shim below routes through
122
+ ``sys.modules['cctally']`` so test ``monkeypatch.setitem(ns, ...)``
123
+ on the historical name still overrides the 5 internal callers
124
+ here),
121
125
  ``_release_parse_semver`` / ``_release_semver_sort_key`` (lives
122
- in ``_lib_semver`` and re-exported by cctally),
126
+ in ``_lib_semver``; shims below route directly to that module —
127
+ the cctally re-exports were removed in the release-command split
128
+ privatization),
123
129
  ``load_config`` (lives in ``_cctally_config``; re-exported),
124
130
  ``_BANNER_SUPPRESSED_COMMANDS`` (lives in ``_cctally_db``;
125
131
  re-exported by cctally — composed with the update-only
@@ -210,17 +216,35 @@ def load_config(*args, **kwargs):
210
216
 
211
217
 
212
218
  def _release_read_latest_release_version(*args, **kwargs):
219
+ """Back-compat shim. The implementation now lives in
220
+ ``bin/_lib_changelog._read_latest_changelog_version``; ``bin/cctally``
221
+ re-exports it under the historical name. This shim routes through
222
+ the ``cctally`` namespace (not directly to ``_lib_changelog``) so
223
+ that ``monkeypatch.setitem(ns, "_release_read_latest_release_version", ...)``
224
+ in tests/test_update.py + tests/test_release_internals.py overrides
225
+ the 5+ internal callers in this module. New code should call
226
+ ``_lib_changelog._read_latest_changelog_version`` directly.
227
+ """
213
228
  return sys.modules["cctally"]._release_read_latest_release_version(
214
229
  *args, **kwargs
215
230
  )
216
231
 
217
232
 
233
+ # The semver shims now resolve via ``_lib_semver`` directly — the
234
+ # cctally re-exports (``_release_parse_semver`` / ``_release_semver_sort_key``)
235
+ # were removed in the release-command split privatization. Kept as
236
+ # call-time shims (vs. module-top ``from _lib_semver import ...``) so
237
+ # tests can still ``monkeypatch.setitem(ns, "_release_parse_semver", ...)``
238
+ # on this module's namespace if needed; the bare-name lookup inside this
239
+ # module resolves here, not at the import site.
218
240
  def _release_parse_semver(*args, **kwargs):
219
- return sys.modules["cctally"]._release_parse_semver(*args, **kwargs)
241
+ import _lib_semver
242
+ return _lib_semver._release_parse_semver(*args, **kwargs)
220
243
 
221
244
 
222
245
  def _release_semver_sort_key(*args, **kwargs):
223
- return sys.modules["cctally"]._release_semver_sort_key(*args, **kwargs)
246
+ import _lib_semver
247
+ return _lib_semver._release_semver_sort_key(*args, **kwargs)
224
248
 
225
249
 
226
250
  def _normalize_alerts_enabled_value(*args, **kwargs):
@@ -0,0 +1,44 @@
1
+ """Public helper: read the latest stamped release header from CHANGELOG.md.
2
+
3
+ Read-only. Pure with respect to inputs (CHANGELOG.md contents). The
4
+ historical name ``_release_read_latest_release_version`` carried a
5
+ ``_release_`` prefix because the helper originated with the release-
6
+ automation work, but the function is not release-machinery: doctor,
7
+ the share kernel, and ``cctally --version`` all read it. Lives in a
8
+ public sibling so the maintainer-only release tooling can move to a
9
+ private artifact without dragging the version reader with it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+
16
+
17
+ def _cctally():
18
+ """Call-time accessor for the ``cctally`` module (project memory
19
+ ``_cctally() accessor pattern``). Avoids module-top ``import cctally``
20
+ so monkeypatch-sensitive globals (``CHANGELOG_PATH`` and
21
+ ``RELEASE_HEADER_RE``) stay reachable for tests."""
22
+ return sys.modules["cctally"]
23
+
24
+
25
+ def _read_latest_changelog_version() -> tuple[str, str] | None:
26
+ """Read latest ``## [X.Y.Z] - YYYY-MM-DD`` header from
27
+ ``CHANGELOG_PATH``. Returns ``(version, date)`` or ``None`` if the
28
+ file is missing or has no stamped release header.
29
+
30
+ Body is byte-equivalent to the original
31
+ ``_release_read_latest_release_version`` definition in ``bin/cctally``
32
+ (the rename is the only intentional change); the regex
33
+ ``RELEASE_HEADER_RE`` is read from the ``cctally`` module so any
34
+ in-process update to the pattern remains the single source of truth.
35
+ """
36
+ c = _cctally()
37
+ try:
38
+ text = c.CHANGELOG_PATH.read_text(encoding="utf-8")
39
+ except FileNotFoundError:
40
+ return None
41
+ m = c.RELEASE_HEADER_RE.search(text)
42
+ if not m:
43
+ return None
44
+ return (m.group(1), m.group(2))
@@ -76,7 +76,7 @@ def _release_compute_next_version(
76
76
  return _release_format_semver(nxt_maj, nxt_min, nxt_pat, prerelease_id, 1)
77
77
 
78
78
  if is_prerelease:
79
- raise ValueError("current version is a prerelease; run 'cctally release finalize' first or use --bump in a prerelease bump")
79
+ raise ValueError("current version is a prerelease; run 'cctally-release finalize' first or use --bump in a prerelease bump")
80
80
 
81
81
  if kind == "patch":
82
82
  return _release_format_semver(cur_maj, cur_min, cur_pat + 1)
@@ -289,8 +289,10 @@ def _release_version() -> str:
289
289
  at `bin/cctally:86`). Falls back to `"dev"` when CHANGELOG is unreadable
290
290
  or has no stamped release entry yet (pre-release dev builds).
291
291
 
292
- Parallel to `_release_read_latest_release_version` in `bin/cctally` —
293
- intentionally duplicated so the template module stays free of any
292
+ Parallel to `_lib_changelog._read_latest_changelog_version`
293
+ (re-exported on `bin/cctally` under the historical
294
+ `_release_read_latest_release_version` name) — intentionally
295
+ duplicated so the template module stays free of any
294
296
  `bin/cctally` import. If CHANGELOG header format changes, update both.
295
297
  """
296
298
  from pathlib import Path