odoo-dev 1.2.0__tar.gz → 1.2.2__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 (66) hide show
  1. odoo_dev-1.2.2/.release-please-manifest.json +3 -0
  2. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/CHANGELOG.md +20 -0
  3. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/PKG-INFO +16 -4
  4. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/README.md +14 -2
  5. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/pyproject.toml +1 -1
  6. odoo_dev-1.2.2/src/odoo_dev/__init__.py +3 -0
  7. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/commands/run.py +37 -8
  8. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/commands/vendor.py +41 -3
  9. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/develop.py +15 -0
  10. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/edit.py +9 -2
  11. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/materialize.py +15 -0
  12. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/migrate.py +127 -17
  13. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/update.py +26 -7
  14. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/verify.py +49 -1
  15. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_vendor_develop.py +37 -0
  16. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_vendor_edit.py +33 -0
  17. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_vendor_materialize.py +31 -0
  18. odoo_dev-1.2.2/tests/test_vendor_migrate.py +256 -0
  19. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_vendor_update.py +25 -0
  20. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_vendor_verify.py +67 -2
  21. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/uv.lock +1 -1
  22. odoo_dev-1.2.0/.release-please-manifest.json +0 -3
  23. odoo_dev-1.2.0/src/odoo_dev/__init__.py +0 -3
  24. odoo_dev-1.2.0/tests/test_vendor_migrate.py +0 -128
  25. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/.github/workflows/release-please.yml +0 -0
  26. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/.gitignore +0 -0
  27. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/.gitmodules +0 -0
  28. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/.python-version +0 -0
  29. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/release-please-config.json +0 -0
  30. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/cli.py +0 -0
  31. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/commands/__init__.py +0 -0
  32. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/commands/bump.py +0 -0
  33. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/commands/db.py +0 -0
  34. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/commands/docker.py +0 -0
  35. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/commands/setup.py +0 -0
  36. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/config.py +0 -0
  37. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/preflight.py +0 -0
  38. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/__init__.py +0 -0
  39. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/docker/Dockerfile +0 -0
  40. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/docker/__init__.py +0 -0
  41. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/docker/docker-entrypoint.sh +0 -0
  42. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/vscode/__init__.py +0 -0
  43. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/vscode/launch.json +0 -0
  44. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/vscode/settings.json +0 -0
  45. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/templates/vscode/tasks.json +0 -0
  46. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/utils/__init__.py +0 -0
  47. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/utils/console.py +0 -0
  48. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/utils/manifest.py +0 -0
  49. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/__init__.py +0 -0
  50. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/lock.py +0 -0
  51. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/sources.py +0 -0
  52. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/src/odoo_dev/vendor/sync.py +0 -0
  53. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/__init__.py +0 -0
  54. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/fixtures/__init__.py +0 -0
  55. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_bump.py +0 -0
  56. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_cli.py +0 -0
  57. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_config.py +0 -0
  58. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_db.py +0 -0
  59. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_db_config.py +0 -0
  60. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_drop_database.py +0 -0
  61. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_integration.py +0 -0
  62. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_preflight.py +0 -0
  63. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_run.py +0 -0
  64. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_setup.py +0 -0
  65. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_setup_db.py +0 -0
  66. {odoo_dev-1.2.0 → odoo_dev-1.2.2}/tests/test_vendor_lock.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.2.2"
