Skip to content
rlsbl.changelog.files
On this page

File management layer for JSONL changelog files including reading, writing, appending entries, and path resolution for .rlsbl directories.

#rlsbl.changelog.files

#rlsbl.changelog.files

File management layer for JSONL changelog files including reading, writing, appending entries, and path resolution for .rlsbl directories.

#RemapResult

Result of remapping hashes in one JSONL file.

#RemapReport

Full report of a remap across all JSONL files in one changes dir.

results lists the files that were modified. unmapped and ambiguous record, per file path, hashes that could NOT be mapped: hashes matching no rewrite key, and abbreviated hashes matching more than one rewrite key, respectively. Callers must decide whether unmapped hashes are a problem (e.g. by checking that they still resolve after the rewrite).

#_map_hash

python
def _map_hash(h: str, sha_map: dict) -> 'tuple[str | None, bool]'

Map one (possibly abbreviated) hash through the rewrites map.

Returns (new_sha, ambiguous). new_sha is None when the hash matched no key; ambiguous is True when an abbreviated hash matched more than one key.

#can_remap_hash

python
def can_remap_hash(h: str, sha_map: dict) -> bool

Whether remap_jsonl_hashes could fix hash h with sha_map.

True when the (possibly abbreviated) hash matches exactly one key of the map. Used by the scrub recovery path to decide whether a rewrite journal can repair a dangling changelog hash before mutating any files.

#_parse_semver

python
def _parse_semver(filename: str) -> _SemverKey | None

Extract a sort key from a versioned {version}.jsonl filename, or None.

The version vocabulary and the ordering are :mod:rlsbl.release_file's -- the same grammar the release archives are named and ordered by, because the same release flow names both files for the same version. This carries only what is specific to a changelog file: the .jsonl extension. It used to restate the pattern, the pre-release channel ranks and the ordering, so a new channel had to be added in two places to be recognized in both.

Returns (major, minor, patch, is_stable, preid_rank, counter) where is_stable is 1 for stable versions, so they sort after every pre-release of the same base.

#get_changes_dir

python
def get_changes_dir(project_path: str) -> str

Return the path to .rlsbl/changes/ inside the project.

#refuse_non_releasable_member_changes

python
def refuse_non_releasable_member_changes(workspace_root, projects, *, operation)

Refuse a non-releasable member that carries its own changes directory.

A member outside every releasable (releasable = false, dev node or not) has no version, no release and no changelog: nothing ever finalizes unreleased.jsonl there, nothing generates a CHANGELOG.md from it, and no release record explains its range. A .rlsbl/changes/ under one is either residue from before the member stopped being releasable, or content that belongs to a releasable -- and every path that reads it silently treats it as a changelog nobody owns.

Raised where such a directory would be READ, so the state is refused at the point it would take effect rather than merely reported.

#enumerate_changelog_dirs

python
def enumerate_changelog_dirs(project_root, workspace_root=None, workspace_projects=None)

Enumerate every changelog changes-dir whose JSONL files may reference commit hashes.

Standalone: the project's own .rlsbl/changes/. Monorepo: every workspace project's .rlsbl/changes/ PLUS every releasable's .rlsbl-monorepo/releasables/<name>/changes/ (enumerated from disk, so coverage matches what is actually in the working tree).

workspace_projects may be passed by callers that already loaded the workspace; when omitted it is loaded from workspace_root.

Only directories that exist are returned.

#changelog_remap_globs

python
def changelog_remap_globs(project_root, workspace_root=None, workspace_projects=None)

Build the safegit --remap-shas-in glob list for a scrub.

