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