Skip to content
rlsbl.commands.monorepo.extract_cmd
On this page

Extracts a releasable into its own repository, filtering an unmirrored one with git-filter-repo and promoting a mirrored one from the mirror it has.

#rlsbl.commands.monorepo.extract_cmd

#rlsbl.commands.monorepo.extract_cmd

Extract a releasable out of a workspace into its own repository.

rlsbl monorepo extract <releasable> <target-path> is the ONE conversion command in the outbound direction, and the unit it operates on is the RELEASABLE -- the portable unit. A releasable owns a version, a changelog, a release-file archive and a tag scheme; a single member package owns none of those on its own, so "extract this package" was never a complete question. The two commands that used to ask it (monorepo extract <package> and monorepo extract-releasable) are gone.

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 every question the apply will act on -- which tags translate, which trees must match, what the destination will look like, what the source loses. --dry-run renders that plan and stops. Otherwise the plan is applied, item by item, in the order it was rendered: the preview IS the pipeline.

Refusals happen during observation, so they cost nothing and fire identically under a preview:

  • the releasable contains the root member. The root member owns every path

no other member claims, and a workspace has exactly one -- extracting it would leave the source with no root. Restructure first.

  • a remaining member depends on an extracted member. The edge would dangle

the moment the members leave, so the conversion refuses and names the exact rlsbl rewrite invocation that severs it. Extract never rewrites a manifest itself: the rewrite commands own that, and composing them is the design.

  • the usual preconditions: the target path exists, git-filter-repo is missing

(an unmirrored releasable only -- a promotion filters nothing), the source tree is dirty, a release is in flight, a translated tag would collide, or saferm is absent and --delete-with-rm was not passed.

Two engines, chosen by whether the releasable is MIRRORED ----------------------------------------------------------

A releasable bound to a subtree mirror is promoted, not filtered. The mirror already holds this subtree's standalone history -- every commit that touched the member has a synthetic counterpart there, produced by the deterministic subtree split -- and consumers already resolve those commit ids. Filtering the monorepo again would build a SECOND standalone history of the same code, so the destination starts from the mirror instead: it is cloned, the monorepo-to-mirror correspondence is derived by splitting each commit the conversion has to translate, the deletion is justified by tree-hash equality against the mirror's pre-scaffold split commit, and the correspondence is persisted into the destination's transition record as a promotion-split-map event. Everything below applies to both engines except where it names the filter.

What an apply actually moves ----------------------------

  • History: for an unmirrored releasable, via git-filter-repo on a fresh

clone -- the union of the member paths, hoisted to the repository root when the releasable has a single member. For a mirrored one, the mirror's own history, adopted whole.

  • Tree-object identity is then VERIFIED per member: the source's

HEAD:<member> tree must equal the corresponding tree in the filtered result -- or, for a promotion, the root tree of the mirror's pre-scaffold split commit. A mismatch is a hard error naming both hashes, and nothing further is written. This is the one check that says the code that arrived is the code that left, and for a promotion it is also what justifies deleting the monorepo's copy.

  • The whole release state: the releasable's state directory -- version,

changes/ (locked JSONL and generated markdown), releases/ (the archives, release commits included), config.json, lint/, hooks/ and its own transition record -- moves to wherever the destination keeps it: .rlsbl/ for a standalone successor, .rlsbl-monorepo/releasables/<name>/ for a workspace.

  • The release commits and the changelog hashes are remapped through filter-repo's

commit map (or, for a promotion, the subtree-split correspondence), and the tree hashes recomputed at the new commits and paths. What could not be mapped is NAMED in the output rather than silently left stale.

  • Tags translate to the destination's scheme, with one boundary alias at the

current version so the pre-conversion name still resolves in the new repository. Another live member's tags are pruned; a tag matching no current member is KEPT (it is most likely this releasable's own history under an older prefix, and release history is never destroyed on a guess).

  • A transition record in the destination explains all of it, and the source

records the departure of the releasable's tag globs.

