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.
- odoo_dev-1.5.0/.release-please-manifest.json +3 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/CHANGELOG.md +20 -0
- odoo_dev-1.3.0/README.md → odoo_dev-1.5.0/PKG-INFO +27 -0
- odoo_dev-1.3.0/PKG-INFO → odoo_dev-1.5.0/README.md +12 -14
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/pyproject.toml +2 -1
- odoo_dev-1.5.0/src/odoo_dev/__init__.py +3 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/git.py +351 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/run.py +20 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/setup.py +123 -2
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/config.py +34 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/materialize.py +20 -3
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/verify.py +49 -4
- odoo_dev-1.5.0/tests/test_drop_filestore.py +49 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_git_hygiene.py +140 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_integration.py +9 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_setup.py +8 -1
- odoo_dev-1.5.0/tests/test_setup_data_dir.py +179 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_vendor_verify.py +60 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/uv.lock +36 -1
- odoo_dev-1.3.0/.release-please-manifest.json +0 -3
- odoo_dev-1.3.0/src/odoo_dev/__init__.py +0 -3
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/.github/workflows/release-please.yml +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/.gitignore +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/.gitmodules +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/.python-version +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/release-please-config.json +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/cli.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/bump.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/db.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/docker.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/commands/vendor.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/preflight.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/docker/Dockerfile +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/docker/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/docker/docker-entrypoint.sh +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/vscode/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/vscode/launch.json +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/vscode/settings.json +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/templates/vscode/tasks.json +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/utils/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/utils/console.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/utils/manifest.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/develop.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/edit.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/lock.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/migrate.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/sources.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/sync.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/src/odoo_dev/vendor/update.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/fixtures/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_bump.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_cli.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_config.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_db.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_db_config.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_drop_database.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_preflight.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_run.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_setup_db.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_vendor_develop.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_vendor_edit.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_vendor_lock.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_vendor_materialize.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_vendor_migrate.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.5.0}/tests/test_vendor_update.py +0 -0
|
@@ -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
|
+
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
|
|
|
@@ -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:
|