odoo-dev 1.2.1__tar.gz → 1.2.3__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 (65) hide show
  1. odoo_dev-1.2.3/.release-please-manifest.json +3 -0
  2. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/CHANGELOG.md +17 -0
  3. odoo_dev-1.2.1/README.md → odoo_dev-1.2.3/PKG-INFO +28 -2
  4. odoo_dev-1.2.1/PKG-INFO → odoo_dev-1.2.3/README.md +14 -16
  5. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/pyproject.toml +4 -4
  6. odoo_dev-1.2.3/src/odoo_dev/__init__.py +3 -0
  7. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/commands/vendor.py +32 -3
  8. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/vendor/materialize.py +15 -0
  9. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/vendor/migrate.py +119 -8
  10. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/vendor/verify.py +49 -1
  11. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_vendor_materialize.py +31 -0
  12. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_vendor_migrate.py +85 -2
  13. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_vendor_verify.py +67 -2
  14. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/uv.lock +1 -1
  15. odoo_dev-1.2.1/.release-please-manifest.json +0 -3
  16. odoo_dev-1.2.1/src/odoo_dev/__init__.py +0 -3
  17. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/.github/workflows/release-please.yml +0 -0
  18. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/.gitignore +0 -0
  19. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/.gitmodules +0 -0
  20. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/.python-version +0 -0
  21. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/release-please-config.json +0 -0
  22. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/cli.py +0 -0
  23. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/commands/__init__.py +0 -0
  24. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/commands/bump.py +0 -0
  25. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/commands/db.py +0 -0
  26. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/commands/docker.py +0 -0
  27. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/commands/run.py +0 -0
  28. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/commands/setup.py +0 -0
  29. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/config.py +0 -0
  30. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/preflight.py +0 -0
  31. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/templates/__init__.py +0 -0
  32. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/templates/docker/Dockerfile +0 -0
  33. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/templates/docker/__init__.py +0 -0
  34. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/templates/docker/docker-entrypoint.sh +0 -0
  35. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/templates/vscode/__init__.py +0 -0
  36. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/templates/vscode/launch.json +0 -0
  37. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/templates/vscode/settings.json +0 -0
  38. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/templates/vscode/tasks.json +0 -0
  39. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/utils/__init__.py +0 -0
  40. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/utils/console.py +0 -0
  41. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/utils/manifest.py +0 -0
  42. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/vendor/__init__.py +0 -0
  43. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/vendor/develop.py +0 -0
  44. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/vendor/edit.py +0 -0
  45. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/vendor/lock.py +0 -0
  46. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/vendor/sources.py +0 -0
  47. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/vendor/sync.py +0 -0
  48. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/src/odoo_dev/vendor/update.py +0 -0
  49. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/__init__.py +0 -0
  50. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/fixtures/__init__.py +0 -0
  51. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_bump.py +0 -0
  52. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_cli.py +0 -0
  53. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_config.py +0 -0
  54. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_db.py +0 -0
  55. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_db_config.py +0 -0
  56. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_drop_database.py +0 -0
  57. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_integration.py +0 -0
  58. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_preflight.py +0 -0
  59. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_run.py +0 -0
  60. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_setup.py +0 -0
  61. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_setup_db.py +0 -0
  62. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_vendor_develop.py +0 -0
  63. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_vendor_edit.py +0 -0
  64. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_vendor_lock.py +0 -0
  65. {odoo_dev-1.2.1 → odoo_dev-1.2.3}/tests/test_vendor_update.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.2.3"
