odoo-dev 1.5.0__tar.gz → 1.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- odoo_dev-1.6.0/.release-please-manifest.json +3 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/CHANGELOG.md +14 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/PKG-INFO +11 -1
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/README.md +10 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/pyproject.toml +1 -1
- odoo_dev-1.6.0/src/odoo_dev/__init__.py +3 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/commands/vendor.py +23 -2
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/vendor/edit.py +9 -1
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/vendor/lock.py +6 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/vendor/sources.py +46 -4
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/vendor/verify.py +21 -1
- odoo_dev-1.6.0/tests/test_vendor_mainline.py +107 -0
- odoo_dev-1.6.0/tests/test_vendor_sources.py +58 -0
- odoo_dev-1.5.0/.release-please-manifest.json +0 -3
- odoo_dev-1.5.0/src/odoo_dev/__init__.py +0 -3
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/.github/workflows/release-please.yml +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/.gitignore +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/.gitmodules +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/.python-version +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/release-please-config.json +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/cli.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/commands/__init__.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/commands/bump.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/commands/db.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/commands/docker.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/commands/git.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/commands/run.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/commands/setup.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/config.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/preflight.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/templates/__init__.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/templates/docker/Dockerfile +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/templates/docker/__init__.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/templates/docker/docker-entrypoint.sh +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/templates/vscode/__init__.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/templates/vscode/launch.json +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/templates/vscode/settings.json +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/templates/vscode/tasks.json +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/utils/__init__.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/utils/console.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/utils/manifest.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/vendor/__init__.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/vendor/develop.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/vendor/materialize.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/vendor/migrate.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/vendor/sync.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/src/odoo_dev/vendor/update.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/__init__.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/fixtures/__init__.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_bump.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_cli.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_config.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_db.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_db_config.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_drop_database.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_drop_filestore.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_git_hygiene.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_integration.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_preflight.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_run.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_setup.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_setup_data_dir.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_setup_db.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_vendor_develop.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_vendor_edit.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_vendor_lock.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_vendor_materialize.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_vendor_migrate.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_vendor_update.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/tests/test_vendor_verify.py +0 -0
- {odoo_dev-1.5.0 → odoo_dev-1.6.0}/uv.lock +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.6.0](https://github.com/bemade/odoo-dev/compare/v1.5.1...v1.6.0) (2026-10-05)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **vendor:** pin upstream feature branches, gate production on merged pins ([#28](https://github.com/bemade/odoo-dev/issues/28)) ([e232daa](https://github.com/bemade/odoo-dev/commit/e232daa1eb0b040445f97046bdc79ef745153298))
|
|
9
|
+
|
|
10
|
+
## [1.5.1](https://github.com/bemade/odoo-dev/compare/v1.5.0...v1.5.1) (2026-10-02)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **vendor:** resolve --branch to the fetched remote tip, not the clone-time local branch ([#26](https://github.com/bemade/odoo-dev/issues/26)) ([af03080](https://github.com/bemade/odoo-dev/commit/af030803ba92ba140c875d728c22994eb9271d39))
|
|
16
|
+
|
|
3
17
|
## [1.5.0](https://github.com/bemade/odoo-dev/compare/v1.4.0...v1.5.0) (2026-10-01)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: odoo-dev
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.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
|
|
@@ -156,6 +156,8 @@ odoo-dev vendor sync # Materialize vendored/ from addons.lock
|
|
|
156
156
|
odoo-dev vendor check # CI gate: verify vendored/ byte-matches the pins
|
|
157
157
|
odoo-dev vendor add fsm --source github.com/bemade/bemade-addons --version 18.0.1.3.2
|
|
158
158
|
odoo-dev vendor bump fsm --version 18.0.1.4.0 # Move a pin and re-materialize
|
|
159
|
+
odoo-dev vendor bump fsm --branch 18.0-fix-x # Track a branch, pin its head
|
|
160
|
+
odoo-dev vendor check --mainline 18.0 # Production gate: pins must be merged upstream
|
|
159
161
|
odoo-dev vendor update # Pull newest upstream for all tracked addons
|
|
160
162
|
odoo-dev vendor update fsm --dry-run # Show what would update, change nothing
|
|
161
163
|
odoo-dev vendor develop fsm # Edit fsm against a live source clone
|
|
@@ -181,6 +183,14 @@ name collides between `addons/` and `vendored/`; and no file under `vendored/` i
|
|
|
181
183
|
gitignored. Python bytecode (`__pycache__/`, `*.pyc`) is excluded from the byte
|
|
182
184
|
comparison, so running the test suite doesn't turn the check red.
|
|
183
185
|
|
|
186
|
+
`vendor check --mainline <branch>` (for merge requests to production) also fails
|
|
187
|
+
any pin whose commit is not in the history of that branch of its source — an
|
|
188
|
+
upstream change still under test on a feature branch. A client can pin a feature
|
|
189
|
+
branch while the change is tested on its staging (`vendor bump <addon> --branch
|
|
190
|
+
<feature>`), but production only takes it once it is merged upstream and
|
|
191
|
+
re-pinned. An entry that is meant to live on a branch (a long-lived fork) opts
|
|
192
|
+
out with `allow_unmerged: true` in `addons.lock`.
|
|
193
|
+
|
|
184
194
|
That last assertion catches a trap specific to migration: repo-wide ignore rules
|
|
185
195
|
(`node_modules`, `package.json`, …) are harmless while shared addons live in
|
|
186
196
|
submodules, but they start applying the moment those addons become real files
|
|
@@ -141,6 +141,8 @@ odoo-dev vendor sync # Materialize vendored/ from addons.lock
|
|
|
141
141
|
odoo-dev vendor check # CI gate: verify vendored/ byte-matches the pins
|
|
142
142
|
odoo-dev vendor add fsm --source github.com/bemade/bemade-addons --version 18.0.1.3.2
|
|
143
143
|
odoo-dev vendor bump fsm --version 18.0.1.4.0 # Move a pin and re-materialize
|
|
144
|
+
odoo-dev vendor bump fsm --branch 18.0-fix-x # Track a branch, pin its head
|
|
145
|
+
odoo-dev vendor check --mainline 18.0 # Production gate: pins must be merged upstream
|
|
144
146
|
odoo-dev vendor update # Pull newest upstream for all tracked addons
|
|
145
147
|
odoo-dev vendor update fsm --dry-run # Show what would update, change nothing
|
|
146
148
|
odoo-dev vendor develop fsm # Edit fsm against a live source clone
|
|
@@ -166,6 +168,14 @@ name collides between `addons/` and `vendored/`; and no file under `vendored/` i
|
|
|
166
168
|
gitignored. Python bytecode (`__pycache__/`, `*.pyc`) is excluded from the byte
|
|
167
169
|
comparison, so running the test suite doesn't turn the check red.
|
|
168
170
|
|
|
171
|
+
`vendor check --mainline <branch>` (for merge requests to production) also fails
|
|
172
|
+
any pin whose commit is not in the history of that branch of its source — an
|
|
173
|
+
upstream change still under test on a feature branch. A client can pin a feature
|
|
174
|
+
branch while the change is tested on its staging (`vendor bump <addon> --branch
|
|
175
|
+
<feature>`), but production only takes it once it is merged upstream and
|
|
176
|
+
re-pinned. An entry that is meant to live on a branch (a long-lived fork) opts
|
|
177
|
+
out with `allow_unmerged: true` in `addons.lock`.
|
|
178
|
+
|
|
169
179
|
That last assertion catches a trap specific to migration: repo-wide ignore rules
|
|
170
180
|
(`node_modules`, `package.json`, …) are harmless while shared addons live in
|
|
171
181
|
submodules, but they start applying the moment those addons become real files
|
|
@@ -70,11 +70,23 @@ def check_cmd(
|
|
|
70
70
|
".repos/). Use in CI for repos that declare themselves fully vendored.",
|
|
71
71
|
),
|
|
72
72
|
] = False,
|
|
73
|
+
mainline: Annotated[
|
|
74
|
+
Optional[str],
|
|
75
|
+
typer.Option(
|
|
76
|
+
"--mainline",
|
|
77
|
+
help="Also fail any pin whose commit is not on this branch of its "
|
|
78
|
+
"source (e.g. 19.0): an upstream change still on a feature branch. "
|
|
79
|
+
"Use in CI on merge requests to production. Entries marked "
|
|
80
|
+
"'allow_unmerged: true' are exempt.",
|
|
81
|
+
),
|
|
82
|
+
] = None,
|
|
73
83
|
) -> None:
|
|
74
84
|
"""Verify vendored/ matches addons.lock exactly (the CI gate). Exit 1 on any problem."""
|
|
75
85
|
cfg = load_config()
|
|
76
86
|
lock = Lockfile.load(cfg.lockfile_path)
|
|
77
|
-
problems = verify(
|
|
87
|
+
problems = verify(
|
|
88
|
+
cfg.project_dir, lock, allow_hybrid=not no_hybrid, mainline=mainline
|
|
89
|
+
)
|
|
78
90
|
if problems:
|
|
79
91
|
for p in problems:
|
|
80
92
|
error(p)
|
|
@@ -124,11 +136,20 @@ def bump_cmd(
|
|
|
124
136
|
commit: Annotated[
|
|
125
137
|
Optional[str], typer.Option("--commit", help="Bump to an explicit commit/ref.")
|
|
126
138
|
] = None,
|
|
139
|
+
branch: Annotated[
|
|
140
|
+
Optional[str],
|
|
141
|
+
typer.Option(
|
|
142
|
+
"--branch",
|
|
143
|
+
help="Track this branch from now on (e.g. an upstream feature branch "
|
|
144
|
+
"under test, or the mainline once merged); without --version/--commit "
|
|
145
|
+
"the pin moves to its head.",
|
|
146
|
+
),
|
|
147
|
+
] = None,
|
|
127
148
|
) -> None:
|
|
128
149
|
"""Move an addon's pin to a new version/commit (or its tracked branch HEAD) and re-sync."""
|
|
129
150
|
cfg = load_config()
|
|
130
151
|
try:
|
|
131
|
-
entry = bump_addon(cfg.project_dir, name, version, commit)
|
|
152
|
+
entry = bump_addon(cfg.project_dir, name, version, commit, branch=branch)
|
|
132
153
|
except EditError as exc:
|
|
133
154
|
error(str(exc))
|
|
134
155
|
raise typer.Exit(2)
|
|
@@ -67,13 +67,21 @@ def bump_addon(
|
|
|
67
67
|
version: Optional[str] = None,
|
|
68
68
|
commit: Optional[str] = None,
|
|
69
69
|
cache_dir: Optional[Path] = None,
|
|
70
|
+
branch: Optional[str] = None,
|
|
70
71
|
) -> LockEntry:
|
|
71
|
-
"""Move an existing addon's pin to a new version/commit (or its branch HEAD).
|
|
72
|
+
"""Move an existing addon's pin to a new version/commit (or its branch HEAD).
|
|
73
|
+
|
|
74
|
+
``branch`` switches the branch the entry tracks (e.g. an upstream feature
|
|
75
|
+
branch under test, or back to the mainline once it merged); without a
|
|
76
|
+
version/commit the pin moves to that branch's head.
|
|
77
|
+
"""
|
|
72
78
|
project_dir = Path(project_dir)
|
|
73
79
|
lock = Lockfile.load(project_dir / "addons.lock")
|
|
74
80
|
if name not in lock.entries:
|
|
75
81
|
raise EditError(f"{name}: not in addons.lock (use 'vendor add')")
|
|
76
82
|
entry = lock.entries[name]
|
|
83
|
+
if branch:
|
|
84
|
+
entry.branch = branch
|
|
77
85
|
sha = _resolve_pin(name, entry.source, version, commit, entry.branch, cache_dir)
|
|
78
86
|
entry.commit = sha
|
|
79
87
|
# ``version`` is the tag-intent metadata, and ``vendor check``'s moved-tag
|
|
@@ -31,6 +31,9 @@ class LockEntry:
|
|
|
31
31
|
commit: str
|
|
32
32
|
version: Optional[str] = None
|
|
33
33
|
branch: Optional[str] = None
|
|
34
|
+
# An intentional pin outside the source's mainline (a long-lived fork or
|
|
35
|
+
# feature branch): exempt from ``vendor check --mainline``.
|
|
36
|
+
allow_unmerged: bool = False
|
|
34
37
|
|
|
35
38
|
def to_dict(self) -> dict:
|
|
36
39
|
d: dict = {"source": self.source}
|
|
@@ -39,6 +42,8 @@ class LockEntry:
|
|
|
39
42
|
if self.branch is not None:
|
|
40
43
|
d["branch"] = self.branch
|
|
41
44
|
d["commit"] = self.commit
|
|
45
|
+
if self.allow_unmerged:
|
|
46
|
+
d["allow_unmerged"] = True
|
|
42
47
|
return d
|
|
43
48
|
|
|
44
49
|
@classmethod
|
|
@@ -59,6 +64,7 @@ class LockEntry:
|
|
|
59
64
|
commit=str(commit),
|
|
60
65
|
version=(str(data["version"]) if data.get("version") is not None else None),
|
|
61
66
|
branch=(str(data["branch"]) if data.get("branch") is not None else None),
|
|
67
|
+
allow_unmerged=bool(data.get("allow_unmerged", False)),
|
|
62
68
|
)
|
|
63
69
|
|
|
64
70
|
|
|
@@ -41,11 +41,15 @@ def _has_commit(repo: Path, commit: str) -> bool:
|
|
|
41
41
|
)
|
|
42
42
|
|
|
43
43
|
|
|
44
|
+
def _is_local(source: str) -> bool:
|
|
45
|
+
local = Path(source).expanduser()
|
|
46
|
+
return local.is_dir() and (local / ".git").exists()
|
|
47
|
+
|
|
48
|
+
|
|
44
49
|
def _ensure_clone(source: str, cache_dir: Path | None = None) -> Path:
|
|
45
50
|
"""Return a local repo path for ``source`` (clone into cache if remote), fetched."""
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return local
|
|
51
|
+
if _is_local(source):
|
|
52
|
+
return Path(source).expanduser()
|
|
49
53
|
cache_dir = cache_dir or default_cache_dir()
|
|
50
54
|
repo = cache_dir / _cache_key(source)
|
|
51
55
|
if not repo.exists():
|
|
@@ -67,7 +71,14 @@ def _ensure_clone(source: str, cache_dir: Path | None = None) -> Path:
|
|
|
67
71
|
def resolve_commit(source: str, ref: str, cache_dir: Path | None = None) -> str:
|
|
68
72
|
"""Resolve a ref (tag, branch, or sha) in ``source`` to a full commit sha."""
|
|
69
73
|
repo = _ensure_clone(source, cache_dir)
|
|
70
|
-
|
|
74
|
+
# A cached clone's local branches are frozen at clone time; fetch only moves
|
|
75
|
+
# origin/*, so the remote-tracking ref must win there. A local source is the
|
|
76
|
+
# user's own repo, where the local branch is the truth.
|
|
77
|
+
if _is_local(source):
|
|
78
|
+
candidates = (ref, f"origin/{ref}", f"refs/tags/{ref}")
|
|
79
|
+
else:
|
|
80
|
+
candidates = (f"origin/{ref}", ref, f"refs/tags/{ref}")
|
|
81
|
+
for candidate in candidates:
|
|
71
82
|
res = subprocess.run(
|
|
72
83
|
[
|
|
73
84
|
"git",
|
|
@@ -126,6 +137,37 @@ def get_source_at(
|
|
|
126
137
|
return repo
|
|
127
138
|
|
|
128
139
|
|
|
140
|
+
def commit_on_branch(repo: Path, source: str, commit: str, branch: str) -> bool:
|
|
141
|
+
"""True if ``commit`` is in the history of ``branch`` of ``source``.
|
|
142
|
+
|
|
143
|
+
A remote source's cache is fetched for ``branch`` first, so a merge made
|
|
144
|
+
upstream after the cache was filled is seen. Raises RuntimeError when the
|
|
145
|
+
branch does not exist.
|
|
146
|
+
"""
|
|
147
|
+
if not _is_local(source):
|
|
148
|
+
subprocess.run(
|
|
149
|
+
["git", "-C", str(repo), "fetch", "origin",
|
|
150
|
+
f"+refs/heads/{branch}:refs/remotes/origin/{branch}"],
|
|
151
|
+
capture_output=True,
|
|
152
|
+
text=True,
|
|
153
|
+
)
|
|
154
|
+
for ref in (f"refs/remotes/origin/{branch}", f"refs/heads/{branch}"):
|
|
155
|
+
known = subprocess.run(
|
|
156
|
+
["git", "-C", str(repo), "rev-parse", "--verify", "-q", ref],
|
|
157
|
+
capture_output=True,
|
|
158
|
+
text=True,
|
|
159
|
+
)
|
|
160
|
+
if known.returncode == 0:
|
|
161
|
+
return (
|
|
162
|
+
subprocess.run(
|
|
163
|
+
["git", "-C", str(repo), "merge-base", "--is-ancestor", commit, ref],
|
|
164
|
+
capture_output=True,
|
|
165
|
+
).returncode
|
|
166
|
+
== 0
|
|
167
|
+
)
|
|
168
|
+
raise RuntimeError(f"{source}: no branch {branch!r}")
|
|
169
|
+
|
|
170
|
+
|
|
129
171
|
def tag_resolves_to(repo: Path, tag: str, commit: str) -> bool:
|
|
130
172
|
"""True if ``tag`` in ``repo`` dereferences to ``commit`` (the moved-tag tripwire)."""
|
|
131
173
|
res = subprocess.run(
|
|
@@ -25,7 +25,7 @@ from typing import Optional
|
|
|
25
25
|
|
|
26
26
|
from odoo_dev.vendor.lock import Lockfile
|
|
27
27
|
from odoo_dev.vendor.materialize import extract_subtree, is_bytecode, tree_diff
|
|
28
|
-
from odoo_dev.vendor.sources import get_source_at, tag_resolves_to
|
|
28
|
+
from odoo_dev.vendor.sources import commit_on_branch, get_source_at, tag_resolves_to
|
|
29
29
|
|
|
30
30
|
_MANIFEST_NAMES = ("__manifest__.py", "__openerp__.py")
|
|
31
31
|
|
|
@@ -192,12 +192,18 @@ def verify(
|
|
|
192
192
|
lock: Lockfile,
|
|
193
193
|
cache_dir: Optional[Path] = None,
|
|
194
194
|
allow_hybrid: bool = True,
|
|
195
|
+
mainline: Optional[str] = None,
|
|
195
196
|
) -> list:
|
|
196
197
|
"""Return a list of human-readable problems; empty means the gate is green.
|
|
197
198
|
|
|
198
199
|
``allow_hybrid=False`` (the CI gate for a repo that declares itself fully
|
|
199
200
|
vendored) additionally fails if any ``addons/`` symlink still points into a
|
|
200
201
|
``.repos/`` submodule.
|
|
202
|
+
|
|
203
|
+
``mainline`` (the production gate, e.g. ``"19.0"``) additionally fails any
|
|
204
|
+
pin whose commit is not in the history of that branch of its source — an
|
|
205
|
+
upstream change still under test on a feature branch. Entries marked
|
|
206
|
+
``allow_unmerged`` are exempt.
|
|
201
207
|
"""
|
|
202
208
|
project_dir = Path(project_dir)
|
|
203
209
|
vendored = project_dir / "vendored"
|
|
@@ -236,6 +242,20 @@ def verify(
|
|
|
236
242
|
for d in tree_diff(ref, vendored / name):
|
|
237
243
|
problems.append(f"{name}: vendored copy differs from pin — {d}")
|
|
238
244
|
|
|
245
|
+
if mainline and not entry.allow_unmerged:
|
|
246
|
+
try:
|
|
247
|
+
merged = commit_on_branch(repo, entry.source, entry.commit, mainline)
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
problems.append(f"{name}: cannot check mainline {mainline}: {exc}")
|
|
250
|
+
else:
|
|
251
|
+
if not merged:
|
|
252
|
+
problems.append(
|
|
253
|
+
f"{name}: pinned commit {entry.commit[:12]} is not on "
|
|
254
|
+
f"{mainline} of its source (an unmerged upstream change: "
|
|
255
|
+
f"merge it upstream and re-pin, or mark the entry "
|
|
256
|
+
f"'allow_unmerged: true' if the branch pin is intended)"
|
|
257
|
+
)
|
|
258
|
+
|
|
239
259
|
if entry.version:
|
|
240
260
|
tag = f"{name}/{entry.version}"
|
|
241
261
|
if not tag_resolves_to(repo, tag, entry.commit):
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Pins on unmerged upstream branches: ``bump --branch`` and the mainline gate.
|
|
2
|
+
|
|
3
|
+
A client may pin a shared addon to an upstream FEATURE branch while the change
|
|
4
|
+
is tested on its staging. Production must only carry commits that reached the
|
|
5
|
+
source's mainline: ``verify(..., mainline="18.0")`` (``vendor check
|
|
6
|
+
--mainline``) fails any pin whose commit is not in the mainline's history,
|
|
7
|
+
unless the entry is marked ``allow_unmerged: true`` (an intentional long-lived
|
|
8
|
+
branch pin).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from odoo_dev.vendor.edit import add_addon, bump_addon
|
|
15
|
+
from odoo_dev.vendor.lock import Lockfile, LockEntry
|
|
16
|
+
from odoo_dev.vendor.sync import sync_addons
|
|
17
|
+
from odoo_dev.vendor.verify import verify
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _git(repo: Path, *args: str) -> str:
|
|
21
|
+
return subprocess.run(
|
|
22
|
+
["git", "-C", str(repo), *args], check=True, capture_output=True, text=True
|
|
23
|
+
).stdout.strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _source_with_feature(tmp_path: Path):
|
|
27
|
+
"""Source repo: mainline ``18.0`` and an unmerged ``feat`` branch."""
|
|
28
|
+
repo = tmp_path / "src"
|
|
29
|
+
(repo / "shared_addon").mkdir(parents=True)
|
|
30
|
+
(repo / "shared_addon" / "__manifest__.py").write_text(
|
|
31
|
+
"{'name': 'shared', 'version': '18.0.1.0.0'}\n"
|
|
32
|
+
)
|
|
33
|
+
(repo / "shared_addon" / "m.py").write_text("v = 1\n")
|
|
34
|
+
_git(repo, "init", "-q", "-b", "18.0")
|
|
35
|
+
_git(repo, "config", "user.email", "t@t")
|
|
36
|
+
_git(repo, "config", "user.name", "t")
|
|
37
|
+
_git(repo, "add", "-A")
|
|
38
|
+
_git(repo, "commit", "-qm", "v1")
|
|
39
|
+
main_sha = _git(repo, "rev-parse", "HEAD")
|
|
40
|
+
_git(repo, "checkout", "-qb", "feat")
|
|
41
|
+
(repo / "shared_addon" / "m.py").write_text("v = 2\n")
|
|
42
|
+
_git(repo, "commit", "-qam", "feature")
|
|
43
|
+
feat_sha = _git(repo, "rev-parse", "HEAD")
|
|
44
|
+
_git(repo, "checkout", "-q", "18.0")
|
|
45
|
+
return repo, main_sha, feat_sha
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _pinned(tmp_path, repo, sha, **kw):
|
|
49
|
+
proj = tmp_path / "client"
|
|
50
|
+
proj.mkdir(exist_ok=True)
|
|
51
|
+
lock = Lockfile(entries={"shared_addon": LockEntry("shared_addon", str(repo), sha, **kw)})
|
|
52
|
+
lock.dump(proj / "addons.lock")
|
|
53
|
+
sync_addons(proj, lock, cache_dir=tmp_path / "cache")
|
|
54
|
+
return proj, lock
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_mainline_gate_refuses_unmerged_pin(tmp_path):
|
|
58
|
+
repo, _main, feat = _source_with_feature(tmp_path)
|
|
59
|
+
proj, lock = _pinned(tmp_path, repo, feat, branch="feat")
|
|
60
|
+
problems = verify(proj, lock, cache_dir=tmp_path / "cache", mainline="18.0")
|
|
61
|
+
assert len(problems) == 1
|
|
62
|
+
assert "not on 18.0" in problems[0]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_mainline_gate_passes_once_merged(tmp_path):
|
|
66
|
+
repo, _main, feat = _source_with_feature(tmp_path)
|
|
67
|
+
_git(repo, "merge", "--no-ff", "-q", "-m", "merge feat", "feat")
|
|
68
|
+
proj, lock = _pinned(tmp_path, repo, feat, branch="feat")
|
|
69
|
+
assert verify(proj, lock, cache_dir=tmp_path / "cache", mainline="18.0") == []
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_allow_unmerged_opts_out_and_round_trips(tmp_path):
|
|
73
|
+
repo, _main, feat = _source_with_feature(tmp_path)
|
|
74
|
+
proj, lock = _pinned(tmp_path, repo, feat, branch="feat", allow_unmerged=True)
|
|
75
|
+
assert verify(proj, lock, cache_dir=tmp_path / "cache", mainline="18.0") == []
|
|
76
|
+
reloaded = Lockfile.load(proj / "addons.lock")
|
|
77
|
+
assert reloaded.entries["shared_addon"].allow_unmerged is True
|
|
78
|
+
assert "allow_unmerged" in (proj / "addons.lock").read_text()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_mainline_pin_passes_and_flag_is_omitted_by_default(tmp_path):
|
|
82
|
+
repo, main, _feat = _source_with_feature(tmp_path)
|
|
83
|
+
proj, lock = _pinned(tmp_path, repo, main, branch="18.0")
|
|
84
|
+
assert verify(proj, lock, cache_dir=tmp_path / "cache", mainline="18.0") == []
|
|
85
|
+
assert "allow_unmerged" not in (proj / "addons.lock").read_text()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_without_mainline_a_branch_pin_is_fine(tmp_path):
|
|
89
|
+
repo, _main, feat = _source_with_feature(tmp_path)
|
|
90
|
+
proj, lock = _pinned(tmp_path, repo, feat, branch="feat")
|
|
91
|
+
assert verify(proj, lock, cache_dir=tmp_path / "cache") == []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_bump_to_a_branch_tracks_it(tmp_path):
|
|
95
|
+
repo, main, feat = _source_with_feature(tmp_path)
|
|
96
|
+
proj = tmp_path / "client"
|
|
97
|
+
proj.mkdir()
|
|
98
|
+
add_addon(proj, "shared_addon", str(repo), commit=main, cache_dir=tmp_path / "cache")
|
|
99
|
+
entry = bump_addon(proj, "shared_addon", branch="feat", cache_dir=tmp_path / "cache")
|
|
100
|
+
assert entry.branch == "feat"
|
|
101
|
+
assert entry.commit == feat
|
|
102
|
+
assert entry.version is None
|
|
103
|
+
assert (proj / "vendored" / "shared_addon" / "m.py").read_text() == "v = 2\n"
|
|
104
|
+
# an explicit commit wins over the branch head, and the branch is recorded
|
|
105
|
+
entry = bump_addon(proj, "shared_addon", commit=main, branch="18.0",
|
|
106
|
+
cache_dir=tmp_path / "cache")
|
|
107
|
+
assert (entry.branch, entry.commit) == ("18.0", main)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Tests for resolving refs against a source repo (``vendor/sources.py``)."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from odoo_dev.vendor.sources import resolve_commit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _git(repo: Path, *args: str) -> str:
|
|
10
|
+
return subprocess.run(
|
|
11
|
+
["git", "-C", str(repo), *args], check=True, capture_output=True, text=True
|
|
12
|
+
).stdout.strip()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _commit(repo: Path, content: str) -> str:
|
|
16
|
+
(repo / "m.py").write_text(content)
|
|
17
|
+
_git(repo, "add", "-A")
|
|
18
|
+
_git(repo, "commit", "-qm", content)
|
|
19
|
+
return _git(repo, "rev-parse", "HEAD")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _upstream(tmp_path: Path) -> Path:
|
|
23
|
+
repo = tmp_path / "upstream"
|
|
24
|
+
repo.mkdir()
|
|
25
|
+
_git(repo, "init", "-q", "-b", "18.0")
|
|
26
|
+
_git(repo, "config", "user.email", "t@t")
|
|
27
|
+
_git(repo, "config", "user.name", "t")
|
|
28
|
+
_commit(repo, "v = 1\n")
|
|
29
|
+
return repo
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_branch_resolves_to_fetched_tip_not_clone_time_local_branch(tmp_path):
|
|
33
|
+
"""A cached clone keeps a local branch frozen at clone time; fetch only moves
|
|
34
|
+
``origin/<branch>``. Resolving the branch must follow the remote, or every
|
|
35
|
+
``add``/``bump --branch`` silently pins the tip as of the first clone."""
|
|
36
|
+
upstream = _upstream(tmp_path)
|
|
37
|
+
source = f"file://{upstream}"
|
|
38
|
+
cache = tmp_path / "cache"
|
|
39
|
+
resolve_commit(source, "18.0", cache) # first use: clones into the cache
|
|
40
|
+
|
|
41
|
+
new_tip = _commit(upstream, "v = 2\n")
|
|
42
|
+
|
|
43
|
+
assert resolve_commit(source, "18.0", cache) == new_tip
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_local_source_resolves_its_own_branch(tmp_path):
|
|
47
|
+
"""A local-path source is the user's repo, used as-is: its local branch is
|
|
48
|
+
the truth, even when it also has a (stale) ``origin/<branch>``."""
|
|
49
|
+
upstream = _upstream(tmp_path)
|
|
50
|
+
local = tmp_path / "local"
|
|
51
|
+
subprocess.run(
|
|
52
|
+
["git", "clone", "-q", str(upstream), str(local)], check=True, capture_output=True
|
|
53
|
+
)
|
|
54
|
+
_git(local, "config", "user.email", "t@t")
|
|
55
|
+
_git(local, "config", "user.name", "t")
|
|
56
|
+
local_tip = _commit(local, "v = local\n")
|
|
57
|
+
|
|
58
|
+
assert resolve_commit(str(local), "18.0") == local_tip
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|