Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
python-version: "3.14"
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/python-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
# pgdevkit (a real dependency as of `bdt cleanup orphaned-dbs`'s
# pgdevkit delegation) requires Python >=3.14.
python-version: ["3.14"]

steps:
- uses: actions/checkout@v4
Expand Down
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.12
3.14
92 changes: 90 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# bmsdna-devtools

Shared developer tooling for BMS projects: PR build/check status, PR
creation, issue/work item creation and comments, git worktrees, a
commit-and-push helper with pre-flight checks, and Azure log queries.
creation, issue/work item creation and comments, git worktrees (creation and
merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with
pre-flight checks, and Azure log queries.
`bdt pr *` and `bdt issue *` auto-detect whether the current repo's `origin`
remote is Azure DevOps or GitHub and use `az`/`gh` accordingly.
Consolidates near-duplicate scripts that used to be copy-pasted across
Expand Down Expand Up @@ -239,6 +240,93 @@ Creates `.worktrees/<name>` branched from `--base`, initializes submodules
(unless `--no-submodules`), and copies an env file into the new worktree as
`.env` (auto-detects `.local_env` then `.env` if `--env-file` isn't given).

## `bdt cleanup worktrees` / `bdt cleanup orphaned-dbs` / `bdt cleanup db` / `bdt cleanup worktree`

```bash
bdt cleanup worktrees [root] [--remote origin] [--keep-dbs] [--yes]
bdt cleanup orphaned-dbs [root] [--include-caution] [--yes]
bdt cleanup db [path] [--confirm]
bdt cleanup worktree [path] [--keep-db] [--confirm]
```

The first two recursively scan every git repo under `root` (default: `.`)
for worktrees. `bdt cleanup worktrees` prunes the ones fully merged into
`<remote>/main`/`<remote>/test` (falling back to local `main`/`test` if no
such remote refs exist) — e.g. a tree of `.worktrees/<branch>` directories
accumulated across several repos over time. `bdt cleanup orphaned-dbs` has no
`--remote`/merge-status notion at all: it just finds pgdevkit test DBs with
no matching *live* git worktree, regardless of whether that worktree was
ever merged anywhere. Like `bdt issue delete`, neither command has an
interactive prompt — both only ever *print* what they would remove/drop;
pass `--yes` to actually do it.

`bdt cleanup db` / `bdt cleanup worktree` instead target one specific,
still-live worktree — `path` defaults to `.`, so both are meant to be run
from inside the worktree in question, regardless of its merge status.
`cleanup db` only drops that worktree's own pgdevkit test DB(s), leaving the
worktree itself alone; `cleanup worktree` removes the worktree too (and,
unless `--keep-db`, its DB(s) along with it) — refusing the main checkout, a
protected branch (`main`/`test`), a locked worktree, or a dirty one
(submodules included). Since these two act on a single worktree a human
picked out by hand, rather than scanning for candidates, they default to an
interactive `y/N` confirmation instead of `--yes`; pass `--confirm` to skip
it for non-interactive use.

Every one of these four commands, before dropping any database, additionally
requires typing `yes` at an interactive prompt whenever `--pg-host` isn't
`localhost`/`127.0.0.1`/`::1` — this specific check has no flag to bypass it
(not even `--yes`/`--confirm`), so a script or agent can never drop a
database on a shared/remote Postgres instance without a human confirming it
directly.

The DB-naming algorithm and orphan detection are entirely
[pgdevkit](https://github.com/bmsuisse/pgdevkit)'s own
(`pgdevkit.testdb.workspace_db_names()` /
`pgdevkit.testdb.find_orphaned_dbs()`) — this used to be a hand-rolled
reimplementation here (to avoid an import), which risked drifting out of
sync with pgdevkit's actual naming; now that pgdevkit exposes both directly,
bdt just calls them. If a repo's root `pyproject.toml` has a
`[tool.pgdevkit].engine = "postgres"` (the default once `[tool.pgdevkit]`
exists at all), removing one of its worktrees also drops the Postgres test
database(s) pgdevkit created for that branch — pass `--keep-dbs` to skip
that. A DB whose name ends in a bare branch name
(`main`/`test`/`dev`/`head`/`i18n`, rather than a slugified feature branch)
is flagged `⚠ possibly a standing reference DB` and excluded even with
`--yes`, since that might be an intentional baseline DB rather than an
orphaned leftover — pass `--include-caution` too if you've verified it
really is safe to drop. That flagging (bdt's own heuristic, not pgdevkit's)
is the one bit of naming-adjacent logic still here.

A repo can additionally own **sibling** test DBs (e.g. a second DB for a
vendored mock service) and **nested** ones (an unrelated per-branch DB, under
a different pgdevkit project name, that happens to share the same branch).
Siblings are pgdevkit's own concern now — configure
`[tool.pgdevkit].extra_db_suffixes` in the *consuming* repo (see pgdevkit's
README) and both `ensure_testdb`-side tooling and `bdt cleanup` pick it up
automatically. Nested projects have no pgdevkit equivalent (it's a wholly
separate project name/pyproject.toml, not a literal suffix of the same
project's DB), so that stays configured here:

```toml
[tool.bdt.worktree]
db_nested_projects = ["akeneo_editor"] # a wholly separate per-branch DB,
# its own (possibly section-less)
# pyproject.toml, sharing this
# worktree's branch
```

`--pg-port`/`--pg-user` (all four commands; env vars `PGPORT`/`PGUSER`, no
fallback to `$USER`/`$LOGNAME` — those are set in virtually every shell,
which would make the pgdevkit-user default below never fire) default to
*pgdevkit's own* test-container port/user, not the OS user or Postgres'
standard `5432` — `bdt cleanup orphaned-dbs`'s listing step always connects
via pgdevkit's own
`PGDEVKIT_TESTDB_*`-driven resolution (it's calling straight into pgdevkit),
so its own `psql`-based DROP step defaults to matching that, rather than
silently targeting a different Postgres instance than the one that was just
queried. Pass `--pg-port`/`--pg-user` explicitly if your setup deliberately
differs.

## `bdt commit`

```bash
Expand Down
101 changes: 101 additions & 0 deletions bmsdna/devtools/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import requests
import typer
from pgdevkit.testdb import constants as pgdevkit_constants

from . import ado_issue, app_service_logs, commit as commit_mod
from . import env_config
Expand Down Expand Up @@ -66,6 +67,35 @@ def main(
logs_app = typer.Typer(name="logs", help="Application Insights / Log Analytics queries")
app.add_typer(logs_app, name="logs")

cleanup_app = typer.Typer(
name="cleanup",
help="Prune merged git worktrees and their orphaned pgdevkit Postgres test databases",
)
app.add_typer(cleanup_app, name="cleanup")

# Shared `--pg-port`/`--pg-user` defaults for `bdt cleanup *`: pgdevkit's own
# test-container port/user (its `find_orphaned_dbs()`/`workspace_db_names()`
# connect via *its* PGDEVKIT_TESTDB_* env vars, not these flags -- these only
# drive bdt's own `psql`-based DROP DATABASE step, see testdb.py's module
# docstring -- so defaulting to anything else would make the two halves of
# `bdt cleanup orphaned-dbs` silently target different Postgres instances).
_PG_HOST_OPTION = typer.Option(
pgdevkit_constants.HOST, "--pg-host", envvar="PGHOST", help="Postgres host to connect to (default: pgdevkit's own test-container host)"
)
_PG_PORT_OPTION = typer.Option(
pgdevkit_constants.PORT, "--pg-port", envvar="PGPORT", help="Postgres port to connect to (default: pgdevkit's own test-container port)"
)
_PG_USER_OPTION = typer.Option(
None,
"--pg-user",
# Deliberately just PGUSER, not also $USER/$LOGNAME: those generic OS
# envvars are set on virtually every shell, which would make `pg_user or
# pgdevkit_constants.USER`'s fallback never fire and silently connect as
# the wrong role on everyone's machine.
envvar="PGUSER",
help="Postgres user to connect as (default: pgdevkit's own test-container user)",
)


def _resolve_ado_pr(pat: str | None, remote: AdoRemote, source_branch: str, target: str) -> tuple[requests.Session, dict]:
session = requests.Session()
Expand Down Expand Up @@ -608,6 +638,77 @@ def worktree(
worktree_mod.create(name, base=base, env_file=env_file, submodules=submodules, install_cmd=install_cmd)


@cleanup_app.command("worktrees")
def cleanup_worktrees(
root: Path = typer.Argument(Path("."), help="Root folder to scan for git repositories (recursively)"),
remote: str = typer.Option("origin", "--remote", help="Remote name whose main/test branches count as 'merged into' (falls back to local main/test if no such remote refs exist)"),
keep_dbs: bool = typer.Option(False, "--keep-dbs", help="Don't drop a removed worktree's pgdevkit test DB(s) along with it"),
yes: bool = typer.Option(False, "--yes", help="Actually remove; without this, only prints what would be removed"),
pg_host: str = _PG_HOST_OPTION,
pg_port: int = _PG_PORT_OPTION,
pg_user: str | None = _PG_USER_OPTION,
) -> None:
"""Find and remove git worktrees fully merged into main/test (and their pgdevkit test DB(s)), across every repo under root."""
worktree_mod.clean_worktrees(
root, remote=remote, keep_dbs=keep_dbs, yes=yes, pg_host=pg_host, pg_port=pg_port, pg_user=pg_user or pgdevkit_constants.USER
)


@cleanup_app.command("orphaned-dbs")
def cleanup_orphaned_dbs(
root: Path = typer.Argument(Path("."), help="Root folder to scan for git repositories (recursively)"),
include_caution: bool = typer.Option(
False, "--include-caution", help="Also drop DBs flagged as possibly a standing reference DB (verify those first!)"
),
yes: bool = typer.Option(False, "--yes", help="Actually drop; without this, only prints what would be dropped"),
pg_host: str = _PG_HOST_OPTION,
pg_port: int = _PG_PORT_OPTION,
pg_user: str | None = _PG_USER_OPTION,
) -> None:
"""Find and drop pgdevkit test DBs whose worktree is already gone (e.g. removed by hand before this command existed)."""
worktree_mod.clean_orphaned_dbs(
root, include_caution=include_caution, yes=yes, pg_host=pg_host, pg_port=pg_port, pg_user=pg_user or pgdevkit_constants.USER
)


@cleanup_app.command("db")
def cleanup_db(
root: Path = typer.Argument(Path("."), help="Worktree/repo whose own pgdevkit test DB(s) to drop (default: current directory)"),
confirm: bool = typer.Option(False, "--confirm", help="Drop without an interactive confirmation prompt"),
pg_host: str = _PG_HOST_OPTION,
pg_port: int = _PG_PORT_OPTION,
pg_user: str | None = _PG_USER_OPTION,
) -> None:
"""Drop this worktree's own pgdevkit test DB(s), without touching the worktree itself.

Meant to be run from inside a worktree (default root is '.'). Always requires an
explicit confirmation -- pass --confirm to skip the interactive prompt.
"""
worktree_mod.clean_current_db(
root.resolve(), confirm=confirm, pg_host=pg_host, pg_port=pg_port, pg_user=pg_user or pgdevkit_constants.USER
)


@cleanup_app.command("worktree")
def cleanup_worktree(
path: Path = typer.Argument(Path("."), help="Worktree to remove (default: current directory)"),
keep_db: bool = typer.Option(False, "--keep-db", help="Don't drop this worktree's pgdevkit test DB(s) along with it"),
confirm: bool = typer.Option(False, "--confirm", help="Remove without an interactive confirmation prompt"),
pg_host: str = _PG_HOST_OPTION,
pg_port: int = _PG_PORT_OPTION,
pg_user: str | None = _PG_USER_OPTION,
) -> None:
"""Remove this one worktree (and, unless --keep-db, its pgdevkit test DB(s)) -- regardless of merge status.

Meant to be run from inside the worktree to remove (default path is '.'). Refuses to touch
the main checkout, a protected branch (main/test), a locked worktree, or a dirty one. Always
requires an explicit confirmation -- pass --confirm to skip the interactive prompt.
"""
worktree_mod.clean_current_worktree(
path, confirm=confirm, keep_db=keep_db, pg_host=pg_host, pg_port=pg_port, pg_user=pg_user or pgdevkit_constants.USER
)


@app.command()
def commit(
message: str,
Expand Down
7 changes: 6 additions & 1 deletion bmsdna/devtools/cli_tools.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Locating required external CLIs (az, gh) with clear errors when missing.
"""Locating required external CLIs (az, gh, psql) with clear errors when missing.

Uses `shutil.which` rather than a bare command name so Windows .cmd/.bat/.exe
shims (e.g. az.cmd from the MSI installer) resolve correctly via PATHEXT —
Expand All @@ -16,6 +16,7 @@

AZ_INSTALL_HINT = "Install the Azure CLI: https://learn.microsoft.com/cli/azure/install-azure-cli"
GH_INSTALL_HINT = "Install the GitHub CLI: https://cli.github.com"
PSQL_INSTALL_HINT = "Install the PostgreSQL client tools (psql): https://www.postgresql.org/download/"


def is_claude_code() -> bool:
Expand Down Expand Up @@ -153,3 +154,7 @@ def require_az() -> str:

def require_gh() -> str:
return require_tool("gh", GH_INSTALL_HINT)


def require_psql() -> str:
return require_tool("psql", PSQL_INSTALL_HINT)
Loading
Loading