Skip to content
rlsbl.commands.monorepo.absorb_cmd
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-filter on 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

python
def healing(self) -> bool

Is this a re-run over a previous absorb's partial work?

#clone_path

python
def clone_path(self) -> str

#releasable_dir

python
def releasable_dir(self) -> str

#dest_full

python
def dest_full(self) -> str

#member_rlsbl_dir

python
def member_rlsbl_dir(self) -> str

#Applied

What the apply pipeline learned as it ran, passed between its steps.

#record_path

python
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

python
def _tree_hash(repo, path, rev='HEAD')

The git tree object of path in repo at rev.

#_git_tag_names

python
def _git_tag_names(repo, pattern=None)

The tag names in repo, optionally filtered by a glob.

#_origin_url

python
def _origin_url(repo)

origin's URL, or the repository's own path when it has no remote.

#_root_commits

python
def _root_commits(repo)

The parentless commits of repo -- its durable identity.

#_commit_exists

python
def _commit_exists(repo, sha)

Does sha name a commit object that exists in repo?

#_dirty_paths

python
def _dirty_paths(root)

Working-tree changes at root, minus rlsbl's own advisory lock.

#_delete_path

python
def _delete_path(path, *, description, delete_with_rm)

Delete path through saferm, or through rm -rf when asked to.

#_write_json

python
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

python
def _check_source_repo(source_repo)

The source must exist, be a git repository, and be committed.

#_source_targets

python
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

python
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

python
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

python
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

python
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

python
def _trailer_values(body, key)

Every value the commit message body records under trailer key.

#_find_merge

python
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_overlap on

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

python
def _released_versions(state_dir)

Every version a releasable's own state says it has already released.

#_check_version_overlap

python
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

python
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

python
def _read_source_state(source_repo)

What the source's .rlsbl/ carries: changelog, archives, config.

#resolve_arrival

python
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

python
def _repository_bound_publishers(arr)

The arriving targets whose publisher is authorized per REPOSITORY.

#_next_steps

python
def _next_steps(arr)

The steps rlsbl deliberately does NOT take on the operator's behalf.

#observe

python
def observe(arr) -> Preview

The whole plan, as a keyed verdict list in apply order.

#_apply_source

python
def _apply_source(arr, item, run)

Take the lock, clone the source, and rewrite the clone under the prefix.

#_apply_releasable

python
def _apply_releasable(arr, item, run)

Nothing to do: the releasable is written with the workspace entry.

#_apply_history

python
def _apply_history(arr, item, run)

Fetch the rewritten history WITHOUT tags and merge it in.

#_map_sha

python
def _map_sha(old, sha_map)

Map one (possibly abbreviated) SHA through filter-repo's commit map.

#_create_tag

python
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

python
def _apply_tags(arr, item, run)

Create the destination-scheme tags, plus the one boundary alias.

#_apply_state

python
def _apply_state(arr, item, run)

Remap the arriving state onto the rewritten history, then move it.

#_remap_changelog

python
def _remap_changelog(arr, run, changes_dir)

Map the arriving changelog hashes onto the rewritten commits.

#_remap_release_commits

python
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

python
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

python
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

python
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

python
def _migrate_changes(arr, run, changes_dir)

Move the arriving changelog into the releasable's changes directory.

#_migrate_releases

python
def _migrate_releases(arr, run, releases_dir)

Move the arriving release archives into the releasable's releases dir.

#_migrate_identity

python
def _migrate_identity(arr, run)

Give a newly created releasable the version and config that arrived.

#_remove_residue

python
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

python
def _apply_workspace(arr, item, run)

Register the member (and its releasable), then commit and sync.

#_require_clean

python
def _require_clean(arr, why)

Hard-error when the workspace is dirty at a point it must not be.

#_commit_step_output

python
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

python
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

python
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

python
def _sync_workspace(arr)

Regenerate the CI router for the member list this absorb changed.

#_assert_workspace_loads

python
def _assert_workspace_loads(arr)

The workspace must read back through rlsbl's own loader.

#_apply_transition_record

python
def _apply_transition_record(arr, item, run)

Write the releasable's transition record: conversion first, then the rest.

#_apply_next_steps

python
def _apply_next_steps(arr, item, run)

Print what the operator still has to do. rlsbl administers nothing.

#cmd_absorb

python
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 with rm -rf instead

of saferm. Without it, an absent saferm is a refusal.

  • dry_run: render the plan and stop.

#_cmd_absorb

python
def _cmd_absorb(flags, project_root)

rlsbl monorepo absorb <source_repo> <dest_path>.

Search