On this page
Absorbs an external repository into a workspace as a releasable -- history rewritten under the destination path, tags imported, release state moved in.
#rlsbl.commands.monorepo.absorb_cmd
#rlsbl.commands.monorepo.absorb_cmd
Absorb an external repository into a workspace, as a releasable.
rlsbl monorepo absorb <source_repo> <dest_path> is the INBOUND conversion, and it is the mirror image of rlsbl monorepo extract: the source's history is rewritten to live under dest_path, merged in, its version tags imported under the destination's tag scheme, and its whole release state -- changelog, release archives with their release commits, config and version -- moved into a releasable's state directory.
The unit is the RELEASABLE, in both directions ----------------------------------------------
An absorbed repository always arrives as a releasable, because that is what it already was: it owns a version, a changelog, a release-file archive and a tag scheme, and a member package owns none of those on its own. --releasable names an existing group to join; without it an AUTO-SINGLETON releasable is created for the arriving member, with its tag_format written EXPLICITLY -- derived from the member's primary target's scheme, never inherited by accident. A source whose targets span both tag schemes (Go's {path}/v* and everybody else's {name}@v*) is refused with the remedy: state the format with --tag-format.
The shape: observe, then either render or apply -----------------------------------------------
The command is a reconciler built on :mod:rlsbl.preview_apply. Observation runs under :func:~rlsbl.preview_apply.no_writes and answers everything the apply acts on -- which tags import, which collide, what the state migration carries, whether a previous run already did some of it. --dry-run renders that plan and stops; otherwise the plan is applied item by item, in the order it was rendered.
Refusals happen during observation, so they cost nothing and fire identically under a preview: a source that is missing, not a git repository, or dirty; a broken target declaration; mixed tag schemes; a destination path already taken (on disk or in workspace.toml); a member name already used; a releasable named that does not exist, or one that exists with no release state; a tag whose NAME or whose VERSION already exists in the destination; a dirty workspace; and a missing git-filter-repo or saferm.
What an apply moves -------------------
- History, via
git-filter-repo --to-subdirectory-filteron a working
clone under the workspace's own .git/rlsbl/, fetched in with --no-tags and merged with --allow-unrelated-histories.
- Tags, created by rlsbl at the mapped commits rather than fetched: the
fetch is deliberately tag-free, so a tag the destination already owns can never be overwritten or deleted by the import. One boundary alias is created at the current version, keeping the source's own tag name resolvable.
- The release state: the arriving
.rlsbl/changes/and
.rlsbl/releases/ move into the releasable's state directory, their changelog hashes remapped through filter-repo's commit map and their release release commits remapped and VERIFIED -- a recorded tree hash is content-addressed, so a faithful rewrite reproduces it exactly.
- A transition record in the releasable's state directory explains all of it.
Re-running a crashed absorb ---------------------------
Every step is detected before it is repeated: the merge by its own trailer plus the source's root-commit identity AND the releasable it recorded, a tag by already existing at the mapped commit, the workspace entry by its content, and a changelog entry by its id -- or, for an entry that carries none, by its content. A run interrupted anywhere can be re-run to completion without duplicating what already happened.
A heal re-derives NOTHING. Every value it writes comes from what the first run recorded: the trailers and the state already migrated. The re-run's source repository answers one question -- is this the same conversion? -- so a fork whose manifest moved on cannot overwrite the version this conversion shipped. A re-run aimed at a DIFFERENT releasable is not that conversion at all and is refused, because healing skips the version-overlap check on exactly the ground that the target is unchanged.
What it does NOT do: push anything (the tags it creates are local), touch the source repository, or administer any external system. Those are next steps.
#AbsorbError
A refusal or failure in the inbound conversion.
A subclass of the conversion error both directions share, so a caller that catches ExtractError around either conversion keeps working while an absorb-specific handler can still name its own.
#TagImport
One source tag's translation into the destination's scheme.
#TagPlan
What the conversion will do to the destination's tags.
#SourceState
The release state the source repository carries in its .rlsbl/.
#Arrival
Everything observation resolved about one absorption.
#healing
def healing(self) -> boolIs this a re-run over a previous absorb's partial work?
#clone_path
def clone_path(self) -> str#releasable_dir
def releasable_dir(self) -> str#dest_full
def dest_full(self) -> str#member_rlsbl_dir
def member_rlsbl_dir(self) -> str#Applied
What the apply pipeline learned as it ran, passed between its steps.
#record_path
def record_path(self, workspace_root, *paths)Record repo-relative paths for the commit this run will make.
Deliberately not spelled touch: that name is a filesystem mutation everywhere else in this codebase, and the chokepoint scanner reads it as one -- a bookkeeping method has no business borrowing it.
#_tree_hash
def _tree_hash(repo, path, rev='HEAD')The git tree object of path in repo at rev.
#_git_tag_names
def _git_tag_names(repo, pattern=None)The tag names in repo, optionally filtered by a glob.
#_origin_url
def _origin_url(repo)origin's URL, or the repository's own path when it has no remote.
#_root_commits
def _root_commits(repo)The parentless commits of repo -- its durable identity.
#_commit_exists
def _commit_exists(repo, sha)Does sha name a commit object that exists in repo?
#_dirty_paths
def _dirty_paths(root)Working-tree changes at root, minus rlsbl's own advisory lock.
#_delete_path
def _delete_path(path, *, description, delete_with_rm)Delete path through saferm, or through rm -rf when asked to.
#_write_json
def _write_json(path, data, *, exists_ok=True)Write a JSON config file at 644, or preserve the mode it already has.
A fresh config is an ordinary readable file (644 through the umask); a rewrite keeps whatever mode the file already carries. Pinning a mode here is how a 644 config silently became owner-only.
#_check_source_repo
def _check_source_repo(source_repo)The source must exist, be a git repository, and be committed.
#_source_targets
def _source_targets(source_repo)The source's declared or detected targets, refusing a broken declaration.
The source's .rlsbl/config.json becomes the absorbed unit's config and its targets decide the tag scheme, so a config file with no targets key is refused HERE -- before any history is rewritten -- rather than surfacing later as a mis-schemed tag import. A source with no config file at all is the legitimate auto-detect path.
#_derive_tag_format
def _derive_tag_format(source_repo, entries, name, dest_path)The tag format the auto-singleton releasable is created with.
Derivation is shared with monorepo add, the other command that creates a releasable from a member (:func:rlsbl.tag_glob.derive_releasable_tag_format); only the subject of the mixed-scheme refusal differs, since here it is the source repository's own declaration that spans both schemes.
#_version_key
def _version_key(version)Order two version strings, pre-releases before their stable base.
The release archives' own ordering, so "which of these versions is the newest" has one answer in the tool rather than a second ordering written here. A source repository's tags are not rlsbl's to name, so a version outside that vocabulary sorts lowest rather than refusing: it is a candidate for max here, never a version rlsbl records.
#_resolve_version
def _resolve_version(source_repo, entries, version_tags)The version the absorbed unit arrives at.
Its own manifest first -- that is what the project says about itself -- and its highest version tag second. A source that answers neither is a refusal: a releasable's version file is state rlsbl reads for real, and inventing a 0.0.0 for it would be a lie in a file the release flow bumps from.
#_arrival_version
def _arrival_version(workspace_root, source_repo, entries, version_tags, *, releasable_name, healing, creates_releasable)The version the absorbed unit arrives at -- RECORDED before derived.
A heal re-derives nothing. The first run already wrote this unit's version into the releasable it created, and that record is what every later step reads; asking the re-run's source again would let a fork with a bumped manifest overwrite the recorded version with one this conversion never shipped. The re-run's source repository answers the identity question (is this the same conversion?) and nothing else.
Only a releasable this absorb CREATED has a version it recorded. When the member joined an existing releasable, that releasable's version is its own and the absorb never writes it, so there is nothing recorded to prefer and the source is asked as on a first run.
#_trailer_values
def _trailer_values(body, key)Every value the commit message body records under trailer key.
#_find_merge
def _find_merge(workspace_root, name, dest_path, source_root_shas, releasable)The commit of a previous absorb of this unit, or None.
Detection is the trailer this command writes PLUS two identities the trailers carry:
- the SOURCE's root commit -- the same name and destination path absorbed
from a DIFFERENT repository is not a re-run of this conversion, it is a collision, and it is refused rather than healed;
- the target RELEASABLE -- healing skips :func:
_check_version_overlapon
the grounds that a re-run is the same conversion, so a re-run aimed at another releasable must not be classified as one. It would skip exactly the check that guards the releasable it is newly pointing at.
A merge that carries no releasable trailer cannot answer the second question, so it is refused rather than guessed at. That state only exists for a merge written before the trailer did.
#_released_versions
def _released_versions(state_dir)Every version a releasable's own state says it has already released.
#_check_version_overlap
def _check_version_overlap(workspace_root, releasable_name, source_repo, state, version_tags)Refuse a version the destination releasable has already released.
A tag-name collision is about SPELLING; this is about the release record, which is where the fact really lives: a releasable's changes/<v>.jsonl and releases/v<v>.toml say which versions it shipped, whatever its tags happen to be named. Two releases of one version is a state no later command can make sense of -- changelog generate would have two sources for one section and the unreleased range would be bounded by a version with two release commits -- so it is refused before anything is written.
#_plan_tags
def _plan_tags(workspace_root, source_repo, releasable_name, tag_format, version, *, glob, healing)Classify every source tag, and refuse both kinds of collision.
Names are resolvable before anything is written: the destination's tags are already there, and the imported names are a pure function of the source's tags and the destination's format. The SHAs are not -- filter-repo assigns them -- so they are resolved at apply time.
Two refusals, both here:
- a ref-name collision: the tag rlsbl would create already exists. It
is never overwritten and never deleted -- a destination tag belongs to the destination's own release history.
- a same-version collision: a tag matching this releasable's glob
already stands at a version the source also carries, under whatever spelling. The unit would then have two releases claiming one version.
A tag that already exists AND was planned is reported as present rather than refused when a previous run of THIS absorb created it (healing); its commit is verified at apply time.
#_read_source_state
def _read_source_state(source_repo)What the source's .rlsbl/ carries: changelog, archives, config.
#resolve_arrival
def resolve_arrival(workspace_root, source_repo, dest_path, *, name, registry_name, releasable_name, tag_format, delete_with_rm)Resolve and validate one absorption. Reads only; refuses loudly.
#_repository_bound_publishers
def _repository_bound_publishers(arr)The arriving targets whose publisher is authorized per REPOSITORY.
#_next_steps
def _next_steps(arr)The steps rlsbl deliberately does NOT take on the operator's behalf.
#observe
def observe(arr) -> PreviewThe whole plan, as a keyed verdict list in apply order.
#_apply_source
def _apply_source(arr, item, run)Take the lock, clone the source, and rewrite the clone under the prefix.
#_apply_releasable
def _apply_releasable(arr, item, run)Nothing to do: the releasable is written with the workspace entry.
#_apply_history
def _apply_history(arr, item, run)Fetch the rewritten history WITHOUT tags and merge it in.
#_map_sha
def _map_sha(old, sha_map)Map one (possibly abbreviated) SHA through filter-repo's commit map.
#_create_tag
def _create_tag(arr, run, tag, sha, *, what)Create tag at sha, healing an identical one and refusing others.
Never a delete and never a move: a tag that already stands where this absorb would put it is the previous run's own work, and one that stands anywhere else belongs to somebody and is a hard error.
#_apply_tags
def _apply_tags(arr, item, run)Create the destination-scheme tags, plus the one boundary alias.
#_apply_state
def _apply_state(arr, item, run)Remap the arriving state onto the rewritten history, then move it.
#_remap_changelog
def _remap_changelog(arr, run, changes_dir)Map the arriving changelog hashes onto the rewritten commits.
#_remap_release_commits
def _remap_release_commits(arr, run, releases_dir)Remap every arriving release commit onto the rewritten history.
An archive records the commit a version shipped from and the git tree of every path it shipped. The commit is mapped through filter-repo's map; the trees are recomputed at the new commit and the path the member now has, and CHECKED against what was recorded. A tree hash is content-addressed, so a faithful rewrite reproduces it exactly -- a disagreement means the content of a historical release changed under the rewrite, and it is a hard error while the destination can still be reset.
#_dest_release_commit_path
def _dest_release_commit_path(arr, old_path)A recorded path's spelling in the destination.
A standalone project records its release at "."; that same content now sits under the member's path, so the key becomes the member path -- exactly the spelling a workspace release writes, and exactly what an extract turns back into ".".
#_already_migrated
def _already_migrated(path)How to recognize an entry the file at path already holds.
Two indexes, because id is optional on read: an entry that carries one is identified by it, and one that does not falls back to its CONTENT -- the only identity a line without an id has. Identifying id-less entries by id alone made every historical entry unrecognizable, so a re-run appended a second copy of what it had already migrated.
#_entries_to_migrate
def _entries_to_migrate(arriving, known_ids, known_content)The arriving entries the target does not already hold.
Neither index is extended while iterating, deliberately: the arriving file is copied as it stands, so two identical lines in it stay two lines, and a re-run recognizes both of them at once.
#_migrate_changes
def _migrate_changes(arr, run, changes_dir)Move the arriving changelog into the releasable's changes directory.
#_migrate_releases
def _migrate_releases(arr, run, releases_dir)Move the arriving release archives into the releasable's releases dir.
#_migrate_identity
def _migrate_identity(arr, run)Give a newly created releasable the version and config that arrived.
#_remove_residue
def _remove_residue(arr, run, changes_dir, releases_dir)Delete the per-package release state that has moved to the releasable.
A releasable's changelog, release archives and version have one home, and it is the releasable's state directory. What arrived under the member's own .rlsbl/ is the same state under the layout the source used, so once it has moved it is residue -- the same set rlsbl monorepo cleanup removes, scoped to this one member.
Two things it deliberately leaves: per-package hooks/ (a live feature), and the member's own .rlsbl/config.json. The config was COPIED to the releasable as its base, not moved: a member config is a legal override, it is what the scaffold that follows merges into, and deduplicating it against the releasable's is rlsbl monorepo cleanup's decision to make, not this conversion's.
#_apply_workspace
def _apply_workspace(arr, item, run)Register the member (and its releasable), then commit and sync.
#_require_clean
def _require_clean(arr, why)Hard-error when the workspace is dirty at a point it must not be.
#_commit_step_output
def _commit_step_output(arr, message)Commit what the step that just ran wrote, and nothing else.
The file list is the working tree's own report, taken immediately after a step that started from a tree :func:_require_clean had just verified -- so it names exactly that step's output, not a sweep of whatever happened to be lying around. The two steps that need this are the ones whose output rlsbl does not enumerate: rlsbl scaffold (which usually commits its own files, leaving nothing here) and monorepo sync, whose own auto-commit resolves its git repository from the process working directory rather than from the workspace it was pointed at.
#_scaffold_member
def _scaffold_member(arr)Scaffold the arriving member, surfacing the command's own failure.
The absorbed repository was scaffolded for a standalone project; its CI, hooks and workflows are regenerated for a member of this workspace. A non-zero exit is a hard error rather than a warning: a member that is half-scaffolded is a repository nobody can release, and finding that out later costs more than stopping here.
#_sweep_member_rlsbl
def _sweep_member_rlsbl(arr)Remove what the scaffold wrote that a releasable member may not keep.
rlsbl scaffold writes a standalone project's .rlsbl/: a scaffolding version marker and a bases/ directory of merge bases. Under a releasable those belong to the releasable, and the releasable-residue check errors on a member that keeps them -- so an absorb that ran the scaffold and stopped would hand back a workspace failing its own checks.
The rule is not restated here: :func:verify_minimal_rlsbl is the one place that says what a member's .rlsbl/ may hold, and this removes exactly what it names, the same set rlsbl monorepo cleanup removes.
#_sync_workspace
def _sync_workspace(arr)Regenerate the CI router for the member list this absorb changed.
#_assert_workspace_loads
def _assert_workspace_loads(arr)The workspace must read back through rlsbl's own loader.
#_apply_transition_record
def _apply_transition_record(arr, item, run)Write the releasable's transition record: conversion first, then the rest.
#_apply_next_steps
def _apply_next_steps(arr, item, run)Print what the operator still has to do. rlsbl administers nothing.
#cmd_absorb
def cmd_absorb(workspace_root, source_repo, dest_path, *, name=None, registry_name='', releasable_name=None, tag_format='', delete_with_rm=False, dry_run=False)Absorb an external repository into the workspace. Returns the Preview.
Args:
workspace_root: path to the monorepo root.source_repo: the external repository to absorb.dest_path: the member path its history is rewritten under.name: the member's workspace name (the destination's basename when
omitted).
registry_name: the package registry identity recorded in workspace.toml.releasable_name: an existing releasable to join. When omitted, a
singleton releasable named after the member is created.
tag_format: the created releasable's tag format, stated explicitly.
Illegal together with releasable_name, which brings its own.
delete_with_rm: delete the per-package residue withrm -rfinstead
of saferm. Without it, an absent saferm is a refusal.
dry_run: render the plan and stop.
#_cmd_absorb
def _cmd_absorb(flags, project_root)rlsbl monorepo absorb <source_repo> <dest_path>.