3
+ }
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.3](https://github.com/bemade/odoo-dev/compare/v1.2.2...v1.2.3) (2026-09-02)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * point project URLs at the GitHub source, not the retired GitLab ([61d7348](https://github.com/bemade/odoo-dev/commit/61d734858e50fb8080b508959b9566b4b3134ba0))
9
+
10
+ ## [1.2.2](https://github.com/bemade/odoo-dev/compare/v1.2.1...v1.2.2) (2026-08-14)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **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)
16
+ * **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)
17
+ * **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)
18
+ * **vendor:** three failure modes found in vendoring + CI cycles ([051715a](https://github.com/bemade/odoo-dev/commit/051715a10a17163ebe79cd2fe05a7bbe42328511))
19
+
3
20
  ## [1.2.1](https://github.com/bemade/odoo-dev/compare/v1.2.0...v1.2.1) (2026-07-14)
4
21
 
5
22
 
@@ -1,3 +1,17 @@
1
+ Metadata-Version: 2.5
2
+ Name: odoo-dev
3
+ Version: 1.2.3
4
+ Summary: Odoo Development Environment Helper
5
+ Project-URL: Homepage, https://github.com/bemade/odoo-dev
6
+ Project-URL: Repository, https://github.com/bemade/odoo-dev
7
+ Project-URL: Issues, https://github.com/bemade/odoo-dev/issues
8
+ License-Expression: LGPL-3.0
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: pyyaml>=6.0
11
+ Requires-Dist: rich>=13.0.0
12
+ Requires-Dist: typer>=0.9.0
13
+ Description-Content-Type: text/markdown
14
+
1
15
  # odoo-dev
2
16
 
3
17
  A CLI tool for managing Odoo development environments. Handles local Python setup, Docker containers, database operations, and more.
@@ -161,8 +175,20 @@ something other than the current pin.
161
175
 
162
176
  `vendor check` verifies, per addon: the vendored files byte-match the pinned
163
177
  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/`.
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.
166
192
 
167
193
  `vendor update` is the **pull** side: each client owns its pins. For every addon
168
194
  that tracks upstream — a `version` (bump to the newest `<addon>/<version>` tag) or
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: odoo-dev
3
- Version: 1.2.1
4
- Summary: Odoo Development Environment Helper
5
- Project-URL: Homepage, https://git.bemade.org/bemade/odoo-dev
6
- Project-URL: Repository, https://git.bemade.org/bemade/odoo-dev
7
- Project-URL: Issues, https://git.bemade.org/bemade/odoo-dev/-/issues
8
- License-Expression: LGPL-3.0
9
- Requires-Python: >=3.12
10
- Requires-Dist: pyyaml>=6.0
11
- Requires-Dist: rich>=13.0.0
12
- Requires-Dist: typer>=0.9.0
13
- Description-Content-Type: text/markdown
14
-
15
1
  # odoo-dev
16
2
 
17
3
  A CLI tool for managing Odoo development environments. Handles local Python setup, Docker containers, database operations, and more.
@@ -175,8 +161,20 @@ something other than the current pin.
175
161
 
176
162
  `vendor check` verifies, per addon: the vendored files byte-match the pinned
177
163
  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/`.
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.
180
178
 
181
179
  `vendor update` is the **pull** side: each client owns its pins. For every addon
182
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.1"
3
+ version = "1.2.3"
4
4
  description = "Odoo Development Environment Helper"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -12,9 +12,9 @@ dependencies = [
12
12
  license = "LGPL-3.0"
13
13
 
14
14
  [project.urls]
15
- Homepage = "https://git.bemade.org/bemade/odoo-dev"
16
- Repository = "https://git.bemade.org/bemade/odoo-dev"
17
- Issues = "https://git.bemade.org/bemade/odoo-dev/-/issues"
15
+ Homepage = "https://github.com/bemade/odoo-dev"
16
+ Repository = "https://github.com/bemade/odoo-dev"
17
+ Issues = "https://github.com/bemade/odoo-dev/issues"
18
18
 
19
19
  [project.scripts]
20
20
  odoo-dev = "odoo_dev.cli:app"
@@ -0,0 +1,3 @@
1
+ """Odoo Development Environment Helper."""
2
+
3
+ __version__ = "1.2.3" # x-release-please-version
@@ -14,7 +14,7 @@ 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,
@@ -24,7 +24,12 @@ from odoo_dev.vendor.develop import (
24
24
  )
25
25
  from odoo_dev.vendor.edit import EditError, add_addon, bump_addon
26
26
  from odoo_dev.vendor.lock import Lockfile
27
- 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
+ )
28
33
  from odoo_dev.vendor.sync import sync_addons
29
34
  from odoo_dev.vendor.update import apply_update, find_updates
30
35
  from odoo_dev.vendor.verify import verify
@@ -147,8 +152,23 @@ def migrate_cmd(
147
152
  ) -> None:
148
153
  """Convert addons/ symlinks into .repos submodules to vendored/ + addons.lock."""
149
154
  cfg = load_config()
150
- if dry_run:
155
+ try:
151
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:
152
172
  if not plans:
153
173
  info("Nothing to migrate (no symlinked submodule addons found).")
154
174
  return
@@ -168,6 +188,15 @@ def migrate_cmd(
168
188
  if cfg.config_file.exists() and cfg.vendored_dir.is_dir():
169
189
  if ensure_addons_path(cfg.config_file, str(cfg.vendored_dir)):
170
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
+ )
171
200
  if unused:
172
201
  info("")
173
202
  if deinit:
@@ -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,7 +1,7 @@
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,
4
+ derives a per-addon pin (submodule remote URL = source, recorded gitlink = commit,
5
5
  manifest version -> version ONLY when the ``<addon>/<version>`` tag resolves to
6
6
  the pinned commit; a commit that isn't the tagged release (a pin behind or ahead
7
7
  of the tag, or any intermediate commit) is left commit-only, no version),
@@ -23,12 +23,45 @@ from odoo_dev.utils.manifest import manifest_path, read_version
23
23
  from odoo_dev.vendor.lock import LockEntry, Lockfile
24
24
  from odoo_dev.vendor.sources import tag_resolves_to
25
25
  from odoo_dev.vendor.sync import sync_addons
26
+ from odoo_dev.vendor.verify import gitignored_under_vendored
26
27
 
27
28
 
28
29
  class MigrateError(Exception):
29
30
  pass
30
31
 
31
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
+
32
65
  def read_gitmodules(project_dir: Path) -> list:
33
66
  """Return [(name, path, url)] for each submodule declared in .gitmodules."""
34
67
  gm = Path(project_dir) / ".gitmodules"
@@ -57,19 +90,82 @@ def read_gitmodules(project_dir: Path) -> list:
57
90
  ]
