Skip to content
rlsbl.git_util
On this page

The single implementation of git's is-A-an-ancestor-of-B question and of the rev spec naming a released path's tree, plus the commit-level half of file attribution and the subtree-remote SSH host check.

#rlsbl.git_util

#rlsbl.git_util

Shared git utilities: commit ancestry, and commit-level file attribution.

Holds :func:ancestry, the single implementation of the "is A an ancestor of B?" question every part of rlsbl asks (the mirror tripwire, the changelog validation cache, the release candidate check, the resume check), plus the commit-level half of file attribution -- retrieving the files a commit changed and asking :mod:rlsbl.ownership who owns them -- and the SSH host check for subtree remotes and manual-push detection.

It also holds :func:tree_rev_spec, the one place rlsbl decides how a released path's tree is named at a commit. Five call sites wrote that decision out themselves -- the release flow's release-commit writer, the release-commit remap, extract, absorb, and the archive backfill -- and each carried its own copy of the rule that "." means rev^{tree} while a subdirectory means rev:path. The runners stay per-caller (they differ in timeout, error wording and which seam they go through); the SPELLING is decided here.

Attribution itself (which member owns a path) is decided in :mod:rlsbl.ownership and nowhere else. This module only supplies the git reads it needs.

#Ancestry

The outcome of an ancestry question, with "cannot tell" spelled out.

git merge-base --is-ancestor answers with three exit codes, not two: 0 means yes, 1 means no, and anything else (128, typically) means git could not answer -- an object the repository does not have, a broken object store. Collapsing that third case into False makes "we do not know" indistinguishable from "we checked, and no", which is how a truncated history quietly turns into a wrong decision.

Exit 1 is not always an honest "no" either, which is why :func:ancestry does more than translate exit codes. In a SHALLOW repository the walk from the descendant stops at the graft boundary, so a commit whose connecting history was never downloaded gets exit 1 -- the same answer git gives for a genuinely unrelated commit, for a question it cannot answer. Two --depth 1 fetches into one repository reproduce it with both commits present. :func:ancestry therefore reports :attr:INDETERMINABLE for exit 1 in a shallow repository, and keeps :attr:FALSE for exit 1 in a full one.

Each caller decides what :attr:INDETERMINABLE means for it, and records the decision where the call is: the mirror reconciler and the release's recorded-candidate check branch on it separately from FALSE; the changelog validation cache and release resume fold it into the same fail-safe branch FALSE takes (recompute; refuse).

#ancestry

python
def ancestry(ancestor: str, descendant: str, cwd: str | None=None, *, timeout: int | None=10) -> Ancestry

Is ancestor reachable from descendant? The one implementation.

A commit is its own ancestor, as git has it. Never raises: a timeout or a missing git binary is :attr:Ancestry.INDETERMINABLE, same as git's own "I cannot answer" exit code.

Exact semantics, exit code by exit code:

  • 0 -> :attr:Ancestry.TRUE.
  • 1 -> :attr:Ancestry.FALSE in a full repository;

:attr:Ancestry.INDETERMINABLE when the repository is shallow, because the walk stops at the graft boundary and git answers "no" to a question it could not follow to the end.

  • anything else (128, typically) -> :attr:Ancestry.INDETERMINABLE.

The shallowness probe is itself a read that can fail (no repository at cwd, an ancient git). An unreadable answer is taken as "not shallow", which keeps an ordinary "no" ordinary: a cwd that is no repository at all would have made merge-base exit 128 in the first place.

Lives here rather than in a command handler because strictcli's effects-bypass lint reads a handler's own body: a ctx.effects call made through rlsbl's chokepoint module is indistinguishable, to that lint, from a raw subprocess call. Keeping effectful work out of the registration module is the shape the lint is asking for, and it is where this belongs anyway.

#_is_shallow

python
def _is_shallow(cwd: str | None, *, timeout: int | None) -> bool

Is the repository at cwd shallow? False when the probe cannot say.

#tree_rev_spec

python
def tree_rev_spec(sha: str, path: str) -> str

The git rev spec naming the tree of path at commit sha.

"." and "" both mean the repository root, which git spells <rev>^{tree} -- <rev>:. is not the same thing and <rev>: is not a spec at all. Every other path is <rev>:<path>.

The one derivation for a rule that used to be restated at each site that records or verifies a released tree. Resolving the spec is the caller's: they run it through different seams, with different timeouts and different words for a read that could not answer.

#tree_at

python
def tree_at(sha: str, path: str, *, cwd: str | None=None, timeout: int | None=10) -> str | None