What it does NOT do: push anything (the tags it creates are local -- the boundary alias reaches a remote through the release flow, which owns that namespace), create a remote, scaffold the new repository, or touch any external system. Those are printed as next steps.

#TagPlan

What the conversion will do to the destination clone's tags.

#changes_names

python
def changes_names(self) -> bool

#Departure

Everything observation resolved about one extraction.

#is_promotion

python
def is_promotion(self) -> bool

Is this a mirror promotion rather than a filter-repo extraction?

#member_paths

python
def member_paths(self) -> list

#member_names

python
def member_names(self) -> list

#is_multi

python
def is_multi(self) -> bool

#dest_state_dir

python
def dest_state_dir(self) -> str

Where the releasable's state directory lands in the destination.

#dest_member_path

python
def dest_member_path(self, member) -> str

A member's path in the destination ("" means the repo root).

#Applied

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

#_tree_hash

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

The git tree object of path in repo at rev.

The spelling is :func:~rlsbl.git_util.tree_rev_spec's; the whole tree-identity verification funnels through here, so a test can make one side lie.

#_tracked_entries

python
def _tracked_entries(repo, path)

(mode, relative path) for everything tracked under path at HEAD.

-z because the paths are read, not displayed: git's default output C-quotes any path outside plain ASCII, and a member with a non-ASCII file under it would be judged on an escaped spelling of its own contents.

#_check_member_contents

python
def _check_member_contents(workspace_root, members)

Refuse a member the conversion could not carry, before anything is done.

Two shapes, both fatal at observation:

  • a gitlink (a submodule) under a member. The source-side edit is

committed by naming the paths the working tree reports, and the commit tool refuses a gitlink path -- so an extract that reached that commit would already have deleted the member and would leave the source half-mutated with nothing recorded. Refusing here costs nothing.

  • nothing tracked at all at the member's path. Its tree object is what

the conversion verifies identity with, and a path with no tree raises a raw git rev-parse failure deep in observation instead of saying which member is empty.

#_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.

#_delete_path

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

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

saferm is the default because a conversion's deletions are exactly the kind that want an audit trail and an undo. --delete-with-rm is the operator stating that this machine has no saferm and they accept a plain removal -- which is why an absent saferm WITHOUT the flag is a refusal (raised during observation) rather than a silent downgrade.

#_write_config

python
def _write_config(config_path, config)

Write a JSON config file, creating its directory when absent.

config.write_project_config writes <root>/.rlsbl/config.json, and the config this touches is the SOURCE ROOT's state home -- which is the root member's releasable directory when the root member belongs to one. The path is therefore passed in rather than derived from a project root.

MODES: a config that already exists keeps the mode it has, and a fresh one is an ordinary readable file through the umask. Neither is pinned: a config file is a committed, world-readable project file, and pinning 0600 here turned an ordinary 644 config into an owner-only one every time a conversion declared a dependency floor in it.

#_root_state_dir

python
def _root_state_dir(workspace_root, projects, releasables)

The source ROOT member's state home, or None when it has none.

A releasable's own records live in its state directory; the root member's live in the state directory of the releasable it belongs to. A root member outside every releasable (the common dev-node root) has no releasable directory, and its state home is the repository's own .rlsbl/. Both answers feed :func:rlsbl.transition_record.get_transition_record_path and the config writer, which is why one function resolves them.

#_relative

python
def _relative(workspace_root, path)

path as a repo-relative path, for naming in a commit.

#_dirty_paths

python
def _dirty_paths(root)

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

The lock file is this process's own infrastructure: it exists only while the conversion runs and is deleted on the way out. A scaffolded repository gitignores it, so it usually never shows up here at all -- the exemption is STRUCTURAL (a fixed path, never gitignore-derived) so that a repository whose .gitignore predates the entry cannot make the conversion refuse over its own lock, or worse, commit it.

This is also the commit list for both repositories: naming what the tree actually reports cannot miss a path a step forgot to declare, and cannot name a directory whose untracked contents would be swept in with it.

#_other_member_globs

python
def _other_member_globs(workspace_root, projects, releasables, *, exclude_project_names, exclude_releasable_names)

