gitacross 1.0.0__py3-none-any.whl

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/source.py ADDED
@@ -0,0 +1,297 @@
1
+ """Source endpoint factory — fetches releases from any configured source type.
2
+
3
+ Supported types: gitea, github, local.
4
+ """
5
+
6
+ import fnmatch
7
+ import logging
8
+ from pathlib import Path
9
+
10
+ from .git import GitRepo
11
+ from .providers import get_api_client
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def create_source(config, cache_dir):
17
+ """Factory: build a Source endpoint from the config."""
18
+ t = config.type
19
+ if t == "local":
20
+ return _LocalSource(config)
21
+ if t in ("gitea", "github"):
22
+ if config.mode not in ("release", "tag", "commit"):
23
+ raise ValueError(
24
+ f"Unknown source mode '{config.mode}' — expected 'release', 'tag', or 'commit'"
25
+ )
26
+ return _RemoteSource(config, cache_dir)
27
+ raise ValueError(f"Unknown source type: {t}")
28
+
29
+
30
+ def _filter_from_sync_point(releases, sync_from):
31
+ """Filter to releases at or after the sync_from tag (inclusive).
32
+
33
+ Releases must already be sorted oldest-first. If sync_from is empty
34
+ return all releases. If the tag isn't found, return nothing —
35
+ better to skip than silently backfill history.
36
+ """
37
+ if not sync_from:
38
+ return releases
39
+ for i, rel in enumerate(releases):
40
+ if rel["tag_name"] == sync_from:
41
+ return releases[i:]
42
+ logger.warning("sync_from tag '%s' not found — syncing nothing", sync_from)
43
+ return []
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Source interface (duck-typed, no ABC to keep imports light)
48
+ # Required: fetch_releases(), export_tag(tag, dest)
49
+ # ---------------------------------------------------------------------------
50
+
51
+
52
+ class _RemoteSource:
53
+ """Source backed by a remote API (Gitea/GitHub) + a bare git mirror."""
54
+
55
+ def __init__(self, config, cache_dir):
56
+ self._repo = config.repo
57
+ self._type = config.type
58
+ self._api = get_api_client(config.type, config.api, config.repo, config.token)
59
+ self._api.verify_access()
60
+ repo_slug = config.repo.replace("/", "_")
61
+ self._git = GitRepo.ensure_mirror(
62
+ config.clone_url,
63
+ Path(cache_dir) / f"source_{config.type}_{repo_slug}.git",
64
+ )
65
+ self._include_prereleases = config.include_prereleases
66
+ self._include_drafts = config.include_drafts
67
+ self._sync_from = config.sync_from
68
+ self._mode = config.mode
69
+ # Branch used to resolve HEAD in commit mode (empty = auto-detect)
70
+ self._branch = getattr(config, "branch", "")
71
+
72
+ def fetch_releases(self):
73
+ """Return the release list for the configured source mode.
74
+
75
+ mode=release (default): paginated API releases, filtered, sorted
76
+ oldest-first. Falls back to git tags only when the API has no
77
+ releases at all.
78
+ mode=tag: git tags from the mirror treated as releases.
79
+ mode=commit: single snapshot of the current branch HEAD, gated on
80
+ sync_from ancestry.
81
+ """
82
+ if self._mode == "commit":
83
+ logger.info("Source mode 'commit' — syncing latest branch HEAD")
84
+ return self._fetch_commit_snapshot()
85
+
86
+ if self._mode == "tag":
87
+ logger.info("Source mode 'tag' — reading releases from git tags")
88
+ return self._fetch_tags_as_releases()
89
+
90
+ releases = []
91
+ page = 1
92
+ while True:
93
+ batch = self._api.list_releases(page=page)
94
+ if not batch:
95
+ break
96
+ releases.extend(batch)
97
+ page += 1
98
+
99
+ # No API releases — fall back to git tags from the mirror.
100
+ # This handles repos where tags were pushed without creating releases.
101
+ if not releases:
102
+ logger.info("No API releases found — falling back to git tags")
103
+ tags = self._fetch_tags_as_releases()
104
+ if not tags:
105
+ logger.warning(
106
+ "No API releases or git tags found for %s repo '%s'. "
107
+ "If this repository does not use releases/tags, set 'mode: commit' under 'source:' in your config.",
108
+ self._type,
109
+ self._repo,
110
+ )
111
+ return tags
112
+
113
+ result = []
114
+ for rel in releases:
115
+ if not self._include_prereleases and rel.get("prerelease"):
116
+ continue
117
+ if not self._include_drafts and rel.get("draft"):
118
+ continue
119
+ result.append(rel)
120
+
121
+ result.sort(
122
+ key=lambda r: r.get("source_date")
123
+ or r.get("published_at")
124
+ or r.get("created_at")
125
+ or ""
126
+ )
127
+
128
+ for rel in result:
129
+ rel["commit_sha"] = self._git.resolve_commit(rel.get("tag_name", ""))
130
+ rel["source_date"] = rel.get("published_at") or rel.get("created_at") or ""
131
+
132
+ # Release mode: sync_from must be an API release. If it's missing from
133
+ # the filtered list, explain why and sync nothing — the user can switch
134
+ # this source to mode: tag when the tag only exists in git.
135
+ if self._sync_from and not any(
136
+ r["tag_name"] == self._sync_from for r in result
137
+ ):
138
+ if any(r["tag_name"] == self._sync_from for r in releases):
139
+ logger.warning(
140
+ "sync_from tag '%s' is a release but was filtered out (prerelease/draft). "
141
+ + "Set include_prereleases/include_drafts to include it; syncing nothing for now.",
142
+ self._sync_from,
143
+ )
144
+ elif self._git.tag_exists(self._sync_from):
145
+ logger.warning(
146
+ "sync_from tag '%s' has no API release (git tag only). "
147
+ + "Set source mode: tag to sync from git tags; syncing nothing for now.",
148
+ self._sync_from,
149
+ )
150
+ else:
151
+ return _filter_from_sync_point(result, self._sync_from)
152
+ return []
153
+
154
+ return _filter_from_sync_point(result, self._sync_from)
155
+
156
+ def _fetch_tags_as_releases(self):
157
+ """Read git tags from the local mirror (sorted oldest-first) and treat them as releases.
158
+
159
+ Uses only ``list_tags_sorted_by_date`` — the unsorted ``list_tags``
160
+ call is unnecessary because both return the same tag set.
161
+ """
162
+ sorted_tags = self._git.list_tags_sorted_by_date()
163
+ if not sorted_tags and self._mode == "tag":
164
+ logger.warning(
165
+ "No git tags found for %s repo '%s'. "
166
+ "If this repository does not use tags, set 'mode: commit' under 'source:' in your config.",
167
+ self._type,
168
+ self._repo,
169
+ )
170
+ result = []
171
+ for tag in sorted_tags:
172
+ commit_sha = self._git.resolve_commit(tag)
173
+ result.append({
174
+ "id": tag,
175
+ "tag_name": tag,
176
+ "commit_sha": commit_sha,
177
+ "body": "",
178
+ "prerelease": False,
179
+ "draft": False,
180
+ "source_date": self._git.tag_commit_date(tag),
181
+ })
182
+ return _filter_from_sync_point(result, self._sync_from)
183
+
184
+ def _fetch_commit_snapshot(self):
185
+ """Return a single synthetic release for the current branch HEAD.
186
+
187
+ Returns an empty list when the HEAD SHA cannot be resolved.
188
+ State's SHA key handles idempotency — already-synced commits are
189
+ skipped by the caller.
190
+
191
+ The synthetic release has ``tag_name=None`` to tell the sync loop
192
+ that this is a raw commit snapshot — no tag or API release should be
193
+ created on the target.
194
+ """
195
+ if self._sync_from:
196
+ logger.warning(
197
+ "sync_from is set but has no effect in commit mode — "
198
+ "state already prevents re-syncing the same SHA. "
199
+ "Remove sync_from from your config to suppress this warning."
200
+ )
201
+
202
+ branch = self._branch or None # pass None to trigger auto-detect
203
+ head_sha = self._git.resolve_default_branch_head(branch)
204
+ if not head_sha:
205
+ logger.warning(
206
+ "Could not resolve branch HEAD in commit mode — skipping"
207
+ )
208
+ return []
209
+
210
+ commit_date = self._git.tag_commit_date(head_sha)
211
+ logger.info(
212
+ "Commit mode: HEAD is %s (%s)",
213
+ head_sha[:12],
214
+ commit_date or "unknown date",
215
+ )
216
+ return [{
217
+ "id": head_sha,
218
+ # tag_name=None signals 'no tag' to the sync loop
219
+ "tag_name": None,
220
+ "commit_sha": head_sha,
221
+ "body": "",
222
+ "prerelease": False,
223
+ "draft": False,
224
+ "source_date": commit_date,
225
+ }]
226
+
227
+ def resolve_commit(self, ref):
228
+ return self._git.resolve_commit(ref)
229
+
230
+ def export_tag(self, tag, dest):
231
+ self._git.export_tag(tag, dest)
232
+
233
+ def export_release(self, rel, dest):
234
+ ref = rel.get("commit_sha") or rel.get("tag_name", "")
235
+ self._git.export_commit(ref, dest)
236
+
237
+ def download_asset(self, asset, dest):
238
+ self._api.download_asset(asset, dest)
239
+
240
+ def list_release_assets(self, release_id):
241
+ return self._api.list_release_assets(release_id)
242
+
243
+ @property
244
+ def repo_path(self):
245
+ return self._git.git_dir
246
+
247
+
248
+ class _LocalSource:
249
+ """Source backed by a local git repository — tags are 'releases'."""
250
+
251
+ def __init__(self, config):
252
+ self._git = GitRepo.local(config.path)
253
+ self._tag_pattern = config.tag_pattern
254
+ self._sync_from = config.sync_from
255
+
256
+ def fetch_releases(self):
257
+ # list_tags_sorted_by_date returns the complete tag set, already ordered
258
+ # oldest-first — no need to call list_tags() separately for filtering.
259
+ sorted_tags = self._git.list_tags_sorted_by_date()
260
+ # Apply tag pattern filter directly (avoids the O(n²) list-in-list check)
261
+ ordered = [t for t in sorted_tags if fnmatch.fnmatch(t, self._tag_pattern)]
262
+ # Local tags have no prerelease/draft/body metadata.
263
+ result = []
264
+ for tag in ordered:
265
+ commit_sha = self._git.resolve_commit(tag)
266
+ result.append(
267
+ {
268
+ "id": tag, # tag name doubles as release ID
269
+ "tag_name": tag,
270
+ "commit_sha": commit_sha,
271
+ "body": "",
272
+ "prerelease": False,
273
+ "draft": False,
274
+ "source_date": self._git.tag_commit_date(tag),
275
+ }
276
+ )
277
+ return _filter_from_sync_point(result, self._sync_from)
278
+
279
+ def resolve_commit(self, ref):
280
+ return self._git.resolve_commit(ref)
281
+
282
+ def export_tag(self, tag, dest):
283
+ self._git.export_tag(tag, dest)
284
+
285
+ def export_release(self, rel, dest):
286
+ ref = rel.get("commit_sha") or rel.get("tag_name", "")
287
+ self._git.export_commit(ref, dest)
288
+
289
+ def download_asset(self, asset, dest):
290
+ pass
291
+
292
+ def list_release_assets(self, release_id):
293
+ return []
294
+
295
+ @property
296
+ def repo_path(self):
297
+ return self._git.git_dir
gitacross/state.py ADDED
@@ -0,0 +1,67 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+
5
+
6
+ class State:
7
+ """Persistent state tracking which releases have been synced.
8
+
9
+ Releases are keyed by tag name (not API release id) so state stays
10
+ valid across source modes (`release` vs `tag`). Written atomically
11
+ (tempfile + rename) after each successful release.
12
+ """
13
+
14
+ def __init__(self, path):
15
+ self.path = Path(path)
16
+ self._data = self._load()
17
+
18
+ def _load(self):
19
+ if self.path.exists():
20
+ with open(self.path) as f:
21
+ data = yaml.safe_load(f) or {}
22
+ else:
23
+ data = {}
24
+ self._migrate_entries(data)
25
+ return data
26
+
27
+ @staticmethod
28
+ def _migrate_entries(data):
29
+ """Re-key release entries by tag name and migrate field names."""
30
+ projects = data.get("projects") or {}
31
+ for project in projects.values():
32
+ releases = project.get("releases")
33
+ if not isinstance(releases, dict):
34
+ continue
35
+ for key, entry in list(releases.items()):
36
+ if not isinstance(entry, dict):
37
+ continue
38
+ # Remove deprecated commit_sha
39
+ entry.pop("commit_sha", None)
40
+ # Rename published_at -> source_date
41
+ if "published_at" in entry and "source_date" not in entry:
42
+ entry["source_date"] = entry.pop("published_at")
43
+ tag = entry.get("tag")
44
+ if not tag or str(key) == str(tag):
45
+ continue
46
+ if str(tag) not in releases:
47
+ releases[str(tag)] = entry
48
+ del releases[key]
49
+
50
+ def has_release(self, project_name, tag_name):
51
+ releases = (
52
+ self._data.get("projects", {}).get(project_name, {}).get("releases", {})
53
+ )
54
+ return str(tag_name) in releases
55
+
56
+ def add_release(self, project_name, tag_name, data):
57
+ self._data.setdefault("projects", {}).setdefault(project_name, {}).setdefault(
58
+ "releases", {}
59
+ )
60
+ self._data["projects"][project_name]["releases"][str(tag_name)] = data
61
+
62
+ def save(self):
63
+ self.path.parent.mkdir(parents=True, exist_ok=True)
64
+ tmp = self.path.with_suffix(".tmp")
65
+ with open(tmp, "w") as f:
66
+ yaml.dump(self._data, f, default_flow_style=False)
67
+ tmp.replace(self.path)
gitacross/target.py ADDED
@@ -0,0 +1,159 @@
1
+ """Target endpoint factory — commits, tags, pushes, and creates releases.
2
+
3
+ Supported types: gitea, github, local.
4
+ """
5
+
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ from .git import GitRepo
10
+ from .providers import get_api_client
11
+ from .retry import retry
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def create_target(config, cache_dir, retry_max=3, retry_backoff=2, author=None):
17
+ """Factory: build a Target endpoint from the config."""
18
+ t = config.type
19
+ if t == "local":
20
+ return _LocalTarget(config, author=author)
21
+ if t in ("gitea", "github"):
22
+ return _RemoteTarget(config, cache_dir, retry_max, retry_backoff, author=author)
23
+ raise ValueError(f"Unknown target type: {t}")
24
+
25
+
26
+ def _resolve_author(author):
27
+ """Return ``(name, email)`` from an author config, or ``(None, None)`` if unset."""
28
+ if author and author.enabled:
29
+ return author.name or None, author.email or None
30
+ return None, None
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Target interface (duck-typed)
35
+ # Required: setup(branch), commit(wd, msg), tag(name, msg), push(br, tag),
36
+ # create_release(tag, name, body, prerelease), tag_exists(tag),
37
+ # head_sha(), repo_path
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ class _RemoteTarget:
42
+ """Target backed by a remote API + a bare git mirror."""
43
+
44
+ def __init__(self, config, cache_dir, retry_max, retry_backoff, author=None):
45
+ self._api = get_api_client(config.type, config.api, config.repo, config.token)
46
+ repo_meta = self._api.ensure_repo_exists()
47
+ # Derive clone URL: prefer explicit config value, fall back to API-returned URL.
48
+ clone_url = getattr(config, "clone_url", None) or (
49
+ repo_meta.get("clone_url") if repo_meta else None
50
+ )
51
+ if not clone_url:
52
+ raise RuntimeError(
53
+ f"Could not determine clone URL for '{config.repo}'. "
54
+ "Set clone_url in the target config or ensure the API returns it."
55
+ )
56
+ repo_slug = config.repo.replace("/", "_")
57
+ self._git = GitRepo.ensure_mirror(
58
+ clone_url,
59
+ Path(cache_dir) / f"target_{config.type}_{repo_slug}.git",
60
+ )
61
+ self._retry_max = retry_max
62
+ self._retry_backoff = retry_backoff
63
+ self._author = author
64
+
65
+ def setup(self, branch):
66
+ self._git.ensure_branch(branch)
67
+
68
+ def commit(self, work_dir, message, date=None):
69
+ author_name, author_email = _resolve_author(self._author)
70
+ return self._git.commit(
71
+ work_dir, message,
72
+ date=date, author_name=author_name, author_email=author_email,
73
+ )
74
+
75
+ def tag(self, name, message):
76
+ self._git.tag(name, message)
77
+
78
+ def push(self, branch, tag=None):
79
+ refs = [branch]
80
+ if tag:
81
+ refs.append(f"+refs/tags/{tag}")
82
+ retry(
83
+ lambda: self._git.push("origin", *refs),
84
+ max_attempts=self._retry_max,
85
+ backoff_seconds=self._retry_backoff,
86
+ )
87
+
88
+ def create_release(self, tag, name, body, prerelease=False):
89
+ return retry(
90
+ lambda: self._api.create_release(tag, name, body, prerelease),
91
+ max_attempts=self._retry_max,
92
+ backoff_seconds=self._retry_backoff,
93
+ )
94
+
95
+ def upload_release_asset(self, tag, file_path, name=None, release=None, stream=False):
96
+ if not release:
97
+ release = self._api.get_release_by_tag(tag)
98
+ if not release:
99
+ logger.warning(
100
+ "Release %s not found on target — skipping asset upload", tag
101
+ )
102
+ return None
103
+ return retry(
104
+ lambda: self._api.upload_asset(release, file_path, name=name, stream=stream),
105
+ max_attempts=self._retry_max,
106
+ backoff_seconds=self._retry_backoff,
107
+ )
108
+
109
+ def tag_exists(self, tag):
110
+ return self._git.tag_exists(tag)
111
+
112
+ def head_sha(self):
113
+ return self._git.head_sha()
114
+
115
+ @property
116
+ def repo_path(self):
117
+ return self._git.git_dir
118
+
119
+
120
+ class _LocalTarget:
121
+ """Target backed by a local git repo — no push, no API release."""
122
+
123
+ def __init__(self, config, author=None):
124
+ self._git = GitRepo.local(config.path)
125
+ self._author = author
126
+
127
+ def setup(self, branch):
128
+ self._git.ensure_branch(branch)
129
+
130
+ def commit(self, work_dir, message, date=None):
131
+ author_name, author_email = _resolve_author(self._author)
132
+ result = self._git.commit(
133
+ work_dir, message,
134
+ date=date, author_name=author_name, author_email=author_email,
135
+ )
136
+ self._git.reset_worktree()
137
+ return result
138
+
139
+ def tag(self, name, message):
140
+ self._git.tag(name, message)
141
+
142
+ def push(self, branch, tag=None):
143
+ pass # local — nothing to push
144
+
145
+ def create_release(self, tag, name, body, prerelease=False):
146
+ pass # local — no API release to create
147
+
148
+ def upload_release_asset(self, tag, file_path, name=None, release=None, stream=False):
149
+ pass # local — no API release to upload assets to
150
+
151
+ def tag_exists(self, tag):
152
+ return self._git.tag_exists(tag)
153
+
154
+ def head_sha(self):
155
+ return self._git.head_sha()
156
+
157
+ @property
158
+ def repo_path(self):
159
+ return self._git.git_dir