58
91
 
59
92
 
60
- def _submodule_head(project_dir: Path, sub_path: str) -> str:
61
- return subprocess.run(
62
- ["git", "-C", str(Path(project_dir) / sub_path), "rev-parse", "HEAD"],
63
- 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],
64
105
  capture_output=True,
65
106
  text=True,
66
- ).stdout.strip()
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"],
136
+ capture_output=True,
137
+ text=True,
138
+ )
139
+ return res.stdout.strip() if res.returncode == 0 else None
140
+
141
+
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,
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
67
157
 
68
158
 
69
159
  def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) -> list:
70
160
  """Return a list of planned pins without changing anything.
71
161
 
72
- 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.
73
169
  """
74
170
  project_dir = Path(project_dir)
75
171
  addons_dir = project_dir / "addons"
@@ -77,6 +173,8 @@ def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) ->
77
173
  return []
78
174
  subs = read_gitmodules(project_dir)
79
175
  only = set(addons) if addons is not None else None
176
+ uninit = set(uninitialized_submodules(project_dir))
177
+ blocked = []
80
178
 
81
179
  plans = []
82
180
  for link in sorted(addons_dir.iterdir()):
@@ -99,6 +197,9 @@ def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) ->
99
197
  if sub is None:
100
198
  continue # symlink not into a known submodule; skip
101
199
  s_name, s_path, s_url = sub
200
+ if s_path in uninit:
201
+ blocked.append((name, s_path))
202
+ continue
102
203
  sub_root = (project_dir / s_path).resolve()
103
204
  subpath = real.relative_to(sub_root)
104
205
  if str(subpath) != name:
@@ -106,7 +207,7 @@ def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) ->
106
207
  f"{name}: addon sits at '{subpath}' inside submodule {s_path}, not at "
107
208
  f"the root as '{name}'. Nested addons aren't handled yet — migrate by hand."
108
209
  )
109
- commit = _submodule_head(project_dir, s_path)
210
+ commit = _gitlink_commit(project_dir, s_path)
110
211
  version = None
111
212
  mf = manifest_path(real)
112
213
  if mf is not None:
@@ -125,10 +226,20 @@ def plan_migration(project_dir: Path, addons: Optional[Iterable[str]] = None) ->
125
226
  source=s_url,
126
227
  commit=commit,
127
228
  version=version,
