exist-shell 0.1.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.
@@ -0,0 +1,767 @@
1
+ """sync command — sync a local folder with a remote eXist collection."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import NamedTuple, TypedDict
9
+
10
+ import typer
11
+
12
+ from exist_shell.cache import invalidate
13
+ from exist_shell.client import ExistClient
14
+ from exist_shell.completions import collection_target_completer
15
+ from exist_shell.config import Config
16
+ from exist_shell.models import CollectionEntry, ResourceEntry
17
+ from exist_shell.utils import (
18
+ check_xml_wellformed,
19
+ guess_mime,
20
+ handle_exist_errors,
21
+ is_remote,
22
+ parse_target,
23
+ resolve_collection,
24
+ )
25
+
26
+
27
+ def _get_sync_cache_dir() -> Path:
28
+ """Return the sync manifest cache directory, resolved from the active config.
29
+
30
+ Returns:
31
+ Path to the sync cache directory.
32
+ """
33
+ return Config.load().resolved_cache_dir() / "sync"
34
+
35
+
36
+ class ManifestEntry(TypedDict, total=False):
37
+ """Per-file state stored in the sync manifest.
38
+
39
+ Attributes:
40
+ local_sha256: SHA-256 hex digest of the local file at last sync.
41
+ remote_last_modified: Server-assigned mtime at last sync (empty string
42
+ immediately after upload, before the re-list that records it).
43
+ local_mtime_ns: Local file mtime in nanoseconds at last sync.
44
+ local_size: Local file size in bytes at last sync.
45
+ """
46
+
47
+ local_sha256: str
48
+ remote_last_modified: str
49
+ local_mtime_ns: int
50
+ local_size: int
51
+
52
+
53
+ class SyncAction(Enum):
54
+ """Outcome of a single-file sync decision."""
55
+
56
+ UPLOADED = "uploaded"
57
+ DOWNLOADED = "downloaded"
58
+ SKIPPED = "skipped"
59
+ CONFLICT = "conflict"
60
+ DELETED = "deleted"
61
+ CREATED = "created"
62
+ INVALID = "invalid"
63
+
64
+
65
+ class RemoteResource(NamedTuple):
66
+ """A resource found during a remote tree walk.
67
+
68
+ Attributes:
69
+ rel_path: Path relative to the sync root, using ``/`` as separator.
70
+ entry: The ResourceEntry returned by the REST listing.
71
+ """
72
+
73
+ rel_path: str
74
+ entry: ResourceEntry
75
+
76
+
77
+ class RemoteTree(NamedTuple):
78
+ """Result of a recursive remote tree walk.
79
+
80
+ Attributes:
81
+ resources: All resources found, with paths relative to the walk root.
82
+ subcollections: Relative paths of all subcollections found.
83
+ """
84
+
85
+ resources: list[RemoteResource]
86
+ subcollections: list[str]
87
+
88
+
89
+ class Manifest:
90
+ """Sync manifest: tracks per-file state and handles checkpoint writes."""
91
+
92
+ def __init__(self, data: dict[str, ManifestEntry], checkpoint_every: int) -> None:
93
+ """Initialize the manifest.
94
+
95
+ Args:
96
+ data: Per-file state loaded from disk (or empty for a fresh manifest).
97
+ checkpoint_every: Mutation count between automatic checkpoint writes.
98
+ """
99
+ self._data = data
100
+ self._dirty = 0
101
+ self._checkpoint_every = checkpoint_every
102
+
103
+ def get(self, rel_path: str) -> ManifestEntry:
104
+ """Return the manifest entry for rel_path, or an empty entry if absent.
105
+
106
+ Args:
107
+ rel_path: Relative path of the file within the sync tree.
108
+
109
+ Returns:
110
+ The stored ManifestEntry, or an empty ManifestEntry if not present.
111
+ """
112
+ return self._data.get(rel_path, ManifestEntry())
113
+
114
+ def __contains__(self, rel_path: str) -> bool:
115
+ """Return True if rel_path has a manifest entry.
116
+
117
+ Args:
118
+ rel_path: Relative path of the file within the sync tree.
119
+
120
+ Returns:
121
+ True if an entry exists for rel_path, False otherwise.
122
+ """
123
+ return rel_path in self._data
124
+
125
+ def set(self, rel_path: str, entry: ManifestEntry) -> None:
126
+ """Upsert an entry and mark the manifest dirty.
127
+
128
+ Args:
129
+ rel_path: Relative path of the file within the sync tree.
130
+ entry: New state to store for this file.
131
+ """
132
+ self._data[rel_path] = entry
133
+ self._dirty += 1
134
+
135
+ def pop(self, rel_path: str) -> None:
136
+ """Remove an entry (if present) and mark the manifest dirty.
137
+
138
+ Args:
139
+ rel_path: Relative path of the file within the sync tree.
140
+ """
141
+ self._data.pop(rel_path, None)
142
+ self._dirty += 1
143
+
144
+ def maybe_save(self, nick: str, remote_path: str) -> None:
145
+ """Write to disk when accumulated mutations reach the threshold.
146
+
147
+ Args:
148
+ nick: Collection nickname.
149
+ remote_path: Remote collection path.
150
+ """
151
+ if self._dirty >= self._checkpoint_every:
152
+ self._write(nick, remote_path)
153
+ self._dirty = 0
154
+
155
+ def save(self, nick: str, remote_path: str) -> None:
156
+ """Unconditional final write.
157
+
158
+ Args:
159
+ nick: Collection nickname.
160
+ remote_path: Remote collection path.
161
+ """
162
+ self._write(nick, remote_path)
163
+
164
+ def _write(self, nick: str, remote_path: str) -> None:
165
+ """Atomically write manifest data to disk.
166
+
167
+ Args:
168
+ nick: Collection nickname.
169
+ remote_path: Remote collection path.
170
+ """
171
+ p = _manifest_path(nick, remote_path)
172
+ p.parent.mkdir(parents=True, exist_ok=True)
173
+ tmp = p.with_suffix(".tmp")
174
+ tmp.write_text(json.dumps(self._data))
175
+ tmp.rename(p)
176
+
177
+
178
+ def _sha256(path: Path) -> str:
179
+ """Return the hex SHA-256 digest of a file's contents.
180
+
181
+ Args:
182
+ path: Local file to hash.
183
+
184
+ Returns:
185
+ Lowercase hex digest string.
186
+ """
187
+ return hashlib.sha256(path.read_bytes()).hexdigest()
188
+
189
+
190
+ def _manifest_path(nick: str, remote_path: str) -> Path:
191
+ """Return the manifest file path for a (nick, remote_path) pair.
192
+
193
+ Args:
194
+ nick: Collection nickname.
195
+ remote_path: Remote collection path.
196
+
197
+ Returns:
198
+ Absolute path to the JSON manifest file.
199
+ """
200
+ key = hashlib.sha256(remote_path.encode()).hexdigest()[:16]
201
+ return _get_sync_cache_dir() / f"{nick}@{key}.json"
202
+
203
+
204
+ def _load_manifest(nick: str, remote_path: str, checkpoint_every: int) -> Manifest:
205
+ """Load the sync manifest, returning an empty manifest if the file is missing.
206
+
207
+ Args:
208
+ nick: Collection nickname.
209
+ remote_path: Remote collection path.
210
+ checkpoint_every: Mutation count between automatic checkpoint writes.
211
+
212
+ Returns:
213
+ Manifest wrapping the last-synced state for each file.
214
+ """
215
+ p = _manifest_path(nick, remote_path)
216
+ if not p.exists():
217
+ return Manifest({}, checkpoint_every)
218
+ try:
219
+ return Manifest(json.loads(p.read_text()), checkpoint_every)
220
+ except Exception:
221
+ return Manifest({}, checkpoint_every)
222
+
223
+
224
+ def _walk_remote(client: ExistClient, base_path: str) -> RemoteTree:
225
+ """Recursively list all resources and subcollections under a remote path.
226
+
227
+ Args:
228
+ client: Active ExistClient.
229
+ base_path: Full eXist path to walk (e.g. ``/db/myapp/reports``).
230
+
231
+ Returns:
232
+ RemoteTree with resources and subcollections relative to ``base_path``.
233
+ """
234
+ items = client.list_collection(base_path)
235
+ resources: list[RemoteResource] = []
236
+ subcollections: list[str] = []
237
+ for item in items:
238
+ if isinstance(item, CollectionEntry):
239
+ subcollections.append(item.name)
240
+ subtree = _walk_remote(client, f"{base_path}/{item.name}")
241
+ resources.extend(
242
+ RemoteResource(f"{item.name}/{r.rel_path}", r.entry) for r in subtree.resources
243
+ )
244
+ subcollections.extend(f"{item.name}/{c}" for c in subtree.subcollections)
245
+ else:
246
+ resources.append(RemoteResource(item.name, item))
247
+ return RemoteTree(resources, subcollections)
248
+
249
+
250
+ def _push_file(
251
+ client: ExistClient,
252
+ full_path: str,
253
+ local_file: Path,
254
+ rel_path: str,
255
+ remote_mtime: str,
256
+ manifest: Manifest,
257
+ force: bool,
258
+ dry_run: bool,
259
+ ) -> SyncAction:
260
+ """Decide and execute the push action for a single file.
261
+
262
+ Stores an empty ``remote_last_modified`` after upload; callers must
263
+ re-list the remote collection afterwards to record the server-assigned mtime.
264
+
265
+ Uses a stat-based fast path: if both the local file's mtime/size and the
266
+ remote mtime match the manifest, the file is skipped without reading or
267
+ hashing its contents.
268
+
269
+ Args:
270
+ client: Active ExistClient.
271
+ full_path: Full eXist base path of the collection (e.g. ``/db/myapp``).
272
+ local_file: Local file to upload.
273
+ rel_path: Relative path of the file within the sync tree.
274
+ remote_mtime: Current ``last_modified`` from the remote listing.
275
+ manifest: Sync manifest (mutated in place on upload).
276
+ force: If True, upload regardless of manifest state.
277
+ dry_run: If True, do not perform the upload.
278
+
279
+ Returns:
280
+ The SyncAction taken.
281
+ """
282
+ mime = guess_mime(local_file, "application/xml")
283
+
284
+ def _upload(local_hash: str, stat: os.stat_result) -> None:
285
+ if not dry_run:
286
+ client.put_document(
287
+ f"{full_path}/{rel_path}",
288
+ local_file.read_bytes(),
289
+ mime,
290
+ )
291
+ manifest.set(rel_path, ManifestEntry(
292
+ local_sha256=local_hash,
293
+ remote_last_modified="",
294
+ local_mtime_ns=stat.st_mtime_ns,
295
+ local_size=stat.st_size,
296
+ ))
297
+
298
+ def _xml_valid() -> bool:
299
+ return not check_xml_wellformed(local_file.read_bytes(), mime)
300
+
301
+ entry = manifest.get(rel_path)
302
+
303
+ if force or rel_path not in manifest:
304
+ if not _xml_valid():
305
+ return SyncAction.INVALID
306
+ stat = local_file.stat()
307
+ _upload(_sha256(local_file), stat)
308
+ return SyncAction.UPLOADED
309
+
310
+ remote_changed = remote_mtime != entry.get("remote_last_modified", "")
311
+ stat = local_file.stat()
312
+ stat_changed = (
313
+ stat.st_mtime_ns != entry.get("local_mtime_ns", -1)
314
+ or stat.st_size != entry.get("local_size", -1)
315
+ )
316
+
317
+ if not stat_changed and not remote_changed:
318
+ return SyncAction.SKIPPED
319
+
320
+ local_hash = _sha256(local_file)
321
+ local_changed = local_hash != entry.get("local_sha256", "")
322
+
323
+ if local_changed and remote_changed:
324
+ return SyncAction.CONFLICT
325
+
326
+ if local_changed:
327
+ if not _xml_valid():
328
+ return SyncAction.INVALID
329
+ _upload(local_hash, stat)
330
+ return SyncAction.UPLOADED
331
+
332
+ # stat changed but content identical (e.g. file was touched) — refresh stat only
333
+ if not dry_run:
334
+ manifest.set(rel_path, ManifestEntry(
335
+ **{**entry, "local_mtime_ns": stat.st_mtime_ns, "local_size": stat.st_size}
336
+ ))
337
+ return SyncAction.SKIPPED
338
+
339
+
340
+ def _pull_file(
341
+ client: ExistClient,
342
+ full_path: str,
343
+ dest: Path,
344
+ rel_path: str,
345
+ remote_mtime: str,
346
+ manifest: Manifest,
347
+ force: bool,
348
+ dry_run: bool,
349
+ ) -> SyncAction:
350
+ """Decide and execute the pull action for a single file.
351
+
352
+ Args:
353
+ client: Active ExistClient.
354
+ full_path: Full eXist base path of the collection (e.g. ``/db/myapp``).
355
+ dest: Local directory to pull into.
356
+ rel_path: Relative path of the file within the sync tree.
357
+ remote_mtime: Current ``last_modified`` from the remote listing.
358
+ manifest: Sync manifest (mutated in place on download).
359
+ force: If True, download regardless of manifest state.
360
+ dry_run: If True, do not perform the download.
361
+
362
+ Returns:
363
+ The SyncAction taken.
364
+ """
365
+ local_file = dest / rel_path
366
+
367
+ def _download() -> None:
368
+ if not dry_run:
369
+ result = client.get_document(f"{full_path}/{rel_path}")
370
+ local_file.parent.mkdir(parents=True, exist_ok=True)
371
+ local_file.write_bytes(result.content)
372
+ stat = local_file.stat()
373
+ manifest.set(rel_path, ManifestEntry(
374
+ local_sha256=_sha256(local_file),
375
+ remote_last_modified=remote_mtime,
376
+ local_mtime_ns=stat.st_mtime_ns,
377
+ local_size=stat.st_size,
378
+ ))
379
+
380
+ entry = manifest.get(rel_path)
381
+
382
+ if force or rel_path not in manifest:
383
+ _download()
384
+ return SyncAction.DOWNLOADED
385
+
386
+ remote_changed = remote_mtime != entry.get("remote_last_modified", "")
387
+
388
+ if not remote_changed:
389
+ if not local_file.exists():
390
+ _download()
391
+ return SyncAction.DOWNLOADED
392
+ return SyncAction.SKIPPED
393
+
394
+ local_hash = _sha256(local_file) if local_file.exists() else ""
395
+ if local_hash != entry.get("local_sha256", ""):
396
+ return SyncAction.CONFLICT
397
+
398
+ _download()
399
+ return SyncAction.DOWNLOADED
400
+
401
+
402
+ def _ensure_remote_dirs(
403
+ client: ExistClient, full_path: str, local_dirs: set[str], remote_cols: list[str], dry_run: bool
404
+ ) -> None:
405
+ """Create remote subcollections that exist locally but not remotely.
406
+
407
+ Args:
408
+ client: Active ExistClient.
409
+ full_path: Full eXist base path of the collection.
410
+ local_dirs: Relative paths of all local subdirectories.
411
+ remote_cols: Relative paths of subcollections already present remotely.
412
+ dry_run: If True, log but do not create.
413
+ """
414
+ remote_col_set = set(remote_cols)
415
+ for local_dir in sorted(local_dirs):
416
+ if local_dir not in remote_col_set:
417
+ typer.echo(f"+ {local_dir}/ (new collection)")
418
+ if not dry_run:
419
+ client.create_collection(f"{full_path}/{local_dir}")
420
+
421
+
422
+ def _ensure_local_dirs(dest: Path, remote_cols: list[str], dry_run: bool) -> None:
423
+ """Create local subdirectories that exist remotely but not locally.
424
+
425
+ Args:
426
+ dest: Local destination directory.
427
+ remote_cols: Relative paths of remote subcollections.
428
+ dry_run: If True, log but do not create.
429
+ """
430
+ for rel_col in remote_cols:
431
+ local_dir = dest / rel_col
432
+ if not local_dir.exists():
433
+ typer.echo(f"+ {rel_col}/ (new directory)")
434
+ if not dry_run:
435
+ local_dir.mkdir(parents=True, exist_ok=True)
436
+
437
+
438
+ def _delete_remote_extras(
439
+ client: ExistClient,
440
+ full_path: str,
441
+ local_files: set[str],
442
+ remote_resources: list[RemoteResource],
443
+ manifest: Manifest,
444
+ dry_run: bool,
445
+ ) -> int:
446
+ """Delete remote files that have no corresponding local file.
447
+
448
+ Args:
449
+ client: Active ExistClient.
450
+ full_path: Full eXist base path of the collection.
451
+ local_files: Relative paths of all local files.
452
+ remote_resources: All remote resources from the current listing.
453
+ manifest: Sync manifest (mutated in place on deletion).
454
+ dry_run: If True, log but do not delete.
455
+
456
+ Returns:
457
+ Number of files deleted (or that would be deleted).
458
+ """
459
+ count = 0
460
+ for resource in remote_resources:
461
+ if resource.rel_path not in local_files:
462
+ typer.echo(f"✗ {resource.rel_path} (deleted)")
463
+ if not dry_run:
464
+ client.delete_document(f"{full_path}/{resource.rel_path}")
465
+ manifest.pop(resource.rel_path)
466
+ count += 1
467
+ return count
468
+
469
+
470
+ def _delete_local_extras(
471
+ dest: Path,
472
+ remote_resources: list[RemoteResource],
473
+ manifest: Manifest,
474
+ dry_run: bool,
475
+ ) -> int:
476
+ """Delete local files that have no corresponding remote resource.
477
+
478
+ Args:
479
+ dest: Local destination directory.
480
+ remote_resources: All remote resources from the current listing.
481
+ manifest: Sync manifest (mutated in place on deletion).
482
+ dry_run: If True, log but do not delete.
483
+
484
+ Returns:
485
+ Number of files deleted (or that would be deleted).
486
+ """
487
+ remote_set = {r.rel_path for r in remote_resources}
488
+ count = 0
489
+ for local_file in sorted(dest.rglob("*")):
490
+ if not local_file.is_file():
491
+ continue
492
+ rel_path = local_file.relative_to(dest).as_posix()
493
+ if rel_path not in remote_set:
494
+ typer.echo(f"✗ {rel_path} (deleted)")
495
+ if not dry_run:
496
+ local_file.unlink()
497
+ manifest.pop(rel_path)
498
+ count += 1
499
+ return count
500
+
501
+
502
+ def _delete_empty_remote_dirs(
503
+ client: ExistClient,
504
+ full_path: str,
505
+ source: Path,
506
+ dry_run: bool,
507
+ ) -> int:
508
+ """Delete remote subcollections that are empty and have no local counterpart.
509
+
510
+ Re-fetches the remote tree so the check reflects the state after file
511
+ deletions. Processes deepest collections first so parents become empty
512
+ naturally as children are removed.
513
+
514
+ Args:
515
+ client: Active ExistClient.
516
+ full_path: Full eXist base path of the collection.
517
+ source: Local source directory (used to check for local counterparts).
518
+ dry_run: If True, log but do not delete.
519
+
520
+ Returns:
521
+ Number of collections deleted (or that would be deleted).
522
+ """
523
+ tree = _walk_remote(client, full_path)
524
+ resource_paths = {r.rel_path for r in tree.resources}
525
+ by_depth = sorted(tree.subcollections, key=lambda c: c.count("/"), reverse=True)
526
+ count = 0
527
+ for rel_col in by_depth:
528
+ if (source / rel_col).is_dir():
529
+ continue
530
+ if any(rp.startswith(f"{rel_col}/") or rp == rel_col for rp in resource_paths):
531
+ continue
532
+ typer.echo(f"✗ {rel_col}/ (empty collection deleted)")
533
+ if not dry_run:
534
+ client.delete_collection(f"{full_path}/{rel_col}")
535
+ count += 1
536
+ return count
537
+
538
+
539
+ def _delete_empty_local_dirs(dest: Path, dry_run: bool) -> int:
540
+ """Delete local subdirectories that are empty after file deletions.
541
+
542
+ Processes deepest directories first so parents become empty naturally
543
+ as children are removed.
544
+
545
+ Args:
546
+ dest: Local destination directory.
547
+ dry_run: If True, log but do not delete.
548
+
549
+ Returns:
550
+ Number of directories deleted (or that would be deleted).
551
+ """
552
+ dirs = [p for p in dest.rglob("*") if p.is_dir()]
553
+ by_depth = sorted(dirs, key=lambda p: len(p.parts), reverse=True)
554
+ count = 0
555
+ for d in by_depth:
556
+ if not any(d.iterdir()):
557
+ rel = d.relative_to(dest).as_posix()
558
+ typer.echo(f"✗ {rel}/ (empty directory deleted)")
559
+ if not dry_run:
560
+ d.rmdir()
561
+ count += 1
562
+ return count
563
+
564
+
565
+ def _print_summary(counts: dict[SyncAction, int]) -> None:
566
+ """Print the sync summary line.
567
+
568
+ Args:
569
+ counts: Map of SyncAction to the number of files that took that action.
570
+ """
571
+ parts = []
572
+ no_plural = {"conflict", "invalid xml"}
573
+ for action, label in [
574
+ (SyncAction.UPLOADED, "uploaded"),
575
+ (SyncAction.DOWNLOADED, "downloaded"),
576
+ (SyncAction.SKIPPED, "skipped"),
577
+ (SyncAction.CONFLICT, "conflict"),
578
+ (SyncAction.DELETED, "deleted"),
579
+ (SyncAction.INVALID, "invalid xml"),
580
+ ]:
581
+ n = counts.get(action, 0)
582
+ if n:
583
+ parts.append(f"{n} {label}{'s' if n != 1 and label not in no_plural else ''}")
584
+ typer.echo("---")
585
+ typer.echo(", ".join(parts) if parts else "nothing to do")
586
+
587
+
588
+ def _push(
589
+ source: Path, nick: str, path: str, force: bool, fail_fast: bool, dry_run: bool, delete: bool, checkpoint_every: int, verbose: bool
590
+ ) -> None:
591
+ """Push a local directory tree to a remote collection.
592
+
593
+ Args:
594
+ source: Local directory to push from.
595
+ nick: Collection nickname.
596
+ path: Remote path within the collection.
597
+ force: If True, upload all files regardless of manifest state.
598
+ fail_fast: If True, stop on the first conflict or XML validation failure (manifest is saved).
599
+ dry_run: If True, print actions without performing them.
600
+ delete: If True, remove remote files absent from the local tree.
601
+ checkpoint_every: Save the manifest after every N mutations (uploads/deletes).
602
+ verbose: If True, also print unchanged (skipped) files.
603
+ """
604
+ collection, server, full_path = resolve_collection(nick, path)
605
+ manifest = _load_manifest(nick, path, checkpoint_every)
606
+ counts: dict[SyncAction, int] = {}
607
+
608
+ with handle_exist_errors(path, nick, collection.server_nick):
609
+ with ExistClient(server) as client:
610
+ tree = _walk_remote(client, full_path)
611
+ remote_index = {r.rel_path: r.entry for r in tree.resources}
612
+
613
+ all_paths = list(source.rglob("*"))
614
+ local_files = sorted(p for p in all_paths if p.is_file())
615
+ local_dirs = {p.relative_to(source).as_posix() for p in all_paths if p.is_dir()}
616
+ local_file_rels = {p.relative_to(source).as_posix() for p in local_files}
617
+
618
+ _ensure_remote_dirs(client, full_path, local_dirs, tree.subcollections, dry_run)
619
+
620
+ total = len(local_files)
621
+ fail_fast_triggered = False
622
+ for i, local_file in enumerate(local_files, 1):
623
+ rel_path = local_file.relative_to(source).as_posix()
624
+ remote_mtime = remote_index[rel_path].last_modified or "" if rel_path in remote_index else ""
625
+ action = _push_file(client, full_path, local_file, rel_path, remote_mtime, manifest, force, dry_run)
626
+ pct = int(i / total * 100) if total else 100
627
+ prefix = f"[{pct:3d}%] "
628
+ labels = {
629
+ SyncAction.UPLOADED: f"{prefix}↑ {rel_path} ({'new' if rel_path not in remote_index else 'modified'})",
630
+ SyncAction.CONFLICT: f"{prefix}! {rel_path} (conflict: modified on both sides, skipping)",
631
+ SyncAction.INVALID: f"{prefix}! {rel_path} (not well-formed XML, skipping)",
632
+ }
633
+ if verbose:
634
+ labels[SyncAction.SKIPPED] = f"{prefix}= {rel_path} (unchanged)"
635
+ label = labels.get(action, "")
636
+ if label:
637
+ typer.echo(label)
638
+ counts[action] = counts.get(action, 0) + 1
639
+ manifest.maybe_save(nick, path)
640
+ if action in {SyncAction.INVALID, SyncAction.CONFLICT} and fail_fast:
641
+ fail_fast_triggered = True
642
+ break
643
+
644
+ if not fail_fast_triggered and delete:
645
+ counts[SyncAction.DELETED] = _delete_remote_extras(
646
+ client, full_path, local_file_rels, tree.resources, manifest, dry_run
647
+ )
648
+ counts[SyncAction.DELETED] += _delete_empty_remote_dirs(
649
+ client, full_path, source, dry_run
650
+ )
651
+
652
+ # Re-list to capture server-assigned mtimes after uploads
653
+ if not dry_run and counts.get(SyncAction.UPLOADED, 0):
654
+ updated_tree = _walk_remote(client, full_path)
655
+ for resource in updated_tree.resources:
656
+ if resource.rel_path in manifest:
657
+ manifest.get(resource.rel_path)["remote_last_modified"] = (
658
+ resource.entry.last_modified or ""
659
+ )
660
+
661
+ if not dry_run:
662
+ manifest.save(nick, path)
663
+ invalidate(nick)
664
+
665
+ _print_summary(counts)
666
+
667
+ if fail_fast_triggered:
668
+ raise typer.Exit(1)
669
+
670
+
671
+ def _pull(
672
+ nick: str, path: str, dest: Path, force: bool, dry_run: bool, delete: bool, checkpoint_every: int, verbose: bool
673
+ ) -> None:
674
+ """Pull a remote collection into a local directory.
675
+
676
+ Args:
677
+ nick: Collection nickname.
678
+ path: Remote path within the collection.
679
+ dest: Local directory to pull into.
680
+ force: If True, download all files regardless of manifest state.
681
+ dry_run: If True, print actions without performing them.
682
+ delete: If True, remove local files absent from the remote collection.
683
+ checkpoint_every: Save the manifest after every N mutations (downloads/deletes).
684
+ verbose: If True, also print unchanged (skipped) files.
685
+ """
686
+ collection, server, full_path = resolve_collection(nick, path)
687
+ manifest = _load_manifest(nick, path, checkpoint_every)
688
+ counts: dict[SyncAction, int] = {}
689
+
690
+ with handle_exist_errors(path, nick, collection.server_nick):
691
+ with ExistClient(server) as client:
692
+ tree = _walk_remote(client, full_path)
693
+
694
+ _ensure_local_dirs(dest, tree.subcollections, dry_run)
695
+
696
+ total = len(tree.resources)
697
+ for i, resource in enumerate(tree.resources, 1):
698
+ remote_mtime = resource.entry.last_modified or ""
699
+ is_new = resource.rel_path not in manifest
700
+ action = _pull_file(
701
+ client, full_path, dest, resource.rel_path, remote_mtime, manifest, force, dry_run
702
+ )
703
+ pct = int(i / total * 100) if total else 100
704
+ prefix = f"[{pct:3d}%] "
705
+ labels = {
706
+ SyncAction.DOWNLOADED: f"{prefix}↓ {resource.rel_path} ({'new' if is_new else 'modified'})",
707
+ SyncAction.CONFLICT: f"{prefix}! {resource.rel_path} (conflict: modified on both sides, skipping)",
708
+ }
709
+ if verbose:
710
+ labels[SyncAction.SKIPPED] = f"{prefix}= {resource.rel_path} (unchanged)"
711
+ label = labels.get(action, "")
712
+ if label:
713
+ typer.echo(label)
714
+ counts[action] = counts.get(action, 0) + 1
715
+ manifest.maybe_save(nick, path)
716
+
717
+ if delete:
718
+ counts[SyncAction.DELETED] = _delete_local_extras(
719
+ dest, tree.resources, manifest, dry_run
720
+ )
721
+ counts[SyncAction.DELETED] += _delete_empty_local_dirs(dest, dry_run)
722
+
723
+ if not dry_run:
724
+ manifest.save(nick, path)
725
+
726
+ _print_summary(counts)
727
+
728
+
729
+ def sync(
730
+ source: str = typer.Argument(
731
+ help="Source: local directory or ``<nick>[:<path>]``.",
732
+ autocompletion=collection_target_completer("collection", allow_local=True),
733
+ ),
734
+ dest: str = typer.Argument(
735
+ help="Destination: local directory or ``<nick>[:<path>]``.",
736
+ autocompletion=collection_target_completer("collection", allow_local=True),
737
+ ),
738
+ force: bool = typer.Option(False, "--force", "-f", help="Transfer all files, bypassing conflict detection."),
739
+ fail_fast: bool = typer.Option(False, "--fail-fast", help="Stop on the first conflict or XML validation failure (manifest is saved so the run can resume)."),
740
+ dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Show what would be transferred without doing it."),
741
+ delete: bool = typer.Option(False, "--delete", help="Remove destination files absent from the source."),
742
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show unchanged (skipped) files in addition to transfers."),
743
+ checkpoint_every: int = typer.Option(
744
+ 100, "--checkpoint-every", help="Save the sync manifest every N files so a failed run can resume."
745
+ ),
746
+ ) -> None:
747
+ """Sync a local folder and a remote collection, transferring only changed files."""
748
+ src_remote = is_remote(source)
749
+ dst_remote = is_remote(dest)
750
+
751
+ if src_remote and dst_remote:
752
+ typer.echo("Error: both source and destination are remote. Use cp for remote-to-remote copies.", err=True)
753
+ raise typer.Exit(1)
754
+ if not src_remote and not dst_remote:
755
+ typer.echo("Error: one of source or destination must be a remote collection (nick:path).", err=True)
756
+ raise typer.Exit(1)
757
+
758
+ if not src_remote:
759
+ source_path = Path(source)
760
+ if not source_path.is_dir():
761
+ typer.echo(f"Error: '{source}' is not a directory.", err=True)
762
+ raise typer.Exit(1)
763
+ nick, path = parse_target(dest, path_required=False)
764
+ _push(source_path, nick, path, force, fail_fast, dry_run, delete, checkpoint_every, verbose)
765
+ else:
766
+ nick, path = parse_target(source, path_required=False)
767
+ _pull(nick, path, Path(dest), force, dry_run, delete, checkpoint_every, verbose)