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.
Files changed (29) hide show
  1. {gitacross-1.1.0/src/gitacross.egg-info → gitacross-2.0.0}/PKG-INFO +43 -11
  2. {gitacross-1.1.0 → gitacross-2.0.0}/README.md +41 -9
  3. {gitacross-1.1.0 → gitacross-2.0.0}/pyproject.toml +2 -2
  4. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/__init__.py +37 -13
  5. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/__main__.py +2 -0
  6. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/config.py +93 -25
  7. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/git.py +25 -16
  8. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/linter.py +88 -103
  9. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/main.py +76 -55
  10. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/providers/__init__.py +2 -0
  11. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/providers/base.py +20 -19
  12. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/providers/gitea.py +2 -0
  13. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/providers/github.py +2 -0
  14. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/renderer.py +2 -0
  15. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/retry.py +4 -2
  16. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/source.py +13 -9
  17. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/state.py +5 -25
  18. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross/target.py +3 -1
  19. {gitacross-1.1.0 → gitacross-2.0.0/src/gitacross.egg-info}/PKG-INFO +43 -11
  20. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross.egg-info/SOURCES.txt +0 -2
  21. {gitacross-1.1.0 → gitacross-2.0.0}/tests/test_sync.py +502 -328
  22. gitacross-1.1.0/src/gitacross/gitea.py +0 -9
  23. gitacross-1.1.0/src/gitacross/github.py +0 -9
  24. {gitacross-1.1.0 → gitacross-2.0.0}/LICENSE +0 -0
  25. {gitacross-1.1.0 → gitacross-2.0.0}/setup.cfg +0 -0
  26. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross.egg-info/dependency_links.txt +0 -0
  27. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross.egg-info/entry_points.txt +0 -0
  28. {gitacross-1.1.0 → gitacross-2.0.0}/src/gitacross.egg-info/requires.txt +0 -0
  29. {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: 1.1.0
3
+ Version: 2.0.0
4
4
  Summary: Mirror releases and git commits across platforms (Gitea, GitHub, local) with transform pipelines.
5
- Author: GitAcross Authors
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` (alias: `preserve_release_description`). Set at the project or endpoint level; `false` leaves the target release description empty.
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 (aliases: `preserve_assets`, `include_assets`). Project-level field:
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.from_path("config.yml")
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(config_path, project=None, dry_run=False, reset=False, work_dir=".gitsync")` | Sync from a config file — the primary entry point. Returns one dict per project: `project`, `synced`, `releases_synced`, `releases`, `error` |
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(config_path, print_output=True)` | Lint a config file → `LintReport` (`.is_valid`, `.errors`) |
543
- | `fix_config(config_path, write_back=True, print_output=True)` | Fix misplaced/redundant options → `FixReport` |
544
- | `Config(config_path)` / `Config.from_path(config_path)` | Load a config file; exposes `.projects` |
545
- | `ProjectConfig` | One mirror: `name`, `enabled`, `source`, `target`, `renderer`, `retry`, `sync_assets`, `stream_assets`, `preserve_description`, `commit_message`, `release_description` |
546
- | `ConfigLinter`, `ConfigFixer`, `LintIssue`, `FixIssue`, `LintReport`, `FixReport`, `LintSeverity` | Building blocks for programmatic linting and fixing |
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` (alias: `preserve_release_description`). Set at the project or endpoint level; `false` leaves the target release description empty.
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 (aliases: `preserve_assets`, `include_assets`). Project-level field:
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.from_path("config.yml")
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(config_path, project=None, dry_run=False, reset=False, work_dir=".gitsync")` | Sync from a config file — the primary entry point. Returns one dict per project: `project`, `synced`, `releases_synced`, `releases`, `error` |
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(config_path, print_output=True)` | Lint a config file → `LintReport` (`.is_valid`, `.errors`) |
515
- | `fix_config(config_path, write_back=True, print_output=True)` | Fix misplaced/redundant options → `FixReport` |
516
- | `Config(config_path)` / `Config.from_path(config_path)` | Load a config file; exposes `.projects` |
517
- | `ProjectConfig` | One mirror: `name`, `enabled`, `source`, `target`, `renderer`, `retry`, `sync_assets`, `stream_assets`, `preserve_description`, `commit_message`, `release_description` |
518
- | `ConfigLinter`, `ConfigFixer`, `LintIssue`, `FixIssue`, `LintReport`, `FixReport`, `LintSeverity` | Building blocks for programmatic linting and fixing |
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 = "1.1.0"
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 = "GitAcross Authors" }
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.from_path("config.yml")
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
- __version__ = "0.1.0"
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, main
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,4 +1,6 @@
1
1
  """Entry point when invoked with `python -m gitacross`."""
2
+ from __future__ import annotations
3
+
2
4
 
3
5
  from .main import main
4
6
 
@@ -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
- _VALID_PROJECT_KEYS = {
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
- _KNOWN_SOURCE_KEYS = {
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
- _KNOWN_ENDPOINT_KEYS = {
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
- def __init__(self, config_path):
46
- with open(config_path) as f:
47
- raw = yaml.safe_load(f)
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
- self.projects = [ProjectConfig(p) for p in (raw.get("projects") or [])]
54
-
55
- @classmethod
56
- def from_path(cls, config_path):
57
- return cls(config_path)
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 ValueError(
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 _VALID_PROJECT_KEYS:
206
+ if k in VALID_PROJECT_KEYS:
140
207
  continue
141
- if k in _KNOWN_SOURCE_KEYS:
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
- _KNOWN_SOURCE_KEYS[k],
147
- _KNOWN_SOURCE_KEYS[k],
213
+ KNOWN_SOURCE_KEYS[k],
214
+ KNOWN_SOURCE_KEYS[k],
148
215
  k,
149
216
  raw[k],
150
217
  )
151
- elif k in _KNOWN_ENDPOINT_KEYS:
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
- _KNOWN_ENDPOINT_KEYS[k],
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", "preserve_release_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", "preserve_assets", "include_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."""