229
+ checkout=_submodule_checkout(project_dir, s_path),
128
230
  symlink=link,
129
231
  sub_path=s_path,
130
232
  )
131
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
+ )
132
243
  return plans
133
244
 
134
245
 
@@ -6,7 +6,8 @@ Assertions (any failure => non-empty problem list => CI red):
6
6
  3. moved-tag tripwire (when the entry carries a ``version`` tag);
7
7
  4. external python deps of vendored manifests are present in ``requirements.txt``;
8
8
  5. no addon name in both ``addons/`` and ``vendored/`` (double-load);
9
- 6. (``allow_hybrid=False`` only) no ``addons/`` symlink still points into a
9
+ 6. no file under ``vendored/`` is gitignored (it would be dropped at commit);
10
+ 7. (``allow_hybrid=False`` only) no ``addons/`` symlink still points into a
10
11
  ``.repos/`` submodule — i.e. the repo is fully vendored, not a hybrid.
11
12
  The gate writes nothing.
12
13
  """
@@ -16,6 +17,7 @@ from __future__ import annotations
16
17
  import ast
17
18
  import os
18
19
  import re
20
+ import subprocess
19
21
  import tempfile
20
22
  from pathlib import Path
21
23
  from typing import Optional
@@ -108,6 +110,45 @@ def hybrid_submodule_addons(project_dir: Path) -> list:
108
110
  return hybrids
109
111
 
110
112
 
113
+ def gitignored_under_vendored(project_dir: Path) -> list:
114
+ """Paths under ``vendored/`` that git would silently refuse to add.
115
+
116
+ Assertions 1-2 compare the pinned subtree against the WORKING TREE, which
117
+ ``sync``/``migrate`` just materialized in full — so they always match, and are
118
+ blind to a repo-wide ignore rule (``node_modules``, ``package.json``, …) that
119
+ is harmless while shared addons live in submodules but starts applying the
120
+ moment they become real files here. ``git add`` then skips those files without
121
+ a word and the committed tree is incomplete: green locally, red only once CI
122
+ checks out a fresh copy, with an error that reads like an upstream problem.
123
+
124
+ ``check-ignore`` (not a tracked-vs-on-disk comparison) is the right probe: it
125
+ reports exactly the paths git would drop, and consults the index, so neither a
126
+ not-yet-staged ``vendor add`` nor a deliberate ``git add -f`` is a false alarm.
127
+ """
128
+ project_dir = Path(project_dir)
129
+ vendored = project_dir / "vendored"
130
+ if not vendored.is_dir() or not (project_dir / ".git").exists():
131
+ return []
132
+ rels = [
133
+ str(p.relative_to(project_dir))
134
+ for p in sorted(vendored.rglob("*"))
135
+ if p.is_symlink() or p.is_file()
136
+ ]
137
+ if not rels:
138
+ return []
139
+ res = subprocess.run(
140
+ ["git", "-C", str(project_dir), "check-ignore", "--stdin"],
141
+ input="\n".join(rels) + "\n",
142
+ capture_output=True,
143
+ text=True,
144
+ )
145
+ # 0 = at least one path is ignored, 1 = none are; anything else is an error
146
+ # (no git, not a work tree) and must not fail the gate on its own.
147
+ if res.returncode not in (0, 1):
148
+ return []
149
+ return [line for line in res.stdout.splitlines() if line.strip()]
150
+
151
+
111
152
  def verify(
112
153
  project_dir: Path,
113
154
  lock: Lockfile,
@@ -179,6 +220,13 @@ def verify(
179
220
  f"{name}: present in BOTH addons/ and vendored/ (double-load)"
180
221
  )
181
222
 
223
+ for rel in gitignored_under_vendored(project_dir):
224
+ problems.append(
225
+ f"{rel}: under vendored/ but ignored by .gitignore — git will drop it "
226
+ f"from the commit and the pushed tree will not match addons.lock "
227
+ f"(add a '!vendored/**' negation, keep it last)"
228
+ )
229
+
182
230
  if not allow_hybrid:
183
231
  for name in hybrid_submodule_addons(project_dir):
184
232
  problems.append(
@@ -137,6 +137,37 @@ def test_tree_diff_detects_missing_and_extra_files(tmp_path):
137
137
  assert any("thing.py" in d for d in diffs)
138
138
 
139
139
 
140
+ def test_tree_diff_ignores_bytecode_artifacts(tmp_path):
141
+ """Running the addon compiles it; ``__pycache__`` is not part of the pin.
142
+
143
+ The .pyc are gitignored and absent from a fresh CI checkout, so treating them
144
+ as "extra" made ``vendor check`` fail locally right after ``odoo-dev test``.
145
+ """
146
+ repo, sha = _make_source_repo(tmp_path)
147
+ a = tmp_path / "a"
148
+ b = tmp_path / "b"
149
+ extract_subtree(repo, sha, "my_addon", a)
150
+ extract_subtree(repo, sha, "my_addon", b)
151
+ cache = b / "models" / "__pycache__"
152
+ cache.mkdir()
153
+ (cache / "thing.cpython-312.pyc").write_bytes(b"\x00compiled")
154
+ (b / "models" / "thing.pyo").write_bytes(b"\x00compiled")
155
+ assert tree_diff(a, b) == []
156
+
157
+
158
+ def test_tree_diff_still_detects_real_change_beside_bytecode(tmp_path):
159
+ """Ignoring bytecode must not mask a genuine drift in the same directory."""
160
+ repo, sha = _make_source_repo(tmp_path)
161
+ a = tmp_path / "a"
162
+ b = tmp_path / "b"
163
+ extract_subtree(repo, sha, "my_addon", a)
164
+ extract_subtree(repo, sha, "my_addon", b)
165
+ (b / "models" / "__pycache__").mkdir()
166
+ (b / "models" / "__pycache__" / "thing.cpython-312.pyc").write_bytes(b"\x00")
167
+ (b / "models" / "thing.py").write_text("x = 2\n")
168
+ assert any("thing.py" in d for d in tree_diff(a, b))
169
+
170
+
140
171
  def test_extract_bad_commit_raises(tmp_path):
141
172
  repo, _ = _make_source_repo(tmp_path)
142
173
  with pytest.raises(Exception):
@@ -6,9 +6,14 @@ from pathlib import Path
6
6
 
7
7
  import pytest
8
8
 
9
- from odoo_dev.vendor.migrate import migrate_repo, plan_migration, MigrateError
9
+ from odoo_dev.vendor.migrate import (
10
+ MigrateError,
11
+ ensure_vendored_not_ignored,
12
+ migrate_repo,
13
+ plan_migration,
14
+ )
10
15
  from odoo_dev.vendor.lock import Lockfile
11
- from odoo_dev.vendor.verify import verify
16
+ from odoo_dev.vendor.verify import gitignored_under_vendored, verify
12
17
 
13
18
 
14
19
  def _git(repo: Path, *args: str) -> str:
@@ -171,3 +176,81 @@ def test_migrate_is_idempotent(tmp_path):
171
176
  # second run: symlink already gone -> nothing to plan, no error
172
177
  lock, unused = migrate_repo(client, cache_dir=tmp_path / "cache")
173
178
  assert unused == []
179
+
180
+
181
+ # --- pins come from the recorded gitlink, never the submodule working copy -----
182
+
183
+
184
+ def test_plan_hard_fails_on_uninitialized_submodule(tmp_path):
185
+ """Regression: an uninitialized submodule is an EMPTY DIR, so
186
+ ``git -C <sub> rev-parse HEAD`` walks up to the superproject, returns ITS head
187
+ and exits 0. Every addon across every source repo got pinned to that one sha,
188
+ and the lock looked perfectly valid.
189
+ """
190
+ source, sha = _source_repo(tmp_path)
191
+ client = _client_repo(tmp_path, source)
192
+ _git(client, "submodule", "deinit", "-f", ".repos/sale-workflow")
193
+ (client / ".repos" / "sale-workflow").mkdir(exist_ok=True)
194
+ superproject_head = _git(client, "rev-parse", "HEAD")
195
+
196
+ with pytest.raises(MigrateError) as exc:
197
+ plan_migration(client)
198
+ assert ".repos/sale-workflow" in str(exc.value)
199
+ assert "submodule update --init" in str(exc.value)
200
+ # and the sha that WOULD have been written is not the superproject's
201
+ assert sha != superproject_head
202
+
203
+
204
+ def test_plan_pins_recorded_gitlink_not_local_checkout(tmp_path):
205
+ """A submodule checked out somewhere other than the recorded gitlink (a common
206
+ state while debugging a shared addon) must not silently redefine the pin: the
207
+ lock has to describe the branch being migrated.
208
+ """
209
+ source, first = _source_repo(tmp_path)
210
+ client = _client_repo(tmp_path, source) # gitlink records `first`
211
+ # move the source forward and check the submodule out there, without
212
+ # committing the gitlink bump in the superproject
213
+ (source / "shared_addon" / "models" / "m.py").write_text("z = 99\n")
214
+ _git(source, "add", "-A")
215
+ _git(source, "commit", "-qm", "local debugging")
216
+ later = _git(source, "rev-parse", "HEAD")
217
+ sub = client / ".repos" / "sale-workflow"
218
+ _git(sub, "fetch", "-q", "origin")
219
+ _git(sub, "checkout", "-q", later)
220
+
221
+ plans = plan_migration(client)
222
+ assert plans[0]["commit"] == first # the gitlink, not the checkout
223
+ assert plans[0]["checkout"] == later # surfaced so the CLI can warn
224
+
225
+
226
+ # --- migrate repairs the .gitignore it just made dangerous --------------------
227
+
228
+
229
+ def _vendored_project(tmp_path: Path, gitignore: str) -> Path:
230
+ proj = tmp_path / "proj"
231
+ (proj / "vendored" / "shared_addon" / "static").mkdir(parents=True)
232
+ (proj / "vendored" / "shared_addon" / "static" / "package.json").write_text("{}\n")
233
+ _git(proj, "init", "-q")
234
+ (proj / ".gitignore").write_text(gitignore)
235
+ return proj
236
+
237
+
238
+ def test_ensure_vendored_not_ignored_appends_negation(tmp_path):
239
+ proj = _vendored_project(tmp_path, "node_modules\npackage.json\n")
240
+ assert gitignored_under_vendored(proj) # the trap is armed
241
+
242
+ assert ensure_vendored_not_ignored(proj) is True
243
+ assert "!vendored/**" in (proj / ".gitignore").read_text()
244
+ assert gitignored_under_vendored(proj) == [] # and disarmed
245
+
246
+
247
+ def test_ensure_vendored_not_ignored_is_idempotent(tmp_path):
248
+ proj = _vendored_project(tmp_path, "node_modules\npackage.json\n")
249
+ assert ensure_vendored_not_ignored(proj) is True
250
+ assert ensure_vendored_not_ignored(proj) is False
251
+
252
+
253
+ def test_ensure_vendored_not_ignored_leaves_a_clean_repo_alone(tmp_path):
254
+ proj = _vendored_project(tmp_path, "*.pyc\n")
255
+ assert ensure_vendored_not_ignored(proj) is False
256
+ assert "!vendored/**" not in (proj / ".gitignore").read_text()
@@ -17,9 +17,17 @@ def _git(repo: Path, *args: str) -> str:
17
17
 
18
18
 
19
19
  def _source_repo(
20
- tmp_path: Path, *, python_dep: str | None = None, tag: str | None = None
20
+ tmp_path: Path,
21
+ *,
22
+ python_dep: str | None = None,
23
+ tag: str | None = None,
24
+ extra: dict | None = None,
21
25
  ):
22
- """A source repo with addon ``shared_addon``; optional python dep + version tag."""
26
+ """A source repo with addon ``shared_addon``; optional python dep + version tag.
27
+
28
+ ``extra`` maps addon-relative paths to contents, for files whose NAME is what
29
+ matters (e.g. a bundled ``package.json`` a repo-wide ignore rule would eat).
30
+ """
23
31
  repo = tmp_path / "src"
24
32
  (repo / "shared_addon").mkdir(parents=True)
25
33
  manifest = {"name": "shared_addon", "version": "18.0.1.0.0"}
@@ -27,6 +35,10 @@ def _source_repo(
27
35
  manifest["external_dependencies"] = {"python": [python_dep]}
28
36
  (repo / "shared_addon" / "__manifest__.py").write_text(repr(manifest) + "\n")
29
37
  (repo / "shared_addon" / "models.py").write_text("y = 2\n")
38
+ for rel, content in (extra or {}).items():
39
+ p = repo / "shared_addon" / rel
40
+ p.parent.mkdir(parents=True, exist_ok=True)
41
+ p.write_text(content)
30
42
  _git(repo, "init", "-q")
31
43
  _git(repo, "config", "user.email", "t@t")
32
44
  _git(repo, "config", "user.name", "t")
@@ -171,6 +183,59 @@ def test_verify_strict_green_on_fully_vendored_repo(tmp_path):
171
183
  assert verify(proj, lock, cache_dir=tmp_path / "cache", allow_hybrid=False) == []
172
184
 
173
185
 
186
+ # --- .gitignore silently stripping files out of vendored/ ---------------------
187
+
188
+
189
+ def _git_project(tmp_path: Path, entry: LockEntry, gitignore: str) -> tuple:
190
+ proj, lock = _project(tmp_path, entry)
191
+ _git(proj, "init", "-q")
192
+ _git(proj, "config", "user.email", "t@t")
193
+ _git(proj, "config", "user.name", "t")
194
+ (proj / ".gitignore").write_text(gitignore)
195
+ return proj, lock
196
+
197
+
198
+ def test_verify_red_when_gitignore_would_drop_vendored_files(tmp_path):
199
+ """Regression: repo-wide js-tooling ignores are harmless while shared addons
200
+ live in submodules, but start applying the moment they become real files under
201
+ vendored/. ``git add`` then skips them silently and the committed tree is
202
+ incomplete — green locally, red only in CI, with an error that reads like an
203
+ upstream problem.
204
+ """
205
+ repo, sha = _source_repo(
206
+ tmp_path, extra={"static/src/lib/vendorlib/package.json": "{}\n"}
207
+ )
208
+ proj, lock = _git_project(
209
+ tmp_path,
210
+ LockEntry("shared_addon", str(repo), sha),
211
+ "node_modules\npackage.json\n",
212
+ )
213
+ sync_addons(proj, lock, cache_dir=tmp_path / "cache")
214
+ problems = verify(proj, lock, cache_dir=tmp_path / "cache")
215
+ assert any("package.json" in p and ".gitignore" in p for p in problems)
216
+
217
+
218
+ def test_verify_green_when_vendored_negation_present(tmp_path):
219
+ repo, sha = _source_repo(
220
+ tmp_path, extra={"static/src/lib/vendorlib/package.json": "{}\n"}
221
+ )
222
+ proj, lock = _git_project(
223
+ tmp_path,
224
+ LockEntry("shared_addon", str(repo), sha),
225
+ "node_modules\npackage.json\n!vendored/**\n",
226
+ )
227
+ sync_addons(proj, lock, cache_dir=tmp_path / "cache")
228
+ assert verify(proj, lock, cache_dir=tmp_path / "cache") == []
229
+
230
+
231
+ def test_verify_gitignore_check_is_silent_outside_a_git_repo(tmp_path):
232
+ """A plain (non-git) project dir must not trip the new assertion."""
233
+ repo, sha = _source_repo(tmp_path, extra={"package.json": "{}\n"})
234
+ proj, lock = _project(tmp_path, LockEntry("shared_addon", str(repo), sha))
235
+ sync_addons(proj, lock, cache_dir=tmp_path / "cache")
236
+ assert verify(proj, lock, cache_dir=tmp_path / "cache") == []
237
+
238
+
174
239
  def test_verify_strict_ignores_non_repos_symlink(tmp_path):
175
240
  proj, lock = _sync_clean(tmp_path)
176
241
  # A symlink that does NOT point into .repos/ is not a hybrid.
@@ -96,7 +96,7 @@ wheels = [
96
96
 
97
97
  [[package]]
98
98
  name = "odoo-dev"
99
- version = "0.3.3"
99
+ version = "1.2.2"
100
100
  source = { editable = "." }
101
101
  dependencies = [
102
102
  { name = "pyyaml" },
@@ -1,3 +0,0 @@
1
- {
2
- ".": "1.2.1"
3
- }
@@ -1,3 +0,0 @@
1
- """Odoo Development Environment Helper."""
2
-
3
- __version__ = "1.2.1" # 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