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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Gitea provider — REST API client for Gitea instances."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import shutil
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .base import BaseAPIClient
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _MultipartReader:
|
|
19
|
+
"""File-like object that streams multipart/form-data without buffering the attachment.
|
|
20
|
+
|
|
21
|
+
Yields: boundary_header_bytes → file_content_chunks → boundary_footer_bytes.
|
|
22
|
+
The caller computes ``Content-Length = len(header) + file_size + len(footer)``
|
|
23
|
+
upfront so HTTP/1.1 chunked-encoding is not required.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, header: bytes, file_path, footer: bytes):
|
|
27
|
+
self._parts = [io.BytesIO(header), open(file_path, "rb"), io.BytesIO(footer)]
|
|
28
|
+
self._idx = 0
|
|
29
|
+
|
|
30
|
+
def read(self, size=-1):
|
|
31
|
+
if size == 0:
|
|
32
|
+
return b""
|
|
33
|
+
chunks = []
|
|
34
|
+
remaining = size
|
|
35
|
+
while self._idx < len(self._parts):
|
|
36
|
+
chunk = self._parts[self._idx].read(remaining if remaining > 0 else -1)
|
|
37
|
+
if chunk:
|
|
38
|
+
chunks.append(chunk)
|
|
39
|
+
if remaining > 0:
|
|
40
|
+
remaining -= len(chunk)
|
|
41
|
+
if remaining <= 0:
|
|
42
|
+
break
|
|
43
|
+
else:
|
|
44
|
+
self._idx += 1
|
|
45
|
+
return b"".join(chunks)
|
|
46
|
+
|
|
47
|
+
def close(self):
|
|
48
|
+
for part in self._parts:
|
|
49
|
+
try:
|
|
50
|
+
part.close()
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
def __enter__(self):
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def __exit__(self, *args):
|
|
58
|
+
self.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GiteaClient(BaseAPIClient):
|
|
62
|
+
"""Gitea REST API client.
|
|
63
|
+
|
|
64
|
+
Differences from the base:
|
|
65
|
+
- Auth header uses ``token <tok>`` (not Bearer)
|
|
66
|
+
- Release conflict code is 409 (not 422)
|
|
67
|
+
- Asset upload uses multipart/form-data
|
|
68
|
+
- Asset download falls back to constructing a URL from asset IDs
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
_platform_name = "Gitea"
|
|
72
|
+
_conflict_codes = (409, 422)
|
|
73
|
+
_release_conflict_code = 409
|
|
74
|
+
|
|
75
|
+
def __init__(self, api: str, repo: str, token: str):
|
|
76
|
+
super().__init__(api, repo, token)
|
|
77
|
+
self._headers = {
|
|
78
|
+
"Authorization": f"token {token}",
|
|
79
|
+
"Accept": "application/json",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Asset management
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def download_asset(self, asset, dest):
|
|
87
|
+
"""Download a Gitea release asset to *dest*."""
|
|
88
|
+
url = asset.get("browser_download_url") or ""
|
|
89
|
+
if not url and asset.get("id") and asset.get("release_id"):
|
|
90
|
+
url = (
|
|
91
|
+
f"{self.api}/repos/{self.repo}/releases"
|
|
92
|
+
f"/{asset['release_id']}/assets/{asset['id']}"
|
|
93
|
+
)
|
|
94
|
+
if not url:
|
|
95
|
+
raise ValueError(f"Asset has no download URL: {asset}")
|
|
96
|
+
# Gitea can return a relative URL for self-hosted instances
|
|
97
|
+
if url.startswith("/"):
|
|
98
|
+
host_base = self.api.split("/api")[0]
|
|
99
|
+
url = f"{host_base}{url}"
|
|
100
|
+
headers = dict(self._headers)
|
|
101
|
+
headers["Accept"] = "*/*"
|
|
102
|
+
req = urllib.request.Request(url, headers=headers)
|
|
103
|
+
with urllib.request.urlopen(req) as resp, open(dest, "wb") as f:
|
|
104
|
+
shutil.copyfileobj(resp, f)
|
|
105
|
+
|
|
106
|
+
def upload_asset(self, release_or_id, file_path, name=None, stream=False):
|
|
107
|
+
"""Upload an asset to a Gitea release using multipart/form-data.
|
|
108
|
+
|
|
109
|
+
Idempotent — returns ``None`` if the asset already exists (409/422).
|
|
110
|
+
|
|
111
|
+
When *stream* is ``True`` the file content is never fully loaded into
|
|
112
|
+
RAM: a ``_MultipartReader`` wraps the boundary bytes + file handle and
|
|
113
|
+
is passed directly to ``urllib``. ``Content-Length`` is pre-computed
|
|
114
|
+
from ``len(header) + file_size + len(footer)`` so chunked encoding is
|
|
115
|
+
not needed.
|
|
116
|
+
"""
|
|
117
|
+
name = name or Path(file_path).name
|
|
118
|
+
rel_id = (
|
|
119
|
+
release_or_id.get("id")
|
|
120
|
+
if isinstance(release_or_id, dict)
|
|
121
|
+
else release_or_id
|
|
122
|
+
)
|
|
123
|
+
url = (
|
|
124
|
+
f"{self.api}/repos/{self.repo}/releases/{rel_id}/assets"
|
|
125
|
+
f"?name={urllib.parse.quote(name)}"
|
|
126
|
+
)
|
|
127
|
+
boundary = f"----GitAcrossBoundary{uuid.uuid4().hex}"
|
|
128
|
+
header_bytes = (
|
|
129
|
+
f"--{boundary}\r\n"
|
|
130
|
+
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'
|
|
131
|
+
f"Content-Type: application/octet-stream\r\n\r\n"
|
|
132
|
+
).encode("utf-8")
|
|
133
|
+
footer_bytes = f"\r\n--{boundary}--\r\n".encode("utf-8")
|
|
134
|
+
|
|
135
|
+
req_headers = {
|
|
136
|
+
"Authorization": self._headers["Authorization"],
|
|
137
|
+
"Accept": "application/json",
|
|
138
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
if stream:
|
|
143
|
+
file_size = Path(file_path).stat().st_size
|
|
144
|
+
content_length = len(header_bytes) + file_size + len(footer_bytes)
|
|
145
|
+
req_headers["Content-Length"] = str(content_length)
|
|
146
|
+
with _MultipartReader(header_bytes, file_path, footer_bytes) as body:
|
|
147
|
+
req = urllib.request.Request(
|
|
148
|
+
url, data=body, headers=req_headers, method="POST"
|
|
149
|
+
)
|
|
150
|
+
with urllib.request.urlopen(req) as resp:
|
|
151
|
+
raw = resp.read()
|
|
152
|
+
return json.loads(raw) if raw else None
|
|
153
|
+
else:
|
|
154
|
+
with open(file_path, "rb") as f:
|
|
155
|
+
file_bytes = f.read()
|
|
156
|
+
body = header_bytes + file_bytes + footer_bytes
|
|
157
|
+
req_headers["Content-Length"] = str(len(body))
|
|
158
|
+
req = urllib.request.Request(
|
|
159
|
+
url, data=body, headers=req_headers, method="POST"
|
|
160
|
+
)
|
|
161
|
+
with urllib.request.urlopen(req) as resp:
|
|
162
|
+
raw = resp.read()
|
|
163
|
+
return json.loads(raw) if raw else None
|
|
164
|
+
except urllib.error.HTTPError as e:
|
|
165
|
+
if e.code in (409, 422):
|
|
166
|
+
logger.info("Asset %s already exists on Gitea (idempotent)", name)
|
|
167
|
+
return None
|
|
168
|
+
body_text = e.read().decode()
|
|
169
|
+
logger.error("Gitea upload asset error %s: %s", e.code, body_text)
|
|
170
|
+
raise
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""GitHub provider — REST API client for github.com and GitHub Enterprise."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import shutil
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .base import BaseAPIClient
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GitHubClient(BaseAPIClient):
|
|
17
|
+
"""GitHub REST API client.
|
|
18
|
+
|
|
19
|
+
Differences from the base:
|
|
20
|
+
- Auth header uses ``Bearer <tok>``
|
|
21
|
+
- ``Accept: application/vnd.github+json``
|
|
22
|
+
- Release pagination uses ``per_page`` (not ``limit``)
|
|
23
|
+
- Release conflict code is 422 (not 409)
|
|
24
|
+
- Asset upload uses raw ``application/octet-stream`` to the upload endpoint
|
|
25
|
+
- Asset download requires ``Accept: application/octet-stream``
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
_platform_name = "GitHub"
|
|
29
|
+
_conflict_codes = (409, 422)
|
|
30
|
+
_release_conflict_code = 422
|
|
31
|
+
|
|
32
|
+
def __init__(self, api: str, repo: str, token: str):
|
|
33
|
+
super().__init__(api, repo, token)
|
|
34
|
+
self._headers = {
|
|
35
|
+
"Authorization": f"Bearer {token}",
|
|
36
|
+
"Accept": "application/vnd.github+json",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def list_releases(self, page=1, limit=50):
|
|
40
|
+
"""GitHub uses ``per_page`` instead of ``limit``."""
|
|
41
|
+
return self._request("GET", f"releases?page={page}&per_page={limit}")
|
|
42
|
+
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
# Asset management
|
|
45
|
+
# ------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def download_asset(self, asset, dest):
|
|
48
|
+
"""Download a GitHub release asset to *dest*."""
|
|
49
|
+
url = asset.get("url") or asset.get("browser_download_url")
|
|
50
|
+
if not url:
|
|
51
|
+
raise ValueError(f"Asset has no download URL: {asset}")
|
|
52
|
+
headers = dict(self._headers)
|
|
53
|
+
headers["Accept"] = "application/octet-stream"
|
|
54
|
+
req = urllib.request.Request(url, headers=headers)
|
|
55
|
+
with urllib.request.urlopen(req) as resp, open(dest, "wb") as f:
|
|
56
|
+
shutil.copyfileobj(resp, f)
|
|
57
|
+
|
|
58
|
+
def upload_asset(self, release_or_id, file_path, name=None, stream=False):
|
|
59
|
+
"""Upload an asset to a GitHub release as raw ``application/octet-stream``.
|
|
60
|
+
|
|
61
|
+
Idempotent — returns ``None`` if the asset already exists (422).
|
|
62
|
+
|
|
63
|
+
When *stream* is ``True`` the file object is passed directly to
|
|
64
|
+
``urllib`` so the content is read in chunks rather than all at once.
|
|
65
|
+
``Content-Length`` is set from ``file.stat().st_size`` so no
|
|
66
|
+
chunked-encoding handshake is needed.
|
|
67
|
+
"""
|
|
68
|
+
name = name or Path(file_path).name
|
|
69
|
+
upload_url = None
|
|
70
|
+
if isinstance(release_or_id, dict):
|
|
71
|
+
upload_url = release_or_id.get("upload_url")
|
|
72
|
+
rel_id = release_or_id.get("id")
|
|
73
|
+
else:
|
|
74
|
+
rel_id = release_or_id
|
|
75
|
+
|
|
76
|
+
# Resolve the upload base URL (prefer the API-returned upload_url).
|
|
77
|
+
if upload_url:
|
|
78
|
+
base_url = upload_url.split("{")[0]
|
|
79
|
+
elif self.api == "https://api.github.com":
|
|
80
|
+
base_url = (
|
|
81
|
+
f"https://uploads.github.com/repos/{self.repo}/releases/{rel_id}/assets"
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
base_url = f"{self.api}/repos/{self.repo}/releases/{rel_id}/assets"
|
|
85
|
+
|
|
86
|
+
url = f"{base_url}?name={urllib.parse.quote(name)}"
|
|
87
|
+
req_headers = {
|
|
88
|
+
"Authorization": self._headers["Authorization"],
|
|
89
|
+
"Accept": "application/vnd.github+json",
|
|
90
|
+
"Content-Type": "application/octet-stream",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
if stream:
|
|
95
|
+
file_size = Path(file_path).stat().st_size
|
|
96
|
+
req_headers["Content-Length"] = str(file_size)
|
|
97
|
+
with open(file_path, "rb") as f:
|
|
98
|
+
req = urllib.request.Request(
|
|
99
|
+
url, data=f, headers=req_headers, method="POST"
|
|
100
|
+
)
|
|
101
|
+
with urllib.request.urlopen(req) as resp:
|
|
102
|
+
raw = resp.read()
|
|
103
|
+
return json.loads(raw) if raw else None
|
|
104
|
+
else:
|
|
105
|
+
with open(file_path, "rb") as f:
|
|
106
|
+
data = f.read()
|
|
107
|
+
req_headers["Content-Length"] = str(len(data))
|
|
108
|
+
req = urllib.request.Request(
|
|
109
|
+
url, data=data, headers=req_headers, method="POST"
|
|
110
|
+
)
|
|
111
|
+
with urllib.request.urlopen(req) as resp:
|
|
112
|
+
raw = resp.read()
|
|
113
|
+
return json.loads(raw) if raw else None
|
|
114
|
+
except urllib.error.HTTPError as e:
|
|
115
|
+
if e.code == 422:
|
|
116
|
+
logger.info("Asset %s already exists on GitHub (idempotent)", name)
|
|
117
|
+
return None
|
|
118
|
+
body_text = e.read().decode()
|
|
119
|
+
logger.error("GitHub upload asset error %s: %s", e.code, body_text)
|
|
120
|
+
raise
|
gitacross/renderer.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def apply_operations(work_dir, project):
|
|
11
|
+
"""Run the full render pipeline on *work_dir* after source tag overlay.
|
|
12
|
+
|
|
13
|
+
1. Remove paths in `ignore` (glob list, always first).
|
|
14
|
+
2. Apply `operations` in declaration order.
|
|
15
|
+
"""
|
|
16
|
+
work = Path(work_dir)
|
|
17
|
+
|
|
18
|
+
for pattern in project.renderer.ignore:
|
|
19
|
+
_remove_glob(work, pattern)
|
|
20
|
+
|
|
21
|
+
for op in project.renderer.operations:
|
|
22
|
+
if "remove" in op:
|
|
23
|
+
_op_remove(work, op["remove"])
|
|
24
|
+
elif "rename" in op:
|
|
25
|
+
_op_rename(work, op["rename"])
|
|
26
|
+
elif "replace" in op:
|
|
27
|
+
_op_replace(work, op["replace"])
|
|
28
|
+
elif "add" in op:
|
|
29
|
+
_op_add(work, op["add"])
|
|
30
|
+
elif "validate" in op:
|
|
31
|
+
_op_validate(work, op["validate"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Internal helpers
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _remove_glob(root, pattern):
|
|
40
|
+
"""Remove all files/dirs matching a glob pattern relative to *root*."""
|
|
41
|
+
paths = sorted(root.rglob(pattern), key=lambda p: len(str(p)), reverse=True)
|
|
42
|
+
for path in paths:
|
|
43
|
+
if not path.exists():
|
|
44
|
+
continue
|
|
45
|
+
if path.is_dir() and not path.is_symlink():
|
|
46
|
+
shutil.rmtree(path)
|
|
47
|
+
else:
|
|
48
|
+
path.unlink()
|
|
49
|
+
_clean_empty_dirs(root)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _clean_empty_dirs(root):
|
|
53
|
+
for path in sorted(root.rglob("*"), key=lambda p: len(str(p)), reverse=True):
|
|
54
|
+
if path.is_dir() and not any(path.iterdir()):
|
|
55
|
+
path.rmdir()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _iter_files(root):
|
|
59
|
+
for path in root.rglob("*"):
|
|
60
|
+
if path.is_file():
|
|
61
|
+
yield path
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Operation handlers
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _op_remove(work, ops):
|
|
70
|
+
"""Remove matching files/dirs.
|
|
71
|
+
Each op: {path: str, pattern?: literal|glob|regex}
|
|
72
|
+
"""
|
|
73
|
+
for op in ops:
|
|
74
|
+
pat = op.get("path", "")
|
|
75
|
+
mode = op.get("pattern", "literal")
|
|
76
|
+
if mode == "literal":
|
|
77
|
+
target = work / pat
|
|
78
|
+
if target.exists():
|
|
79
|
+
if target.is_dir():
|
|
80
|
+
shutil.rmtree(target)
|
|
81
|
+
else:
|
|
82
|
+
target.unlink()
|
|
83
|
+
elif mode == "glob":
|
|
84
|
+
_remove_glob(work, pat)
|
|
85
|
+
elif mode == "regex":
|
|
86
|
+
for f in _iter_files(work):
|
|
87
|
+
if re.search(pat, str(f.relative_to(work))):
|
|
88
|
+
f.unlink()
|
|
89
|
+
_clean_empty_dirs(work)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _op_rename(work, ops):
|
|
93
|
+
"""Rename files/dirs. Processes deepest paths first to avoid parent conflicts.
|
|
94
|
+
|
|
95
|
+
Each op: {from: str, to: str, pattern?: literal|glob|regex}
|
|
96
|
+
"""
|
|
97
|
+
# ponytail: only literal renames are implemented. Glob/regex rename raises.
|
|
98
|
+
for op in sorted(ops, key=lambda o: -len(o.get("from", ""))):
|
|
99
|
+
mode = op.get("pattern", "literal")
|
|
100
|
+
if mode != "literal":
|
|
101
|
+
raise NotImplementedError(
|
|
102
|
+
f"Rename pattern '{mode}' is not yet implemented — use 'literal'"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
src = work / op["from"]
|
|
106
|
+
dst = work / op["to"]
|
|
107
|
+
if src.exists():
|
|
108
|
+
if dst.exists():
|
|
109
|
+
raise RuntimeError(f"Rename conflict: {op['to']} already exists")
|
|
110
|
+
src.rename(dst)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _op_replace(work, ops):
|
|
114
|
+
"""Search-and-replace in UTF-8 text files.
|
|
115
|
+
|
|
116
|
+
Each op: {search: str, replace: str, pattern?: literal|regex, glob?: str, path?: str}
|
|
117
|
+
"""
|
|
118
|
+
for op in ops:
|
|
119
|
+
search = op["search"]
|
|
120
|
+
replace = op["replace"]
|
|
121
|
+
mode = op.get("pattern", "literal")
|
|
122
|
+
path_filter = op.get("path")
|
|
123
|
+
glob_filter = op.get("glob")
|
|
124
|
+
|
|
125
|
+
for f in _iter_files(work):
|
|
126
|
+
rel = f.relative_to(work)
|
|
127
|
+
if path_filter:
|
|
128
|
+
if str(rel) != path_filter:
|
|
129
|
+
continue
|
|
130
|
+
elif glob_filter:
|
|
131
|
+
if not fnmatch.fnmatch(str(rel), glob_filter):
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
text = f.read_text("utf-8")
|
|
136
|
+
except (UnicodeDecodeError, ValueError):
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
new_text = (
|
|
140
|
+
re.sub(search, replace, text)
|
|
141
|
+
if mode == "regex"
|
|
142
|
+
else text.replace(search, replace)
|
|
143
|
+
)
|
|
144
|
+
if new_text != text:
|
|
145
|
+
f.write_text(new_text, encoding="utf-8")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _op_add(work, ops):
|
|
149
|
+
"""Create files in the work tree before commit.
|
|
150
|
+
|
|
151
|
+
Each op: {path: str, content: str}
|
|
152
|
+
Raises if the path already exists (avoids silently overwriting).
|
|
153
|
+
Creates parent directories automatically.
|
|
154
|
+
"""
|
|
155
|
+
for op in ops:
|
|
156
|
+
path = op["path"]
|
|
157
|
+
target = work / path
|
|
158
|
+
if target.exists():
|
|
159
|
+
raise RuntimeError(f"Add conflict: '{path}' already exists")
|
|
160
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
target.write_text(op.get("content", ""), encoding="utf-8")
|
|
162
|
+
logger.debug("Added file: %s", path)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _op_validate(work, ops):
|
|
166
|
+
"""Assert conditions on the final tree. Aborts on failure."""
|
|
167
|
+
for op in ops:
|
|
168
|
+
assert_type = op.get("assert", "")
|
|
169
|
+
path = op.get("path", "")
|
|
170
|
+
pattern = op.get("pattern", "")
|
|
171
|
+
target = work / path
|
|
172
|
+
|
|
173
|
+
if assert_type == "file_exists":
|
|
174
|
+
if not target.exists():
|
|
175
|
+
raise RuntimeError(f"Validation failed: '{path}' must exist")
|
|
176
|
+
elif assert_type == "file_absent":
|
|
177
|
+
if target.exists():
|
|
178
|
+
raise RuntimeError(f"Validation failed: '{path}' must not exist")
|
|
179
|
+
elif assert_type == "string_exists":
|
|
180
|
+
if not target.exists():
|
|
181
|
+
raise RuntimeError(
|
|
182
|
+
f"Validation failed: '{path}' not found (for string_exists check)"
|
|
183
|
+
)
|
|
184
|
+
content = target.read_text("utf-8", errors="replace")
|
|
185
|
+
if pattern not in content:
|
|
186
|
+
raise RuntimeError(
|
|
187
|
+
f"Validation failed: '{pattern}' not found in '{path}'"
|
|
188
|
+
)
|
|
189
|
+
elif assert_type == "string_absent":
|
|
190
|
+
if target.exists():
|
|
191
|
+
content = target.read_text("utf-8", errors="replace")
|
|
192
|
+
if pattern in content:
|
|
193
|
+
raise RuntimeError(
|
|
194
|
+
f"Validation failed: '{pattern}' found in '{path}'"
|
|
195
|
+
)
|
gitacross/retry.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
logger = logging.getLogger(__name__)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def retry(fn, max_attempts=3, backoff_seconds=2, exceptions=(Exception,)):
|
|
8
|
+
"""Run fn with exponential backoff. Re-raises the last exception on final failure."""
|
|
9
|
+
for attempt in range(max_attempts):
|
|
10
|
+
try:
|
|
11
|
+
return fn()
|
|
12
|
+
except exceptions as e:
|
|
13
|
+
if attempt == max_attempts - 1:
|
|
14
|
+
raise
|
|
15
|
+
delay = backoff_seconds * (2**attempt)
|
|
16
|
+
logger.warning(
|
|
17
|
+
"Attempt %d/%d failed: %s. Retrying in %.1fs...",
|
|
18
|
+
attempt + 1,
|
|
19
|
+
max_attempts,
|
|
20
|
+
e,
|
|
21
|
+
delay,
|
|
22
|
+
)
|
|
23
|
+
time.sleep(delay)
|