rlsbl v0.92.0 /rlsbl.utils
On this page

Shared utilities: subprocess runner, tool detection, project root discovery, git helpers, version bumping, changelog extraction, and commit tooling.

#rlsbl.utils

#rlsbl.utils

Shared utilities: subprocess runner, git helpers, version bumping, changelog extraction, commit tooling, and GitHub API queries.

#run

python
def run(cmd, args=None, timeout=120, env=None, cwd=None)

Run a command with args, return trimmed stdout. Raise on failure.

#warn_exception

python
def warn_exception(context: str, exc: Exception) -> None

Print a warning with optional traceback for non-fatal errors.

#require_tool

python
def require_tool(name, purpose=None, fatal=True)

Check that a CLI tool is available on PATH.

Args:

  • name: command name (e.g., "uv", "npm", "go").
  • purpose: optional human-readable reason ("for editable install"),

included in the error message when fatal.

  • fatal: if True, raise FileNotFoundError on missing tool; if False,

return None silently.

Returns the resolved path to the tool, or None if missing and not fatal.

#find_project_root

python
def find_project_root(start=None)

Walk up from start (default: cwd) to find .rlsbl/ or .rlsbl-monorepo/.

Returns the directory path containing the marker, or None if not found. Prefers the nearest ancestor with either marker.

#detect_uv_workspace_root

python
def detect_uv_workspace_root(project_dir: str) -> str | None

Walk up from project_dir to find a uv workspace root that includes it as a member.

Checks each ancestor for a pyproject.toml with [tool.uv.workspace.members]. If found, expands member globs and excludes, then checks if project_dir is in the resolved member set.

Returns the workspace root directory, or None if project_dir is not a member of any uv workspace.

#is_clean_tree

python
def is_clean_tree()

Returns True if the git working tree is clean (no uncommitted changes).

#get_last_version_tag

python
def get_last_version_tag(tag_glob: str='v*') -> str | None

Get the most recent version tag reachable from HEAD.

When tag_glob is set (e.g. mylib@v* in monorepo mode), uses it as the --match pattern so each project resolves its own last release tag.

Returns the tag string on success. On failure, checks whether the repo is a shallow clone:

  • If shallow: raises GitError (shallow clones lack the history needed

for changelog validation).

  • If not shallow: returns None (no tags exist -- genuine first release).

#get_current_branch

python
def get_current_branch()

Returns the current git branch name.

Raises GitError when HEAD is detached (git returns the literal string "HEAD"), since callers like push_if_needed would silently misbehave by operating on origin/HEAD.

#get_push_timeout

python
def get_push_timeout(config=None)

Return the push timeout in seconds.

Precedence: RLSBL_PUSH_TIMEOUT env var > config dict push_timeout

default 120 (the documented contract).

config may be None (env > default only).

#get_check_timeout

python
def get_check_timeout(config=None)

Return the check timeout in seconds.

Precedence: RLSBL_CHECK_TIMEOUT env var > config dict check_timeout

default 120.

config may be None (env > default only).

#get_hook_timeout

python
def get_hook_timeout()

Return the hook timeout in seconds, from RLSBL_HOOK_TIMEOUT or default None.

If not set, returns None (no timeout — hooks run to completion). If set, parses as a positive integer. On invalid value, prints a warning and returns None.

#remote_branch_exists

python
def remote_branch_exists(branch)

Check whether origin/{branch} exists as a valid ref.

#push_if_needed

python
def push_if_needed(branch, env=None, *, config)

Push the branch to origin if local is ahead of remote.

Args:

  • branch: branch name to push.
  • env: optional environment dict passed to the push subprocess (e.g. to

set RLSBL_RELEASE_PUSH=1 so the pre-push hook recognises the push as release-authorized). Defaults to None (inherit current env).

  • config: project config dict forwarded to get_push_timeout.

#extract_changelog_entry_from_text

python
def extract_changelog_entry_from_text(content, version)

Extract a changelog entry for a specific version from a markdown string.

Looks for a heading like '## 1.2.3' and captures everything until the next heading or EOF.

#extract_changelog_entry

python
def extract_changelog_entry(changelog_path, version)

Extract a changelog entry for a specific version.

Looks for a heading like '## 1.2.3' and captures everything until the next heading or EOF.

#check_gh_installed

python
def check_gh_installed()

