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.
Files changed (41) hide show
  1. {gitacross-2.2.0/src/gitacross.egg-info → gitacross-2.3.1}/PKG-INFO +10 -2
  2. {gitacross-2.2.0 → gitacross-2.3.1}/README.md +9 -1
  3. {gitacross-2.2.0 → gitacross-2.3.1}/pyproject.toml +1 -1
  4. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/cli.py +7 -0
  5. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/config.py +37 -5
  6. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/git.py +118 -15
  7. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/main.py +48 -0
  8. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/source.py +1 -1
  9. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/state.py +15 -2
  10. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/target.py +8 -3
  11. {gitacross-2.2.0 → gitacross-2.3.1/src/gitacross.egg-info}/PKG-INFO +10 -2
  12. {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_config.py +79 -0
  13. {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_git.py +207 -0
  14. {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_integration.py +221 -0
  15. {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_source.py +119 -12
  16. {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_state.py +33 -0
  17. {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_sync_api.py +123 -0
  18. {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_target.py +148 -0
  19. {gitacross-2.2.0 → gitacross-2.3.1}/LICENSE +0 -0
  20. {gitacross-2.2.0 → gitacross-2.3.1}/setup.cfg +0 -0
  21. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/__init__.py +0 -0
  22. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/__main__.py +0 -0
  23. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/linter/__init__.py +0 -0
  24. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/linter/fixer.py +0 -0
  25. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/linter/keys.py +0 -0
  26. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/linter/models.py +0 -0
  27. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/linter/validator.py +0 -0
  28. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/providers/__init__.py +0 -0
  29. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/providers/base.py +0 -0
  30. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/providers/gitea.py +0 -0
  31. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/providers/github.py +0 -0
  32. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/renderer.py +0 -0
  33. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross/retry.py +0 -0
  34. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross.egg-info/SOURCES.txt +0 -0
  35. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross.egg-info/dependency_links.txt +0 -0
  36. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross.egg-info/entry_points.txt +0 -0
  37. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross.egg-info/requires.txt +0 -0
  38. {gitacross-2.2.0 → gitacross-2.3.1}/src/gitacross.egg-info/top_level.txt +0 -0
  39. {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_linter.py +0 -0
  40. {gitacross-2.2.0 → gitacross-2.3.1}/tests/test_providers.py +0 -0
  41. {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.2.0
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.2.0"
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
- raw_host = self.api.split("://")[1].split("/")[0] if "://" in self.api else self.api
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"] + [str(a) for a in args]
42
- logger.debug("> git %s", _redact(" ".join(str(a) for a in args)))
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("-C", str(path), "remote", "get-url", "origin", check=False)
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("-C", str(path), "remote", "set-url", "origin", url)
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("-C", str(path), "fetch", "--tags", "--prune", "origin")
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", "--git-dir")
110
- git_dir = p / result.stdout.strip()
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
- return cls(git_dir, is_bare=False)
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("--git-dir", str(self.git_dir), *args, text=text, env=env, **kwargs)
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) / f"source_{config.type}_{config.repo_slug}.git",
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) or {}
32
- else:
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) / f"target_{config.type}_{config.repo_slug}.git",
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.local(config.path)
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.2.0
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