The tree object of path at sha, or None when it does not resolve.

The tolerant resolver, for a caller that treats an absent path as a fact to record rather than an error -- a workspace's member directories did not exist during its standalone era, and the archive backfill notes that instead of failing. A caller that needs a resolution failure to be fatal runs :func:tree_rev_spec through its own runner and raises its own error.

#get_commit_files

python
def get_commit_files(sha)

Get the list of files changed by a single commit.

Returns a list of file paths relative to the repo root, or None on error.

--root is required: a parentless commit (a repo's first commit) has nothing to diff against, and without it git diff-tree prints nothing at all -- making the commit that created the entire project look like it touched no files, so every project-scope match against it failed.

#StashError

A managed operation refused to run while a stash is present.

#stash_entries

python
def stash_entries(cwd=None, *, timeout=10)

The repository's stash entries, one line each.

The ONE stash probe. A stash is uncommitted work with no branch of its own: nothing in the repository records what it belongs to, and every operation that rewrites, commits or force-pushes this working tree can therefore neither carry it along nor tell that it left it behind. Each of those operations asks here.

A git read that cannot answer yields no entries rather than a failure: the probe is a hygiene check, and an unreadable stash list is not evidence of one.

#stash_refusal_message

python
def stash_refusal_message(entries, *, operation, detail)

The message an operation refuses a present stash with.

operation names what is being refused ("release", "backfill"); detail is one sentence saying what that operation does to this working tree, so the refusal explains itself rather than merely asserting a rule.

#refuse_present_stash

python
def refuse_present_stash(cwd=None, *, operation, detail, error=StashError)

Raise when the repository has a stash, naming it and the way out.

#commit_files

python
def commit_files(sha, *, operation)

Return the files a commit changed, or raise naming the commit.

:func:get_commit_files answers None when git could not say -- a missing object, a timeout, no git at all. Every attribution caller used to turn that into a guess (include the commit "to be safe", or drop it), so a broken read silently changed which member a commit was charged to. It is a hard error instead, naming the commit and the operation that asked.

#commit_owner_names

python
def commit_owner_names(sha, members, *, operation) -> set

Names of the members owning any file the commit sha changed.

Tool-owned paths (:mod:rlsbl.ownership) contribute no owner, so a commit that only touches changelog state yields the empty set.

#filter_commits_for_scope

python
def filter_commits_for_scope(commits, scope, *, operation)

Filter commits to those touching a file owned by a member in scope.

scope is an :class:~rlsbl.ownership.OwnershipScope, which carries the whole member list alongside the subset asked about -- attribution needs both, because a file's owner is decided against every member, not just the ones the caller cares about. A releasable's scope additionally claims its own state directory, which no member's declared path covers. None means "no workspace" and returns commits unchanged.

#extract_ssh_host

python
def extract_ssh_host(git_url)

Extract the SSH host from a git URL.

Supports SCP-like syntax (git@host:owner/repo.git) and explicit SSH URLs (ssh://git@host/owner/repo.git). Returns the host string, or None for HTTPS URLs, empty strings, or unparseable formats.

#validate_subtree_remote_ssh_host

python
def validate_subtree_remote_ssh_host(subtree_remote, project_root)

Validate that subtree_remote and origin use the same SSH host.

Hard error (sys.exit(1)) when both URLs are SSH and their hosts differ. Silently passes when either URL is not SSH or when origin cannot be read.

#get_push_changed_files

python
def get_push_changed_files(refs)

Get the list of files changed across all pushed refs.

Takes a list of (local_sha, remote_sha) tuples (as returned by _parse_stdin_refs in prepush_utils.py).

Returns a set of file paths relative to the repo root, or None if git commands fail.

#affected_members

python
def affected_members(changed_files, members)

Determine which workspace members own at least one of changed_files.

Single-owner attribution: a file counts for exactly one member, so a change under pkg/inner affects pkg/inner and not its parent, and a change to a root file affects the root member alone. Members are returned in workspace declaration order.

#detect_manual_push_branches

python
def detect_manual_push_branches(stdin_lines, release_branches)

Return list of release branch names being pushed to manually.

Parses pre-push hook stdin lines for refs/heads/<branch> patterns and returns branch names that match release_branches.

Returns an empty list if no release branches are being pushed to or if stdin_lines is empty/None.

There is deliberately NO environment-variable bypass. Release-internal pushes run git push --no-verify, so they never invoke the hook at all; every push that reaches this function is a hook-running push, i.e. manual.

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