The tag globs of every member/releasable that is NOT being extracted.

These identify which scheme-parsing tags in the extracted clone are genuinely foreign -- another live member's release history, safe to prune. A scheme tag matching none of them is an ORPHAN and is kept: it is most likely the extracted releasable's own history under an older prefix.

Resolved against the SOURCE workspace before anything is removed, because target detection reads the still-present member directories. A member whose target declaration is broken (a .rlsbl/config.json with no targets key) makes that resolution impossible, and that is a hard error HERE -- before any history is rewritten -- rather than a silent fallback to a default scheme that would prune the wrong tags.

#_same_commit

python
def _same_commit(repo, left, right)

Do two tag names resolve to the same commit in repo?

#_plan_tags

python
def _plan_tags(workspace_root, own_glob, foreign_globs, own_format, dest_format, releasable_name, version)

Classify every tag in the source repository. Never touches a tag.

Tag NAMES are the same in the clone as in the source (a clone carries every tag), so the whole classification -- including the collision pre-check -- is answerable before anything is cloned. The SHAs are not: filter-repo rewrites them, so they are resolved at apply time in the destination.

#_read_toml_doc

python
def _read_toml_doc(path)

Parse a TOML file, or None when it is absent or unreadable.

Unreadable is None rather than an error on purpose: this reads manifests to decide which REMEDY to print for an edge that is already a refusal. A manifest nobody can parse costs its evidence, not the refusal.

#_read_json_doc

python
def _read_json_doc(path)

Parse a JSON file, or None when it is absent or unreadable.

#_member_spellings

python
def _member_spellings(workspace_root, member, *, manifest, key)

Every name the departing member can be spelled with in one ecosystem.

Its workspace name, the registry_name the workspace declares for it, and the name its own manifest declares -- a member registered as pkgA can perfectly well publish as acme-pkg-a and be depended on under that name.

#_go_module_path

python
def _go_module_path(workspace_root, path)

The module path a go.mod under path declares, or None.

#_python_inbound_remedy

python
def _python_inbound_remedy(workspace_root, dependent, member)

The Python edit, when the dependent's pyproject really names the member.

#_npm_inbound_remedy

python
def _npm_inbound_remedy(workspace_root, dependent, member)

The npm edit. Stated in full: no rewrite command owns package.json.

#_go_inbound_remedy

python
def _go_inbound_remedy(workspace_root, dependent, member)

The Go edit, when the dependent's go.mod requires the member's module.

#_inbound_remedies

python
def _inbound_remedies(workspace_root, dependent, member, dep)

Every edit that severs one inbound dependency edge.