The globs are repo-relative and use Go path.Match semantics (safegit's matchScope): * never crosses /, so every glob is an exact per-directory pattern. Coverage is derived from the SAME enumeration as hash validation (enumerate_changelog_dirs) so remap coverage and validation coverage can never diverge:

  • Standalone: .rlsbl/changes/*.jsonl -- emitted unconditionally, since

historical commits may contain changelog files even when the working tree currently has none.

  • Monorepo: one exact glob per enumerated per-project changes dir, plus a

single wildcard glob .rlsbl-monorepo/releasables/*/changes/*.jsonl that also covers releasables deleted from the working tree but still present in history.

DELIBERATELY EXCLUDED: committed scrub archives (.rlsbl/scrubs/*.json and the releasable-level equivalent). They are records of what WAS -- the old-side SHAs they record dangle by design as soon as the original scrub prunes the old objects, so remapping them on a later scrub would falsify the record without ever making the old side resolvable. Validation (validate_all_hashes_resolve) likewise never reads them, so remap and validation agree on the exclusion. .validated caches carry no extension match and are deleted by the scrub flow anyway.

#_list_jsonl_files

python
def _list_jsonl_files(changes_dir)

All JSONL files in a changes dir: unreleased.jsonl plus versioned.

#validate_all_hashes_resolve

python
def validate_all_hashes_resolve(dirs, *, repo_root)

Verify that every commit hash in every JSONL file resolves via git.

Runs git rev-parse for each distinct hash in repo_root — the repository the hashes belong to. The parameter is mandatory so callers (including the planned validation-only mode) can never accidentally resolve against whatever repo the process CWD happens to be in.

Returns {filepath: [unresolvable hashes]} — empty when everything resolves.

#changes_dir_exists

python
def changes_dir_exists(project_path: str) -> bool

Check if .rlsbl/changes/ exists in the project.

#list_versioned_files

python
def list_versioned_files(changes_dir: str) -> list[tuple[str, str]]

List all versioned JSONL files, sorted by semver (newest first).

Matches both stable (x.y.z.jsonl) and pre-release (x.y.z-preid.N.jsonl) filenames.

Returns (version_string, filepath) pairs.

#read_unreleased

python
def read_unreleased(changes_dir: str, *, enforce_format_version: bool=False) -> list[ChangelogEntry]

Read unreleased.jsonl and return entries. Empty list if file missing.

enforce_format_version is threaded to :func:parse_jsonl: when True, a line lacking format_version is a hard error.

#append_entry

python
def append_entry(changes_dir: str, entry: ChangelogEntry) -> None

Append one entry to unreleased.jsonl atomically.

Writes the serialized line to a temp file, then appends it to the target. Creates the changes directory and unreleased.jsonl if they don't exist.

#append_entry_to_version

python
def append_entry_to_version(changes_dir: str, version: str, entry: ChangelogEntry) -> None

Append one entry to a versioned JSONL file (e.g., 0.39.0.jsonl).

The caller is responsible for unlocking/re-locking the file if it is read-only.

#_append_entry_to_file

python
def _append_entry_to_file(target: str, entry: ChangelogEntry) -> None

Append one entry to any JSONL file, creating parents when missing.

One append of one line, through :func:rlsbl.effects.append_lines -- the shared append the transition record uses too. Prior content is never read back and rewritten, so a changelog add racing another one cannot clobber the entry it just wrote, and a torn last line cannot swallow the new entry: the helper leads the append with a separating newline when the file does not end in one.

It used to stage the line in a tempfile.mkstemp file and then copy that into the target, which bought nothing -- the copy was itself a plain append, so a crash mid-write could truncate the target either way -- and cost purity: mkstemp creates its file unconditionally, so under --dry-run the recorded cleanup never ran and the preview left a stray .tmp in .rlsbl/changes/.

#_warn_stale_entries

python
def _warn_stale_entries(src: str, tag_glob: str) -> None

Warn on stderr for entries in unreleased.jsonl referencing out-of-range commits.

In monorepo mode, an entry whose commits all sit before the release the RELEASE RECORD bounds this checkout to is stale — typically left over from a sibling project's release. We emit a warning per stale entry but do not strip them (warn-only). The release record is the release archives beside src's own changes directory.

#finalize_version

python
def finalize_version(changes_dir: str, version: str, tag_glob: str | None=None) -> None

Rename unreleased.jsonl to x.y.z.jsonl and create a fresh unreleased.jsonl.

Sets the versioned file read-only (chmod 0o444). Raises FileNotFoundError if unreleased.jsonl doesn't exist.

When tag_glob is provided (monorepo mode), inspects each entry in unreleased.jsonl before the rename and warns on stderr for any whose commits fall outside the current project's unreleased range. Warn-only: the stale entries are not stripped.

#_jsonl_lines

python
def _jsonl_lines(path: str) -> list[str]

The non-blank lines of a JSONL file, verbatim and without newlines.

Read as TEXT rather than parsed: an un-finalize puts lines back exactly as they were written, so a field this version of the parser does not model -- or would re-serialize differently -- survives untouched.

#unfinalize_version

python
def unfinalize_version(changes_dir: str, version: str) -> list[str]

Reverse a finalize_version: restore x.y.z.jsonl back to unreleased.jsonl.

  1. Makes the versioned file writable.
  2. MERGES its entries back into unreleased.jsonl -- released entries first,

then whatever accumulated after the release -- and removes it.

  1. Deletes the per-version .md file if present.
  2. Returns the list of changed file paths (for committing).

Returns an empty list if the versioned file doesn't exist.

The merge is the whole point of step 2. Between a release and the undo of that release, entries land in the fresh unreleased.jsonl; renaming the versioned file over it destroyed every one of them, and the undo that did it reported success. Order is released-then-new, which is the order the two sets of commits were made in.

#is_read_only

python
def is_read_only(path: str) -> bool

Check if a file has no write permissions (for any user class).

#writable_jsonl

python
def writable_jsonl(path)

Context manager that temporarily makes a read-only JSONL file writable.

If the file is already writable, yields without changing permissions. On exit (even after exceptions), restores original read-only state.

#remap_jsonl_hashes

python
def remap_jsonl_hashes(changes_dir, sha_map) -> RemapReport

Replace commit hashes in all JSONL files using a rewrites mapping.

Scans unreleased.jsonl and all versioned *.jsonl files in changes_dir. Hashes are matched exactly against the (full-SHA) keys of sha_map; abbreviated hashes are matched by unique prefix. Only files containing matching hashes are modified. Uses writable_jsonl to handle read-only versioned files.

Returns a RemapReport: modified files plus, per file, the hashes that could not be mapped (no key match, or ambiguous abbreviated prefix). Returns an empty report if changes_dir does not exist.

#load_filter_repo_commit_map

python
def load_filter_repo_commit_map(path: str) -> 'tuple[dict[str, str], list[str]]'

Load a git-filter-repo commit-map into a clean {old: new} dict.

git-filter-repo writes .git/filter-repo/commit-map with two quirks that make it unsafe to feed straight into :func:remap_jsonl_hashes:

  • A header row of the literal tokens old and new (whitespace

padded). Ingested naively it becomes a junk {"old": "new"} entry.

  • Pruned commits map to the all-zeros :data:NULL_SHA. Ingested naively

they would rewrite surviving real hashes to nothing, corrupting the changelog. This is the actual corruption vector.

Returns (sha_map, pruned) where sha_map maps surviving old SHAs to their new SHAs (header and null-target rows excluded) and pruned is the list of old SHAs whose commits were dropped (null target), so callers can log how many entries reference now-deleted commits.

#read_changelog_format_version_enforced

python
def read_changelog_format_version_enforced(config: dict) -> 'tuple[bool, bool]'

Read the changelog_format_version_enforced flag from a config dict.

Returns (enforced, key_present):

  • key ABSENT -> (False, False): legacy mode. There is no enforced

default; the absence is surfaced by the changelog-format-version warn check ("enforcement not yet enabled").

  • key present and boolean -> (value, True).

A present-but-non-boolean value is a hard error (:class:ConfigError) -- invalid config is never silently coerced.

More tools from this site

  • claudestream Drive Claude Code from Python: run it as a subprocess and read its output as typed events, with async and sync sessions, sandbox policies, and tools you define in Python
  • claudewheel A TUI Claude Code Launcher that lets you have more than one profile, manage sessions lifecycle, pick the exact CC version, model to use (even older unlisted ones), pick which GitHub account to use, etc.
  • dirstat Fast, single-binary directory statistics CLI: every file under a tree grouped by format, with counts, sizes, and lines of code, as a colored terminal table or as JSON
  • fastware A batteries-included ASGI framework: msgspec JSON, a managed Granian server, dependency injection, SSE, WebSockets, auth, and a test client
  • go-toml-edit Zero-dep TOML editing library for Go with comment preservation
  • howmuchleft The fastest Claude Code statusline: context window, 5-hour, and weekly limit usage as three customizable gradient bars, rendering in about 6 ms
  • orxtra
  • pgdesign
  • predraw Declarative rendering pipeline: describe a scene in JSON and get SVG, PNG and WebP out, with light and dark style tokens, reusable components and text converted to path outlines
  • reposummary Turn a git repository's history into a Markdown journal: pick a time window or revision range and get a readable digest of what changed, optionally narrated by an LLM
  • safegit git wrapper CLI that gives each commit its own temporary index and retries ref updates on conflict, so concurrent agents share one repository
  • saferm Command-line replacement for rm that archives every deletion with a mandatory reason and the context it ran in, so deleted files can be listed, inspected and restored
  • selfdoc Static Site Generator that builds a project's documentation site directly from its source code, so the docs can never drift from the code they describe, with SEO/AEO, first-class blog, search, and cross-project linking built in
  • strictcli
  • stricttest An always-on test-isolation floor: a pytest plugin and a Go env-hygiene module that make a test suite structurally unable to reach real credentials, the real HOME, the network, or the development repository.
  • wesktop A Python framework that turns an ASGI web app into a desktop application, serving it from a local Granian server and displaying it in a native OS window via pywebview
Search