gitacross 2.3.0__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.
- {gitacross-2.3.0/src/gitacross.egg-info → gitacross-2.3.2}/PKG-INFO +4 -3
- {gitacross-2.3.0 → gitacross-2.3.2}/README.md +3 -2
- {gitacross-2.3.0 → gitacross-2.3.2}/pyproject.toml +1 -1
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/cli.py +7 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/config.py +37 -5
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/git.py +5 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/main.py +48 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/source.py +111 -8
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/state.py +15 -2
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/target.py +1 -1
- {gitacross-2.3.0 → gitacross-2.3.2/src/gitacross.egg-info}/PKG-INFO +4 -3
- {gitacross-2.3.0 → gitacross-2.3.2}/tests/test_config.py +79 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/tests/test_git.py +35 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/tests/test_source.py +252 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/tests/test_state.py +33 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/tests/test_sync_api.py +100 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/tests/test_target.py +54 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/LICENSE +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/setup.cfg +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/__init__.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/__main__.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/linter/__init__.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/linter/fixer.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/linter/keys.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/linter/models.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/linter/validator.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/providers/__init__.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/providers/base.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/providers/gitea.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/providers/github.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/renderer.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross/retry.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross.egg-info/SOURCES.txt +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross.egg-info/dependency_links.txt +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross.egg-info/entry_points.txt +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross.egg-info/requires.txt +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/src/gitacross.egg-info/top_level.txt +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/tests/test_integration.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/tests/test_linter.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/tests/test_providers.py +0 -0
- {gitacross-2.3.0 → gitacross-2.3.2}/tests/test_renderer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitacross
|
|
3
|
-
Version: 2.3.
|
|
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
|
|
|
@@ -443,7 +443,7 @@ GitAcross can be driven from the command line or called directly from Python.
|
|
|
443
443
|
### CLI
|
|
444
444
|
|
|
445
445
|
```
|
|
446
|
-
gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
|
|
446
|
+
gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--clean-cache] [--lint] [--fix] [-v]
|
|
447
447
|
```
|
|
448
448
|
|
|
449
449
|
| Flag | Description |
|
|
@@ -453,6 +453,7 @@ gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--re
|
|
|
453
453
|
| `--workdir PATH` | Where state and cache live (default: `.gitsync`) |
|
|
454
454
|
| `--dry-run` | Preview changes without committing or pushing |
|
|
455
455
|
| `--reset` | Clear saved state and cache before running (fresh start) |
|
|
456
|
+
| `--clean-cache` | Delete mirror caches no longer referenced by the config (e.g. after changing a repo's host or name in the config) |
|
|
456
457
|
| `--lint` | Check the config for YAML errors, invalid settings, and redundant options |
|
|
457
458
|
| `--fix` | Fix misplaced keys and remove redundant options in the config |
|
|
458
459
|
| `-v, --verbose` | Debug logging |
|
|
@@ -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
|
|
|
@@ -414,7 +414,7 @@ GitAcross can be driven from the command line or called directly from Python.
|
|
|
414
414
|
### CLI
|
|
415
415
|
|
|
416
416
|
```
|
|
417
|
-
gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
|
|
417
|
+
gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--clean-cache] [--lint] [--fix] [-v]
|
|
418
418
|
```
|
|
419
419
|
|
|
420
420
|
| Flag | Description |
|
|
@@ -424,6 +424,7 @@ gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--re
|
|
|
424
424
|
| `--workdir PATH` | Where state and cache live (default: `.gitsync`) |
|
|
425
425
|
| `--dry-run` | Preview changes without committing or pushing |
|
|
426
426
|
| `--reset` | Clear saved state and cache before running (fresh start) |
|
|
427
|
+
| `--clean-cache` | Delete mirror caches no longer referenced by the config (e.g. after changing a repo's host or name in the config) |
|
|
427
428
|
| `--lint` | Check the config for YAML errors, invalid settings, and redundant options |
|
|
428
429
|
| `--fix` | Fix misplaced keys and remove redundant options in the config |
|
|
429
430
|
| `-v, --verbose` | Debug logging |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitacross"
|
|
7
|
-
version = "2.3.
|
|
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"
|
|
@@ -50,6 +50,11 @@ def main():
|
|
|
50
50
|
default=DEFAULT_WORK_DIR,
|
|
51
51
|
help="Directory for state.yml and cache/ (default: .gitsync)",
|
|
52
52
|
)
|
|
53
|
+
_ = parser.add_argument(
|
|
54
|
+
"--clean-cache",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="Delete mirror caches no longer referenced by the config",
|
|
57
|
+
)
|
|
53
58
|
_ = parser.add_argument("-v", "--verbose", action="store_true", help="Debug logging")
|
|
54
59
|
args = parser.parse_args()
|
|
55
60
|
|
|
@@ -62,6 +67,7 @@ def main():
|
|
|
62
67
|
dry_run_flag = cast(bool, args.dry_run)
|
|
63
68
|
reset_flag = cast(bool, args.reset)
|
|
64
69
|
workdir = cast(str, args.workdir)
|
|
70
|
+
clean_cache_flag = cast(bool, args.clean_cache)
|
|
65
71
|
|
|
66
72
|
_setup_logging(verbose)
|
|
67
73
|
|
|
@@ -96,6 +102,7 @@ def main():
|
|
|
96
102
|
dry_run=dry_run_flag,
|
|
97
103
|
reset=reset_flag,
|
|
98
104
|
work_dir=workdir,
|
|
105
|
+
clean_cache=clean_cache_flag,
|
|
99
106
|
)
|
|
100
107
|
except (FileNotFoundError, ValueError) as exc:
|
|
101
108
|
sys.exit(str(exc))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import logging
|
|
4
5
|
import os
|
|
5
6
|
from pathlib import Path
|
|
@@ -170,6 +171,41 @@ class _EndpointConfig:
|
|
|
170
171
|
"""Filesystem-safe repo identifier (``owner/name`` → ``owner_name``)."""
|
|
171
172
|
return self.repo.replace("/", "_")
|
|
172
173
|
|
|
174
|
+
@property
|
|
175
|
+
def host(self):
|
|
176
|
+
"""Git host for a remote endpoint.
|
|
177
|
+
|
|
178
|
+
GitHub's API lives at api.github.com, but its git host is github.com.
|
|
179
|
+
Gitea's API typically lives on the same host as git, so no transform is
|
|
180
|
+
needed there. Returns "" when no ``api`` is configured.
|
|
181
|
+
"""
|
|
182
|
+
if not self.api:
|
|
183
|
+
return ""
|
|
184
|
+
raw_host = self.api.split("://")[1].split("/")[0] if "://" in self.api else self.api
|
|
185
|
+
return "github.com" if raw_host == "api.github.com" else raw_host
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def host_slug(self):
|
|
189
|
+
"""Filesystem-safe host identifier for cache paths.
|
|
190
|
+
|
|
191
|
+
Keeps dots and hyphens (so e.g. ``my.host.com`` and ``my-host.com`` stay
|
|
192
|
+
distinct); only the port separator and any slashes become underscores.
|
|
193
|
+
"""
|
|
194
|
+
return self.host.lower().replace(":", "_").replace("/", "_")
|
|
195
|
+
|
|
196
|
+
def mirror_dir_name(self, role):
|
|
197
|
+
"""Deterministic, collision-resistant cache directory name for a remote endpoint.
|
|
198
|
+
|
|
199
|
+
Mirrors persist between runs — that is what makes them a cache — so the
|
|
200
|
+
name must be a pure function of the endpoint identity, never random.
|
|
201
|
+
Separator-based slugs alone can still collide (e.g. ``a/b_c`` and
|
|
202
|
+
``a_b/c`` on the same host), so a short hash of the full identity
|
|
203
|
+
(role, type, host, repo) is appended to the readable prefix.
|
|
204
|
+
"""
|
|
205
|
+
identity = "\0".join([role, self.type, self.host.lower(), self.repo])
|
|
206
|
+
digest = hashlib.sha1(identity.encode("utf-8")).hexdigest()[:10]
|
|
207
|
+
return f"{role}_{self.type}_{self.host_slug}_{self.repo_slug}_{digest}.git"
|
|
208
|
+
|
|
173
209
|
@property
|
|
174
210
|
def owner(self):
|
|
175
211
|
return self.repo.split("/")[0] if "/" in self.repo else ""
|
|
@@ -179,11 +215,7 @@ class _EndpointConfig:
|
|
|
179
215
|
"""HTTPS clone URL with token embedded for auth."""
|
|
180
216
|
if not self.api or not self.repo:
|
|
181
217
|
return ""
|
|
182
|
-
|
|
183
|
-
# GitHub's API lives at api.github.com, but its git host is github.com.
|
|
184
|
-
# Gitea's API typically lives on the same host as git, so no transform needed.
|
|
185
|
-
host = "github.com" if raw_host == "api.github.com" else raw_host
|
|
186
|
-
return f"https://{self.owner}:{self.token}@{host}/{self.repo}.git"
|
|
218
|
+
return f"https://{self.owner}:{self.token}@{self.host}/{self.repo}.git"
|
|
187
219
|
|
|
188
220
|
|
|
189
221
|
@final
|
|
@@ -196,6 +196,11 @@ class GitRepo:
|
|
|
196
196
|
)
|
|
197
197
|
p.mkdir(parents=True, exist_ok=True)
|
|
198
198
|
_ = _git("init", str(p))
|
|
199
|
+
# Give a fresh repo a repo-local identity so commits never depend on
|
|
200
|
+
# the ambient git config (mirrors get the same treatment in
|
|
201
|
+
# ensure_mirror). Existing repos are opened as-is and left untouched.
|
|
202
|
+
_ = _git("-C", str(p), "config", "user.name", "GitAcross")
|
|
203
|
+
_ = _git("-C", str(p), "config", "user.email", "sync@gitacross")
|
|
199
204
|
logger.info("Initialised git repository at %s", p)
|
|
200
205
|
return cls.local(path)
|
|
201
206
|
|
|
@@ -446,6 +446,7 @@ def run(
|
|
|
446
446
|
dry_run: bool = False,
|
|
447
447
|
reset: bool = False,
|
|
448
448
|
work_dir: str | Path = DEFAULT_WORK_DIR,
|
|
449
|
+
clean_cache: bool = False,
|
|
449
450
|
):
|
|
450
451
|
"""Sync releases from a config — the primary Python API entry point.
|
|
451
452
|
|
|
@@ -469,6 +470,14 @@ def run(
|
|
|
469
470
|
syncing so that all releases are treated as new.
|
|
470
471
|
work_dir: Directory path for ``state.yml`` and ``cache/``.
|
|
471
472
|
Defaults to ``.gitsync``.
|
|
473
|
+
clean_cache: When ``True``, delete bare mirror clones under
|
|
474
|
+
``work_dir/cache/`` that no endpoint in the config references
|
|
475
|
+
anymore (e.g. after the config changed hosts or repos). Mirrors are
|
|
476
|
+
disposable clones, so this only ever costs a re-clone. Skipped in
|
|
477
|
+
dry-run mode. Note: mirrors are identified per config, so if
|
|
478
|
+
multiple config files share one ``work_dir``, run them with the
|
|
479
|
+
same ``clean_cache`` setting to avoid deleting each other's
|
|
480
|
+
mirrors.
|
|
472
481
|
|
|
473
482
|
Returns:
|
|
474
483
|
A list of dicts — one per enabled project that was attempted — with
|
|
@@ -522,6 +531,9 @@ def run(
|
|
|
522
531
|
if project_name and not projects:
|
|
523
532
|
raise ValueError(f"Project '{project_name}' not found in config")
|
|
524
533
|
|
|
534
|
+
if clean_cache and not dry_run and not reset:
|
|
535
|
+
_clean_mirror_cache(work_dir, config)
|
|
536
|
+
|
|
525
537
|
results = []
|
|
526
538
|
for proj in projects:
|
|
527
539
|
if not proj.enabled:
|
|
@@ -552,6 +564,42 @@ def run(
|
|
|
552
564
|
return results
|
|
553
565
|
|
|
554
566
|
|
|
567
|
+
def _clean_mirror_cache(work_dir: str | Path, config: Config) -> None:
|
|
568
|
+
"""Delete mirror clones under ``work_dir/cache/`` not referenced by *config*.
|
|
569
|
+
|
|
570
|
+
Cache keys are deterministic identity names (role, type, host, repo), so a
|
|
571
|
+
config edit that changes any of those orphans the previous mirror. Orphans
|
|
572
|
+
are harmless but waste disk, so this removes them. The reference set is
|
|
573
|
+
built from *every* project in the config — enabled or not — and remote
|
|
574
|
+
endpoints only, so running a project subset or re-enabling a disabled
|
|
575
|
+
project never deletes a mirror it still needs.
|
|
576
|
+
"""
|
|
577
|
+
cache_dir = Path(work_dir) / "cache"
|
|
578
|
+
if not cache_dir.is_dir():
|
|
579
|
+
return
|
|
580
|
+
referenced = {
|
|
581
|
+
endpoint.mirror_dir_name(role)
|
|
582
|
+
for proj in config.projects
|
|
583
|
+
for endpoint, role in ((proj.source, "source"), (proj.target, "target"))
|
|
584
|
+
if endpoint.is_remote
|
|
585
|
+
}
|
|
586
|
+
removed = []
|
|
587
|
+
for child in sorted(cache_dir.iterdir()):
|
|
588
|
+
if (
|
|
589
|
+
child.is_dir()
|
|
590
|
+
and child.name.endswith(".git")
|
|
591
|
+
and child.name not in referenced
|
|
592
|
+
):
|
|
593
|
+
shutil.rmtree(child, ignore_errors=True)
|
|
594
|
+
removed.append(child.name)
|
|
595
|
+
if removed:
|
|
596
|
+
logger.info(
|
|
597
|
+
"Removed %d stale mirror cache(s): %s",
|
|
598
|
+
len(removed),
|
|
599
|
+
", ".join(removed),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
|
|
555
603
|
|
|
556
604
|
|
|
557
605
|
# Imported at the bottom to avoid a circular import (cli.py imports .main.run).
|
|
@@ -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
|
# ---------------------------------------------------------------------------
|
|
@@ -64,7 +167,7 @@ class _RemoteSource:
|
|
|
64
167
|
self._api.ensure_repo_exists(create=not dry_run)
|
|
65
168
|
self._git = GitRepo.ensure_mirror(
|
|
66
169
|
config.clone_url,
|
|
67
|
-
Path(cache_dir) /
|
|
170
|
+
Path(cache_dir) / config.mirror_dir_name("source"),
|
|
68
171
|
)
|
|
69
172
|
self._include_prereleases = config.include_prereleases
|
|
70
173
|
self._include_drafts = config.include_drafts
|
|
@@ -26,11 +26,24 @@ class State:
|
|
|
26
26
|
return f"State(work_dir={str(self.work_dir)!r})"
|
|
27
27
|
|
|
28
28
|
def _load(self):
|
|
29
|
+
data = {}
|
|
29
30
|
if self.path.exists():
|
|
30
31
|
with open(self.path) as f:
|
|
31
|
-
data = yaml.safe_load(f)
|
|
32
|
-
|
|
32
|
+
data = yaml.safe_load(f)
|
|
33
|
+
# Tolerate degenerate or hand-edited YAML (e.g. a bare ``projects:``
|
|
34
|
+
# key with no value, non-map documents, or null entries) so a corrupt
|
|
35
|
+
# state file can never crash a run — it is simply treated as empty.
|
|
36
|
+
if not isinstance(data, dict):
|
|
33
37
|
data = {}
|
|
38
|
+
projects = data.get("projects")
|
|
39
|
+
if not isinstance(projects, dict):
|
|
40
|
+
projects = {}
|
|
41
|
+
data["projects"] = projects
|
|
42
|
+
for name, project in list(projects.items()):
|
|
43
|
+
if not isinstance(project, dict):
|
|
44
|
+
projects[name] = {}
|
|
45
|
+
elif not isinstance(project.get("releases"), dict):
|
|
46
|
+
project["releases"] = {}
|
|
34
47
|
return data
|
|
35
48
|
|
|
36
49
|
def has_release(self, project_name, tag_name):
|
|
@@ -58,7 +58,7 @@ class _RemoteTarget:
|
|
|
58
58
|
)
|
|
59
59
|
self._git = GitRepo.ensure_mirror(
|
|
60
60
|
clone_url,
|
|
61
|
-
Path(cache_dir) /
|
|
61
|
+
Path(cache_dir) / config.mirror_dir_name("target"),
|
|
62
62
|
)
|
|
63
63
|
self._retry_max = retry_max
|
|
64
64
|
self._retry_backoff = retry_backoff
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitacross
|
|
3
|
-
Version: 2.3.
|
|
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
|
|
|
@@ -443,7 +443,7 @@ GitAcross can be driven from the command line or called directly from Python.
|
|
|
443
443
|
### CLI
|
|
444
444
|
|
|
445
445
|
```
|
|
446
|
-
gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
|
|
446
|
+
gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--clean-cache] [--lint] [--fix] [-v]
|
|
447
447
|
```
|
|
448
448
|
|
|
449
449
|
| Flag | Description |
|
|
@@ -453,6 +453,7 @@ gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--re
|
|
|
453
453
|
| `--workdir PATH` | Where state and cache live (default: `.gitsync`) |
|
|
454
454
|
| `--dry-run` | Preview changes without committing or pushing |
|
|
455
455
|
| `--reset` | Clear saved state and cache before running (fresh start) |
|
|
456
|
+
| `--clean-cache` | Delete mirror caches no longer referenced by the config (e.g. after changing a repo's host or name in the config) |
|
|
456
457
|
| `--lint` | Check the config for YAML errors, invalid settings, and redundant options |
|
|
457
458
|
| `--fix` | Fix misplaced keys and remove redundant options in the config |
|
|
458
459
|
| `-v, --verbose` | Debug logging |
|
|
@@ -73,6 +73,85 @@ projects:
|
|
|
73
73
|
os.unlink(config_path)
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
def test_endpoint_host_and_host_slug():
|
|
77
|
+
"""host/host_slug identify the git host and stay filesystem-safe."""
|
|
78
|
+
from gitacross.config import _EndpointConfig
|
|
79
|
+
|
|
80
|
+
gitea = _EndpointConfig(
|
|
81
|
+
{"type": "gitea", "repo": "a/b", "api": "https://git.nodebay.top/api/v1"},
|
|
82
|
+
is_source=True,
|
|
83
|
+
)
|
|
84
|
+
assert gitea.host == "git.nodebay.top"
|
|
85
|
+
assert gitea.host_slug == "git.nodebay.top"
|
|
86
|
+
|
|
87
|
+
# GitHub's API host maps to its git host
|
|
88
|
+
gh = _EndpointConfig(
|
|
89
|
+
{"type": "github", "repo": "a/b", "api": "https://api.github.com"},
|
|
90
|
+
is_source=True,
|
|
91
|
+
)
|
|
92
|
+
assert gh.host == "github.com"
|
|
93
|
+
assert gh.host_slug == "github.com"
|
|
94
|
+
|
|
95
|
+
# Ports are kept in the slug so hosts differing only by port stay distinct
|
|
96
|
+
port = _EndpointConfig(
|
|
97
|
+
{"type": "gitea", "repo": "a/b", "api": "https://git.nodebay.top:8443/api/v1"},
|
|
98
|
+
is_source=True,
|
|
99
|
+
)
|
|
100
|
+
assert port.host_slug == "git.nodebay.top_8443"
|
|
101
|
+
|
|
102
|
+
# Dots vs hyphens are NOT collapsed into the same slug
|
|
103
|
+
assert (
|
|
104
|
+
_EndpointConfig({"api": "https://my.host.com"}, is_source=True).host_slug
|
|
105
|
+
== "my.host.com"
|
|
106
|
+
)
|
|
107
|
+
assert (
|
|
108
|
+
_EndpointConfig({"api": "https://my-host.com"}, is_source=True).host_slug
|
|
109
|
+
== "my-host.com"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# No api configured -> empty host
|
|
113
|
+
assert _EndpointConfig({"api": ""}, is_source=True).host == ""
|
|
114
|
+
print(" ✓ config: endpoint host / host_slug")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_mirror_dir_name_deterministic_and_unique():
|
|
118
|
+
"""mirror_dir_name is deterministic across runs and collision-resistant."""
|
|
119
|
+
import re
|
|
120
|
+
|
|
121
|
+
from gitacross.config import _EndpointConfig
|
|
122
|
+
|
|
123
|
+
def name(repo, api="https://git.nodebay.top/api/v1", kind="gitea", role="source"):
|
|
124
|
+
raw = {"type": kind, "repo": repo, "api": api}
|
|
125
|
+
return _EndpointConfig(raw, is_source=True).mirror_dir_name(role)
|
|
126
|
+
|
|
127
|
+
# Deterministic: same identity always yields the same cache name
|
|
128
|
+
assert name("uqkami/Awara") == name("uqkami/Awara")
|
|
129
|
+
|
|
130
|
+
# Role is part of the identity (source vs target never share a mirror)
|
|
131
|
+
assert name("uqkami/Awara", role="target") != name("uqkami/Awara", role="source")
|
|
132
|
+
|
|
133
|
+
# Readable prefix + a short hex digest before the .git suffix
|
|
134
|
+
n = name("uqkami/Awara")
|
|
135
|
+
assert n.startswith("source_gitea_git.nodebay.top_uqkami_Awara_")
|
|
136
|
+
digest = n.rsplit("_", 1)[1]
|
|
137
|
+
assert re.fullmatch(r"[0-9a-f]{10}\.git", digest)
|
|
138
|
+
|
|
139
|
+
# Slug-ambiguous owner/repo pairs must never share a mirror
|
|
140
|
+
assert name("a/b_c") != name("a_b/c")
|
|
141
|
+
|
|
142
|
+
# Host case differences resolve to the same host -> same cache (dedup)
|
|
143
|
+
upper = _EndpointConfig(
|
|
144
|
+
{"type": "gitea", "repo": "a/b", "api": "https://GIT.NODEBAY.TOP/api/v1"},
|
|
145
|
+
is_source=True,
|
|
146
|
+
).mirror_dir_name("source")
|
|
147
|
+
assert upper == name("a/b")
|
|
148
|
+
|
|
149
|
+
# Different hosts or ports stay distinct
|
|
150
|
+
assert name("a/b", api="https://git.nodebay.top:8443/api/v1") != name("a/b")
|
|
151
|
+
assert name("a/b", api="https://gitea.example.com/api/v1") != name("a/b")
|
|
152
|
+
print(" ✓ config: mirror_dir_name is deterministic and collision-resistant")
|
|
153
|
+
|
|
154
|
+
|
|
76
155
|
def test_config_local():
|
|
77
156
|
from gitacross.config import Config
|
|
78
157
|
|
|
@@ -276,6 +276,41 @@ def test_git_mirror_instance_commands_whitelisted_but_not_local():
|
|
|
276
276
|
assert not any("safe.directory" in a for a in local_cmd)
|
|
277
277
|
print(" ✓ git: mirror commands whitelisted, local repos left strict")
|
|
278
278
|
|
|
279
|
+
|
|
280
|
+
def test_git_ensure_local_new_repo_commits_without_ambient_identity(monkeypatch):
|
|
281
|
+
"""Auto-initialised repos carry a repo-local identity, so commits never
|
|
282
|
+
depend on the ambient git config.
|
|
283
|
+
|
|
284
|
+
Regression: fresh ensure_local repos had no identity configured, so commits
|
|
285
|
+
failed with exit 128 ("Please tell me who you are") on machines without a
|
|
286
|
+
global git user.name/email.
|
|
287
|
+
"""
|
|
288
|
+
from gitacross.git import GitRepo
|
|
289
|
+
|
|
290
|
+
# Strip all ambient identity: only repo-local config may be used
|
|
291
|
+
monkeypatch.setenv("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
292
|
+
monkeypatch.setenv("GIT_CONFIG_SYSTEM", "/dev/null")
|
|
293
|
+
for var in (
|
|
294
|
+
"GIT_AUTHOR_NAME",
|
|
295
|
+
"GIT_AUTHOR_EMAIL",
|
|
296
|
+
"GIT_COMMITTER_NAME",
|
|
297
|
+
"GIT_COMMITTER_EMAIL",
|
|
298
|
+
):
|
|
299
|
+
monkeypatch.delenv(var, raising=False)
|
|
300
|
+
|
|
301
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
302
|
+
repo = Path(tmp) / "repo"
|
|
303
|
+
git = GitRepo.ensure_local(repo)
|
|
304
|
+
git.ensure_branch("main")
|
|
305
|
+
|
|
306
|
+
work = Path(tmp) / "work"
|
|
307
|
+
work.mkdir()
|
|
308
|
+
_ = _make_file(work, "f.txt", "hello")
|
|
309
|
+
result = git.commit(work, "Release v1.0")
|
|
310
|
+
assert result.returncode in (0, 1)
|
|
311
|
+
assert git.head_sha()
|
|
312
|
+
print(" ✓ git: auto-initialised repos commit without ambient identity")
|
|
313
|
+
|
|
279
314
|
def test_git_reset_worktree_bare_noop():
|
|
280
315
|
"""reset_worktree is a no-op for bare repos — HEAD must stay untouched."""
|
|
281
316
|
from gitacross.git import GitRepo
|
|
@@ -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
|
|
@@ -718,6 +881,95 @@ def test_remote_source_duck_typed_helpers():
|
|
|
718
881
|
mock_api.list_release_assets.assert_called_with(7)
|
|
719
882
|
print(" ✓ source: remote passthrough helpers delegate correctly")
|
|
720
883
|
|
|
884
|
+
|
|
885
|
+
def test_remote_source_cache_separates_hosts():
|
|
886
|
+
"""Same-named repos on different hosts must not share a mirror cache dir.
|
|
887
|
+
|
|
888
|
+
Regression: mirror names only encoded type + owner/repo, so two hosts with
|
|
889
|
+
the same repo slug collided — the shared mirror flipped origin and re-fetched
|
|
890
|
+
between projects, dirtying each other's cache.
|
|
891
|
+
"""
|
|
892
|
+
from gitacross.config import _EndpointConfig
|
|
893
|
+
from gitacross.source import create_source
|
|
894
|
+
|
|
895
|
+
calls = []
|
|
896
|
+
|
|
897
|
+
def fake_ensure_mirror(url, dest):
|
|
898
|
+
calls.append((url, dest))
|
|
899
|
+
return mock.MagicMock()
|
|
900
|
+
|
|
901
|
+
host_a = _EndpointConfig(
|
|
902
|
+
{"type": "gitea", "repo": "uqkami/Awara",
|
|
903
|
+
"api": "https://git.nodebay.top/api/v1", "token": "t"},
|
|
904
|
+
is_source=True,
|
|
905
|
+
)
|
|
906
|
+
host_b = _EndpointConfig(
|
|
907
|
+
{"type": "gitea", "repo": "uqkami/Awara",
|
|
908
|
+
"api": "https://gitea.example.com/api/v1", "token": "t"},
|
|
909
|
+
is_source=True,
|
|
910
|
+
)
|
|
911
|
+
same_as_a = _EndpointConfig(
|
|
912
|
+
{"type": "gitea", "repo": "uqkami/Awara",
|
|
913
|
+
"api": "https://git.nodebay.top/api/v1", "token": "other-token"},
|
|
914
|
+
is_source=True,
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
mock_apis = [mock.MagicMock() for _ in range(3)]
|
|
918
|
+
with mock.patch(
|
|
919
|
+
"gitacross.source.get_api_client", side_effect=mock_apis
|
|
920
|
+
), mock.patch(
|
|
921
|
+
"gitacross.git.GitRepo.ensure_mirror", side_effect=fake_ensure_mirror
|
|
922
|
+
):
|
|
923
|
+
_ = create_source(host_a, "cache")
|
|
924
|
+
_ = create_source(host_b, "cache")
|
|
925
|
+
_ = create_source(same_as_a, "cache")
|
|
926
|
+
|
|
927
|
+
dest_a, dest_b, dest_same = (Path(d) for _, d in calls)
|
|
928
|
+
for d in (dest_a, dest_b):
|
|
929
|
+
assert str(d).endswith(".git")
|
|
930
|
+
assert "source_gitea_git.nodebay.top_uqkami_Awara_" in str(dest_a)
|
|
931
|
+
assert "source_gitea_gitea.example.com_uqkami_Awara_" in str(dest_b)
|
|
932
|
+
# Different hosts -> separate mirrors; same host (any token) -> shared mirror
|
|
933
|
+
assert dest_a != dest_b
|
|
934
|
+
assert dest_a == dest_same
|
|
935
|
+
print(" ✓ source: remote source cache dirs are separated per host")
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def test_remote_source_cache_hash_kills_slug_collisions():
|
|
939
|
+
"""Slug-ambiguous repos (a/b_c vs a_b/c) on one host never share a mirror."""
|
|
940
|
+
from gitacross.config import _EndpointConfig
|
|
941
|
+
from gitacross.source import create_source
|
|
942
|
+
|
|
943
|
+
calls = []
|
|
944
|
+
|
|
945
|
+
def fake_ensure_mirror(url, dest):
|
|
946
|
+
calls.append((url, dest))
|
|
947
|
+
return mock.MagicMock()
|
|
948
|
+
|
|
949
|
+
def cfg(repo):
|
|
950
|
+
return _EndpointConfig(
|
|
951
|
+
{"type": "gitea", "repo": repo,
|
|
952
|
+
"api": "https://git.nodebay.top/api/v1", "token": "t"},
|
|
953
|
+
is_source=True,
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
mock_apis = [mock.MagicMock() for _ in range(2)]
|
|
957
|
+
with mock.patch(
|
|
958
|
+
"gitacross.source.get_api_client", side_effect=mock_apis
|
|
959
|
+
), mock.patch(
|
|
960
|
+
"gitacross.git.GitRepo.ensure_mirror", side_effect=fake_ensure_mirror
|
|
961
|
+
):
|
|
962
|
+
_ = create_source(cfg("a/b_c"), "cache")
|
|
963
|
+
_ = create_source(cfg("a_b/c"), "cache")
|
|
964
|
+
|
|
965
|
+
dest1, dest2 = (Path(d) for _, d in calls)
|
|
966
|
+
# Both have the same readable slug prefix (a_b_c), but the identity hashes
|
|
967
|
+
# differ, so they resolve to distinct mirror dirs
|
|
968
|
+
assert dest1 != dest2
|
|
969
|
+
assert "a_b_c_" in str(dest1)
|
|
970
|
+
assert "a_b_c_" in str(dest2)
|
|
971
|
+
print(" ✓ source: slug collisions resolve to distinct cache dirs")
|
|
972
|
+
|
|
721
973
|
def test_local_source_duck_typed_helpers():
|
|
722
974
|
from gitacross.config import _EndpointConfig
|
|
723
975
|
from gitacross.source import create_source
|
|
@@ -74,4 +74,37 @@ def test_state():
|
|
|
74
74
|
print(" ✓ state persistence")
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
def test_state_tolerates_degenerate_yaml():
|
|
78
|
+
"""A corrupt or hand-edited state file must never crash a run.
|
|
79
|
+
|
|
80
|
+
Regression: a state.yml containing only ``projects:`` parses to
|
|
81
|
+
``{"projects": None}``, which crashed has_release with an AttributeError.
|
|
82
|
+
"""
|
|
83
|
+
from gitacross.state import State
|
|
84
|
+
|
|
85
|
+
degenerate = [
|
|
86
|
+
"projects:\n", # bare key, no value (the reported crash)
|
|
87
|
+
"projects: []\n", # list instead of map
|
|
88
|
+
"[]\n", # scalar document
|
|
89
|
+
"projects:\n myapp:\n", # null project entry
|
|
90
|
+
"projects:\n myapp:\n releases:\n", # null releases entry
|
|
91
|
+
"", # empty file
|
|
92
|
+
]
|
|
93
|
+
for i, content in enumerate(degenerate):
|
|
94
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
95
|
+
path = Path(tmp) / "state.yml"
|
|
96
|
+
_ = path.write_text(content)
|
|
97
|
+
state = State(tmp)
|
|
98
|
+
assert not state.has_release("myapp", "v1.0"), f"case {i}: {content!r}"
|
|
99
|
+
state.add_release(
|
|
100
|
+
"myapp",
|
|
101
|
+
"v1.0",
|
|
102
|
+
{"tag": "v1.0", "target_commit": "x"},
|
|
103
|
+
)
|
|
104
|
+
state.save()
|
|
105
|
+
# Round-trip: the release is now visible and nothing else crashes
|
|
106
|
+
assert State(tmp).has_release("myapp", "v1.0"), f"case {i}: {content!r}"
|
|
107
|
+
print(" ✓ state: degenerate state.yml is treated as empty, never crashes")
|
|
108
|
+
|
|
109
|
+
|
|
77
110
|
|
|
@@ -363,6 +363,106 @@ def test_run_git_missing_reports_clear_error(monkeypatch):
|
|
|
363
363
|
print(" ✓ run: missing git binary surfaces a clear error message")
|
|
364
364
|
|
|
365
365
|
|
|
366
|
+
def test_run_clean_cache_removes_orphaned_mirrors():
|
|
367
|
+
"""clean_cache deletes mirrors no config endpoint references — nothing else."""
|
|
368
|
+
import gitacross
|
|
369
|
+
|
|
370
|
+
from gitacross.config import Config
|
|
371
|
+
|
|
372
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
373
|
+
tmp = Path(tmp)
|
|
374
|
+
cfg_path = tmp / "config.yml"
|
|
375
|
+
_ = cfg_path.write_text("""
|
|
376
|
+
projects:
|
|
377
|
+
- name: proj-a
|
|
378
|
+
source:
|
|
379
|
+
type: gitea
|
|
380
|
+
repo: owner/repo
|
|
381
|
+
api: https://git.nodebay.top/api/v1
|
|
382
|
+
token: t
|
|
383
|
+
target:
|
|
384
|
+
type: local
|
|
385
|
+
path: /tmp/tgt-a
|
|
386
|
+
- name: proj-disabled
|
|
387
|
+
enabled: false
|
|
388
|
+
source:
|
|
389
|
+
type: gitea
|
|
390
|
+
repo: owner/old
|
|
391
|
+
api: https://gitea.example.com/api/v1
|
|
392
|
+
token: t
|
|
393
|
+
target:
|
|
394
|
+
type: local
|
|
395
|
+
path: /tmp/tgt-b
|
|
396
|
+
""")
|
|
397
|
+
cfg = Config(str(cfg_path))
|
|
398
|
+
referenced = cfg.projects[0].source.mirror_dir_name("source")
|
|
399
|
+
disabled_ref = cfg.projects[1].source.mirror_dir_name("source")
|
|
400
|
+
wd = tmp / "workdir"
|
|
401
|
+
|
|
402
|
+
with mock.patch("gitacross.main.sync_project", return_value=[]):
|
|
403
|
+
# (a) no cache dir yet -> clean-cache is a safe no-op
|
|
404
|
+
_ = gitacross.run(cfg, work_dir=wd, clean_cache=True)
|
|
405
|
+
|
|
406
|
+
# Build a cache with referenced + orphaned mirrors and junk
|
|
407
|
+
cache = wd / "cache"
|
|
408
|
+
(cache / referenced).mkdir(parents=True)
|
|
409
|
+
(cache / disabled_ref).mkdir()
|
|
410
|
+
orphan1 = cache / "source_gitea_git.nodebay.top_owner_old_aaaa.git"
|
|
411
|
+
orphan2 = cache / "target_github_github.com_owner_repo_bbbb.git"
|
|
412
|
+
orphan1.mkdir()
|
|
413
|
+
orphan2.mkdir()
|
|
414
|
+
(cache / "notes.txt").write_text("junk")
|
|
415
|
+
(cache / "not-a-mirror").mkdir()
|
|
416
|
+
|
|
417
|
+
# (b) orphans removed; referenced / disabled-project / junk survive
|
|
418
|
+
_ = gitacross.run(cfg, work_dir=wd, clean_cache=True)
|
|
419
|
+
|
|
420
|
+
assert not orphan1.exists()
|
|
421
|
+
assert not orphan2.exists()
|
|
422
|
+
assert (cache / referenced).is_dir()
|
|
423
|
+
assert (cache / disabled_ref).is_dir()
|
|
424
|
+
assert (cache / "notes.txt").read_text() == "junk"
|
|
425
|
+
assert (cache / "not-a-mirror").is_dir()
|
|
426
|
+
|
|
427
|
+
# (c) dry-run never deletes anything
|
|
428
|
+
(cache / "fresh_orphan_cccc.git").mkdir()
|
|
429
|
+
with mock.patch("gitacross.main.sync_project", return_value=[]):
|
|
430
|
+
_ = gitacross.run(cfg, work_dir=wd, clean_cache=True, dry_run=True)
|
|
431
|
+
assert (cache / "fresh_orphan_cccc.git").is_dir()
|
|
432
|
+
|
|
433
|
+
# (d) a second clean run removes it; (e) a third has nothing left to do
|
|
434
|
+
with mock.patch("gitacross.main.sync_project", return_value=[]):
|
|
435
|
+
_ = gitacross.run(cfg, work_dir=wd, clean_cache=True)
|
|
436
|
+
assert not (cache / "fresh_orphan_cccc.git").exists()
|
|
437
|
+
with mock.patch("gitacross.main.sync_project", return_value=[]):
|
|
438
|
+
_ = gitacross.run(cfg, work_dir=wd, clean_cache=True)
|
|
439
|
+
print(" ✓ run: clean_cache removes only unreferenced mirrors")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def test_main_clean_cache_flag_passed_to_run():
|
|
443
|
+
"""CLI --clean-cache is forwarded to run()."""
|
|
444
|
+
from gitacross.cli import main
|
|
445
|
+
|
|
446
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
447
|
+
cfg = Path(tmp) / "config.yml"
|
|
448
|
+
_write_local_config(cfg, [{"name": "proj-cli"}])
|
|
449
|
+
captured = {}
|
|
450
|
+
|
|
451
|
+
def _fake_run(*args, **kwargs):
|
|
452
|
+
captured.update(kwargs)
|
|
453
|
+
return []
|
|
454
|
+
|
|
455
|
+
with mock.patch("gitacross.main.run", side_effect=_fake_run), mock.patch(
|
|
456
|
+
"sys.argv", ["gitacross", "--config", str(cfg), "--clean-cache"]
|
|
457
|
+
):
|
|
458
|
+
try:
|
|
459
|
+
main()
|
|
460
|
+
except SystemExit as e:
|
|
461
|
+
assert e.code in (0, None), f"expected exit 0, got {e.code}"
|
|
462
|
+
assert captured.get("clean_cache") is True
|
|
463
|
+
print(" ✓ cli: --clean-cache flag is forwarded to run()")
|
|
464
|
+
|
|
465
|
+
|
|
366
466
|
def test_run_dry_run_passed_through():
|
|
367
467
|
import gitacross
|
|
368
468
|
|
|
@@ -248,6 +248,60 @@ def test_create_target_unknown_type():
|
|
|
248
248
|
assert "Unknown target type" in str(e)
|
|
249
249
|
print(" ✓ target: unknown type rejected")
|
|
250
250
|
|
|
251
|
+
def test_remote_target_cache_separates_hosts():
|
|
252
|
+
"""Same-named target repos on different hosts get separate mirror caches."""
|
|
253
|
+
from gitacross.config import _EndpointConfig
|
|
254
|
+
from gitacross.target import create_target
|
|
255
|
+
|
|
256
|
+
calls = []
|
|
257
|
+
|
|
258
|
+
def fake_ensure_mirror(url, dest):
|
|
259
|
+
calls.append((url, dest))
|
|
260
|
+
return mock.MagicMock()
|
|
261
|
+
|
|
262
|
+
host_a = _EndpointConfig(
|
|
263
|
+
{"type": "gitea", "repo": "uqkami/Awara",
|
|
264
|
+
"api": "https://git.nodebay.top/api/v1", "token": "t"},
|
|
265
|
+
is_source=False,
|
|
266
|
+
)
|
|
267
|
+
host_b = _EndpointConfig(
|
|
268
|
+
{"type": "gitea", "repo": "uqkami/Awara",
|
|
269
|
+
"api": "https://gitea.example.com/api/v1", "token": "t"},
|
|
270
|
+
is_source=False,
|
|
271
|
+
)
|
|
272
|
+
same_as_a = _EndpointConfig(
|
|
273
|
+
{"type": "gitea", "repo": "uqkami/Awara",
|
|
274
|
+
"api": "https://git.nodebay.top/api/v1", "token": "other"},
|
|
275
|
+
is_source=False,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
mock_apis = []
|
|
279
|
+
for _cfg in (host_a, host_b, same_as_a):
|
|
280
|
+
api = mock.MagicMock()
|
|
281
|
+
api.ensure_repo_exists.return_value = {
|
|
282
|
+
"clone_url": f"https://{_cfg.host}/{_cfg.repo}.git"
|
|
283
|
+
}
|
|
284
|
+
mock_apis.append(api)
|
|
285
|
+
|
|
286
|
+
with mock.patch(
|
|
287
|
+
"gitacross.target.get_api_client", side_effect=mock_apis
|
|
288
|
+
), mock.patch(
|
|
289
|
+
"gitacross.git.GitRepo.ensure_mirror", side_effect=fake_ensure_mirror
|
|
290
|
+
):
|
|
291
|
+
_ = create_target(host_a, "cache")
|
|
292
|
+
_ = create_target(host_b, "cache")
|
|
293
|
+
_ = create_target(same_as_a, "cache")
|
|
294
|
+
|
|
295
|
+
dest_a, dest_b, dest_same = (Path(d) for _, d in calls)
|
|
296
|
+
assert "target_gitea_git.nodebay.top_uqkami_Awara_" in str(dest_a)
|
|
297
|
+
assert "target_gitea_gitea.example.com_uqkami_Awara_" in str(dest_b)
|
|
298
|
+
for d in (dest_a, dest_b):
|
|
299
|
+
assert str(d).endswith(".git")
|
|
300
|
+
assert dest_a != dest_b
|
|
301
|
+
assert dest_a == dest_same # same host + repo shares one mirror (dedup)
|
|
302
|
+
print(" ✓ target: remote target cache dirs are separated per host")
|
|
303
|
+
|
|
304
|
+
|
|
251
305
|
def test_remote_target_no_clone_url_raises():
|
|
252
306
|
"""A remote target without any clone URL raises a clear error."""
|
|
253
307
|
from gitacross.config import _EndpointConfig
|
|
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
|