Check that the gh CLI is installed.

#check_gh_auth

python
def check_gh_auth()

Check that the gh CLI is authenticated.

#find_commit_tool

python
def find_commit_tool()

Detect safegit or fall back to git for committing.

Returns "safegit" if available on PATH, otherwise "git". Prints a one-time warning to stderr when falling back to git.

#has_staged_or_modified

python
def has_staged_or_modified(paths: list[str], cwd: str | None=None) -> bool

Check if any of the given paths have staged or unstaged changes.

When cwd is set, git commands run from that directory and os.path.exists checks are resolved relative to it. Paths must be relative to cwd (or absolute).

#commit_files

python
def commit_files(message: str, files: list[str], allow_failure: bool=False, autogenerated: bool=True, cwd: str | None=None) -> bool

Commit specific files using safegit (preferred) or git.

When autogenerated is True, passes --trailer "Autogenerated: true" to the commit command (supported by git 2.32+ and safegit 0.10.0+).

When cwd is set, the commit tool runs from that directory. File paths must be relative to cwd (or absolute). This is needed in monorepo mode where paths are relative to the repo root but the process CWD is a sub-project directory.

Returns True on success. When allow_failure is True, catches errors and returns False with a warning to stderr. When False, exceptions propagate.

#commit_files_if_changed

python
def commit_files_if_changed(message: str, files: list[str], skip_message: str='No changes to commit.', autogenerated: bool=True, cwd: str | None=None) -> bool

Commit files only if they have actual changes, otherwise print skip_message.

Returns True if a commit was made, False if nothing changed. Raises on commit failure (never uses allow_failure=True).

#_parse_prerelease_suffix

python
def _parse_prerelease_suffix(version)

Parse the pre-release suffix from a semver version string.

Returns (preid, counter) if the version has a suffix like "-alpha.0", or (None, None) if no suffix is present.

Raises VersionError if the suffix format is invalid.

#bump_version

python
def bump_version(version, bump_type, preid='')

Bump a semver version string by the given type.

Supported bump types: patch, minor, major, hotfix, prerelease.

When preid is set with a standard bump (patch/minor/major), the bumped base version gets a pre-release suffix appended: e.g. minor + alpha on "0.42.0" produces "0.43.0-alpha.0".

When bump_type is "prerelease":

  • The current version must have a pre-release suffix.
  • If preid is empty or matches the current preid: increment the counter.
  • If preid is "stable": strip the suffix, return the base version.
  • If preid is higher in the ordering (alpha < beta < rc < stable): promote.
  • If preid is lower: error (cannot demote).

Hotfix with preid is a hard error.

Without preid, the existing behavior is preserved: strip any pre-release suffix and bump the base version normally.

#is_private_repo

python
def is_private_repo()

Detect if the current repo is private via GitHub API.

Returns True if private, False if public, None if detection fails.

#extract_github_repo_from_remote

python
def extract_github_repo_from_remote(remote_url: str) -> str | None

Extract owner/repo from a git remote URL.

Supports:

  • SCP-style: [email protected]:owner/repo.git, git@gw:owner/repo.git, gp:owner/repo.git
  • HTTPS: https://github.com/owner/repo.git

Returns "owner/repo" or None if the URL doesn't match.

#get_origin_repo

python
def get_origin_repo() -> str | None

Get owner/repo for the origin remote of the current git repo.

Returns None on any error (no remote, not a git repo, unparseable URL).

#get_github_repo

python
def get_github_repo(config: dict | None=None) -> str | None

Resolve the GitHub owner/repo slug from config or the git remote.

Precedence:

  1. config["github_repo"] if config is provided and the key is set.
  2. get_origin_repo() to parse the origin remote URL.

Returns "owner/repo" or None if neither source provides a slug.

#run_gh

python
def run_gh(args: list, config: dict | None=None, **kwargs) -> str

Run a gh CLI command with automatic GH_REPO resolution.

Resolves the repo slug via get_github_repo(config) and, if found, sets GH_REPO in a per-call env dict so gh targets the correct repository. Does NOT mutate os.environ (critical for thread-safety in watch.py's ThreadPoolExecutor).

All extra kwargs are forwarded to run().

#read_go_module_path

python
def read_go_module_path(project_dir: str) -> str | None

Read the module path from go.mod.

Returns None if go.mod does not exist or cannot be parsed.