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
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
def can_remap_hash(h: str, sha_map: dict) -> boolWhether 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
def _parse_semver(filename: str) -> _SemverKey | NoneExtract a sort key from a versioned filename, or None.
Returns (major, minor, patch, is_stable, preid_rank, counter) where is_stable is 1 for stable versions (so they sort after pre-releases) and preid_rank maps alpha=0, beta=1, rc=2.
#get_changes_dir
def get_changes_dir(project_path: str) -> strReturn the path to .rlsbl/changes/ inside the project.
#enumerate_changelog_dirs
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
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
def _list_jsonl_files(changes_dir)All JSONL files in a changes dir: unreleased.jsonl plus versioned.
#validate_all_hashes_resolve
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
def changes_dir_exists(project_path: str) -> boolCheck if .rlsbl/changes/ exists in the project.
#list_versioned_files
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
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
def append_entry(changes_dir: str, entry: ChangelogEntry) -> NoneAppend 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
def append_entry_to_version(changes_dir: str, version: str, entry: ChangelogEntry) -> NoneAppend 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
def _append_entry_to_file(target: str, entry: ChangelogEntry) -> NoneAppend one entry to any JSONL file, creating parents when missing.
One append of one line, through the effect seam. 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
def _warn_stale_entries(src: str, tag_glob: str) -> NoneWarn on stderr for entries in unreleased.jsonl referencing out-of-range commits.
In monorepo mode, an entry whose commits all sit before the project's last tag 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).
#finalize_version
def finalize_version(changes_dir: str, version: str, tag_glob: str | None=None) -> NoneRename 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.
#unfinalize_version
def unfinalize_version(changes_dir: str, version: str) -> list[str]Reverse a finalize_version: restore x.y.z.jsonl back to unreleased.jsonl.
- Makes the versioned file writable.
- Renames it to unreleased.jsonl.
- Deletes the per-version .md file if present.
- Returns the list of changed file paths (for committing).
Returns an empty list if the versioned file doesn't exist.
#is_read_only
def is_read_only(path: str) -> boolCheck if a file has no write permissions (for any user class).
#writable_jsonl
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
def remap_jsonl_hashes(changes_dir, sha_map) -> RemapReportReplace 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
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
oldandnew(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
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.