Decided from the DEPENDING member's manifests on disk, never from the scanner's dep_type: the Python scanner marks a [tool.uv.sources] path/workspace edge "versioned" (only a direct name @ file:// reference is "path"), so branching on that string routes the commonest Python edge to a Go remedy naming a go.mod that does not exist.

Every remedy that applies is returned, because an edge can be declared in more than one place -- a depends_on in workspace.toml AND the manifest that really carries it -- and severing one of them leaves the other.

#_check_inbound

python
def _check_inbound(workspace_root, graph, projects, members, member_names)

Refuse when a REMAINING member depends on one that is leaving.

The edge cannot survive the conversion: the depended-on package will not be in this repository any more. Severing it is a manifest rewrite, and rlsbl has commands that do exactly that -- so extract refuses and names them rather than editing somebody's manifest as a side effect of a conversion.

#_outbound_edges

python
def _outbound_edges(graph, members, member_names)

Edges FROM a departing member TO one that stays -- reported, not refused.

The departing manifest keeps a reference to a package that is no longer a sibling. That is a normal, resolvable situation (the dependency is published, or becomes published), unlike the inbound direction where a repository that stays behind is left pointing at nothing. It is stated in the plan so the operator sees it before the conversion, not after.

#_check_no_inflight

python
def _check_no_inflight(workspace_root, releasables)

Hard-error when a release is in flight anywhere in the workspace.

#resolve_departure

python
def resolve_departure(workspace_root, releasable_name, target_path, *, delete_with_rm)

Resolve and validate one extraction. Reads only; refuses loudly.

Every refusal in the command lives here or in the helpers it calls, so a --dry-run refuses exactly what an apply would, and neither has written anything by the time it does.

#_next_steps

python
def _next_steps(dep)

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

#_repository_bound_publishers

python
def _repository_bound_publishers(workspace_root, members)

The departing members' targets whose publisher names the repository.

Asked of the target rather than derived from its name: which registries bind publishing to a repository is the registry's fact, and the target registry is where rlsbl keeps those.

Called once, during observation, while the members are still on disk: the result is a set of per-target DECLARATIONS, so it is settled the moment the plan is made and is carried on the Departure from there. Re-detecting it at apply time reads directories the apply itself has already deleted.

A member whose targets cannot be detected contributes no hint. That is deliberate: the hint is guidance, and a broken declaration on a DEPARTING member is not otherwise this conversion's business (its tag scheme comes from the releasable).

#_tag_verdict

python
def _tag_verdict(dep, plan)

(state, summary) for the tag item -- three outcomes, not two.

"Nothing translated" has two very different causes, and collapsing them reports a falsehood for one of them. A releasable that owns no tag yet -- the freshly split one the manual-split procedure produces -- translates nothing because there is nothing to translate, while its format still changes on the way out; saying the destination "keeps this releasable's tag format" there contradicts the format change the same item's facts show.

#_state_entries

python
def _state_entries(dep)

The state-directory entries that will be transplanted, in order.

#_archived_versions

python
def _archived_versions(dep)

The archived release files (v*.toml) the transplant carries.

#observe

python
def observe(dep) -> Preview

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

#_departing_registry_names

python
def _departing_registry_names(dep)

The names the departing members publish under.

#_source_transition_record_path

python
def _source_transition_record_path(dep)

Where the SOURCE records the departure: the WORKSPACE-scoped record.

<root>/.rlsbl-monorepo/transitions.jsonl. A departure is a fact about this repository's tag namespace -- these globs stopped belonging here -- not about any releasable in it, and the departing releasable's own record leaves with the conversion. The other two candidates are both wrong:

  • <root>/.rlsbl/transitions.jsonl (the standalone home) cannot exist in a

workspace at all: rlsbl's root-rlsbl-conflict check refuses a root .rlsbl/ beside .rlsbl-monorepo/, so writing there would make the source fail its own workspace checks;

  • a surviving releasable's record would file a repository-wide fact under

whichever releasable happened to be picked.

#_apply_filter

python
def _apply_filter(dep, item, run)

Clone the source and rewrite the clone down to the member paths.

#_promotion_source_shas

python
def _promotion_source_shas(dep)

Every monorepo commit the promotion has to translate, HEAD first.

The release record's release commits (which commit each released version shipped from), the changelog's commit hashes, and the current HEAD -- the three things that name a monorepo commit and travel with the conversion.

#_build_split_map

python
def _build_split_map(dep)

({monorepo sha: mirror sha}, [(sha, why)]) for this promotion.

One git subtree split per distinct commit, so every entry is the split git itself computed for that commit rather than an offset guessed from another entry. A commit the split cannot answer for -- one that predates the member's directory, or a hash the monorepo no longer has -- is NOT guessed: it is left out of the map and named, and the ordinary unmapped-hash paths (dropped changelog entries, a release commit left as recorded) handle it exactly as they do for a filter that pruned a commit.

#_apply_promotion_clone

python
def _apply_promotion_clone(dep, item, run)

Start the destination from the MIRROR's history, and map the two.

#_require_clean_mirror_contract

python
def _require_clean_mirror_contract(dep, member)

Refuse to adopt a mirror whose contract the reconciler does not vouch for.

The same :func:~rlsbl.commands.monorepo.mirror_cmd.observe the reconciler runs, on the same remote -- so "may this mirror be promoted?" and "may this mirror be converged?" are answered by one classification rather than by two that can drift apart.

contract_violated and ancestry_undetermined refuse: the first because a promotion would make hand-authored commits the permanent history of the extracted repository, the second because whether that is what would happen was never established. Neither refusal sends the operator at rlsbl monorepo mirror: converging force-pushes the split over the mirror's tip, which for a contract violation means DESTROYING the very commits this refusal is about. The remedy is to resolve them first.

behind is deliberately not refused here: the tree proof below is the check that names it, with both tree hashes.

#_apply_promotion_trees

python
def _apply_promotion_trees(dep, item, run)

Prove the mirror carries exactly the subtree that is about to be deleted.

The proof is TREE-HASH EQUALITY: the monorepo's HEAD:<member> tree must equal the root tree of the mirror's PRE-SCAFFOLD split commit -- the commit under the scaffold layer, which is the split itself. A git tree hash is content-addressed, so equality is not a heuristic: it says the bytes the source is about to lose are the bytes the destination already has.

Only the tip and its first parent are considered, because those are the only two shapes the mirror contract allows (a bare split, or exactly one scaffold commit atop one). A mirror that is behind fails here and says so, rather than promoting an older tree and taking the newer one out of the monorepo.

The tree proof is the SECOND question, not the first. A promotion adopts the mirror's whole history, so the mirror contract itself -- the reconciler's own classification -- is asked before it: a pair of hand-authored commits that cancel each other out leaves the member's tree byte-identical and would sail through a proof that only compares trees.

#_apply_promotion_tags

python
def _apply_promotion_tags(dep, item, run)

Materialize each released version's destination tag from the release record.

The mirror carries tags for the versions released since it was bound, and nothing for the ones before. Both are handled by the same rule: the tag for a version stands at the split of that version's recorded release commit. A tag already there at that commit is kept; one standing anywhere else is a hard error, because a released tag names what shipped and moving one is never this conversion's decision.

#_apply_dependencies

python
def _apply_dependencies(dep, item, run)

Nothing to do: the edges were judged during observation.

#_apply_trees

python
def _apply_trees(dep, item, run)

Verify tree-object identity per member. Any mismatch is fatal.

#_apply_state

python
def _apply_state(dep, item, run)

Transplant the state directory, then remap its hashes and release commits.

#_merge_standalone_config

python
def _merge_standalone_config(dep, releasable_config, dest_config)

Merge the releasable config under the member's own, for a flat repo.

In a workspace the releasable-level config is the base and a member's own .rlsbl/config.json overrides individual keys. A standalone successor has one config file where both used to live, so the same precedence is applied once, here, rather than left to whichever file happened to be copied last.

The mode is the one the destination's own config already carries (or the umask's, when there is none) -- see :func:_write_config.

