gitacross 2.2.0__tar.gz → 2.3.1__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.2.0/src/gitacross.egg-info → gitacross-2.3.1}/PKG-INFO +10 -2
- {gitacross-2.2.0 → gitacross-2.3.1}/README.md +9 -1
- {gitacross-2.2.0 → gitacross-2.3.1}/pyproject.toml +1 -1
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/cli.py +7 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/config.py +37 -5
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/git.py +118 -15
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/main.py +48 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/source.py +1 -1
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/state.py +15 -2
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/target.py +8 -3
- {gitacross-2.2.0 → gitacross-2.3.1/src/gitacross.egg-info}/PKG-INFO +10 -2
- {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_config.py +79 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_git.py +207 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_integration.py +221 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_source.py +119 -12
- {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_state.py +33 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_sync_api.py +123 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_target.py +148 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/LICENSE +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/setup.cfg +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/__init__.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/__main__.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/linter/__init__.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/linter/fixer.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/linter/keys.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/linter/models.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/linter/validator.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/providers/__init__.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/providers/base.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/providers/gitea.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/providers/github.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/renderer.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/retry.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross.egg-info/SOURCES.txt +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross.egg-info/dependency_links.txt +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross.egg-info/entry_points.txt +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross.egg-info/requires.txt +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross.egg-info/top_level.txt +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_linter.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_providers.py +0 -0
- {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_renderer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitacross
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.1
|
|
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
|
|
@@ -157,6 +157,13 @@ A config file starts with a `projects` list — each entry is one mirror and nee
|
|
|
157
157
|
|
|
158
158
|
Tokens use `${VAR}` syntax — resolved from environment variables.
|
|
159
159
|
|
|
160
|
+
For local **targets**, the `path` does not have to exist yet: if the directory is
|
|
161
|
+
missing or is not already a git repository, GitAcross creates the directory and
|
|
162
|
+
runs `git init` there before committing. If a repository already exists at that
|
|
163
|
+
path it is opened as-is — existing git metadata is never re-initialised or
|
|
164
|
+
overwritten (bare repositories and broken `.git` markers are refused with an
|
|
165
|
+
error). Local **sources** must point at an existing git repository.
|
|
166
|
+
|
|
160
167
|
| Option | Description |
|
|
161
168
|
|---|---|
|
|
162
169
|
| [`enabled`](#enabled) | Disable a project without deleting it |
|
|
@@ -436,7 +443,7 @@ GitAcross can be driven from the command line or called directly from Python.
|
|
|
436
443
|
### CLI
|
|
437
444
|
|
|
438
445
|
```
|
|
439
|
-
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]
|
|
440
447
|
```
|
|
441
448
|
|
|
442
449
|
| Flag | Description |
|
|
@@ -446,6 +453,7 @@ gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--re
|
|
|
446
453
|
| `--workdir PATH` | Where state and cache live (default: `.gitsync`) |
|
|
447
454
|
| `--dry-run` | Preview changes without committing or pushing |
|
|
448
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) |
|
|
449
457
|
| `--lint` | Check the config for YAML errors, invalid settings, and redundant options |
|
|
450
458
|
| `--fix` | Fix misplaced keys and remove redundant options in the config |
|
|
451
459
|
| `-v, --verbose` | Debug logging |
|
|
@@ -128,6 +128,13 @@ A config file starts with a `projects` list — each entry is one mirror and nee
|
|
|
128
128
|
|
|
129
129
|
Tokens use `${VAR}` syntax — resolved from environment variables.
|
|
130
130
|
|
|
131
|
+
For local **targets**, the `path` does not have to exist yet: if the directory is
|
|
132
|
+
missing or is not already a git repository, GitAcross creates the directory and
|
|
133
|
+
runs `git init` there before committing. If a repository already exists at that
|
|
134
|
+
path it is opened as-is — existing git metadata is never re-initialised or
|
|
135
|
+
overwritten (bare repositories and broken `.git` markers are refused with an
|
|
136
|
+
error). Local **sources** must point at an existing git repository.
|
|
137
|
+
|
|
131
138
|
| Option | Description |
|
|
132
139
|
|---|---|
|
|
133
140
|
| [`enabled`](#enabled) | Disable a project without deleting it |
|
|
@@ -407,7 +414,7 @@ GitAcross can be driven from the command line or called directly from Python.
|
|
|
407
414
|
### CLI
|
|
408
415
|
|
|
409
416
|
```
|
|
410
|
-
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]
|
|
411
418
|
```
|
|
412
419
|
|
|
413
420
|
| Flag | Description |
|
|
@@ -417,6 +424,7 @@ gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--re
|
|
|
417
424
|
| `--workdir PATH` | Where state and cache live (default: `.gitsync`) |
|
|
418
425
|
| `--dry-run` | Preview changes without committing or pushing |
|
|
419
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) |
|
|
420
428
|
| `--lint` | Check the config for YAML errors, invalid settings, and redundant options |
|
|
421
429
|
| `--fix` | Fix misplaced keys and remove redundant options in the config |
|
|
422
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.
|
|
7
|
+
version = "2.3.1"
|
|
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
|
|
@@ -37,9 +37,16 @@ def _log_git_stderr(command, returncode, stderr, level):
|
|
|
37
37
|
)
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def _git(*args, check=True, input_data=None, text=True, env=None):
|
|
41
|
-
cmd = ["git"]
|
|
42
|
-
|
|
40
|
+
def _git(*args, check=True, input_data=None, text=True, env=None, safe_dir=None):
|
|
41
|
+
cmd = ["git"]
|
|
42
|
+
if safe_dir:
|
|
43
|
+
# Git refuses to operate in a repository owned by another user
|
|
44
|
+
# ("dubious ownership"). The bare mirrors under the cache dir are
|
|
45
|
+
# created and managed by gitacross itself, so whitelist exactly that
|
|
46
|
+
# directory for this one invocation via -c — never written to a config.
|
|
47
|
+
cmd += ["-c", f"safe.directory={Path(safe_dir).resolve()}"]
|
|
48
|
+
cmd += [str(a) for a in args]
|
|
49
|
+
logger.debug("> git %s", _redact(" ".join(str(a) for a in cmd[1:])))
|
|
43
50
|
try:
|
|
44
51
|
result = subprocess.run(
|
|
45
52
|
cmd,
|
|
@@ -56,6 +63,12 @@ def _git(*args, check=True, input_data=None, text=True, env=None):
|
|
|
56
63
|
if isinstance(part, str):
|
|
57
64
|
e.cmd[i] = _redact(part)
|
|
58
65
|
raise
|
|
66
|
+
except FileNotFoundError as exc:
|
|
67
|
+
# subprocess can't find the `git` binary at all — give a clear error
|
|
68
|
+
# instead of the raw "[Errno 2] No such file or directory: 'git'".
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"git executable not found — install Git and make sure it is on PATH."
|
|
71
|
+
) from exc
|
|
59
72
|
if result.returncode != 0:
|
|
60
73
|
# check=False path — callers inspect the return code themselves
|
|
61
74
|
_log_git_stderr(args[0], result.returncode, result.stderr, level=logging.DEBUG)
|
|
@@ -87,34 +100,122 @@ class GitRepo:
|
|
|
87
100
|
path = Path(dest)
|
|
88
101
|
if path.exists():
|
|
89
102
|
# Update remote URL *before* fetching so token rotation takes effect
|
|
90
|
-
current = _git(
|
|
103
|
+
current = _git(
|
|
104
|
+
"-C", str(path), "remote", "get-url", "origin",
|
|
105
|
+
check=False, safe_dir=path,
|
|
106
|
+
)
|
|
91
107
|
if current.returncode == 0 and current.stdout.strip() != url:
|
|
92
|
-
_ = _git(
|
|
108
|
+
_ = _git(
|
|
109
|
+
"-C", str(path), "remote", "set-url", "origin", url,
|
|
110
|
+
safe_dir=path,
|
|
111
|
+
)
|
|
93
112
|
logger.info("Updated remote URL for mirror at %s", dest)
|
|
94
|
-
_ = _git(
|
|
113
|
+
_ = _git(
|
|
114
|
+
"-C", str(path), "fetch", "--tags", "--prune", "origin",
|
|
115
|
+
safe_dir=path,
|
|
116
|
+
)
|
|
95
117
|
logger.info("Updated mirror at %s", dest)
|
|
96
118
|
else:
|
|
97
119
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
-
_ = _git("clone", "--mirror", url, str(path))
|
|
120
|
+
_ = _git("clone", "--mirror", url, str(path), safe_dir=path)
|
|
99
121
|
logger.info("Cloned mirror from %s", _redact(url))
|
|
100
122
|
# Ensure author identity for automated commits
|
|
101
|
-
_ = _git("-C", str(path), "config", "user.name", "GitAcross")
|
|
102
|
-
_ = _git("-C", str(path), "config", "user.email", "sync@gitacross")
|
|
123
|
+
_ = _git("-C", str(path), "config", "user.name", "GitAcross", safe_dir=path)
|
|
124
|
+
_ = _git("-C", str(path), "config", "user.email", "sync@gitacross", safe_dir=path)
|
|
103
125
|
return cls(path, is_bare=True)
|
|
104
126
|
|
|
105
127
|
@classmethod
|
|
106
128
|
def local(cls, path):
|
|
107
|
-
"""Open an existing local git repository.
|
|
129
|
+
"""Open an existing local git repository.
|
|
130
|
+
|
|
131
|
+
*path* must be the root of its own git working tree. Git resolves a
|
|
132
|
+
path that merely lives *inside* a repository to the nearest enclosing
|
|
133
|
+
repository, so without this check a configured path that is not itself
|
|
134
|
+
a git repository would silently commit to and reset that enclosing
|
|
135
|
+
repo — e.g. the directory the tool is being run from — instead of the
|
|
136
|
+
configured location.
|
|
137
|
+
"""
|
|
108
138
|
p = Path(path)
|
|
109
|
-
result = _git("-C", str(p), "rev-parse", "--
|
|
110
|
-
|
|
111
|
-
if not git_dir.exists():
|
|
139
|
+
result = _git("-C", str(p), "rev-parse", "--show-toplevel", check=False)
|
|
140
|
+
if result.returncode != 0:
|
|
112
141
|
raise ValueError(f"Not a git repository: {path}")
|
|
113
|
-
|
|
142
|
+
root = Path(result.stdout.strip())
|
|
143
|
+
if Path(p).resolve() != root.resolve():
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"Not a git repository: {path} — it is inside the git repository "
|
|
146
|
+
+ f"'{root}'. A local endpoint path must point at a repository "
|
|
147
|
+
+ f"root; run 'git init' in {path} or point the path at {root}."
|
|
148
|
+
)
|
|
149
|
+
git_dir = _git("-C", str(root), "rev-parse", "--absolute-git-dir")
|
|
150
|
+
return cls(Path(git_dir.stdout.strip()), is_bare=False)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def ensure_local(cls, path):
|
|
154
|
+
"""Open a local git repository at *path*, creating it if necessary.
|
|
155
|
+
|
|
156
|
+
Unlike :meth:`local`, a missing directory or a plain directory that is
|
|
157
|
+
not yet a git repository is accepted: the directory is created
|
|
158
|
+
(including parents) and ``git init`` is run there. Used for local
|
|
159
|
+
*targets*, which receive commits. Local *sources* still go through
|
|
160
|
+
:meth:`local` so a misconfigured source path is reported instead of
|
|
161
|
+
silently yielding an empty repository.
|
|
162
|
+
|
|
163
|
+
An existing repository is opened as-is and never re-initialised. If
|
|
164
|
+
*path* already holds git metadata that cannot be used as a working tree
|
|
165
|
+
(a bare repository, a git-internal directory, or a broken ``.git``
|
|
166
|
+
marker), a :class:`ValueError` is raised instead of overwriting it.
|
|
167
|
+
"""
|
|
168
|
+
if not path or not str(path).strip():
|
|
169
|
+
raise ValueError(
|
|
170
|
+
"Local target path is empty — set 'path' to the directory where "
|
|
171
|
+
+ "the mirrored repository should live."
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
return cls.local(path)
|
|
175
|
+
except ValueError:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
# Only auto-initialise when the directory is genuinely not a git
|
|
179
|
+
# repository yet — never run `git init` over existing git metadata
|
|
180
|
+
# (re-initialising a bare repo pollutes it with a nested .git).
|
|
181
|
+
p = Path(path)
|
|
182
|
+
if p.exists() and not p.is_dir():
|
|
183
|
+
raise ValueError(f"Local target path is not a directory: {path}")
|
|
184
|
+
if (p / ".git").exists():
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"Not initialising {path}: it already contains a '.git' entry "
|
|
187
|
+
+ "that is not a usable working tree. Refusing to overwrite it — "
|
|
188
|
+
+ "fix or remove that repository, or point the local target at a "
|
|
189
|
+
+ "missing or empty directory."
|
|
190
|
+
)
|
|
191
|
+
if (p / "HEAD").is_file() and (p / "objects").is_dir():
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"Not initialising {path}: it is a bare git repository or git's "
|
|
194
|
+
+ "internal directory. Local targets need a working tree, and "
|
|
195
|
+
+ "existing repositories are never re-initialised."
|
|
196
|
+
)
|
|
197
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
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")
|
|
204
|
+
logger.info("Initialised git repository at %s", p)
|
|
205
|
+
return cls.local(path)
|
|
114
206
|
|
|
115
207
|
def _g(self, *args, text=True, env=None, **kwargs):
|
|
116
208
|
"""Run git command with --git-dir set."""
|
|
117
|
-
return _git(
|
|
209
|
+
return _git(
|
|
210
|
+
"--git-dir",
|
|
211
|
+
str(self.git_dir),
|
|
212
|
+
*args,
|
|
213
|
+
text=text,
|
|
214
|
+
env=env,
|
|
215
|
+
# Mirrors are gitacross-managed; whitelist them for the ownership check.
|
|
216
|
+
safe_dir=self.git_dir if self.is_bare else None,
|
|
217
|
+
**kwargs,
|
|
218
|
+
)
|
|
118
219
|
|
|
119
220
|
def _gw(self, work_dir, *args, env=None, **kwargs):
|
|
120
221
|
"""Run git command with --git-dir and --work-tree set."""
|
|
@@ -125,6 +226,8 @@ class GitRepo:
|
|
|
125
226
|
str(work_dir),
|
|
126
227
|
*args,
|
|
127
228
|
env=env,
|
|
229
|
+
# Mirrors are gitacross-managed; whitelist them for the ownership check.
|
|
230
|
+
safe_dir=self.git_dir if self.is_bare else None,
|
|
128
231
|
**kwargs,
|
|
129
232
|
)
|
|
130
233
|
|
|
@@ -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).
|
|
@@ -64,7 +64,7 @@ class _RemoteSource:
|
|
|
64
64
|
self._api.ensure_repo_exists(create=not dry_run)
|
|
65
65
|
self._git = GitRepo.ensure_mirror(
|
|
66
66
|
config.clone_url,
|
|
67
|
-
Path(cache_dir) /
|
|
67
|
+
Path(cache_dir) / config.mirror_dir_name("source"),
|
|
68
68
|
)
|
|
69
69
|
self._include_prereleases = config.include_prereleases
|
|
70
70
|
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
|
|
@@ -120,10 +120,15 @@ class _RemoteTarget:
|
|
|
120
120
|
|
|
121
121
|
|
|
122
122
|
class _LocalTarget:
|
|
123
|
-
"""Target backed by a local git repo — no push, no API release.
|
|
123
|
+
"""Target backed by a local git repo — no push, no API release.
|
|
124
|
+
|
|
125
|
+
The repository at ``config.path`` is created on demand (directory plus
|
|
126
|
+
``git init``) if it does not exist yet, so a local target can point at a
|
|
127
|
+
brand-new backup/mirror location.
|
|
128
|
+
"""
|
|
124
129
|
|
|
125
130
|
def __init__(self, config, author=None):
|
|
126
|
-
self._git = GitRepo.
|
|
131
|
+
self._git = GitRepo.ensure_local(config.path)
|
|
127
132
|
self._author = author
|
|
128
133
|
|
|
129
134
|
def setup(self, branch):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitacross
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.1
|
|
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
|
|
@@ -157,6 +157,13 @@ A config file starts with a `projects` list — each entry is one mirror and nee
|
|
|
157
157
|
|
|
158
158
|
Tokens use `${VAR}` syntax — resolved from environment variables.
|
|
159
159
|
|
|
160
|
+
For local **targets**, the `path` does not have to exist yet: if the directory is
|
|
161
|
+
missing or is not already a git repository, GitAcross creates the directory and
|
|
162
|
+
runs `git init` there before committing. If a repository already exists at that
|
|
163
|
+
path it is opened as-is — existing git metadata is never re-initialised or
|
|
164
|
+
overwritten (bare repositories and broken `.git` markers are refused with an
|
|
165
|
+
error). Local **sources** must point at an existing git repository.
|
|
166
|
+
|
|
160
167
|
| Option | Description |
|
|
161
168
|
|---|---|
|
|
162
169
|
| [`enabled`](#enabled) | Disable a project without deleting it |
|
|
@@ -436,7 +443,7 @@ GitAcross can be driven from the command line or called directly from Python.
|
|
|
436
443
|
### CLI
|
|
437
444
|
|
|
438
445
|
```
|
|
439
|
-
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]
|
|
440
447
|
```
|
|
441
448
|
|
|
442
449
|
| Flag | Description |
|
|
@@ -446,6 +453,7 @@ gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--re
|
|
|
446
453
|
| `--workdir PATH` | Where state and cache live (default: `.gitsync`) |
|
|
447
454
|
| `--dry-run` | Preview changes without committing or pushing |
|
|
448
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) |
|
|
449
457
|
| `--lint` | Check the config for YAML errors, invalid settings, and redundant options |
|
|
450
458
|
| `--fix` | Fix misplaced keys and remove redundant options in the config |
|
|
451
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
|
|