gitacross 2.3.1__tar.gz → 2.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {gitacross-2.3.1/src/gitacross.egg-info → gitacross-2.3.2}/PKG-INFO +2 -2
  2. {gitacross-2.3.1 → gitacross-2.3.2}/README.md +1 -1
  3. {gitacross-2.3.1 → gitacross-2.3.2}/pyproject.toml +1 -1
  4. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/source.py +110 -7
  5. {gitacross-2.3.1 → gitacross-2.3.2/src/gitacross.egg-info}/PKG-INFO +2 -2
  6. {gitacross-2.3.1 → gitacross-2.3.2}/tests/test_source.py +163 -0
  7. {gitacross-2.3.1 → gitacross-2.3.2}/LICENSE +0 -0
  8. {gitacross-2.3.1 → gitacross-2.3.2}/setup.cfg +0 -0
  9. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/__init__.py +0 -0
  10. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/__main__.py +0 -0
  11. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/cli.py +0 -0
  12. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/config.py +0 -0
  13. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/git.py +0 -0
  14. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/linter/__init__.py +0 -0
  15. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/linter/fixer.py +0 -0
  16. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/linter/keys.py +0 -0
  17. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/linter/models.py +0 -0
  18. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/linter/validator.py +0 -0
  19. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/main.py +0 -0
  20. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/providers/__init__.py +0 -0
  21. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/providers/base.py +0 -0
  22. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/providers/gitea.py +0 -0
  23. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/providers/github.py +0 -0
  24. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/renderer.py +0 -0
  25. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/retry.py +0 -0
  26. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/state.py +0 -0
  27. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross/target.py +0 -0
  28. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross.egg-info/SOURCES.txt +0 -0
  29. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross.egg-info/dependency_links.txt +0 -0
  30. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross.egg-info/entry_points.txt +0 -0
  31. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross.egg-info/requires.txt +0 -0
  32. {gitacross-2.3.1 → gitacross-2.3.2}/src/gitacross.egg-info/top_level.txt +0 -0
  33. {gitacross-2.3.1 → gitacross-2.3.2}/tests/test_config.py +0 -0
  34. {gitacross-2.3.1 → gitacross-2.3.2}/tests/test_git.py +0 -0
  35. {gitacross-2.3.1 → gitacross-2.3.2}/tests/test_integration.py +0 -0
  36. {gitacross-2.3.1 → gitacross-2.3.2}/tests/test_linter.py +0 -0
  37. {gitacross-2.3.1 → gitacross-2.3.2}/tests/test_providers.py +0 -0
  38. {gitacross-2.3.1 → gitacross-2.3.2}/tests/test_renderer.py +0 -0
  39. {gitacross-2.3.1 → gitacross-2.3.2}/tests/test_state.py +0 -0
  40. {gitacross-2.3.1 → gitacross-2.3.2}/tests/test_sync_api.py +0 -0
  41. {gitacross-2.3.1 → gitacross-2.3.2}/tests/test_target.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitacross
3
- Version: 2.3.1
3
+ Version: 2.3.2
4
4
  Summary: Mirror releases and git commits across platforms (Gitea, GitHub, local) with transform pipelines.
5
5
  Author: Matthew Deik
6
6
  License-Expression: MIT
@@ -223,7 +223,7 @@ Stream each asset upload directly from the temporary download directory on disk
223
223
 
224
224
  ### Source mode: release, tag, or commit
225
225
 
226
- Remote sources sync from the host's **API releases** by default. Two alternatives are available: git tags, or the latest commit of a branch. A `sync_from` key on the source sets the starting point — only releases from that tag onward are synced.
226
+ Remote sources sync from the host's **API releases** by default. Two alternatives are available: git tags, or the latest commit of a branch. A `sync_from` key on the source sets the starting point — only releases from that tag onward are synced. If the exact tag no longer exists (renamed or deleted upstream), releases with a **higher version** are synced instead, so an absent anchor never blocks newer releases. Both SemVer tags (`v1.2.3`) and calendar tags (`2024.05.01`, `2024-05-01`) are compared version-wise; the two schemes are never confused with each other.
227
227
 
228
228
  Synced state is keyed by **tag name**, so switching a repo between `release` and `tag` modes is safe: already-synced tags are skipped regardless of the current mode (older state files keyed by API release id are migrated automatically).
229
229
 
@@ -194,7 +194,7 @@ Stream each asset upload directly from the temporary download directory on disk
194
194
 
