gitacross 2.3.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.3.0/src/gitacross.egg-info → gitacross-2.3.1}/PKG-INFO +3 -2
  2. {gitacross-2.3.0 → gitacross-2.3.1}/README.md +2 -1
  3. {gitacross-2.3.0 → gitacross-2.3.1}/pyproject.toml +1 -1
  4. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/cli.py +7 -0
  5. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/config.py +37 -5
  6. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/git.py +5 -0
  7. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/main.py +48 -0
  8. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/source.py +1 -1
  9. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/state.py +15 -2
  10. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/target.py +1 -1
  11. {gitacross-2.3.0 → gitacross-2.3.1/src/gitacross.egg-info}/PKG-INFO +3 -2
  12. {gitacross-2.3.0 → gitacross-2.3.1}/tests/test_config.py +79 -0
  13. {gitacross-2.3.0 → gitacross-2.3.1}/tests/test_git.py +35 -0
  14. {gitacross-2.3.0 → gitacross-2.3.1}/tests/test_source.py +89 -0
  15. {gitacross-2.3.0 → gitacross-2.3.1}/tests/test_state.py +33 -0
  16. {gitacross-2.3.0 → gitacross-2.3.1}/tests/test_sync_api.py +100 -0
  17. {gitacross-2.3.0 → gitacross-2.3.1}/tests/test_target.py +54 -0
  18. {gitacross-2.3.0 → gitacross-2.3.1}/LICENSE +0 -0
  19. {gitacross-2.3.0 → gitacross-2.3.1}/setup.cfg +0 -0
  20. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/__init__.py +0 -0
  21. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/__main__.py +0 -0
  22. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/linter/__init__.py +0 -0
  23. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/linter/fixer.py +0 -0
  24. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/linter/keys.py +0 -0
  25. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/linter/models.py +0 -0
  26. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/linter/validator.py +0 -0
  27. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/providers/__init__.py +0 -0
  28. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/providers/base.py +0 -0
  29. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/providers/gitea.py +0 -0
  30. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/providers/github.py +0 -0
  31. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/renderer.py +0 -0
  32. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross/retry.py +0 -0
  33. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross.egg-info/SOURCES.txt +0 -0
  34. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross.egg-info/dependency_links.txt +0 -0
  35. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross.egg-info/entry_points.txt +0 -0
  36. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross.egg-info/requires.txt +0 -0
  37. {gitacross-2.3.0 → gitacross-2.3.1}/src/gitacross.egg-info/top_level.txt +0 -0
  38. {gitacross-2.3.0 → gitacross-2.3.1}/tests/test_integration.py +0 -0
  39. {gitacross-2.3.0 → gitacross-2.3.1}/tests/test_linter.py +0 -0
  40. {gitacross-2.3.0 → gitacross-2.3.1}/tests/test_providers.py +0 -0
  41. {gitacross-2.3.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.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
@@ -443,7 +443,7 @@ GitAcross can be driven from the command line or called directly from Python.
443
443
  ### CLI
444
444
 
445
445
  ```
446
- gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
446
+ gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--clean-cache] [--lint] [--fix] [-v]
447
447
  ```
448
448
 
449
449
  | Flag | Description |
@@ -453,6 +453,7 @@ gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--re
453
453
  | `--workdir PATH` | Where state and cache live (default: `.gitsync`) |
454
454
  | `--dry-run` | Preview changes without committing or pushing |
455
455
  | `--reset` | Clear saved state and cache before running (fresh start) |
456
+ | `--clean-cache` | Delete mirror caches no longer referenced by the config (e.g. after changing a repo's host or name in the config) |
456
457
  | `--lint` | Check the config for YAML errors, invalid settings, and redundant options |
457
458
  | `--fix` | Fix misplaced keys and remove redundant options in the config |
458
459
  | `-v, --verbose` | Debug logging |
@@ -414,7 +414,7 @@ GitAcross can be driven from the command line or called directly from Python.
414
414
  ### CLI
415
415
 
416
416
  ```
417
- gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
417
+ gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--clean-cache] [--lint] [--fix] [-v]
418
418
  ```
419
419
 
420
420
  | Flag | Description |
@@ -424,6 +424,7 @@ gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--re
424
424
  | `--workdir PATH` | Where state and cache live (default: `.gitsync`) |
425
425
  | `--dry-run` | Preview changes without committing or pushing |
426
426
  | `--reset` | Clear saved state and cache before running (fresh start) |
427
+ | `--clean-cache` | Delete mirror caches no longer referenced by the config (e.g. after changing a repo's host or name in the config) |
427
428
  | `--lint` | Check the config for YAML errors, invalid settings, and redundant options |
428
429
  | `--fix` | Fix misplaced keys and remove redundant options in the config |
429
430
  | `-v, --verbose` | Debug logging |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitacross"
7
- version = "2.3.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
@@ -196,6 +196,11 @@ class GitRepo:
196
196
  )
197
197
  p.mkdir(parents=True, exist_ok=True)
198
198
  _ = _git("init", str(p))
199
+ # Give a fresh repo a repo-local identity so commits never depend on
200
+ # the ambient git config (mirrors get the same treatment in
201
+ # ensure_mirror). Existing repos are opened as-is and left untouched.
202
+ _ = _git("-C", str(p), "config", "user.name", "GitAcross")
203
+ _ = _git("-C", str(p), "config", "user.email", "sync@gitacross")
199
204
  logger.info("Initialised git repository at %s", p)
200
205
  return cls.local(path)
201
206
 
@@ -446,6 +446,7 @@ def run(
446
446
  dry_run: bool = False,
447
447
  reset: bool = False,
448
448
  work_dir: str | Path = DEFAULT_WORK_DIR,
449
+ clean_cache: bool = False,
449
450
  ):
450
451
  """Sync releases from a config — the primary Python API entry point.
451
452
 
@@ -469,6 +470,14 @@ def run(
469
470
  syncing so that all releases are treated as new.
470
471
  work_dir: Directory path for ``state.yml`` and ``cache/``.
471
472
  Defaults to ``.gitsync``.
473
+ clean_cache: When ``True``, delete bare mirror clones under
474
+ ``work_dir/cache/`` that no endpoint in the config references
475
+ anymore (e.g. after the config changed hosts or repos). Mirrors are
476
+ disposable clones, so this only ever costs a re-clone. Skipped in
477
+ dry-run mode. Note: mirrors are identified per config, so if
478
+ multiple config files share one ``work_dir``, run them with the
479
+ same ``clean_cache`` setting to avoid deleting each other's
480
+ mirrors.
472
481
 
473
482
  Returns:
474
483
  A list of dicts — one per enabled project that was attempted — with
@@ -522,6 +531,9 @@ def run(
522
531
  if project_name and not projects:
523
532
  raise ValueError(f"Project '{project_name}' not found in config")
524
533
 
534
+ if clean_cache and not dry_run and not reset:
535
+ _clean_mirror_cache(work_dir, config)
536
+
525
537
  results = []
526
538
  for proj in projects:
527
539
  if not proj.enabled:
@@ -552,6 +564,42 @@ def run(
552
564
  return results
553
565
 
554
566
 
567
+ def _clean_mirror_cache(work_dir: str | Path, config: Config) -> None:
568
+ """Delete mirror clones under ``work_dir/cache/`` not referenced by *config*.
569
+
570
+ Cache keys are deterministic identity names (role, type, host, repo), so a
571
+ config edit that changes any of those orphans the previous mirror. Orphans
572
+ are harmless but waste disk, so this removes them. The reference set is
573
+ built from *every* project in the config — enabled or not — and remote
574
+ endpoints only, so running a project subset or re-enabling a disabled
575
+ project never deletes a mirror it still needs.
576
+ """
577
+ cache_dir = Path(work_dir) / "cache"
578
+ if not cache_dir.is_dir():
579
+ return
580
+ referenced = {
581
+ endpoint.mirror_dir_name(role)
582
+ for proj in config.projects
583
+ for endpoint, role in ((proj.source, "source"), (proj.target, "target"))
584
+ if endpoint.is_remote
585
+ }
586
+ removed = []
587
+ for child in sorted(cache_dir.iterdir()):
588
+ if (
589
+ child.is_dir()
590
+ and child.name.endswith(".git")
591
+ and child.name not in referenced
592
+ ):
593
+ shutil.rmtree(child, ignore_errors=True)
594
+ removed.append(child.name)
595
+ if removed:
596
+ logger.info(
597
+ "Removed %d stale mirror cache(s): %s",
598
+ len(removed),
599
+ ", ".join(removed),
600
+ )
601
+
602
+
555
603
 
556
604
 
557
605
  # Imported at the bottom to avoid a circular import (cli.py imports .main.run).
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitacross
3
- Version: 2.3.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
@@ -443,7 +443,7 @@ GitAcross can be driven from the command line or called directly from Python.
443
443
  ### CLI
444
444
 
445
445
  ```
446
- gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--lint] [--fix] [-v]
446
+ gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--reset] [--clean-cache] [--lint] [--fix] [-v]
447
447
  ```
448
448
 
449
449
  | Flag | Description |
@@ -453,6 +453,7 @@ gitacross --config PATH [--project-name NAME] [--workdir PATH] [--dry-run] [--re
453
453
  | `--workdir PATH` | Where state and cache live (default: `.gitsync`) |
454
454
  | `--dry-run` | Preview changes without committing or pushing |
455
455
  | `--reset` | Clear saved state and cache before running (fresh start) |
456
+ | `--clean-cache` | Delete mirror caches no longer referenced by the config (e.g. after changing a repo's host or name in the config) |
456
457
  | `--lint` | Check the config for YAML errors, invalid settings, and redundant options |
457
458
  | `--fix` | Fix misplaced keys and remove redundant options in the config |
458
459
  | `-v, --verbose` | Debug logging |
@@ -73,6 +73,85 @@ projects:
73
73
  os.unlink(config_path)
74
74
 
75
75
 
76
+ def test_endpoint_host_and_host_slug():
77
+ """host/host_slug identify the git host and stay filesystem-safe."""
78
+ from gitacross.config import _EndpointConfig
79
+
80
+ gitea = _EndpointConfig(
81
+ {"type": "gitea", "repo": "a/b", "api": "https://git.nodebay.top/api/v1"},
82
+ is_source=True,
83
+ )
84
+ assert gitea.host == "git.nodebay.top"
85
+ assert gitea.host_slug == "git.nodebay.top"
86
+
87
+ # GitHub's API host maps to its git host
88
+ gh = _EndpointConfig(
89
+ {"type": "github", "repo": "a/b", "api": "https://api.github.com"},
90
+ is_source=True,
91
+ )
92
+ assert gh.host == "github.com"
93
+ assert gh.host_slug == "github.com"
94
+
95
+ # Ports are kept in the slug so hosts differing only by port stay distinct
96
+ port = _EndpointConfig(
97
+ {"type": "gitea", "repo": "a/b", "api": "https://git.nodebay.top:8443/api/v1"},
98
+ is_source=True,
99
+ )
100
+ assert port.host_slug == "git.nodebay.top_8443"
101
+
102
+ # Dots vs hyphens are NOT collapsed into the same slug
103
+ assert (
104
+ _EndpointConfig({"api": "https://my.host.com"}, is_source=True).host_slug
105
+ == "my.host.com"
106
+ )
107
+ assert (
108
+ _EndpointConfig({"api": "https://my-host.com"}, is_source=True).host_slug
109
+ == "my-host.com"
110
+ )
111
+
112
+ # No api configured -> empty host
113
+ assert _EndpointConfig({"api": ""}, is_source=True).host == ""
114
+ print(" ✓ config: endpoint host / host_slug")
115
+
116
+
117
+ def test_mirror_dir_name_deterministic_and_unique():
118
+ """mirror_dir_name is deterministic across runs and collision-resistant."""
119
+ import re
120
+
121
+ from gitacross.config import _EndpointConfig
122
+
123
+ def name(repo, api="https://git.nodebay.top/api/v1", kind="gitea", role="source"):
124
+ raw = {"type": kind, "repo": repo, "api": api}
125
+ return _EndpointConfig(raw, is_source=True).mirror_dir_name(role)
126
+
127
+ # Deterministic: same identity always yields the same cache name
128
+ assert name("uqkami/Awara") == name("uqkami/Awara")
129
+
130
+ # Role is part of the identity (source vs target never share a mirror)
131
+ assert name("uqkami/Awara", role="target") != name("uqkami/Awara", role="source")
132
+
133
+ # Readable prefix + a short hex digest before the .git suffix
134
+ n = name("uqkami/Awara")
135
+ assert n.startswith("source_gitea_git.nodebay.top_uqkami_Awara_")
136
+ digest = n.rsplit("_", 1)[1]
137
+ assert re.fullmatch(r"[0-9a-f]{10}\.git", digest)
138
+
139
+ # Slug-ambiguous owner/repo pairs must never share a mirror
140
+ assert name("a/b_c") != name("a_b/c")
141
+
142
+ # Host case differences resolve to the same host -> same cache (dedup)
143
+ upper = _EndpointConfig(
144
+ {"type": "gitea", "repo": "a/b", "api": "https://GIT.NODEBAY.TOP/api/v1"},
145
+ is_source=True,
146
+ ).mirror_dir_name("source")
147
+ assert upper == name("a/b")
148
+
149
+ # Different hosts or ports stay distinct
150
+ assert name("a/b", api="https://git.nodebay.top:8443/api/v1") != name("a/b")
151
+ assert name("a/b", api="https://gitea.example.com/api/v1") != name("a/b")
152
+ print(" ✓ config: mirror_dir_name is deterministic and collision-resistant")
153
+
154
+
76
155
  def test_config_local():
77
156
  from gitacross.config import Config
78
157
 
@@ -276,6 +276,41 @@ def test_git_mirror_instance_commands_whitelisted_but_not_local():
276
276
  assert not any("safe.directory" in a for a in local_cmd)
277
277
  print(" ✓ git: mirror commands whitelisted, local repos left strict")
278
278
 
279
+
280
+ def test_git_ensure_local_new_repo_commits_without_ambient_identity(monkeypatch):
281
+ """Auto-initialised repos carry a repo-local identity, so commits never
282
+ depend on the ambient git config.
283
+
284
+ Regression: fresh ensure_local repos had no identity configured, so commits
285
+ failed with exit 128 ("Please tell me who you are") on machines without a
286
+ global git user.name/email.
287
+ """
288
+ from gitacross.git import GitRepo
289
+
290
+ # Strip all ambient identity: only repo-local config may be used
291
+ monkeypatch.setenv("GIT_CONFIG_GLOBAL", "/dev/null")
292
+ monkeypatch.setenv("GIT_CONFIG_SYSTEM", "/dev/null")
293
+ for var in (
294
+ "GIT_AUTHOR_NAME",
295
+ "GIT_AUTHOR_EMAIL",
296
+ "GIT_COMMITTER_NAME",
297
+ "GIT_COMMITTER_EMAIL",
298
+ ):
299
+ monkeypatch.delenv(var, raising=False)
300
+
301
+ with tempfile.TemporaryDirectory() as tmp:
302
+ repo = Path(tmp) / "repo"
303
+ git = GitRepo.ensure_local(repo)
304
+ git.ensure_branch("main")
305
+
306
+ work = Path(tmp) / "work"
307
+ work.mkdir()
308
+ _ = _make_file(work, "f.txt", "hello")
309
+ result = git.commit(work, "Release v1.0")
310
+ assert result.returncode in (0, 1)
311
+ assert git.head_sha()
312
+ print(" ✓ git: auto-initialised repos commit without ambient identity")
313
+
279
314
  def test_git_reset_worktree_bare_noop():
280
315
  """reset_worktree is a no-op for bare repos — HEAD must stay untouched."""
281
316
  from gitacross.git import GitRepo
@@ -718,6 +718,95 @@ def test_remote_source_duck_typed_helpers():
718
718
  mock_api.list_release_assets.assert_called_with(7)
719
719
  print(" ✓ source: remote passthrough helpers delegate correctly")
720
720
 
721
+
722
+ def test_remote_source_cache_separates_hosts():
723
+ """Same-named repos on different hosts must not share a mirror cache dir.
724
+
725
+ Regression: mirror names only encoded type + owner/repo, so two hosts with
726
+ the same repo slug collided — the shared mirror flipped origin and re-fetched
727
+ between projects, dirtying each other's cache.
728
+ """
729
+ from gitacross.config import _EndpointConfig
730
+ from gitacross.source import create_source
731
+
732
+ calls = []
733
+
734
+ def fake_ensure_mirror(url, dest):
735
+ calls.append((url, dest))
736
+ return mock.MagicMock()
737
+
738
+ host_a = _EndpointConfig(
739
+ {"type": "gitea", "repo": "uqkami/Awara",
740
+ "api": "https://git.nodebay.top/api/v1", "token": "t"},
741
+ is_source=True,
742
+ )
743
+ host_b = _EndpointConfig(
744
+ {"type": "gitea", "repo": "uqkami/Awara",
745
+ "api": "https://gitea.example.com/api/v1", "token": "t"},
746
+ is_source=True,
747
+ )
748
+ same_as_a = _EndpointConfig(
749
+ {"type": "gitea", "repo": "uqkami/Awara",
750
+ "api": "https://git.nodebay.top/api/v1", "token": "other-token"},
751
+ is_source=True,
752
+ )
753
+
754
+ mock_apis = [mock.MagicMock() for _ in range(3)]
755
+ with mock.patch(
756
+ "gitacross.source.get_api_client", side_effect=mock_apis
757
+ ), mock.patch(
758
+ "gitacross.git.GitRepo.ensure_mirror", side_effect=fake_ensure_mirror
759
+ ):
760
+ _ = create_source(host_a, "cache")
761
+ _ = create_source(host_b, "cache")
762
+ _ = create_source(same_as_a, "cache")
763
+
764
+ dest_a, dest_b, dest_same = (Path(d) for _, d in calls)
765
+ for d in (dest_a, dest_b):
766
+ assert str(d).endswith(".git")
767
+ assert "source_gitea_git.nodebay.top_uqkami_Awara_" in str(dest_a)
768
+ assert "source_gitea_gitea.example.com_uqkami_Awara_" in str(dest_b)
769
+ # Different hosts -> separate mirrors; same host (any token) -> shared mirror
770
+ assert dest_a != dest_b
771
+ assert dest_a == dest_same
772
+ print(" ✓ source: remote source cache dirs are separated per host")
773
+
774
+
775
+ def test_remote_source_cache_hash_kills_slug_collisions():
776
+ """Slug-ambiguous repos (a/b_c vs a_b/c) on one host never share a mirror."""
777
+ from gitacross.config import _EndpointConfig
778
+ from gitacross.source import create_source
779
+
780
+ calls = []
781
+
782
+ def fake_ensure_mirror(url, dest):
783
+ calls.append((url, dest))
784
+ return mock.MagicMock()
785
+
786
+ def cfg(repo):
787
+ return _EndpointConfig(
788
+ {"type": "gitea", "repo": repo,
789
+ "api": "https://git.nodebay.top/api/v1", "token": "t"},
790
+ is_source=True,
791
+ )
792
+
793
+ mock_apis = [mock.MagicMock() for _ in range(2)]
794
+ with mock.patch(
795
+ "gitacross.source.get_api_client", side_effect=mock_apis
796
+ ), mock.patch(
797
+ "gitacross.git.GitRepo.ensure_mirror", side_effect=fake_ensure_mirror
798
+ ):
799
+ _ = create_source(cfg("a/b_c"), "cache")
800
+ _ = create_source(cfg("a_b/c"), "cache")
801
+
802
+ dest1, dest2 = (Path(d) for _, d in calls)
803
+ # Both have the same readable slug prefix (a_b_c), but the identity hashes
804
+ # differ, so they resolve to distinct mirror dirs
805
+ assert dest1 != dest2
806
+ assert "a_b_c_" in str(dest1)
807
+ assert "a_b_c_" in str(dest2)
808
+ print(" ✓ source: slug collisions resolve to distinct cache dirs")
809
+
721
810
  def test_local_source_duck_typed_helpers():
722
811
  from gitacross.config import _EndpointConfig
723
812
  from gitacross.source import create_source
@@ -74,4 +74,37 @@ def test_state():
74
74
  print(" ✓ state persistence")
75
75
 
76
76
 
77
+ def test_state_tolerates_degenerate_yaml():
78
+ """A corrupt or hand-edited state file must never crash a run.
79
+
80
+ Regression: a state.yml containing only ``projects:`` parses to
81
+ ``{"projects": None}``, which crashed has_release with an AttributeError.
82
+ """
83
+ from gitacross.state import State
84
+
85
+ degenerate = [
86
+ "projects:\n", # bare key, no value (the reported crash)
87
+ "projects: []\n", # list instead of map
88
+ "[]\n", # scalar document
89
+ "projects:\n myapp:\n", # null project entry
90
+ "projects:\n myapp:\n releases:\n", # null releases entry
91
+ "", # empty file
92
+ ]
93
+ for i, content in enumerate(degenerate):
94
+ with tempfile.TemporaryDirectory() as tmp:
95
+ path = Path(tmp) / "state.yml"
96
+ _ = path.write_text(content)
97
+ state = State(tmp)
98
+ assert not state.has_release("myapp", "v1.0"), f"case {i}: {content!r}"
99
+ state.add_release(
100
+ "myapp",
101
+ "v1.0",
102
+ {"tag": "v1.0", "target_commit": "x"},
103
+ )
104
+ state.save()
105
+ # Round-trip: the release is now visible and nothing else crashes
106
+ assert State(tmp).has_release("myapp", "v1.0"), f"case {i}: {content!r}"
107
+ print(" ✓ state: degenerate state.yml is treated as empty, never crashes")
108
+
109
+
77
110
 
@@ -363,6 +363,106 @@ def test_run_git_missing_reports_clear_error(monkeypatch):
363
363
  print(" ✓ run: missing git binary surfaces a clear error message")
364
364
 
365
365
 
366
+ def test_run_clean_cache_removes_orphaned_mirrors():
367
+ """clean_cache deletes mirrors no config endpoint references — nothing else."""
368
+ import gitacross
369
+
370
+ from gitacross.config import Config
371
+
372
+ with tempfile.TemporaryDirectory() as tmp:
373
+ tmp = Path(tmp)
374
+ cfg_path = tmp / "config.yml"
375
+ _ = cfg_path.write_text("""
376
+ projects:
377
+ - name: proj-a
378
+ source:
379
+ type: gitea
380
+ repo: owner/repo
381
+ api: https://git.nodebay.top/api/v1
382
+ token: t
383
+ target:
384
+ type: local
385
+ path: /tmp/tgt-a
386
+ - name: proj-disabled
387
+ enabled: false
388
+ source:
389
+ type: gitea
390
+ repo: owner/old
391
+ api: https://gitea.example.com/api/v1
392
+ token: t
393
+ target:
394
+ type: local
395
+ path: /tmp/tgt-b
396
+ """)
397
+ cfg = Config(str(cfg_path))
398
+ referenced = cfg.projects[0].source.mirror_dir_name("source")
399
+ disabled_ref = cfg.projects[1].source.mirror_dir_name("source")
400
+ wd = tmp / "workdir"
401
+
402
+ with mock.patch("gitacross.main.sync_project", return_value=[]):
403
+ # (a) no cache dir yet -> clean-cache is a safe no-op
404
+ _ = gitacross.run(cfg, work_dir=wd, clean_cache=True)
405
+
406
+ # Build a cache with referenced + orphaned mirrors and junk
407
+ cache = wd / "cache"
408
+ (cache / referenced).mkdir(parents=True)
409
+ (cache / disabled_ref).mkdir()
410
+ orphan1 = cache / "source_gitea_git.nodebay.top_owner_old_aaaa.git"
411
+ orphan2 = cache / "target_github_github.com_owner_repo_bbbb.git"
412
+ orphan1.mkdir()
413
+ orphan2.mkdir()
414
+ (cache / "notes.txt").write_text("junk")
415
+ (cache / "not-a-mirror").mkdir()
416
+
417
+ # (b) orphans removed; referenced / disabled-project / junk survive
418
+ _ = gitacross.run(cfg, work_dir=wd, clean_cache=True)
419
+
420
+ assert not orphan1.exists()
421
+ assert not orphan2.exists()
422
+ assert (cache / referenced).is_dir()
423
+ assert (cache / disabled_ref).is_dir()
424
+ assert (cache / "notes.txt").read_text() == "junk"
425
+ assert (cache / "not-a-mirror").is_dir()
426
+
427
+ # (c) dry-run never deletes anything
428
+ (cache / "fresh_orphan_cccc.git").mkdir()
429
+ with mock.patch("gitacross.main.sync_project", return_value=[]):
430
+ _ = gitacross.run(cfg, work_dir=wd, clean_cache=True, dry_run=True)
431
+ assert (cache / "fresh_orphan_cccc.git").is_dir()
432
+
433
+ # (d) a second clean run removes it; (e) a third has nothing left to do
434
+ with mock.patch("gitacross.main.sync_project", return_value=[]):
435
+ _ = gitacross.run(cfg, work_dir=wd, clean_cache=True)
436
+ assert not (cache / "fresh_orphan_cccc.git").exists()
437
+ with mock.patch("gitacross.main.sync_project", return_value=[]):
438
+ _ = gitacross.run(cfg, work_dir=wd, clean_cache=True)
439
+ print(" ✓ run: clean_cache removes only unreferenced mirrors")
440
+
441
+
442
+ def test_main_clean_cache_flag_passed_to_run():
443
+ """CLI --clean-cache is forwarded to run()."""
444
+ from gitacross.cli import main
445
+
446
+ with tempfile.TemporaryDirectory() as tmp:
447
+ cfg = Path(tmp) / "config.yml"
448
+ _write_local_config(cfg, [{"name": "proj-cli"}])
449
+ captured = {}
450
+
451
+ def _fake_run(*args, **kwargs):
452
+ captured.update(kwargs)
453
+ return []
454
+
455
+ with mock.patch("gitacross.main.run", side_effect=_fake_run), mock.patch(
456
+ "sys.argv", ["gitacross", "--config", str(cfg), "--clean-cache"]
457
+ ):
458
+ try:
459
+ main()
460
+ except SystemExit as e:
461
+ assert e.code in (0, None), f"expected exit 0, got {e.code}"
462
+ assert captured.get("clean_cache") is True
463
+ print(" ✓ cli: --clean-cache flag is forwarded to run()")
464
+
465
+
366
466
  def test_run_dry_run_passed_through():
367
467
  import gitacross
368
468
 
@@ -248,6 +248,60 @@ def test_create_target_unknown_type():
248
248
  assert "Unknown target type" in str(e)
249
249
  print(" ✓ target: unknown type rejected")
250
250
 
251
+ def test_remote_target_cache_separates_hosts():
252
+ """Same-named target repos on different hosts get separate mirror caches."""
253
+ from gitacross.config import _EndpointConfig
254
+ from gitacross.target import create_target
255
+
256
+ calls = []
257
+
258
+ def fake_ensure_mirror(url, dest):
259
+ calls.append((url, dest))
260
+ return mock.MagicMock()
261
+
262
+ host_a = _EndpointConfig(
263
+ {"type": "gitea", "repo": "uqkami/Awara",
264
+ "api": "https://git.nodebay.top/api/v1", "token": "t"},
265
+ is_source=False,
266
+ )
267
+ host_b = _EndpointConfig(
268
+ {"type": "gitea", "repo": "uqkami/Awara",
269
+ "api": "https://gitea.example.com/api/v1", "token": "t"},
270
+ is_source=False,
271
+ )
272
+ same_as_a = _EndpointConfig(
273
+ {"type": "gitea", "repo": "uqkami/Awara",
274
+ "api": "https://git.nodebay.top/api/v1", "token": "other"},
275
+ is_source=False,
276
+ )
277
+
278
+ mock_apis = []
279
+ for _cfg in (host_a, host_b, same_as_a):
280
+ api = mock.MagicMock()
281
+ api.ensure_repo_exists.return_value = {
282
+ "clone_url": f"https://{_cfg.host}/{_cfg.repo}.git"
283
+ }
284
+ mock_apis.append(api)
285
+
286
+ with mock.patch(
287
+ "gitacross.target.get_api_client", side_effect=mock_apis
288
+ ), mock.patch(
289
+ "gitacross.git.GitRepo.ensure_mirror", side_effect=fake_ensure_mirror
290
+ ):
291
+ _ = create_target(host_a, "cache")
292
+ _ = create_target(host_b, "cache")
293
+ _ = create_target(same_as_a, "cache")
294
+
295
+ dest_a, dest_b, dest_same = (Path(d) for _, d in calls)
296
+ assert "target_gitea_git.nodebay.top_uqkami_Awara_" in str(dest_a)
297
+ assert "target_gitea_gitea.example.com_uqkami_Awara_" in str(dest_b)
298
+ for d in (dest_a, dest_b):
299
+ assert str(d).endswith(".git")
300
+ assert dest_a != dest_b
301
+ assert dest_a == dest_same # same host + repo shares one mirror (dedup)
302
+ print(" ✓ target: remote target cache dirs are separated per host")
303
+
304
+
251
305
  def test_remote_target_no_clone_url_raises():
252
306
  """A remote target without any clone URL raises a clear error."""
253
307
  from gitacross.config import _EndpointConfig
File without changes
File without changes