odoo-dev 1.2.2__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. odoo_dev-1.3.0/.release-please-manifest.json +3 -0
  2. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/CHANGELOG.md +15 -0
  3. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/PKG-INFO +4 -4
  4. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/pyproject.toml +4 -4
  5. odoo_dev-1.3.0/src/odoo_dev/__init__.py +3 -0
  6. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/cli.py +2 -0
  7. odoo_dev-1.3.0/src/odoo_dev/commands/git.py +345 -0
  8. odoo_dev-1.3.0/tests/test_git_hygiene.py +279 -0
  9. odoo_dev-1.2.2/.release-please-manifest.json +0 -3
  10. odoo_dev-1.2.2/src/odoo_dev/__init__.py +0 -3
  11. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/.github/workflows/release-please.yml +0 -0
  12. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/.gitignore +0 -0
  13. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/.gitmodules +0 -0
  14. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/.python-version +0 -0
  15. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/README.md +0 -0
  16. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/release-please-config.json +0 -0
  17. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/commands/__init__.py +0 -0
  18. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/commands/bump.py +0 -0
  19. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/commands/db.py +0 -0
  20. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/commands/docker.py +0 -0
  21. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/commands/run.py +0 -0
  22. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/commands/setup.py +0 -0
  23. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/commands/vendor.py +0 -0
  24. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/config.py +0 -0
  25. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/preflight.py +0 -0
  26. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/templates/__init__.py +0 -0
  27. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/templates/docker/Dockerfile +0 -0
  28. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/templates/docker/__init__.py +0 -0
  29. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/templates/docker/docker-entrypoint.sh +0 -0
  30. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/templates/vscode/__init__.py +0 -0
  31. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/templates/vscode/launch.json +0 -0
  32. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/templates/vscode/settings.json +0 -0
  33. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/templates/vscode/tasks.json +0 -0
  34. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/utils/__init__.py +0 -0
  35. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/utils/console.py +0 -0
  36. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/utils/manifest.py +0 -0
  37. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/vendor/__init__.py +0 -0
  38. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/vendor/develop.py +0 -0
  39. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/vendor/edit.py +0 -0
  40. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/vendor/lock.py +0 -0
  41. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/vendor/materialize.py +0 -0
  42. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/vendor/migrate.py +0 -0
  43. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/vendor/sources.py +0 -0
  44. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/vendor/sync.py +0 -0
  45. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/vendor/update.py +0 -0
  46. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/src/odoo_dev/vendor/verify.py +0 -0
  47. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/__init__.py +0 -0
  48. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/fixtures/__init__.py +0 -0
  49. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_bump.py +0 -0
  50. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_cli.py +0 -0
  51. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_config.py +0 -0
  52. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_db.py +0 -0
  53. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_db_config.py +0 -0
  54. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_drop_database.py +0 -0
  55. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_integration.py +0 -0
  56. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_preflight.py +0 -0
  57. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_run.py +0 -0
  58. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_setup.py +0 -0
  59. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_setup_db.py +0 -0
  60. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_vendor_develop.py +0 -0
  61. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_vendor_edit.py +0 -0
  62. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_vendor_lock.py +0 -0
  63. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_vendor_materialize.py +0 -0
  64. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_vendor_migrate.py +0 -0
  65. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_vendor_update.py +0 -0
  66. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/tests/test_vendor_verify.py +0 -0
  67. {odoo_dev-1.2.2 → odoo_dev-1.3.0}/uv.lock +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "1.3.0"
