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/sync.py
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
"""Pull, push and status orchestration built on the git and steering modules.
|
|
2
|
+
|
|
3
|
+
Every operation works on *collections* (steering, specs, skills, agents, hooks,
|
|
4
|
+
settings or anything configured): a pull applies each collection of each scope,
|
|
5
|
+
a push proposes all selected collections of one scope in a single branch.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Literal, NamedTuple, ParamSpec, TypeVar, cast
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
from steersync import gitops, paths
|
|
19
|
+
from steersync.config import CollectionConfig, Config, remote_host, validate_remote_url
|
|
20
|
+
from steersync.errors import ConfigError, LintError, SyncConflictError
|
|
21
|
+
from steersync.lint import LintReport, lint_snapshot, merge_reports, prefix_report
|
|
22
|
+
from steersync.locking import exclusive_lock
|
|
23
|
+
from steersync.steering import (
|
|
24
|
+
ChangePlan,
|
|
25
|
+
FileSpec,
|
|
26
|
+
Snapshot,
|
|
27
|
+
apply_plan,
|
|
28
|
+
discover_files,
|
|
29
|
+
enforce_limits,
|
|
30
|
+
executable_paths,
|
|
31
|
+
lock_collections,
|
|
32
|
+
plan_changes,
|
|
33
|
+
read_lock,
|
|
34
|
+
replace_directory_contents,
|
|
35
|
+
snapshot_digest,
|
|
36
|
+
snapshot_directory,
|
|
37
|
+
snapshot_hashes,
|
|
38
|
+
write_lock,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
log = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
Scope = Literal["workspace", "global"]
|
|
44
|
+
|
|
45
|
+
# Explicit ParamSpec/TypeVar instead of PEP 695 syntax: CodeQL's Python extractor
|
|
46
|
+
# does not model inline type parameters and reports them as uninitialised.
|
|
47
|
+
P = ParamSpec("P")
|
|
48
|
+
R = TypeVar("R")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CollectionResult(NamedTuple):
|
|
52
|
+
"""Outcome of applying one collection in one scope."""
|
|
53
|
+
|
|
54
|
+
scope: Scope
|
|
55
|
+
collection: str
|
|
56
|
+
target_dir: Path
|
|
57
|
+
plan: ChangePlan
|
|
58
|
+
written: tuple[str, ...]
|
|
59
|
+
lint: LintReport
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class PullResult(NamedTuple):
|
|
63
|
+
remote: str
|
|
64
|
+
commit: str
|
|
65
|
+
results: tuple[CollectionResult, ...]
|
|
66
|
+
dry_run: bool
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def conflicts(self) -> tuple[str, ...]:
|
|
70
|
+
return tuple(f"{r.scope}:{r.collection}:{c}" for r in self.results for c in r.plan.conflict)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def change_count(self) -> int:
|
|
74
|
+
return sum(r.plan.change_count for r in self.results)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PushResult(NamedTuple):
|
|
78
|
+
status: Literal[
|
|
79
|
+
"pushed", "up-to-date", "already-proposed", "nothing-to-push", "dry-run", "skipped"
|
|
80
|
+
]
|
|
81
|
+
project: str
|
|
82
|
+
collections: tuple[str, ...]
|
|
83
|
+
branch: str | None
|
|
84
|
+
commit: str | None
|
|
85
|
+
digest: str | None
|
|
86
|
+
# Paths in the plan are prefixed with the collection's central directory.
|
|
87
|
+
plan: ChangePlan | None
|
|
88
|
+
lint: LintReport | None
|
|
89
|
+
message: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class StatusResult(NamedTuple):
|
|
93
|
+
project: str | None
|
|
94
|
+
remote: str
|
|
95
|
+
remote_commit: str | None
|
|
96
|
+
# Plans keyed by collection name; ``None`` when the scope is unavailable.
|
|
97
|
+
workspace: dict[str, ChangePlan] | None
|
|
98
|
+
global_: dict[str, ChangePlan]
|
|
99
|
+
lint: LintReport | None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _ScopeTarget(NamedTuple):
|
|
103
|
+
"""Where a scope reads from in the cache clone and writes to locally."""
|
|
104
|
+
|
|
105
|
+
scope: Scope
|
|
106
|
+
source_base: Path
|
|
107
|
+
lock_path: Path
|
|
108
|
+
target_for: Callable[[CollectionConfig], Path | None]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _env(env: Mapping[str, str] | None) -> Mapping[str, str]:
|
|
112
|
+
return os.environ if env is None else env
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _validated_url(config: Config) -> str:
|
|
116
|
+
return validate_remote_url(
|
|
117
|
+
config.remote.url,
|
|
118
|
+
allowed_hosts=config.remote.allowed_hosts,
|
|
119
|
+
allow_local=config.remote.allow_local,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _central_url(config: Config) -> str:
|
|
124
|
+
url = _validated_url(config)
|
|
125
|
+
if urlsplit(url).password:
|
|
126
|
+
log.warning(
|
|
127
|
+
"remote.url embeds a password or token; prefer an ssh key or a git credential helper"
|
|
128
|
+
)
|
|
129
|
+
return url
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _cache_git_env(config: Config, env: Mapping[str, str]) -> dict[str, str]:
|
|
133
|
+
"""git environment for the cache clone: prompts off and every git hook disabled."""
|
|
134
|
+
no_hooks = paths.cache_root(config, env) / "no-hooks"
|
|
135
|
+
no_hooks.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
136
|
+
return gitops.git_environment(env, hooks_dir=no_hooks)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _refresh_cache(config: Config, env: Mapping[str, str], url: str) -> tuple[Path, str]:
|
|
140
|
+
cache_dir = paths.cache_repo_dir(config, env, url)
|
|
141
|
+
git_env = _cache_git_env(config, env)
|
|
142
|
+
gitops.ensure_cache_clone(config, url=url, cache_dir=cache_dir, env=git_env)
|
|
143
|
+
return cache_dir, gitops.head_commit(config, cache_dir, env=git_env)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _spec(collection: CollectionConfig) -> FileSpec:
|
|
147
|
+
return FileSpec(collection.include, collection.exclude, collection.max_depth)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _central_snapshot(config: Config, directory: Path, spec: FileSpec) -> Snapshot:
|
|
151
|
+
"""Read central repository content without following symlinks, within the size limits."""
|
|
152
|
+
snapshot = snapshot_directory(directory, spec, follow_symlinks=False)
|
|
153
|
+
enforce_limits(
|
|
154
|
+
snapshot, max_files=config.sync.max_files, max_total_bytes=config.sync.max_total_bytes
|
|
155
|
+
)
|
|
156
|
+
return snapshot
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _lint_collection(
|
|
160
|
+
collection: CollectionConfig,
|
|
161
|
+
config: Config,
|
|
162
|
+
snapshot: Snapshot,
|
|
163
|
+
workspace_root: Path | None,
|
|
164
|
+
) -> LintReport:
|
|
165
|
+
return lint_snapshot(
|
|
166
|
+
snapshot,
|
|
167
|
+
config.lint,
|
|
168
|
+
workspace_root,
|
|
169
|
+
profile=collection.lint,
|
|
170
|
+
max_file_bytes=collection.max_file_bytes,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _warn_legacy_layout(base: Path, collection: CollectionConfig, source_dir: Path) -> None:
|
|
175
|
+
"""Point at the pre 0.2 flat layout (``global/*.md``) when the collection dir is absent."""
|
|
176
|
+
if source_dir.is_dir() or not base.is_dir():
|
|
177
|
+
return
|
|
178
|
+
flat = FileSpec(collection.include, collection.exclude, max_depth=1)
|
|
179
|
+
if discover_files(base, flat, follow_symlinks=False):
|
|
180
|
+
log.warning(
|
|
181
|
+
"%s has files directly under it but no %s/ directory; since steersync 0.2 each "
|
|
182
|
+
"collection lives in its own directory, move them to %s/",
|
|
183
|
+
base,
|
|
184
|
+
collection.central_dir,
|
|
185
|
+
collection.central_dir,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _with_cache_lock(func: Callable[P, R]) -> Callable[P, R]: # noqa: UP047
|
|
190
|
+
"""Serialise operations that share a cache clone across processes."""
|
|
191
|
+
|
|
192
|
+
@functools.wraps(func)
|
|
193
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
194
|
+
config = cast(Config, args[0] if args else kwargs["config"])
|
|
195
|
+
environment = _env(cast(Mapping[str, str] | None, kwargs.get("env")))
|
|
196
|
+
url = _validated_url(config)
|
|
197
|
+
cache_dir = paths.cache_repo_dir(config, environment, url)
|
|
198
|
+
paths.cache_root(config, environment).mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
199
|
+
lock_path = cache_dir.with_name(cache_dir.name + ".lock")
|
|
200
|
+
with exclusive_lock(lock_path, timeout=config.sync.lock_timeout_seconds):
|
|
201
|
+
return func(*args, **kwargs)
|
|
202
|
+
|
|
203
|
+
return wrapper
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _project_dir(config: Config, cache_dir: Path, project: str) -> Path:
|
|
207
|
+
return paths.ensure_within(cache_dir, Path(config.remote.projects_dir) / project)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def resolve_project(
|
|
211
|
+
config: Config, *, workspace_root: Path, env: Mapping[str, str], remote_url: str | None
|
|
212
|
+
) -> str | None:
|
|
213
|
+
"""Return the project slug for the workspace, or ``None`` when it cannot be determined."""
|
|
214
|
+
url = remote_url or gitops.remote_url(config, workspace_root, env=gitops.git_environment(env))
|
|
215
|
+
if not url:
|
|
216
|
+
return None
|
|
217
|
+
return gitops.project_slug(url)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@_with_cache_lock
|
|
221
|
+
def pull(
|
|
222
|
+
config: Config,
|
|
223
|
+
*,
|
|
224
|
+
workspace_root: Path | None,
|
|
225
|
+
env: Mapping[str, str] | None = None,
|
|
226
|
+
include_workspace: bool = True,
|
|
227
|
+
include_global: bool = True,
|
|
228
|
+
remote_url: str | None = None,
|
|
229
|
+
force: bool = False,
|
|
230
|
+
dry_run: bool = False,
|
|
231
|
+
strict: bool | None = None,
|
|
232
|
+
collections: Sequence[str] | None = None,
|
|
233
|
+
) -> PullResult:
|
|
234
|
+
"""Fetch the central repository and apply every selected collection locally."""
|
|
235
|
+
environment = _env(env)
|
|
236
|
+
url = _central_url(config)
|
|
237
|
+
selected = config.select_collections(collections)
|
|
238
|
+
cache_dir, commit = _refresh_cache(config, environment, url)
|
|
239
|
+
strict_pull = config.sync.strict_pull if strict is None else strict
|
|
240
|
+
targets: list[_ScopeTarget] = []
|
|
241
|
+
|
|
242
|
+
if include_global:
|
|
243
|
+
targets.append(
|
|
244
|
+
_ScopeTarget(
|
|
245
|
+
"global",
|
|
246
|
+
paths.ensure_within(cache_dir, Path(config.remote.global_dir)),
|
|
247
|
+
paths.global_lock_path(config, environment),
|
|
248
|
+
lambda c: paths.global_collection_dir(c, environment),
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
if include_workspace and workspace_root is not None:
|
|
252
|
+
project = resolve_project(
|
|
253
|
+
config, workspace_root=workspace_root, env=environment, remote_url=remote_url
|
|
254
|
+
)
|
|
255
|
+
if project is None:
|
|
256
|
+
log.warning("workspace has no origin remote; skipping workspace collections")
|
|
257
|
+
else:
|
|
258
|
+
targets.append(
|
|
259
|
+
_ScopeTarget(
|
|
260
|
+
"workspace",
|
|
261
|
+
_project_dir(config, cache_dir, project),
|
|
262
|
+
paths.workspace_lock_path(config, workspace_root),
|
|
263
|
+
lambda c: paths.workspace_collection_dir(c, workspace_root),
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
results: list[CollectionResult] = []
|
|
268
|
+
for target in targets:
|
|
269
|
+
results.extend(
|
|
270
|
+
_pull_scope(
|
|
271
|
+
target,
|
|
272
|
+
config,
|
|
273
|
+
selected,
|
|
274
|
+
url=url,
|
|
275
|
+
commit=commit,
|
|
276
|
+
force=force,
|
|
277
|
+
dry_run=dry_run,
|
|
278
|
+
strict_pull=strict_pull,
|
|
279
|
+
workspace_root=workspace_root if target.scope == "workspace" else None,
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
return PullResult(url, commit, tuple(results), dry_run)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _pull_scope(
|
|
286
|
+
target: _ScopeTarget,
|
|
287
|
+
config: Config,
|
|
288
|
+
selected: Mapping[str, CollectionConfig],
|
|
289
|
+
*,
|
|
290
|
+
url: str,
|
|
291
|
+
commit: str,
|
|
292
|
+
force: bool,
|
|
293
|
+
dry_run: bool,
|
|
294
|
+
strict_pull: bool,
|
|
295
|
+
workspace_root: Path | None,
|
|
296
|
+
) -> list[CollectionResult]:
|
|
297
|
+
"""Apply every selected collection of one scope and rewrite the scope's lock once."""
|
|
298
|
+
lock = read_lock(target.lock_path)
|
|
299
|
+
next_lock = lock_collections(lock)
|
|
300
|
+
results: list[CollectionResult] = []
|
|
301
|
+
for name, collection in selected.items():
|
|
302
|
+
target_dir = target.target_for(collection)
|
|
303
|
+
if target_dir is None:
|
|
304
|
+
continue
|
|
305
|
+
source_dir = paths.central_collection_dir(target.source_base, collection)
|
|
306
|
+
_warn_legacy_layout(target.source_base, collection, source_dir)
|
|
307
|
+
result, hashes = _apply_collection(
|
|
308
|
+
target.scope,
|
|
309
|
+
name,
|
|
310
|
+
collection,
|
|
311
|
+
config,
|
|
312
|
+
source_dir,
|
|
313
|
+
target_dir,
|
|
314
|
+
next_lock.get(name, {}),
|
|
315
|
+
force=force,
|
|
316
|
+
dry_run=dry_run,
|
|
317
|
+
strict_pull=strict_pull,
|
|
318
|
+
workspace_root=workspace_root,
|
|
319
|
+
)
|
|
320
|
+
next_lock[name] = hashes
|
|
321
|
+
results.append(result)
|
|
322
|
+
if results and not dry_run:
|
|
323
|
+
write_lock(target.lock_path, remote=url, commit=commit, collections=next_lock)
|
|
324
|
+
return results
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _apply_collection(
|
|
328
|
+
scope: Scope,
|
|
329
|
+
name: str,
|
|
330
|
+
collection: CollectionConfig,
|
|
331
|
+
config: Config,
|
|
332
|
+
source_dir: Path,
|
|
333
|
+
target_dir: Path,
|
|
334
|
+
previous: Mapping[str, str],
|
|
335
|
+
*,
|
|
336
|
+
force: bool,
|
|
337
|
+
dry_run: bool,
|
|
338
|
+
strict_pull: bool,
|
|
339
|
+
workspace_root: Path | None,
|
|
340
|
+
) -> tuple[CollectionResult, dict[str, str]]:
|
|
341
|
+
label = f"{scope}/{name}"
|
|
342
|
+
spec = _spec(collection)
|
|
343
|
+
source = _central_snapshot(config, source_dir, spec)
|
|
344
|
+
report = _lint_collection(collection, config, source, None)
|
|
345
|
+
if strict_pull and not report.ok:
|
|
346
|
+
raise LintError(
|
|
347
|
+
f"{label}: remote content has {len(report.errors)} lint error(s); refusing to pull"
|
|
348
|
+
)
|
|
349
|
+
for finding in report.findings:
|
|
350
|
+
log.log(
|
|
351
|
+
logging.ERROR if finding.severity == "error" else logging.WARNING,
|
|
352
|
+
"%s: %s",
|
|
353
|
+
label,
|
|
354
|
+
finding.render(),
|
|
355
|
+
)
|
|
356
|
+
del workspace_root
|
|
357
|
+
local = snapshot_directory(target_dir, spec)
|
|
358
|
+
plan = plan_changes(source, local, previous, prune=config.sync.prune)
|
|
359
|
+
if plan.conflict and not force:
|
|
360
|
+
log.warning(
|
|
361
|
+
"%s: %d locally modified file(s) kept; use --force to overwrite",
|
|
362
|
+
label,
|
|
363
|
+
len(plan.conflict),
|
|
364
|
+
)
|
|
365
|
+
written: tuple[str, ...] = ()
|
|
366
|
+
if not dry_run:
|
|
367
|
+
executable = executable_paths(source_dir, source)
|
|
368
|
+
written = tuple(apply_plan(target_dir, source, plan, force=force, executable=executable))
|
|
369
|
+
return (
|
|
370
|
+
CollectionResult(scope, name, target_dir, plan, written, report),
|
|
371
|
+
_next_lock(source, plan, previous, force),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class _LocalCollection(NamedTuple):
|
|
376
|
+
name: str
|
|
377
|
+
collection: CollectionConfig
|
|
378
|
+
local_dir: Path
|
|
379
|
+
snapshot: Snapshot
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _local_collections(
|
|
383
|
+
config: Config,
|
|
384
|
+
selected: Mapping[str, CollectionConfig],
|
|
385
|
+
target_for: Callable[[CollectionConfig], Path | None],
|
|
386
|
+
) -> list[_LocalCollection]:
|
|
387
|
+
found: list[_LocalCollection] = []
|
|
388
|
+
for name, collection in selected.items():
|
|
389
|
+
local_dir = target_for(collection)
|
|
390
|
+
if local_dir is None:
|
|
391
|
+
continue
|
|
392
|
+
snapshot = snapshot_directory(local_dir, _spec(collection))
|
|
393
|
+
enforce_limits(
|
|
394
|
+
snapshot, max_files=config.sync.max_files, max_total_bytes=config.sync.max_total_bytes
|
|
395
|
+
)
|
|
396
|
+
found.append(_LocalCollection(name, collection, local_dir, snapshot))
|
|
397
|
+
return found
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _prefixed(collection: CollectionConfig, snapshot: Mapping[str, bytes]) -> Snapshot:
|
|
401
|
+
"""Key a collection snapshot by its path inside the central project directory."""
|
|
402
|
+
prefix = collection.central_dir or ""
|
|
403
|
+
return {f"{prefix}/{rel}" if prefix else rel: data for rel, data in snapshot.items()}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@_with_cache_lock
|
|
407
|
+
def push(
|
|
408
|
+
config: Config,
|
|
409
|
+
*,
|
|
410
|
+
workspace_root: Path,
|
|
411
|
+
env: Mapping[str, str] | None = None,
|
|
412
|
+
remote_url: str | None = None,
|
|
413
|
+
scope: Scope = "workspace",
|
|
414
|
+
dry_run: bool = False,
|
|
415
|
+
run_lint: bool = True,
|
|
416
|
+
message: str | None = None,
|
|
417
|
+
collections: Sequence[str] | None = None,
|
|
418
|
+
) -> PushResult:
|
|
419
|
+
"""Propose the selected local collections as one branch (and merge request) centrally."""
|
|
420
|
+
environment = _env(env)
|
|
421
|
+
url = _central_url(config)
|
|
422
|
+
git_env = gitops.git_environment(environment)
|
|
423
|
+
selected = config.select_collections(collections)
|
|
424
|
+
names = tuple(selected)
|
|
425
|
+
|
|
426
|
+
if scope == "workspace":
|
|
427
|
+
project = resolve_project(
|
|
428
|
+
config, workspace_root=workspace_root, env=environment, remote_url=remote_url
|
|
429
|
+
)
|
|
430
|
+
if project is None:
|
|
431
|
+
raise ConfigError(
|
|
432
|
+
"cannot determine the project: no origin remote and no --remote-url given"
|
|
433
|
+
)
|
|
434
|
+
project_remote = remote_url or gitops.remote_url(config, workspace_root, env=git_env) or ""
|
|
435
|
+
if _same_remote(project_remote, url):
|
|
436
|
+
return PushResult(
|
|
437
|
+
"skipped",
|
|
438
|
+
project,
|
|
439
|
+
names,
|
|
440
|
+
None,
|
|
441
|
+
None,
|
|
442
|
+
None,
|
|
443
|
+
None,
|
|
444
|
+
None,
|
|
445
|
+
"workspace is the central repository",
|
|
446
|
+
)
|
|
447
|
+
locals_ = _local_collections(
|
|
448
|
+
config, selected, lambda c: paths.workspace_collection_dir(c, workspace_root)
|
|
449
|
+
)
|
|
450
|
+
else:
|
|
451
|
+
project = "global"
|
|
452
|
+
locals_ = _local_collections(
|
|
453
|
+
config, selected, lambda c: paths.global_collection_dir(c, environment)
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
combined: Snapshot = {}
|
|
457
|
+
for item in locals_:
|
|
458
|
+
combined.update(_prefixed(item.collection, item.snapshot))
|
|
459
|
+
enforce_limits(
|
|
460
|
+
combined, max_files=config.sync.max_files, max_total_bytes=config.sync.max_total_bytes
|
|
461
|
+
)
|
|
462
|
+
if not combined:
|
|
463
|
+
where = ", ".join(str(item.local_dir) for item in locals_) or "the selected collections"
|
|
464
|
+
return PushResult(
|
|
465
|
+
"nothing-to-push",
|
|
466
|
+
project,
|
|
467
|
+
names,
|
|
468
|
+
None,
|
|
469
|
+
None,
|
|
470
|
+
None,
|
|
471
|
+
None,
|
|
472
|
+
None,
|
|
473
|
+
f"no files in {where}",
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
report: LintReport | None = None
|
|
477
|
+
if run_lint:
|
|
478
|
+
lint_root = workspace_root if scope == "workspace" else None
|
|
479
|
+
report = merge_reports(
|
|
480
|
+
prefix_report(
|
|
481
|
+
_lint_collection(item.collection, config, item.snapshot, lint_root),
|
|
482
|
+
item.collection.central_dir or "",
|
|
483
|
+
)
|
|
484
|
+
for item in locals_
|
|
485
|
+
)
|
|
486
|
+
if not report.ok:
|
|
487
|
+
raise LintError(
|
|
488
|
+
f"{len(report.errors)} lint error(s) in {scope} collections; "
|
|
489
|
+
"fix them or pass --no-lint"
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
cache_dir, base_commit = _refresh_cache(config, environment, url)
|
|
493
|
+
central_base = (
|
|
494
|
+
_project_dir(config, cache_dir, project)
|
|
495
|
+
if scope == "workspace"
|
|
496
|
+
else paths.ensure_within(cache_dir, Path(config.remote.global_dir))
|
|
497
|
+
)
|
|
498
|
+
central: Snapshot = {}
|
|
499
|
+
remote_sizes: dict[str, int] = {}
|
|
500
|
+
for item in locals_:
|
|
501
|
+
central_dir = paths.central_collection_dir(central_base, item.collection)
|
|
502
|
+
remote_snapshot = _central_snapshot(config, central_dir, _spec(item.collection))
|
|
503
|
+
remote_sizes[item.name] = len(remote_snapshot)
|
|
504
|
+
central.update(_prefixed(item.collection, remote_snapshot))
|
|
505
|
+
plan = plan_changes(combined, central, snapshot_hashes(central), prune=True)
|
|
506
|
+
if plan.is_empty:
|
|
507
|
+
return PushResult(
|
|
508
|
+
"up-to-date",
|
|
509
|
+
project,
|
|
510
|
+
names,
|
|
511
|
+
None,
|
|
512
|
+
base_commit,
|
|
513
|
+
snapshot_digest(combined),
|
|
514
|
+
plan,
|
|
515
|
+
report,
|
|
516
|
+
"central repository already matches",
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
digest = snapshot_digest(combined)
|
|
520
|
+
user = gitops.current_user_id(config, workspace_root, environment)
|
|
521
|
+
branch = gitops.build_branch_name(config, project=project, user=user, content_hash=digest)
|
|
522
|
+
if not gitops.check_ref_format(config, branch, env=git_env):
|
|
523
|
+
raise ConfigError(f"generated branch name is not a valid git ref: {branch}")
|
|
524
|
+
if dry_run:
|
|
525
|
+
return PushResult(
|
|
526
|
+
"dry-run",
|
|
527
|
+
project,
|
|
528
|
+
names,
|
|
529
|
+
branch,
|
|
530
|
+
None,
|
|
531
|
+
digest,
|
|
532
|
+
plan,
|
|
533
|
+
report,
|
|
534
|
+
f"would push {plan.change_count} change(s)",
|
|
535
|
+
)
|
|
536
|
+
if gitops.remote_branch_exists(config, url, branch, env=git_env):
|
|
537
|
+
return PushResult(
|
|
538
|
+
"already-proposed",
|
|
539
|
+
project,
|
|
540
|
+
names,
|
|
541
|
+
branch,
|
|
542
|
+
None,
|
|
543
|
+
digest,
|
|
544
|
+
plan,
|
|
545
|
+
report,
|
|
546
|
+
"an identical proposal branch already exists",
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
cache_env = _cache_git_env(config, environment)
|
|
550
|
+
author_name = (
|
|
551
|
+
gitops.config_value(config, workspace_root, "user.name", env=git_env) or "steersync"
|
|
552
|
+
)
|
|
553
|
+
author_email = (
|
|
554
|
+
gitops.config_value(config, workspace_root, "user.email", env=git_env)
|
|
555
|
+
or "steersync@localhost"
|
|
556
|
+
)
|
|
557
|
+
options = gitops.render_push_options(config, project=project, user=user, content_hash=digest)
|
|
558
|
+
try:
|
|
559
|
+
gitops.create_branch(
|
|
560
|
+
config, cache_dir, branch, f"origin/{config.remote.default_branch}", env=cache_env
|
|
561
|
+
)
|
|
562
|
+
commit_paths: list[str] = []
|
|
563
|
+
for item in locals_:
|
|
564
|
+
if not item.snapshot and not remote_sizes.get(item.name):
|
|
565
|
+
continue # nothing to write or delete; git rejects a pathspec with no files
|
|
566
|
+
central_dir = paths.central_collection_dir(central_base, item.collection)
|
|
567
|
+
replace_directory_contents(
|
|
568
|
+
central_dir,
|
|
569
|
+
item.snapshot,
|
|
570
|
+
_spec(item.collection),
|
|
571
|
+
executable=executable_paths(item.local_dir, item.snapshot),
|
|
572
|
+
)
|
|
573
|
+
commit_paths.append(central_dir.relative_to(cache_dir).as_posix())
|
|
574
|
+
commit = gitops.commit_paths(
|
|
575
|
+
config,
|
|
576
|
+
cache_dir,
|
|
577
|
+
paths=commit_paths,
|
|
578
|
+
message=message or _commit_message(project, user, plan),
|
|
579
|
+
author_name=author_name,
|
|
580
|
+
author_email=author_email,
|
|
581
|
+
env=cache_env,
|
|
582
|
+
)
|
|
583
|
+
if commit is None:
|
|
584
|
+
return PushResult(
|
|
585
|
+
"up-to-date",
|
|
586
|
+
project,
|
|
587
|
+
names,
|
|
588
|
+
branch,
|
|
589
|
+
base_commit,
|
|
590
|
+
digest,
|
|
591
|
+
plan,
|
|
592
|
+
report,
|
|
593
|
+
"no changes after staging",
|
|
594
|
+
)
|
|
595
|
+
_, options_delivered = gitops.push_branch(
|
|
596
|
+
config, cache_dir, url=url, branch=branch, push_options=options, env=cache_env
|
|
597
|
+
)
|
|
598
|
+
finally:
|
|
599
|
+
# Always leave the cache on a clean default branch, even after a failed push.
|
|
600
|
+
gitops.create_branch(
|
|
601
|
+
config,
|
|
602
|
+
cache_dir,
|
|
603
|
+
config.remote.default_branch,
|
|
604
|
+
f"origin/{config.remote.default_branch}",
|
|
605
|
+
env=cache_env,
|
|
606
|
+
)
|
|
607
|
+
return PushResult(
|
|
608
|
+
"pushed",
|
|
609
|
+
project,
|
|
610
|
+
names,
|
|
611
|
+
branch,
|
|
612
|
+
commit,
|
|
613
|
+
digest,
|
|
614
|
+
plan,
|
|
615
|
+
report,
|
|
616
|
+
f"pushed {plan.change_count} change(s) to {branch}"
|
|
617
|
+
+ ("" if options_delivered or not options else " (push options not accepted by remote)"),
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _commit_message(project: str, user: str, plan: ChangePlan) -> str:
|
|
622
|
+
lines = [f"steering: update {project} from {user}", ""]
|
|
623
|
+
lines.extend(f"add: {p}" for p in plan.add)
|
|
624
|
+
lines.extend(f"update: {p}" for p in plan.update)
|
|
625
|
+
lines.extend(f"delete: {p}" for p in plan.delete)
|
|
626
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _next_lock(
|
|
630
|
+
source: Snapshot, plan: ChangePlan, previous: Mapping[str, str], force: bool
|
|
631
|
+
) -> dict[str, str]:
|
|
632
|
+
"""Compute the lock content after applying ``plan``.
|
|
633
|
+
|
|
634
|
+
Conflicting files keep their previous lock entry so later pulls still see
|
|
635
|
+
them as locally modified. With ``force`` everything tracks the source.
|
|
636
|
+
"""
|
|
637
|
+
source_hashes = snapshot_hashes(source)
|
|
638
|
+
if force:
|
|
639
|
+
return source_hashes
|
|
640
|
+
next_lock = {rel: digest for rel, digest in source_hashes.items() if rel not in plan.conflict}
|
|
641
|
+
next_lock.update({rel: previous[rel] for rel in plan.conflict if rel in previous})
|
|
642
|
+
return next_lock
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _same_remote(left: str, right: str) -> bool:
|
|
646
|
+
"""True when two URLs name the same repository (same host and project path)."""
|
|
647
|
+
try:
|
|
648
|
+
same_path = (
|
|
649
|
+
gitops.project_path_from_url(left).lower()
|
|
650
|
+
== gitops.project_path_from_url(right).lower()
|
|
651
|
+
)
|
|
652
|
+
same_host = (remote_host(left) or "").lower() == (remote_host(right) or "").lower()
|
|
653
|
+
except ConfigError, Exception:
|
|
654
|
+
return False
|
|
655
|
+
return same_path and same_host
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@_with_cache_lock
|
|
659
|
+
def status(
|
|
660
|
+
config: Config,
|
|
661
|
+
*,
|
|
662
|
+
workspace_root: Path | None,
|
|
663
|
+
env: Mapping[str, str] | None = None,
|
|
664
|
+
remote_url: str | None = None,
|
|
665
|
+
run_lint: bool = True,
|
|
666
|
+
collections: Sequence[str] | None = None,
|
|
667
|
+
) -> StatusResult:
|
|
668
|
+
"""Compare local collections with the central repository without changing anything."""
|
|
669
|
+
environment = _env(env)
|
|
670
|
+
url = _central_url(config)
|
|
671
|
+
selected = config.select_collections(collections)
|
|
672
|
+
cache_dir, commit = _refresh_cache(config, environment, url)
|
|
673
|
+
global_base = paths.ensure_within(cache_dir, Path(config.remote.global_dir))
|
|
674
|
+
global_lock = lock_collections(read_lock(paths.global_lock_path(config, environment)))
|
|
675
|
+
global_plans: dict[str, ChangePlan] = {}
|
|
676
|
+
for name, collection in selected.items():
|
|
677
|
+
target = paths.global_collection_dir(collection, environment)
|
|
678
|
+
if target is None:
|
|
679
|
+
continue
|
|
680
|
+
source_dir = paths.central_collection_dir(global_base, collection)
|
|
681
|
+
_warn_legacy_layout(global_base, collection, source_dir)
|
|
682
|
+
spec = _spec(collection)
|
|
683
|
+
global_plans[name] = plan_changes(
|
|
684
|
+
_central_snapshot(config, source_dir, spec),
|
|
685
|
+
snapshot_directory(target, spec),
|
|
686
|
+
global_lock.get(name, {}),
|
|
687
|
+
prune=config.sync.prune,
|
|
688
|
+
)
|
|
689
|
+
project: str | None = None
|
|
690
|
+
workspace_plans: dict[str, ChangePlan] | None = None
|
|
691
|
+
report: LintReport | None = None
|
|
692
|
+
if workspace_root is not None:
|
|
693
|
+
project = resolve_project(
|
|
694
|
+
config, workspace_root=workspace_root, env=environment, remote_url=remote_url
|
|
695
|
+
)
|
|
696
|
+
if project is not None:
|
|
697
|
+
workspace_plans = {}
|
|
698
|
+
reports: list[LintReport] = []
|
|
699
|
+
project_base = _project_dir(config, cache_dir, project)
|
|
700
|
+
for name, collection in selected.items():
|
|
701
|
+
target = paths.workspace_collection_dir(collection, workspace_root)
|
|
702
|
+
if target is None:
|
|
703
|
+
continue
|
|
704
|
+
spec = _spec(collection)
|
|
705
|
+
local = snapshot_directory(target, spec)
|
|
706
|
+
source_dir = paths.central_collection_dir(project_base, collection)
|
|
707
|
+
_warn_legacy_layout(project_base, collection, source_dir)
|
|
708
|
+
central = _central_snapshot(config, source_dir, spec)
|
|
709
|
+
workspace_plans[name] = plan_changes(
|
|
710
|
+
local, central, snapshot_hashes(central), prune=True
|
|
711
|
+
)
|
|
712
|
+
if run_lint:
|
|
713
|
+
reports.append(
|
|
714
|
+
prefix_report(
|
|
715
|
+
_lint_collection(collection, config, local, workspace_root),
|
|
716
|
+
collection.workspace_path or "",
|
|
717
|
+
)
|
|
718
|
+
)
|
|
719
|
+
if run_lint:
|
|
720
|
+
report = merge_reports(reports)
|
|
721
|
+
return StatusResult(project, url, commit, workspace_plans, global_plans, report)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def raise_on_conflicts(result: PullResult) -> None:
|
|
725
|
+
if result.conflicts:
|
|
726
|
+
raise SyncConflictError("locally modified files kept: " + ", ".join(result.conflicts))
|