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/config.py ADDED
@@ -0,0 +1,532 @@
1
+ """Configuration loading and validation.
2
+
3
+ Configuration is layered: packaged defaults, then an optional YAML file, then
4
+ environment variables. Everything is validated with pydantic before use.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ import re
12
+ from collections.abc import Mapping, Sequence
13
+ from importlib import resources
14
+ from pathlib import Path, PurePosixPath
15
+ from typing import Any, Literal
16
+ from urllib.parse import urlsplit
17
+
18
+ import yaml
19
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
20
+
21
+ from steersync.errors import ConfigError
22
+
23
+ ENV_CONFIG = "STEERSYNC_CONFIG"
24
+ ENV_REMOTE = "STEERSYNC_REMOTE"
25
+ ENV_DEFAULT_BRANCH = "STEERSYNC_DEFAULT_BRANCH"
26
+ ENV_CACHE_DIR = "STEERSYNC_CACHE_DIR"
27
+ ENV_LOG_LEVEL = "STEERSYNC_LOG_LEVEL"
28
+ ENV_LOG_FORMAT = "STEERSYNC_LOG_FORMAT"
29
+ ENV_STRICT = "STEERSYNC_STRICT"
30
+ ENV_GIT_BINARY = "STEERSYNC_GIT_BINARY"
31
+
32
+ CONFIG_FILE_NAMES: tuple[str, ...] = ("steersync.yaml", "steersync.yml")
33
+
34
+ _SCP_LIKE = re.compile(r"^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.\-\[\]]+:(?![/\\]{2})[^\s]+$")
35
+ _HOST_RE = re.compile(r"^[A-Za-z0-9.\-]+$")
36
+ _PLACEHOLDER_RE = re.compile(r"\{([a-z_]+)\}")
37
+ _ALLOWED_PLACEHOLDERS = frozenset({"default_branch", "project", "user", "hash"})
38
+ _COLLECTION_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$")
39
+
40
+ LintProfile = Literal["steering", "markdown", "skill", "agent", "hooks", "json", "generic"]
41
+ LINT_PROFILES: tuple[str, ...] = (
42
+ "steering",
43
+ "markdown",
44
+ "skill",
45
+ "agent",
46
+ "hooks",
47
+ "json",
48
+ "generic",
49
+ )
50
+ # Deprecated ``paths`` keys and the collection setting that replaced each one.
51
+ _LEGACY_PATH_KEYS: dict[str, tuple[str, str]] = {
52
+ "workspace_steering": ("steering", "workspace_path"),
53
+ "global_steering": ("steering", "global_path"),
54
+ }
55
+
56
+ log = logging.getLogger(__name__)
57
+
58
+
59
+ class _Strict(BaseModel):
60
+ model_config = ConfigDict(frozen=True, extra="forbid")
61
+
62
+
63
+ class ForbiddenPattern(_Strict):
64
+ """A regular expression that must not appear in a steering document."""
65
+
66
+ id: str = Field(min_length=1, max_length=64, pattern=r"^[a-z0-9][a-z0-9-]*$")
67
+ pattern: str = Field(min_length=1, max_length=512)
68
+ message: str = Field(min_length=1, max_length=200)
69
+
70
+ @field_validator("pattern")
71
+ @classmethod
72
+ def _compiles(cls, value: str) -> str:
73
+ try:
74
+ re.compile(value)
75
+ except re.error as exc:
76
+ msg = f"invalid regular expression: {exc}"
77
+ raise ValueError(msg) from exc
78
+ return value
79
+
80
+
81
+ class PyMarkdownConfig(_Strict):
82
+ enabled: bool = True
83
+ severity: Literal["error", "warning"] = "warning"
84
+ disabled_rules: tuple[str, ...] = ()
85
+
86
+
87
+ class LintConfig(_Strict):
88
+ max_file_bytes: int = Field(default=65536, ge=1024, le=10_000_000)
89
+ require_h1: bool = True
90
+ allowed_extra_keys: tuple[str, ...] = ("x-steersync",)
91
+ file_reference_check: bool = True
92
+ pymarkdown: PyMarkdownConfig = PyMarkdownConfig()
93
+ forbidden_patterns: tuple[ForbiddenPattern, ...] = ()
94
+
95
+
96
+ class RemoteConfig(_Strict):
97
+ url: str = ""
98
+ default_branch: str = Field(default="main", min_length=1, max_length=200)
99
+ global_dir: str = Field(default="global", min_length=1)
100
+ projects_dir: str = Field(default="projects", min_length=1)
101
+ allowed_hosts: tuple[str, ...] = ()
102
+ allow_local: bool = False
103
+ push_options: tuple[str, ...] = ()
104
+
105
+ @field_validator("global_dir", "projects_dir")
106
+ @classmethod
107
+ def _relative_dir(cls, value: str) -> str:
108
+ parts = Path(value).parts
109
+ if Path(value).is_absolute() or ".." in parts or not parts:
110
+ msg = "must be a relative directory inside the repository"
111
+ raise ValueError(msg)
112
+ return value.strip("/")
113
+
114
+ @field_validator("push_options")
115
+ @classmethod
116
+ def _push_options(cls, value: tuple[str, ...]) -> tuple[str, ...]:
117
+ for option in value:
118
+ if "\n" in option or "\r" in option or not option.strip():
119
+ msg = "push options must be single non empty lines"
120
+ raise ValueError(msg)
121
+ unknown = set(_PLACEHOLDER_RE.findall(option)) - _ALLOWED_PLACEHOLDERS
122
+ if unknown:
123
+ msg = f"unknown placeholder(s) in push option: {sorted(unknown)}"
124
+ raise ValueError(msg)
125
+ return value
126
+
127
+ @field_validator("allowed_hosts")
128
+ @classmethod
129
+ def _hosts(cls, value: tuple[str, ...]) -> tuple[str, ...]:
130
+ for host in value:
131
+ if not _HOST_RE.match(host):
132
+ msg = f"invalid host name: {host!r}"
133
+ raise ValueError(msg)
134
+ return tuple(h.lower() for h in value)
135
+
136
+
137
+ class GitConfig(_Strict):
138
+ binary: str = Field(default="git", min_length=1)
139
+ timeout_seconds: int = Field(default=120, ge=5, le=3600)
140
+ clone_depth: int = Field(default=1, ge=1, le=1000)
141
+ retries: int = Field(default=2, ge=0, le=10)
142
+ retry_backoff_seconds: float = Field(default=2.0, ge=0, le=60)
143
+ retry_max_backoff_seconds: float = Field(default=30.0, ge=0, le=600)
144
+
145
+ @field_validator("binary")
146
+ @classmethod
147
+ def _binary(cls, value: str) -> str:
148
+ if value.startswith("-") or any(c in value for c in "\n\r\0"):
149
+ msg = "invalid git binary"
150
+ raise ValueError(msg)
151
+ return value
152
+
153
+
154
+ class BranchConfig(_Strict):
155
+ template: str = Field(default="steering/{project}/{user}-{hash}", min_length=1)
156
+ hash_length: int = Field(default=8, ge=6, le=64)
157
+
158
+ @field_validator("template")
159
+ @classmethod
160
+ def _template(cls, value: str) -> str:
161
+ names = set(_PLACEHOLDER_RE.findall(value))
162
+ unknown = names - {"project", "user", "hash"}
163
+ if unknown:
164
+ msg = f"unknown placeholder(s) in branch template: {sorted(unknown)}"
165
+ raise ValueError(msg)
166
+ if "{hash}" not in value:
167
+ msg = "branch template must contain {hash} so identical pushes dedupe"
168
+ raise ValueError(msg)
169
+ return value
170
+
171
+
172
+ def _relative_path(value: str) -> str:
173
+ """Validate a path that must stay inside its base directory."""
174
+ if Path(value).is_absolute() or ".." in Path(value).parts or not value.strip():
175
+ msg = "must be a relative path without parent references"
176
+ raise ValueError(msg)
177
+ return value.strip().strip("/")
178
+
179
+
180
+ class CollectionConfig(_Strict):
181
+ """One kind of Kiro content that is synced as a unit.
182
+
183
+ A collection maps a directory in the workspace (and optionally one under the
184
+ Kiro home) onto a directory in the central repository, and says which files
185
+ inside it are synced and how they are validated.
186
+ """
187
+
188
+ enabled: bool = True
189
+ description: str = Field(default="", max_length=200)
190
+ # Directory relative to the workspace root; ``null`` means the collection has no
191
+ # workspace scope (it is only synced globally).
192
+ workspace_path: str | None = None
193
+ # Directory relative to the Kiro home (``KIRO_HOME`` or ``~/.kiro``); ``null`` means
194
+ # the collection has no global scope.
195
+ global_path: str | None = None
196
+ # Directory under ``global/`` and ``projects/<slug>/`` in the central repository.
197
+ # Defaults to the collection name.
198
+ central_dir: str | None = None
199
+ # Glob patterns relative to the collection directory. ``**`` spans directories.
200
+ include: tuple[str, ...] = ("**/*",)
201
+ exclude: tuple[str, ...] = ()
202
+ # Limit discovery to this many directory levels (1 = only the top level).
203
+ max_depth: int | None = Field(default=None, ge=1, le=64)
204
+ lint: LintProfile = "generic"
205
+ # Overrides ``lint.max_file_bytes`` for this collection.
206
+ max_file_bytes: int | None = Field(default=None, ge=1024, le=100_000_000)
207
+ # Add the workspace directory to ``.git/info/exclude`` on ``steersync init``.
208
+ git_exclude: bool = True
209
+
210
+ @field_validator("workspace_path", "global_path", "central_dir")
211
+ @classmethod
212
+ def _relative(cls, value: str | None) -> str | None:
213
+ return None if value is None else _relative_path(value)
214
+
215
+ @field_validator("include", "exclude")
216
+ @classmethod
217
+ def _patterns(cls, value: tuple[str, ...]) -> tuple[str, ...]:
218
+ for pattern in value:
219
+ if not pattern.strip() or pattern.startswith(("/", "\\")) or "\\" in pattern:
220
+ msg = f"invalid glob pattern {pattern!r}: patterns are relative posix globs"
221
+ raise ValueError(msg)
222
+ if ".." in PurePosixPath(pattern).parts or "\0" in pattern:
223
+ msg = f"invalid glob pattern {pattern!r}: parent references are not allowed"
224
+ raise ValueError(msg)
225
+ return value
226
+
227
+ @field_validator("include")
228
+ @classmethod
229
+ def _not_empty(cls, value: tuple[str, ...]) -> tuple[str, ...]:
230
+ if not value:
231
+ msg = "include must list at least one glob pattern"
232
+ raise ValueError(msg)
233
+ return value
234
+
235
+
236
+ class PathsConfig(_Strict):
237
+ workspace_lock: str = ".kiro/steersync.lock.json"
238
+ global_lock: str = "steersync.lock.json"
239
+ cache_dir: str = "~/.cache/steersync"
240
+
241
+ @field_validator("workspace_lock", "global_lock")
242
+ @classmethod
243
+ def _relative(cls, value: str) -> str:
244
+ return _relative_path(value)
245
+
246
+
247
+ class SyncConfig(_Strict):
248
+ prune: bool = True
249
+ strict_pull: bool = False
250
+ lock_timeout_seconds: int = Field(default=120, ge=1, le=3600)
251
+ max_files: int = Field(default=1000, ge=1, le=100_000)
252
+ max_total_bytes: int = Field(default=10_485_760, ge=1024, le=1_073_741_824)
253
+
254
+
255
+ class HookConfig(_Strict):
256
+ strict: bool = False
257
+ timeout_seconds: int = Field(default=60, ge=5, le=3600)
258
+
259
+
260
+ class LoggingConfig(_Strict):
261
+ level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
262
+ format: Literal["text", "json"] = "text"
263
+
264
+
265
+ class Config(_Strict):
266
+ """Fully validated steersync configuration."""
267
+
268
+ remote: RemoteConfig = RemoteConfig()
269
+ git: GitConfig = GitConfig()
270
+ branch: BranchConfig = BranchConfig()
271
+ paths: PathsConfig = PathsConfig()
272
+ collections: dict[str, CollectionConfig] = {}
273
+ sync: SyncConfig = SyncConfig()
274
+ lint: LintConfig = LintConfig()
275
+ hook: HookConfig = HookConfig()
276
+ logging: LoggingConfig = LoggingConfig()
277
+ source_file: Path | None = None
278
+
279
+ @field_validator("collections", mode="before")
280
+ @classmethod
281
+ def _default_central_dir(cls, value: object) -> object:
282
+ if not isinstance(value, dict):
283
+ return value
284
+ filled: dict[str, Any] = {}
285
+ for name, raw in value.items():
286
+ needs_default = isinstance(raw, dict) and raw.get("central_dir") is None
287
+ filled[name] = {**raw, "central_dir": name} if needs_default else raw
288
+ return filled
289
+
290
+ @field_validator("collections")
291
+ @classmethod
292
+ def _collections(cls, value: dict[str, CollectionConfig]) -> dict[str, CollectionConfig]:
293
+ seen: dict[str, dict[str, str]] = {
294
+ "central_dir": {},
295
+ "workspace_path": {},
296
+ "global_path": {},
297
+ }
298
+ for name, collection in value.items():
299
+ if not _COLLECTION_NAME_RE.match(name):
300
+ msg = f"invalid collection name {name!r}: use lowercase letters, digits and dashes"
301
+ raise ValueError(msg)
302
+ if collection.workspace_path is None and collection.global_path is None:
303
+ msg = f"collection {name!r} needs a workspace_path or a global_path"
304
+ raise ValueError(msg)
305
+ if not collection.enabled:
306
+ continue
307
+ for key, used in seen.items():
308
+ path = getattr(collection, key)
309
+ if path is None:
310
+ continue
311
+ if path in used:
312
+ msg = f"collections {used[path]!r} and {name!r} share the same {key} {path!r}"
313
+ raise ValueError(msg)
314
+ used[path] = name
315
+ return value
316
+
317
+ def enabled_collections(self) -> dict[str, CollectionConfig]:
318
+ """Return the enabled collections in configuration order."""
319
+ return {name: c for name, c in self.collections.items() if c.enabled}
320
+
321
+ def select_collections(self, names: Sequence[str] | None) -> dict[str, CollectionConfig]:
322
+ """Return the enabled collections named in ``names`` (all of them when empty)."""
323
+ enabled = self.enabled_collections()
324
+ if not names:
325
+ return enabled
326
+ unknown = [n for n in names if n not in self.collections]
327
+ if unknown:
328
+ msg = f"unknown collection(s): {', '.join(sorted(unknown))}"
329
+ raise ConfigError(msg)
330
+ disabled = [n for n in names if n not in enabled]
331
+ if disabled:
332
+ msg = f"collection(s) disabled in configuration: {', '.join(sorted(disabled))}"
333
+ raise ConfigError(msg)
334
+ return {name: enabled[name] for name in dict.fromkeys(names)}
335
+
336
+
337
+ def default_config_text() -> str:
338
+ """Return the packaged default configuration as YAML text."""
339
+ return resources.files("steersync.resources").joinpath("default_config.yaml").read_text("utf-8")
340
+
341
+
342
+ def _load_yaml(text: str, origin: str) -> dict[str, Any]:
343
+ try:
344
+ data = yaml.safe_load(text)
345
+ except yaml.YAMLError as exc:
346
+ msg = f"{origin}: invalid YAML: {exc}"
347
+ raise ConfigError(msg) from exc
348
+ if data is None:
349
+ return {}
350
+ if not isinstance(data, dict):
351
+ msg = f"{origin}: top level must be a mapping"
352
+ raise ConfigError(msg)
353
+ return data
354
+
355
+
356
+ def deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
357
+ """Return ``base`` with ``override`` merged in. Nested mappings merge recursively."""
358
+ merged: dict[str, Any] = dict(base)
359
+ for key, value in override.items():
360
+ existing = merged.get(key)
361
+ if isinstance(existing, Mapping) and isinstance(value, Mapping):
362
+ merged[key] = deep_merge(existing, value)
363
+ else:
364
+ merged[key] = value
365
+ return merged
366
+
367
+
368
+ def _set_nested(data: dict[str, Any], dotted: str, value: Any) -> None: # noqa: ANN401
369
+ node = data
370
+ *parents, leaf = dotted.split(".")
371
+ for part in parents:
372
+ child = node.get(part)
373
+ if not isinstance(child, dict):
374
+ child = {}
375
+ node[part] = child
376
+ node = child
377
+ node[leaf] = value
378
+
379
+
380
+ def _env_overrides(env: Mapping[str, str]) -> dict[str, Any]:
381
+ mapping = {
382
+ ENV_REMOTE: "remote.url",
383
+ ENV_DEFAULT_BRANCH: "remote.default_branch",
384
+ ENV_CACHE_DIR: "paths.cache_dir",
385
+ ENV_LOG_LEVEL: "logging.level",
386
+ ENV_LOG_FORMAT: "logging.format",
387
+ ENV_GIT_BINARY: "git.binary",
388
+ }
389
+ overrides: dict[str, Any] = {}
390
+ for env_name, dotted in mapping.items():
391
+ raw = env.get(env_name)
392
+ if raw is not None and raw != "":
393
+ value: Any = raw.upper() if dotted == "logging.level" else raw
394
+ _set_nested(overrides, dotted, value)
395
+ strict = env.get(ENV_STRICT)
396
+ if strict is not None:
397
+ _set_nested(overrides, "hook.strict", strict.strip().lower() in {"1", "true", "yes", "on"})
398
+ return overrides
399
+
400
+
401
+ def find_config_file(
402
+ workspace_root: Path | None,
403
+ env: Mapping[str, str],
404
+ explicit: Path | None = None,
405
+ ) -> Path | None:
406
+ """Locate the configuration file using the documented precedence."""
407
+ if explicit is not None:
408
+ if not explicit.is_file():
409
+ msg = f"configuration file not found: {explicit}"
410
+ raise ConfigError(msg)
411
+ return explicit
412
+ from_env = env.get(ENV_CONFIG)
413
+ if from_env:
414
+ candidate = Path(from_env).expanduser()
415
+ if not candidate.is_file():
416
+ msg = f"{ENV_CONFIG} points to a missing file: {candidate}"
417
+ raise ConfigError(msg)
418
+ return candidate
419
+ candidates: list[Path] = []
420
+ if workspace_root is not None:
421
+ candidates.extend(workspace_root / ".kiro" / name for name in CONFIG_FILE_NAMES)
422
+ xdg = env.get("XDG_CONFIG_HOME")
423
+ config_home = (
424
+ Path(xdg).expanduser() if xdg else Path(env.get("HOME", "~")).expanduser() / ".config"
425
+ )
426
+ candidates.extend(config_home / "steersync" / name for name in ("config.yaml", "config.yml"))
427
+ return next((c for c in candidates if c.is_file()), None)
428
+
429
+
430
+ def load_config(
431
+ *,
432
+ workspace_root: Path | None = None,
433
+ env: Mapping[str, str] | None = None,
434
+ explicit: Path | None = None,
435
+ ) -> Config:
436
+ """Load, merge and validate configuration."""
437
+ environment = os.environ if env is None else env
438
+ data = _load_yaml(default_config_text(), "packaged defaults")
439
+ source = find_config_file(workspace_root, environment, explicit)
440
+ if source is not None:
441
+ try:
442
+ text = source.read_text("utf-8")
443
+ except OSError as exc:
444
+ msg = f"cannot read configuration file {source}: {exc}"
445
+ raise ConfigError(msg) from exc
446
+ data = deep_merge(data, _load_yaml(text, str(source)))
447
+ data = deep_merge(data, _env_overrides(environment))
448
+ data = _migrate_legacy_paths(data, str(source) if source else "configuration")
449
+ data["source_file"] = source
450
+ try:
451
+ return Config.model_validate(data)
452
+ except ValidationError as exc:
453
+ details = "; ".join(
454
+ f"{'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in exc.errors()
455
+ )
456
+ msg = f"invalid configuration: {details}"
457
+ raise ConfigError(msg) from exc
458
+
459
+
460
+ def _migrate_legacy_paths(data: dict[str, Any], origin: str) -> dict[str, Any]:
461
+ """Map the pre 0.2 ``paths.*_steering`` keys onto the steering collection."""
462
+ paths_section = data.get("paths")
463
+ if not isinstance(paths_section, dict):
464
+ return data
465
+ legacy = {k: v for k, v in paths_section.items() if k in _LEGACY_PATH_KEYS}
466
+ if not legacy:
467
+ return data
468
+ migrated = dict(data)
469
+ migrated["paths"] = {k: v for k, v in paths_section.items() if k not in _LEGACY_PATH_KEYS}
470
+ collections = dict(migrated.get("collections") or {})
471
+ for key, value in legacy.items():
472
+ collection, setting = _LEGACY_PATH_KEYS[key]
473
+ existing = collections.get(collection)
474
+ target = dict(existing) if isinstance(existing, dict) else {}
475
+ target[setting] = value
476
+ collections[collection] = target
477
+ log.warning(
478
+ "%s: paths.%s is deprecated; use collections.%s.%s", origin, key, collection, setting
479
+ )
480
+ migrated["collections"] = collections
481
+ return migrated
482
+
483
+
484
+ def validate_remote_url(
485
+ url: str, *, allowed_hosts: tuple[str, ...] = (), allow_local: bool = False
486
+ ) -> str:
487
+ """Validate a git remote URL and return it unchanged.
488
+
489
+ Only ssh://, https://, git@host:path and (optionally) local file URLs are accepted.
490
+ Anything that could be interpreted as a git option or a helper transport is rejected.
491
+ """
492
+ candidate = url.strip()
493
+ if not candidate:
494
+ msg = "remote URL is empty; set remote.url in configuration or STEERSYNC_REMOTE"
495
+ raise ConfigError(msg)
496
+ if candidate.startswith("-") or any(c in candidate for c in "\n\r\0 \t"):
497
+ msg = "remote URL contains illegal characters"
498
+ raise ConfigError(msg)
499
+ if "::" in candidate:
500
+ msg = "remote helper transports (ext::, fd::) are not allowed"
501
+ raise ConfigError(msg)
502
+ host = _remote_host(candidate, allow_local=allow_local)
503
+ if allowed_hosts and host and host.lower() not in allowed_hosts:
504
+ msg = f"remote host {host!r} is not in remote.allowed_hosts"
505
+ raise ConfigError(msg)
506
+ return candidate
507
+
508
+
509
+ def _remote_host(url: str, *, allow_local: bool) -> str | None:
510
+ parts = urlsplit(url)
511
+ if parts.scheme in {"ssh", "https", "git+ssh", "ssh+git"}:
512
+ if not parts.hostname:
513
+ msg = f"remote URL has no host: {url}"
514
+ raise ConfigError(msg)
515
+ return parts.hostname
516
+ if parts.scheme == "file" or url.startswith("/"):
517
+ if not allow_local:
518
+ msg = "local remotes are disabled; set remote.allow_local to true to permit them"
519
+ raise ConfigError(msg)
520
+ return None
521
+ if parts.scheme in {"http", "git", "ftp", "ftps"}:
522
+ msg = f"insecure remote scheme {parts.scheme!r} is not allowed"
523
+ raise ConfigError(msg)
524
+ if _SCP_LIKE.match(url):
525
+ return url.split(":", 1)[0].rsplit("@", 1)[-1]
526
+ msg = f"unsupported remote URL: {url}"
527
+ raise ConfigError(msg)
528
+
529
+
530
+ def remote_host(url: str) -> str | None:
531
+ """Return the host of a validated remote URL, or ``None`` for local remotes."""
532
+ return _remote_host(url, allow_local=True)
steersync/errors.py ADDED
@@ -0,0 +1,52 @@
1
+ """Exception types raised by steersync.
2
+
3
+ Each error carries a process exit code so the CLI can map failures consistently.
4
+ """
5
+
6
+
7
+ class SteersyncError(Exception):
8
+ """Base error for all steersync failures."""
9
+
10
+ exit_code: int = 1
11
+
12
+
13
+ class ConfigError(SteersyncError):
14
+ """Configuration is missing, malformed or fails validation."""
15
+
16
+ exit_code = 2
17
+
18
+
19
+ class GitError(SteersyncError):
20
+ """A git invocation failed or returned unexpected output."""
21
+
22
+ exit_code = 3
23
+
24
+
25
+ class LintError(SteersyncError):
26
+ """Steering documents failed validation."""
27
+
28
+ exit_code = 4
29
+
30
+
31
+ class SyncConflictError(SteersyncError):
32
+ """Local and remote steering changed in incompatible ways."""
33
+
34
+ exit_code = 5
35
+
36
+
37
+ class UnsafePathError(SteersyncError):
38
+ """A path escaped the directory it must stay inside."""
39
+
40
+ exit_code = 6
41
+
42
+
43
+ class LimitExceededError(SteersyncError):
44
+ """Content from the central repository exceeds the configured safety limits."""
45
+
46
+ exit_code = 7
47
+
48
+
49
+ class LockTimeoutError(SteersyncError):
50
+ """Another steersync process held the cache lock for too long."""
51
+
52
+ exit_code = 8