3
+ }
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.0](https://github.com/bemade/odoo-dev/compare/v1.2.3...v1.3.0) (2026-09-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * **git:** add `odoo-dev git` for ref hygiene and honest merge-state answers ([2983595](https://github.com/bemade/odoo-dev/commit/298359576510e4dace6ac0693c8a8f6b31eee8da))
9
+ * **git:** add `odoo-dev git` for ref hygiene and honest merge-state answers ([626f05e](https://github.com/bemade/odoo-dev/commit/626f05ef1a804275fc0a2b2af2696cca5ca2c03a))
10
+
11
+ ## [1.2.3](https://github.com/bemade/odoo-dev/compare/v1.2.2...v1.2.3) (2026-09-02)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * point project URLs at the GitHub source, not the retired GitLab ([61d7348](https://github.com/bemade/odoo-dev/commit/61d734858e50fb8080b508959b9566b4b3134ba0))
17
+
3
18
  ## [1.2.2](https://github.com/bemade/odoo-dev/compare/v1.2.1...v1.2.2) (2026-08-14)
4
19
 
5
20
 
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: odoo-dev
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
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
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
8
  License-Expression: LGPL-3.0
9
9
  Requires-Python: >=3.12
10
10
  Requires-Dist: pyyaml>=6.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "odoo-dev"
3
- version = "1.2.2"
3
+ version = "1.3.0"
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.3.0" # x-release-please-version
@@ -7,6 +7,7 @@ import typer
7
7
  from odoo_dev import __version__
8
8
  from odoo_dev.commands import bump as bump_cmd
9
9
  from odoo_dev.commands import db, docker, run, setup
10
+ from odoo_dev.commands import git as git_cmd
10
11
  from odoo_dev.commands import vendor as vendor_cmd
11
12
 
12
13
 
@@ -42,6 +43,7 @@ def _main(
42
43
  app.add_typer(db.app, name="db")
43
44
  app.add_typer(docker.app, name="docker")
44
45
  app.add_typer(vendor_cmd.app, name="vendor")
46
+ app.add_typer(git_cmd.app, name="git")
45
47
 
46
48
  # Setup commands
47
49
  app.command()(setup.setup)
@@ -0,0 +1,345 @@
1
+ """``odoo-dev git`` — ref hygiene and honest "did this ship?" answers.
2
+
3
+ Three questions this repeatedly gets wrong in a multi-branch Odoo project, and
4
+ the traps behind each:
5
+
6
+ **"Is this branch merged?"** ``git branch --no-merged`` reads *local* refs. A
7
+ local branch that is behind its origin counterpart is reported as merged while
8
+ origin's tip is not, so the local form is the optimistic lie. Always fetch and
9
+ ask against remote refs.
10
+
11
+ **"Did this feature ship?"** ``--contains`` / ``--no-merged`` answer a question
12
+ about *object identity*, not content. Anything squashed, cherry-picked or
13
+ re-created reads as unmerged forever even when the code is live. The only
14
+ honest answer diffs the files.
15
+
16
+ **"What are these stale refs?"** Per-task clone workflows leave orphaned
17
+ remote-tracking refs behind. ``fetch --prune`` cannot clean them, because there
18
+ is no configured remote left to prune against -- they simply persist, and show
19
+ up in every ref listing as branches that do not exist.
20
+ """
21
+
22
+ import subprocess
23
+ from typing import Annotated, Optional
24
+
25
+ import typer
26
+
27
+ from odoo_dev.utils.console import error, info, success, warning
28
+
29
+ app = typer.Typer(
30
+ name="git", help="Git ref hygiene and merge-state answers.", no_args_is_help=True
31
+ )
32
+
33
+
34
+ def _git(*args: str, check: bool = True) -> str:
35
+ """Run a git command and return stripped stdout."""
36
+ r = subprocess.run(["git", *args], capture_output=True, text=True)
37
+ if check and r.returncode != 0:
38
+ raise RuntimeError((r.stderr or r.stdout).strip())
39
+ return r.stdout.strip()
40
+
41
+
42
+ def _ok(*args: str) -> bool:
43
+ """True when the git command exits 0 (for --is-ancestor and friends)."""
44
+ return (
45
+ subprocess.run(["git", *args], capture_output=True, text=True).returncode == 0
46
+ )
47
+
48
+
49
+ def _configured_remotes() -> set[str]:
50
+ return {ln for ln in _git("remote").splitlines() if ln}
51
+
52
+
53
+ @app.command(name="prune-refs")
54
+ def prune_refs(
55
+ apply: Annotated[
56
+ bool, typer.Option("--apply", help="Actually delete. Default is a dry run.")
57
+ ] = False,
58
+ namespaces: Annotated[
59
+ Optional[str],
60
+ typer.Option(
61
+ "--namespaces",
62
+ help="Comma-separated extra ref namespaces to sweep, e.g. 'refs/test,refs/wip'.",
63
+ ),
64
+ ] = None,
65
+ ) -> None:
66
+ """Delete remote-tracking refs whose remote no longer exists, plus custom namespaces.
67
+
68
+ Per-task clone workflows leave refs like
69
+ ``refs/remotes/task-3629/feature/...`` behind. ``git fetch --prune`` never
70
+ removes them: pruning happens per configured remote, and that remote is
71
+ gone, so nothing owns them. They then show up forever in ref listings and
72
+ in any script that walks ``refs/remotes``.
73
+
74
+ Dry-run by default. Deletion of a ref is not recoverable through the reflog
75
+ once the objects are gc'd, so nothing is removed without ``--apply``.
76
+ """
77
+ remotes = _configured_remotes()
78
+ if not remotes:
79
+ error("no configured remotes -- refusing to guess what is stale")
80
+ raise typer.Exit(2)
81
+
82
+ info(f"configured remotes: {', '.join(sorted(remotes))}")
83
+
84
+ doomed: list[str] = []
85
+ for ref in _git("for-each-ref", "--format=%(refname)", "refs/remotes").splitlines():
86
+ # refs/remotes/<remote>/<branch...>
87
+ parts = ref.split("/", 3)
88
+ if len(parts) < 4:
89
+ continue
90
+ if parts[2] not in remotes:
91
+ doomed.append(ref)
92
+
93
+ for ns in (n.strip() for n in (namespaces or "").split(",") if n.strip()):
94
+ doomed.extend(
95
+ r for r in _git("for-each-ref", "--format=%(refname)", ns).splitlines() if r
96
+ )
97
+
98
+ if not doomed:
99
+ success("nothing stale -- every ref belongs to a configured remote")
100
+ return
101
+
102
+ warning(f"{len(doomed)} stale ref(s):")
103
+ for r in doomed:
104
+ info(f" {r}")
105
+
106
+ if not apply:
107
+ info("\nDRY RUN -- nothing deleted. Re-run with --apply.")
108
+ return
109
+
110
+ # update-ref requires FULLY-QUALIFIED refnames. It deliberately will not
111
+ # DWIM-shorten the way checkout/log/branch do, because deleting the wrong
112
+ # ref is unrecoverable -- so it refuses anything that is not already a
113
+ # valid ref path rather than guessing the namespace.
114
+ for r in doomed:
115
+ _git("update-ref", "-d", r)
116
+ success(f"deleted {r}")
117
+ info("\nRun `git gc --prune=now` to reclaim the objects.")
118
+
119
+
120
+ @app.command()
121
+ def unmerged(
122
+ target: Annotated[
123
+ str, typer.Argument(help="Target branch, e.g. 19.0-prod")
124
+ ] = "19.0-prod",
125
+ remote: Annotated[str, typer.Option("--remote", help="Remote name")] = "origin",
126
+ no_fetch: Annotated[
127
+ bool, typer.Option("--no-fetch", help="Skip the fetch --prune first")
128
+ ] = False,
129
+ ) -> None:
130
+ """List remote branches not merged into TARGET, using remote refs only.
131
+
132
+ This is the form that tells the truth. ``git branch --no-merged`` consults
133
+ local refs, which are only as good as your last fetch: a local branch
134
+ sitting behind its origin counterpart is reported as merged while origin's
135
+ tip is not, and nothing warns you. That discrepancy is how a stale tip gets
136
+ merged into a release and quietly drops commits.
137
+
138
+ Object identity only, so treat the output as candidates. A squashed or
139
+ cherry-picked branch appears here forever -- confirm with ``git shipped``.
140
+ """
141
+ if not no_fetch:
142
+ info(f"fetching {remote} --prune ...")
143
+ _git("fetch", "--prune", "--quiet", remote)
144
+
145
+ tgt = f"{remote}/{target}"
146
+ if not _ok("rev-parse", "--verify", tgt):
147
+ error(f"{tgt} does not exist")
148
+ raise typer.Exit(2)
149
+
150
+ rows: list[tuple[str, str, str]] = []
151
+ for line in _git(
152
+ "for-each-ref",
153
+ "--format=%(refname:short)%09%(committerdate:short)%09%(authorname)",
154
+ f"refs/remotes/{remote}",
155
+ ).splitlines():
156
+ ref, date, author = (line.split("\t") + ["", ""])[:3]
157
+ short = ref[len(remote) + 1 :]
158
+ if short in ("HEAD", target):
159
+ continue
160
+ if _ok("merge-base", "--is-ancestor", ref, tgt):
161
+ continue
162
+ rows.append((short, date, author))
163
+
164
+ if not rows:
165
+ success(f"every {remote} branch is merged into {target}")
166
+ return
167
+
168
+ warning(f"{len(rows)} branch(es) not merged into {target} (by object identity):")
169
+ for short, date, author in sorted(rows, key=lambda r: r[1], reverse=True):
170
+ info(f" {date} {short:<52} {author}")
171
+ info(
172
+ "\nSquashed/cherry-picked work also lands here. Verify with `odoo-dev git shipped`."
173
+ )
174
+
175
+
176
+ @app.command()
177
+ def shipped(
178
+ ref: Annotated[str, typer.Argument(help="Branch or commit that may have shipped")],
179
+ paths: Annotated[
180
+ Optional[list[str]],
181
+ typer.Argument(
182
+ help="Files/dirs to compare. Omit to use the files REF itself changed."
183
+ ),
184
+ ] = None,
185
+ target: Annotated[
186
+ str, typer.Option("--target", help="Where it should have landed")
187
+ ] = "origin/19.0-prod",
188
+ grep: Annotated[
189
+ Optional[list[str]],
190
+ typer.Option(
191
+ "--grep",
192
+ help="Marker string that must appear in TARGET's copy. Repeatable. "
193
+ "This is what actually settles 'did it ship' when paths differ.",
194
+ ),
195
+ ] = None,
196
+ ) -> None:
197
+ """Did REF's work reach TARGET? Answered by content, never by ancestry.
198
+
199
+ ``--contains`` and ``--no-merged`` compare commit objects, so a squashed,
200
+ cherry-picked or re-created branch reads as unmerged even when its code is
201
+ live in production -- and as a result people go looking for "missing"
202
+ features that shipped months ago.
203
+
204
+ With no paths given it derives them -- the files REF itself changed -- which
205
+ is almost always the right set. It then diffs those between REF and TARGET
206
+ and reports what actually differs. An empty diff means the content is identical. A non-empty diff is
207
+ not proof of absence either: the target may simply have moved on since,
208
+ which is why the per-path detail is printed rather than a verdict.
209
+ """
210
+ for r in (ref, target):
211
+ if not _ok("rev-parse", "--verify", r):
212
+ error(f"{r} does not exist")
213
+ raise typer.Exit(2)
214
+
215
+ # Ancestry is ASYMMETRIC evidence, which is the whole subtlety here.
216
+ #
217
+ # True -> conclusive. Every commit in REF is reachable from TARGET, so
218
+ # the work is there by definition. A content diff can only muddy
219
+ # a settled answer, because the target may have moved on and
220
+ # "differs" would then read as doubt.
221
+ # False -> proves nothing. Squashed, cherry-picked or re-created work is
222
+ # never an ancestor even when it is live in production. Fall
223
+ # through to content, which is the only honest test.
224
+ is_ancestor = _ok("merge-base", "--is-ancestor", ref, target)
225
+
226
+ if is_ancestor:
227
+ success(
228
+ f"{ref} IS an ancestor of {target} -- every commit is reachable, "
229
+ f"so this work shipped."
230
+ )
231
+ if not grep:
232
+ info(" Ancestry is conclusive in this direction; no content check needed.")
233
+ return
234
+ # `--grep` is an explicit content question, so honour it anyway: the
235
+ # caller may be checking that a marker SURVIVED, not merely that the
236
+ # commits landed. Reachable does not mean un-reverted.
237
+ info(
238
+ " Ancestry settles reachability; checking your markers anyway, "
239
+ "since a later commit can still have removed them."
240
+ )
241
+ else:
242
+ info(f"ancestry: {ref} is NOT an ancestor of {target}")
243
+ info(" (proves nothing on its own -- content below is what counts)")
244
+
245
+ # Deriving the paths is almost always what you want: git already knows
246
+ # which files REF touched, and naming them by hand risks checking the wrong
247
+ # ones. A whole-tree path such as `.` always differs against a target that
248
+ # has moved on, which cannot distinguish "did not ship" from "shipped, then
249
+ # the repo continued".
250
+ if not paths:
251
+ if is_ancestor:
252
+ # merge-base(REF, TARGET) == REF here, so that diff is empty by
253
+ # construction. What REF *introduced* is the diff against its first
254
+ # parent -- which for a merge commit is the branch it merged in.
255
+ spec = (f"{ref}^1", ref)
256
+ else:
257
+ base = _git("merge-base", ref, target, check=False)
258
+ if not base:
259
+ error(
260
+ f"no common ancestor between {ref} and {target} -- pass paths explicitly"
261
+ )
262
+ raise typer.Exit(2)
263
+ spec = (base, ref)
264
+ paths = [
265
+ p for p in _git("diff", "--name-only", *spec, check=False).splitlines() if p
266
+ ]
267
+ if not paths:
268
+ success(f"{ref} changes no files -- nothing to compare.")
269
+ return
270
+ info(
271
+ f"comparing the {len(paths)} file(s) {ref} changed "
272
+ f"(derived; pass paths to override)"
273
+ )
274
+ elif any(p in (".", "./", "") for p in paths):
275
+ warning(
276
+ " '.' compares the entire tree and will always differ -- omit the "
277
+ "path argument to compare only what REF changed."
278
+ )
279
+
280
+ identical, differs, missing = [], [], []
281
+ for p in paths:
282
+ in_ref = _ok("cat-file", "-e", f"{ref}:{p}")
283
+ in_tgt = _ok("cat-file", "-e", f"{target}:{p}")
284
+ if in_ref and not in_tgt:
285
+ missing.append(p)
286
+ elif not _git("diff", "--name-only", ref, target, "--", p, check=False):
287
+ identical.append(p)
288
+ else:
289
+ differs.append(p)
290
+
291
+ for p in identical:
292
+ success(f" identical {p}")
293
+ for p in differs:
294
+ warning(f" differs {p}")
295
+ for p in missing:
296
+ error(f" ABSENT from {target}: {p}")
297
+
298
+ # Markers are the decisive test when paths differ. A file that has moved on
299
+ # still contains the feature's fingerprints if the feature shipped; if the
300
+ # markers are gone, the work was reverted or never landed.
301
+ missing_markers: list[str] = []
302
+ if grep:
303
+ info("")
304
+ for pat in grep:
305
+ hits = 0
306
+ for p in paths:
307
+ if not _ok("cat-file", "-e", f"{target}:{p}"):
308
+ continue
309
+ body = _git("show", f"{target}:{p}", check=False)
310
+ hits += sum(1 for ln in body.splitlines() if pat in ln)
311
+ if hits:
312
+ success(f" marker present in {target}: {pat!r} ({hits} line(s))")
313
+ else:
314
+ error(f" marker ABSENT from {target}: {pat!r}")
315
+ missing_markers.append(pat)
316
+
317
+ if missing:
318
+ info(
319
+ "\nPaths absent from the target are the real signal -- that work did not ship."
320
+ )
321
+ raise typer.Exit(1)
322
+ if missing_markers:
323
+ info(
324
+ "\nMarkers absent -- this work is NOT in the target, whatever ancestry says."
325
+ )
326
+ raise typer.Exit(1)
327
+ if grep and differs:
328
+ if is_ancestor:
329
+ success(
330
+ "\nPaths differ because the target moved on, but every marker "
331
+ "survived: the work is reachable AND still present."
332
+ )
333
+ else:
334
+ success(
335
+ "\nPaths differ but every marker is present: the work SHIPPED and "
336
+ "the target has since moved on. This is exactly the case ancestry "
337
+ "reports wrongly."
338
+ )
339
+ return
340
+ if differs:
341
+ info("\nDiffering paths may just mean the target moved on. Inspect with:")
342
+ for p in differs:
343
+ info(f" git diff {ref} {target} -- {p}")
344
+ else:
345
+ success("\nEvery path matches -- this work is present in the target.")
@@ -0,0 +1,279 @@
1
+ """Tests for ``odoo-dev git``.
2
+
3
+ These build real throwaway repos rather than mocking git: every bug this
4
+ command exists to prevent is a bug about what git actually reports, so a mock
5
+ would test the assumption instead of the behaviour.
6
+ """
7
+
8
+ import subprocess
9
+
10
+ import pytest
11
+ from typer.testing import CliRunner
12
+
13
+ from odoo_dev.cli import app
14
+
15
+ runner = CliRunner()
16
+
17
+
18
+ def _run(*args, cwd):
19
+ subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
20
+
21
+
22
+ @pytest.fixture()
23
+ def repo(tmp_path, monkeypatch):
24
+ """An origin plus a clone, with one commit on the default branch."""
25
+ origin = tmp_path / "origin.git"
26
+ work = tmp_path / "work"
27
+ _run("init", "--bare", "-b", "main", str(origin), cwd=tmp_path)
28
+ _run("clone", str(origin), str(work), cwd=tmp_path)
29
+ for k, v in (("user.email", "t@example.com"), ("user.name", "T")):
30
+ _run("config", k, v, cwd=work)
31
+ (work / "feature.py").write_text("MARKER_ALPHA = 1\n")
32
+ _run("add", "-A", cwd=work)
33
+ _run("commit", "-m", "feat: alpha", cwd=work)
34
+ _run("push", "-u", "origin", "main", cwd=work)
35
+ monkeypatch.chdir(work)
36
+ return work
37
+
38
+
39
+ def test_prune_refs_is_dry_by_default(repo):
40
+ """A stale ref is reported but survives without --apply."""
41
+ _run("update-ref", "refs/remotes/task-999/feature/x", "HEAD", cwd=repo)
42
+ res = runner.invoke(app, ["git", "prune-refs"])
43
+ assert res.exit_code == 0
44
+ assert "task-999" in res.stdout
45
+ assert "DRY RUN" in res.stdout
46
+ # still there
47
+ out = subprocess.run(
48
+ ["git", "for-each-ref", "--format=%(refname)", "refs/remotes"],
49
+ cwd=repo,
50
+ capture_output=True,
51
+ text=True,
52
+ ).stdout
53
+ assert "task-999" in out
54
+
55
+
56
+ def test_prune_refs_apply_removes_only_orphans(repo):
57
+ """--apply deletes the orphaned ref and leaves origin's refs untouched."""
58
+ _run("update-ref", "refs/remotes/task-999/feature/x", "HEAD", cwd=repo)
59
+ res = runner.invoke(app, ["git", "prune-refs", "--apply"])
60
+ assert res.exit_code == 0
61
+ out = subprocess.run(
62
+ ["git", "for-each-ref", "--format=%(refname)", "refs/remotes"],
63
+ cwd=repo,
64
+ capture_output=True,
65
+ text=True,
66
+ ).stdout
67
+ assert "task-999" not in out
68
+ assert "refs/remotes/origin/main" in out
69
+
70
+
71
+ def test_shipped_sees_reapplied_work_that_ancestry_misses(repo):
72
+ """The whole point: same content, different SHA -> ancestry lies, content does not."""
73
+ # Branch off and add the feature there.
74
+ _run("checkout", "-b", "side", cwd=repo)
75
+ (repo / "feature.py").write_text("MARKER_ALPHA = 1\nMARKER_BETA = 2\n")
76
+ _run("commit", "-am", "feat: beta", cwd=repo)
77
+ side = subprocess.run(
78
+ ["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True
79
+ ).stdout.strip()
80
+
81
+ # Re-apply the SAME content on main as an independent commit (what a squash
82
+ # merge or a manual re-application produces), then move main on afterwards
83
+ # so the two histories genuinely diverge.
84
+ _run("checkout", "main", cwd=repo)
85
+ (repo / "feature.py").write_text("MARKER_ALPHA = 1\nMARKER_BETA = 2\n")
86
+ _run("commit", "-am", "feat: beta (reapplied)", cwd=repo)
87
+ # main then moves on *within the same file* -- the real-world shape, where
88
+ # the feature shipped months ago and the file has been edited since.
89
+ (repo / "feature.py").write_text(
90
+ "MARKER_ALPHA = 1\nMARKER_BETA = 2\nadded_later = True\n"
91
+ )
92
+ _run("commit", "-am", "chore: main moves on", cwd=repo)
93
+ _run("push", "origin", "main", cwd=repo)
94
+
95
+ # Ancestry says the side commit never landed...
96
+ assert (
97
+ subprocess.run(
98
+ ["git", "merge-base", "--is-ancestor", side, "origin/main"],
99
+ cwd=repo,
100
+ capture_output=True,
101
+ ).returncode
102
+ != 0
103
+ )
104
+ # ...but the marker is present, so shipped() must report SHIPPED.
105
+ res = runner.invoke(
106
+ app,
107
+ [
108
+ "git",
109
+ "shipped",
110
+ side,
111
+ "feature.py",
112
+ "--target",
113
+ "origin/main",
114
+ "--grep",
115
+ "MARKER_BETA",
116
+ ],
117
+ )
118
+ assert res.exit_code == 0
119
+ assert "SHIPPED" in res.stdout
120
+ assert "differs" in res.stdout
121
+
122
+
123
+ def test_shipped_reports_identical_paths_without_needing_markers(repo):
124
+ """Byte-identical content is a sufficient answer when ancestry does not apply."""
125
+ _run("checkout", "-b", "twin", cwd=repo)
126
+ (repo / "note.txt").write_text("unrelated\n") # diverge without touching feature.py
127
+ _run("add", "-A", cwd=repo)
128
+ _run("commit", "-m", "chore: diverge", cwd=repo)
129
+ twin = subprocess.run(
130
+ ["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True
131
+ ).stdout.strip()
132
+ _run("checkout", "main", cwd=repo)
133
+ res = runner.invoke(
134
+ app, ["git", "shipped", twin, "feature.py", "--target", "origin/main"]
135
+ )
136
+ assert res.exit_code == 0
137
+ assert "identical" in res.stdout
138
+
139
+
140
+ def test_shipped_short_circuits_when_ancestry_is_conclusive(repo):
141
+ """A true ancestry answer is proof; it must not fall through to a diff.
142
+
143
+ Regression: comparing HEAD (already an ancestor) against a moved-on target
144
+ reported "differs", which reads as doubt about something already settled.
145
+ """
146
+ _run("checkout", "-b", "later", cwd=repo)
147
+ (repo / "feature.py").write_text("MARKER_ALPHA = 1\nmoved_on = True\n")
148
+ _run("commit", "-am", "chore: target moves on", cwd=repo)
149
+ _run("push", "origin", "later", cwd=repo)
150
+ _run("checkout", "main", cwd=repo)
151
+
152
+ res = runner.invoke(
153
+ app, ["git", "shipped", "HEAD", "feature.py", "--target", "origin/later"]
154
+ )
155
+ assert res.exit_code == 0
156
+ assert "shipped" in res.stdout
157
+ assert "differs" not in res.stdout
158
+
159
+
160
+ def test_shipped_warns_that_a_whole_tree_path_is_uninformative(repo):
161
+ """'.' always differs against a moved-on target, so say so rather than imply doubt."""
162
+ _run("checkout", "-b", "side2", cwd=repo)
163
+ (repo / "feature.py").write_text("MARKER_ALPHA = 1\nbeta = 2\n")
164
+ _run("commit", "-am", "feat: side2", cwd=repo)
165
+ side = subprocess.run(
166
+ ["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True
167
+ ).stdout.strip()
168
+ _run("checkout", "main", cwd=repo)
169
+ (repo / "other.py").write_text("z = 1\n")
170
+ _run("add", "-A", cwd=repo)
171
+ _run("commit", "-m", "chore: diverge", cwd=repo)
172
+ _run("push", "origin", "main", cwd=repo)
173
+
174
+ res = runner.invoke(app, ["git", "shipped", side, ".", "--target", "origin/main"])
175
+ assert "entire tree" in res.stdout
176
+
177
+
178
+ def test_shipped_derives_paths_when_none_are_given(repo):
179
+ """Omitting paths must compare exactly the files REF changed."""
180
+ # Pre-existing file on BOTH sides: derivation must ignore it, because REF
181
+ # does not change it.
182
+ (repo / "untouched.py").write_text("irrelevant = True\n")
183
+ _run("add", "-A", cwd=repo)
184
+ _run("commit", "-m", "chore: unrelated file", cwd=repo)
185
+ _run("push", "origin", "main", cwd=repo)
186
+
187
+ _run("checkout", "-b", "derive", cwd=repo)
188
+ (repo / "feature.py").write_text("MARKER_ALPHA = 1\nMARKER_GAMMA = 3\n")
189
+ _run("commit", "-am", "feat: gamma", cwd=repo)
190
+ ref = subprocess.run(
191
+ ["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True
192
+ ).stdout.strip()
193
+ _run("checkout", "main", cwd=repo)
194
+ (repo / "feature.py").write_text("MARKER_ALPHA = 1\nMARKER_GAMMA = 3\nlater = 1\n")
195
+ _run("commit", "-am", "chore: main moves on", cwd=repo)
196
+ _run("push", "origin", "main", cwd=repo)
197
+
198
+ res = runner.invoke(
199
+ app,
200
+ ["git", "shipped", ref, "--target", "origin/main", "--grep", "MARKER_GAMMA"],
201
+ )
202
+ assert res.exit_code == 0
203
+ assert "derived" in res.stdout
204
+ assert "comparing the 1 file(s)" in res.stdout
205
+ assert "feature.py" in res.stdout
206
+ assert "untouched.py" not in res.stdout
207
+
208
+
209
+ def test_shipped_derives_paths_for_an_ancestor_ref(repo):
210
+ """For an ancestor, merge-base==REF, so paths come from REF's first parent."""
211
+ (repo / "feature.py").write_text("MARKER_ALPHA = 1\nMARKER_DELTA = 4\n")
212
+ _run("commit", "-am", "feat: delta", cwd=repo)
213
+ ref = subprocess.run(
214
+ ["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True
215
+ ).stdout.strip()
216
+ (repo / "feature.py").write_text("MARKER_ALPHA = 1\nMARKER_DELTA = 4\nafter = 1\n")
217
+ _run("commit", "-am", "chore: after", cwd=repo)
218
+ _run("push", "origin", "main", cwd=repo)
219
+
220
+ res = runner.invoke(
221
+ app,
222
+ ["git", "shipped", ref, "--target", "origin/main", "--grep", "MARKER_DELTA"],
223
+ )
224
+ assert res.exit_code == 0
225
+ # Derivation must not collapse to "nothing to compare" for an ancestor.
226
+ assert "nothing to compare" not in res.stdout
227
+ assert "feature.py" in res.stdout
228
+
229
+
230
+ def test_shipped_flags_a_marker_reverted_after_it_landed(repo):
231
+ """Reachable is not the same as still present -- a later revert must fail."""
232
+ (repo / "feature.py").write_text("MARKER_ALPHA = 1\nMARKER_EPSILON = 5\n")
233
+ _run("commit", "-am", "feat: epsilon", cwd=repo)
234
+ ref = subprocess.run(
235
+ ["git", "rev-parse", "HEAD"], cwd=repo, capture_output=True, text=True
236
+ ).stdout.strip()
237
+ (repo / "feature.py").write_text("MARKER_ALPHA = 1\n") # reverted later
238
+ _run("commit", "-am", "revert: drop epsilon", cwd=repo)
239
+ _run("push", "origin", "main", cwd=repo)
240
+
241
+ res = runner.invoke(
242
+ app,
243
+ ["git", "shipped", ref, "--target", "origin/main", "--grep", "MARKER_EPSILON"],
244
+ )
245
+ assert res.exit_code == 1
246
+ assert "ABSENT" in res.stdout
247
+
248
+
249
+ def test_shipped_fails_when_the_marker_never_landed(repo):
250
+ """A marker absent from the target is a real negative, and exits non-zero."""
251
+ res = runner.invoke(
252
+ app,
253
+ [
254
+ "git",
255
+ "shipped",
256
+ "HEAD",
257
+ "feature.py",
258
+ "--target",
259
+ "origin/main",
260
+ "--grep",
261
+ "MARKER_NEVER",
262
+ ],
263
+ )
264
+ assert res.exit_code == 1
265
+ assert "ABSENT" in res.stdout
266
+
267
+
268
+ def test_unmerged_reads_remote_refs_not_stale_local_ones(repo):
269
+ """The bug this exists for: a stale local ref makes a branch look merged."""
270
+ _run("checkout", "-b", "wip", cwd=repo)
271
+ (repo / "other.py").write_text("x = 1\n")
272
+ _run("add", "-A", cwd=repo)
273
+ _run("commit", "-m", "feat: wip", cwd=repo)
274
+ _run("push", "-u", "origin", "wip", cwd=repo)
275
+ _run("checkout", "main", cwd=repo)
276
+
277
+ res = runner.invoke(app, ["git", "unmerged", "main", "--no-fetch"])
278
+ assert res.exit_code == 0
279
+ assert "wip" in res.stdout
@@ -1,3 +0,0 @@
1
- {
2
- ".": "1.2.2"
3
- }
@@ -1,3 +0,0 @@
1
- """Odoo Development Environment Helper."""
2
-
3
- __version__ = "1.2.2" # x-release-please-version
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes