steersync 0.2.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.
- steersync/__init__.py +5 -0
- steersync/__main__.py +6 -0
- steersync/cli.py +768 -0
- steersync/config.py +532 -0
- steersync/errors.py +52 -0
- steersync/gitops.py +550 -0
- steersync/hooks.py +146 -0
- steersync/lint.py +631 -0
- steersync/locking.py +69 -0
- steersync/logging_utils.py +60 -0
- steersync/mcp_server.py +171 -0
- steersync/paths.py +98 -0
- steersync/resources/default_config.yaml +154 -0
- steersync/steering.py +457 -0
- steersync/sync.py +726 -0
- steersync-0.2.0.dist-info/METADATA +342 -0
- steersync-0.2.0.dist-info/RECORD +20 -0
- steersync-0.2.0.dist-info/WHEEL +4 -0
- steersync-0.2.0.dist-info/entry_points.txt +2 -0
- steersync-0.2.0.dist-info/licenses/LICENSE +190 -0
steersync/gitops.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"""Thin, safe wrapper around the git binary.
|
|
2
|
+
|
|
3
|
+
All calls use argument lists, never a shell, and run with interactive prompts
|
|
4
|
+
disabled so hooks and MCP servers fail fast instead of hanging.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import random
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import NamedTuple
|
|
19
|
+
from urllib.parse import urlsplit
|
|
20
|
+
|
|
21
|
+
from steersync.config import Config, validate_remote_url
|
|
22
|
+
from steersync.errors import GitError
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
ZERO_OID_RE = re.compile(r"^0{40}(0{24})?$")
|
|
27
|
+
_UNSAFE_COMPONENT = re.compile(r"[^A-Za-z0-9._-]+")
|
|
28
|
+
_UNSAFE_REF_CHARS = re.compile(r"[^A-Za-z0-9._/-]+")
|
|
29
|
+
_SCRUBBED_ENV_PREFIXES = ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_NAMESPACE")
|
|
30
|
+
_CREDENTIAL_IN_URL = re.compile(r"(?<=://)[^/@\s]+@")
|
|
31
|
+
_TRANSIENT_RE = re.compile(
|
|
32
|
+
r"could not resolve host|connection (?:timed out|reset|refused|closed)|operation timed out"
|
|
33
|
+
r"|timed out after|early eof|the remote end hung up|rpc failed|unexpected disconnect"
|
|
34
|
+
r"|http(?:/[0-9.]+)? 5\d\d|returned error: 5\d\d|error: 5\d\d|temporary failure"
|
|
35
|
+
r"|ssh: connect to host|gnutls_handshake|ssl_read|tls connection was non-properly terminated",
|
|
36
|
+
re.IGNORECASE | re.MULTILINE,
|
|
37
|
+
)
|
|
38
|
+
# A server that does not support push options drops the connection, so git also prints
|
|
39
|
+
# "the remote end hung up"; that rejection is permanent and must not be retried.
|
|
40
|
+
_PERMANENT_RE = re.compile(r"push options", re.IGNORECASE)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class GitResult(NamedTuple):
|
|
44
|
+
"""Result of a git invocation."""
|
|
45
|
+
|
|
46
|
+
returncode: int
|
|
47
|
+
stdout: str
|
|
48
|
+
stderr: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PrePushRef(NamedTuple):
|
|
52
|
+
"""One line of the pre-push hook stdin contract from githooks(5)."""
|
|
53
|
+
|
|
54
|
+
local_ref: str
|
|
55
|
+
local_oid: str
|
|
56
|
+
remote_ref: str
|
|
57
|
+
remote_oid: str
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_delete(self) -> bool:
|
|
61
|
+
return bool(ZERO_OID_RE.match(self.local_oid))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def redact_url(url: str) -> str:
|
|
65
|
+
"""Strip user info from URLs before they reach logs."""
|
|
66
|
+
return _CREDENTIAL_IN_URL.sub("***@", url)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_transient(message: str) -> bool:
|
|
70
|
+
"""Return True when a git error message looks like a temporary network or server fault."""
|
|
71
|
+
if _PERMANENT_RE.search(message):
|
|
72
|
+
return False
|
|
73
|
+
return bool(_TRANSIENT_RE.search(message))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def backoff_delay(config: Config, attempt: int) -> float:
|
|
77
|
+
"""Exponential backoff with up to 25 percent jitter, capped by configuration."""
|
|
78
|
+
base = config.git.retry_backoff_seconds * float(1 << attempt)
|
|
79
|
+
capped = min(base, config.git.retry_max_backoff_seconds)
|
|
80
|
+
return capped + random.uniform(0, capped * 0.25) # noqa: S311 (jitter, not security)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def git_environment(base: Mapping[str, str], *, hooks_dir: Path | None = None) -> dict[str, str]:
|
|
84
|
+
"""Build the environment for git subprocesses.
|
|
85
|
+
|
|
86
|
+
Keeps the caller's SSH agent, credential helper and proxy settings, disables
|
|
87
|
+
prompts, and removes variables that would redirect git at the wrong repository.
|
|
88
|
+
"""
|
|
89
|
+
env = {k: v for k, v in base.items() if not k.startswith(_SCRUBBED_ENV_PREFIXES)}
|
|
90
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
91
|
+
env["GIT_SSH_COMMAND"] = env.get("GIT_SSH_COMMAND", "ssh -o BatchMode=yes")
|
|
92
|
+
env["LC_ALL"] = "C"
|
|
93
|
+
env["STEERSYNC_ACTIVE"] = "1"
|
|
94
|
+
if hooks_dir is not None:
|
|
95
|
+
env["GIT_CONFIG_COUNT"] = "1"
|
|
96
|
+
env["GIT_CONFIG_KEY_0"] = "core.hooksPath"
|
|
97
|
+
env["GIT_CONFIG_VALUE_0"] = str(hooks_dir)
|
|
98
|
+
return env
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_git(
|
|
102
|
+
config: Config,
|
|
103
|
+
args: Sequence[str],
|
|
104
|
+
*,
|
|
105
|
+
cwd: Path | None = None,
|
|
106
|
+
env: Mapping[str, str] | None = None,
|
|
107
|
+
check: bool = True,
|
|
108
|
+
timeout: int | None = None,
|
|
109
|
+
stdin: str | None = None,
|
|
110
|
+
retries: int = 0,
|
|
111
|
+
) -> GitResult:
|
|
112
|
+
"""Run git with hardened defaults and return its output.
|
|
113
|
+
|
|
114
|
+
``retries`` re-runs the command after transient network or server failures
|
|
115
|
+
(including timeouts) with exponential backoff. Only idempotent network
|
|
116
|
+
operations should pass a non zero value. Standard input is never inherited:
|
|
117
|
+
when the caller is an MCP stdio server, inheriting it would let git read the
|
|
118
|
+
protocol stream.
|
|
119
|
+
"""
|
|
120
|
+
base_env = os.environ if env is None else env
|
|
121
|
+
command = [
|
|
122
|
+
config.git.binary,
|
|
123
|
+
"-c",
|
|
124
|
+
"protocol.ext.allow=never",
|
|
125
|
+
"-c",
|
|
126
|
+
f"protocol.file.allow={'always' if config.remote.allow_local else 'never'}",
|
|
127
|
+
"-c",
|
|
128
|
+
"core.askPass=",
|
|
129
|
+
*args,
|
|
130
|
+
]
|
|
131
|
+
name = args[0] if args else ""
|
|
132
|
+
log.debug("git %s (cwd=%s)", " ".join(redact_url(a) for a in args), cwd)
|
|
133
|
+
attempt = 0
|
|
134
|
+
while True:
|
|
135
|
+
try:
|
|
136
|
+
completed = subprocess.run(
|
|
137
|
+
command,
|
|
138
|
+
cwd=cwd,
|
|
139
|
+
env=dict(base_env),
|
|
140
|
+
capture_output=True,
|
|
141
|
+
text=True,
|
|
142
|
+
input=stdin,
|
|
143
|
+
stdin=None if stdin is not None else subprocess.DEVNULL,
|
|
144
|
+
timeout=timeout or config.git.timeout_seconds,
|
|
145
|
+
check=False,
|
|
146
|
+
)
|
|
147
|
+
except FileNotFoundError as exc:
|
|
148
|
+
msg = f"git binary not found: {config.git.binary}"
|
|
149
|
+
raise GitError(msg) from exc
|
|
150
|
+
except subprocess.TimeoutExpired as exc:
|
|
151
|
+
if attempt < retries:
|
|
152
|
+
attempt = _wait_before_retry(config, name, attempt, "timed out")
|
|
153
|
+
continue
|
|
154
|
+
msg = f"git {name} timed out after {exc.timeout:.0f}s"
|
|
155
|
+
raise GitError(msg) from exc
|
|
156
|
+
result = GitResult(completed.returncode, completed.stdout, completed.stderr)
|
|
157
|
+
if result.returncode != 0 and attempt < retries and is_transient(result.stderr):
|
|
158
|
+
attempt = _wait_before_retry(config, name, attempt, result.stderr.strip())
|
|
159
|
+
continue
|
|
160
|
+
break
|
|
161
|
+
if check and result.returncode != 0:
|
|
162
|
+
detail = redact_url(result.stderr.strip() or result.stdout.strip())
|
|
163
|
+
msg = f"git {name} failed ({result.returncode}): {detail}"
|
|
164
|
+
raise GitError(msg)
|
|
165
|
+
return result
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _wait_before_retry(config: Config, name: str, attempt: int, reason: str) -> int:
|
|
169
|
+
delay = backoff_delay(config, attempt)
|
|
170
|
+
log.warning(
|
|
171
|
+
"git %s failed transiently (%s); retry %d in %.1fs",
|
|
172
|
+
name,
|
|
173
|
+
redact_url(reason.splitlines()[-1] if reason else "unknown"),
|
|
174
|
+
attempt + 1,
|
|
175
|
+
delay,
|
|
176
|
+
)
|
|
177
|
+
time.sleep(delay)
|
|
178
|
+
return attempt + 1
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def git_version(config: Config, env: Mapping[str, str] | None = None) -> str:
|
|
182
|
+
return run_git(config, ["--version"], env=env).stdout.strip()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def repo_toplevel(config: Config, start: Path, env: Mapping[str, str] | None = None) -> Path | None:
|
|
186
|
+
"""Return the working tree root containing ``start`` or ``None`` outside a repository."""
|
|
187
|
+
result = run_git(config, ["rev-parse", "--show-toplevel"], cwd=start, env=env, check=False)
|
|
188
|
+
if result.returncode != 0:
|
|
189
|
+
return None
|
|
190
|
+
return Path(result.stdout.strip())
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def git_dir(config: Config, repo: Path, env: Mapping[str, str] | None = None) -> Path:
|
|
194
|
+
out = run_git(config, ["rev-parse", "--git-dir"], cwd=repo, env=env).stdout.strip()
|
|
195
|
+
path = Path(out)
|
|
196
|
+
return path if path.is_absolute() else (repo / path).resolve()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def remote_url(
|
|
200
|
+
config: Config, repo: Path, name: str = "origin", env: Mapping[str, str] | None = None
|
|
201
|
+
) -> str | None:
|
|
202
|
+
result = run_git(config, ["remote", "get-url", name], cwd=repo, env=env, check=False)
|
|
203
|
+
if result.returncode != 0:
|
|
204
|
+
return None
|
|
205
|
+
return result.stdout.strip() or None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def config_value(
|
|
209
|
+
config: Config, repo: Path | None, key: str, env: Mapping[str, str] | None = None
|
|
210
|
+
) -> str | None:
|
|
211
|
+
result = run_git(config, ["config", "--get", key], cwd=repo, env=env, check=False)
|
|
212
|
+
if result.returncode != 0:
|
|
213
|
+
return None
|
|
214
|
+
return result.stdout.strip() or None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def project_path_from_url(url: str) -> str:
|
|
218
|
+
"""Derive ``group/subgroup/project`` from any supported remote URL."""
|
|
219
|
+
candidate = url.strip()
|
|
220
|
+
parts = urlsplit(candidate)
|
|
221
|
+
if parts.scheme in {"ssh", "https", "http", "git", "git+ssh", "ssh+git", "file"}:
|
|
222
|
+
path = parts.path
|
|
223
|
+
elif candidate.startswith("/"):
|
|
224
|
+
path = candidate
|
|
225
|
+
elif ":" in candidate:
|
|
226
|
+
path = candidate.split(":", 1)[1]
|
|
227
|
+
else:
|
|
228
|
+
path = candidate
|
|
229
|
+
path = path.strip("/")
|
|
230
|
+
path = path.removesuffix(".git").rstrip("/")
|
|
231
|
+
if parts.scheme == "file" or candidate.startswith("/"):
|
|
232
|
+
path = path.rsplit("/", 1)[-1]
|
|
233
|
+
if not path:
|
|
234
|
+
msg = f"cannot derive a project path from remote URL {redact_url(url)}"
|
|
235
|
+
raise GitError(msg)
|
|
236
|
+
return path
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def slugify_component(component: str) -> str:
|
|
240
|
+
"""Make a single path component safe for directory and ref names."""
|
|
241
|
+
cleaned = _UNSAFE_COMPONENT.sub("-", component).strip("-.")
|
|
242
|
+
cleaned = cleaned.removesuffix(".lock")
|
|
243
|
+
while ".." in cleaned:
|
|
244
|
+
cleaned = cleaned.replace("..", ".")
|
|
245
|
+
return cleaned.lower()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def project_slug(url: str) -> str:
|
|
249
|
+
"""Return a sanitised ``group/subgroup/project`` slug for a remote URL."""
|
|
250
|
+
components = [slugify_component(c) for c in project_path_from_url(url).split("/")]
|
|
251
|
+
kept = [c for c in components if c]
|
|
252
|
+
if not kept:
|
|
253
|
+
msg = f"remote URL {redact_url(url)} yields an empty project slug"
|
|
254
|
+
raise GitError(msg)
|
|
255
|
+
return "/".join(kept)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def current_user_id(config: Config, repo: Path | None, env: Mapping[str, str]) -> str:
|
|
259
|
+
"""Return a short identifier for the current user suitable for branch names."""
|
|
260
|
+
email = config_value(config, repo, "user.email", env=env)
|
|
261
|
+
candidate = (
|
|
262
|
+
email.split("@", 1)[0] if email else env.get("USER") or env.get("USERNAME") or "user"
|
|
263
|
+
)
|
|
264
|
+
slug = slugify_component(candidate)
|
|
265
|
+
return slug or "user"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def sanitise_ref(name: str) -> str:
|
|
269
|
+
"""Sanitise a candidate ref name per git-check-ref-format rules."""
|
|
270
|
+
cleaned = _UNSAFE_REF_CHARS.sub("-", name)
|
|
271
|
+
cleaned = re.sub(r"/{2,}", "/", cleaned)
|
|
272
|
+
cleaned = cleaned.replace("@{", "-")
|
|
273
|
+
while ".." in cleaned:
|
|
274
|
+
cleaned = cleaned.replace("..", ".")
|
|
275
|
+
parts = [p.strip(".").removesuffix(".lock") for p in cleaned.split("/")]
|
|
276
|
+
cleaned = "/".join(p for p in parts if p)
|
|
277
|
+
return cleaned.strip("-/.")[:200]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def build_branch_name(config: Config, *, project: str, user: str, content_hash: str) -> str:
|
|
281
|
+
"""Render and sanitise the proposal branch name."""
|
|
282
|
+
short_hash = content_hash[: config.branch.hash_length]
|
|
283
|
+
rendered = config.branch.template.format(project=project, user=user, hash=short_hash)
|
|
284
|
+
name = sanitise_ref(rendered)
|
|
285
|
+
if not name or name == "@":
|
|
286
|
+
msg = "branch template produced an invalid ref name"
|
|
287
|
+
raise GitError(msg)
|
|
288
|
+
return name
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def check_ref_format(config: Config, name: str, env: Mapping[str, str] | None = None) -> bool:
|
|
292
|
+
result = run_git(config, ["check-ref-format", "--branch", name], env=env, check=False)
|
|
293
|
+
return result.returncode == 0
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def render_push_options(config: Config, *, project: str, user: str, content_hash: str) -> list[str]:
|
|
297
|
+
values = {
|
|
298
|
+
"default_branch": config.remote.default_branch,
|
|
299
|
+
"project": project,
|
|
300
|
+
"user": user,
|
|
301
|
+
"hash": content_hash[: config.branch.hash_length],
|
|
302
|
+
}
|
|
303
|
+
return [option.format(**values) for option in config.remote.push_options]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def remote_branch_exists(
|
|
307
|
+
config: Config, url: str, branch: str, env: Mapping[str, str] | None = None
|
|
308
|
+
) -> bool:
|
|
309
|
+
"""Return True when ``refs/heads/<branch>`` exists on the remote."""
|
|
310
|
+
result = run_git(
|
|
311
|
+
config,
|
|
312
|
+
["ls-remote", "--exit-code", "--heads", "--", url, f"refs/heads/{branch}"],
|
|
313
|
+
env=env,
|
|
314
|
+
check=False,
|
|
315
|
+
retries=config.git.retries,
|
|
316
|
+
)
|
|
317
|
+
if result.returncode == 0:
|
|
318
|
+
return True
|
|
319
|
+
if result.returncode == 2:
|
|
320
|
+
return False
|
|
321
|
+
msg = f"ls-remote failed ({result.returncode}): {redact_url(result.stderr.strip())}"
|
|
322
|
+
raise GitError(msg)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def remote_head_oid(
|
|
326
|
+
config: Config, url: str, branch: str, env: Mapping[str, str] | None = None
|
|
327
|
+
) -> str | None:
|
|
328
|
+
result = run_git(
|
|
329
|
+
config,
|
|
330
|
+
["ls-remote", "--heads", "--", url, f"refs/heads/{branch}"],
|
|
331
|
+
env=env,
|
|
332
|
+
retries=config.git.retries,
|
|
333
|
+
)
|
|
334
|
+
first = result.stdout.strip().splitlines()[:1]
|
|
335
|
+
return first[0].split()[0] if first else None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def ensure_cache_clone(
|
|
339
|
+
config: Config,
|
|
340
|
+
*,
|
|
341
|
+
url: str,
|
|
342
|
+
cache_dir: Path,
|
|
343
|
+
env: Mapping[str, str] | None = None,
|
|
344
|
+
) -> Path:
|
|
345
|
+
"""Clone the central repository into the cache or refresh an existing clone.
|
|
346
|
+
|
|
347
|
+
The clone is shallow and single branch. On every call the default branch is
|
|
348
|
+
reset to the remote tip so the cache never carries stale local state. A cache
|
|
349
|
+
that cannot be refreshed for a non transient reason (corruption, an
|
|
350
|
+
interrupted clone, a rewritten default branch) is deleted and cloned again.
|
|
351
|
+
"""
|
|
352
|
+
validate_remote_url(
|
|
353
|
+
url, allowed_hosts=config.remote.allowed_hosts, allow_local=config.remote.allow_local
|
|
354
|
+
)
|
|
355
|
+
if (cache_dir / ".git").is_dir():
|
|
356
|
+
try:
|
|
357
|
+
_refresh_clone(config, url=url, cache_dir=cache_dir, env=env)
|
|
358
|
+
except GitError as exc:
|
|
359
|
+
if is_transient(str(exc)):
|
|
360
|
+
raise
|
|
361
|
+
log.warning("cache clone %s is unusable (%s); recreating it", cache_dir, exc)
|
|
362
|
+
else:
|
|
363
|
+
return cache_dir
|
|
364
|
+
_remove_cache_dir(cache_dir)
|
|
365
|
+
cache_dir.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
366
|
+
run_git(
|
|
367
|
+
config,
|
|
368
|
+
[
|
|
369
|
+
"clone",
|
|
370
|
+
"--quiet",
|
|
371
|
+
"--depth",
|
|
372
|
+
str(config.git.clone_depth),
|
|
373
|
+
"--single-branch",
|
|
374
|
+
"--branch",
|
|
375
|
+
config.remote.default_branch,
|
|
376
|
+
"--no-tags",
|
|
377
|
+
"--",
|
|
378
|
+
url,
|
|
379
|
+
str(cache_dir),
|
|
380
|
+
],
|
|
381
|
+
env=env,
|
|
382
|
+
retries=config.git.retries,
|
|
383
|
+
)
|
|
384
|
+
_reset_to_remote(config, cache_dir=cache_dir, env=env)
|
|
385
|
+
return cache_dir
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _refresh_clone(
|
|
389
|
+
config: Config, *, url: str, cache_dir: Path, env: Mapping[str, str] | None
|
|
390
|
+
) -> None:
|
|
391
|
+
branch = config.remote.default_branch
|
|
392
|
+
stored = remote_url(config, cache_dir, env=env)
|
|
393
|
+
if stored != url:
|
|
394
|
+
run_git(config, ["remote", "set-url", "origin", "--", url], cwd=cache_dir, env=env)
|
|
395
|
+
run_git(
|
|
396
|
+
config,
|
|
397
|
+
[
|
|
398
|
+
"fetch",
|
|
399
|
+
"--depth",
|
|
400
|
+
str(config.git.clone_depth),
|
|
401
|
+
"--prune",
|
|
402
|
+
"--no-tags",
|
|
403
|
+
"origin",
|
|
404
|
+
f"+refs/heads/{branch}:refs/remotes/origin/{branch}",
|
|
405
|
+
],
|
|
406
|
+
cwd=cache_dir,
|
|
407
|
+
env=env,
|
|
408
|
+
retries=config.git.retries,
|
|
409
|
+
)
|
|
410
|
+
_reset_to_remote(config, cache_dir=cache_dir, env=env)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _reset_to_remote(config: Config, *, cache_dir: Path, env: Mapping[str, str] | None) -> None:
|
|
414
|
+
branch = config.remote.default_branch
|
|
415
|
+
lock = cache_dir / ".git" / "index.lock"
|
|
416
|
+
if lock.exists():
|
|
417
|
+
log.warning("removing stale %s left by an interrupted git process", lock)
|
|
418
|
+
lock.unlink(missing_ok=True)
|
|
419
|
+
run_git(
|
|
420
|
+
config,
|
|
421
|
+
["checkout", "--quiet", "--force", "-B", branch, f"origin/{branch}"],
|
|
422
|
+
cwd=cache_dir,
|
|
423
|
+
env=env,
|
|
424
|
+
)
|
|
425
|
+
run_git(config, ["clean", "-fdqx"], cwd=cache_dir, env=env)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _remove_cache_dir(cache_dir: Path) -> None:
|
|
429
|
+
if cache_dir.is_symlink() or cache_dir.is_file():
|
|
430
|
+
cache_dir.unlink()
|
|
431
|
+
elif cache_dir.exists():
|
|
432
|
+
shutil.rmtree(cache_dir)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def head_commit(config: Config, repo: Path, env: Mapping[str, str] | None = None) -> str:
|
|
436
|
+
return run_git(config, ["rev-parse", "HEAD"], cwd=repo, env=env).stdout.strip()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def create_branch(
|
|
440
|
+
config: Config, repo: Path, branch: str, start_point: str, env: Mapping[str, str] | None = None
|
|
441
|
+
) -> None:
|
|
442
|
+
run_git(config, ["checkout", "--quiet", "-B", branch, start_point], cwd=repo, env=env)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def commit_paths(
|
|
446
|
+
config: Config,
|
|
447
|
+
repo: Path,
|
|
448
|
+
*,
|
|
449
|
+
paths: Sequence[str],
|
|
450
|
+
message: str,
|
|
451
|
+
author_name: str,
|
|
452
|
+
author_email: str,
|
|
453
|
+
env: Mapping[str, str] | None = None,
|
|
454
|
+
) -> str | None:
|
|
455
|
+
"""Stage ``paths`` and commit. Returns the new commit id or ``None`` if nothing changed."""
|
|
456
|
+
run_git(config, ["add", "--all", "--", *paths], cwd=repo, env=env)
|
|
457
|
+
staged = run_git(config, ["diff", "--cached", "--quiet"], cwd=repo, env=env, check=False)
|
|
458
|
+
if staged.returncode == 0:
|
|
459
|
+
return None
|
|
460
|
+
run_git(
|
|
461
|
+
config,
|
|
462
|
+
[
|
|
463
|
+
"-c",
|
|
464
|
+
f"user.name={author_name}",
|
|
465
|
+
"-c",
|
|
466
|
+
f"user.email={author_email}",
|
|
467
|
+
"commit",
|
|
468
|
+
"--quiet",
|
|
469
|
+
"--no-verify",
|
|
470
|
+
"--no-gpg-sign",
|
|
471
|
+
"--message",
|
|
472
|
+
message,
|
|
473
|
+
],
|
|
474
|
+
cwd=repo,
|
|
475
|
+
env=env,
|
|
476
|
+
)
|
|
477
|
+
return head_commit(config, repo, env=env)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def push_branch(
|
|
481
|
+
config: Config,
|
|
482
|
+
repo: Path,
|
|
483
|
+
*,
|
|
484
|
+
url: str,
|
|
485
|
+
branch: str,
|
|
486
|
+
push_options: Sequence[str],
|
|
487
|
+
env: Mapping[str, str] | None = None,
|
|
488
|
+
) -> tuple[GitResult, bool]:
|
|
489
|
+
"""Push ``branch`` to ``url`` with the given push options.
|
|
490
|
+
|
|
491
|
+
Servers that do not advertise push options make git fail instead of ignoring
|
|
492
|
+
them, so the push is retried once without options. The second element of the
|
|
493
|
+
result says whether the options were delivered.
|
|
494
|
+
"""
|
|
495
|
+
refspec = f"refs/heads/{branch}:refs/heads/{branch}"
|
|
496
|
+
args = ["push", "--quiet", "--no-verify"]
|
|
497
|
+
for option in push_options:
|
|
498
|
+
args.extend(["--push-option", option])
|
|
499
|
+
args.extend(["--", url, refspec])
|
|
500
|
+
result = run_git(config, args, cwd=repo, env=env, check=False, retries=config.git.retries)
|
|
501
|
+
if result.returncode == 0:
|
|
502
|
+
return result, bool(push_options)
|
|
503
|
+
if push_options and "push options" in result.stderr:
|
|
504
|
+
log.warning("remote does not accept push options; pushing without them")
|
|
505
|
+
retry = run_git(
|
|
506
|
+
config,
|
|
507
|
+
["push", "--quiet", "--no-verify", "--", url, refspec],
|
|
508
|
+
cwd=repo,
|
|
509
|
+
env=env,
|
|
510
|
+
retries=config.git.retries,
|
|
511
|
+
)
|
|
512
|
+
return retry, False
|
|
513
|
+
detail = redact_url(result.stderr.strip() or result.stdout.strip())
|
|
514
|
+
msg = f"git push failed ({result.returncode}): {detail}"
|
|
515
|
+
raise GitError(msg)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def parse_pre_push_stdin(text: str) -> list[PrePushRef]:
|
|
519
|
+
"""Parse the ref lines git feeds a pre-push hook on stdin."""
|
|
520
|
+
refs: list[PrePushRef] = []
|
|
521
|
+
for line in text.splitlines():
|
|
522
|
+
fields = line.split()
|
|
523
|
+
if len(fields) == 4:
|
|
524
|
+
refs.append(PrePushRef(*fields))
|
|
525
|
+
return refs
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def write_info_exclude(
|
|
529
|
+
config: Config, repo: Path, pattern: str, env: Mapping[str, str] | None = None
|
|
530
|
+
) -> bool:
|
|
531
|
+
"""Append ``pattern`` to ``.git/info/exclude``. Returns True when a change was made."""
|
|
532
|
+
exclude = git_dir(config, repo, env=env) / "info" / "exclude"
|
|
533
|
+
exclude.parent.mkdir(parents=True, exist_ok=True)
|
|
534
|
+
existing = exclude.read_text("utf-8").splitlines() if exclude.is_file() else []
|
|
535
|
+
if pattern in existing:
|
|
536
|
+
return False
|
|
537
|
+
with exclude.open("a", encoding="utf-8") as handle:
|
|
538
|
+
if existing and existing[-1] != "":
|
|
539
|
+
handle.write("\n")
|
|
540
|
+
handle.write(f"# added by steersync\n{pattern}\n")
|
|
541
|
+
return True
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def hooks_dir(config: Config, repo: Path, env: Mapping[str, str] | None = None) -> Path:
|
|
545
|
+
"""Return the effective hooks directory for ``repo`` honouring core.hooksPath."""
|
|
546
|
+
custom = config_value(config, repo, "core.hooksPath", env=env)
|
|
547
|
+
if custom:
|
|
548
|
+
path = Path(custom).expanduser()
|
|
549
|
+
return path if path.is_absolute() else repo / path
|
|
550
|
+
return git_dir(config, repo, env=env) / "hooks"
|
steersync/hooks.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Generation and installation of git hooks, Kiro hooks and MCP configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import stat
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal, NamedTuple
|
|
10
|
+
|
|
11
|
+
from steersync import gitops
|
|
12
|
+
from steersync.config import Config
|
|
13
|
+
|
|
14
|
+
HOOK_MARKER = "# managed by steersync"
|
|
15
|
+
CHAINED_SUFFIX = ".local"
|
|
16
|
+
|
|
17
|
+
PRE_PUSH_TEMPLATE = """#!/bin/sh
|
|
18
|
+
{marker} (do not edit; reinstall with `steersync install-hook --force`)
|
|
19
|
+
# Chains to {chained} when present, then proposes the .kiro collections to the central repository.
|
|
20
|
+
set -u
|
|
21
|
+
hook_dir=$(cd "$(dirname "$0")" && pwd)
|
|
22
|
+
tmp=$(mktemp "${{TMPDIR:-/tmp}}/steersync-prepush.XXXXXX") || exit 1
|
|
23
|
+
trap 'rm -f "$tmp"' EXIT
|
|
24
|
+
cat >"$tmp"
|
|
25
|
+
if [ -x "$hook_dir/{chained}" ]; then
|
|
26
|
+
"$hook_dir/{chained}" "$@" <"$tmp" || exit $?
|
|
27
|
+
fi
|
|
28
|
+
if [ "${{STEERSYNC_ACTIVE:-}}" = "1" ]; then
|
|
29
|
+
exit 0
|
|
30
|
+
fi
|
|
31
|
+
if command -v steersync >/dev/null 2>&1; then
|
|
32
|
+
steersync push --from-hook --remote-url "${{2:-}}" <"$tmp" || exit $?
|
|
33
|
+
else
|
|
34
|
+
echo "steersync: not on PATH; skipping steering sync" >&2
|
|
35
|
+
fi
|
|
36
|
+
exit 0
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HookInstallResult(NamedTuple):
|
|
41
|
+
path: Path
|
|
42
|
+
chained: Path | None
|
|
43
|
+
action: Literal["installed", "updated", "unchanged"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def render_pre_push_hook() -> str:
|
|
47
|
+
return PRE_PUSH_TEMPLATE.format(marker=HOOK_MARKER, chained=f"pre-push{CHAINED_SUFFIX}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def install_pre_push_hook(
|
|
51
|
+
config: Config, repo: Path, *, force: bool = False, env: Mapping[str, str] | None = None
|
|
52
|
+
) -> HookInstallResult:
|
|
53
|
+
"""Install the chaining pre-push hook into the repository's hooks directory.
|
|
54
|
+
|
|
55
|
+
An existing foreign hook is preserved as ``pre-push.local`` and invoked first.
|
|
56
|
+
The function never changes ``core.hooksPath``.
|
|
57
|
+
"""
|
|
58
|
+
hooks = gitops.hooks_dir(config, repo, env=env)
|
|
59
|
+
hooks.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
target = hooks / "pre-push"
|
|
61
|
+
content = render_pre_push_hook()
|
|
62
|
+
chained: Path | None = None
|
|
63
|
+
if target.exists():
|
|
64
|
+
existing = target.read_text("utf-8", errors="replace")
|
|
65
|
+
if HOOK_MARKER in existing:
|
|
66
|
+
if existing == content:
|
|
67
|
+
return HookInstallResult(target, None, "unchanged")
|
|
68
|
+
if not force:
|
|
69
|
+
msg = f"{target} is an older steersync hook; rerun with --force to update it"
|
|
70
|
+
raise FileExistsError(msg)
|
|
71
|
+
action: Literal["installed", "updated", "unchanged"] = "updated"
|
|
72
|
+
else:
|
|
73
|
+
chained = hooks / f"pre-push{CHAINED_SUFFIX}"
|
|
74
|
+
if chained.exists() and not force:
|
|
75
|
+
msg = f"{chained} already exists; rerun with --force to overwrite it"
|
|
76
|
+
raise FileExistsError(msg)
|
|
77
|
+
target.replace(chained)
|
|
78
|
+
action = "installed"
|
|
79
|
+
else:
|
|
80
|
+
action = "installed"
|
|
81
|
+
target.write_text(content, encoding="utf-8")
|
|
82
|
+
target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
83
|
+
return HookInstallResult(target, chained, action)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def render_kiro_hook(
|
|
87
|
+
engine: Literal["v2", "v3"],
|
|
88
|
+
*,
|
|
89
|
+
command: str = "steersync pull --quiet --best-effort",
|
|
90
|
+
timeout: int = 30,
|
|
91
|
+
) -> str:
|
|
92
|
+
"""Render the Kiro hook definition that pulls steering when a session starts."""
|
|
93
|
+
if engine == "v2":
|
|
94
|
+
payload: dict[str, object] = {
|
|
95
|
+
"hooks": {"agentSpawn": [{"command": command, "timeout_ms": timeout * 1000}]}
|
|
96
|
+
}
|
|
97
|
+
else:
|
|
98
|
+
payload = {
|
|
99
|
+
"version": "v1",
|
|
100
|
+
"hooks": [
|
|
101
|
+
{
|
|
102
|
+
"name": "steersync-pull",
|
|
103
|
+
"trigger": "SessionStart",
|
|
104
|
+
"action": {"type": "command", "command": command},
|
|
105
|
+
"timeout": timeout,
|
|
106
|
+
"enabled": True,
|
|
107
|
+
}
|
|
108
|
+
],
|
|
109
|
+
}
|
|
110
|
+
return json.dumps(payload, indent=2) + "\n"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def render_mcp_config(
|
|
114
|
+
*, command: str = "uvx", args: tuple[str, ...] = ("steersync", "mcp"), remote: str | None = None
|
|
115
|
+
) -> str:
|
|
116
|
+
"""Render the ``mcp.json`` fragment registering the steersync MCP server."""
|
|
117
|
+
env: dict[str, str] = {
|
|
118
|
+
"STEERSYNC_REMOTE": remote or "${STEERSYNC_REMOTE}",
|
|
119
|
+
"SSH_AUTH_SOCK": "${SSH_AUTH_SOCK}",
|
|
120
|
+
"HTTPS_PROXY": "${HTTPS_PROXY}",
|
|
121
|
+
"GIT_TERMINAL_PROMPT": "0",
|
|
122
|
+
}
|
|
123
|
+
payload = {
|
|
124
|
+
"mcpServers": {
|
|
125
|
+
"steersync": {
|
|
126
|
+
"command": command,
|
|
127
|
+
"args": list(args),
|
|
128
|
+
"env": env,
|
|
129
|
+
"timeout": 60000,
|
|
130
|
+
"requestTimeout": 180000,
|
|
131
|
+
"disabled": False,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return json.dumps(payload, indent=2) + "\n"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def render_pre_commit_config(rev: str = "v0.2.0") -> str:
|
|
139
|
+
"""Render the ``.pre-commit-config.yaml`` snippet consumers add to their projects."""
|
|
140
|
+
return (
|
|
141
|
+
"repos:\n"
|
|
142
|
+
" - repo: https://github.com/SCGIS-Wales/steersync\n"
|
|
143
|
+
f" rev: {rev}\n"
|
|
144
|
+
" hooks:\n"
|
|
145
|
+
" - id: steersync-push\n"
|
|
146
|
+
)
|