#_remap_changelog

python
def _remap_changelog(dep, run)

Map the transplanted changelog hashes onto the rewritten commits.

#_regenerate_pruned_markdown

python
def _regenerate_pruned_markdown(dep, run, changes_dir, dropped)

Re-render the .md of every released version that lost entries.

A released version is a JSONL and a generated markdown that describe the same thing. Dropping entries from the JSONL without re-rendering left the pair disagreeing -- in the extreme, an empty JSONL beside a markdown still listing the entries -- and the next changelog generate would have erased that section with nobody deciding to.

The description and context come from the version's archived release file, which travels with the conversion, so an emptied version still renders its own heading and description over "No user-facing changes."

#_map_sha

python
def _map_sha(old, sha_map)

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

#_remap_release_commits

python
def _remap_release_commits(dep, run)

Remap every archived release commit onto the rewritten history.

An archive records which commit a version shipped from and the tree of every path it shipped. Both are stated in the SOURCE's object graph, which the filter has just replaced, so both are rewritten here: the commit through filter-repo's map, the trees recomputed at the new commit and the path the member now has.

A release commit whose commit the filter pruned is left exactly as it was and NAMED on stderr. Rewriting it to nothing would be worse (the fields are the record of what shipped), and aborting mid-conversion would leave a half-converted pair of repositories -- the transition record is what explains the stale value.

