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/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
+ )