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.
Files changed (67) hide show
  1. odoo_dev-1.4.0/.release-please-manifest.json +3 -0
  2. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/CHANGELOG.md +8 -0
  3. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/PKG-INFO +2 -1
  4. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/pyproject.toml +2 -1
  5. odoo_dev-1.4.0/src/odoo_dev/__init__.py +3 -0
  6. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/git.py +351 -0
  7. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_git_hygiene.py +140 -0
  8. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/uv.lock +36 -1
  9. odoo_dev-1.3.0/.release-please-manifest.json +0 -3
  10. odoo_dev-1.3.0/src/odoo_dev/__init__.py +0 -3
  11. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/.github/workflows/release-please.yml +0 -0
  12. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/.gitignore +0 -0
  13. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/.gitmodules +0 -0
  14. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/.python-version +0 -0
  15. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/README.md +0 -0
  16. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/release-please-config.json +0 -0
  17. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/cli.py +0 -0
  18. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/__init__.py +0 -0
  19. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/bump.py +0 -0
  20. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/db.py +0 -0
  21. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/docker.py +0 -0
  22. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/run.py +0 -0
  23. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/setup.py +0 -0
  24. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/commands/vendor.py +0 -0
  25. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/config.py +0 -0
  26. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/preflight.py +0 -0
  27. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/__init__.py +0 -0
  28. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/docker/Dockerfile +0 -0
  29. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/docker/__init__.py +0 -0
  30. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/docker/docker-entrypoint.sh +0 -0
  31. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/vscode/__init__.py +0 -0
  32. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/vscode/launch.json +0 -0
  33. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/vscode/settings.json +0 -0
  34. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/templates/vscode/tasks.json +0 -0
  35. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/utils/__init__.py +0 -0
  36. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/utils/console.py +0 -0
  37. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/utils/manifest.py +0 -0
  38. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/__init__.py +0 -0
  39. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/develop.py +0 -0
  40. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/edit.py +0 -0
  41. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/lock.py +0 -0
  42. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/materialize.py +0 -0
  43. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/migrate.py +0 -0
  44. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/sources.py +0 -0
  45. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/sync.py +0 -0
  46. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/update.py +0 -0
  47. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/src/odoo_dev/vendor/verify.py +0 -0
  48. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/__init__.py +0 -0
  49. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/fixtures/__init__.py +0 -0
  50. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_bump.py +0 -0
  51. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_cli.py +0 -0
  52. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_config.py +0 -0
  53. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_db.py +0 -0
  54. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_db_config.py +0 -0
  55. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_drop_database.py +0 -0
  56. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_integration.py +0 -0
  57. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_preflight.py +0 -0
  58. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_run.py +0 -0
  59. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_setup.py +0 -0
  60. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_setup_db.py +0 -0
  61. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_vendor_develop.py +0 -0
  62. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_vendor_edit.py +0 -0
  63. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_vendor_lock.py +0 -0
  64. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_vendor_materialize.py +0 -0
  65. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_vendor_migrate.py +0 -0
  66. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_vendor_update.py +0 -0
  67. {odoo_dev-1.3.0 → odoo_dev-1.4.0}/tests/test_vendor_verify.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.4.0"
3
+ }
@@ -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.0
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.0"
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
 
@@ -0,0 +1,3 @@
1
+ """Odoo Development Environment Helper."""
2
+
3
+ __version__ = "1.4.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 []
@@ -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.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
+ ]
@@ -1,3 +0,0 @@
1
- {
2
- ".": "1.3.0"
3
- }
@@ -1,3 +0,0 @@
1
- """Odoo Development Environment Helper."""
2
-
3
- __version__ = "1.3.0" # x-release-please-version
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