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/__init__.py +34 -0
- gitacross/__main__.py +6 -0
- gitacross/config.py +218 -0
- gitacross/git.py +342 -0
- gitacross/gitea.py +9 -0
- gitacross/github.py +9 -0
- gitacross/linter.py +1105 -0
- gitacross/main.py +360 -0
- gitacross/providers/__init__.py +44 -0
- gitacross/providers/base.py +230 -0
- gitacross/providers/gitea.py +170 -0
- gitacross/providers/github.py +120 -0
- gitacross/renderer.py +195 -0
- gitacross/retry.py +23 -0
- gitacross/source.py +297 -0
- gitacross/state.py +67 -0
- gitacross/target.py +159 -0
- gitacross-1.0.0.dist-info/METADATA +318 -0
- gitacross-1.0.0.dist-info/RECORD +23 -0
- gitacross-1.0.0.dist-info/WHEEL +5 -0
- gitacross-1.0.0.dist-info/entry_points.txt +2 -0
- gitacross-1.0.0.dist-info/licenses/LICENSE +21 -0
- gitacross-1.0.0.dist-info/top_level.txt +1 -0
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
|