odoo-dev 1.3.0__tar.gz → 1.4.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.4.0/.release-please-manifest.json +3 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/CHANGELOG.md +8 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/PKG-INFO +2 -1
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/pyproject.toml +2 -1
- odoo_dev-1.4.0/src/odoo_dev/__init__.py +3 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/git.py +351 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_git_hygiene.py +140 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.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.4.0}/.github/workflows/release-please.yml +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/.gitignore +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/.gitmodules +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/.python-version +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/README.md +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/release-please-config.json +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/cli.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/bump.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/db.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/docker.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/run.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/setup.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/vendor.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/config.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/preflight.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/docker/Dockerfile +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/docker/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/docker/docker-entrypoint.sh +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/vscode/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/vscode/launch.json +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/vscode/settings.json +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/vscode/tasks.json +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/utils/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/utils/console.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/utils/manifest.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/develop.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/edit.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/lock.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/materialize.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/migrate.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/sources.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/sync.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/update.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/verify.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/fixtures/__init__.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_bump.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_cli.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_config.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_db.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_db_config.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_drop_database.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_integration.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_preflight.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_run.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_setup.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_setup_db.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_vendor_develop.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_vendor_edit.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_vendor_lock.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_vendor_materialize.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_vendor_migrate.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_vendor_update.py +0 -0
- {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_vendor_verify.py +0 -0
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.0](https://github.com/bemade/odoo-dev/compare/v1.3.0...v1.4.0) (2026-09-04)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **git:** add `sweep` — classify remote branches, pick, delete ([86fc297](https://github.com/bemade/odoo-dev/commit/86fc29774916f35b6c7470daf9188e2c4bf78907))
|
|
9
|
+
* **git:** add `sweep` — classify remote branches, pick, delete ([69fb870](https://github.com/bemade/odoo-dev/commit/69fb8708c940aa1534e1c342ee138966df584e0f))
|
|
10
|
+
|
|
3
11
|
## [1.3.0](https://github.com/bemade/odoo-dev/compare/v1.2.3...v1.3.0) (2026-09-02)
|
|
4
12
|
|
|
5
13
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: odoo-dev
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Odoo Development Environment Helper
|
|
5
5
|
Project-URL: Homepage, https://github.com/bemade/odoo-dev
|
|
6
6
|
Project-URL: Repository, https://github.com/bemade/odoo-dev
|
|
@@ -8,6 +8,7 @@ Project-URL: Issues, https://github.com/bemade/odoo-dev/issues
|
|
|
8
8
|
License-Expression: LGPL-3.0
|
|
9
9
|
Requires-Python: >=3.12
|
|
10
10
|
Requires-Dist: pyyaml>=6.0
|
|
11
|
+
Requires-Dist: questionary>=2.0
|
|
11
12
|
Requires-Dist: rich>=13.0.0
|
|
12
13
|
Requires-Dist: typer>=0.9.0
|
|
13
14
|
Description-Content-Type: text/markdown
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "odoo-dev"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.4.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 []
|
|
@@ -277,3 +277,143 @@ def test_unmerged_reads_remote_refs_not_stale_local_ones(repo):
|
|
|
277
277
|
res = runner.invoke(app, ["git", "unmerged", "main", "--no-fetch"])
|
|
278
278
|
assert res.exit_code == 0
|
|
279
279
|
assert "wip" in res.stdout
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# --- sweep -------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _sweep_repo(repo):
|
|
286
|
+
"""Add branches covering each class sweep must distinguish."""
|
|
287
|
+
# contained: merged into main
|
|
288
|
+
_run("checkout", "-q", "-b", "done", cwd=repo)
|
|
289
|
+
(repo / "done.py").write_text("x = 1\n")
|
|
290
|
+
_run("add", "-A", cwd=repo)
|
|
291
|
+
_run("commit", "-m", "feat: done", cwd=repo)
|
|
292
|
+
_run("checkout", "-q", "main", cwd=repo)
|
|
293
|
+
_run("merge", "--no-ff", "-m", "merge done", "done", cwd=repo)
|
|
294
|
+
# unknown: real unmerged work, same era as main
|
|
295
|
+
_run("checkout", "-q", "-b", "live", cwd=repo)
|
|
296
|
+
(repo / "live.py").write_text("y = 2\n")
|
|
297
|
+
_run("add", "-A", cwd=repo)
|
|
298
|
+
_run("commit", "-m", "feat: live", cwd=repo)
|
|
299
|
+
_run("checkout", "-q", "main", cwd=repo)
|
|
300
|
+
_run("push", "-q", "origin", "main", "done", "live", cwd=repo)
|
|
301
|
+
_run("fetch", "-q", "--prune", "origin", cwd=repo)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_sweep_classifies_contained_and_unknown(repo):
|
|
305
|
+
_sweep_repo(repo)
|
|
306
|
+
res = runner.invoke(app, ["git", "sweep", "--no-check-requests", "--no-fetch"])
|
|
307
|
+
assert res.exit_code == 0, res.stdout
|
|
308
|
+
assert "CONTAINED" in res.stdout
|
|
309
|
+
assert "UNKNOWN" in res.stdout
|
|
310
|
+
assert "DRY RUN" in res.stdout
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_sweep_is_dry_by_default(repo):
|
|
314
|
+
"""Nothing is deleted without --apply, even the provably safe class."""
|
|
315
|
+
_sweep_repo(repo)
|
|
316
|
+
res = runner.invoke(
|
|
317
|
+
app, ["git", "sweep", "--no-check-requests", "--no-fetch", "--non-interactive"]
|
|
318
|
+
)
|
|
319
|
+
assert res.exit_code == 0
|
|
320
|
+
out = subprocess.run(
|
|
321
|
+
["git", "ls-remote", "--heads", "origin"],
|
|
322
|
+
cwd=repo,
|
|
323
|
+
capture_output=True,
|
|
324
|
+
text=True,
|
|
325
|
+
).stdout
|
|
326
|
+
assert "refs/heads/done" in out
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_sweep_apply_deletes_only_contained(repo):
|
|
330
|
+
"""--non-interactive takes exactly CONTAINED; unmerged work survives."""
|
|
331
|
+
_sweep_repo(repo)
|
|
332
|
+
res = runner.invoke(
|
|
333
|
+
app,
|
|
334
|
+
[
|
|
335
|
+
"git",
|
|
336
|
+
"sweep",
|
|
337
|
+
"--no-check-requests",
|
|
338
|
+
"--no-fetch",
|
|
339
|
+
"--non-interactive",
|
|
340
|
+
"--apply",
|
|
341
|
+
],
|
|
342
|
+
)
|
|
343
|
+
assert res.exit_code == 0, res.stdout
|
|
344
|
+
out = subprocess.run(
|
|
345
|
+
["git", "ls-remote", "--heads", "origin"],
|
|
346
|
+
cwd=repo,
|
|
347
|
+
capture_output=True,
|
|
348
|
+
text=True,
|
|
349
|
+
).stdout
|
|
350
|
+
assert "refs/heads/done" not in out
|
|
351
|
+
assert "refs/heads/live" in out
|
|
352
|
+
assert "refs/heads/main" in out
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_sweep_writes_manifest_with_shas(repo):
|
|
356
|
+
"""The manifest exists before deletion and carries a restorable SHA."""
|
|
357
|
+
_sweep_repo(repo)
|
|
358
|
+
sha = subprocess.run(
|
|
359
|
+
["git", "rev-parse", "origin/done"],
|
|
360
|
+
cwd=repo,
|
|
361
|
+
capture_output=True,
|
|
362
|
+
text=True,
|
|
363
|
+
).stdout.strip()
|
|
364
|
+
runner.invoke(
|
|
365
|
+
app,
|
|
366
|
+
[
|
|
367
|
+
"git",
|
|
368
|
+
"sweep",
|
|
369
|
+
"--no-check-requests",
|
|
370
|
+
"--no-fetch",
|
|
371
|
+
"--non-interactive",
|
|
372
|
+
"--apply",
|
|
373
|
+
],
|
|
374
|
+
)
|
|
375
|
+
manifests = list((repo / ".git" / "odoo-dev").glob("sweep-*.tsv"))
|
|
376
|
+
assert len(manifests) == 1
|
|
377
|
+
body = manifests[0].read_text()
|
|
378
|
+
assert sha in body
|
|
379
|
+
assert "done\t" in body
|
|
380
|
+
# and that SHA really does bring it back
|
|
381
|
+
_run("push", "origin", f"{sha}:refs/heads/done", cwd=repo)
|
|
382
|
+
out = subprocess.run(
|
|
383
|
+
["git", "ls-remote", "--heads", "origin"],
|
|
384
|
+
cwd=repo,
|
|
385
|
+
capture_output=True,
|
|
386
|
+
text=True,
|
|
387
|
+
).stdout
|
|
388
|
+
assert "refs/heads/done" in out
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def test_sweep_flags_stale_by_base(repo):
|
|
392
|
+
"""A branch cut before a mass tree change is STALE-BY-BASE, not UNKNOWN.
|
|
393
|
+
|
|
394
|
+
This is the class that age cannot detect: the branch below is the newest in
|
|
395
|
+
the repo and still must never be merged, because it predates files main
|
|
396
|
+
owns.
|
|
397
|
+
"""
|
|
398
|
+
for i in range(60):
|
|
399
|
+
(repo / f"vendored_{i}.py").write_text(f"V = {i}\n")
|
|
400
|
+
_run("add", "-A", cwd=repo)
|
|
401
|
+
_run("commit", "-m", "chore: vendor the tree", cwd=repo)
|
|
402
|
+
_run("push", "-q", "origin", "main", cwd=repo)
|
|
403
|
+
# branch from BEFORE that commit, then add something new to it
|
|
404
|
+
_run("checkout", "-q", "-b", "oldbase", "HEAD~1", cwd=repo)
|
|
405
|
+
(repo / "late.py").write_text("late = True\n")
|
|
406
|
+
_run("add", "-A", cwd=repo)
|
|
407
|
+
_run("commit", "-m", "feat: late work on an old base", cwd=repo)
|
|
408
|
+
_run("push", "-q", "origin", "oldbase", cwd=repo)
|
|
409
|
+
_run("checkout", "-q", "main", cwd=repo)
|
|
410
|
+
_run("fetch", "-q", "--prune", "origin", cwd=repo)
|
|
411
|
+
|
|
412
|
+
res = runner.invoke(app, ["git", "sweep", "--no-check-requests", "--no-fetch"])
|
|
413
|
+
assert res.exit_code == 0, res.stdout
|
|
414
|
+
assert "STALE-BY-BASE" in res.stdout
|
|
415
|
+
# and it is never auto-selected
|
|
416
|
+
res2 = runner.invoke(
|
|
417
|
+
app, ["git", "sweep", "--no-check-requests", "--no-fetch", "--non-interactive"]
|
|
418
|
+
)
|
|
419
|
+
assert "oldbase" not in res2.stdout.split("selected:")[-1]
|
|
@@ -96,10 +96,11 @@ wheels = [
|
|
|
96
96
|
|
|
97
97
|
[[package]]
|
|
98
98
|
name = "odoo-dev"
|
|
99
|
-
version = "1.2.
|
|
99
|
+
version = "1.2.3"
|
|
100
100
|
source = { editable = "." }
|
|
101
101
|
dependencies = [
|
|
102
102
|
{ name = "pyyaml" },
|
|
103
|
+
{ name = "questionary" },
|
|
103
104
|
{ name = "rich" },
|
|
104
105
|
{ name = "typer" },
|
|
105
106
|
]
|
|
@@ -113,6 +114,7 @@ dev = [
|
|
|
113
114
|
[package.metadata]
|
|
114
115
|
requires-dist = [
|
|
115
116
|
{ name = "pyyaml", specifier = ">=6.0" },
|
|
117
|
+
{ name = "questionary", specifier = ">=2.0" },
|
|
116
118
|
{ name = "rich", specifier = ">=13.0.0" },
|
|
117
119
|
{ name = "typer", specifier = ">=0.9.0" },
|
|
118
120
|
]
|
|
@@ -159,6 +161,18 @@ wheels = [
|
|
|
159
161
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
160
162
|
]
|
|
161
163
|
|
|
164
|
+
[[package]]
|
|
165
|
+
name = "prompt-toolkit"
|
|
166
|
+
version = "3.0.53"
|
|
167
|
+
source = { registry = "https://pypi.org/simple" }
|
|
168
|
+
dependencies = [
|
|
169
|
+
{ name = "wcwidth" },
|
|
170
|
+
]
|
|
171
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" }
|
|
172
|
+
wheels = [
|
|
173
|
+
{ url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" },
|
|
174
|
+
]
|
|
175
|
+
|
|
162
176
|
[[package]]
|
|
163
177
|
name = "pygments"
|
|
164
178
|
version = "2.19.2"
|
|
@@ -259,6 +273,18 @@ wheels = [
|
|
|
259
273
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
|
260
274
|
]
|
|
261
275
|
|
|
276
|
+
[[package]]
|
|
277
|
+
name = "questionary"
|
|
278
|
+
version = "2.1.1"
|
|
279
|
+
source = { registry = "https://pypi.org/simple" }
|
|
280
|
+
dependencies = [
|
|
281
|
+
{ name = "prompt-toolkit" },
|
|
282
|
+
]
|
|
283
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
|
|
284
|
+
wheels = [
|
|
285
|
+
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
|
|
286
|
+
]
|
|
287
|
+
|
|
262
288
|
[[package]]
|
|
263
289
|
name = "rich"
|
|
264
290
|
version = "14.3.2"
|
|
@@ -304,3 +330,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
|
|
|
304
330
|
wheels = [
|
|
305
331
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
|
306
332
|
]
|
|
333
|
+
|
|
334
|
+
[[package]]
|
|
335
|
+
name = "wcwidth"
|
|
336
|
+
version = "0.8.3"
|
|
337
|
+
source = { registry = "https://pypi.org/simple" }
|
|
338
|
+
sdist = { url = "https://files.pythonhosted.org/packages/36/57/ed58088fafdf4c55a0ad6bde846502567645424d7ebf325230b9237f4085/wcwidth-0.8.3.tar.gz", hash = "sha256:d128512515fbf4612e0ff21fd6380399210318b7b54a9af59dff8454cf9730eb", size = 1458450, upload-time = "2026-08-28T18:10:06.875Z" }
|
|
339
|
+
wheels = [
|
|
340
|
+
{ url = "https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl", hash = "sha256:d5b73dba6158a595ec9370350e7f2637bcac8d6c5e4fde34f30fcffb6103a5e4", size = 331669, upload-time = "2026-08-28T18:10:04.909Z" },
|
|
341
|
+
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|