gitacross 2.3.1__tar.gz → 2.4.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.
- {gitacross-2.3.1/src/gitacross.egg-info → gitacross-2.4.0}/PKG-INFO +12 -7
- {gitacross-2.3.1 → gitacross-2.4.0}/README.md +11 -6
- {gitacross-2.3.1 → gitacross-2.4.0}/pyproject.toml +1 -1
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/linter/fixer.py +10 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/linter/validator.py +32 -5
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/renderer.py +39 -4
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/source.py +110 -7
- {gitacross-2.3.1 → gitacross-2.4.0/src/gitacross.egg-info}/PKG-INFO +12 -7
- {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_linter.py +69 -1
- {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_renderer.py +89 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_source.py +163 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/LICENSE +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/setup.cfg +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/__init__.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/__main__.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/cli.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/config.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/git.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/linter/__init__.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/linter/keys.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/linter/models.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/main.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/providers/__init__.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/providers/base.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/providers/gitea.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/providers/github.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/retry.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/state.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross/target.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross.egg-info/SOURCES.txt +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross.egg-info/dependency_links.txt +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross.egg-info/entry_points.txt +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross.egg-info/requires.txt +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/src/gitacross.egg-info/top_level.txt +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_config.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_git.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_integration.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_providers.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_state.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_sync_api.py +0 -0
- {gitacross-2.3.1 → gitacross-2.4.0}/tests/test_target.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitacross
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
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
|
|
|
@@ -390,17 +390,22 @@ In `regex` mode, backreferences (e.g. `\1`) are expanded before the casing adapt
|
|
|
390
390
|
| Field | Required | Description |
|
|
391
391
|
|---|---|---|
|
|
392
392
|
| `path` | yes | File path to create (parent dirs auto-created) |
|
|
393
|
-
| `content` |
|
|
393
|
+
| `content` | one of | Inline file contents. Takes precedence over `src` when both are given |
|
|
394
|
+
| `src` | one of | Path of an existing **regular file** to copy in — binary-safe, mode & timestamps preserved (relative to where you run gitacross) |
|
|
395
|
+
|
|
396
|
+
Provide inline `content` or point `src` at an existing file (`content` wins if both are set — use `content: ""` for an empty file). Inline `content` is written as UTF-8 text with `\n` line endings on every OS. `src` copies the file in with `shutil.copy2` — no decode/encode round-trip and no newline translation — so binary assets work and the result is byte-identical on any platform. `src` must point to a single file, not a directory.
|
|
394
397
|
|
|
395
398
|
```yaml
|
|
396
399
|
- add:
|
|
397
400
|
- path: .github/FUNDING.yml
|
|
398
401
|
content: |
|
|
399
402
|
github: myuser
|
|
400
|
-
- path:
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
403
|
+
- path: LICENSE
|
|
404
|
+
src: ../templates/LICENSE # copy an existing file instead of inlining it
|
|
405
|
+
- path: assets/favicon.ico
|
|
406
|
+
src: ../../branding/favicon.ico # binary files work too
|
|
407
|
+
- path: tools/deploy.sh
|
|
408
|
+
src: ./scripts/deploy.sh # executable bit is preserved (copy2)
|
|
404
409
|
```
|
|
405
410
|
|
|
406
411
|
#### `validate`
|
|
@@ -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
|
|
|
@@ -361,17 +361,22 @@ In `regex` mode, backreferences (e.g. `\1`) are expanded before the casing adapt
|
|
|
361
361
|
| Field | Required | Description |
|
|
362
362
|
|---|---|---|
|
|
363
363
|
| `path` | yes | File path to create (parent dirs auto-created) |
|
|
364
|
-
| `content` |
|
|
364
|
+
| `content` | one of | Inline file contents. Takes precedence over `src` when both are given |
|
|
365
|
+
| `src` | one of | Path of an existing **regular file** to copy in — binary-safe, mode & timestamps preserved (relative to where you run gitacross) |
|
|
366
|
+
|
|
367
|
+
Provide inline `content` or point `src` at an existing file (`content` wins if both are set — use `content: ""` for an empty file). Inline `content` is written as UTF-8 text with `\n` line endings on every OS. `src` copies the file in with `shutil.copy2` — no decode/encode round-trip and no newline translation — so binary assets work and the result is byte-identical on any platform. `src` must point to a single file, not a directory.
|
|
365
368
|
|
|
366
369
|
```yaml
|
|
367
370
|
- add:
|
|
368
371
|
- path: .github/FUNDING.yml
|
|
369
372
|
content: |
|
|
370
373
|
github: myuser
|
|
371
|
-
- path:
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
374
|
+
- path: LICENSE
|
|
375
|
+
src: ../templates/LICENSE # copy an existing file instead of inlining it
|
|
376
|
+
- path: assets/favicon.ico
|
|
377
|
+
src: ../../branding/favicon.ico # binary files work too
|
|
378
|
+
- path: tools/deploy.sh
|
|
379
|
+
src: ./scripts/deploy.sh # executable bit is preserved (copy2)
|
|
375
380
|
```
|
|
376
381
|
|
|
377
382
|
#### `validate`
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitacross"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.4.0"
|
|
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"
|
|
@@ -244,6 +244,16 @@ class ConfigFixer:
|
|
|
244
244
|
"Removed redundant 'case_sensitive: true' in validate operation",
|
|
245
245
|
project_name=p_name,
|
|
246
246
|
)
|
|
247
|
+
if (
|
|
248
|
+
op_type == "add"
|
|
249
|
+
and item.get("content") is not None
|
|
250
|
+
and item.get("src")
|
|
251
|
+
):
|
|
252
|
+
del item["src"]
|
|
253
|
+
self._add(
|
|
254
|
+
"Removed redundant 'src' in add operation ('content' takes precedence)",
|
|
255
|
+
project_name=p_name,
|
|
256
|
+
)
|
|
247
257
|
|
|
248
258
|
|
|
249
259
|
|
|
@@ -693,14 +693,41 @@ class ConfigLinter:
|
|
|
693
693
|
|
|
694
694
|
elif op_type == "add":
|
|
695
695
|
for item in items:
|
|
696
|
-
if (
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
696
|
+
if not isinstance(item, dict):
|
|
697
|
+
self._add(
|
|
698
|
+
LintSeverity.ERROR,
|
|
699
|
+
"Add operation item requires 'path' and either 'content' or 'src'.",
|
|
700
|
+
project_name=p_name,
|
|
701
|
+
)
|
|
702
|
+
continue
|
|
703
|
+
if not item.get("path") or (
|
|
704
|
+
item.get("content") is None and not item.get("src")
|
|
705
|
+
):
|
|
706
|
+
self._add(
|
|
707
|
+
LintSeverity.ERROR,
|
|
708
|
+
"Add operation item requires 'path' and either 'content' or 'src'.",
|
|
709
|
+
project_name=p_name,
|
|
710
|
+
)
|
|
711
|
+
if item.get("content") is not None and not isinstance(
|
|
712
|
+
item["content"], str
|
|
700
713
|
):
|
|
701
714
|
self._add(
|
|
702
715
|
LintSeverity.ERROR,
|
|
703
|
-
"Add
|
|
716
|
+
"Add option 'content' must be a string.",
|
|
717
|
+
project_name=p_name,
|
|
718
|
+
)
|
|
719
|
+
if item.get("src") is not None and not isinstance(
|
|
720
|
+
item["src"], str
|
|
721
|
+
):
|
|
722
|
+
self._add(
|
|
723
|
+
LintSeverity.ERROR,
|
|
724
|
+
"Add option 'src' must be a string path to an existing file.",
|
|
725
|
+
project_name=p_name,
|
|
726
|
+
)
|
|
727
|
+
if item.get("content") is not None and item.get("src"):
|
|
728
|
+
self._add(
|
|
729
|
+
LintSeverity.REDUNDANT,
|
|
730
|
+
"'src' is ignored when 'content' is present in add operation.",
|
|
704
731
|
project_name=p_name,
|
|
705
732
|
)
|
|
706
733
|
|
|
@@ -196,9 +196,17 @@ def _match_case(matched, replacement):
|
|
|
196
196
|
def _op_add(work, ops):
|
|
197
197
|
"""Create files in the work tree before commit.
|
|
198
198
|
|
|
199
|
-
Each op: {path: str, content
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
Each op: {path: str, content?: str, src?: str}
|
|
200
|
+
- content: inline file contents — takes precedence over ``src`` when both
|
|
201
|
+
are given (even an empty string). Written as UTF-8 text with ``\n`` line
|
|
202
|
+
endings on every OS.
|
|
203
|
+
- src: path of an existing regular file to copy in — binary-safe (no
|
|
204
|
+
decode/encode round-trip) via :func:`shutil.copy2`, so the bytes as well
|
|
205
|
+
as the mode/timestamps are preserved. Directories are rejected; relative
|
|
206
|
+
paths resolve against the current working directory.
|
|
207
|
+
If neither is given the file is created empty. Raises if the path already
|
|
208
|
+
exists (avoids silently overwriting). Creates parent directories
|
|
209
|
+
automatically.
|
|
202
210
|
"""
|
|
203
211
|
for op in ops:
|
|
204
212
|
path = op["path"]
|
|
@@ -206,10 +214,37 @@ def _op_add(work, ops):
|
|
|
206
214
|
if target.exists():
|
|
207
215
|
raise RuntimeError(f"Add conflict: '{path}' already exists")
|
|
208
216
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
-
|
|
217
|
+
content = op.get("content")
|
|
218
|
+
if content is not None:
|
|
219
|
+
_write_text_lf(target, content)
|
|
220
|
+
else:
|
|
221
|
+
src = op.get("src")
|
|
222
|
+
if not src:
|
|
223
|
+
_write_text_lf(target, "")
|
|
224
|
+
else:
|
|
225
|
+
source = Path(src)
|
|
226
|
+
if not source.is_file():
|
|
227
|
+
if source.exists():
|
|
228
|
+
raise RuntimeError(
|
|
229
|
+
f"Add failed: 'src' '{src}' is not a file (path: '{path}') — only regular files can be added"
|
|
230
|
+
)
|
|
231
|
+
raise RuntimeError(
|
|
232
|
+
f"Add failed: source file '{src}' not found for '{path}'"
|
|
233
|
+
)
|
|
234
|
+
_ = shutil.copy2(source, target)
|
|
210
235
|
logger.debug("Added file: %s", path)
|
|
211
236
|
|
|
212
237
|
|
|
238
|
+
def _write_text_lf(path, text):
|
|
239
|
+
"""Write *text* as UTF-8 with ``\n`` endings regardless of the host OS.
|
|
240
|
+
|
|
241
|
+
Text mode on Windows would otherwise translate ``\n`` to ``\r\n``
|
|
242
|
+
(``os.linesep``), making inline content depend on where gitacross runs.
|
|
243
|
+
"""
|
|
244
|
+
with open(path, "w", encoding="utf-8", newline="\n") as f:
|
|
245
|
+
_ = f.write(text)
|
|
246
|
+
|
|
247
|
+
|
|
213
248
|
def _op_validate(work, ops):
|
|
214
249
|
"""Assert conditions on the final tree. Aborts on failure.
|
|
215
250
|
|
|
@@ -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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
+
Version: 2.4.0
|
|
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
|
|
|
@@ -390,17 +390,22 @@ In `regex` mode, backreferences (e.g. `\1`) are expanded before the casing adapt
|
|
|
390
390
|
| Field | Required | Description |
|
|
391
391
|
|---|---|---|
|
|
392
392
|
| `path` | yes | File path to create (parent dirs auto-created) |
|
|
393
|
-
| `content` |
|
|
393
|
+
| `content` | one of | Inline file contents. Takes precedence over `src` when both are given |
|
|
394
|
+
| `src` | one of | Path of an existing **regular file** to copy in — binary-safe, mode & timestamps preserved (relative to where you run gitacross) |
|
|
395
|
+
|
|
396
|
+
Provide inline `content` or point `src` at an existing file (`content` wins if both are set — use `content: ""` for an empty file). Inline `content` is written as UTF-8 text with `\n` line endings on every OS. `src` copies the file in with `shutil.copy2` — no decode/encode round-trip and no newline translation — so binary assets work and the result is byte-identical on any platform. `src` must point to a single file, not a directory.
|
|
394
397
|
|
|
395
398
|
```yaml
|
|
396
399
|
- add:
|
|
397
400
|
- path: .github/FUNDING.yml
|
|
398
401
|
content: |
|
|
399
402
|
github: myuser
|
|
400
|
-
- path:
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
403
|
+
- path: LICENSE
|
|
404
|
+
src: ../templates/LICENSE # copy an existing file instead of inlining it
|
|
405
|
+
- path: assets/favicon.ico
|
|
406
|
+
src: ../../branding/favicon.ico # binary files work too
|
|
407
|
+
- path: tools/deploy.sh
|
|
408
|
+
src: ./scripts/deploy.sh # executable bit is preserved (copy2)
|
|
404
409
|
```
|
|
405
410
|
|
|
406
411
|
#### `validate`
|
|
@@ -859,6 +859,9 @@ def test_linter_operations_kitchen_sink():
|
|
|
859
859
|
- replace: [search, replace]
|
|
860
860
|
- replace: [{search: a, replace: b, pattern: banana}]
|
|
861
861
|
- add: [{}]
|
|
862
|
+
- add: [{path: no-body}]
|
|
863
|
+
- add: [{path: n, content: 123}]
|
|
864
|
+
- add: [{path: n, src: 123}]
|
|
862
865
|
- validate: [not-a-dict]
|
|
863
866
|
""")
|
|
864
867
|
assert not report.is_valid
|
|
@@ -871,10 +874,35 @@ def test_linter_operations_kitchen_sink():
|
|
|
871
874
|
assert "Rename pattern mode 'glob' is not supported" in msgs
|
|
872
875
|
assert "Replace operation item requires 'search' and 'replace'" in msgs
|
|
873
876
|
assert "Invalid replace pattern mode 'banana'" in msgs
|
|
874
|
-
assert "Add operation item requires 'path' and 'content'" in msgs
|
|
877
|
+
assert "Add operation item requires 'path' and either 'content' or 'src'" in msgs
|
|
878
|
+
assert "Add option 'content' must be a string" in msgs
|
|
879
|
+
assert "Add option 'src' must be a string path" in msgs
|
|
875
880
|
assert "Validate operation item requires 'path'" in msgs
|
|
876
881
|
print(" ✓ linter: operation kitchen-sink validation")
|
|
877
882
|
|
|
883
|
+
|
|
884
|
+
def test_linter_add_src_and_precedence():
|
|
885
|
+
"""add accepts 'content' or 'src'; both together is redundant, not an error."""
|
|
886
|
+
from gitacross.linter import ConfigLinter
|
|
887
|
+
|
|
888
|
+
linter = ConfigLinter()
|
|
889
|
+
report = linter.lint_yaml_string("""
|
|
890
|
+
- name: p
|
|
891
|
+
source: {type: local, path: /a}
|
|
892
|
+
target: {type: local, path: /b}
|
|
893
|
+
renderer:
|
|
894
|
+
operations:
|
|
895
|
+
- add: [{path: a.txt, content: hi}]
|
|
896
|
+
- add: [{path: b.txt, src: tmpl.txt}]
|
|
897
|
+
- add: [{path: c.txt, content: hi, src: tmpl.txt}]
|
|
898
|
+
- add: [{path: d.txt, content: ""}]
|
|
899
|
+
""")
|
|
900
|
+
assert report.is_valid # redundant 'src' + no type errors
|
|
901
|
+
red = " | ".join(r.message for r in report.redundant)
|
|
902
|
+
assert "'src' is ignored when 'content' is present" in red
|
|
903
|
+
assert len(report.errors) == 0
|
|
904
|
+
print(" ✓ linter: add accepts content or src (content wins when both given)")
|
|
905
|
+
|
|
878
906
|
def test_fix_report_and_issue_formatting():
|
|
879
907
|
"""FixIssue strings and FixReport.format_text variants."""
|
|
880
908
|
from gitacross.linter import FixIssue, FixReport
|
|
@@ -1029,3 +1057,43 @@ def test_fixer_missing_endpoint_sections():
|
|
|
1029
1057
|
).is_valid
|
|
1030
1058
|
print(" ✓ fixer: missing endpoint sections and junk operations handled")
|
|
1031
1059
|
|
|
1060
|
+
|
|
1061
|
+
def test_fixer_add_drops_src_shadowed_by_content():
|
|
1062
|
+
"""'src' is dead weight next to 'content' — --fix removes it, keeps src alone."""
|
|
1063
|
+
import yaml
|
|
1064
|
+
|
|
1065
|
+
from gitacross.linter import ConfigFixer
|
|
1066
|
+
|
|
1067
|
+
fixer = ConfigFixer()
|
|
1068
|
+
r = fixer.fix_yaml_string(
|
|
1069
|
+
"- name: p\n"
|
|
1070
|
+
+ " source: {type: local, path: /a}\n"
|
|
1071
|
+
+ " target: {type: local, path: /b}\n"
|
|
1072
|
+
+ " renderer:\n"
|
|
1073
|
+
+ " operations:\n"
|
|
1074
|
+
+ " - add:\n"
|
|
1075
|
+
+ " - path: a.txt\n"
|
|
1076
|
+
+ " content: hi\n"
|
|
1077
|
+
+ " src: tmpl.txt\n"
|
|
1078
|
+
+ " - path: b.txt\n"
|
|
1079
|
+
+ " src: tmpl.txt\n"
|
|
1080
|
+
)
|
|
1081
|
+
assert r.is_valid
|
|
1082
|
+
assert any("'src' in add operation" in f.message for f in r.fixes)
|
|
1083
|
+
adds = yaml.safe_load(r.content)[0]["renderer"]["operations"][0]["add"]
|
|
1084
|
+
assert "src" not in adds[0]
|
|
1085
|
+
assert adds[0]["content"] == "hi"
|
|
1086
|
+
assert adds[1]["src"] == "tmpl.txt" # src alone is untouched
|
|
1087
|
+
|
|
1088
|
+
# content: null does NOT shadow src → src kept
|
|
1089
|
+
r = fixer.fix_yaml_string(
|
|
1090
|
+
"- name: p\n"
|
|
1091
|
+
+ " source: {type: local, path: /a}\n"
|
|
1092
|
+
+ " target: {type: local, path: /b}\n"
|
|
1093
|
+
+ " renderer:\n"
|
|
1094
|
+
+ " operations:\n"
|
|
1095
|
+
+ " - add: [{path: a.txt, content: null, src: tmpl.txt}]\n"
|
|
1096
|
+
)
|
|
1097
|
+
assert r.is_valid and not r.fixes
|
|
1098
|
+
print(" ✓ fixer: add op drops 'src' shadowed by 'content'")
|
|
1099
|
+
|
|
@@ -431,6 +431,95 @@ def test_renderer_add():
|
|
|
431
431
|
print(" ✓ renderer add")
|
|
432
432
|
|
|
433
433
|
|
|
434
|
+
def test_renderer_add_from_src():
|
|
435
|
+
"""add can copy an existing file via 'src'; inline 'content' wins over it."""
|
|
436
|
+
from gitacross.config import ProjectConfig
|
|
437
|
+
from gitacross.renderer import apply_operations
|
|
438
|
+
|
|
439
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
440
|
+
root = Path(tmp)
|
|
441
|
+
# Source template lives *outside* the exported work tree (as in real
|
|
442
|
+
# runs — the work tree is an ephemeral export, so 'src' resolves
|
|
443
|
+
# against the process working directory).
|
|
444
|
+
template = Path(tmp) / "FUNDING.tmpl"
|
|
445
|
+
_ = template.write_text("github: myuser\n", encoding="utf-8")
|
|
446
|
+
_ = _make_file(root, "README.md")
|
|
447
|
+
|
|
448
|
+
ops = [
|
|
449
|
+
{
|
|
450
|
+
"add": [
|
|
451
|
+
{"path": ".github/FUNDING.yml", "src": str(template)},
|
|
452
|
+
# content wins, even an empty string
|
|
453
|
+
{"path": "inline.txt", "content": "inline", "src": str(template)},
|
|
454
|
+
{"path": "empty.txt", "content": "", "src": str(template)},
|
|
455
|
+
]
|
|
456
|
+
}
|
|
457
|
+
]
|
|
458
|
+
project = ProjectConfig(_make_project_raw(ops))
|
|
459
|
+
apply_operations(root, project)
|
|
460
|
+
|
|
461
|
+
assert (root / ".github/FUNDING.yml").read_text() == "github: myuser\n"
|
|
462
|
+
assert (root / "inline.txt").read_text() == "inline"
|
|
463
|
+
assert (root / "empty.txt").read_text() == ""
|
|
464
|
+
|
|
465
|
+
# Missing source file -> loud error naming both paths
|
|
466
|
+
ops = [{"add": [{"path": "x.txt", "src": str(root / "nope.txt")}]}]
|
|
467
|
+
project = ProjectConfig(_make_project_raw(ops))
|
|
468
|
+
try:
|
|
469
|
+
apply_operations(root, project)
|
|
470
|
+
assert False, "should raise"
|
|
471
|
+
except RuntimeError as e:
|
|
472
|
+
assert "source file" in str(e) and "x.txt" in str(e)
|
|
473
|
+
|
|
474
|
+
# Directory as src -> explicit rejection, not a confusing copy
|
|
475
|
+
(root / "adir").mkdir()
|
|
476
|
+
ops = [{"add": [{"path": "y.txt", "src": str(root / "adir")}]}]
|
|
477
|
+
project = ProjectConfig(_make_project_raw(ops))
|
|
478
|
+
try:
|
|
479
|
+
apply_operations(root, project)
|
|
480
|
+
assert False, "should raise"
|
|
481
|
+
except RuntimeError as e:
|
|
482
|
+
assert "is not a file" in str(e) and "y.txt" in str(e)
|
|
483
|
+
|
|
484
|
+
print(" ✓ renderer add from src file")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def test_renderer_add_src_binary():
|
|
488
|
+
"""src copies non-UTF-8/binary files byte-for-byte; copy2 keeps the mode."""
|
|
489
|
+
from gitacross.config import ProjectConfig
|
|
490
|
+
from gitacross.renderer import apply_operations
|
|
491
|
+
|
|
492
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
493
|
+
root = Path(tmp)
|
|
494
|
+
# Every byte value 0..255 (invalid UTF-8) + NULs + no trailing newline
|
|
495
|
+
payload = bytes(range(256)) * 3 + b"\x00\xfftail"
|
|
496
|
+
asset = root / "logo.bin"
|
|
497
|
+
_ = asset.write_bytes(payload)
|
|
498
|
+
_ = _make_file(root, "README.md")
|
|
499
|
+
if os.name != "nt": # exec bit is a POSIX concept
|
|
500
|
+
os.chmod(asset, 0o755)
|
|
501
|
+
|
|
502
|
+
ops = [{"add": [{"path": "assets/deep/logo.bin", "src": str(asset)}]}]
|
|
503
|
+
project = ProjectConfig(_make_project_raw(ops))
|
|
504
|
+
apply_operations(root, project)
|
|
505
|
+
|
|
506
|
+
copied = root / "assets/deep/logo.bin"
|
|
507
|
+
assert copied.exists()
|
|
508
|
+
assert copied.read_bytes() == payload # exact byte identity
|
|
509
|
+
assert asset.read_bytes() == payload # source untouched
|
|
510
|
+
assert (root / "README.md").exists()
|
|
511
|
+
if os.name != "nt":
|
|
512
|
+
assert os.stat(copied).st_mode & 0o777 == 0o755 # mode preserved
|
|
513
|
+
|
|
514
|
+
# empty add item (no content/src) still creates an empty file
|
|
515
|
+
ops = [{"add": [{"path": "bare.txt"}]}]
|
|
516
|
+
project = ProjectConfig(_make_project_raw(ops))
|
|
517
|
+
apply_operations(root, project)
|
|
518
|
+
assert (root / "bare.txt").read_bytes() == b""
|
|
519
|
+
|
|
520
|
+
print(" ✓ renderer add copies binary src byte-for-byte (mode preserved)")
|
|
521
|
+
|
|
522
|
+
|
|
434
523
|
def test_renderer_validate_fail():
|
|
435
524
|
from gitacross.config import ProjectConfig
|
|
436
525
|
from gitacross.renderer import apply_operations
|
|
@@ -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
|
|
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
|