gitacross 1.1.0__tar.gz → 2.0.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-1.1.0/src/gitacross.egg-info → gitacross-2.0.0}/PKG-INFO +43 -11
- {gitacross-1.1.0 → gitacross-2.0.0}/README.md +41 -9
- {gitacross-1.1.0 → gitacross-2.0.0}/pyproject.toml +2 -2
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/__init__.py +37 -13
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/__main__.py +2 -0
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/config.py +93 -25
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/git.py +25 -16
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/linter.py +88 -103
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/main.py +76 -55
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/providers/__init__.py +2 -0
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/providers/base.py +20 -19
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/providers/gitea.py +2 -0
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/providers/github.py +2 -0
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/renderer.py +2 -0
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/retry.py +4 -2
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/source.py +13 -9
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/state.py +5 -25
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/target.py +3 -1
- {gitacross-1.1.0 → gitacross-2.0.0/src/gitacross.egg-info}/PKG-INFO +43 -11
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross.egg-info/SOURCES.txt +0 -2
- {gitacross-1.1.0 → gitacross-2.0.0}/tests/test_sync.py +502 -328
- gitacross-1.1.0/src/gitacross/gitea.py +0 -9
- gitacross-1.1.0/src/gitacross/github.py +0 -9
- {gitacross-1.1.0 → gitacross-2.0.0}/LICENSE +0 -0
- {gitacross-1.1.0 → gitacross-2.0.0}/setup.cfg +0 -0
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross.egg-info/dependency_links.txt +0 -0
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross.egg-info/entry_points.txt +0 -0
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross.egg-info/requires.txt +0 -0
- {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross.egg-info/top_level.txt +0 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitacross
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Mirror releases and git commits across platforms (Gitea, GitHub, local) with transform pipelines.
|
|
5
|
-
Author:
|
|
5
|
+
Author: Matthew Deik
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Keywords: git,gitea,github,mirror,sync,release
|
|
8
8
|
Classifier: Development Status :: 4 - Beta
|
|
@@ -162,7 +162,7 @@ Set to `false` to pause a project without removing it from the config. Default `
|
|
|
162
162
|
|
|
163
163
|
#### `preserve_description`
|
|
164
164
|
|
|
165
|
-
Copy the source release notes/body to the target release. Default `true
|
|
165
|
+
Copy the source release notes/body to the target release. Default `true`. Set at the project or endpoint level; `false` leaves the target release description empty.
|
|
166
166
|
|
|
167
167
|
#### `release_description`
|
|
168
168
|
|
|
@@ -174,7 +174,7 @@ Custom commit message for the target commits (alias: `commit_template`). Default
|
|
|
174
174
|
|
|
175
175
|
#### `sync_assets`
|
|
176
176
|
|
|
177
|
-
Mirror prebuilt release packages from the source to the target release — so you only need CI on the source platform
|
|
177
|
+
Mirror prebuilt release packages from the source to the target release — so you only need CI on the source platform. Project-level field:
|
|
178
178
|
|
|
179
179
|
| Value | Behaviour |
|
|
180
180
|
|---|---|
|
|
@@ -489,7 +489,7 @@ results = gitacross.run("config.yml", reset=True)
|
|
|
489
489
|
<summary>Full control — loop over projects yourself</summary>
|
|
490
490
|
|
|
491
491
|
```python
|
|
492
|
-
config = gitacross.Config
|
|
492
|
+
config = gitacross.Config("config.yml")
|
|
493
493
|
|
|
494
494
|
for project in config.projects:
|
|
495
495
|
if project.enabled:
|
|
@@ -535,12 +535,44 @@ if not report.is_valid:
|
|
|
535
535
|
|
|
536
536
|
</details>
|
|
537
537
|
|
|
538
|
+
<details>
|
|
539
|
+
<summary>Sync from YAML held in a variable — no file needed</summary>
|
|
540
|
+
|
|
541
|
+
```python
|
|
542
|
+
# ${VAR} tokens still resolve from the environment
|
|
543
|
+
config = gitacross.Config.from_yaml_string("""
|
|
544
|
+
projects:
|
|
545
|
+
- name: my-mirror
|
|
546
|
+
source:
|
|
547
|
+
type: gitea
|
|
548
|
+
repo: owner/repo
|
|
549
|
+
api: https://gitea.example.com/api/v1
|
|
550
|
+
token: ${GITEA_TOKEN}
|
|
551
|
+
target:
|
|
552
|
+
type: github
|
|
553
|
+
repo: owner/repo
|
|
554
|
+
api: https://api.github.com
|
|
555
|
+
token: ${GITHUB_TOKEN}
|
|
556
|
+
""")
|
|
557
|
+
gitacross.run(config, dry_run=True)
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
</details>
|
|
561
|
+
|
|
562
|
+
All public symbols are importable directly from `gitacross`:
|
|
563
|
+
|
|
538
564
|
| Symbol | What it does |
|
|
539
565
|
|---|---|
|
|
540
|
-
| `run(
|
|
566
|
+
| `run(config, project=None, dry_run=False, reset=False, work_dir=".gitsync")` | **Primary entry point.** Sync from a config — a `Config` instance, a path, or an open file object
|
|
541
567
|
| `sync_project(project, work_dir=".gitsync", dry_run=False)` | Sync one project's new releases (respects `project.enabled`); state and cache live in `work_dir`. Returns dicts with `tag`, `source_commit`, `target_commit`, `source_date` |
|
|
542
|
-
| `lint_config(
|
|
543
|
-
| `fix_config(
|
|
544
|
-
| `Config(
|
|
545
|
-
| `ProjectConfig` |
|
|
546
|
-
| `ConfigLinter
|
|
568
|
+
| `lint_config(config, print_output=True)` | Lint a config (path or open file object) → `LintReport` |
|
|
569
|
+
| `fix_config(config, write_back=True, print_output=True)` | Fix misplaced/redundant options (path only — writes back to the file) → `FixReport` |
|
|
570
|
+
| `Config(config_source)` | Load a config from a path or open file object; exposes `.projects`. `Config.from_yaml_string(content)` loads a config from raw YAML text (`str` or `bytes`) — no file or stream needed |
|
|
571
|
+
| `ProjectConfig(raw)` | Build one mirror project from a raw config dict (see the “No config file” example). Fields: `name`, `enabled`, `source`, `target`, `renderer`, `retry`, `preserve_description`, `sync_assets`, `stream_assets`, `commit_message`, `release_description` |
|
|
572
|
+
| `ConfigLinter()` | Collect lint issues programmatically: `lint_file(config)`, `lint_yaml_string(content)`; results accumulate in `.issues` |
|
|
573
|
+
| `ConfigFixer()` | Fix a config programmatically: `fix_yaml_string(content)` → `FixReport`; actions recorded in `.fixes` |
|
|
574
|
+
| `LintIssue(severity, message, project=None, key=None)` | One lint finding |
|
|
575
|
+
| `FixIssue(message, project=None)` | One applied fix |
|
|
576
|
+
| `LintReport(issues)` | Lint results: `.issues`, `.errors`, `.warnings`, `.redundant`, `.is_valid`, `.format_text()` |
|
|
577
|
+
| `FixReport(fixes, content, is_valid, error=None)` | Fix results: `.fixes`, `.content`, `.is_valid`, `.error`, `.format_text()` |
|
|
578
|
+
| `LintSeverity` | Severity levels used by `LintIssue`: `LintSeverity.ERROR`, `LintSeverity.WARNING`, `LintSeverity.REDUNDANT` |
|
|
@@ -134,7 +134,7 @@ Set to `false` to pause a project without removing it from the config. Default `
|
|
|
134
134
|
|
|
135
135
|
#### `preserve_description`
|
|
136
136
|
|
|
137
|
-
Copy the source release notes/body to the target release. Default `true
|
|
137
|
+
Copy the source release notes/body to the target release. Default `true`. Set at the project or endpoint level; `false` leaves the target release description empty.
|
|
138
138
|
|
|
139
139
|
#### `release_description`
|
|
140
140
|
|
|
@@ -146,7 +146,7 @@ Custom commit message for the target commits (alias: `commit_template`). Default
|
|
|
146
146
|
|
|
147
147
|
#### `sync_assets`
|
|
148
148
|
|
|
149
|
-
Mirror prebuilt release packages from the source to the target release — so you only need CI on the source platform
|
|
149
|
+
Mirror prebuilt release packages from the source to the target release — so you only need CI on the source platform. Project-level field:
|
|
150
150
|
|
|
151
151
|
| Value | Behaviour |
|
|
152
152
|
|---|---|
|
|
@@ -461,7 +461,7 @@ results = gitacross.run("config.yml", reset=True)
|
|
|
461
461
|
<summary>Full control — loop over projects yourself</summary>
|
|
462
462
|
|
|
463
463
|
```python
|
|
464
|
-
config = gitacross.Config
|
|
464
|
+
config = gitacross.Config("config.yml")
|
|
465
465
|
|
|
466
466
|
for project in config.projects:
|
|
467
467
|
if project.enabled:
|
|
@@ -507,12 +507,44 @@ if not report.is_valid:
|
|
|
507
507
|
|
|
508
508
|
</details>
|
|
509
509
|
|
|
510
|
+
<details>
|
|
511
|
+
<summary>Sync from YAML held in a variable — no file needed</summary>
|
|
512
|
+
|
|
513
|
+
```python
|
|
514
|
+
# ${VAR} tokens still resolve from the environment
|
|
515
|
+
config = gitacross.Config.from_yaml_string("""
|
|
516
|
+
projects:
|
|
517
|
+
- name: my-mirror
|
|
518
|
+
source:
|
|
519
|
+
type: gitea
|
|
520
|
+
repo: owner/repo
|
|
521
|
+
api: https://gitea.example.com/api/v1
|
|
522
|
+
token: ${GITEA_TOKEN}
|
|
523
|
+
target:
|
|
524
|
+
type: github
|
|
525
|
+
repo: owner/repo
|
|
526
|
+
api: https://api.github.com
|
|
527
|
+
token: ${GITHUB_TOKEN}
|
|
528
|
+
""")
|
|
529
|
+
gitacross.run(config, dry_run=True)
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
</details>
|
|
533
|
+
|
|
534
|
+
All public symbols are importable directly from `gitacross`:
|
|
535
|
+
|
|
510
536
|
| Symbol | What it does |
|
|
511
537
|
|---|---|
|
|
512
|
-
| `run(
|
|
538
|
+
| `run(config, project=None, dry_run=False, reset=False, work_dir=".gitsync")` | **Primary entry point.** Sync from a config — a `Config` instance, a path, or an open file object
|
|
513
539
|
| `sync_project(project, work_dir=".gitsync", dry_run=False)` | Sync one project's new releases (respects `project.enabled`); state and cache live in `work_dir`. Returns dicts with `tag`, `source_commit`, `target_commit`, `source_date` |
|
|
514
|
-
| `lint_config(
|
|
515
|
-
| `fix_config(
|
|
516
|
-
| `Config(
|
|
517
|
-
| `ProjectConfig` |
|
|
518
|
-
| `ConfigLinter
|
|
540
|
+
| `lint_config(config, print_output=True)` | Lint a config (path or open file object) → `LintReport` |
|
|
541
|
+
| `fix_config(config, write_back=True, print_output=True)` | Fix misplaced/redundant options (path only — writes back to the file) → `FixReport` |
|
|
542
|
+
| `Config(config_source)` | Load a config from a path or open file object; exposes `.projects`. `Config.from_yaml_string(content)` loads a config from raw YAML text (`str` or `bytes`) — no file or stream needed |
|
|
543
|
+
| `ProjectConfig(raw)` | Build one mirror project from a raw config dict (see the “No config file” example). Fields: `name`, `enabled`, `source`, `target`, `renderer`, `retry`, `preserve_description`, `sync_assets`, `stream_assets`, `commit_message`, `release_description` |
|
|
544
|
+
| `ConfigLinter()` | Collect lint issues programmatically: `lint_file(config)`, `lint_yaml_string(content)`; results accumulate in `.issues` |
|
|
545
|
+
| `ConfigFixer()` | Fix a config programmatically: `fix_yaml_string(content)` → `FixReport`; actions recorded in `.fixes` |
|
|
546
|
+
| `LintIssue(severity, message, project=None, key=None)` | One lint finding |
|
|
547
|
+
| `FixIssue(message, project=None)` | One applied fix |
|
|
548
|
+
| `LintReport(issues)` | Lint results: `.issues`, `.errors`, `.warnings`, `.redundant`, `.is_valid`, `.format_text()` |
|
|
549
|
+
| `FixReport(fixes, content, is_valid, error=None)` | Fix results: `.fixes`, `.content`, `.is_valid`, `.error`, `.format_text()` |
|
|
550
|
+
| `LintSeverity` | Severity levels used by `LintIssue`: `LintSeverity.ERROR`, `LintSeverity.WARNING`, `LintSeverity.REDUNDANT` |
|
|
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitacross"
|
|
7
|
-
version = "
|
|
7
|
+
version = "2.0.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"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
authors = [
|
|
13
|
-
{ name = "
|
|
13
|
+
{ name = "Matthew Deik" }
|
|
14
14
|
]
|
|
15
15
|
keywords = ["git", "gitea", "github", "mirror", "sync", "release"]
|
|
16
16
|
classifiers = [
|
|
@@ -11,7 +11,7 @@ Quickstart (Python API)::
|
|
|
11
11
|
results = gitacross.run("config.yml", project="my-mirror", dry_run=True)
|
|
12
12
|
|
|
13
13
|
# Lower-level: build objects yourself for full control:
|
|
14
|
-
config = gitacross.Config
|
|
14
|
+
config = gitacross.Config("config.yml")
|
|
15
15
|
for project in config.projects:
|
|
16
16
|
if project.enabled:
|
|
17
17
|
gitacross.sync_project(project, ".gitsync")
|
|
@@ -21,8 +21,12 @@ Quickstart (Python API)::
|
|
|
21
21
|
if not report.is_valid:
|
|
22
22
|
gitacross.fix_config("config.yml")
|
|
23
23
|
"""
|
|
24
|
+
from __future__ import annotations
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
import re
|
|
27
|
+
from importlib.metadata import PackageNotFoundError
|
|
28
|
+
from importlib.metadata import version as _installed_version
|
|
29
|
+
from pathlib import Path
|
|
26
30
|
|
|
27
31
|
from .config import Config, ProjectConfig
|
|
28
32
|
from .linter import (
|
|
@@ -36,20 +40,35 @@ from .linter import (
|
|
|
36
40
|
fix_config,
|
|
37
41
|
lint_config,
|
|
38
42
|
)
|
|
39
|
-
from .main import run, sync_project
|
|
43
|
+
from .main import main, run, sync_project
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _package_version() -> str:
|
|
47
|
+
"""Return the package version, stored only in pyproject.toml.
|
|
48
|
+
|
|
49
|
+
Installed: read from the distribution metadata, which setuptools populates
|
|
50
|
+
from ``[project].version``. Source checkout (package not installed): parse
|
|
51
|
+
the literal out of ``pyproject.toml``. Falls back to ``"0.0.0"`` if neither
|
|
52
|
+
is available.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
return _installed_version("gitacross")
|
|
56
|
+
except PackageNotFoundError:
|
|
57
|
+
pass
|
|
58
|
+
pyproject = Path(__file__).resolve().parent.parent.parent / "pyproject.toml"
|
|
59
|
+
if pyproject.is_file():
|
|
60
|
+
match = re.search(
|
|
61
|
+
r'^version\s*=\s*"([^"]+)"', pyproject.read_text(encoding="utf-8"), re.MULTILINE
|
|
62
|
+
)
|
|
63
|
+
if match:
|
|
64
|
+
return match.group(1)
|
|
65
|
+
return "0.0.0"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__version__ = _package_version()
|
|
40
69
|
|
|
41
70
|
__all__ = [
|
|
42
|
-
"__version__",
|
|
43
|
-
# Config
|
|
44
71
|
"Config",
|
|
45
|
-
"ProjectConfig",
|
|
46
|
-
# High-level API
|
|
47
|
-
"run",
|
|
48
|
-
# Lower-level API
|
|
49
|
-
"sync_project",
|
|
50
|
-
# CLI entry point (exposed for completeness)
|
|
51
|
-
"main",
|
|
52
|
-
# Linter
|
|
53
72
|
"ConfigFixer",
|
|
54
73
|
"ConfigLinter",
|
|
55
74
|
"FixIssue",
|
|
@@ -57,6 +76,11 @@ __all__ = [
|
|
|
57
76
|
"LintIssue",
|
|
58
77
|
"LintReport",
|
|
59
78
|
"LintSeverity",
|
|
79
|
+
"ProjectConfig",
|
|
80
|
+
"__version__",
|
|
60
81
|
"fix_config",
|
|
61
82
|
"lint_config",
|
|
83
|
+
"main",
|
|
84
|
+
"run",
|
|
85
|
+
"sync_project",
|
|
62
86
|
]
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import logging
|
|
2
4
|
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import final
|
|
3
7
|
|
|
4
8
|
import yaml
|
|
5
9
|
|
|
6
10
|
logger = logging.getLogger(__name__)
|
|
7
11
|
|
|
8
|
-
|
|
12
|
+
VALID_PROJECT_KEYS = {
|
|
9
13
|
"name",
|
|
10
14
|
"enabled",
|
|
11
15
|
"source",
|
|
@@ -13,10 +17,7 @@ _VALID_PROJECT_KEYS = {
|
|
|
13
17
|
"renderer",
|
|
14
18
|
"retry",
|
|
15
19
|
"preserve_description",
|
|
16
|
-
"preserve_release_description",
|
|
17
20
|
"sync_assets",
|
|
18
|
-
"preserve_assets",
|
|
19
|
-
"include_assets",
|
|
20
21
|
"stream_assets",
|
|
21
22
|
"commit_message",
|
|
22
23
|
"commit_template",
|
|
@@ -25,7 +26,7 @@ _VALID_PROJECT_KEYS = {
|
|
|
25
26
|
"description_template",
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
KNOWN_SOURCE_KEYS = {
|
|
29
30
|
"mode": "source",
|
|
30
31
|
"sync_from": "source",
|
|
31
32
|
"include_prereleases": "source",
|
|
@@ -33,7 +34,7 @@ _KNOWN_SOURCE_KEYS = {
|
|
|
33
34
|
"tag_pattern": "source",
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
KNOWN_ENDPOINT_KEYS = {
|
|
37
38
|
"repo": "source or target",
|
|
38
39
|
"api": "source or target",
|
|
39
40
|
"token": "source or target",
|
|
@@ -41,25 +42,90 @@ _KNOWN_ENDPOINT_KEYS = {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
|
|
45
|
+
@final
|
|
44
46
|
class Config:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
"""Load and parse a config from a file path or an already-open file object.
|
|
48
|
+
|
|
49
|
+
``config_source`` may be a ``str``/:class:`~pathlib.Path` (opened and read
|
|
50
|
+
here) or any file-like object with a ``read()`` method (e.g. the result of
|
|
51
|
+
``open()`` or an ``io.StringIO``), matching what ``yaml.safe_load`` accepts.
|
|
52
|
+
To parse YAML held directly in a variable, use :meth:`from_yaml_string`.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, config_source):
|
|
56
|
+
if isinstance(config_source, (str, Path)):
|
|
57
|
+
try:
|
|
58
|
+
with open(config_source) as f:
|
|
59
|
+
raw = yaml.safe_load(f)
|
|
60
|
+
except FileNotFoundError as exc:
|
|
61
|
+
raise Config._missing_source_error(exc, config_source) from exc
|
|
62
|
+
elif hasattr(config_source, "read"):
|
|
63
|
+
# Open file-like object (StringIO, an open() handle, BytesIO, ...)
|
|
64
|
+
raw = yaml.safe_load(config_source)
|
|
65
|
+
else:
|
|
66
|
+
raise TypeError(
|
|
67
|
+
"config_source must be a path (str/Path) or an open file-like "
|
|
68
|
+
+ f"object, got {type(config_source).__name__}"
|
|
69
|
+
)
|
|
70
|
+
self.projects = self._projects_from_parsed(raw)
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_yaml_string(cls, content):
|
|
74
|
+
"""Build a :class:`Config` from raw YAML text (``str`` or ``bytes``).
|
|
75
|
+
|
|
76
|
+
Equivalent to wrapping *content* in ``io.StringIO``/``io.BytesIO`` and
|
|
77
|
+
passing that to :class:`Config`, but hands the string straight to the
|
|
78
|
+
YAML parser — no intermediate file or stream object:
|
|
79
|
+
|
|
80
|
+
>>> config = Config.from_yaml_string("projects: []")
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
yaml.YAMLError: If *content* is not valid YAML.
|
|
84
|
+
TypeError: If *content* is not a ``str`` or ``bytes``.
|
|
85
|
+
"""
|
|
86
|
+
if not isinstance(content, (str, bytes)):
|
|
87
|
+
raise TypeError(
|
|
88
|
+
"content must be a str or bytes holding YAML, got "
|
|
89
|
+
+ type(content).__name__
|
|
90
|
+
)
|
|
91
|
+
config = cls.__new__(cls)
|
|
92
|
+
config.projects = cls._projects_from_parsed(yaml.safe_load(content))
|
|
93
|
+
return config
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _projects_from_parsed(raw) -> list[ProjectConfig]:
|
|
97
|
+
"""Normalize one parsed YAML document into project configs.
|
|
98
|
+
|
|
99
|
+
Single source of truth for the accepted top-level shapes: a ``projects``
|
|
100
|
+
mapping, a flat list of project mappings, or nothing (empty config).
|
|
101
|
+
Shared by :meth:`__init__` and :meth:`from_yaml_string` so both accept
|
|
102
|
+
exactly the same documents.
|
|
103
|
+
"""
|
|
48
104
|
if isinstance(raw, list):
|
|
49
105
|
# Flat list at top level: [ {name:..., source:..., ...} ]
|
|
50
106
|
raw = {"projects": raw}
|
|
51
107
|
elif raw is None:
|
|
52
108
|
raw = {"projects": []}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
@
|
|
56
|
-
def
|
|
57
|
-
|
|
109
|
+
return [ProjectConfig(p) for p in (raw.get("projects") or [])]
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _missing_source_error(exc: FileNotFoundError, config_source) -> FileNotFoundError:
|
|
113
|
+
"""Re-raise a failed path open, hinting when the string looks like YAML
|
|
114
|
+
content rather than a path. Cosmetic only — never affects dispatch."""
|
|
115
|
+
text = os.fspath(config_source)
|
|
116
|
+
hint = (
|
|
117
|
+
" — did you mean to pass YAML content? Use "
|
|
118
|
+
+ "Config.from_yaml_string(...) or wrap it in io.StringIO(...)."
|
|
119
|
+
if "\n" in text or ": " in text
|
|
120
|
+
else ""
|
|
121
|
+
)
|
|
122
|
+
return FileNotFoundError(f"{exc}{hint}")
|
|
58
123
|
|
|
59
124
|
def __repr__(self):
|
|
60
125
|
return f"Config(projects={len(self.projects)})"
|
|
61
126
|
|
|
62
127
|
|
|
128
|
+
@final
|
|
63
129
|
class _EndpointConfig:
|
|
64
130
|
"""Config for a source or target endpoint.
|
|
65
131
|
|
|
@@ -116,10 +182,11 @@ class _EndpointConfig:
|
|
|
116
182
|
return f"https://{self.owner}:{self.token}@{host}/{self.repo}.git"
|
|
117
183
|
|
|
118
184
|
|
|
185
|
+
@final
|
|
119
186
|
class ProjectConfig:
|
|
120
187
|
def __init__(self, raw):
|
|
121
188
|
if not isinstance(raw, dict):
|
|
122
|
-
raise
|
|
189
|
+
raise TypeError(
|
|
123
190
|
f"Project entry must be a map/dict (got {type(raw).__name__})"
|
|
124
191
|
)
|
|
125
192
|
name = raw.get("name")
|
|
@@ -136,24 +203,24 @@ class ProjectConfig:
|
|
|
136
203
|
|
|
137
204
|
# Check for misplaced or unknown keys at the project level
|
|
138
205
|
for k in raw:
|
|
139
|
-
if k in
|
|
206
|
+
if k in VALID_PROJECT_KEYS:
|
|
140
207
|
continue
|
|
141
|
-
if k in
|
|
208
|
+
if k in KNOWN_SOURCE_KEYS:
|
|
142
209
|
logger.warning(
|
|
143
210
|
"Project '%s': '%s' was specified at the project level, but must be configured under '%s:' (e.g. %s.%s: %s).",
|
|
144
211
|
self.name,
|
|
145
212
|
k,
|
|
146
|
-
|
|
147
|
-
|
|
213
|
+
KNOWN_SOURCE_KEYS[k],
|
|
214
|
+
KNOWN_SOURCE_KEYS[k],
|
|
148
215
|
k,
|
|
149
216
|
raw[k],
|
|
150
217
|
)
|
|
151
|
-
elif k in
|
|
218
|
+
elif k in KNOWN_ENDPOINT_KEYS:
|
|
152
219
|
logger.warning(
|
|
153
220
|
"Project '%s': '%s' was specified at the project level, but belongs under '%s:'.",
|
|
154
221
|
self.name,
|
|
155
222
|
k,
|
|
156
|
-
|
|
223
|
+
KNOWN_ENDPOINT_KEYS[k],
|
|
157
224
|
)
|
|
158
225
|
else:
|
|
159
226
|
logger.warning(
|
|
@@ -163,18 +230,16 @@ class ProjectConfig:
|
|
|
163
230
|
)
|
|
164
231
|
|
|
165
232
|
# preserve_description — single cascade: project → source → target → True
|
|
166
|
-
# Supports aliases: preserve_release_description (legacy)
|
|
167
233
|
self.preserve_description = _cascade(
|
|
168
234
|
raw, raw_source, raw_target,
|
|
169
|
-
keys=["preserve_description"
|
|
235
|
+
keys=["preserve_description"],
|
|
170
236
|
default=True,
|
|
171
237
|
)
|
|
172
238
|
|
|
173
239
|
# sync_assets — single cascade: project → source → target → False
|
|
174
|
-
# Supports aliases: preserve_assets, include_assets (legacy)
|
|
175
240
|
self.sync_assets = _cascade(
|
|
176
241
|
raw, raw_source, raw_target,
|
|
177
|
-
keys=["sync_assets"
|
|
242
|
+
keys=["sync_assets"],
|
|
178
243
|
default=False,
|
|
179
244
|
)
|
|
180
245
|
|
|
@@ -202,6 +267,7 @@ class ProjectConfig:
|
|
|
202
267
|
)
|
|
203
268
|
|
|
204
269
|
|
|
270
|
+
@final
|
|
205
271
|
class _AuthorConfig:
|
|
206
272
|
def __init__(self, raw):
|
|
207
273
|
self.name = raw.get("name", "")
|
|
@@ -212,6 +278,7 @@ class _AuthorConfig:
|
|
|
212
278
|
return bool(self.name) or bool(self.email)
|
|
213
279
|
|
|
214
280
|
|
|
281
|
+
@final
|
|
215
282
|
class _RendererConfig:
|
|
216
283
|
def __init__(self, raw):
|
|
217
284
|
self.ignore = raw.get("ignore", [])
|
|
@@ -220,6 +287,7 @@ class _RendererConfig:
|
|
|
220
287
|
self.author = _AuthorConfig(raw_author) if raw_author else _AuthorConfig({})
|
|
221
288
|
|
|
222
289
|
|
|
290
|
+
@final
|
|
223
291
|
class _RetryConfig:
|
|
224
292
|
def __init__(self, raw):
|
|
225
293
|
self.max_attempts = raw.get("max_attempts", 3)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import io
|
|
2
4
|
import logging
|
|
3
5
|
import os
|
|
@@ -5,6 +7,7 @@ import re
|
|
|
5
7
|
import subprocess
|
|
6
8
|
import tarfile
|
|
7
9
|
from pathlib import Path
|
|
10
|
+
from typing import final
|
|
8
11
|
|
|
9
12
|
logger = logging.getLogger(__name__)
|
|
10
13
|
|
|
@@ -59,6 +62,7 @@ def _git(*args, check=True, input_data=None, text=True, env=None):
|
|
|
59
62
|
return result
|
|
60
63
|
|
|
61
64
|
|
|
65
|
+
@final
|
|
62
66
|
class GitRepo:
|
|
63
67
|
"""A git repository for release operations.
|
|
64
68
|
|
|
@@ -85,17 +89,17 @@ class GitRepo:
|
|
|
85
89
|
# Update remote URL *before* fetching so token rotation takes effect
|
|
86
90
|
current = _git("-C", str(path), "remote", "get-url", "origin", check=False)
|
|
87
91
|
if current.returncode == 0 and current.stdout.strip() != url:
|
|
88
|
-
_git("-C", str(path), "remote", "set-url", "origin", url)
|
|
92
|
+
_ = _git("-C", str(path), "remote", "set-url", "origin", url)
|
|
89
93
|
logger.info("Updated remote URL for mirror at %s", dest)
|
|
90
|
-
_git("-C", str(path), "fetch", "--tags", "--prune", "origin")
|
|
94
|
+
_ = _git("-C", str(path), "fetch", "--tags", "--prune", "origin")
|
|
91
95
|
logger.info("Updated mirror at %s", dest)
|
|
92
96
|
else:
|
|
93
97
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
-
_git("clone", "--mirror", url, str(path))
|
|
98
|
+
_ = _git("clone", "--mirror", url, str(path))
|
|
95
99
|
logger.info("Cloned mirror from %s", _redact(url))
|
|
96
100
|
# Ensure author identity for automated commits
|
|
97
|
-
_git("-C", str(path), "config", "user.name", "GitAcross")
|
|
98
|
-
_git("-C", str(path), "config", "user.email", "sync@gitacross")
|
|
101
|
+
_ = _git("-C", str(path), "config", "user.name", "GitAcross")
|
|
102
|
+
_ = _git("-C", str(path), "config", "user.email", "sync@gitacross")
|
|
99
103
|
return cls(path, is_bare=True)
|
|
100
104
|
|
|
101
105
|
@classmethod
|
|
@@ -163,6 +167,11 @@ class GitRepo:
|
|
|
163
167
|
|
|
164
168
|
def export_commit(self, commit_sha, dest):
|
|
165
169
|
"""Export a commit's file tree into *dest* (empty dir recommended)."""
|
|
170
|
+
# git archive of an empty tree emits a dangling pax_global_header that
|
|
171
|
+
# tarfile can't read, so skip archiving when the tree has no files.
|
|
172
|
+
tree = self._g("ls-tree", "-r", "--name-only", commit_sha, check=False)
|
|
173
|
+
if tree.returncode != 0 or not tree.stdout.strip():
|
|
174
|
+
return
|
|
166
175
|
result = self._g("archive", commit_sha, "--format=tar", text=False)
|
|
167
176
|
with tarfile.open(fileobj=io.BytesIO(result.stdout), mode="r|") as tar:
|
|
168
177
|
tar.extractall(path=str(dest))
|
|
@@ -180,7 +189,7 @@ class GitRepo:
|
|
|
180
189
|
# Already exists locally
|
|
181
190
|
local = self._g("show-ref", "--verify", f"refs/heads/{branch}", check=False)
|
|
182
191
|
if local.returncode == 0:
|
|
183
|
-
self._g("symbolic-ref", "HEAD", f"refs/heads/{branch}")
|
|
192
|
+
_ = self._g("symbolic-ref", "HEAD", f"refs/heads/{branch}")
|
|
184
193
|
return
|
|
185
194
|
|
|
186
195
|
# Exists on remote
|
|
@@ -188,12 +197,12 @@ class GitRepo:
|
|
|
188
197
|
"show-ref", "--verify", f"refs/remotes/origin/{branch}", check=False
|
|
189
198
|
)
|
|
190
199
|
if remote.returncode == 0:
|
|
191
|
-
self._g("branch", "--force", branch, f"origin/{branch}")
|
|
192
|
-
self._g("symbolic-ref", "HEAD", f"refs/heads/{branch}")
|
|
200
|
+
_ = self._g("branch", "--force", branch, f"origin/{branch}")
|
|
201
|
+
_ = self._g("symbolic-ref", "HEAD", f"refs/heads/{branch}")
|
|
193
202
|
return
|
|
194
203
|
|
|
195
204
|
# Brand new branch — just set HEAD, the ref is created on first commit
|
|
196
|
-
self._g("symbolic-ref", "HEAD", f"refs/heads/{branch}")
|
|
205
|
+
_ = self._g("symbolic-ref", "HEAD", f"refs/heads/{branch}")
|
|
197
206
|
|
|
198
207
|
def commit(self, work_dir, message, date=None, author_name=None, author_email=None):
|
|
199
208
|
"""Stage all files in *work_dir* and commit on current branch.
|
|
@@ -204,7 +213,7 @@ class GitRepo:
|
|
|
204
213
|
If *author_name* and/or *author_email* are provided, they override the
|
|
205
214
|
git identity for this commit (both author and committer).
|
|
206
215
|
"""
|
|
207
|
-
self._gw(work_dir, "add", "-A")
|
|
216
|
+
_ = self._gw(work_dir, "add", "-A")
|
|
208
217
|
env = dict(os.environ)
|
|
209
218
|
if date:
|
|
210
219
|
env.update({
|
|
@@ -239,25 +248,25 @@ class GitRepo:
|
|
|
239
248
|
|
|
240
249
|
def tag(self, name, message):
|
|
241
250
|
"""Create (or force-update) an annotated tag."""
|
|
242
|
-
self._g("tag", "-f", name, "-m", message)
|
|
251
|
+
_ = self._g("tag", "-f", name, "-m", message)
|
|
243
252
|
|
|
244
253
|
def reset_worktree(self):
|
|
245
254
|
"""Populate the working tree to match HEAD. No-op for bare repos."""
|
|
246
255
|
if not self.is_bare:
|
|
247
|
-
_git("-C", str(self.git_dir.parent), "reset", "--hard", "HEAD")
|
|
256
|
+
_ = _git("-C", str(self.git_dir.parent), "reset", "--hard", "HEAD")
|
|
248
257
|
|
|
249
258
|
def push(self, remote, *refs):
|
|
250
259
|
"""Push specific refs to remote."""
|
|
251
260
|
# Mirrors (cloned with --mirror) reject explicit refspecs by default.
|
|
252
261
|
# Temporarily disable mirror mode so we can push only the refs we need.
|
|
253
262
|
if self.is_bare:
|
|
254
|
-
self._g("config", "--local", "remote.origin.mirror", "false")
|
|
263
|
+
_ = self._g("config", "--local", "remote.origin.mirror", "false")
|
|
255
264
|
try:
|
|
256
|
-
self._g("push", remote, *refs)
|
|
265
|
+
_ = self._g("push", remote, *refs)
|
|
257
266
|
finally:
|
|
258
|
-
self._g("config", "--local", "remote.origin.mirror", "true")
|
|
267
|
+
_ = self._g("config", "--local", "remote.origin.mirror", "true")
|
|
259
268
|
else:
|
|
260
|
-
self._g("push", remote, *refs)
|
|
269
|
+
_ = self._g("push", remote, *refs)
|
|
261
270
|
|
|
262
271
|
def tag_exists(self, tag):
|
|
263
272
|
"""Check if a tag exists locally."""
|