195
195
  ### Source mode: release, tag, or commit
196
196
 
197
- Remote sources sync from the host's **API releases** by default. Two alternatives are available: git tags, or the latest commit of a branch. A `sync_from` key on the source sets the starting point — only releases from that tag onward are synced.
197
+ Remote sources sync from the host's **API releases** by default. Two alternatives are available: git tags, or the latest commit of a branch. A `sync_from` key on the source sets the starting point — only releases from that tag onward are synced. If the exact tag no longer exists (renamed or deleted upstream), releases with a **higher version** are synced instead, so an absent anchor never blocks newer releases. Both SemVer tags (`v1.2.3`) and calendar tags (`2024.05.01`, `2024-05-01`) are compared version-wise; the two schemes are never confused with each other.
198
198
 
199
199
  Synced state is keyed by **tag name**, so switching a repo between `release` and `tag` modes is safe: already-synced tags are skipped regardless of the current mode (older state files keyed by API release id are migrated automatically).
200
200
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitacross"
7
- version = "2.3.1"
7
+ version = "2.3.2"
8
8
  description = "Mirror releases and git commits across platforms (Gitea, GitHub, local) with transform pipelines."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
 
7
7
  import fnmatch
8
8
  import logging
9
+ import re
9
10
  from pathlib import Path
10
11
  from typing import final
11
12
 
@@ -31,20 +32,122 @@ def create_source(config, cache_dir, dry_run=False):
31
32
  )
32
33
 
33
34
 
34
- def _filter_from_sync_point(releases, sync_from):
35
- """Filter to releases at or after the sync_from tag (inclusive).
35
+ # Calendar versions start with a 4-digit year (YYYY[.MM[.DD[...]]], e.g.
36
+ # 2024.05.01 or 2024-05-01). Every dot/dash/underscore-separated numeric
37
+ # field is part of the date sequence.
38
+ _CALENDAR_RE = re.compile(r"[^0-9]*(\d{4}(?:[._-]\d{1,4})*)")
39
+ # SemVer-style cores are dot-separated numbers; a hyphen introduces a
40
+ # prerelease, never another core field.
41
+ _SEMVER_CORE_RE = re.compile(r"(\d+(?:\.\d+)*)")
42
+
43
+
44
+ def _version_key(tag):
45
+ """Parse a version-like tag into a comparable ``(fields, prerelease)`` key.
46
+
47
+ Two schemes are supported, parsed separately because they are semantically
48
+ different:
36
49
 