A tree that DOES resolve is checked rather than merely rewritten. Tree hashes are content-addressed, so a faithful filter recomputes the recorded value exactly; a disagreement means the content of a historical release changed under the rewrite, and this is the only place that fact is observable. It is a hard error -- at this point the source is still untouched (the source-side edit is a later step), so the destination can be deleted and nothing is lost.

#_dest_release_commit_path

python
def _dest_release_commit_path(dep, old_path)

A recorded path's spelling in the destination.

A workspace releasable's release commit is keyed by member path and those paths are unchanged; a standalone successor hoisted its one member to the root, so that member's key becomes "." -- the same spelling a standalone release writes.

#_apply_tags

python
def _apply_tags(dep, item, run)

Retag the clone: translate own tags, keep one alias, prune foreign ones.

#_apply_destination

python
def _apply_destination(dep, item, run)

Give the destination the identity its own loader needs, then commit.

#carried_member_keys

python
def carried_member_keys()

The member keys an extracted member arrives still declaring.

#_write_destination_workspace

python
def _write_destination_workspace(dep)

Write the new repository's workspace.toml.

It carries a dev-node ROOT member (the extracted members keep their own subdirectories, so nothing owns the repository root otherwise, and a workspace without a root member does not load), and the releasable with an EXPLICIT tag_format -- the tags travelled unchanged, and a format left to the default would be a different question than the one they answer.

Each member arrives declaring what it declared: everything the extract does not rewrite itself travels, so the destination's checks read the same member the source's did.

#_write_standalone_releasable

python
def _write_standalone_releasable(dep)

Write .rlsbl/releasable.toml for a single-member successor.

create_standalone_releasable would otherwise derive the name from the manifest, which is not necessarily the releasable's name -- and the name is what its tags, its changelog home and its release state were written under. Stating it explicitly is what makes the successor read back as the same releasable that left.

#_assert_destination_loads

python
def _assert_destination_loads(dep)

The new repository must read back through rlsbl's own loader.

#_apply_transition_record

python
def _apply_transition_record(dep, item, run)

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

#_apply_source

python
def _apply_source(dep, item, run)

Remove the departed members from the source and commit the whole edit.

#_floor_config_paths

python
def _floor_config_paths(dep)

Which config files the source declares the departed packages' floors in.

Every releasable that STAYS, because that is the set of configs the dep-floors check reads: it compares a manifest against its lock using the config resolved for that releasable, so a declaration anywhere else polices nothing.

Not the repository root's .rlsbl/config.json: rlsbl's own root-rlsbl-conflict check refuses a root .rlsbl/ beside .rlsbl-monorepo/, so in a workspace whose root member owns no releasable there is no legal root config to write. The releasable configs are the workspace's equivalent of it, and one of them IS the root member's own when the root member belongs to a releasable.

#_declare_dep_floors

python
def _declare_dep_floors(dep)

Add the departing packages to the remaining releasables' floors.

They are external packages from now on: anything here that ends up depending on one must declare a floor at the version it was developed against, and the dep-floors check only polices packages named in internal_dep_floors. Declaring them now is what makes the check speak up the first time somebody adds the dependency back through a registry.

#_apply_next_steps

python
def _apply_next_steps(dep, item, run)

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

#_apply_history

python
def _apply_history(dep, item, run)

Build the destination's history: a promotion clones, an extract filters.

#_apply_identity

python
def _apply_identity(dep, item, run)

Prove the code that arrives is the code that leaves.

#_apply_tag_step

python
def _apply_tag_step(dep, item, run)

Give the destination its tags.

#cmd_extract

python
def cmd_extract(workspace_root, releasable_name, target_path, *, dry_run=False, delete_with_rm=False)

Extract a releasable into its own repository. Returns the Preview.

Args:

  • workspace_root: path to the monorepo root.
  • releasable_name: the releasable to extract, whole.
  • target_path: where the new repository is created (must not exist).
  • dry_run: render the plan and stop.
  • delete_with_rm: delete the departed directories with rm -rf

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

#_cmd_extract

python
def _cmd_extract(flags, project_root)

rlsbl monorepo extract <releasable> <target-path>.

Search