pgdevkit 0.6.2__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/PKG-INFO +24 -3
  2. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/README.md +23 -2
  3. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/cli.py +23 -2
  4. pgdevkit-0.7.0/pgdevkit/testdb/__init__.py +21 -0
  5. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/api.py +50 -15
  6. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/config.py +13 -0
  7. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/mssql/api.py +37 -17
  8. pgdevkit-0.7.0/pgdevkit/testdb/naming.py +112 -0
  9. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pyproject.toml +1 -1
  10. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/conftest.py +20 -0
  11. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_api.py +93 -1
  12. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_cli.py +24 -0
  13. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_config.py +23 -0
  14. pgdevkit-0.7.0/tests/testdb/test_naming.py +121 -0
  15. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/uv.lock +1 -1
  16. pgdevkit-0.6.2/pgdevkit/testdb/__init__.py +0 -3
  17. pgdevkit-0.6.2/pgdevkit/testdb/naming.py +0 -41
  18. pgdevkit-0.6.2/tests/testdb/test_naming.py +0 -51
  19. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/.github/workflows/auto-release.yml +0 -0
  20. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/.github/workflows/python-publish.yml +0 -0
  21. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/.github/workflows/python-test.yml +0 -0
  22. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/.gitignore +0 -0
  23. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/.python-version +0 -0
  24. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/docs/database-layout.md +0 -0
  25. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/__init__.py +0 -0
  26. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/areas.py +0 -0
  27. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/backends/__init__.py +0 -0
  28. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/backends/base.py +0 -0
  29. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/backends/mssql.py +0 -0
  30. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/backends/postgres.py +0 -0
  31. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/connection.py +0 -0
  32. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/__init__.py +0 -0
  33. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/complex_types.py +0 -0
  34. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/connection.py +0 -0
  35. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/crud.py +0 -0
  36. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/loader.py +0 -0
  37. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/model.py +0 -0
  38. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/mssql_crud.py +0 -0
  39. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/db/mssql_sql.py +0 -0
  40. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/dialect.py +0 -0
  41. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/diff.py +0 -0
  42. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/envtag.py +0 -0
  43. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/fetch_missing.py +0 -0
  44. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/introspect.py +0 -0
  45. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/lakebase.py +0 -0
  46. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/migrate.py +0 -0
  47. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/models.py +0 -0
  48. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/mssql_introspect.py +0 -0
  49. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/parser.py +0 -0
  50. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/schemas.py +0 -0
  51. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/sql_text.py +0 -0
  52. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/_docker.py +0 -0
  53. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/constants.py +0 -0
  54. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/container.py +0 -0
  55. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/mssql/__init__.py +0 -0
  56. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/mssql/constants.py +0 -0
  57. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/mssql/container.py +0 -0
  58. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/query.py +0 -0
  59. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/pgdevkit/testdb/schema.py +0 -0
  60. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/skills/pgdevkit/SKILL.md +0 -0
  61. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  62. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/skills/pgdevkit/references/temporal-tables.md +0 -0
  63. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/__init__.py +0 -0
  64. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/conftest.py +0 -0
  65. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/db/__init__.py +0 -0
  66. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/db/test_complex_types.py +0 -0
  67. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/db/test_connection.py +0 -0
  68. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/db/test_crud.py +0 -0
  69. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/db/test_loader.py +0 -0
  70. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/db/test_mssql_crud_live.py +0 -0
  71. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/db/test_mssql_crud_sql.py +0 -0
  72. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/fixtures/01_schema.sql +0 -0
  73. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/fixtures/02_types.sql +0 -0
  74. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/fixtures/03_tables.sql +0 -0
  75. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/fixtures/04_views.sql +0 -0
  76. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/fixtures/05_functions.sql +0 -0
  77. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/fixtures/06_indexes.sql +0 -0
  78. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_areas.py +0 -0
  79. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_cli_compare.py +0 -0
  80. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_compare.py +0 -0
  81. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_compare_mssql_live.py +0 -0
  82. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_connection.py +0 -0
  83. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_dialect.py +0 -0
  84. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_diff_mssql.py +0 -0
  85. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_envtag.py +0 -0
  86. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_fetch_missing.py +0 -0
  87. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_fetch_missing_cli.py +0 -0
  88. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_lakebase.py +0 -0
  89. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_migrate.py +0 -0
  90. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_migrate_areas.py +0 -0
  91. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_migrate_env.py +0 -0
  92. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_migrate_schemas.py +0 -0
  93. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_mssql_introspect.py +0 -0
  94. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_parser_areas.py +0 -0
  95. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_parser_mssql.py +0 -0
  96. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_parser_schemas.py +0 -0
  97. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/test_schemas.py +0 -0
  98. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/__init__.py +0 -0
  99. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  100. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/event.sql +0 -0
  101. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/event_kind.sql +0 -0
  102. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  103. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  104. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/prod_only.prod.sql +0 -0
  105. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/widget.init.sql +0 -0
  106. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  107. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  108. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  109. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  110. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  111. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  112. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  113. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  114. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/permissions/grants.sql +0 -0
  115. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  116. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  117. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  118. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  119. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  120. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  121. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_api_mssql.py +0 -0
  122. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_api_mssql_live.py +0 -0
  123. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_config_mssql.py +0 -0
  124. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_constants.py +0 -0
  125. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_container.py +0 -0
  126. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_mssql_constants.py +0 -0
  127. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_query.py +0 -0
  128. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_schema.py +0 -0
  129. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_schema_filtering.py +0 -0
  130. {pgdevkit-0.6.2 → pgdevkit-0.7.0}/tests/testdb/test_schema_mssql.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.6.2
