odoo-dev 1.2.3__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.
- odoo_dev-1.3.0/.release-please-manifest.json +3 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/CHANGELOG.md +8 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/PKG-INFO +1 -1
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/pyproject.toml +1 -1
- odoo_dev-1.3.0/src/odoo_dev/__init__.py +3 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/cli.py +2 -0
- odoo_dev-1.3.0/src/odoo_dev/commands/git.py +345 -0
- odoo_dev-1.3.0/tests/test_git_hygiene.py +279 -0
- odoo_dev-1.2.3/.release-please-manifest.json +0 -3
- odoo_dev-1.2.3/src/odoo_dev/__init__.py +0 -3
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/.github/workflows/release-please.yml +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/.gitignore +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/.gitmodules +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/.python-version +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/README.md +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/release-please-config.json +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/commands/__init__.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/commands/bump.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/commands/db.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/commands/docker.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/commands/run.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/commands/setup.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/commands/vendor.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/config.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/preflight.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/templates/__init__.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/templates/docker/Dockerfile +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/templates/docker/__init__.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/templates/docker/docker-entrypoint.sh +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/templates/vscode/__init__.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/templates/vscode/launch.json +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/templates/vscode/settings.json +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/templates/vscode/tasks.json +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/utils/__init__.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/utils/console.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/utils/manifest.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/vendor/__init__.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/vendor/develop.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/vendor/edit.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/vendor/lock.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/vendor/materialize.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/vendor/migrate.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/vendor/sources.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/vendor/sync.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/vendor/update.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/src/odoo_dev/vendor/verify.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/__init__.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/fixtures/__init__.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_bump.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_cli.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_config.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_db.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_db_config.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_drop_database.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_integration.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_preflight.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_run.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_setup.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_setup_db.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_vendor_develop.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_vendor_edit.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_vendor_lock.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_vendor_materialize.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_vendor_migrate.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_vendor_update.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/tests/test_vendor_verify.py +0 -0
- {odoo_dev-1.2.3 → odoo_dev-1.3.0}/uv.lock +0 -0
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## [1.2.3](https://github.com/bemade/odoo-dev/compare/v1.2.2...v1.2.3) (2026-09-02)
|
|
4
12
|
|
|
5
13
|
|
|
@@ -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
|
|
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
|
|
File without changes
|