3
+ }
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.2](https://github.com/bemade/odoo-dev/compare/v1.2.1...v1.2.2) (2026-08-14)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **vendor:** exclude python bytecode from the vendored tree comparison ([eab5a0a](https://github.com/bemade/odoo-dev/commit/eab5a0a0779821432724e2be31dcb416e5a60c6b)), closes [#11](https://github.com/bemade/odoo-dev/issues/11)
9
+ * **vendor:** fail when .gitignore would strip files out of vendored/ ([6880616](https://github.com/bemade/odoo-dev/commit/6880616fb3f8d503171f7a6e0f5f6bfe98b667a8)), closes [#13](https://github.com/bemade/odoo-dev/issues/13)
10
+ * **vendor:** pin from the recorded gitlink, refuse uninitialized submodules ([001f86f](https://github.com/bemade/odoo-dev/commit/001f86ff1ae4b89b89681aa8e9878cf2bd9f1f1c)), closes [#12](https://github.com/bemade/odoo-dev/issues/12)
11
+ * **vendor:** three failure modes found in vendoring + CI cycles ([051715a](https://github.com/bemade/odoo-dev/commit/051715a10a17163ebe79cd2fe05a7bbe42328511))
12
+
13
+ ## [1.2.1](https://github.com/bemade/odoo-dev/compare/v1.2.0...v1.2.1) (2026-07-14)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * **test:** make full-suite runs work under a dev odoo.conf ([2a1b752](https://github.com/bemade/odoo-dev/commit/2a1b7527d29b3bf8bb776e41ce739b4864543b73))
19
+ * **vendor:** bump clears stale version when pinning to a commit ([19544ba](https://github.com/bemade/odoo-dev/commit/19544bac55a3dded4b912cc9f2ac7226f5d72d61))
20
+ * **vendor:** constrain `vendor update` to the pin's own major series ([5b58e26](https://github.com/bemade/odoo-dev/commit/5b58e2676de6c1b63a678d1dd365efca9e4c8b99))
21
+ * **vendor:** migrate sets version only when the tag resolves to the pin ([ea59b1d](https://github.com/bemade/odoo-dev/commit/ea59b1d62c9844fb831925d9973b4ec179b803e6))
22
+
3
23
  ## [1.2.0](https://github.com/bemade/odoo-dev/compare/v1.1.0...v1.2.0) (2026-07-07)
4
24
 
5
25
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: odoo-dev
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Odoo Development Environment Helper
5
5
  Project-URL: Homepage, https://git.bemade.org/bemade/odoo-dev
6
6
  Project-URL: Repository, https://git.bemade.org/bemade/odoo-dev
@@ -175,8 +175,20 @@ something other than the current pin.
175
175
 
176
176
  `vendor check` verifies, per addon: the vendored files byte-match the pinned
177
177
  commit; a `version` tag (if set) still resolves to that commit; every manifest
178
- `external_dependencies['python']` is named in `requirements.txt`; and no addon
179
- name collides between `addons/` and `vendored/`.
178
+ `external_dependencies['python']` is named in `requirements.txt`; no addon
179
+ name collides between `addons/` and `vendored/`; and no file under `vendored/` is
180
+ gitignored. Python bytecode (`__pycache__/`, `*.pyc`) is excluded from the byte
181
+ comparison, so running the test suite doesn't turn the check red.
182
+
183
+ That last assertion catches a trap specific to migration: repo-wide ignore rules
184
+ (`node_modules`, `package.json`, …) are harmless while shared addons live in
185
+ submodules, but they start applying the moment those addons become real files
186
+ under `vendored/` — and `git add` then skips the matching files without a word,
187
+ so the committed tree is incomplete while everything looks fine locally.
188
+ `vendor migrate` appends a `!vendored/**` negation to `.gitignore` when it
189
+ detects the situation. Pins themselves come from the gitlink the branch records,
190
+ not from whatever the submodule happens to be checked out at; migrate refuses to
191
+ run against uninitialized submodules.
180
192
 
181
193
  `vendor update` is the **pull** side: each client owns its pins. For every addon
182
194
  that tracks upstream — a `version` (bump to the newest `<addon>/<version>` tag) or
@@ -161,8 +161,20 @@ something other than the current pin.
161
161
 
162
162
  `vendor check` verifies, per addon: the vendored files byte-match the pinned
163
163
  commit; a `version` tag (if set) still resolves to that commit; every manifest
164
- `external_dependencies['python']` is named in `requirements.txt`; and no addon
165
- name collides between `addons/` and `vendored/`.
164
+ `external_dependencies['python']` is named in `requirements.txt`; no addon
165
+ name collides between `addons/` and `vendored/`; and no file under `vendored/` is
166
+ gitignored. Python bytecode (`__pycache__/`, `*.pyc`) is excluded from the byte
167
+ comparison, so running the test suite doesn't turn the check red.
168
+
169
+ That last assertion catches a trap specific to migration: repo-wide ignore rules
170
+ (`node_modules`, `package.json`, …) are harmless while shared addons live in
171
+ submodules, but they start applying the moment those addons become real files
172
+ under `vendored/` — and `git add` then skips the matching files without a word,
173
+ so the committed tree is incomplete while everything looks fine locally.
174
+ `vendor migrate` appends a `!vendored/**` negation to `.gitignore` when it
175
+ detects the situation. Pins themselves come from the gitlink the branch records,
176
+ not from whatever the submodule happens to be checked out at; migrate refuses to
177
+ run against uninitialized submodules.
166
178
 
167
179
  `vendor update` is the **pull** side: each client owns its pins. For every addon
168
180
  that tracks upstream — a `version` (bump to the newest `<addon>/<version>` tag) or
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "odoo-dev"
3
- version = "1.2.0"
3
+ version = "1.2.2"
4
4
  description = "Odoo Development Environment Helper"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,3 @@
1
+ """Odoo Development Environment Helper."""
2
+
3
+ __version__ = "1.2.2" # x-release-please-version
@@ -235,17 +235,18 @@ def test(
235
235
  break
236
236
 
237
237
  # Auto-load exclusions from repo_deps.yaml when not explicitly provided
238
- if exclude is None and repo_deps_file is not None:
238
+ no_ci_modules: list[str] = []
239
+ if repo_deps_file is not None:
239
240
  try:
240
241
  import yaml
241
242
 
242
243
  data = yaml.safe_load(repo_deps_file.read_text()) or {}
243
- no_ci = data.get("no_ci", [])
244
- if no_ci:
245
- exclude = ",".join(no_ci)
246
- info(f"Excluding (from {repo_deps_file} no_ci): {exclude}")
244
+ no_ci_modules = list(data.get("no_ci", []) or [])
247
245
  except Exception:
248
- pass
246
+ no_ci_modules = []
247
+ if exclude is None and no_ci_modules:
248
+ exclude = ",".join(no_ci_modules)
249
+ info(f"Excluding (from {repo_deps_file} no_ci): {exclude}")
249
250
 
250
251
  # Verify setup
251
252
  if not cfg.venv_path.exists():
@@ -376,6 +377,9 @@ def test(
376
377
  "--stop-after-init",
377
378
  "--http-port",
378
379
  str(http_port),
380
+ # No need to fork prefork workers for a stop-after-init install.
381
+ "--workers",
382
+ "0",
379
383
  ],
380
384
  )
381
385
 
@@ -422,11 +426,36 @@ def test(
422
426
  "--stop-after-init",
423
427
  "--http-port",
424
428
  str(http_port),
429
+ # HttpCase needs the threaded server (ThreadedServer.httpd);
430
+ # a prefork/multi-worker conf raises
431
+ # "'PreforkServer' object has no attribute 'httpd'" in
432
+ # setUpClass. Force single-process regardless of odoo.conf.
433
+ "--workers",
434
+ "0",
435
+ # A restrictive dbfilter in odoo.conf (e.g. `dbfilter = ^rwi$`)
436
+ # never matches the ephemeral test DB, so HttpCase requests get
437
+ # "dbfilter rejects it; logging session out" -> auth='user'
438
+ # routes 404 and JSON endpoints return non-JSON. Scope the
439
+ # filter to this run's test DB so the test server serves it.
440
+ f"--db-filter=^{db_name}$",
425
441
  ]
426
442
  )
427
443
 
428
- if test_tags:
429
- test_cmd.extend(["--test-tags", test_tags])
444
+ # Excluding no_ci modules from the tested-module *list* is not enough:
445
+ # they still install as transitive deps, and Odoo's button_upgrade
446
+ # cascades 'to upgrade' to downstream dependents of any upgraded target
447
+ # module (`-u modules`). A no_ci module that depends on a tested module
448
+ # thus gets re-upgraded and its post_install tests run. Exclude their
449
+ # tests explicitly via Odoo test-tags. The TagsSelector auto-adds
450
+ # '+standard' when only '-' filters are present, so this keeps running
451
+ # every standard test except those belonging to a no_ci module.
452
+ effective_test_tags = test_tags
453
+ if effective_test_tags is None and no_ci_modules:
454
+ effective_test_tags = ",".join(f"-/{m}" for m in no_ci_modules)
455
+ info(f"Excluding no_ci module tests via --test-tags: {effective_test_tags}")
456
+
457
+ if effective_test_tags:
458
+ test_cmd.extend(["--test-tags", effective_test_tags])
430
459
 
431
460
  result = subprocess.run(test_cmd)
432
461
  test_exit_code = result.returncode
@@ -14,16 +14,22 @@ from typing import Annotated, Optional
14
14
  import typer
15
15
 
16
16
  from odoo_dev.config import load_config
17
- from odoo_dev.utils.console import error, info, success
17
+ from odoo_dev.utils.console import error, info, success, warning
18
18
  from odoo_dev.vendor.develop import (
19
19
  DevelopError,
20
20
  develop_state,
21
+ ensure_addons_path,
21
22
  start_develop,
22
23
  stop_develop,
23
24
  )
24
25
  from odoo_dev.vendor.edit import EditError, add_addon, bump_addon
25
26
  from odoo_dev.vendor.lock import Lockfile
26
- from odoo_dev.vendor.migrate import migrate_repo, plan_migration
27
+ from odoo_dev.vendor.migrate import (
28
+ MigrateError,
29
+ ensure_vendored_not_ignored,
30
+ migrate_repo,
31
+ plan_migration,
32
+ )
27
33
  from odoo_dev.vendor.sync import sync_addons
28
34
  from odoo_dev.vendor.update import apply_update, find_updates
29
35
  from odoo_dev.vendor.verify import verify
@@ -146,8 +152,23 @@ def migrate_cmd(
146
152
  ) -> None:
147
153
  """Convert addons/ symlinks into .repos submodules to vendored/ + addons.lock."""
148
154
  cfg = load_config()
149
- if dry_run:
155
+ try:
150
156
  plans = plan_migration(cfg.project_dir, addon or None)
157
+ except MigrateError as exc:
158
+ error(str(exc))
159
+ raise typer.Exit(2)
160
+
161
+ # The pin is the gitlink the branch records. Say so when the submodule is
162
+ # checked out somewhere else, since that checkout is what the dev sees.
163
+ for p in plans:
164
+ if p["checkout"] and p["checkout"] != p["commit"]:
165
+ warning(
166
+ f"{p['name']}: {p['sub_path']} is checked out at "
167
+ f"{p['checkout'][:12]} but this branch records "
168
+ f"{p['commit'][:12]} — pinning the recorded commit."
169
+ )
170
+
171
+ if dry_run:
151
172
  if not plans:
152
173
  info("Nothing to migrate (no symlinked submodule addons found).")
153
174
  return
@@ -159,6 +180,23 @@ def migrate_cmd(
159
180
 
160
181
  lock, unused = migrate_repo(cfg.project_dir, addons=addon or None, deinit=deinit)
161
182
  success(f"Migrated {len(lock.entries)} addon(s) to vendored/ (see addons.lock).")
183
+
184
+ # Wire vendored/ into the local conf's addons_path. A fresh `setup` adds it,
185
+ # but `setup` won't overwrite an existing conf, so a repo vendored for the
186
+ # first time would otherwise leave Odoo unable to find the new addons
187
+ # ("invalid module names, ignored"). Local conf only — never Docker/CI.
188
+ if cfg.config_file.exists() and cfg.vendored_dir.is_dir():
189
+ if ensure_addons_path(cfg.config_file, str(cfg.vendored_dir)):
190
+ info(f"Added {cfg.vendored_dir} to {cfg.config_file} addons_path.")
191
+
192
+ # Repo-wide ignore rules that were harmless against submodule content now
193
+ # apply to real files under vendored/, where git would drop them from the
194
+ # commit without a word. Negate them at the point of damage.
195
+ if ensure_vendored_not_ignored(cfg.project_dir):
196
+ info(
197
+ "Added a '!vendored/**' negation to .gitignore (rules matched "
198
+ "vendored files, which git would have silently skipped)."
199
+ )
162
200
  if unused:
163
201
  info("")
164
202
  if deinit:
@@ -167,6 +167,21 @@ def remove_addons_path(conf_file: Path, path: str) -> None:
167
167
  _write_addons_path(conf_file, [p for p in read_addons_path(conf_file) if p != path])
168
168
 
169
169
 
170
+ def ensure_addons_path(conf_file: Path, path: str) -> bool:
171
+ """Append ``path`` to the conf's addons_path if it isn't already listed.
172
+
173
+ Idempotent; returns True only when it actually added the entry. Used by
174
+ ``vendor migrate`` to wire ``vendored/`` into a conf that predates vendoring
175
+ (``setup`` adds it for fresh confs but never overwrites an existing one).
176
+ """
177
+ paths = read_addons_path(conf_file)
178
+ if path in paths:
179
+ return False
180
+ paths.append(path)
181
+ _write_addons_path(conf_file, paths)
182
+ return True
183
+
184
+
170
185
  # --- overlay / gitignore / state ------------------------------------------------
171
186
 
172
187
 
@@ -76,8 +76,15 @@ def bump_addon(
76
76
  entry = lock.entries[name]
77
77
  sha = _resolve_pin(name, entry.source, version, commit, entry.branch, cache_dir)
78
78
  entry.commit = sha
79
- if version is not None:
80
- entry.version = version
79
+ # ``version`` is the tag-intent metadata, and ``vendor check``'s moved-tag
80
+ # tripwire (verify.py) requires ``<name>/<version>`` to still resolve to
81
+ # ``commit``. A ``--commit`` / branch-HEAD bump moves the pin off the tagged
82
+ # commit, so the old ``version`` is now stale and would (correctly) fail the
83
+ # tripwire. Set it to whatever the bump was keyed on: the new tag for a
84
+ # ``--version`` bump, or ``None`` for a raw ``--commit`` / branch bump — a
85
+ # commit pin carries no version metadata (matching how ``migrate`` renders
86
+ # untagged pins).
87
+ entry.version = version
81
88
  lock.entries[name] = entry
82
89
  lock.dump(project_dir / "addons.lock")
83
90
  sync_addons(project_dir, lock, cache_dir=cache_dir, names=[name])
@@ -5,6 +5,11 @@ modes and symlinks survive verbatim — the vendored copy is byte-identical to t
5
5
  source subtree. :func:`tree_diff` then compares two trees on content, the
6
6
  executable bit, and symlink targets (a plain ``diff -r`` silently ignores the
7
7
  latter two and follows symlinks), which is what makes ``vendor check`` trustworthy.
8
+
9
+ Python bytecode (``__pycache__/``, ``*.pyc``, ``*.pyo``) is excluded from the
10
+ comparison: running or testing a vendored addon locally compiles it in place, and
11
+ those artifacts are gitignored build output that is neither part of the pinned
12
+ source nor present in a fresh CI checkout.
8
13
  """
9
14
 
10
15
  from __future__ import annotations
@@ -60,12 +65,18 @@ def extract_subtree(repo: Path, commit: str, addon: str, dest: Path) -> None:
60
65
  )
61
66
 
62
67
 
68
+ _BYTECODE_DIRS = {"__pycache__"}
69
+ _BYTECODE_SUFFIXES = (".pyc", ".pyo")
70
+
71
+
63
72
  def _entries(root: Path) -> dict:
64
73
  """Map each path relative to ``root`` to a comparable fingerprint.
65
74
 
66
75
  - symlink -> ("link", target) (never dereferenced)
67
76
  - file -> ("file", exec_bit, sha1)
68
77
  - dir -> ("dir",)
78
+
79
+ Python bytecode is skipped entirely (see the module docstring).
69
80
  """
70
81
  root = Path(root)
71
82
  out: dict = {}
@@ -73,6 +84,8 @@ def _entries(root: Path) -> dict:
73
84
  # Do not descend into symlinked directories; record them as links.
74
85
  real_dirs = []
75
86
  for d in dirnames:
87
+ if d in _BYTECODE_DIRS:
88
+ continue
76
89
  p = Path(dirpath) / d
77
90
  rel = str(p.relative_to(root))
78
91
  if p.is_symlink():
@@ -82,6 +95,8 @@ def _entries(root: Path) -> dict:
82
95
  real_dirs.append(d)
83
96
  dirnames[:] = real_dirs
84
97
  for f in filenames:
98
+ if f.endswith(_BYTECODE_SUFFIXES):
99
+ continue
85
100
  p = Path(dirpath) / f
86
101
  rel = str(p.relative_to(root))
87
102
  if p.is_symlink():
@@ -1,8 +1,10 @@
1
1
  """``vendor migrate``: convert a submodule+symlink repo to a vendored one.
2
2
 
3
3
  Reads the ``addons/<addon>`` symlinks that point into ``.repos/*`` submodules,
4
- derives a per-addon pin (submodule remote URL = source, submodule HEAD = commit,
5
- manifest version -> version when a matching ``<addon>/<version>`` tag exists),
4
+ derives a per-addon pin (submodule remote URL = source, recorded gitlink = commit,
5
+ manifest version -> version ONLY when the ``<addon>/<version>`` tag resolves to
6
+ the pinned commit; a commit that isn't the tagged release (a pin behind or ahead
7
+ of the tag, or any intermediate commit) is left commit-only, no version),
6
8
  writes ``addons.lock``, materializes ``vendored/<addon>/`` from the submodule,
7
9
  and removes the now-redundant symlink. Client-private real dirs under ``addons/``
8
10
  are left untouched; only symlinks into submodules are migrated. Submodule removal
@@ -19,13 +21,47 @@ from typing import Iterable, Optional
19
21
 
20
22
  from odoo_dev.utils.manifest import manifest_path, read_version
21
23
  from odoo_dev.vendor.lock import LockEntry, Lockfile
24
+ from odoo_dev.vendor.sources import tag_resolves_to
22
25
  from odoo_dev.vendor.sync import sync_addons
26
+ from odoo_dev.vendor.verify import gitignored_under_vendored
23
27
 
24
28
 
25
29
  class MigrateError(Exception):
26
30
  pass
27
31
 
28
32
 
33
+ _NEGATION = "!vendored/**"
34
+ _NEGATION_BLOCK = (
35
+ "\n# Vendored addons must match addons.lock byte-for-byte, so no repo-wide\n"
36
+ "# ignore rule may strip files out of them. Keep last.\n"
37
+ f"{_NEGATION}\n"
38
+ )
39
+
40
+
41
+ def ensure_vendored_not_ignored(project_dir: Path) -> bool:
42
+ """Append a ``!vendored/**`` negation when ``.gitignore`` would eat vendored files.
43
+
44
+ ``migrate`` is the command that creates the hazard — it turns submodule content
45
+ (governed by the submodule's own ignore rules) into real files under the
46
+ superproject's — so it is the right place to repair it, in the same shape as
47
+ ``ensure_addons_path`` fixing up ``conf/odoo.conf``. Idempotent; returns True
48
+ only when it actually wrote.
49
+ """
50
+ project_dir = Path(project_dir)
51
+ if not gitignored_under_vendored(project_dir):
52
+ return False
53
+ gitignore = project_dir / ".gitignore"
54
+ text = gitignore.read_text() if gitignore.exists() else ""
55
+ if any(line.strip() == _NEGATION for line in text.splitlines()):
56
+ # Already negated yet still ignored: a later rule re-excludes the path, or
57
+ # the negation cannot apply. Leave it — `vendor check` reports the files.
58
+ return False
59
+ if text and not text.endswith("\n"):
60
+ text += "\n"
61
+ gitignore.write_text(text + _NEGATION_BLOCK)
62
+ return True
63
+
64
+
29
65
  def read_gitmodules(project_dir: Path) -> list:
30
66
  """Return [(name, path, url)] for each submodule declared in .gitmodules."""
31
67
  gm = Path(project_dir) / ".gitmodules"
@@ -54,29 +90,82 @@ def read_gitmodules(project_dir: Path) -> list:
54
90
  ]
55
91
 
56
92
 
57
- def _submodule_head(project_dir: Path, sub_path: str) -> str:
58
- return subprocess.run(
59
- ["git", "-C", str(Path(project_dir) / sub_path), "rev-parse", "HEAD"],
60
- check=True,
93
+ def _gitlink_commit(project_dir: Path, sub_path: str) -> str:
94
+ """The commit the SUPERPROJECT records for ``sub_path`` — the authoritative pin.
95
+
96
+ Read from the superproject tree, never from the submodule working copy. An
97
+ uninitialized submodule is an empty directory, so ``git -C <sub> rev-parse
98
+ HEAD`` walks up to the enclosing repo, returns the SUPERPROJECT's head and
99
+ exits 0 — pinning every addon across every source repo to one wrong sha, in a
100
+ lockfile that ``vendor check`` then happily validates against itself.
101
+ ``ls-tree`` cannot walk up, and the ``160000`` mode proves it is a gitlink.
102
+ """
103
+ res = subprocess.run(
104
+ ["git", "-C", str(project_dir), "ls-tree", "HEAD", "--", sub_path],
105
+ capture_output=True,
106
+ text=True,
107
+ )
108
+ line = res.stdout.strip()
109
+ if res.returncode != 0 or not line:
110
+ raise MigrateError(
111
+ f"{sub_path}: no submodule recorded at this path in HEAD — cannot "
112
+ f"derive a pin (commit the submodule, or fix .gitmodules)."
113
+ )
114
+ mode, _, rest = line.partition(" ")
115
+ obj_type, _, rest = rest.partition(" ")
116
+ sha = rest.split("\t", 1)[0].strip()
117
+ if mode != "160000" or obj_type != "commit":
118
+ raise MigrateError(
119
+ f"{sub_path}: recorded in HEAD as a {obj_type}, not a submodule "
120
+ f"gitlink — cannot derive a pin."
121
+ )
122
+ return sha
123
+
124
+
125
+ def _submodule_checkout(project_dir: Path, sub_path: str) -> Optional[str]:
126
+ """The commit the submodule working copy is actually on, or None if absent.
127
+
128
+ Only meaningful for an initialized submodule (which owns a ``.git``); the
129
+ caller uses it to warn when the checkout has drifted off the gitlink.
130
+ """
131
+ sub_root = Path(project_dir) / sub_path
132
+ if not (sub_root / ".git").exists():
133
+ return None
134
+ res = subprocess.run(
135
+ ["git", "-C", str(sub_root), "rev-parse", "HEAD"],
61
136
  capture_output=True,
62
137
  text=True,
63
- ).stdout.strip()
138
+ )
139
+ return res.stdout.strip() if res.returncode == 0 else None
64
140
 
65
141
 
66
- def _tag_exists(repo: Path, tag: str) -> bool:
67
- return (
68
- subprocess.run(
69
- ["git", "-C", str(repo), "rev-parse", f"refs/tags/{tag}"],
70
- capture_output=True,
71
- ).returncode
72
- == 0
142
+ def uninitialized_submodules(project_dir: Path) -> list:
143
+ """Submodule paths git reports as not checked out (``-`` prefix)."""
144
+ res = subprocess.run(
145
+ ["git", "-C", str(project_dir), "submodule", "status"],
146
+ capture_output=True,
147
+ text=True,
73
148
  )
149
+ paths = []
150
+ for line in res.stdout.splitlines():
151
+ if not line.startswith("-"):
152
+ continue
153
+ parts = line[1:].split()
154
+ if len(parts) >= 2:
155
+ paths.append(parts[1])
156
+ return paths
74
157
 
75
158
 
76
159
  def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) -> list:
77
160
  """Return a list of planned pins without changing anything.
78
161
 
79
- Each item: dict(name, source, commit, version, symlink, sub_path).
162
+ Each item: dict(name, source, commit, version, checkout, symlink, sub_path).
163
+ ``checkout`` is the submodule's actual HEAD, so the caller can warn when it
164
+ has drifted off the recorded gitlink the pin comes from.
165
+
166
+ Raises :class:`MigrateError` if any submodule being migrated is uninitialized:
167
+ its addon content and manifest are simply not on disk, and its pin cannot be
168
+ trusted to mean anything.
80
169
  """
81
170
  project_dir = Path(project_dir)
82
171
  addons_dir = project_dir / "addons"
@@ -84,6 +173,8 @@ def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) ->
84
173
  return []
85
174
  subs = read_gitmodules(project_dir)
86
175
  only = set(addons) if addons is not None else None
176
+ uninit = set(uninitialized_submodules(project_dir))
177
+ blocked = []
87
178
 
88
179
  plans = []
89
180
  for link in sorted(addons_dir.iterdir()):
@@ -106,6 +197,9 @@ def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) ->
106
197
  if sub is None:
107
198
  continue # symlink not into a known submodule; skip
108
199
  s_name, s_path, s_url = sub
200
+ if s_path in uninit:
201
+ blocked.append((name, s_path))
202
+ continue
109
203
  sub_root = (project_dir / s_path).resolve()
110
204
  subpath = real.relative_to(sub_root)
111
205
  if str(subpath) != name:
@@ -113,12 +207,18 @@ def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) ->
113
207
  f"{name}: addon sits at '{subpath}' inside submodule {s_path}, not at "
114
208
  f"the root as '{name}'. Nested addons aren't handled yet — migrate by hand."
115
209
  )
116
- commit = _submodule_head(project_dir, s_path)
210
+ commit = _gitlink_commit(project_dir, s_path)
117
211
  version = None
118
212
  mf = manifest_path(real)
119
213
  if mf is not None:
120
214
  v = read_version(mf.read_text())
121
- if v is not None and _tag_exists(sub_root, f"{name}/{v}"):
215
+ # Only claim a ``version`` when the tag actually points at the pinned
216
+ # commit. Checking mere tag EXISTENCE was wrong: the same manifest
217
+ # version can live at several commits (the tag is cut at one of
218
+ # them), so a pin at any other commit with that version would carry a
219
+ # ``version`` whose tag resolves elsewhere — tripping ``vendor
220
+ # check``'s moved-tag guard. A non-release pin is commit-only.
221
+ if v is not None and tag_resolves_to(sub_root, f"{name}/{v}", commit):
122
222
  version = v
123
223
  plans.append(
124
224
  dict(
@@ -126,10 +226,20 @@ def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) ->
126
226
  source=s_url,
127
227
  commit=commit,
128
228
  version=version,
229
+ checkout=_submodule_checkout(project_dir, s_path),
129
230
  symlink=link,
130
231
  sub_path=s_path,
131
232
  )
132
233
  )
234
+
235
+ if blocked:
236
+ paths = sorted({s_path for _, s_path in blocked})
237
+ names = ", ".join(sorted(name for name, _ in blocked))
238
+ raise MigrateError(
239
+ f"uninitialized submodule(s): {', '.join(paths)} — needed by {names}. "
240
+ f"Run 'git submodule update --init' first; migrating without them "
241
+ f"would pin every addon to the superproject's own HEAD."
242
+ )
133
243
  return plans
134
244
 
135
245
 
@@ -24,27 +24,46 @@ def _version_key(v: str) -> tuple:
24
24
  return tuple(int(p) if p.isdigit() else 0 for p in v.replace("-", ".").split("."))
25
25
 
26
26
 
27
+ def _series(v: str) -> str:
28
+ """The major-series segment of a version — the cross-series guard.
29
+
30
+ An Odoo addon carries both ``18.0.*`` and ``19.0.*`` version tags in its source;
31
+ version-tracking must stay within the pin's own series (``18`` vs ``19``), since
32
+ moving across Odoo series is a deliberate migration, never an auto-update.
33
+ """
34
+ return v.split(".", 1)[0]
35
+
36
+
27
37
  def list_addon_versions(
28
- source: str, addon: str, cache_dir: Optional[Path] = None
38
+ source: str,
39
+ addon: str,
40
+ cache_dir: Optional[Path] = None,
41
+ series: Optional[str] = None,
29
42
  ) -> list[str]:
30
- """All ``<addon>/<version>`` tag versions present in ``source``."""
43
+ """``<addon>/<version>`` tag versions in ``source``, optionally same-series only."""
31
44
  repo = _ensure_clone(source, cache_dir)
32
45
  r = subprocess.run(
33
46
  ["git", "-C", str(repo), "tag", "-l", f"{addon}/*"],
34
47
  capture_output=True,
35
48
  text=True,
36
49
  )
37
- return [
50
+ versions = [
38
51
  t.split("/", 1)[1]
39
52
  for t in r.stdout.split()
40
53
  if t.startswith(f"{addon}/") and "/" in t
41
54
  ]
55
+ if series is not None:
56
+ versions = [v for v in versions if _series(v) == series]
57
+ return versions
42
58
 
43
59
 
44
60
  def latest_version(
45
- source: str, addon: str, cache_dir: Optional[Path] = None
61
+ source: str,
62
+ addon: str,
63
+ cache_dir: Optional[Path] = None,
64
+ series: Optional[str] = None,
46
65
  ) -> Optional[str]:
47
- versions = list_addon_versions(source, addon, cache_dir)
66
+ versions = list_addon_versions(source, addon, cache_dir, series)
48
67
  return max(versions, key=_version_key) if versions else None
49
68
 
50
69
 
@@ -65,8 +84,8 @@ def find_updates(
65
84
  for name, e in lock.entries.items():
66
85
  if only is not None and name not in only:
67
86
  continue
68
- if e.version: # version-tracked
69
- latest = latest_version(e.source, name, cache_dir)
87
+ if e.version: # version-tracked (within the pin's own major series)
88
+ latest = latest_version(e.source, name, cache_dir, series=_series(e.version))
70
89
  if latest and _version_key(latest) > _version_key(e.version):
71
90
  updates.append(
72
91
  {