3
+ Version: 0.7.0
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
@@ -221,8 +221,8 @@ def ensure_test_postgres():
221
221
  os.environ[k] = v
222
222
  ```
223
223
 
224
- CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`. `up`/`reset` accept
225
- `--env` (default `local_test`) — see "Environment-tagged files" above.
224
+ CLI: `pgdb testdb up|reset|run-sql|status|shell|clean|list-orphaned`. `up`/`reset`
225
+ accept `--env` (default `local_test`) — see "Environment-tagged files" above.
226
226
 
227
227
  `up`/`reset` accept `--area`/`--exclude-area` and `--schema`/`--exclude-schema`
228
228
  (see "Area and schema filtering" above) to scope which `database/` files get
@@ -231,6 +231,27 @@ applied — e.g. `pgdb testdb up --schema billing` for a test DB with only the
231
231
  project's schema to apply. `ensure_testdb`/`reset_testdb` take the same
232
232
  keyword arguments when called from Python (e.g. from a pytest fixture).
233
233
 
234
+ Every git worktree/branch of a project gets its own database, named after
235
+ `project_name` + branch (see `pgdevkit.testdb.naming.workspace_db_name`).
236
+ Removing a worktree (or deleting its directory without `git worktree
237
+ remove`) doesn't drop its database — `pgdb testdb list-orphaned` lists
238
+ this project's databases whose worktree no longer exists, and
239
+ `pgdb testdb clean --orphaned` drops them (as opposed to `--all`, which
240
+ drops every database of this project regardless of whether its worktree is
241
+ still live). The same is available from Python as
242
+ `pgdevkit.testdb.find_orphaned_dbs()` and
243
+ `pgdevkit.testdb.clean_testdb(orphaned=True)`.
244
+
245
+ If your project's own test setup also creates a sibling database per
246
+ worktree (e.g. `<main_db>_myservice` for a mock service used only by that
247
+ project's tests), add its literal suffix so orphan detection knows it
248
+ belongs to a live worktree too:
249
+
250
+ ```toml
251
+ [tool.pgdevkit]
252
+ extra_db_suffixes = ["_myservice"]
253
+ ```
254
+
234
255
  Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
235
256
  be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
236
257
  `PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching the
@@ -200,8 +200,8 @@ def ensure_test_postgres():
200
200
  os.environ[k] = v
201
201
  ```
202
202
 
203
- CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`. `up`/`reset` accept
204
- `--env` (default `local_test`) — see "Environment-tagged files" above.
203
+ CLI: `pgdb testdb up|reset|run-sql|status|shell|clean|list-orphaned`. `up`/`reset`
204
+ accept `--env` (default `local_test`) — see "Environment-tagged files" above.
205
205
 
206
206
  `up`/`reset` accept `--area`/`--exclude-area` and `--schema`/`--exclude-schema`
207
207
  (see "Area and schema filtering" above) to scope which `database/` files get
@@ -210,6 +210,27 @@ applied — e.g. `pgdb testdb up --schema billing` for a test DB with only the
210
210
  project's schema to apply. `ensure_testdb`/`reset_testdb` take the same
211
211
  keyword arguments when called from Python (e.g. from a pytest fixture).
212
212
 
213
+ Every git worktree/branch of a project gets its own database, named after
214
+ `project_name` + branch (see `pgdevkit.testdb.naming.workspace_db_name`).
215
+ Removing a worktree (or deleting its directory without `git worktree
216
+ remove`) doesn't drop its database — `pgdb testdb list-orphaned` lists
217
+ this project's databases whose worktree no longer exists, and
218
+ `pgdb testdb clean --orphaned` drops them (as opposed to `--all`, which
219
+ drops every database of this project regardless of whether its worktree is
220
+ still live). The same is available from Python as
221
+ `pgdevkit.testdb.find_orphaned_dbs()` and
222
+ `pgdevkit.testdb.clean_testdb(orphaned=True)`.
223
+
224
+ If your project's own test setup also creates a sibling database per
225
+ worktree (e.g. `<main_db>_myservice` for a mock service used only by that
226
+ project's tests), add its literal suffix so orphan detection knows it
227
+ belongs to a live worktree too:
228
+
229
+ ```toml
230
+ [tool.pgdevkit]
231
+ extra_db_suffixes = ["_myservice"]
232
+ ```
233
+
213
234
  Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
214
235
  be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
215
236
  `PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching the
@@ -302,12 +302,33 @@ def testdb_shell() -> None:
302
302
  @testdb_app.command("clean")
303
303
  def testdb_clean(
304
304
  all: bool = typer.Option(False, "--all", help="Drop every database belonging to this project"),
305
+ orphaned: bool = typer.Option(
306
+ False,
307
+ "--orphaned",
308
+ help="Drop only databases whose git worktree no longer exists (see `testdb list-orphaned`)",
309
+ ),
305
310
  ) -> None:
306
- """Drop this workspace's database (or every database of this project with --all)."""
307
- testdb.clean_testdb(all=all)
311
+ """Drop this workspace's database (or every database of this project with --all,
312
+ or only its orphaned ones with --orphaned)."""
313
+ if all and orphaned:
314
+ err_console.print("[red]Error:[/red] pass at most one of --all, --orphaned")
315
+ raise typer.Exit(2)
316
+ testdb.clean_testdb(all=all, orphaned=orphaned)
308
317
  console.print("[green]Cleaned.[/green]")
309
318
 
310
319
 
320
+ @testdb_app.command("list-orphaned")
321
+ def testdb_list_orphaned() -> None:
322
+ """List this project's databases whose git worktree no longer exists,
323
+ without dropping them."""
324
+ names = testdb.find_orphaned_dbs()
325
+ if not names:
326
+ console.print("No orphaned databases.")
327
+ return
328
+ for name in names:
329
+ console.print(name)
330
+
331
+
311
332
  @migrate_app.command("check")
312
333
  def migrate_check(
313
334
  migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"),
@@ -0,0 +1,21 @@
1
+ from .api import (
2
+ clean_testdb,
3
+ dsn_for,
4
+ ensure_testdb,
5
+ find_orphaned_dbs,
6
+ reset_testdb,
7
+ run_sql,
8
+ shell_argv,
9
+ status,
10
+ )
11
+
12
+ __all__ = [
13
+ "clean_testdb",
14
+ "dsn_for",
15
+ "ensure_testdb",
16
+ "find_orphaned_dbs",
17
+ "reset_testdb",
18
+ "run_sql",
19
+ "shell_argv",
20
+ "status",
21
+ ]
@@ -9,7 +9,14 @@ from psycopg.sql import SQL, Identifier
9
9
  from . import constants, query
10
10
  from .config import ProjectConfig, load_config
11
11
  from .container import ensure_container
12
- from .naming import current_branch, slugify, workspace_db_name
12
+ from .naming import (
13
+ current_branch,
14
+ escape_like_prefix,
15
+ expected_db_names,
16
+ live_worktree_branches,
17
+ slugify,
18
+ workspace_db_name,
19
+ )
13
20
  from .schema import apply_schema
14
21
 
15
22
 
@@ -68,6 +75,16 @@ async def _drop_database(db_name: str) -> None:
68
75
  await con.execute(SQL("DROP DATABASE IF EXISTS {}").format(Identifier(db_name)))
69
76
 
70
77
 
78
+ async def _dbs_with_prefix(prefix: str) -> list[str]:
79
+ escaped_prefix = escape_like_prefix(prefix)
80
+ async with await psycopg.AsyncConnection.connect(_admin_dsn(), autocommit=True) as con:
81
+ result = await con.execute(
82
+ "SELECT datname FROM pg_database WHERE datname LIKE %(pattern)s ESCAPE '\\'",
83
+ {"pattern": f"{escaped_prefix}%"},
84
+ )
85
+ return [row[0] for row in await result.fetchall()]
86
+
87
+
71
88
  async def _apply(
72
89
  config: ProjectConfig,
73
90
  db_name: str,
@@ -154,27 +171,45 @@ def reset_testdb(
154
171
  )
155
172
 
156
173
 
157
- def clean_testdb(project_root: Path | None = None, all: bool = False) -> None:
174
+ async def _find_orphaned_dbs(config: ProjectConfig) -> list[str]:
175
+ prefix = f"{slugify(config.name)}_"
176
+ actual = await _dbs_with_prefix(prefix)
177
+ expected = expected_db_names(config, live_worktree_branches(config.root))
178
+ return sorted(set(actual) - expected)
179
+
180
+
181
+ def find_orphaned_dbs(project_root: Path | None = None) -> list[str]:
182
+ """Databases belonging to this project (matched by its name-slug prefix)
183
+ that don't belong to any currently live git worktree of this repo --
184
+ i.e. their branch's worktree was removed (or never existed) without
185
+ also dropping its database."""
186
+ config, _ = _resolve(project_root)
187
+ if config.engine == "mssql":
188
+ return _mssql_api().find_orphaned_dbs(config)
189
+ return asyncio.run(_find_orphaned_dbs(config))
190
+
191
+
192
+ def clean_testdb(project_root: Path | None = None, all: bool = False, orphaned: bool = False) -> None:
158
193
  """Drop this workspace's database. With all=True, drop every database
159
194
  belonging to this project (matched by its name-slug prefix), across
160
- every worktree/branch."""
195
+ every worktree/branch. With orphaned=True, drop only those without a
196
+ currently live git worktree (see `find_orphaned_dbs`). At most one of
197
+ all/orphaned may be set."""
198
+ if all and orphaned:
199
+ raise ValueError("clean_testdb: pass at most one of all=True, orphaned=True")
200
+
161
201
  config, db_name = _resolve(project_root)
162
202
  if config.engine == "mssql":
163
- _mssql_api().clean_testdb(config, db_name, all)
203
+ _mssql_api().clean_testdb(config, db_name, all, orphaned)
164
204
  return
165
205
 
166
206
  async def _run() -> None:
167
- if not all:
168
- await _drop_database(db_name)
169
- return
170
- prefix = f"{slugify(config.name)}_"
171
- escaped_prefix = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
172
- async with await psycopg.AsyncConnection.connect(_admin_dsn(), autocommit=True) as con:
173
- result = await con.execute(
174
- "SELECT datname FROM pg_database WHERE datname LIKE %(pattern)s ESCAPE '\\'",
175
- {"pattern": f"{escaped_prefix}%"},
176
- )
177
- names = [row[0] for row in await result.fetchall()]
207
+ if orphaned:
208
+ names = await _find_orphaned_dbs(config)
209
+ elif all:
210
+ names = await _dbs_with_prefix(f"{slugify(config.name)}_")
211
+ else:
212
+ names = [db_name]
178
213
  for name in names:
179
214
  await _drop_database(name)
180
215
 
@@ -14,6 +14,7 @@ class ProjectConfig:
14
14
  database_dir: str = "database"
15
15
  env_prefix: str = ""
16
16
  extensions: tuple[str, ...] = ()
17
+ extra_db_suffixes: tuple[str, ...] = ()
17
18
  engine: str = "postgres"
18
19
  root: Path = field(default_factory=Path)
19
20
 
@@ -48,6 +49,17 @@ def load_config(start: Path | None = None) -> ProjectConfig:
48
49
  f"[tool.pgdevkit].extensions in {pyproject} must be a list, got {type(extensions).__name__}"
49
50
  )
50
51
 
52
+ # Lets a repo that layers an extra, literally-suffixed sibling database
53
+ # on top of its main workspace DB (e.g. a mock-service DB used only by
54
+ # that repo's own test setup) teach `find_orphaned_dbs`/`clean_testdb`
55
+ # about it, without pgdevkit needing to know why that suffix exists.
56
+ extra_db_suffixes = section.get("extra_db_suffixes", [])
57
+ if not isinstance(extra_db_suffixes, list):
58
+ raise TypeError(
59
+ f"[tool.pgdevkit].extra_db_suffixes in {pyproject} must be a list, "
60
+ f"got {type(extra_db_suffixes).__name__}"
61
+ )
62
+
51
63
  # PGDEVKIT_TESTDB_ENGINE lets CI/ad-hoc runs flip engines without
52
64
  # editing pyproject.toml; the toml value is the durable, per-project
53
65
  # default (a project's database/ tree is written in one dialect, so
@@ -59,6 +71,7 @@ def load_config(start: Path | None = None) -> ProjectConfig:
59
71
  database_dir=section.get("database_dir", "database"),
60
72
  env_prefix=section.get("env_prefix", ""),
61
73
  extensions=tuple(extensions),
74
+ extra_db_suffixes=tuple(extra_db_suffixes),
62
75
  engine=engine,
63
76
  root=root,
64
77
  )
@@ -168,26 +168,46 @@ def ensure_testdb(
168
168
  return _env_for(config, db_name)
169
169
 
170
170
 
171
- def clean_testdb(config: ProjectConfig, db_name: str, all: bool) -> None:
172
- from ..naming import slugify
171
+ async def _dbs_with_prefix(prefix: str) -> list[str]:
172
+ from ..naming import escape_like_prefix
173
173
 
174
- async def _run() -> None:
175
- if not all:
176
- await _drop_database(db_name)
177
- return
178
- prefix = f"{slugify(config.name)}_"
179
- escaped_prefix = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
174
+ escaped_prefix = escape_like_prefix(prefix)
180
175
 
181
- def _list_names() -> list[str]:
182
- conn = mssql_python.connect(_admin_dsn(), autocommit=True)
183
- try:
184
- cur = conn.cursor()
185
- cur.execute("SELECT name FROM sys.databases WHERE name LIKE ? ESCAPE '\\'", [f"{escaped_prefix}%"])
186
- return [row[0] for row in cur.fetchall()]
187
- finally:
188
- conn.close()
176
+ def _list_names() -> list[str]:
177
+ conn = mssql_python.connect(_admin_dsn(), autocommit=True)
178
+ try:
179
+ cur = conn.cursor()
180
+ cur.execute("SELECT name FROM sys.databases WHERE name LIKE ? ESCAPE '\\'", [f"{escaped_prefix}%"])
181
+ return [row[0] for row in cur.fetchall()]
182
+ finally:
183
+ conn.close()
189
184
 
190
- names = await asyncio.to_thread(_list_names)
185
+ return await asyncio.to_thread(_list_names)
186
+
187
+
188
+ async def _find_orphaned_dbs(config: ProjectConfig) -> list[str]:
189
+ from ..naming import expected_db_names, live_worktree_branches, slugify
190
+
191
+ prefix = f"{slugify(config.name)}_"
192
+ actual = await _dbs_with_prefix(prefix)
193
+ expected = expected_db_names(config, live_worktree_branches(config.root))
194
+ return sorted(set(actual) - expected)
195
+
196
+
197
+ def find_orphaned_dbs(config: ProjectConfig) -> list[str]:
198
+ return asyncio.run(_find_orphaned_dbs(config))
199
+
200
+
201
+ def clean_testdb(config: ProjectConfig, db_name: str, all: bool, orphaned: bool = False) -> None:
202
+ from ..naming import slugify
203
+
204
+ async def _run() -> None:
205
+ if orphaned:
206
+ names = await _find_orphaned_dbs(config)
207
+ elif all:
208
+ names = await _dbs_with_prefix(f"{slugify(config.name)}_")
209
+ else:
210
+ names = [db_name]
191
211
  for name in names:
192
212
  await _drop_database(name)
193
213
 
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from .config import ProjectConfig
11
+
12
+ _INVALID_CHARS = re.compile(r"[^a-z0-9_]+")
13
+ _MAX_SLUG_LEN = 30
14
+
15
+
16
+ def slugify(value: str) -> str:
17
+ """Lowercase, replace invalid chars with '_', truncate+hash if too long."""
18
+ slug = _INVALID_CHARS.sub("_", value.lower()).strip("_")
19
+ if not slug:
20
+ slug = "x"
21
+ if len(slug) <= _MAX_SLUG_LEN:
22
+ return slug
23
+ digest = hashlib.sha256(slug.encode()).hexdigest()[:8]
24
+ return f"{slug[:_MAX_SLUG_LEN]}_{digest}"
25
+
26
+
27
+ def current_branch(cwd: Path | None = None) -> str:
28
+ """Return the branch checked out in the git worktree rooted at cwd."""
29
+ result = subprocess.run(
30
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
31
+ cwd=cwd,
32
+ capture_output=True,
33
+ text=True,
34
+ check=True,
35
+ )
36
+ return result.stdout.strip()
37
+
38
+
39
+ def workspace_db_name(project_name: str, branch: str) -> str:
40
+ """Compute a Postgres-safe, collision-resistant database name for this
41
+ project+branch. A second slugify pass over the joined string guarantees
42
+ the result stays under Postgres's 63-byte identifier limit even when
43
+ both inputs are already at the per-component truncation limit."""
44
+ joined = f"{slugify(project_name)}_{slugify(branch)}"
45
+ return slugify(joined)
46
+
47
+
48
+ def live_worktree_branches(repo: Path) -> list[str]:
49
+ """Branches checked out in every currently-live worktree of the repo
50
+ containing `repo` -- "live" meaning its filesystem path still exists and
51
+ it isn't a bare checkout. `git worktree list` reports every worktree of
52
+ a repo regardless of which one it's run from, so this works whether
53
+ `repo` is the main checkout or a linked worktree.
54
+
55
+ A detached-HEAD worktree (no `branch ...` porcelain line) is reported as
56
+ branch "HEAD" -- matching what `current_branch()` (and so
57
+ `workspace_db_name()`) computes for that same worktree via `git
58
+ rev-parse --abbrev-ref HEAD`. Without this, a detached-HEAD worktree
59
+ (e.g. a CI checkout, which defaults to one) would never appear "live"
60
+ here, and its database would look orphaned and get dropped out from
61
+ under it."""
62
+ result = subprocess.run(
63
+ ["git", "worktree", "list", "--porcelain"],
64
+ cwd=repo,
65
+ capture_output=True,
66
+ text=True,
67
+ check=True,
68
+ )
69
+
70
+ branches: list[str] = []
71
+ path: Path | None = None
72
+ branch: str | None = None
73
+ bare = False
74
+
75
+ def _flush() -> None:
76
+ if path is not None and not bare and branch is not None and path.exists():
77
+ branches.append(branch)
78
+
79
+ for line in result.stdout.splitlines():
80
+ if not line:
81
+ _flush()
82
+ path, branch, bare = None, None, False
83
+ elif line.startswith("worktree "):
84
+ path = Path(line[len("worktree ") :])
85
+ elif line.startswith("branch "):
86
+ branch = line[len("branch ") :].removeprefix("refs/heads/")
87
+ elif line == "detached":
88
+ branch = "HEAD"
89
+ elif line == "bare":
90
+ bare = True
91
+ _flush()
92
+
93
+ return branches
94
+
95
+
96
+ def escape_like_prefix(prefix: str) -> str:
97
+ """Escape a literal string for use as a `LIKE ... ESCAPE '\\'` prefix
98
+ pattern (with a trailing `%` the caller adds), so a project name/branch
99
+ containing `%` or `_` can't widen the match to an unrelated database."""
100
+ return prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
101
+
102
+
103
+ def expected_db_names(config: "ProjectConfig", branches: list[str]) -> set[str]:
104
+ """Every DB name a currently live worktree of this project is entitled
105
+ to own: the main workspace DB per branch, plus one `<main>{suffix}`
106
+ sibling per `[tool.pgdevkit].extra_db_suffixes` entry."""
107
+ names: set[str] = set()
108
+ for branch in branches:
109
+ main_db = workspace_db_name(config.name, branch)
110
+ names.add(main_db)
111
+ names.update(f"{main_db}{suffix}" for suffix in config.extra_db_suffixes)
112
+ return names
@@ -11,7 +11,7 @@ packages = ["pgdevkit"]
11
11
 
12
12
  [project]
13
13
  name = "pgdevkit"
14
- version = "0.6.2"
14
+ version = "0.7.0"
15
15
  description = "A helper for developing with Postgres"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.14"
@@ -74,3 +74,23 @@ def project_factory(tmp_path: Path) -> Callable[..., Path]:
74
74
  return _make_project(tmp_path, name, branch, engine)
75
75
 
76
76
  return _factory
77
+
78
+
79
+ @pytest.fixture
80
+ def worktree_project_factory(tmp_path: Path) -> Callable[..., tuple[Path, Callable[[str], Path]]]:
81
+ """Build a real project repo (with a `[tool.pgdevkit]` pyproject.toml,
82
+ like `project_factory`) plus a `add_worktree(branch)` helper that adds a
83
+ linked `git worktree` of that same repo -- for tests that need more than
84
+ one *live* worktree of one repo, e.g. orphaned-DB detection."""
85
+
86
+ def _factory(name: str, main_branch: str = "main", engine: str = "postgres") -> tuple[Path, Callable[[str], Path]]:
87
+ repo = _make_project(tmp_path, name, main_branch, engine)
88
+
89
+ def _add_worktree(branch: str) -> Path:
90
+ path = tmp_path / f"{name}-{branch}"
91
+ subprocess.run(["git", "worktree", "add", "-q", str(path), "-b", branch], cwd=repo, check=True)
92
+ return path
93
+
94
+ return repo, _add_worktree
95
+
96
+ return _factory
@@ -1,5 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import shutil
4
+ import subprocess
3
5
  from pathlib import Path
4
6
  from typing import Callable
5
7
 
@@ -7,7 +9,7 @@ import psycopg
7
9
  import pytest
8
10
 
9
11
  from pgdevkit.testdb import constants
10
- from pgdevkit.testdb.api import clean_testdb, ensure_testdb, reset_testdb, status
12
+ from pgdevkit.testdb.api import clean_testdb, ensure_testdb, find_orphaned_dbs, reset_testdb, status
11
13
  from pgdevkit.testdb.config import load_config
12
14
  from pgdevkit.testdb.naming import slugify
13
15
  from tests.testdb.conftest import requires_podman
@@ -112,3 +114,93 @@ def test_dsn_for_matches_status(project_factory: Callable[[str, str], Path]):
112
114
  assert dsn_for(project) == status(project)["dsn"]
113
115
  finally:
114
116
  clean_testdb(project)
117
+
118
+
119
+ def test_clean_testdb_rejects_all_and_orphaned_together(project_factory: Callable[[str, str], Path]):
120
+ project = project_factory("apitest6", "main")
121
+ with pytest.raises(ValueError, match="all=True, orphaned=True"):
122
+ clean_testdb(project, all=True, orphaned=True)
123
+
124
+
125
+ @requires_podman
126
+ def test_find_orphaned_dbs_excludes_live_worktrees(
127
+ worktree_project_factory: Callable[..., tuple[Path, Callable[[str], Path]]],
128
+ ):
129
+ repo, add_worktree = worktree_project_factory("orphtest")
130
+ feature = add_worktree("feature")
131
+ ghost = add_worktree("ghost")
132
+ try:
133
+ ensure_testdb(repo)
134
+ ensure_testdb(feature)
135
+ ghost_db = ensure_testdb(ghost)["ORPHTEST_POSTGRES_DB"]
136
+ subprocess.run(["git", "worktree", "remove", "--force", str(ghost)], cwd=repo, check=True)
137
+
138
+ assert find_orphaned_dbs(repo) == [ghost_db]
139
+ finally:
140
+ clean_testdb(repo, all=True)
141
+
142
+
143
+ @requires_podman
144
+ def test_find_orphaned_dbs_treats_manually_deleted_worktree_as_orphaned(
145
+ worktree_project_factory: Callable[..., tuple[Path, Callable[[str], Path]]],
146
+ ):
147
+ # A worktree dir removed with plain `rm -rf` (no `git worktree remove`)
148
+ # still shows up in `git worktree list` as prunable -- it must still be
149
+ # treated as not-live.
150
+ repo, add_worktree = worktree_project_factory("orphtest2")
151
+ ghost = add_worktree("ghost")
152
+ try:
153
+ ghost_db = ensure_testdb(ghost)["ORPHTEST2_POSTGRES_DB"]
154
+ shutil.rmtree(ghost)
155
+
156
+ assert find_orphaned_dbs(repo) == [ghost_db]
157
+ finally:
158
+ clean_testdb(repo, all=True)
159
+
160
+
161
+ @requires_podman
162
+ def test_clean_orphaned_drops_only_orphaned_dbs(
163
+ worktree_project_factory: Callable[..., tuple[Path, Callable[[str], Path]]],
164
+ ):
165
+ repo, add_worktree = worktree_project_factory("orphtest3")
166
+ feature = add_worktree("feature")
167
+ ghost = add_worktree("ghost")
168
+ try:
169
+ ensure_testdb(repo)
170
+ feature_db = ensure_testdb(feature)["ORPHTEST3_POSTGRES_DB"]
171
+ ensure_testdb(ghost)
172
+ subprocess.run(["git", "worktree", "remove", "--force", str(ghost)], cwd=repo, check=True)
173
+
174
+ clean_testdb(repo, orphaned=True)
175
+
176
+ assert find_orphaned_dbs(repo) == []
177
+ with psycopg.connect(constants.conninfo("postgres")) as con:
178
+ with con.cursor() as cur:
179
+ cur.execute("SELECT count(*) FROM pg_database WHERE datname = %s", (feature_db,))
180
+ (count,) = cur.fetchone()
181
+ assert count == 1
182
+ finally:
183
+ clean_testdb(repo, all=True)
184
+
185
+
186
+ @requires_podman
187
+ def test_find_orphaned_dbs_respects_extra_db_suffixes(
188
+ worktree_project_factory: Callable[..., tuple[Path, Callable[[str], Path]]],
189
+ ):
190
+ repo, _ = worktree_project_factory("orphtest4")
191
+ pyproject = repo / "pyproject.toml"
192
+ pyproject.write_text(
193
+ pyproject.read_text(encoding="utf-8").rstrip("\n") + '\nextra_db_suffixes = ["_sibling"]\n',
194
+ encoding="utf-8",
195
+ )
196
+ try:
197
+ main_db = ensure_testdb(repo)["ORPHTEST4_POSTGRES_DB"]
198
+ sibling_db = f"{main_db}_sibling"
199
+ stray_db = f"{main_db}_stray"
200
+ with psycopg.connect(constants.conninfo("postgres"), autocommit=True) as con:
201
+ con.execute(f'CREATE DATABASE "{sibling_db}"')
202
+ con.execute(f'CREATE DATABASE "{stray_db}"')
203
+
204
+ assert find_orphaned_dbs(repo) == [stray_db]
205
+ finally:
206
+ clean_testdb(repo, all=True)
@@ -105,3 +105,27 @@ def test_testdb_clean(project_factory: Callable[[str, str], Path], monkeypatch):
105
105
  runner.invoke(app, ["testdb", "up"])
106
106
  result = runner.invoke(app, ["testdb", "clean"])
107
107
  assert result.exit_code == 0, result.output
108
+
109
+
110
+ @requires_podman
111
+ def test_testdb_list_orphaned_reports_none_for_a_single_live_worktree(
112
+ project_factory: Callable[[str, str], Path], monkeypatch
113
+ ):
114
+ project = project_factory("clitest7", "main")
115
+ monkeypatch.chdir(project)
116
+ try:
117
+ runner.invoke(app, ["testdb", "up"])
118
+ result = runner.invoke(app, ["testdb", "list-orphaned"])
119
+ assert result.exit_code == 0, result.output
120
+ assert "No orphaned databases." in result.output
121
+ finally:
122
+ clean_testdb(project)
123
+
124
+
125
+ def test_testdb_clean_rejects_all_and_orphaned_together(
126
+ project_factory: Callable[[str, str], Path], monkeypatch
127
+ ):
128
+ project = project_factory("clitest8", "main")
129
+ monkeypatch.chdir(project)
130
+ result = runner.invoke(app, ["testdb", "clean", "--all", "--orphaned"])
131
+ assert result.exit_code == 2
@@ -69,3 +69,26 @@ def test_extensions_must_be_a_list(tmp_path: Path):
69
69
  )
70
70
  with pytest.raises(TypeError, match="extensions"):
71
71
  load_config(tmp_path)
72
+
73
+
74
+ def test_extra_db_suffixes_defaults_empty(tmp_path: Path):
75
+ project = tmp_path / "myproj"
76
+ project.mkdir()
77
+ config = load_config(project)
78
+ assert config.extra_db_suffixes == ()
79
+
80
+
81
+ def test_reads_extra_db_suffixes(tmp_path: Path):
82
+ (tmp_path / "pyproject.toml").write_text(
83
+ '[tool.pgdevkit]\nname = "ccmt"\nextra_db_suffixes = ["_onetrade"]\n', encoding="utf-8"
84
+ )
85
+ config = load_config(tmp_path)
86
+ assert config.extra_db_suffixes == ("_onetrade",)
87
+
88
+
89
+ def test_extra_db_suffixes_must_be_a_list(tmp_path: Path):
90
+ (tmp_path / "pyproject.toml").write_text(
91
+ '[tool.pgdevkit]\nname = "x"\nextra_db_suffixes = "_onetrade"\n', encoding="utf-8"
92
+ )
93
+ with pytest.raises(TypeError, match="extra_db_suffixes"):
94
+ load_config(tmp_path)