37
- Releases must already be sorted oldest-first. If sync_from is empty
38
- return all releases. If the tag isn't found, return nothing —
39
- better to skip than silently backfill history.
50
+ * **SemVer** — ``v1.2.3``, ``v1.2.3-rc.1``. Dot-separated core; a hyphen
51
+ starts a prerelease that sorts before the final release.
52
+ * **Calendar (CalVer)** — ``2024.05.01``, ``2024-05-01``, ``2024.05``.
53
+ Date-based: a leading 4-digit year marks it, and every numeric field
54
+ (dot *or* dash separated) belongs to the (year, month, day, ...)
55
+ sequence, compared as plain numbers. No major/minor/patch or SemVer
56
+ prerelease semantics apply to the date fields themselves.
57
+
58
+ Returns ``None`` when the tag carries no parseable version.
59
+ """
60
+ if not isinstance(tag, str):
61
+ return None
62
+ calendar = _CALENDAR_RE.match(tag)
63
+ if calendar:
64
+ fields = tuple(int(part) for part in re.split(r"[._-]", calendar.group(1)))
65
+ rest = tag[calendar.end():].split("+", 1)[0]
66
+ else:
67
+ match = _SEMVER_CORE_RE.search(tag)
68
+ if not match:
69
+ return None
70
+ fields = tuple(int(part) for part in match.group(1).split("."))
71
+ rest = tag[match.end():].split("+", 1)[0]
72
+ # Anything remaining after the version fields is a prerelease; build
73
+ # metadata ("+...") is ignored for ordering, per SemVer.
74
+ ids = tuple(re.findall(r"[0-9A-Za-z]+", rest))
75
+ return fields, (ids or None)
76
+
77
+
78
+ def _cmp_version_key(a, b):
79
+ """Compare two ``_version_key`` results: -1/0/1 (SemVer-ish ordering)."""
80
+ (ca, pa), (cb, pb) = a, b
81
+ width = max(len(ca), len(cb))
82
+ ca += (0,) * (width - len(ca))
83
+ cb += (0,) * (width - len(cb))
84
+ if ca != cb:
85
+ return 1 if ca > cb else -1
86
+ # Same core: a final release outranks any prerelease of it
87
+ if pa is None and pb is None:
88
+ return 0
89
+ if pa is None:
90
+ return 1
91
+ if pb is None:
92
+ return -1
93
+ # Compare prerelease identifiers: numeric < alphanumeric, identifiers
94
+ # compare numerically when both numeric, lexically when both alphanumeric
95
+ for x, y in zip(pa, pb):
96
+ xn, yn = x.isdigit(), y.isdigit()
97
+ if xn and yn:
98
+ xi, yi = int(x), int(y)
99
+ if xi != yi:
100
+ return 1 if xi > yi else -1
101
+ elif xn != yn:
102
+ return -1 if xn else 1
103
+ elif x != y:
104
+ return 1 if x > y else -1
105
+ return 1 if len(pa) > len(pb) else (-1 if len(pa) < len(pb) else 0)
106
+
107
+
108
+ def _newer_than_version(tag, cutoff):
109
+ """True when *tag* parses as a version strictly newer than *cutoff*."""
110
+ key = _version_key(tag)
111
+ return key is not None and _cmp_version_key(key, cutoff) > 0
112
+
113
+
114
+ def _filter_from_sync_point(releases, sync_from):
115
+ """Filter to releases at or after the sync_from tag.
116
+
117
+ Releases must already be sorted oldest-first. If sync_from is empty return
118
+ all releases. When the exact tag is present, everything from it onwards is
119
+ included; when it is missing (renamed, deleted, or never released), releases
120
+ whose version is strictly newer than sync_from are synced instead. Tags that
121
+ cannot be compared (no version digits) are skipped rather than silently
122
+ backfilling history.
40
123
  """
41
124
  if not sync_from:
42
125
  return releases
43
126
  for i, rel in enumerate(releases):
44
127
  if rel["tag_name"] == sync_from:
45
128
  return releases[i:]
46
- logger.warning("sync_from tag '%s' not found — syncing nothing", sync_from)
47
- return []
129
+
130
+ # Exact tag not found — fall back to releases with a newer version.
131
+ cutoff = _version_key(sync_from)
132
+ if cutoff is None:
133
+ logger.warning(
134
+ "sync_from '%s' not found and is not version-like — syncing nothing",
135
+ sync_from,
136
+ )
137
+ return []
138
+ newer = [r for r in releases if _newer_than_version(r.get("tag_name"), cutoff)]
139
+ if not newer:
140
+ logger.warning(
141
+ "sync_from tag '%s' not found and no releases are newer — syncing nothing",
142
+ sync_from,
143
+ )
144
+ else:
145
+ logger.info(
146
+ "sync_from tag '%s' not found — syncing %d release(s) newer than it",
147
+ sync_from,
148
+ len(newer),
149
+ )
150
+ return newer
48
151
 
49
152
 
50
153
  # ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitacross
3
- Version: 2.3.1
3
+ Version: 2.3.2
4
4
  Summary: Mirror releases and git commits across platforms (Gitea, GitHub, local) with transform pipelines.
5
5
  Author: Matthew Deik
6
6
  License-Expression: MIT
@@ -223,7 +223,7 @@ Stream each asset upload directly from the temporary download directory on disk
223
223
 
224
224
  ### Source mode: release, tag, or commit
225
225
 
226
- Remote sources sync from the host's **API releases** by default. Two alternatives are available: git tags, or the latest commit of a branch. A `sync_from` key on the source sets the starting point — only releases from that tag onward are synced.
226
+ Remote sources sync from the host's **API releases** by default. Two alternatives are available: git tags, or the latest commit of a branch. A `sync_from` key on the source sets the starting point — only releases from that tag onward are synced. If the exact tag no longer exists (renamed or deleted upstream), releases with a **higher version** are synced instead, so an absent anchor never blocks newer releases. Both SemVer tags (`v1.2.3`) and calendar tags (`2024.05.01`, `2024-05-01`) are compared version-wise; the two schemes are never confused with each other.
227
227
 
228
228
  Synced state is keyed by **tag name**, so switching a repo between `release` and `tag` modes is safe: already-synced tags are skipped regardless of the current mode (older state files keyed by API release id are migrated automatically).
229
229
 
@@ -103,9 +103,172 @@ def test_source_local_sync_from():
103
103
  releases2 = src2.fetch_releases()
104
104
  assert releases2 == []
105
105
 
106
+ # sync_from = missing tag but with newer releases present — the newer
107
+ # releases are synced instead of syncing nothing
108
+ cfg3 = _EndpointConfig(
109
+ {
110
+ "type": "local",
111
+ "path": str(repo),
112
+ "tag_pattern": "v*",
113
+ "sync_from": "v1.5",
114
+ },
115
+ is_source=True,
116
+ )
117
+ src3 = create_source(cfg3, tmp)
118
+ tags3 = [r["tag_name"] for r in src3.fetch_releases()]
119
+ assert tags3 == ["v2.0", "v3.0"]
120
+
106
121
  print(" ✓ source: local sync_from")
107
122
 
108
123
 
124
+ def test_filter_from_sync_point_fallback_newer_versions():
125
+ """A missing sync_from tag falls back to releases with a newer version."""
126
+ from gitacross.source import _filter_from_sync_point
127
+
128
+ def rel(tag):
129
+ return {"tag_name": tag}
130
+
131
+ all_rel = [rel("v0.8.0"), rel("v0.9.0"), rel("v1.0.0"), rel("v1.2.0"), rel("v2.0.0")]
132
+
133
+ # Exact match: everything from the tag onwards (unchanged behaviour)
134
+ assert [r["tag_name"] for r in _filter_from_sync_point(all_rel, "v0.9.0")] == [
135
+ "v0.9.0", "v1.0.0", "v1.2.0", "v2.0.0",
136
+ ]
137
+
138
+ # Missing tag: only strictly-newer versions are kept (v0.8.0 < v0.9.0)
139
+ assert [r["tag_name"] for r in _filter_from_sync_point(all_rel, "v0.9.1")] == [
140
+ "v1.0.0", "v1.2.0", "v2.0.0",
141
+ ]
142
+
143
+ # Numeric (not lexicographic) comparison: v0.10.0 > v0.9.9
144
+ assert [
145
+ r["tag_name"]
146
+ for r in _filter_from_sync_point([rel("v0.10.0"), rel("v0.9.0")], "v0.9.9")
147
+ ] == ["v0.10.0"]
148
+
149
+ # Zero-padding: v1.2 == v1.2.0, so it is NOT counted as newer
150
+ assert [
151
+ r["tag_name"]
152
+ for r in _filter_from_sync_point([rel("v1.2"), rel("v1.3")], "v1.2.0")
153
+ ] == ["v1.3"]
154
+
155
+ # No newer release and nothing comparable -> nothing synced
156
+ assert _filter_from_sync_point([rel("v0.5.0")], "v0.9.0") == []
157
+ assert _filter_from_sync_point([rel("v0.9.0"), rel("latest")], "v0.9.1") == []
158
+
159
+ # A non-version sync_from cannot be compared -> nothing synced
160
+ assert _filter_from_sync_point(all_rel, "alpha") == []
161
+
162
+ # Unversioned / non-string release tags are never counted as newer
163
+ assert _filter_from_sync_point(
164
+ [rel("latest"), {"tag_name": None}, rel("v1.0.0")], "v0.9.0"
165
+ ) == [rel("v1.0.0")]
166
+
167
+ # SemVer: a prerelease of a version is OLDER than its final release, so a
168
+ # missing v1.2.3 never pulls in v1.2.3-rc.1 — only truly newer versions do
169
+ assert [
170
+ r["tag_name"]
171
+ for r in _filter_from_sync_point([rel("v1.2.3-rc.1"), rel("v1.2.4")], "v1.2.3")
172
+ ] == ["v1.2.4"]
173
+
174
+ # Prerelease identifier ordering: numeric pre identifiers compare
175
+ # numerically, and a final release outranks any prerelease of its version
176
+ assert [
177
+ r["tag_name"]
178
+ for r in _filter_from_sync_point(
179
+ [rel("v1.2.3-rc.2"), rel("v1.2.3")], "v1.2.3-rc.1"
180
+ )
181
+ ] == ["v1.2.3-rc.2", "v1.2.3"]
182
+
183
+ # SemVer: numeric prerelease identifiers sort BEFORE alphanumeric ones, and
184
+ # a final release outranks every prerelease of its version
185
+ assert [
186
+ r["tag_name"]
187
+ for r in _filter_from_sync_point(
188
+ [rel("v1.0.0-1"), rel("v1.0.0-10"), rel("v1.0.0-beta"), rel("v1.0.0")],
189
+ "v1.0.0-2",
190
+ )
191
+ ] == ["v1.0.0-10", "v1.0.0-beta", "v1.0.0"]
192
+
193
+ # Build metadata is ignored for ordering
194
+ assert [
195
+ r["tag_name"]
196
+ for r in _filter_from_sync_point([rel("v1.2.3+meta"), rel("v1.2.4")], "v1.2.3")
197
+ ] == ["v1.2.4"]
198
+
199
+ # Alphanumeric prerelease identifiers compare lexically (beta > alpha), and
200
+ # longer identifier lists after an equal prefix sort later
201
+ assert [
202
+ r["tag_name"]
203
+ for r in _filter_from_sync_point(
204
+ [rel("v1.0.0-alpha.1"), rel("v1.0.0-beta"), rel("v1.0.0")],
205
+ "v1.0.0-alpha",
206
+ )
207
+ ] == ["v1.0.0-alpha.1", "v1.0.0-beta", "v1.0.0"]
208
+ assert [
209
+ r["tag_name"]
210
+ for r in _filter_from_sync_point(
211
+ [
212
+ rel("v1.0.0-alpha"),
213
+ rel("v1.0.0-beta"),
214
+ rel("v1.0.0-alpha.2"),
215
+ rel("v1.0.0-alpha.1.beta"),
216
+ ],
217
+ "v1.0.0-alpha.1",
218
+ )
219
+ ] == ["v1.0.0-beta", "v1.0.0-alpha.2", "v1.0.0-alpha.1.beta"]
220
+
221
+ # ── Calendar versions (CalVer) are date-based, not SemVer ──
222
+ # Dot-shaped dates compare as plain (year, month, day) numbers
223
+ assert [
224
+ r["tag_name"]
225
+ for r in _filter_from_sync_point(
226
+ [rel("2024.01.15"), rel("2024.04.30"), rel("2024.05.01")], "2024.04.01"
227
+ )
228
+ ] == ["2024.04.30", "2024.05.01"]
229
+
230
+ # Dash-separated dates: '-' joins date fields; it is NOT a prerelease
231
+ # marker, so 2024-06-01 is newer than 2024-05-01
232
+ assert [
233
+ r["tag_name"]
234
+ for r in _filter_from_sync_point(
235
+ [rel("2024-04-15"), rel("2024-06-01")], "2024-05-01"
236
+ )
237
+ ] == ["2024-06-01"]
238
+
239
+ # Month-only anchor (2024.05 = May) vs day releases
240
+ assert [
241
+ r["tag_name"]
242
+ for r in _filter_from_sync_point([rel("2024.04"), rel("2024.05.10")], "2024.05")
243
+ ] == ["2024.05.10"]
244
+
245
+ # A prerelease of the same calendar date is not newer than the date itself
246
+ assert [
247
+ r["tag_name"]
248
+ for r in _filter_from_sync_point(
249
+ [rel("2024.05.01-rc.1"), rel("2024.05.02")], "2024.05.01"
250
+ )
251
+ ] == ["2024.05.02"]
252
+
253
+ # Scheme switch on one repo: a 2024.x calendar tag clears a v2-era SemVer
254
+ # anchor, while a v9.x tag never clears a 2024 calendar anchor
255
+ assert [
256
+ r["tag_name"]
257
+ for r in _filter_from_sync_point([rel("v2.9.0"), rel("2024.01.15")], "v2.9.9")
258
+ ] == ["2024.01.15"]
259
+ assert [
260
+ r["tag_name"]
261
+ for r in _filter_from_sync_point(
262
+ [rel("2024.02.01"), rel("v9.9.9")], "2024.01.15"
263
+ )
264
+ ] == ["2024.02.01"]
265
+
266
+ # No sync_from -> everything
267
+ assert len(_filter_from_sync_point(all_rel, "")) == 5
268
+
269
+ print(" ✓ source: missing sync_from falls back to newer versions")
270
+
271
+
109
272
  def test_source_remote_mode_tag_sync_from():
110
273
  """mode: tag reads releases straight from git tags, no API involved."""
111
274
  from unittest import mock
File without changes
File without changes
File without changes
File without changes