odoo-dev 1.3.0__tar.gz → 1.5.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 (69) hide show
  1. odoo_dev-1.5.0/.release-please-manifest.json +3 -0
  2. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/CHANGELOG.md +20 -0
  3. odoo_dev-1.3.0/README.md → odoo_dev-1.5.0/PKG-INFO +27 -0
  4. odoo_dev-1.3.0/PKG-INFO → odoo_dev-1.5.0/README.md +12 -14
  5. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/pyproject.toml +2 -1
  6. odoo_dev-1.5.0/src/odoo_dev/__init__.py +3 -0
  7. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/git.py +351 -0
  8. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/run.py +20 -0
  9. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/setup.py +123 -2
  10. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/config.py +34 -0
  11. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/materialize.py +20 -3
  12. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/verify.py +49 -4
  13. odoo_dev-1.5.0/tests/test_drop_filestore.py +49 -0
  14. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_git_hygiene.py +140 -0
  15. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_integration.py +9 -0
  16. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_setup.py +8 -1
  17. odoo_dev-1.5.0/tests/test_setup_data_dir.py +179 -0
  18. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_vendor_verify.py +60 -0
  19. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/uv.lock +36 -1
  20. odoo_dev-1.3.0/.release-please-manifest.json +0 -3
  21. odoo_dev-1.3.0/src/odoo_dev/__init__.py +0 -3
  22. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/.github/workflows/release-please.yml +0 -0
  23. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/.gitignore +0 -0
  24. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/.gitmodules +0 -0
  25. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/.python-version +0 -0
  26. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/release-please-config.json +0 -0
  27. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/cli.py +0 -0
  28. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/__init__.py +0 -0
  29. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/bump.py +0 -0
  30. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/db.py +0 -0
  31. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/docker.py +0 -0
  32. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/vendor.py +0 -0
  33. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/preflight.py +0 -0
  34. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/__init__.py +0 -0
  35. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/docker/Dockerfile +0 -0
  36. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/docker/__init__.py +0 -0
  37. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/docker/docker-entrypoint.sh +0 -0
  38. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/vscode/__init__.py +0 -0
  39. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/vscode/launch.json +0 -0
  40. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/vscode/settings.json +0 -0
  41. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/vscode/tasks.json +0 -0
  42. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/utils/__init__.py +0 -0
  43. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/utils/console.py +0 -0
  44. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/utils/manifest.py +0 -0
  45. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/__init__.py +0 -0
  46. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/develop.py +0 -0
  47. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/edit.py +0 -0
  48. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/lock.py +0 -0
  49. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/migrate.py +0 -0
  50. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/sources.py +0 -0
  51. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/sync.py +0 -0
  52. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/update.py +0 -0
  53. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/__init__.py +0 -0
  54. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/fixtures/__init__.py +0 -0
  55. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_bump.py +0 -0
  56. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_cli.py +0 -0
  57. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_config.py +0 -0
  58. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_db.py +0 -0
  59. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_db_config.py +0 -0
  60. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_drop_database.py +0 -0
  61. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_preflight.py +0 -0
  62. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_run.py +0 -0
  63. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_setup_db.py +0 -0
  64. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_vendor_develop.py +0 -0
  65. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_vendor_edit.py +0 -0
  66. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_vendor_lock.py +0 -0
  67. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_vendor_materialize.py +0 -0
  68. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_vendor_migrate.py +0 -0
  69. {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_vendor_update.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.5.0"
3
+ }
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.5.0](https://github.com/bemade/odoo-dev/compare/v1.4.0...v1.5.0) (2026-10-01)
4
+
5
+
6
+ ### Features
7
+
8
+ * **setup:** project-local data_dir, addon manifest deps, ADMIN_PASSWD ([#23](https://github.com/bemade/odoo-dev/issues/23)) ([60c931e](https://github.com/bemade/odoo-dev/commit/60c931e3cf38c025895eb183d1a3a1ee929931e2))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **vendor:** ignore local python bytecode in vendor check, fail on tracked bytecode ([#25](https://github.com/bemade/odoo-dev/issues/25)) ([8d679af](https://github.com/bemade/odoo-dev/commit/8d679af962d96002a1ff978e6229ffeaf063250c))
14
+
15
+ ## [1.4.0](https://github.com/bemade/odoo-dev/compare/v1.3.0...v1.4.0) (2026-09-04)
16
+
17
+
18
+ ### Features
19
+
20
+ * **git:** add `sweep` — classify remote branches, pick, delete ([86fc297](https://github.com/bemade/odoo-dev/commit/86fc29774916f35b6c7470daf9188e2c4bf78907))
21
+ * **git:** add `sweep` — classify remote branches, pick, delete ([69fb870](https://github.com/bemade/odoo-dev/commit/69fb8708c940aa1534e1c342ee138966df584e0f))
22
+
3
23
  ## [1.3.0](https://github.com/bemade/odoo-dev/compare/v1.2.3...v1.3.0) (2026-09-02)
4
24
 
5
25
 
@@ -1,3 +1,18 @@
1
+ Metadata-Version: 2.5
2
+ Name: odoo-dev
3
+ Version: 1.5.0
4
+ Summary: Odoo Development Environment Helper
5
+ Project-URL: Homepage, https://github.com/bemade/odoo-dev
6
+ Project-URL: Repository, https://github.com/bemade/odoo-dev
7
+ Project-URL: Issues, https://github.com/bemade/odoo-dev/issues
8
+ License-Expression: LGPL-3.0
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: pyyaml>=6.0
11
+ Requires-Dist: questionary>=2.0
12
+ Requires-Dist: rich>=13.0.0
13
+ Requires-Dist: typer>=0.9.0
14
+ Description-Content-Type: text/markdown
15
+
1
16
  # odoo-dev
2
17
 
3
18
  A CLI tool for managing Odoo development environments. Handles local Python setup, Docker containers, database operations, and more.
@@ -221,8 +236,20 @@ PYTHON_VERSION=3.12
221
236
  # DB_PORT=5432
222
237
  # DB_USER=odoo
223
238
  # DB_PASSWORD=odoo
239
+
240
+ # Optional — Odoo master password, written into conf/odoo.conf by `setup`.
241
+ # Defaults to "admin".
242
+ # ADMIN_PASSWD=admin
224
243
  ```
225
244
 
245
+ `setup` also points `data_dir` at a project-local `.odoo-data/` (git-ignored)
246
+ rather than the shared `~/.local/share/Odoo`, so filestores, sessions and the
247
+ addons cache stay scoped to the checkout they belong to.
248
+
249
+ Python packages declared in your addons' manifests — `external_dependencies`
250
+ *and* the OCA `test_external_dependencies` convention, across both `addons/`
251
+ and `vendored/` — are installed into the venv by `setup`.
252
+
226
253
  ## Requirements
227
254
 
228
255
  - Python 3.12+
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: odoo-dev
3
- Version: 1.3.0
4
- Summary: Odoo Development Environment Helper
5
- Project-URL: Homepage, https://github.com/bemade/odoo-dev
6
- Project-URL: Repository, https://github.com/bemade/odoo-dev
7
- Project-URL: Issues, https://github.com/bemade/odoo-dev/issues
8
- License-Expression: LGPL-3.0
9
- Requires-Python: >=3.12
10
- Requires-Dist: pyyaml>=6.0
11
- Requires-Dist: rich>=13.0.0
12
- Requires-Dist: typer>=0.9.0
13
- Description-Content-Type: text/markdown
14
-
15
1
  # odoo-dev
16
2
 
17
3
  A CLI tool for managing Odoo development environments. Handles local Python setup, Docker containers, database operations, and more.
@@ -235,8 +221,20 @@ PYTHON_VERSION=3.12
235
221
  # DB_PORT=5432
236
222
  # DB_USER=odoo
237
223
  # DB_PASSWORD=odoo
224
+
225
+ # Optional — Odoo master password, written into conf/odoo.conf by `setup`.
226
+ # Defaults to "admin".
227
+ # ADMIN_PASSWD=admin
238
228
  ```
239
229
 
230
+ `setup` also points `data_dir` at a project-local `.odoo-data/` (git-ignored)
231
+ rather than the shared `~/.local/share/Odoo`, so filestores, sessions and the
232
+ addons cache stay scoped to the checkout they belong to.
233
+
234
+ Python packages declared in your addons' manifests — `external_dependencies`
235
+ *and* the OCA `test_external_dependencies` convention, across both `addons/`
236
+ and `vendored/` — are installed into the venv by `setup`.
237
+
240
238
  ## Requirements
241
239
 
242
240
  - Python 3.12+
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "odoo-dev"
3
- version = "1.3.0"
3
+ version = "1.5.0"
4
4
  description = "Odoo Development Environment Helper"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -8,6 +8,7 @@ dependencies = [
8
8
  "typer>=0.9.0",
9
9
  "rich>=13.0.0",
10
10
  "pyyaml>=6.0",
11
+ "questionary>=2.0",
11
12
  ]
12
13
  license = "LGPL-3.0"
13
14
 
@@ -0,0 +1,3 @@
1
+ """Odoo Development Environment Helper."""
2
+
3
+ __version__ = "1.5.0" # x-release-please-version
@@ -19,7 +19,14 @@ is no configured remote left to prune against -- they simply persist, and show
19
19
  up in every ref listing as branches that do not exist.
20
20
  """
21
21
 
22
+ import json
23
+ import os
24
+ import re
25
+ import shutil
22
26
  import subprocess
27
+ import sys
28
+ import time
29
+ from datetime import datetime, timezone
23
30
  from typing import Annotated, Optional
24
31
 
25
32
  import typer
@@ -343,3 +350,347 @@ def shipped(
343
350
  info(f" git diff {ref} {target} -- {p}")
344
351
  else:
345
352
  success("\nEvery path matches -- this work is present in the target.")
353
+
354
+
355
+ # --- sweep -------------------------------------------------------------------
356
+ #
357
+ # The classification ladder below is the one that survived a real cleanup of two
358
+ # long-lived client repos (89 and 41 branches). Its shape is dictated by what
359
+ # each test can actually prove:
360
+ #
361
+ # KEEP long-lived lines. Never a candidate, whatever their age.
362
+ # IN-FLIGHT an open MR/PR names it as source. Deleting one of these closes
363
+ # the request out from under its author.
364
+ # CONTAINED `merge-base --is-ancestor` says every commit is reachable from
365
+ # a branch we keep. This is the ONLY class that proves deletion
366
+ # loses nothing, and so the only one pre-checked.
367
+ # STALE-BY-BASE not contained, and its diff against the target mass-deletes
368
+ # paths the target owns -- the fingerprint of a branch cut before
369
+ # a structural change (e.g. submodules -> vendored files). These
370
+ # look mergeable and are not: an MR would propose deleting the
371
+ # target's tree. Never auto-select.
372
+ # UNKNOWN everything else. Genuinely needs a human.
373
+ #
374
+ # Deliberately absent: proving supersession by content across a base gap. The
375
+ # obvious implementation -- diff the paths the old branch touched against its
376
+ # replacement -- reads as "identical" for branches whose fork point predates a
377
+ # tree reorganisation, because the derived path set is dominated by the
378
+ # reorganisation rather than the branch's own work. `odoo-dev git shipped`
379
+ # answers that question properly, per branch, with markers. A branch superseded
380
+ # by an open MR becomes CONTAINED for free once that MR merges, so the honest
381
+ # move is to wait rather than guess.
382
+
383
+ _KEEP_RE = re.compile(r"^(\d+\.\d+(-(prod|staging))?|main|master)$")
384
+
385
+
386
+ def _default_targets(remote: str) -> list[str]:
387
+ """Long-lived lines, by name. Version branches plus main/master."""
388
+ out = []
389
+ for line in _git(
390
+ "for-each-ref", "--format=%(refname:short)", f"refs/remotes/{remote}"
391
+ ).splitlines():
392
+ short = line[len(remote) + 1 :]
393
+ if short and short != "HEAD" and _KEEP_RE.match(short):
394
+ out.append(short)
395
+ return sorted(out)
396
+
397
+
398
+ def _open_request_sources(remote: str) -> tuple[set[str], Optional[str]]:
399
+ """Source branches of open MRs/PRs. Returns (branches, warning_or_None).
400
+
401
+ JSON endpoints only. Scraping the human-readable `glab mr list` table looks
402
+ easier and is a trap: its header line ("... (Page 1)") satisfies a naive
403
+ "text in parentheses" match and yields a phantom branch named `Page 1`.
404
+ """
405
+ url = _git("remote", "get-url", remote, check=False)
406
+ if not url:
407
+ return set(), f"no URL for remote {remote!r} -- skipping request lookup"
408
+
409
+ if "github" in url:
410
+ tool, args = "gh", [
411
+ "pr",
412
+ "list",
413
+ "--state",
414
+ "open",
415
+ "--limit",
416
+ "200",
417
+ "--json",
418
+ "headRefName",
419
+ ]
420
+ else:
421
+ tool, args = "glab", [
422
+ "api",
423
+ "projects/:id/merge_requests?state=opened&per_page=100",
424
+ ]
425
+
426
+ if not shutil.which(tool):
427
+ return set(), (
428
+ f"{tool} not installed -- cannot see open requests. Branches with an "
429
+ f"open MR/PR will appear as UNKNOWN, not as safe to delete."
430
+ )
431
+
432
+ r = subprocess.run([tool, *args], capture_output=True, text=True)
433
+ if r.returncode != 0:
434
+ return set(), (
435
+ f"{tool} failed ({(r.stderr or r.stdout).strip().splitlines()[:1]}) -- "
436
+ f"treating all branches as having no open request."
437
+ )
438
+ try:
439
+ payload = json.loads(r.stdout or "[]")
440
+ except json.JSONDecodeError:
441
+ return set(), f"could not parse {tool} output as JSON"
442
+
443
+ key = "headRefName" if tool == "gh" else "source_branch"
444
+ return {item[key] for item in payload if item.get(key)}, None
445
+
446
+
447
+ def _missing_share(target: str, ref: str) -> tuple[int, float]:
448
+ """(count, share) of TARGET's paths that REF does not have.
449
+
450
+ The SHARE is what distinguishes the two cases, and using the raw count
451
+ instead is a false-positive generator:
452
+
453
+ * a branch merely BEHIND a fast-moving target legitimately lacks whatever
454
+ was added since -- dozens of files, but a tiny fraction of the tree;
455
+ * a branch cut before a structural change (submodules -> vendored files,
456
+ a big reorganisation) lacks most of the tree.
457
+
458
+ Only the second is dangerous to merge, and only a ratio separates them.
459
+ """
460
+ out = _git("diff", "--name-status", target, ref, check=False)
461
+ missing = sum(1 for ln in out.splitlines() if ln.startswith("D\t"))
462
+ total = len(_git("ls-tree", "-r", "--name-only", target, check=False).splitlines())
463
+ return missing, (missing / total if total else 0.0)
464
+
465
+
466
+ @app.command()
467
+ def sweep(
468
+ targets: Annotated[
469
+ Optional[list[str]],
470
+ typer.Option(
471
+ "--target",
472
+ "-t",
473
+ help="Branch that counts as 'kept'. Repeatable. "
474
+ "Default: version branches (19.0, 19.0-prod, 19.0-staging...) plus main/master.",
475
+ ),
476
+ ] = None,
477
+ remote: Annotated[str, typer.Option("--remote", help="Remote name")] = "origin",
478
+ apply: Annotated[
479
+ bool, typer.Option("--apply", help="Actually delete. Default is a dry run.")
480
+ ] = False,
481
+ no_fetch: Annotated[
482
+ bool, typer.Option("--no-fetch", help="Skip the fetch --prune first")
483
+ ] = False,
484
+ check_requests: Annotated[
485
+ bool,
486
+ typer.Option(
487
+ "--check-requests/--no-check-requests",
488
+ help="Ask glab/gh which branches have an open MR/PR.",
489
+ ),
490
+ ] = True,
491
+ stale_ratio: Annotated[
492
+ float,
493
+ typer.Option(
494
+ "--stale-ratio",
495
+ help="Share of the target's tree a branch must be missing to count "
496
+ "as STALE-BY-BASE. A branch that is merely behind lacks a few "
497
+ "files; one cut before a tree change lacks most of them.",
498
+ ),
499
+ ] = 0.25,
500
+ non_interactive: Annotated[
501
+ bool,
502
+ typer.Option(
503
+ "--non-interactive",
504
+ help="Skip the picker and take exactly the CONTAINED set.",
505
+ ),
506
+ ] = False,
507
+ ) -> None:
508
+ """Classify remote branches and delete the ones you select. DRY RUN unless --apply.
509
+
510
+ Writes a manifest of every branch with its SHA *before* deleting anything,
511
+ so a branch removed here is restorable with::
512
+
513
+ git push <remote> <sha>:refs/heads/<branch>
514
+
515
+ That matters for anything outside CONTAINED: once such a branch is gone its
516
+ commits are unreachable, and the SHA in the manifest is the only way back.
517
+ """
518
+ if not no_fetch:
519
+ info(f"fetching {remote} --prune ...")
520
+ _git("fetch", "--prune", "--quiet", remote)
521
+
522
+ keep = list(targets) if targets else _default_targets(remote)
523
+ if not keep:
524
+ error(
525
+ "no target branches found -- pass --target explicitly. Refusing to "
526
+ "classify anything as deletable with nothing to measure against."
527
+ )
528
+ raise typer.Exit(2)
529
+ info(f"targets: {', '.join(keep)}")
530
+
531
+ for t in keep:
532
+ if not _ok("rev-parse", "--verify", f"{remote}/{t}"):
533
+ error(f"{remote}/{t} does not exist")
534
+ raise typer.Exit(2)
535
+
536
+ in_flight: set[str] = set()
537
+ if check_requests:
538
+ in_flight, warn = _open_request_sources(remote)
539
+ if warn:
540
+ warning(f" {warn}")
541
+ else:
542
+ info(f" {len(in_flight)} branch(es) have an open MR/PR")
543
+
544
+ rows: list[dict] = []
545
+ for line in _git(
546
+ "for-each-ref",
547
+ "--format=%(refname:short)%09%(objectname)%09%(committerdate:short)",
548
+ f"refs/remotes/{remote}",
549
+ ).splitlines():
550
+ ref, sha, date = (line.split("\t") + ["", ""])[:3]
551
+ short = ref[len(remote) + 1 :]
552
+ if not short or short == "HEAD":
553
+ continue
554
+ if short in keep:
555
+ cls, why = "KEEP", "long-lived line"
556
+ elif short in in_flight:
557
+ cls, why = "IN-FLIGHT", "open MR/PR names it as source"
558
+ else:
559
+ holder = next(
560
+ (
561
+ t
562
+ for t in keep
563
+ if _ok("merge-base", "--is-ancestor", ref, f"{remote}/{t}")
564
+ ),
565
+ None,
566
+ )
567
+ if holder:
568
+ cls, why = "CONTAINED", f"contained in {remote}/{holder}"
569
+ else:
570
+ worst_n, worst_share, worst_t = 0, 0.0, ""
571
+ for t in keep:
572
+ n, share = _missing_share(f"{remote}/{t}", ref)
573
+ if share > worst_share:
574
+ worst_n, worst_share, worst_t = n, share, t
575
+ if worst_share >= stale_ratio:
576
+ cls = "STALE-BY-BASE"
577
+ why = (
578
+ f"lacks {worst_n} paths ({worst_share:.0%}) of {worst_t} "
579
+ f"-- cut before a tree change"
580
+ )
581
+ else:
582
+ cls, why = "UNKNOWN", "not contained; needs a human"
583
+ rows.append({"branch": short, "sha": sha, "date": date, "cls": cls, "why": why})
584
+
585
+ counts: dict[str, int] = {}
586
+ for r in rows:
587
+ counts[r["cls"]] = counts.get(r["cls"], 0) + 1
588
+ info("")
589
+ for cls in ("KEEP", "IN-FLIGHT", "CONTAINED", "STALE-BY-BASE", "UNKNOWN"):
590
+ if counts.get(cls):
591
+ info(f" {cls:<14} {counts[cls]}")
592
+
593
+ candidates = [r for r in rows if r["cls"] not in ("KEEP", "IN-FLIGHT")]
594
+ if not candidates:
595
+ success("\nnothing to sweep -- every branch is a keeper or in flight")
596
+ return
597
+
598
+ man = _write_manifest(rows, remote)
599
+ success(f"\nmanifest: {man}")
600
+
601
+ contained = [r for r in candidates if r["cls"] == "CONTAINED"]
602
+ if non_interactive or not sys.stdin.isatty():
603
+ chosen = [r["branch"] for r in contained]
604
+ info(f"non-interactive: taking the {len(chosen)} CONTAINED branch(es) only")
605
+ else:
606
+ chosen = _pick(candidates, apply=apply)
607
+
608
+ if not chosen:
609
+ info("nothing selected -- done")
610
+ return
611
+
612
+ warning(f"\n{len(chosen)} branch(es) selected:")
613
+ for b in chosen:
614
+ r = next(x for x in rows if x["branch"] == b)
615
+ info(f" {r['cls']:<14} {b}")
616
+
617
+ if not apply:
618
+ info("\nDRY RUN -- nothing deleted. Re-run with --apply.")
619
+ return
620
+
621
+ # Re-verify containment immediately before deleting: the survey and the
622
+ # delete are separated by however long the picker was open, and another
623
+ # session pushing in that window is not hypothetical.
624
+ for b in chosen:
625
+ r = next(x for x in rows if x["branch"] == b)
626
+ if r["cls"] == "CONTAINED":
627
+ holder = r["why"].rsplit("/", 1)[-1]
628
+ if not _ok(
629
+ "merge-base", "--is-ancestor", f"{remote}/{b}", f"{remote}/{holder}"
630
+ ):
631
+ error(
632
+ f"{b} is no longer contained in {holder} -- aborting, nothing deleted"
633
+ )
634
+ raise typer.Exit(1)
635
+
636
+ failed = []
637
+ for b in chosen:
638
+ for attempt in (1, 2, 3):
639
+ r = subprocess.run(
640
+ ["git", "push", remote, "--delete", b], capture_output=True, text=True
641
+ )
642
+ if r.returncode == 0:
643
+ success(f" deleted {b}")
644
+ break
645
+ if attempt < 3:
646
+ time.sleep(attempt)
647
+ else:
648
+ error(f" FAILED {b}: {(r.stderr or r.stdout).strip().splitlines()[-1:]}")
649
+ failed.append(b)
650
+
651
+ if failed:
652
+ error(f"\n{len(failed)} deletion(s) failed -- rerun to retry them")
653
+ raise typer.Exit(1)
654
+ success(f"\n{len(chosen)} branch(es) deleted. Restore any of them with:")
655
+ info(f" git push {remote} <sha>:refs/heads/<branch> # SHAs in {man}")
656
+
657
+
658
+ def _write_manifest(rows: list[dict], remote: str) -> str:
659
+ """Record every branch and SHA under .git/ before anything is deleted."""
660
+ d = os.path.join(_git("rev-parse", "--git-dir"), "odoo-dev")
661
+ os.makedirs(d, exist_ok=True)
662
+ path = os.path.join(d, f"sweep-{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}.tsv")
663
+ with open(path, "w") as fh:
664
+ fh.write(
665
+ f"# odoo-dev git sweep -- {datetime.now(timezone.utc):%Y-%m-%d %H:%M:%SZ}\n"
666
+ )
667
+ fh.write(f"# restore with: git push {remote} <sha>:refs/heads/<branch>\n")
668
+ fh.write("branch\tsha\tlast_commit\tclass\tevidence\n")
669
+ for r in rows:
670
+ fh.write(
671
+ f"{r['branch']}\t{r['sha']}\t{r['date']}\t{r['cls']}\t{r['why']}\n"
672
+ )
673
+ return path
674
+
675
+
676
+ def _pick(candidates: list[dict], apply: bool = False) -> list[str]:
677
+ """Checkbox picker. CONTAINED pre-checked; nothing else is."""
678
+ import questionary
679
+
680
+ order = {"CONTAINED": 0, "STALE-BY-BASE": 1, "UNKNOWN": 2}
681
+ ordered = sorted(candidates, key=lambda r: (order.get(r["cls"], 9), r["date"]))
682
+ choices = [
683
+ questionary.Choice(
684
+ title=f"{r['cls']:<14} {r['date']} {r['branch']:<48} {r['why']}",
685
+ value=r["branch"],
686
+ checked=r["cls"] == "CONTAINED",
687
+ )
688
+ for r in ordered
689
+ ]
690
+ prompt = (
691
+ "Select branches to DELETE (space toggles, enter confirms):"
692
+ if apply
693
+ else "Select branches (DRY RUN -- the selection is only reported):"
694
+ )
695
+ picked = questionary.checkbox(prompt, choices=choices).ask()
696
+ return picked or []
@@ -1,6 +1,7 @@
1
1
  """Local Odoo runtime commands."""
2
2
 
3
3
  import os
4
+ import shutil
4
5
  import signal
5
6
  import subprocess
6
7
  import sys
@@ -582,6 +583,25 @@ def _drop_database(cfg, db_name: str) -> None:
582
583
  warning(f"Failed to drop test database {db_name}")
583
584
  if result.stderr:
584
585
  warning(result.stderr.strip())
586
+ return
587
+
588
+ # dropdb only removes the database. odoo-bin's own `db drop` deletes the
589
+ # filestore too, so the fallback has to, or every test run leaks a
590
+ # filestore directory that nothing ever cleans up.
591
+ _drop_filestore(cfg, db_name)
592
+
593
+
594
+ def _drop_filestore(cfg, db_name: str) -> None:
595
+ """Remove the filestore directory belonging to a dropped database."""
596
+ from odoo_dev.config import read_data_dir
597
+
598
+ filestore = read_data_dir(cfg.config_file) / "filestore" / db_name
599
+ if not filestore.is_dir():
600
+ return
601
+ try:
602
+ shutil.rmtree(filestore)
603
+ except OSError as exc:
604
+ warning(f"Could not remove filestore {filestore}: {exc}")
585
605
 
586
606
 
587
607
  def _terminate_connections(db_name: str, db_config: dict[str, str]) -> None: