hlsync 1.18.7__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.

Potentially problematic release.


This version of hlsync might be problematic. Click here for more details.

hlsync/cli.py ADDED
@@ -0,0 +1,2966 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import shlex
6
+ import shutil
7
+ import sys
8
+ import time
9
+ from collections.abc import Callable, Sequence
10
+ from dataclasses import dataclass, replace
11
+ from pathlib import Path, PurePosixPath
12
+ from typing import TextIO, TypeVar
13
+
14
+ from hlsync import __version__
15
+ from hlsync.comparison import (
16
+ ComparisonEntry,
17
+ ComparisonPlan,
18
+ build_comparison,
19
+ mark_untraversed_directories,
20
+ )
21
+ from hlsync.config import (
22
+ DEFAULT_PASSWORD_ENV,
23
+ DEFAULT_USERNAME_ENV,
24
+ ApplicationConfiguration,
25
+ CommonRuleStore,
26
+ ConfigurationError,
27
+ ConfigurationStore,
28
+ ProfileConfiguration,
29
+ RuleUpdate,
30
+ canonical_local_root,
31
+ validate_profile_name,
32
+ )
33
+ from hlsync.gitignore import GitIgnores
34
+ from hlsync.pattern_operands import (
35
+ PatternOperandError,
36
+ add_pattern_operands,
37
+ normalize_pattern_operands,
38
+ )
39
+ from hlsync.progress import DirectoryReadProgress
40
+ from hlsync.rules import (
41
+ RuleError,
42
+ RuleSet,
43
+ SyncRule,
44
+ patterns_from_common_operands,
45
+ patterns_from_operands,
46
+ patterns_from_remote_operands,
47
+ )
48
+ from hlsync.selection import (
49
+ DirectoryContentsSelection,
50
+ FileSelection,
51
+ FileSelector,
52
+ FileSelectorSet,
53
+ SelectionError,
54
+ )
55
+ from hlsync.snapshot import (
56
+ SnapshotError,
57
+ TreeEntry,
58
+ TreeSnapshot,
59
+ list_local_directory,
60
+ snapshot_local,
61
+ )
62
+ from hlsync.transfer import (
63
+ TransferError,
64
+ TransferIssue,
65
+ TransferOperation,
66
+ TransferResult,
67
+ execute_transfer,
68
+ )
69
+ from hlsync.transport import ExplicitFTPSTransport, PathOperationError, TransportError
70
+
71
+ _EntryT = TypeVar("_EntryT")
72
+
73
+ _RESET = "\033[0m"
74
+ _DIRECTORY_COLOR = "\033[38;5;75m"
75
+ _EXCLUDED_DIRECTORY_COLOR = "\033[38;5;24m"
76
+ _COLLAPSED_DIRECTORY_COLOR = "\033[3;38;5;24m"
77
+ _DIFF_MARKER_COLORS = {
78
+ "+": "\033[38;5;82m",
79
+ "~": "\033[33m",
80
+ "-": "\033[31m",
81
+ "?": "\033[35m",
82
+ "x": "\033[90m",
83
+ "#": "\033[90m",
84
+ "r": "\033[38;5;30m",
85
+ "l": "\033[38;5;51m",
86
+ }
87
+ _TRANSFER_MARKERS = {
88
+ "add": "+",
89
+ "update": "~",
90
+ "delete": "-",
91
+ "create": "+",
92
+ }
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class _DiffTraversalRoot:
97
+ path: PurePosixPath
98
+ include_container: bool = False
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class _PendingDiffDirectory:
103
+ path: PurePosixPath
104
+ has_local: bool
105
+ has_remote: bool
106
+ include_container: bool
107
+ is_root: bool
108
+ display_root: PurePosixPath | None
109
+ display_anchor: bool
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class _PendingDiffOutput:
114
+ entry: ComparisonEntry
115
+ collapsed_paths: frozenset[str]
116
+ display_root: PurePosixPath | None
117
+
118
+
119
+ CANONICAL_COMMANDS = (
120
+ "create",
121
+ "connect",
122
+ "map",
123
+ "remove",
124
+ "root",
125
+ "profile",
126
+ "profiles",
127
+ "list",
128
+ "diff",
129
+ "push",
130
+ "pull",
131
+ "rules",
132
+ "exclude",
133
+ "include",
134
+ "help",
135
+ "version",
136
+ )
137
+ COMMAND_ALIASES = {"ls": "list", "lsr": "list"}
138
+ PROFILE_AWARE_COMMANDS = frozenset(
139
+ {
140
+ "connect",
141
+ "map",
142
+ "remove",
143
+ "root",
144
+ "profile",
145
+ "list",
146
+ "diff",
147
+ "push",
148
+ "pull",
149
+ "rules",
150
+ "exclude",
151
+ "include",
152
+ }
153
+ )
154
+
155
+
156
+ def _add_included_only_argument(parser: argparse.ArgumentParser) -> None:
157
+ parser.add_argument(
158
+ "-i",
159
+ "--inc",
160
+ "--included-only",
161
+ dest="included_only",
162
+ action="store_true",
163
+ help="show only included paths",
164
+ )
165
+
166
+
167
+ def _active_options(arguments: argparse.Namespace) -> str | None:
168
+ options: list[str] = []
169
+ if getattr(arguments, "remote", False):
170
+ options.append("remote (--remote)")
171
+ if getattr(arguments, "pull", False):
172
+ options.append("pull perspective (--pull)")
173
+ if getattr(arguments, "recursive", False):
174
+ options.append("recursive (-r)")
175
+ if getattr(arguments, "shallow", False):
176
+ options.append("shallow (-s)")
177
+ if getattr(arguments, "show_all", False):
178
+ options.append("all entries (-a)")
179
+ if getattr(arguments, "included_only", False):
180
+ options.append("included paths only (-i)")
181
+ if getattr(arguments, "show_excluded", False):
182
+ options.append("excluded paths (-x)")
183
+ if getattr(arguments, "include_excluded", False):
184
+ options.append("include excluded paths (-I)")
185
+ if getattr(arguments, "prune", False):
186
+ options.append("prune remote-only paths (-p)")
187
+ if getattr(arguments, "force", False):
188
+ options.append("overwrite newer destinations (--force)")
189
+ if getattr(arguments, "dry", False):
190
+ options.append("dry run (--dry)")
191
+ if getattr(arguments, "paged", False):
192
+ options.append("paged output (--paged)")
193
+ if getattr(arguments, "resume", None) is not None:
194
+ options.append(f"resume at {arguments.resume!r} (--resume)")
195
+ if not options:
196
+ return None
197
+ return f"Options: {'; '.join(options)}."
198
+
199
+
200
+ def _add_recursive_depth_arguments(parser: argparse.ArgumentParser) -> None:
201
+ depth = parser.add_mutually_exclusive_group()
202
+ depth.add_argument(
203
+ "-r", "--recursive", action="store_true",
204
+ help="recurse into selected directories (default)",
205
+ )
206
+ depth.add_argument(
207
+ "-s", "--shallow", action="store_true",
208
+ help="process immediate contents without entering child directories",
209
+ )
210
+
211
+
212
+ def _add_include_excluded_argument(parser: argparse.ArgumentParser) -> None:
213
+ parser.add_argument(
214
+ "-I", "--include-excluded", action="store_true",
215
+ help="treat locally excluded paths as included for this command",
216
+ )
217
+
218
+
219
+ def _resolve_command_name(
220
+ value: str,
221
+ *,
222
+ stdin: TextIO | None = None,
223
+ stdout: TextIO | None = None,
224
+ ) -> str:
225
+ if value in CANONICAL_COMMANDS:
226
+ return value
227
+ if value in COMMAND_ALIASES:
228
+ return COMMAND_ALIASES[value]
229
+ matches = tuple(
230
+ command for command in CANONICAL_COMMANDS if command.startswith(value)
231
+ )
232
+ if not matches:
233
+ raise ConfigurationError(f"unknown command '{value}'")
234
+ if len(matches) > 1:
235
+ is_terminal = getattr(stdin, "isatty", None)
236
+ if stdin is not None and stdout is not None and is_terminal and is_terminal():
237
+ print(f"'{value}' matches multiple commands:\n", file=stdout)
238
+ for index, command in enumerate(matches, start=1):
239
+ print(f" {index}. {command}", file=stdout)
240
+ while True:
241
+ print(
242
+ f"\nChoose a command [1-{len(matches)}]: ",
243
+ end="",
244
+ file=stdout,
245
+ flush=True,
246
+ )
247
+ answer = stdin.readline()
248
+ if answer == "":
249
+ raise ConfigurationError("command selection cancelled")
250
+ try:
251
+ selection = int(answer.strip())
252
+ except ValueError:
253
+ selection = 0
254
+ if 1 <= selection <= len(matches):
255
+ return matches[selection - 1]
256
+ print("Enter one of the listed numbers.", file=stdout)
257
+ raise ConfigurationError(
258
+ f"ambiguous command '{value}': {', '.join(matches)}"
259
+ )
260
+ return matches[0]
261
+
262
+
263
+ def _could_be_command(value: str) -> bool:
264
+ return value in COMMAND_ALIASES or any(
265
+ command.startswith(value) for command in CANONICAL_COMMANDS
266
+ )
267
+
268
+
269
+ def _resolve_invocation(
270
+ raw_arguments: list[str],
271
+ *,
272
+ stdin: TextIO,
273
+ stdout: TextIO,
274
+ ) -> tuple[list[str], str | None]:
275
+ if not raw_arguments or raw_arguments[0].startswith("-"):
276
+ return raw_arguments, None
277
+ first = raw_arguments[0]
278
+ if (
279
+ first not in CANONICAL_COMMANDS
280
+ and first not in COMMAND_ALIASES
281
+ and len(raw_arguments) > 1
282
+ and not raw_arguments[1].startswith("-")
283
+ and _could_be_command(raw_arguments[1])
284
+ ):
285
+ profile_name = validate_profile_name(first)
286
+ raw_arguments = raw_arguments[1:]
287
+ remote_list_alias = raw_arguments[0] == "lsr"
288
+ raw_arguments[0] = _resolve_command_name(
289
+ raw_arguments[0], stdin=stdin, stdout=stdout
290
+ )
291
+ if remote_list_alias:
292
+ raw_arguments.insert(1, "--remote")
293
+ return raw_arguments, profile_name
294
+ remote_list_alias = first == "lsr"
295
+ raw_arguments[0] = _resolve_command_name(first, stdin=stdin, stdout=stdout)
296
+ if remote_list_alias:
297
+ raw_arguments.insert(1, "--remote")
298
+ return raw_arguments, None
299
+
300
+
301
+ def _validate_profile_prefix(arguments: argparse.Namespace) -> None:
302
+ profile_name = getattr(arguments, "profile_prefix", None)
303
+ if profile_name is not None and getattr(arguments, "common_rules", False):
304
+ raise ConfigurationError("a profile prefix cannot be combined with --common")
305
+ if profile_name is not None and arguments.command not in PROFILE_AWARE_COMMANDS:
306
+ raise ConfigurationError(
307
+ f"profile prefix '{profile_name}' cannot be used with "
308
+ f"'{arguments.command}'"
309
+ )
310
+
311
+
312
+ def build_parser() -> argparse.ArgumentParser:
313
+ parser = argparse.ArgumentParser(
314
+ prog="hlsync",
315
+ description=(
316
+ "Transfer mapped files over explicit FTP over TLS. Run commands "
317
+ "inside a mapped profile; prefix a command with a profile only for "
318
+ "a one-command override. Commands may be shortened to any unique "
319
+ "prefix."
320
+ ),
321
+ )
322
+ parser.add_argument("-v", "--version", action="version", version=__version__)
323
+ parser.add_argument(
324
+ "--legend",
325
+ action="store_true",
326
+ help="show diff symbols and exit",
327
+ )
328
+ subparsers = parser.add_subparsers(dest="command")
329
+
330
+ create_parser = subparsers.add_parser(
331
+ "create",
332
+ help="create an FTPS profile with a mapped local root",
333
+ description=(
334
+ "Create an FTPS profile using environment-variable credentials. "
335
+ "Without --local-root, choose the current or another directory."
336
+ ),
337
+ )
338
+ create_parser.add_argument("profile_name", help="new profile name")
339
+ create_parser.add_argument("--host", required=True, help="FTPS server host")
340
+ create_parser.add_argument(
341
+ "--remote-root", required=True, help="absolute server directory"
342
+ )
343
+ create_parser.add_argument(
344
+ "--protocol",
345
+ choices=("ftps",),
346
+ default="ftps",
347
+ help="transfer protocol (default: ftps)",
348
+ )
349
+ create_parser.add_argument(
350
+ "--port", type=int, default=21, help="server port (default: 21)"
351
+ )
352
+ create_parser.add_argument(
353
+ "--username-env",
354
+ help=f"username environment variable (default: {DEFAULT_USERNAME_ENV})",
355
+ )
356
+ create_parser.add_argument(
357
+ "--password-env",
358
+ help=f"password environment variable (default: {DEFAULT_PASSWORD_ENV})",
359
+ )
360
+ create_parser.add_argument(
361
+ "--local-root",
362
+ metavar="PATH",
363
+ help=(
364
+ "local profile directory; when omitted, offer the current "
365
+ "directory and prompt for another if declined"
366
+ ),
367
+ )
368
+
369
+ connect_parser = subparsers.add_parser(
370
+ "connect",
371
+ help="verify a profile's FTPS connection",
372
+ usage="hlsync connect [PROFILE]\n hlsync PROFILE connect",
373
+ description=(
374
+ "Check secure FTPS connectivity, then disconnect. Infer the profile "
375
+ "from the current directory unless named."
376
+ ),
377
+ )
378
+ connect_parser.add_argument(
379
+ "profile_name", nargs="?", help="profile; inferred when omitted"
380
+ )
381
+
382
+ map_parser = subparsers.add_parser(
383
+ "map",
384
+ help="change a profile's local or remote root",
385
+ usage=(
386
+ "hlsync map [PROFILE] [--local-root PATH] [--remote-root PATH]\n"
387
+ " hlsync PROFILE map [--local-root PATH] [--remote-root PATH]"
388
+ ),
389
+ description=(
390
+ "Change mapped roots after a [y/N] confirmation. Omitted roots stay "
391
+ "unchanged; with neither option, use the current local directory."
392
+ ),
393
+ )
394
+ map_parser.add_argument(
395
+ "profile_name", nargs="?", help="profile; inferred when omitted"
396
+ )
397
+ map_parser.add_argument(
398
+ "--local-root",
399
+ metavar="PATH",
400
+ help="new local root; defaults to the current directory",
401
+ )
402
+ map_parser.add_argument(
403
+ "--remote-root",
404
+ metavar="PATH",
405
+ help="new absolute remote root; omitted leaves it unchanged",
406
+ )
407
+
408
+ rule_change_help = {
409
+ "exclude": "exclude paths from synchronization",
410
+ "include": "include paths, overriding broader exclusions",
411
+ }
412
+ for command in ("exclude", "include"):
413
+ rule_parser = subparsers.add_parser(
414
+ command,
415
+ help=rule_change_help[command],
416
+ usage=(
417
+ f"hlsync [PROFILE] {command} [--remote] "
418
+ "[--anywhere | --shallow] [--ext EXT] [-m] PATH\n"
419
+ f" hlsync {command} -c [--remote] "
420
+ "[--anywhere | --shallow] [--ext EXT] [-m] PATH"
421
+ ),
422
+ description=(
423
+ f"Record {command} rules for the current profile, or for every "
424
+ "profile with -c. List rules with 'hlsync rules'."
425
+ ),
426
+ )
427
+ add_pattern_operands(
428
+ rule_parser,
429
+ required=False,
430
+ metavar="PATH",
431
+ help_text=(
432
+ "a path, a quoted wildcard, or comma-separated paths; "
433
+ "wildcards also match in subfolders"
434
+ ),
435
+ )
436
+ rule_parser.add_argument(
437
+ "--remote",
438
+ action="store_true",
439
+ help="apply to protected remote paths",
440
+ )
441
+ rule_matching = rule_parser.add_mutually_exclusive_group()
442
+ rule_matching.add_argument(
443
+ "-s",
444
+ "--shallow",
445
+ action="store_true",
446
+ help="match wildcards in the named folder only, not its subfolders",
447
+ )
448
+ rule_matching.add_argument(
449
+ "--anywhere",
450
+ action="store_true",
451
+ help=(
452
+ "match names at any depth below the current directory; "
453
+ "-c: every profile"
454
+ ),
455
+ )
456
+ rule_parser.add_argument(
457
+ "-c",
458
+ "--common",
459
+ dest="common_rules",
460
+ action="store_true",
461
+ help="record a rule shared by every profile",
462
+ )
463
+ rule_parser.add_argument(
464
+ "--ext",
465
+ metavar="EXT",
466
+ help="match these comma-separated file extensions inside PATH",
467
+ )
468
+ rule_parser.add_argument(
469
+ "-m",
470
+ "--multiple",
471
+ action="store_true",
472
+ help="accept several space-separated paths",
473
+ )
474
+
475
+ rules_parser = subparsers.add_parser(
476
+ "rules",
477
+ help="list or remove synchronization rules",
478
+ usage=(
479
+ "hlsync [PROFILE] rules\n"
480
+ " hlsync [PROFILE] rules rm RULE_ID ...\n"
481
+ " hlsync rules -c [rm COMMON_RULE_ID ...]"
482
+ ),
483
+ description=(
484
+ "List rules, or remove them by their displayed IDs with rm. Use -c for "
485
+ "common rules. Add rules with 'hlsync exclude' and 'hlsync include'."
486
+ ),
487
+ )
488
+ rules_parser.add_argument(
489
+ "operation",
490
+ nargs="?",
491
+ choices=("rm",),
492
+ metavar="rm",
493
+ help="remove the rules with the displayed RULE_IDs",
494
+ )
495
+ rules_parser.add_argument("rule_ids", nargs="*", metavar="RULE_ID")
496
+ rules_parser.add_argument(
497
+ "-c",
498
+ "--common",
499
+ dest="common_rules",
500
+ action="store_true",
501
+ help="manage rules shared by every profile",
502
+ )
503
+ remove_parser = subparsers.add_parser(
504
+ "remove",
505
+ help="remove a profile",
506
+ usage="hlsync remove [PROFILE]\n hlsync PROFILE remove",
507
+ description=(
508
+ "Remove local profile configuration only; remote files stay "
509
+ "unchanged. Infer the profile when omitted."
510
+ ),
511
+ )
512
+ remove_parser.add_argument(
513
+ "profile_name", nargs="?", help="profile; inferred when omitted"
514
+ )
515
+
516
+ root_parser = subparsers.add_parser(
517
+ "root",
518
+ help="print a profile's mapped local root",
519
+ usage="hlsync root [PROFILE]\n hlsync PROFILE root",
520
+ description=(
521
+ "Print only the canonical local root, suitable for "
522
+ "cd \"$(hlsync root PROFILE)\"."
523
+ ),
524
+ )
525
+ root_parser.add_argument(
526
+ "profile_name",
527
+ nargs="?",
528
+ metavar="PROFILE",
529
+ help="profile name; inferred from the current directory when omitted",
530
+ )
531
+
532
+ profile_parser = subparsers.add_parser(
533
+ "profile",
534
+ help="show the current profile or detailed profile information",
535
+ usage=(
536
+ "hlsync profile [PROFILE] [--details]\n"
537
+ " hlsync PROFILE profile [--details]"
538
+ ),
539
+ description=(
540
+ "Print the inferred or named profile. --details adds its "
541
+ "connection, mapping, credentials, and rules."
542
+ ),
543
+ )
544
+ profile_parser.add_argument(
545
+ "profile_name",
546
+ nargs="?",
547
+ metavar="PROFILE",
548
+ help="profile name; inferred from the current directory when omitted",
549
+ )
550
+ profile_parser.add_argument(
551
+ "--details",
552
+ dest="profile_details",
553
+ action="store_true",
554
+ help="show endpoint, mapping, credentials, and rule count",
555
+ )
556
+
557
+ subparsers.add_parser(
558
+ "profiles",
559
+ help="list configured profiles",
560
+ description="List profiles; * marks the one containing this directory.",
561
+ )
562
+
563
+ list_parser = subparsers.add_parser(
564
+ "list",
565
+ aliases=("lsr",),
566
+ help="list the current local or remote directory",
567
+ usage=(
568
+ "hlsync [PROFILE] list [--remote] [-r] [-i] [PATH ...]\n"
569
+ " hlsync [PROFILE] lsr [-r] [-i] [PATH ...]"
570
+ ),
571
+ description=(
572
+ "With no PATH, list the current directory one level. A directory "
573
+ "PATH is also shallow unless -r. Use --remote/lsr for remote and "
574
+ "-i to hide exclusions."
575
+ ),
576
+ )
577
+ add_pattern_operands(
578
+ list_parser,
579
+ required=False,
580
+ metavar="PATH",
581
+ help_text=(
582
+ "relative paths or wildcards; default: current directory"
583
+ ),
584
+ )
585
+ list_parser.add_argument(
586
+ "--remote",
587
+ action="store_true",
588
+ help="list the mapped remote directory over FTPS",
589
+ )
590
+ list_parser.add_argument(
591
+ "-r",
592
+ "--recursive",
593
+ action="store_true",
594
+ help="include descendants of the selected directories",
595
+ )
596
+ _add_included_only_argument(list_parser)
597
+
598
+ diff_parser = subparsers.add_parser(
599
+ "diff",
600
+ help="preview file changes without modifying anything",
601
+ usage=(
602
+ "hlsync [PROFILE] diff [PATH ...]\n"
603
+ " [--pull | --prune] [-r | -s] [-a] [-x] [-I] [--paged]\n"
604
+ " [--resume DIRECTORY]"
605
+ ),
606
+ description=(
607
+ "With no PATH, preview the current subtree recursively. Directory "
608
+ "PATHs are also recursive; use -s for immediate contents only. "
609
+ "Use --pull to reverse direction, "
610
+ "-x to show exclusions, -I to treat them as included, and -a to "
611
+ "show everything."
612
+ ),
613
+ )
614
+ add_pattern_operands(
615
+ diff_parser,
616
+ required=False,
617
+ metavar="PATH",
618
+ help_text=(
619
+ "relative paths or wildcards; default: current directory"
620
+ ),
621
+ )
622
+ diff_direction = diff_parser.add_mutually_exclusive_group()
623
+ diff_direction.add_argument(
624
+ "--pull",
625
+ action="store_true",
626
+ help="show changes from the remote perspective without remote deletion",
627
+ )
628
+ _add_recursive_depth_arguments(diff_parser)
629
+ _add_include_excluded_argument(diff_parser)
630
+ diff_parser.add_argument(
631
+ "-x",
632
+ "--excluded",
633
+ dest="show_excluded",
634
+ action="store_true",
635
+ help="also show excluded paths",
636
+ )
637
+ diff_parser.add_argument(
638
+ "-a",
639
+ "--all",
640
+ dest="show_all",
641
+ action="store_true",
642
+ help="show every compared path, including unchanged and kept ones",
643
+ )
644
+ diff_parser.add_argument(
645
+ "--paged",
646
+ action="store_true",
647
+ help="show one directory, then exit with a resume command",
648
+ )
649
+ diff_parser.add_argument(
650
+ "--resume",
651
+ metavar="DIRECTORY",
652
+ help="resume a paged diff at a profile-relative directory",
653
+ )
654
+ diff_direction.add_argument(
655
+ "-p",
656
+ "--prune",
657
+ action="store_true",
658
+ help="show remote-only paths as deleted, as push --prune would",
659
+ )
660
+ transfer_help = {
661
+ "push": "upload local changes to the remote profile",
662
+ "pull": "replace changed local files from the remote profile",
663
+ }
664
+ transfer_description = {
665
+ "push": (
666
+ "Push local changes. With no PATH, push the current subtree "
667
+ "recursively. Directory PATHs are also recursive; use -s for "
668
+ "immediate contents only. Remote-only paths are kept unless "
669
+ "-p is given, and a remote file is replaced only when the local "
670
+ "copy is newer unless --force is given. Use --dry to "
671
+ "preview the exact push without changing either side."
672
+ ),
673
+ "pull": (
674
+ "Pull requires a PATH. A directory PATH is shallow unless -r; "
675
+ "missing local paths stay missing. A local file is replaced only "
676
+ "when the remote copy is newer unless --force is given."
677
+ ),
678
+ }
679
+ for command in ("push", "pull"):
680
+ transfer_parser = subparsers.add_parser(
681
+ command,
682
+ help=transfer_help[command],
683
+ description=transfer_description[command],
684
+ usage=(
685
+ (
686
+ "hlsync [PROFILE] pull PATH [PATH ...] [-r] [-I] [--force]"
687
+ if command == "pull"
688
+ else "hlsync [PROFILE] push [PATH ...] [-r | -s] [-I]"
689
+ )
690
+ + (" [-p] [--force] [--dry]" if command == "push" else "")
691
+ ),
692
+ )
693
+ _add_include_excluded_argument(transfer_parser)
694
+ add_pattern_operands(
695
+ transfer_parser,
696
+ required=command == "pull",
697
+ metavar="PATH",
698
+ help_text=(
699
+ "relative paths or wildcards; default: current subtree"
700
+ if command == "push"
701
+ else "relative paths or wildcards"
702
+ ),
703
+ )
704
+ if command == "push":
705
+ _add_recursive_depth_arguments(transfer_parser)
706
+ transfer_parser.add_argument(
707
+ "-p",
708
+ "--prune",
709
+ action="store_true",
710
+ help="delete selected remote-only paths",
711
+ )
712
+ transfer_parser.add_argument(
713
+ "--dry",
714
+ action="store_true",
715
+ help="show the exact push without changing either side",
716
+ )
717
+ else:
718
+ transfer_parser.add_argument(
719
+ "-r", "--recursive", action="store_true",
720
+ help="recurse into selected directories",
721
+ )
722
+ transfer_parser.add_argument(
723
+ "--force",
724
+ action="store_true",
725
+ help="overwrite destination files that are not older than the source",
726
+ )
727
+
728
+ help_parser = subparsers.add_parser("help", help="show command help")
729
+ help_parser.add_argument("topic", nargs="?")
730
+
731
+ subparsers.add_parser("version", help="show the installed version")
732
+ return parser
733
+
734
+
735
+ def _confirm(
736
+ prompt: str,
737
+ stdin: TextIO,
738
+ stdout: TextIO,
739
+ *,
740
+ default: bool,
741
+ ) -> bool:
742
+ while True:
743
+ print(prompt, end="", file=stdout, flush=True)
744
+ answer = stdin.readline()
745
+ if answer == "":
746
+ raise ConfigurationError("confirmation requires yes or no")
747
+ normalized = answer.strip().lower()
748
+ if normalized in {"y", "yes"}:
749
+ return True
750
+ if normalized in {"n", "no"}:
751
+ return False
752
+ if normalized == "":
753
+ return default
754
+ print("Please answer yes or no.", file=stdout)
755
+
756
+
757
+ def _create_profile(
758
+ arguments: argparse.Namespace,
759
+ store: ConfigurationStore,
760
+ stdin: TextIO,
761
+ stdout: TextIO,
762
+ ) -> str:
763
+ name = validate_profile_name(arguments.profile_name)
764
+ configuration = store.load()
765
+ if name in configuration.profiles:
766
+ raise ConfigurationError(f"profile '{name}' already exists")
767
+ profile = ProfileConfiguration(
768
+ type=arguments.protocol,
769
+ host=arguments.host,
770
+ remote_root=arguments.remote_root,
771
+ port=arguments.port,
772
+ username_env=(
773
+ arguments.username_env
774
+ if arguments.username_env is not None
775
+ else DEFAULT_USERNAME_ENV
776
+ ),
777
+ password_env=(
778
+ arguments.password_env
779
+ if arguments.password_env is not None
780
+ else DEFAULT_PASSWORD_ENV
781
+ ),
782
+ )
783
+ configuration.profiles[name] = profile
784
+ if arguments.local_root is not None:
785
+ local_root = canonical_local_root(arguments.local_root)
786
+ else:
787
+ current_root = canonical_local_root(Path.cwd())
788
+ prompt = (
789
+ f"Map current directory '{current_root}' to "
790
+ f"'{name}:{profile.remote_root}'? [Y/n] "
791
+ )
792
+ if _confirm(prompt, stdin, stdout, default=True):
793
+ local_root = current_root
794
+ else:
795
+ print(
796
+ "What local folder should be mapped instead? ",
797
+ end="",
798
+ file=stdout,
799
+ flush=True,
800
+ )
801
+ supplied_root = stdin.readline()
802
+ if supplied_root == "" or not supplied_root.strip():
803
+ raise ConfigurationError("a local folder is required")
804
+ local_root = canonical_local_root(supplied_root.strip())
805
+ configuration.set_profile_roots(
806
+ name,
807
+ local_root=local_root,
808
+ remote_root=profile.remote_root,
809
+ )
810
+ message = (
811
+ f"Created FTPS profile '{name}'.\n"
812
+ f"Mapped '{local_root}' to '{name}:{profile.remote_root}'."
813
+ )
814
+ _common_rule_store(store).load()
815
+ store.save(configuration)
816
+ return message
817
+
818
+
819
+ def _common_rule_store(store: ConfigurationStore) -> CommonRuleStore:
820
+ return CommonRuleStore(store.path.with_name("rules.json"))
821
+
822
+
823
+ def _effective_rules(
824
+ store: ConfigurationStore,
825
+ profile: ProfileConfiguration,
826
+ ) -> RuleSet:
827
+ common_rules = _common_rule_store(store).load().rules
828
+ return RuleSet.layered(
829
+ common_rules,
830
+ profile.rules,
831
+ gitignores=GitIgnores(Path(profile.local_root)) if profile.local_root else None,
832
+ )
833
+
834
+
835
+ def _command_rules(
836
+ arguments: argparse.Namespace,
837
+ store: ConfigurationStore,
838
+ profile: ProfileConfiguration,
839
+ ) -> RuleSet:
840
+ rules = _effective_rules(store, profile)
841
+ if not arguments.include_excluded:
842
+ return rules
843
+ return replace(
844
+ rules,
845
+ rules=tuple(rule for rule in rules.rules if rule.target == "remote"),
846
+ gitignores=None,
847
+ )
848
+
849
+
850
+ def _resolve_profile(
851
+ arguments: argparse.Namespace, store: ConfigurationStore
852
+ ) -> tuple[ApplicationConfiguration, str, ProfileConfiguration]:
853
+ configuration = store.load()
854
+ prefix_name = getattr(arguments, "profile_prefix", None)
855
+ positional_name = getattr(arguments, "profile_name", None)
856
+ if prefix_name is not None and positional_name is not None:
857
+ raise ConfigurationError(
858
+ "profile was specified both before the command and as an argument"
859
+ )
860
+ supplied_name = prefix_name or positional_name
861
+ if supplied_name is None:
862
+ active = configuration.profile_for_path(Path.cwd().resolve(strict=True))
863
+ if active is None:
864
+ raise ConfigurationError(
865
+ "current directory is not inside a mapped profile; prefix the "
866
+ "command with a profile (hlsync PROFILE COMMAND)"
867
+ )
868
+ name, profile = active
869
+ return configuration, name, profile
870
+ name = validate_profile_name(supplied_name)
871
+ if name not in configuration.profiles:
872
+ raise ConfigurationError(f"profile '{name}' does not exist")
873
+ return configuration, name, configuration.profiles[name]
874
+
875
+
876
+ def _effective_current_directory(
877
+ arguments: argparse.Namespace,
878
+ profile_root: Path,
879
+ ) -> Path:
880
+ if getattr(arguments, "profile_prefix", None) is not None:
881
+ return profile_root.resolve(strict=True)
882
+ return Path.cwd().resolve(strict=True)
883
+
884
+
885
+ def _connect(arguments: argparse.Namespace, store: ConfigurationStore) -> str:
886
+ _, name, profile = _resolve_profile(arguments, store)
887
+ with ExplicitFTPSTransport(profile):
888
+ pass
889
+ return f"Verified secure connectivity to profile '{name}'."
890
+
891
+
892
+ def _map(
893
+ arguments: argparse.Namespace,
894
+ store: ConfigurationStore,
895
+ stdin: TextIO,
896
+ stdout: TextIO,
897
+ ) -> str:
898
+ configuration, name, profile = _resolve_profile(arguments, store)
899
+ if arguments.local_root is not None:
900
+ local_root = canonical_local_root(arguments.local_root)
901
+ elif arguments.remote_root is None or profile.local_root is None:
902
+ local_root = canonical_local_root(Path.cwd())
903
+ else:
904
+ local_root = profile.local_root
905
+ remote_root = (
906
+ arguments.remote_root
907
+ if arguments.remote_root is not None
908
+ else profile.remote_root
909
+ )
910
+ candidate = profile.with_roots(
911
+ local_root=local_root,
912
+ remote_root=remote_root,
913
+ )
914
+ changes = []
915
+ if profile.local_root != candidate.local_root:
916
+ changes.append(
917
+ f" Local root: {profile.local_root or 'not mapped'} "
918
+ f"→ {candidate.local_root}"
919
+ )
920
+ if profile.remote_root != candidate.remote_root:
921
+ changes.append(
922
+ f" Remote root: {profile.remote_root} → {candidate.remote_root}"
923
+ )
924
+ if not changes:
925
+ return f"Profile '{name}' mapping is unchanged."
926
+
927
+ configuration.set_profile_roots(
928
+ name,
929
+ local_root=candidate.local_root,
930
+ remote_root=candidate.remote_root,
931
+ )
932
+ prompt = "\n".join(
933
+ (f"Change mapping for profile '{name}'?", *changes, "Continue? [y/N] ")
934
+ )
935
+ if not _confirm(prompt, stdin, stdout, default=False):
936
+ return f"Kept profile '{name}' mapping unchanged."
937
+ store.save(configuration)
938
+ return "\n".join((f"Updated profile '{name}' mapping:", *changes))
939
+
940
+
941
+ def _format_rules(
942
+ name: str,
943
+ rules: tuple[SyncRule, ...],
944
+ *,
945
+ heading: str,
946
+ common_scope: bool = False,
947
+ ) -> str:
948
+ scope_heading = (
949
+ f"Common {heading.lower()}"
950
+ if common_scope
951
+ else f"{heading} for profile '{name}'"
952
+ )
953
+ if not rules:
954
+ return f"No {scope_heading.lower()}."
955
+ identifiers = {
956
+ rule.id: _format_rule_id(rule, common_scope=common_scope) for rule in rules
957
+ }
958
+ width = max(len(identifier) for identifier in identifiers.values())
959
+ return "\n".join(
960
+ (
961
+ f"{scope_heading}:",
962
+ *(
963
+ f" {identifiers[rule.id]:>{width}} {rule.action:<7} "
964
+ f"{_format_rule_expression(rule)}"
965
+ for rule in rules
966
+ ),
967
+ )
968
+ )
969
+
970
+
971
+ COMMON_RULES_HEADING = "Common rules (all profiles):"
972
+
973
+
974
+ def _format_rule_sections(
975
+ sections: tuple[tuple[str, tuple[SyncRule, ...], bool], ...],
976
+ ) -> str:
977
+ width = max(
978
+ (
979
+ len(_format_rule_id(rule, common_scope=common_scope))
980
+ for _, rules, common_scope in sections
981
+ for rule in rules
982
+ ),
983
+ default=0,
984
+ )
985
+ return "\n\n".join(
986
+ _format_rule_section(heading, rules, common_scope=common_scope, width=width)
987
+ for heading, rules, common_scope in sections
988
+ )
989
+
990
+
991
+ def _format_rule_section(
992
+ heading: str,
993
+ rules: tuple[SyncRule, ...],
994
+ *,
995
+ common_scope: bool,
996
+ width: int,
997
+ ) -> str:
998
+ if not rules:
999
+ return f"{heading}\n (none)"
1000
+ populated_targets = tuple(
1001
+ (label, tuple(rule for rule in rules if rule.target == target))
1002
+ for label, target in (("Local paths", "local"), ("Remote paths", "remote"))
1003
+ if any(rule.target == target for rule in rules)
1004
+ )
1005
+ show_target_headings = len(populated_targets) > 1 or (
1006
+ populated_targets[0][0] == "Remote paths"
1007
+ )
1008
+ lines = [heading]
1009
+ for target_heading, target_rules in populated_targets:
1010
+ group_indent = " "
1011
+ if show_target_headings:
1012
+ lines.append(f" {target_heading}:")
1013
+ group_indent = " "
1014
+ groups: dict[str, list[tuple[SyncRule, str]]] = {}
1015
+ for rule in target_rules:
1016
+ group, expression = _rule_display_location(rule)
1017
+ groups.setdefault(group, []).append((rule, expression))
1018
+ for group in sorted(groups, key=_rule_group_key):
1019
+ lines.append(f"{group_indent}{group}")
1020
+ for rule, expression in sorted(
1021
+ groups[group],
1022
+ key=lambda item: (item[1].casefold(), item[1], item[0].id),
1023
+ ):
1024
+ identifier = _format_rule_id(rule, common_scope=common_scope)
1025
+ lines.append(
1026
+ f"{group_indent} {identifier:>{width}} "
1027
+ f"{rule.action:<7} {expression}"
1028
+ )
1029
+ return "\n".join(lines)
1030
+
1031
+
1032
+ def _rule_display_location(rule: SyncRule) -> tuple[str, str]:
1033
+ parts = PurePosixPath(rule.pattern).parts
1034
+ wildcard_index = next(
1035
+ (index for index, part in enumerate(parts) if "*" in part),
1036
+ None,
1037
+ )
1038
+ if wildcard_index is None:
1039
+ return _rule_group_label(parts[:-1]), parts[-1]
1040
+ if parts[0] == "**":
1041
+ return "Everywhere", _describe_wildcards(parts, everywhere=True)
1042
+ return _rule_group_label(parts[:wildcard_index]), _describe_wildcards(
1043
+ parts[wildcard_index:], everywhere=False
1044
+ )
1045
+
1046
+
1047
+ def _describe_wildcards(parts: tuple[str, ...], *, everywhere: bool) -> str:
1048
+ if parts == ("**",):
1049
+ return "all contents"
1050
+ deep = parts[0] == "**"
1051
+ body = parts[1:] if deep else parts
1052
+ tree = len(body) > 1 and body[-1] == "**"
1053
+ if tree:
1054
+ body = body[:-1]
1055
+ text = "/".join(body) + ("/" if tree else "")
1056
+ if everywhere or "**" in body:
1057
+ return text
1058
+ return f"{text} (all subfolders)" if deep else f"{text} (this folder only)"
1059
+
1060
+
1061
+ def _rule_group_label(parts: tuple[str, ...]) -> str:
1062
+ return "./" if not parts else f"{PurePosixPath(*parts).as_posix()}/"
1063
+
1064
+
1065
+ def _rule_group_key(group: str) -> tuple[int, str, str]:
1066
+ if group == "./":
1067
+ return (0, "", "")
1068
+ if group == "Everywhere":
1069
+ return (2, "", "")
1070
+ return (1, group.casefold(), group)
1071
+
1072
+
1073
+ def _rules_need_precedence_note(rules: tuple[SyncRule, ...]) -> bool:
1074
+ return any(
1075
+ len({rule.action for rule in rules if rule.target == target}) > 1
1076
+ for target in ("local", "remote")
1077
+ )
1078
+
1079
+
1080
+ def _format_rule_expression(rule: SyncRule) -> str:
1081
+ if "*" not in rule.pattern:
1082
+ return f"./{rule.pattern}"
1083
+ group, expression = _rule_display_location(rule)
1084
+ if group == "Everywhere":
1085
+ return f"{expression} (everywhere)"
1086
+ if expression == "all contents":
1087
+ return f"{group} (all contents)"
1088
+ return f"{group}{expression}"
1089
+
1090
+
1091
+ def _format_rule_id(rule: SyncRule, *, common_scope: bool) -> str:
1092
+ return f"c{rule.id}" if common_scope else str(rule.id)
1093
+
1094
+
1095
+ def _parse_rule_id(value: str, *, common_scope: bool) -> int:
1096
+ identifier = value[1:] if common_scope and value.startswith("c") else value
1097
+ valid_prefix = not common_scope or value.startswith("c")
1098
+ if not valid_prefix or not identifier.isdigit() or int(identifier) < 1:
1099
+ expected = "c-prefixed (for example c3)" if common_scope else "numeric"
1100
+ raise ConfigurationError(f"rule id must be {expected} in this scope")
1101
+ return int(identifier)
1102
+
1103
+
1104
+ def _change_rules(
1105
+ arguments: argparse.Namespace,
1106
+ store: ConfigurationStore,
1107
+ *,
1108
+ include: bool,
1109
+ ) -> str:
1110
+ if len(arguments.pattern_operands) > 1 and not arguments.multiple:
1111
+ raise ConfigurationError("got multiple paths; quote wildcards or add -m")
1112
+ patterns = normalize_pattern_operands(arguments)
1113
+ if arguments.ext is not None:
1114
+ patterns = _extension_patterns(patterns or (".",), arguments.ext)
1115
+ target = "remote" if arguments.remote else "local"
1116
+ if not patterns:
1117
+ raise ConfigurationError(f"{arguments.command} requires at least one path")
1118
+ if not arguments.anywhere and not arguments.shallow:
1119
+ patterns = tuple(_reach_subfolders(pattern) for pattern in patterns)
1120
+ if arguments.anywhere:
1121
+ for pattern in patterns:
1122
+ path = PurePosixPath(pattern)
1123
+ if path.is_absolute() or ".." in path.parts or not path.parts:
1124
+ raise ConfigurationError(
1125
+ "--anywhere requires relative names or patterns"
1126
+ )
1127
+ patterns = tuple(f"**/{pattern}" for pattern in patterns)
1128
+ if arguments.common_rules:
1129
+ common_store = _common_rule_store(store)
1130
+ configuration = common_store.load()
1131
+ normalized = patterns_from_common_operands(
1132
+ patterns,
1133
+ trailing_slash_tree=not arguments.remote,
1134
+ )
1135
+ configuration, update = configuration.with_appended_rules(
1136
+ "include" if include else "exclude",
1137
+ normalized,
1138
+ target=target,
1139
+ )
1140
+ common_store.save(configuration)
1141
+ return _format_rule_update(
1142
+ "",
1143
+ update,
1144
+ include=include,
1145
+ common_scope=True,
1146
+ target=target,
1147
+ )
1148
+ configuration, name, profile = _resolve_profile(arguments, store)
1149
+ root = _require_local_root(name, profile)
1150
+ current_directory = _effective_current_directory(arguments, root)
1151
+ normalizer = (
1152
+ patterns_from_remote_operands
1153
+ if arguments.remote
1154
+ else patterns_from_operands
1155
+ )
1156
+ normalized = normalizer(
1157
+ patterns, profile_root=root, current_directory=current_directory
1158
+ )
1159
+ update = configuration.append_rules(
1160
+ name,
1161
+ "include" if include else "exclude",
1162
+ normalized,
1163
+ target=target,
1164
+ base_rules=_common_rule_store(store).load().rules,
1165
+ )
1166
+ store.save(configuration)
1167
+ return _format_rule_update(name, update, include=include, target=target)
1168
+
1169
+
1170
+ def _extension_patterns(
1171
+ folders: tuple[str, ...], extensions: str
1172
+ ) -> tuple[str, ...]:
1173
+ names = tuple(extension.strip().lstrip(".") for extension in extensions.split(","))
1174
+ if any(not name or "/" in name or "*" in name for name in names):
1175
+ raise ConfigurationError("--ext takes extensions such as jpg,png,webp")
1176
+ if any("*" in folder for folder in folders):
1177
+ raise ConfigurationError("--ext takes a folder, not a wildcard")
1178
+ return tuple(
1179
+ f"{folder.rstrip('/') or '.'}/*.{name}" for folder in folders for name in names
1180
+ )
1181
+
1182
+
1183
+ def _reach_subfolders(pattern: str) -> str:
1184
+ path = PurePosixPath(pattern)
1185
+ if "*" not in pattern or "**" in path.parts or not path.parts:
1186
+ return pattern
1187
+ tree = "/" if pattern.endswith("/") else ""
1188
+ return PurePosixPath(*path.parts[:-1], "**", path.parts[-1]).as_posix() + tree
1189
+
1190
+
1191
+ def _format_rule_update(
1192
+ name: str,
1193
+ update: RuleUpdate,
1194
+ *,
1195
+ include: bool,
1196
+ common_scope: bool = False,
1197
+ target: str = "local",
1198
+ ) -> str:
1199
+ scope = "common synchronization policy" if common_scope else f"profile '{name}'"
1200
+ if update.added and update.removed:
1201
+ lines = [f"Updated synchronization rules for {scope}:"]
1202
+ lines.extend(
1203
+ f" added {_format_rule_id(rule, common_scope=common_scope)} "
1204
+ f"{rule.action:<7} {_format_rule_expression(rule)}"
1205
+ for rule in update.added
1206
+ )
1207
+ lines.extend(
1208
+ f" removed {_format_rule_id(rule, common_scope=common_scope)} "
1209
+ f"{rule.action:<7} {_format_rule_expression(rule)}"
1210
+ for rule in update.removed
1211
+ )
1212
+ return "\n".join(lines)
1213
+ if update.added:
1214
+ action = "Inclusion" if include else "Exclusion"
1215
+ if target == "remote":
1216
+ action = f"Remote {action.lower()}"
1217
+ if common_scope:
1218
+ identifiers = tuple(
1219
+ _format_rule_id(rule, common_scope=True) for rule in update.added
1220
+ )
1221
+ width = max(len(identifier) for identifier in identifiers)
1222
+ return "\n".join(
1223
+ (
1224
+ f"Recorded common {action.lower()} rules:",
1225
+ *(
1226
+ f" {identifier:>{width}} {rule.action:<7} "
1227
+ f"{_format_rule_expression(rule)}"
1228
+ for rule, identifier in zip(
1229
+ update.added, identifiers, strict=True
1230
+ )
1231
+ ),
1232
+ )
1233
+ )
1234
+ return _format_rules(
1235
+ name,
1236
+ update.added,
1237
+ heading=f"Recorded {action.lower()} rules",
1238
+ )
1239
+ if update.removed:
1240
+ result = "included" if include else "excluded"
1241
+ if target == "remote":
1242
+ result = f"remotely {result}"
1243
+ remaining_scope = (
1244
+ "common synchronization policy"
1245
+ if common_scope
1246
+ else f"policy for profile '{name}'"
1247
+ )
1248
+ lines = [
1249
+ f"Paths are {result} by the remaining {remaining_scope};",
1250
+ "removed the unnecessary rules:",
1251
+ ]
1252
+ identifiers = tuple(
1253
+ _format_rule_id(rule, common_scope=common_scope)
1254
+ for rule in update.removed
1255
+ )
1256
+ width = max(len(identifier) for identifier in identifiers)
1257
+ lines.extend(
1258
+ f" {identifier:>{width}} {rule.action:<7} "
1259
+ f"{_format_rule_expression(rule)}"
1260
+ for rule, identifier in zip(update.removed, identifiers, strict=True)
1261
+ )
1262
+ return "\n".join(lines)
1263
+ return f"Synchronization rules for {scope} are unchanged."
1264
+
1265
+
1266
+ def _manage_rules(
1267
+ arguments: argparse.Namespace,
1268
+ store: ConfigurationStore,
1269
+ ) -> str:
1270
+ identifiers = tuple(
1271
+ dict.fromkeys(
1272
+ value.strip()
1273
+ for operand in arguments.rule_ids
1274
+ for value in operand.split(",")
1275
+ )
1276
+ )
1277
+ if arguments.operation == "rm" and not identifiers:
1278
+ raise ConfigurationError("rules rm requires a RULE_ID")
1279
+ if "" in identifiers:
1280
+ raise ConfigurationError("rule ids cannot be empty")
1281
+ if not identifiers:
1282
+ return _list_rules(arguments, store)
1283
+ common_ids = tuple(
1284
+ _parse_rule_id(value, common_scope=True)
1285
+ for value in identifiers
1286
+ if arguments.common_rules or value.startswith("c")
1287
+ )
1288
+ profile_ids = tuple(
1289
+ _parse_rule_id(value, common_scope=False)
1290
+ for value in identifiers
1291
+ if not (arguments.common_rules or value.startswith("c"))
1292
+ )
1293
+ reports = []
1294
+ common_store = _common_rule_store(store)
1295
+ common_configuration = common_store.load()
1296
+ if common_ids:
1297
+ common_configuration, removed = common_configuration.without_rules(common_ids)
1298
+ reports.append(
1299
+ _format_rules("", removed, heading="Rules removed", common_scope=True)
1300
+ )
1301
+ if profile_ids:
1302
+ configuration, name, _ = _resolve_profile(arguments, store)
1303
+ removed = configuration.remove_rules(name, profile_ids)
1304
+ reports.append(_format_rules(name, removed, heading="Rules removed"))
1305
+ store.save(configuration)
1306
+ if common_ids:
1307
+ common_store.save(common_configuration)
1308
+ return "\n".join(reports)
1309
+
1310
+
1311
+ def _list_rules(arguments: argparse.Namespace, store: ConfigurationStore) -> str:
1312
+ common_rules = _common_rule_store(store).load().rules
1313
+ if arguments.common_rules:
1314
+ formatted = _format_rule_sections(((COMMON_RULES_HEADING, common_rules, True),))
1315
+ if _rules_need_precedence_note(common_rules):
1316
+ return f"{formatted}\nLater rules win."
1317
+ return formatted
1318
+ _, name, profile = _resolve_profile(arguments, store)
1319
+ overview = _format_rule_sections(
1320
+ (
1321
+ (COMMON_RULES_HEADING, common_rules, True),
1322
+ (f"Profile rules ('{name}' only):", profile.rules, False),
1323
+ )
1324
+ )
1325
+ return (
1326
+ f"{overview}\n\n"
1327
+ "Profile rules override common rules; later matching rules win."
1328
+ )
1329
+
1330
+
1331
+ def _remove(arguments: argparse.Namespace, store: ConfigurationStore) -> str:
1332
+ configuration, name, _ = _resolve_profile(arguments, store)
1333
+ del configuration.profiles[name]
1334
+ store.save(configuration)
1335
+ return f"Removed profile '{name}'."
1336
+
1337
+
1338
+ def _list_profiles(store: ConfigurationStore) -> str:
1339
+ configuration = store.load()
1340
+ if not configuration.profiles:
1341
+ return "No profiles configured."
1342
+
1343
+ active = configuration.profile_for_path(Path.cwd().resolve(strict=True))
1344
+ active_name = active[0] if active is not None else None
1345
+ return "\n".join(
1346
+ f"{'*' if name == active_name else ' '} {name}"
1347
+ for name in sorted(configuration.profiles)
1348
+ )
1349
+
1350
+
1351
+ def _show_profile(
1352
+ arguments: argparse.Namespace,
1353
+ store: ConfigurationStore,
1354
+ ) -> str:
1355
+ implicit = (
1356
+ getattr(arguments, "profile_prefix", None) is None
1357
+ and arguments.profile_name is None
1358
+ )
1359
+ if implicit:
1360
+ configuration = store.load()
1361
+ active = configuration.profile_for_path(Path.cwd().resolve(strict=True))
1362
+ if active is None:
1363
+ return "No active profile. Use hlsync PROFILE COMMAND."
1364
+ name, profile = active
1365
+ else:
1366
+ _, name, profile = _resolve_profile(arguments, store)
1367
+ if not arguments.profile_details:
1368
+ return name
1369
+ return "\n".join(
1370
+ (
1371
+ f"Profile '{name}':",
1372
+ f" Protocol: {profile.type.upper()}",
1373
+ f" Host: {profile.host}:{profile.port}",
1374
+ f" Remote root: {profile.remote_root}",
1375
+ f" Local root: {profile.local_root or 'not mapped'}",
1376
+ f" Username env: {profile.username_env}",
1377
+ f" Password env: {profile.password_env}",
1378
+ f" Rules: {len(profile.rules)}",
1379
+ )
1380
+ )
1381
+
1382
+
1383
+ def _show_root(
1384
+ arguments: argparse.Namespace,
1385
+ store: ConfigurationStore,
1386
+ ) -> str:
1387
+ _, name, profile = _resolve_profile(arguments, store)
1388
+ return os.fspath(_require_local_root(name, profile))
1389
+
1390
+
1391
+ def _require_local_root(name: str, profile: ProfileConfiguration) -> Path:
1392
+ if profile.local_root is None:
1393
+ raise ConfigurationError(f"profile '{name}' has not been mapped")
1394
+ return Path(profile.local_root)
1395
+
1396
+
1397
+ def _format_tree_listing(
1398
+ arguments: argparse.Namespace,
1399
+ snapshot: TreeSnapshot,
1400
+ *,
1401
+ heading: str,
1402
+ empty_message: str,
1403
+ display_base: PurePosixPath,
1404
+ output: TextIO,
1405
+ status_for: Callable[
1406
+ [TreeEntry], tuple[str, str | None, bool, str | None]
1407
+ ],
1408
+ ) -> str:
1409
+ if not snapshot.entries:
1410
+ return empty_message
1411
+ lines = [heading]
1412
+ options = _active_options(arguments)
1413
+ if options is not None:
1414
+ lines.append(options)
1415
+ color = _use_color(output)
1416
+ emitted_directories: set[str] = set()
1417
+ for entry in _file_browser_order(
1418
+ snapshot.entries,
1419
+ path_of=lambda item: item.path,
1420
+ directory_of=lambda item: item.kind == "directory",
1421
+ ):
1422
+ profile_path = PurePosixPath(entry.path)
1423
+ try:
1424
+ relative = profile_path.relative_to(display_base)
1425
+ except ValueError as error:
1426
+ raise SelectionError(
1427
+ f"selected path '{entry.path}' is outside the display scope"
1428
+ ) from error
1429
+ for index, part in enumerate(relative.parts[:-1]):
1430
+ ancestor = display_base / PurePosixPath(*relative.parts[: index + 1])
1431
+ ancestor_path = ancestor.as_posix()
1432
+ if ancestor_path in emitted_directories:
1433
+ continue
1434
+ lines.append(
1435
+ _format_path_line(
1436
+ " ",
1437
+ directory=True,
1438
+ path=part,
1439
+ depth=index,
1440
+ color=color,
1441
+ marker_color=None,
1442
+ excluded=False,
1443
+ )
1444
+ )
1445
+ emitted_directories.add(ancestor_path)
1446
+ marker, marker_color, excluded, path_color = status_for(entry)
1447
+ lines.append(
1448
+ _format_path_line(
1449
+ marker,
1450
+ directory=entry.kind == "directory",
1451
+ path=relative.name,
1452
+ depth=max(len(relative.parts) - 1, 0),
1453
+ color=color,
1454
+ marker_color=marker_color,
1455
+ excluded=excluded,
1456
+ path_color=path_color,
1457
+ )
1458
+ )
1459
+ if entry.kind == "directory":
1460
+ emitted_directories.add(entry.path)
1461
+ return "\n".join(lines)
1462
+
1463
+
1464
+ def _list_local(
1465
+ arguments: argparse.Namespace,
1466
+ store: ConfigurationStore,
1467
+ output: TextIO,
1468
+ ) -> str:
1469
+ _, name, profile = _resolve_profile(arguments, store)
1470
+ root = _require_local_root(name, profile)
1471
+ selector = _list_selection(arguments, root)
1472
+ raw_snapshot = snapshot_local(
1473
+ root,
1474
+ _effective_rules(store, profile),
1475
+ selector,
1476
+ include_excluded=True,
1477
+ traverse_excluded=True,
1478
+ )
1479
+ if not raw_snapshot.entries:
1480
+ raise SelectionError(f"file selector '{selector.pattern}' matched no paths")
1481
+ snapshot = TreeSnapshot(
1482
+ tuple(
1483
+ entry
1484
+ for entry in raw_snapshot.entries
1485
+ if selector.includes_result(
1486
+ entry.path,
1487
+ directory=entry.kind == "directory",
1488
+ )
1489
+ and (not arguments.included_only or not entry.excluded)
1490
+ )
1491
+ )
1492
+ display_base = _profile_relative_current_directory(arguments, root)
1493
+ return _format_tree_listing(
1494
+ arguments,
1495
+ snapshot,
1496
+ heading=f"Local tree for profile '{name}':",
1497
+ empty_message=f"Local tree for profile '{name}' is empty.",
1498
+ display_base=display_base,
1499
+ output=output,
1500
+ status_for=lambda entry: (
1501
+ "x" if entry.excluded else " ",
1502
+ "\033[90m" if entry.excluded else None,
1503
+ entry.excluded,
1504
+ None,
1505
+ ),
1506
+ )
1507
+
1508
+
1509
+ def _list_remote(
1510
+ arguments: argparse.Namespace,
1511
+ store: ConfigurationStore,
1512
+ progress: TextIO,
1513
+ output: TextIO,
1514
+ ) -> str:
1515
+ _, name, profile = _resolve_profile(arguments, store)
1516
+ root = _require_local_root(name, profile)
1517
+ selector = _list_selection(arguments, root)
1518
+ rules = _effective_rules(store, profile)
1519
+ print(f"Listing remote files for profile '{name}'...", file=progress, flush=True)
1520
+ print("Connecting securely over FTPS...", file=progress, flush=True)
1521
+ with ExplicitFTPSTransport(profile) as transport:
1522
+ raw_snapshot = transport.snapshot(
1523
+ rules,
1524
+ selector,
1525
+ include_excluded=True,
1526
+ traverse_excluded=True,
1527
+ )
1528
+ if not raw_snapshot.entries:
1529
+ raise SelectionError(f"file selector '{selector.pattern}' matched no paths")
1530
+ snapshot = TreeSnapshot(
1531
+ tuple(
1532
+ entry
1533
+ for entry in raw_snapshot.entries
1534
+ if selector.includes_result(
1535
+ entry.path,
1536
+ directory=entry.kind == "directory",
1537
+ )
1538
+ and (
1539
+ not arguments.included_only
1540
+ or (not entry.excluded and not entry.remote_excluded)
1541
+ )
1542
+ )
1543
+ )
1544
+ display_base = _profile_relative_current_directory(arguments, root)
1545
+
1546
+ def remote_status(
1547
+ entry: TreeEntry,
1548
+ ) -> tuple[str, str | None, bool, str | None]:
1549
+ if entry.remote_excluded or entry.excluded:
1550
+ marker = "#" if entry.remote_excluded else "x"
1551
+ return marker, _DIFF_MARKER_COLORS[marker], True, None
1552
+ return " ", None, False, None
1553
+
1554
+ return _format_tree_listing(
1555
+ arguments,
1556
+ snapshot,
1557
+ heading=f"Remote tree for profile '{name}':",
1558
+ empty_message=f"Remote tree for profile '{name}' is empty.",
1559
+ display_base=display_base,
1560
+ output=output,
1561
+ status_for=remote_status,
1562
+ )
1563
+
1564
+
1565
+ def _comparison_kind(entry: ComparisonEntry) -> str:
1566
+ if (
1567
+ entry.local_kind is not None
1568
+ and entry.remote_kind is not None
1569
+ and entry.local_kind != entry.remote_kind
1570
+ ):
1571
+ return f"{entry.local_kind}->{entry.remote_kind}"
1572
+ return entry.local_kind or entry.remote_kind or "unknown"
1573
+
1574
+
1575
+ def _comparison_marker(entry: ComparisonEntry) -> str:
1576
+ side = (
1577
+ "r" if entry.local_kind is None
1578
+ else "l" if entry.remote_kind is None
1579
+ else " "
1580
+ )
1581
+ exclusion = (
1582
+ "#" if entry.state == "remote-excluded"
1583
+ else "x" if entry.state == "excluded"
1584
+ else " "
1585
+ )
1586
+ action = {
1587
+ "excluded": " ",
1588
+ "conflict": "?",
1589
+ "unchanged": "=",
1590
+ "untraversed": " ",
1591
+ "skip": " ",
1592
+ "create-remote": "+",
1593
+ "upload": "+",
1594
+ "replace-remote": "~",
1595
+ "replace-local": "~",
1596
+ "delete-remote": "-",
1597
+ }[entry.action]
1598
+ return f"{action}{exclusion} {side}"
1599
+
1600
+
1601
+ def _comparison_marker_color(marker: str) -> str | None:
1602
+ action, exclusion, side = marker[0], marker[1], marker[3]
1603
+ return (
1604
+ _DIFF_MARKER_COLORS.get(action)
1605
+ or _DIFF_MARKER_COLORS.get(exclusion)
1606
+ or _DIFF_MARKER_COLORS.get(side)
1607
+ )
1608
+
1609
+
1610
+ def _comparison_entry_kind(entry: ComparisonEntry, direction: str) -> str:
1611
+ selected = entry.local_kind if direction == "push" else entry.remote_kind
1612
+ return selected or entry.remote_kind or entry.local_kind or "unknown"
1613
+
1614
+
1615
+ def _use_color(output: TextIO) -> bool:
1616
+ if "NO_COLOR" in os.environ:
1617
+ return False
1618
+ is_terminal = getattr(output, "isatty", None)
1619
+ return bool(is_terminal and is_terminal())
1620
+
1621
+
1622
+ def _format_legend(output: TextIO) -> str:
1623
+ color = _use_color(output)
1624
+ entries = (
1625
+ ("+ l", "local-only; upload", _DIFF_MARKER_COLORS["+"]),
1626
+ ("~ ", "present on both sides; update", _DIFF_MARKER_COLORS["~"]),
1627
+ (" r", "remote-only; retain", _DIFF_MARKER_COLORS["r"]),
1628
+ ("- r", "remote-only; delete with --prune", _DIFF_MARKER_COLORS["-"]),
1629
+ ("-x ", "locally excluded; delete with --prune", _DIFF_MARKER_COLORS["-"]),
1630
+ ("l", "local only", _DIFF_MARKER_COLORS["l"]),
1631
+ ("r", "remote only", _DIFF_MARKER_COLORS["r"]),
1632
+ (" ", "blank side column: present on both sides", None),
1633
+ (
1634
+ "?",
1635
+ "conflict; destination not older, or type mismatch",
1636
+ _DIFF_MARKER_COLORS["?"],
1637
+ ),
1638
+ ("=", "unchanged file", None),
1639
+ ("x", "locally excluded", _DIFF_MARKER_COLORS["x"]),
1640
+ ("#", "remotely excluded; leave untouched", _DIFF_MARKER_COLORS["#"]),
1641
+ ("/", "directory", _DIRECTORY_COLOR),
1642
+ ("▸", "contents not inspected", _COLLAPSED_DIRECTORY_COLOR),
1643
+ )
1644
+ lines = ["Diff legend:"]
1645
+ for marker, meaning, style in entries:
1646
+ rendered = f"{style}{marker}{_RESET}" if color and style else marker
1647
+ lines.append(f" {rendered} {meaning}")
1648
+ return "\n".join(lines)
1649
+
1650
+
1651
+ def _format_path_line(
1652
+ marker: str,
1653
+ *,
1654
+ directory: bool,
1655
+ path: str,
1656
+ depth: int = 0,
1657
+ color: bool,
1658
+ marker_color: str | None,
1659
+ excluded: bool,
1660
+ collapsed: bool = False,
1661
+ path_color: str | None = None,
1662
+ ) -> str:
1663
+ indent = " " * depth
1664
+ label = f"{path}/" if directory else path
1665
+ traversal = " ▸" if collapsed else ""
1666
+ body = f"{marker} {indent}{label}{traversal}"
1667
+ if not color:
1668
+ return body
1669
+ if len(marker) == 4:
1670
+ action, exclusion, side = marker[0], marker[1], marker[3]
1671
+ side_color = _DIFF_MARKER_COLORS.get(side)
1672
+ exclusion_color = _DIFF_MARKER_COLORS.get(exclusion)
1673
+ rendered_side = (
1674
+ f"{side_color}{side}{_RESET}" if side_color and side != " " else side
1675
+ )
1676
+ rendered_exclusion = (
1677
+ f"{exclusion_color}{exclusion}{_RESET}"
1678
+ if exclusion_color and exclusion != " "
1679
+ else exclusion
1680
+ )
1681
+ rendered_action = (
1682
+ f"{marker_color}{action}{_RESET}"
1683
+ if marker_color and action != " "
1684
+ else action
1685
+ )
1686
+ rendered_status = f"{rendered_action}{rendered_exclusion} {rendered_side}"
1687
+ if directory:
1688
+ directory_color = (
1689
+ _COLLAPSED_DIRECTORY_COLOR
1690
+ if collapsed and not marker.strip()
1691
+ else path_color
1692
+ or (_EXCLUDED_DIRECTORY_COLOR if excluded else _DIRECTORY_COLOR)
1693
+ )
1694
+ suffix = " ▸" if collapsed else ""
1695
+ return (
1696
+ f"{rendered_status} {indent}"
1697
+ f"{directory_color}{path}/{suffix}{_RESET}"
1698
+ )
1699
+ path_style = marker_color or side_color
1700
+ rendered_path = (
1701
+ f"{path_style}{label}{traversal}{_RESET}"
1702
+ if path_style
1703
+ else f"{label}{traversal}"
1704
+ )
1705
+ return f"{rendered_status} {indent}{rendered_path}"
1706
+ if directory:
1707
+ if collapsed:
1708
+ colored_marker = (
1709
+ f"{marker_color}{marker}{_RESET}" if marker_color else marker
1710
+ )
1711
+ return (
1712
+ f"{colored_marker} {indent}"
1713
+ f"{_COLLAPSED_DIRECTORY_COLOR}{path}/ ▸{_RESET}"
1714
+ )
1715
+ directory_color = path_color or (
1716
+ _EXCLUDED_DIRECTORY_COLOR if excluded else _DIRECTORY_COLOR
1717
+ )
1718
+ colored_marker = (
1719
+ f"{marker_color}{marker}{_RESET}" if marker_color else marker
1720
+ )
1721
+ return f"{colored_marker} {indent}{directory_color}{path}/{_RESET}"
1722
+ if marker_color:
1723
+ return f"{marker_color}{body}{_RESET}"
1724
+ return body
1725
+
1726
+
1727
+ def _file_browser_order(
1728
+ entries: Sequence[_EntryT],
1729
+ *,
1730
+ path_of: Callable[[_EntryT], str],
1731
+ directory_of: Callable[[_EntryT], bool],
1732
+ ) -> tuple[_EntryT, ...]:
1733
+ directory_paths = {
1734
+ path_of(entry) for entry in entries if directory_of(entry)
1735
+ }
1736
+ for entry in entries:
1737
+ parts = PurePosixPath(path_of(entry)).parts
1738
+ directory_paths.update(
1739
+ PurePosixPath(*parts[:index]).as_posix()
1740
+ for index in range(1, len(parts))
1741
+ )
1742
+
1743
+ def order_key(entry: _EntryT) -> tuple[tuple[int, str, str], ...]:
1744
+ parts = PurePosixPath(path_of(entry)).parts
1745
+ return tuple(
1746
+ (
1747
+ 0
1748
+ if PurePosixPath(*parts[: index + 1]).as_posix()
1749
+ in directory_paths
1750
+ else 1,
1751
+ part.casefold(),
1752
+ part,
1753
+ )
1754
+ for index, part in enumerate(parts)
1755
+ )
1756
+
1757
+ return tuple(sorted(entries, key=order_key))
1758
+
1759
+
1760
+ def _format_comparison_entries(
1761
+ entries: Sequence[ComparisonEntry],
1762
+ direction: str,
1763
+ *,
1764
+ color: bool,
1765
+ collapsed_paths: frozenset[str] = frozenset(),
1766
+ display_path: Callable[[str], tuple[int, str]] | None = None,
1767
+ ) -> tuple[str, ...]:
1768
+ lines: list[str] = []
1769
+ for entry in _file_browser_order(
1770
+ entries,
1771
+ path_of=lambda item: item.path,
1772
+ directory_of=lambda item: _comparison_entry_kind(item, direction)
1773
+ == "directory",
1774
+ ):
1775
+ collapsed = entry.path in collapsed_paths
1776
+ directory = _comparison_entry_kind(entry, direction) == "directory"
1777
+ marker = (
1778
+ " "
1779
+ if directory and entry.action == "unchanged"
1780
+ else _comparison_marker(entry)
1781
+ )
1782
+ depth, path = display_path(entry.path) if display_path else (0, entry.path)
1783
+ lines.append(
1784
+ _format_path_line(
1785
+ marker,
1786
+ directory=directory,
1787
+ path=path,
1788
+ depth=depth,
1789
+ color=color,
1790
+ marker_color=_comparison_marker_color(marker),
1791
+ excluded=entry.action == "excluded",
1792
+ collapsed=collapsed,
1793
+ )
1794
+ )
1795
+ return tuple(lines)
1796
+
1797
+
1798
+ def _scoped_display_path(
1799
+ path: str,
1800
+ *,
1801
+ display_root: PurePosixPath | None,
1802
+ ) -> tuple[int, str]:
1803
+ profile_path = PurePosixPath(path)
1804
+ if display_root is None:
1805
+ return 0, profile_path.as_posix()
1806
+ if profile_path == display_root and display_root.parts:
1807
+ return 0, display_root.as_posix()
1808
+ relative = (
1809
+ profile_path.relative_to(display_root)
1810
+ if display_root.parts
1811
+ else profile_path
1812
+ )
1813
+ depth = len(relative.parts) if display_root.parts else max(
1814
+ len(relative.parts) - 1,
1815
+ 0,
1816
+ )
1817
+ return depth, relative.name
1818
+
1819
+
1820
+ def _comparison_ancestors(
1821
+ path: str,
1822
+ *,
1823
+ display_root: PurePosixPath | None,
1824
+ ) -> tuple[PurePosixPath, ...]:
1825
+ profile_path = PurePosixPath(path)
1826
+ start = len(display_root.parts) if display_root is not None else 1
1827
+ start = max(start, 1)
1828
+ return tuple(
1829
+ PurePosixPath(*profile_path.parts[:length])
1830
+ for length in range(start, len(profile_path.parts))
1831
+ if display_root is None
1832
+ or profile_path.parts[: len(display_root.parts)] == display_root.parts
1833
+ )
1834
+
1835
+
1836
+ def _format_compact_comparison_entry(
1837
+ entry: ComparisonEntry,
1838
+ direction: str,
1839
+ *,
1840
+ color: bool,
1841
+ collapsed: bool,
1842
+ display_root: PurePosixPath | None,
1843
+ emitted_directories: set[str],
1844
+ ) -> tuple[str, ...]:
1845
+ lines: list[str] = []
1846
+ for ancestor in _comparison_ancestors(
1847
+ entry.path,
1848
+ display_root=display_root,
1849
+ ):
1850
+ ancestor_path = ancestor.as_posix()
1851
+ if ancestor_path in emitted_directories:
1852
+ continue
1853
+ depth, label = _scoped_display_path(
1854
+ ancestor_path,
1855
+ display_root=display_root,
1856
+ )
1857
+ lines.append(
1858
+ _format_path_line(
1859
+ " ",
1860
+ directory=True,
1861
+ path=label,
1862
+ depth=depth,
1863
+ color=color,
1864
+ marker_color=None,
1865
+ excluded=False,
1866
+ )
1867
+ )
1868
+ emitted_directories.add(ancestor_path)
1869
+ lines.extend(
1870
+ _format_comparison_entries(
1871
+ (entry,),
1872
+ direction,
1873
+ color=color,
1874
+ collapsed_paths=frozenset((entry.path,)) if collapsed else frozenset(),
1875
+ display_path=lambda path: _scoped_display_path(
1876
+ path,
1877
+ display_root=display_root,
1878
+ ),
1879
+ )
1880
+ )
1881
+ if _comparison_entry_kind(entry, direction) == "directory":
1882
+ emitted_directories.add(entry.path)
1883
+ return tuple(lines)
1884
+
1885
+
1886
+ def _scope_header_lines(
1887
+ display_root: PurePosixPath | None,
1888
+ *,
1889
+ anchored: bool,
1890
+ color: bool,
1891
+ ) -> tuple[str, ...]:
1892
+ if display_root is None:
1893
+ return ()
1894
+ if anchored or not display_root.parts:
1895
+ return ()
1896
+ return (
1897
+ _format_path_line(
1898
+ " ",
1899
+ directory=True,
1900
+ path=display_root.as_posix(),
1901
+ color=color,
1902
+ marker_color=None,
1903
+ excluded=False,
1904
+ ),
1905
+ )
1906
+
1907
+
1908
+ def _file_selection(arguments: argparse.Namespace, root: Path) -> FileSelection:
1909
+ operands = list(normalize_pattern_operands(arguments))
1910
+ current_directory = _effective_current_directory(arguments, root)
1911
+ recursive = arguments.recursive or (
1912
+ arguments.command in {"diff", "push"} and not arguments.shallow
1913
+ )
1914
+ if not operands:
1915
+ return _current_directory_selection(
1916
+ root,
1917
+ current_directory=current_directory,
1918
+ recursive=recursive,
1919
+ )
1920
+ return _directory_contents_selection(
1921
+ operands,
1922
+ root,
1923
+ current_directory=current_directory,
1924
+ recursive=recursive,
1925
+ )
1926
+
1927
+
1928
+
1929
+ def _push_comparison_selection(
1930
+ arguments: argparse.Namespace,
1931
+ root: Path,
1932
+ local: TreeSnapshot,
1933
+ selection: DirectoryContentsSelection,
1934
+ ) -> FileSelection:
1935
+ """Keep explicitly selected remote-only directories as deletion targets."""
1936
+ local_entries = {entry.path: entry for entry in local.entries}
1937
+ for operand in normalize_pattern_operands(arguments):
1938
+ if operand == "." or any(character in operand for character in "*?["):
1939
+ continue
1940
+ path = FileSelector.from_argument(
1941
+ operand.rstrip("/"), profile_root=root,
1942
+ current_directory=_effective_current_directory(arguments, root),
1943
+ ).pattern
1944
+ entry = local_entries.get(path)
1945
+ if entry is None or (entry.excluded and entry.kind == "directory"):
1946
+ return selection.traversal
1947
+ return selection
1948
+
1949
+
1950
+ def _selection_from_values(
1951
+ values: Sequence[str],
1952
+ root: Path,
1953
+ current_directory: Path,
1954
+ ) -> FileSelection:
1955
+ selectors = tuple(
1956
+ FileSelector.from_argument(
1957
+ value,
1958
+ profile_root=root,
1959
+ current_directory=current_directory,
1960
+ )
1961
+ for value in values
1962
+ )
1963
+ if len(selectors) == 1:
1964
+ return selectors[0]
1965
+ return FileSelectorSet(selectors)
1966
+
1967
+
1968
+ def _list_selection(
1969
+ arguments: argparse.Namespace,
1970
+ root: Path,
1971
+ ) -> DirectoryContentsSelection:
1972
+ operands = list(normalize_pattern_operands(arguments))
1973
+ current_directory = _effective_current_directory(arguments, root)
1974
+ if not operands:
1975
+ return _current_directory_selection(
1976
+ root,
1977
+ current_directory=current_directory,
1978
+ recursive=arguments.recursive,
1979
+ )
1980
+ return _directory_contents_selection(
1981
+ operands,
1982
+ root,
1983
+ current_directory=current_directory,
1984
+ recursive=arguments.recursive,
1985
+ )
1986
+
1987
+
1988
+ def _current_directory_selection(
1989
+ root: Path,
1990
+ *,
1991
+ current_directory: Path,
1992
+ recursive: bool,
1993
+ ) -> DirectoryContentsSelection:
1994
+ pattern = "**" if recursive else "*"
1995
+ return DirectoryContentsSelection(
1996
+ _selection_from_values(
1997
+ (pattern,),
1998
+ root,
1999
+ current_directory,
2000
+ )
2001
+ )
2002
+
2003
+
2004
+ def _directory_contents_selection(
2005
+ operands: Sequence[str],
2006
+ root: Path,
2007
+ *,
2008
+ current_directory: Path,
2009
+ recursive: bool,
2010
+ ) -> DirectoryContentsSelection:
2011
+ traversal_patterns: list[str] = []
2012
+ container_patterns: list[str] = []
2013
+ for operand in operands:
2014
+ if operand == ".":
2015
+ traversal_patterns.append("**" if recursive else "*")
2016
+ continue
2017
+ normalized = operand.rstrip("/")
2018
+ container_patterns.append(normalized)
2019
+ traversal_patterns.extend(
2020
+ (
2021
+ normalized,
2022
+ f"{normalized}/{'**' if recursive else '*'}",
2023
+ )
2024
+ )
2025
+ traversal = _selection_from_values(
2026
+ traversal_patterns,
2027
+ root,
2028
+ current_directory,
2029
+ )
2030
+ containers = (
2031
+ _selection_from_values(container_patterns, root, current_directory)
2032
+ if container_patterns
2033
+ else None
2034
+ )
2035
+ return DirectoryContentsSelection(traversal, containers)
2036
+
2037
+
2038
+ def _filtered_listing(
2039
+ listing: TreeSnapshot,
2040
+ selector: FileSelection | None,
2041
+ ) -> TreeSnapshot:
2042
+ return TreeSnapshot(
2043
+ tuple(
2044
+ entry
2045
+ for entry in listing.entries
2046
+ if selector is None or selector.matches(entry.path)
2047
+ )
2048
+ )
2049
+
2050
+
2051
+ def _descendant_directories(
2052
+ local: TreeSnapshot,
2053
+ remote: TreeSnapshot,
2054
+ rules: RuleSet,
2055
+ selector: FileSelection | None,
2056
+ *,
2057
+ descend_remote_only: bool,
2058
+ ) -> tuple[tuple[PurePosixPath, bool, bool], ...]:
2059
+ local_directories = {
2060
+ entry.path: entry for entry in local.entries if entry.kind == "directory"
2061
+ }
2062
+ remote_directories = {
2063
+ entry.path: entry for entry in remote.entries if entry.kind == "directory"
2064
+ }
2065
+ paths = sorted(local_directories.keys() | remote_directories.keys())
2066
+ descendants: list[tuple[PurePosixPath, bool, bool]] = []
2067
+ for path in paths:
2068
+ local_entry = local_directories.get(path)
2069
+ remote_entry = remote_directories.get(path)
2070
+ if local_entry is None and not descend_remote_only:
2071
+ continue
2072
+ if selector is not None and not selector.may_match_descendant(path):
2073
+ continue
2074
+ if (
2075
+ any(
2076
+ entry is not None and entry.excluded
2077
+ for entry in (local_entry, remote_entry)
2078
+ )
2079
+ and not rules.may_include_descendant(path, target="local")
2080
+ ):
2081
+ continue
2082
+ if any(
2083
+ entry is not None and entry.remote_excluded
2084
+ for entry in (local_entry, remote_entry)
2085
+ ):
2086
+ continue
2087
+ descendants.append(
2088
+ (PurePosixPath(path), local_entry is not None, remote_entry is not None)
2089
+ )
2090
+ return tuple(descendants)
2091
+
2092
+
2093
+ def _profile_relative_current_directory(
2094
+ arguments: argparse.Namespace,
2095
+ root: Path,
2096
+ ) -> PurePosixPath:
2097
+ current = _effective_current_directory(arguments, root)
2098
+ try:
2099
+ relative = current.relative_to(root)
2100
+ except ValueError:
2101
+ return PurePosixPath()
2102
+ return PurePosixPath(*relative.parts)
2103
+
2104
+
2105
+ def _diff_traversal_roots(
2106
+ arguments: argparse.Namespace,
2107
+ root: Path,
2108
+ ) -> tuple[_DiffTraversalRoot, ...]:
2109
+ """Find the narrowest deterministic directories needed by the selection."""
2110
+ base = _profile_relative_current_directory(arguments, root)
2111
+ operands = normalize_pattern_operands(arguments)
2112
+ if not operands:
2113
+ return (_DiffTraversalRoot(base),)
2114
+
2115
+ candidates: list[_DiffTraversalRoot] = []
2116
+ for operand in operands:
2117
+ profile_path = base / PurePosixPath(operand)
2118
+ if operand == ".":
2119
+ candidates.append(_DiffTraversalRoot(base))
2120
+ continue
2121
+
2122
+ wildcard_index = next(
2123
+ (
2124
+ index
2125
+ for index, part in enumerate(profile_path.parts)
2126
+ if "*" in part
2127
+ ),
2128
+ None,
2129
+ )
2130
+ if wildcard_index is not None:
2131
+ fixed_parts = profile_path.parts[:wildcard_index]
2132
+ fixed = PurePosixPath(*fixed_parts) if fixed_parts else PurePosixPath()
2133
+ candidates.append(_DiffTraversalRoot(fixed))
2134
+ continue
2135
+
2136
+ local_path = root.joinpath(*profile_path.parts)
2137
+ if local_path.is_dir() and not local_path.is_symlink():
2138
+ candidates.append(_DiffTraversalRoot(profile_path, include_container=True))
2139
+ else:
2140
+ candidates.append(_DiffTraversalRoot(profile_path.parent))
2141
+
2142
+ ordered = sorted(
2143
+ candidates,
2144
+ key=lambda item: (item.path.parts, item.include_container),
2145
+ )
2146
+ roots: list[_DiffTraversalRoot] = []
2147
+ for candidate in ordered:
2148
+ covering = next(
2149
+ (
2150
+ existing
2151
+ for existing in roots
2152
+ if candidate.path.parts[: len(existing.path.parts)]
2153
+ == existing.path.parts
2154
+ ),
2155
+ None,
2156
+ )
2157
+ if covering is None:
2158
+ roots.append(candidate)
2159
+ elif covering.path == candidate.path and candidate.include_container:
2160
+ roots[roots.index(covering)] = candidate
2161
+ return tuple(roots)
2162
+
2163
+
2164
+ def _resume_directory(value: str | None) -> PurePosixPath | None:
2165
+ if value is None:
2166
+ return None
2167
+ path = PurePosixPath(value)
2168
+ if path.is_absolute() or not path.parts or ".." in path.parts:
2169
+ raise SelectionError(
2170
+ "resume directory must be a profile-relative directory"
2171
+ )
2172
+ if any(character in value for character in "*?["):
2173
+ raise SelectionError("resume directory cannot contain wildcards")
2174
+ return path
2175
+
2176
+
2177
+ def _resume_command(arguments: argparse.Namespace, directory: PurePosixPath) -> str:
2178
+ command = ["hlsync"]
2179
+ if arguments.profile_prefix is not None:
2180
+ command.append(arguments.profile_prefix)
2181
+ command.extend(("diff", *arguments.pattern_operands))
2182
+ if arguments.pull:
2183
+ command.append("--pull")
2184
+ if arguments.prune:
2185
+ command.append("--prune")
2186
+ if arguments.recursive:
2187
+ command.append("--recursive")
2188
+ if arguments.shallow:
2189
+ command.append("--shallow")
2190
+ if arguments.show_excluded:
2191
+ command.append("--excluded")
2192
+ if arguments.include_excluded:
2193
+ command.append("--include-excluded")
2194
+ if arguments.show_all:
2195
+ command.append("--all")
2196
+ command.extend(("--paged", "--resume", directory.as_posix()))
2197
+ return shlex.join(command)
2198
+
2199
+
2200
+ def _build_plan(
2201
+ arguments: argparse.Namespace,
2202
+ root: Path,
2203
+ transport: ExplicitFTPSTransport,
2204
+ rules: RuleSet,
2205
+ *,
2206
+ direction: str,
2207
+ progress: TextIO,
2208
+ include_excluded: bool = False,
2209
+ recover_artifacts: bool = True,
2210
+ ) -> tuple[TreeSnapshot, TreeSnapshot, ComparisonPlan]:
2211
+ selector = _file_selection(arguments, root)
2212
+ prune_remote = direction == "push" and getattr(arguments, "prune", False)
2213
+ print("Scanning local files...", file=progress, flush=True)
2214
+
2215
+ def report_local_directory(directory: PurePosixPath) -> None:
2216
+ print(f" Scanning {directory.as_posix()}", file=progress, flush=True)
2217
+
2218
+ local = snapshot_local(
2219
+ root,
2220
+ rules,
2221
+ selector,
2222
+ include_excluded=include_excluded,
2223
+ traverse_excluded=False,
2224
+ respect_remote_boundaries=True,
2225
+ directory_progress=report_local_directory,
2226
+ )
2227
+ print("Reading remote files over FTPS...", file=progress, flush=True)
2228
+
2229
+ with DirectoryReadProgress(progress) as reading:
2230
+ artifact_options: dict[str, Callable[[str], None]] = {}
2231
+ if direction == "push":
2232
+ option = "artifact_recovery" if recover_artifacts else "artifact_preview"
2233
+ artifact_options[option] = reading.message
2234
+ remote = transport.snapshot(
2235
+ rules,
2236
+ selector,
2237
+ include_excluded=include_excluded,
2238
+ traverse_excluded=False,
2239
+ directory_progress=reading.reading,
2240
+ directory_counts=reading.counts,
2241
+ **artifact_options,
2242
+ )
2243
+ print("Comparing local and remote files...", file=progress, flush=True)
2244
+ comparison_selection = (
2245
+ _push_comparison_selection(arguments, root, local, selector)
2246
+ if direction == "push" and prune_remote
2247
+ else selector
2248
+ )
2249
+ plan = build_comparison(
2250
+ local,
2251
+ remote,
2252
+ direction=direction,
2253
+ prune_remote=prune_remote,
2254
+ force=getattr(arguments, "force", False),
2255
+ selector=comparison_selection if arguments.pattern_operands else None,
2256
+ )
2257
+ untraversed_directories = frozenset(
2258
+ entry.path
2259
+ for entry in plan.entries
2260
+ if _comparison_entry_kind(entry, direction) == "directory"
2261
+ and not selector.may_match_descendant(entry.path)
2262
+ and entry.action != "excluded"
2263
+ )
2264
+ plan = mark_untraversed_directories(plan, untraversed_directories)
2265
+ return local, remote, plan
2266
+
2267
+
2268
+ def _diff(
2269
+ arguments: argparse.Namespace,
2270
+ store: ConfigurationStore,
2271
+ progress: TextIO,
2272
+ output: TextIO,
2273
+ ) -> None:
2274
+ _, name, profile = _resolve_profile(arguments, store)
2275
+ root = _require_local_root(name, profile)
2276
+ if arguments.resume is not None and not arguments.paged:
2277
+ raise ConfigurationError("--resume requires --paged")
2278
+ resume = _resume_directory(arguments.resume)
2279
+ selector = _file_selection(arguments, root)
2280
+ rules = _command_rules(arguments, store, profile)
2281
+ direction = "pull" if arguments.pull else "push"
2282
+ prune_remote = direction == "push" and arguments.prune
2283
+ color = _use_color(output)
2284
+ print(f"Checking differences for profile '{name}'...", file=progress, flush=True)
2285
+ options = _active_options(arguments)
2286
+ if options is not None:
2287
+ print(options, file=progress, flush=True)
2288
+ print("Connecting securely over FTPS...", file=progress, flush=True)
2289
+ traversal_roots = _diff_traversal_roots(arguments, root)
2290
+ if resume is not None:
2291
+ root_index = next(
2292
+ (
2293
+ index
2294
+ for index, traversal_root in enumerate(traversal_roots)
2295
+ if resume.parts[: len(traversal_root.path.parts)]
2296
+ == traversal_root.path.parts
2297
+ ),
2298
+ None,
2299
+ )
2300
+ if root_index is None:
2301
+ raise SelectionError(f"resume directory '{resume}' is not reachable")
2302
+ traversal_roots = traversal_roots[root_index:]
2303
+ pending: list[_PendingDiffDirectory | _PendingDiffOutput] = [
2304
+ _PendingDiffDirectory(
2305
+ path=traversal_root.path,
2306
+ has_local=root.joinpath(*traversal_root.path.parts).is_dir(),
2307
+ has_remote=True,
2308
+ include_container=traversal_root.include_container,
2309
+ is_root=True,
2310
+ display_root=(
2311
+ traversal_root.path if len(traversal_roots) == 1 else None
2312
+ ),
2313
+ display_anchor=traversal_root.include_container,
2314
+ )
2315
+ for traversal_root in reversed(traversal_roots)
2316
+ ]
2317
+ seeking = resume is not None
2318
+ selected_count = 0
2319
+ displayed_count = 0
2320
+ hidden: dict[str, int] = {}
2321
+ emitted_directories: set[str] = set()
2322
+
2323
+ def format_entries(
2324
+ entries: Sequence[ComparisonEntry],
2325
+ collapsed_paths: frozenset[str],
2326
+ display_root: PurePosixPath | None,
2327
+ ) -> tuple[str, ...]:
2328
+ if not arguments.show_all:
2329
+ return tuple(
2330
+ line
2331
+ for entry in entries
2332
+ for line in _format_compact_comparison_entry(
2333
+ entry,
2334
+ direction,
2335
+ color=color,
2336
+ collapsed=entry.path in collapsed_paths,
2337
+ display_root=display_root,
2338
+ emitted_directories=emitted_directories,
2339
+ )
2340
+ )
2341
+ return _format_comparison_entries(
2342
+ entries,
2343
+ direction,
2344
+ color=color,
2345
+ collapsed_paths=collapsed_paths,
2346
+ display_path=lambda path: _scoped_display_path(
2347
+ path, display_root=display_root,
2348
+ ),
2349
+ )
2350
+
2351
+ with ExplicitFTPSTransport(profile) as transport:
2352
+ if pending and arguments.show_all:
2353
+ for line in _scope_header_lines(
2354
+ pending[-1].display_root,
2355
+ anchored=pending[-1].display_anchor,
2356
+ color=color,
2357
+ ):
2358
+ print(line, file=output, flush=True)
2359
+ displayed_count += 1
2360
+ while pending:
2361
+ current = pending.pop()
2362
+ if isinstance(current, _PendingDiffOutput):
2363
+ lines = format_entries(
2364
+ (current.entry,), current.collapsed_paths, current.display_root,
2365
+ )
2366
+ for line in lines:
2367
+ print(line, file=output, flush=True)
2368
+ displayed_count += len(lines)
2369
+ continue
2370
+ directory = current.path
2371
+ display_directory = directory.as_posix()
2372
+ local_listing = (
2373
+ list_local_directory(root, directory, rules)
2374
+ if current.has_local
2375
+ else TreeSnapshot()
2376
+ )
2377
+ remote_directory_exists = current.has_remote
2378
+ try:
2379
+ remote_listing = (
2380
+ transport.list_directory(directory, rules)
2381
+ if current.has_remote
2382
+ else TreeSnapshot()
2383
+ )
2384
+ except PathOperationError:
2385
+ if not current.is_root or not directory.parts:
2386
+ raise
2387
+ remote_directory_exists = False
2388
+ remote_listing = TreeSnapshot()
2389
+ descendants = _descendant_directories(
2390
+ local_listing,
2391
+ remote_listing,
2392
+ rules,
2393
+ selector,
2394
+ descend_remote_only=prune_remote,
2395
+ )
2396
+ pending_descendants = tuple(
2397
+ _PendingDiffDirectory(
2398
+ path=path,
2399
+ has_local=local_exists,
2400
+ has_remote=remote_exists,
2401
+ include_container=False,
2402
+ is_root=False,
2403
+ display_root=current.display_root,
2404
+ display_anchor=current.display_anchor,
2405
+ )
2406
+ for path, local_exists, remote_exists in descendants
2407
+ )
2408
+
2409
+ if seeking and directory != resume:
2410
+ assert resume is not None
2411
+ current_parts = directory.parts
2412
+ if resume.parts[: len(current_parts)] != current_parts:
2413
+ raise SelectionError(
2414
+ f"resume directory '{resume}' is not reachable"
2415
+ )
2416
+ branch_length = len(current_parts) + 1
2417
+ branch = PurePosixPath(*resume.parts[:branch_length])
2418
+ matching_branch = next(
2419
+ (
2420
+ candidate
2421
+ for candidate in pending_descendants
2422
+ if candidate.path == branch
2423
+ ),
2424
+ None,
2425
+ )
2426
+ if matching_branch is None:
2427
+ raise SelectionError(
2428
+ f"resume directory '{resume}' is not reachable"
2429
+ )
2430
+ later = tuple(
2431
+ candidate
2432
+ for candidate in pending_descendants
2433
+ if candidate.path > branch
2434
+ )
2435
+ pending.extend(reversed(later))
2436
+ pending.append(matching_branch)
2437
+ continue
2438
+
2439
+ seeking = False
2440
+ local = _filtered_listing(
2441
+ local_listing,
2442
+ selector,
2443
+ )
2444
+ remote = _filtered_listing(
2445
+ remote_listing,
2446
+ selector,
2447
+ )
2448
+ if current.include_container and directory.parts:
2449
+ container_path = directory.as_posix()
2450
+ excluded = rules.excludes(
2451
+ container_path, target="local", is_directory=True
2452
+ )
2453
+ remote_excluded = rules.excludes(
2454
+ container_path, target="remote", is_directory=True
2455
+ )
2456
+ if current.has_local:
2457
+ local = TreeSnapshot(
2458
+ local.entries
2459
+ + (
2460
+ TreeEntry(
2461
+ container_path,
2462
+ "directory",
2463
+ excluded=excluded,
2464
+ remote_excluded=remote_excluded,
2465
+ ),
2466
+ )
2467
+ )
2468
+ if remote_directory_exists:
2469
+ remote = TreeSnapshot(
2470
+ remote.entries
2471
+ + (
2472
+ TreeEntry(
2473
+ container_path,
2474
+ "directory",
2475
+ excluded=excluded,
2476
+ remote_excluded=remote_excluded,
2477
+ ),
2478
+ )
2479
+ )
2480
+ selected_count += len(local.entries) + len(remote.entries)
2481
+ plan = build_comparison(
2482
+ local,
2483
+ remote,
2484
+ direction=direction,
2485
+ prune_remote=prune_remote,
2486
+ )
2487
+ descended_paths = frozenset(
2488
+ path.as_posix() for path, _, _ in descendants
2489
+ )
2490
+ collapsed_paths = frozenset(
2491
+ entry.path
2492
+ for entry in plan.entries
2493
+ if _comparison_entry_kind(entry, direction) == "directory"
2494
+ and entry.path not in descended_paths
2495
+ and entry.path != directory.as_posix()
2496
+ )
2497
+ plan = mark_untraversed_directories(plan, collapsed_paths)
2498
+ shown = tuple(
2499
+ entry
2500
+ for entry in plan.entries
2501
+ if _diff_entry_visible(entry, direction, arguments)
2502
+ )
2503
+ for entry in plan.entries:
2504
+ if entry not in shown and entry.path != directory.as_posix():
2505
+ category = _hidden_diff_category(entry)
2506
+ if category is not None:
2507
+ hidden[category] = hidden.get(category, 0) + 1
2508
+
2509
+ if arguments.paged:
2510
+ pending.extend(reversed(pending_descendants))
2511
+ lines = format_entries(shown, collapsed_paths, current.display_root)
2512
+ for line in lines:
2513
+ print(line, file=output, flush=True)
2514
+ displayed_count += len(lines)
2515
+ if not lines:
2516
+ print(
2517
+ f" nothing to {direction} in {display_directory}",
2518
+ file=output,
2519
+ flush=True,
2520
+ )
2521
+ _print_hidden_summary(hidden, output)
2522
+ if pending:
2523
+ print(
2524
+ f"Resume: {_resume_command(arguments, pending[-1].path)}",
2525
+ file=output,
2526
+ flush=True,
2527
+ )
2528
+ return
2529
+
2530
+ descendants_by_path = {
2531
+ descendant.path.as_posix(): descendant
2532
+ for descendant in pending_descendants
2533
+ }
2534
+ events: list[_PendingDiffDirectory | _PendingDiffOutput] = []
2535
+ ordered_entries = _file_browser_order(
2536
+ plan.entries,
2537
+ path_of=lambda item: item.path,
2538
+ directory_of=lambda item: (
2539
+ _comparison_entry_kind(item, direction) == "directory"
2540
+ ),
2541
+ )
2542
+ directory_entries = tuple(
2543
+ entry
2544
+ for entry in ordered_entries
2545
+ if _comparison_entry_kind(entry, direction) == "directory"
2546
+ )
2547
+ file_entries = tuple(
2548
+ entry
2549
+ for entry in ordered_entries
2550
+ if _comparison_entry_kind(entry, direction) != "directory"
2551
+ and entry in shown
2552
+ )
2553
+ for entry in directory_entries:
2554
+ if entry in shown:
2555
+ events.append(_PendingDiffOutput(
2556
+ entry, collapsed_paths, current.display_root,
2557
+ ))
2558
+ descendant = descendants_by_path.get(entry.path)
2559
+ if descendant is not None:
2560
+ events.append(descendant)
2561
+ events.extend(
2562
+ _PendingDiffOutput(entry, collapsed_paths, current.display_root)
2563
+ for entry in file_entries
2564
+ )
2565
+ scheduled_paths = {
2566
+ event.path.as_posix()
2567
+ for event in events
2568
+ if isinstance(event, _PendingDiffDirectory)
2569
+ }
2570
+ events.extend(
2571
+ descendant
2572
+ for descendant in pending_descendants
2573
+ if descendant.path.as_posix() not in scheduled_paths
2574
+ )
2575
+ pending.extend(reversed(events))
2576
+
2577
+ if seeking:
2578
+ assert resume is not None
2579
+ raise SelectionError(f"resume directory '{resume}' is not reachable")
2580
+ if arguments.pattern_operands and selected_count == 0:
2581
+ raise SelectionError(f"file selector '{selector.pattern}' matched no paths")
2582
+ if displayed_count == 0:
2583
+ print(f" nothing to {direction}", file=output, flush=True)
2584
+ _print_hidden_summary(hidden, output)
2585
+
2586
+
2587
+ _HIDDEN_DIFF_HINTS = {
2588
+ "excluded": "-x or -I",
2589
+ "remote-excluded": "-x",
2590
+ "remote-only": "-a",
2591
+ "local-only": "-a",
2592
+ }
2593
+
2594
+
2595
+ def _diff_entry_visible(
2596
+ entry: ComparisonEntry,
2597
+ direction: str,
2598
+ arguments: argparse.Namespace,
2599
+ ) -> bool:
2600
+ """Show what the transfer would do; -x adds exclusions, -a everything."""
2601
+ if arguments.show_all:
2602
+ return True
2603
+ if entry.action == "excluded":
2604
+ return arguments.show_excluded
2605
+ if entry.action == "untraversed":
2606
+ # An unentered folder matters only if it could still hold changes.
2607
+ if direction == "push":
2608
+ return entry.local_kind is not None
2609
+ return entry.local_kind is not None and entry.remote_kind is not None
2610
+ return entry.action not in {"unchanged", "skip"}
2611
+
2612
+
2613
+ def _hidden_diff_category(entry: ComparisonEntry) -> str | None:
2614
+ if entry.action == "excluded":
2615
+ return entry.state
2616
+ if entry.action in {"skip", "untraversed"}:
2617
+ if entry.local_kind is None:
2618
+ return "remote-only"
2619
+ if entry.remote_kind is None:
2620
+ return "local-only"
2621
+ return None
2622
+
2623
+
2624
+ def _print_hidden_summary(hidden: dict[str, int], output: TextIO) -> None:
2625
+ if not hidden:
2626
+ return
2627
+ parts = ", ".join(
2628
+ f"{hidden[category]} {category} (try using {_HIDDEN_DIFF_HINTS[category]})"
2629
+ for category in _HIDDEN_DIFF_HINTS
2630
+ if category in hidden
2631
+ )
2632
+ print(f"Not shown: {parts}", file=output, flush=True)
2633
+
2634
+
2635
+ def _format_transfer(
2636
+ name: str,
2637
+ result: TransferResult,
2638
+ *,
2639
+ dry_run: bool = False,
2640
+ ) -> str:
2641
+ direction = result.plan.direction.capitalize()
2642
+ if not result.succeeded:
2643
+ lines = [
2644
+ f"{direction} finished with errors for profile '{name}': "
2645
+ f"{result.changed_count} completed, {result.failed_count} failed, "
2646
+ f"{result.skipped_count} skipped."
2647
+ ]
2648
+ return "\n".join(lines)
2649
+ count = result.changed_count
2650
+ if count == 0:
2651
+ nothing = f" Nothing to {result.plan.direction}"
2652
+ if result.plan.direction == "push" and result.unchanged_file_count:
2653
+ files = result.unchanged_file_count
2654
+ noun = "file is" if files == 1 else "files are"
2655
+ nothing += f"; {files} {noun} up to date in this scope"
2656
+ lines = [f"{nothing}."]
2657
+ else:
2658
+ changes = f"{count} change{'s' if count != 1 else ''}"
2659
+ lines = (
2660
+ [f"Dry push: {count} planned change{'s' if count != 1 else ''}."]
2661
+ if dry_run
2662
+ else [f"{direction} complete: {changes}."]
2663
+ )
2664
+ if result.plan.direction == "pull":
2665
+ skipped = [entry for entry in result.plan.entries if entry.action == "skip"]
2666
+ if skipped:
2667
+ lines.append("Remote-only paths not restored:")
2668
+ lines.extend(f" {entry.path}" for entry in skipped)
2669
+ return "\n".join(lines)
2670
+
2671
+
2672
+ def _report_transfer_operation(
2673
+ operation: TransferOperation | TransferIssue,
2674
+ progress: TextIO,
2675
+ ) -> None:
2676
+ if isinstance(operation, TransferIssue):
2677
+ line = f"{operation.status:<7} {operation.path}: {operation.reason}"
2678
+ if _use_color(progress):
2679
+ color = "\033[31m" if operation.status == "failed" else "\033[33m"
2680
+ line = f"{color}{line}{_RESET}"
2681
+ print(f" {line}", file=progress, flush=True)
2682
+ return
2683
+ marker = _TRANSFER_MARKERS[operation.action]
2684
+ print(
2685
+ " " + _format_path_line(
2686
+ marker,
2687
+ directory=operation.kind == "directory",
2688
+ path=operation.path,
2689
+ color=_use_color(progress),
2690
+ marker_color=_DIFF_MARKER_COLORS[marker],
2691
+ excluded=False,
2692
+ ),
2693
+ file=progress,
2694
+ flush=True,
2695
+ )
2696
+
2697
+
2698
+ def _byte_label(size: int) -> str:
2699
+ value = float(size)
2700
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
2701
+ if value < 1024 or unit == "TiB":
2702
+ return f"{value:.1f} {unit}" if unit != "B" else f"{size} B"
2703
+ value /= 1024
2704
+ raise AssertionError("unreachable byte unit")
2705
+
2706
+
2707
+ class _UploadProgress:
2708
+ def __init__(
2709
+ self, output: TextIO, uploads: dict[str, int], *, dry_run: bool,
2710
+ ) -> None:
2711
+ self.output = output
2712
+ self.uploads = uploads
2713
+ self.total_bytes = sum(uploads.values())
2714
+ self.sent = 0
2715
+ self.completed = 0
2716
+ self.issues = 0
2717
+ self.last_draw = 0.0
2718
+ self.visible = False
2719
+ self.terminal = bool(
2720
+ uploads and not dry_run and output.isatty()
2721
+ and os.environ.get("TERM") != "dumb"
2722
+ )
2723
+ if uploads:
2724
+ label = "Planned uploads" if dry_run else "Uploading"
2725
+ print(
2726
+ f"{label}: {len(uploads)} files · {_byte_label(self.total_bytes)}.",
2727
+ file=output, flush=True,
2728
+ )
2729
+
2730
+ def clear(self) -> None:
2731
+ if self.visible:
2732
+ print("\r\033[2K", end="", file=self.output, flush=True)
2733
+ self.visible = False
2734
+
2735
+ def draw(self, *, force: bool = False) -> None:
2736
+ if not self.terminal:
2737
+ return
2738
+ now = time.monotonic()
2739
+ if not force and now - self.last_draw < 0.1:
2740
+ return
2741
+ self.last_draw = now
2742
+ fraction = (
2743
+ self.sent / self.total_bytes if self.total_bytes
2744
+ else self.completed / len(self.uploads)
2745
+ )
2746
+ filled = min(16, int(fraction * 16))
2747
+ bar = "#" * filled + "-" * (16 - filled)
2748
+ line = (
2749
+ f" [{bar}] {self.completed}/{len(self.uploads)} installed · "
2750
+ f"{_byte_label(self.sent)}/{_byte_label(self.total_bytes)} sent"
2751
+ )
2752
+ if self.issues:
2753
+ line += f" · {self.issues} failed/skipped"
2754
+ width = max(1, shutil.get_terminal_size().columns - 1)
2755
+ print(f"\r\033[2K{line[:width]}", end="", file=self.output, flush=True)
2756
+ self.visible = True
2757
+
2758
+ def advance(self, count: int) -> None:
2759
+ self.sent += count
2760
+ self.draw()
2761
+
2762
+ def report(self, event: TransferOperation | TransferIssue) -> None:
2763
+ self.clear()
2764
+ if event.path in self.uploads:
2765
+ if isinstance(event, TransferIssue):
2766
+ self.issues += 1
2767
+ else:
2768
+ self.completed += 1
2769
+ _report_transfer_operation(event, self.output)
2770
+ self.draw(force=True)
2771
+
2772
+ def finish(self) -> None:
2773
+ if self.terminal:
2774
+ self.draw(force=True)
2775
+ print(file=self.output, flush=True)
2776
+ self.visible = False
2777
+
2778
+
2779
+ def _transfer(
2780
+ arguments: argparse.Namespace,
2781
+ store: ConfigurationStore,
2782
+ progress: TextIO,
2783
+ ) -> tuple[str, bool]:
2784
+ _, name, profile = _resolve_profile(arguments, store)
2785
+ root = _require_local_root(name, profile)
2786
+ print(
2787
+ f"Preparing {arguments.command} for profile '{name}'...",
2788
+ file=progress,
2789
+ flush=True,
2790
+ )
2791
+ options = _active_options(arguments)
2792
+ if options is not None:
2793
+ print(options, file=progress, flush=True)
2794
+ print("Connecting securely over FTPS...", file=progress, flush=True)
2795
+ with ExplicitFTPSTransport(profile) as transport:
2796
+ local, remote, plan = _build_plan(
2797
+ arguments,
2798
+ root,
2799
+ transport,
2800
+ _command_rules(arguments, store, profile),
2801
+ direction=arguments.command,
2802
+ progress=progress,
2803
+ include_excluded=arguments.command == "push",
2804
+ recover_artifacts=not getattr(arguments, "dry", False),
2805
+ )
2806
+ dry_run = getattr(arguments, "dry", False)
2807
+ upload_paths = {
2808
+ entry.path for entry in plan.entries
2809
+ if entry.action in {"upload", "replace-remote"}
2810
+ }
2811
+ uploads = {
2812
+ entry.path: entry.size or 0
2813
+ for entry in local.entries
2814
+ if entry.path in upload_paths
2815
+ } if arguments.command == "push" else {}
2816
+ feedback = _UploadProgress(progress, uploads, dry_run=dry_run)
2817
+ executable_actions = {
2818
+ "create-remote",
2819
+ "upload",
2820
+ "replace-remote",
2821
+ "replace-local",
2822
+ "delete-remote",
2823
+ }
2824
+ if any(entry.action in executable_actions for entry in plan.entries):
2825
+ progress_message = (
2826
+ "Dry push..."
2827
+ if dry_run
2828
+ else (
2829
+ "Pushing changes..."
2830
+ if arguments.command == "push"
2831
+ else "Pulling changes..."
2832
+ )
2833
+ )
2834
+ print(progress_message, file=progress, flush=True)
2835
+ try:
2836
+ feedback.draw(force=True)
2837
+ result = execute_transfer(
2838
+ plan,
2839
+ local_root=root,
2840
+ local=local,
2841
+ remote=remote,
2842
+ transport=transport,
2843
+ progress=feedback.report,
2844
+ dry_run=dry_run,
2845
+ byte_progress=feedback.advance if uploads and not dry_run else None,
2846
+ )
2847
+ finally:
2848
+ feedback.finish()
2849
+ return (
2850
+ _format_transfer(name, result, dry_run=dry_run),
2851
+ result.succeeded,
2852
+ )
2853
+
2854
+
2855
+ def _show_help(
2856
+ parser: argparse.ArgumentParser,
2857
+ topic: str | None,
2858
+ stdin: TextIO,
2859
+ stdout: TextIO,
2860
+ ) -> str:
2861
+ if topic is None:
2862
+ return parser.format_help().rstrip()
2863
+ subparser = next(
2864
+ action
2865
+ for action in parser._actions
2866
+ if isinstance(action, argparse._SubParsersAction)
2867
+ )
2868
+ resolved = _resolve_command_name(topic, stdin=stdin, stdout=stdout)
2869
+ return subparser.choices[resolved].format_help().rstrip()
2870
+
2871
+
2872
+ def run(
2873
+ argv: Sequence[str] | None = None,
2874
+ *,
2875
+ store: ConfigurationStore | None = None,
2876
+ stdin: TextIO = sys.stdin,
2877
+ stdout: TextIO = sys.stdout,
2878
+ stderr: TextIO = sys.stderr,
2879
+ ) -> int:
2880
+ parser = build_parser()
2881
+ raw_arguments = list(sys.argv[1:] if argv is None else argv)
2882
+ try:
2883
+ raw_arguments, profile_prefix = _resolve_invocation(
2884
+ raw_arguments,
2885
+ stdin=stdin,
2886
+ stdout=stdout,
2887
+ )
2888
+ except ConfigurationError as error:
2889
+ parser.error(str(error))
2890
+ arguments = parser.parse_args(raw_arguments)
2891
+ arguments.profile_prefix = profile_prefix
2892
+ if arguments.legend:
2893
+ if arguments.command is not None:
2894
+ parser.error("--legend cannot be combined with a command")
2895
+ print(_format_legend(stdout), file=stdout)
2896
+ return 0
2897
+ if arguments.command is None:
2898
+ parser.error("a command is required unless --legend is supplied")
2899
+ configuration_store = store or ConfigurationStore()
2900
+ exit_status = 0
2901
+ try:
2902
+ _validate_profile_prefix(arguments)
2903
+ if arguments.command == "create":
2904
+ message = _create_profile(arguments, configuration_store, stdin, stdout)
2905
+ elif arguments.command == "connect":
2906
+ message = _connect(arguments, configuration_store)
2907
+ elif arguments.command == "map":
2908
+ message = _map(arguments, configuration_store, stdin, stdout)
2909
+ elif arguments.command == "rules":
2910
+ message = _manage_rules(arguments, configuration_store)
2911
+ elif arguments.command in {"exclude", "include"}:
2912
+ message = _change_rules(
2913
+ arguments,
2914
+ configuration_store,
2915
+ include=arguments.command == "include",
2916
+ )
2917
+ elif arguments.command == "remove":
2918
+ message = _remove(arguments, configuration_store)
2919
+ elif arguments.command == "root":
2920
+ message = _show_root(arguments, configuration_store)
2921
+ elif arguments.command == "profile":
2922
+ message = _show_profile(arguments, configuration_store)
2923
+ elif arguments.command == "profiles":
2924
+ message = _list_profiles(configuration_store)
2925
+ elif arguments.command == "list":
2926
+ message = (
2927
+ _list_remote(arguments, configuration_store, stderr, stdout)
2928
+ if arguments.remote
2929
+ else _list_local(arguments, configuration_store, stdout)
2930
+ )
2931
+ elif arguments.command == "diff":
2932
+ _diff(arguments, configuration_store, stderr, stdout)
2933
+ message = None
2934
+ elif arguments.command in {"push", "pull"}:
2935
+ message, succeeded = _transfer(
2936
+ arguments,
2937
+ configuration_store,
2938
+ stderr,
2939
+ )
2940
+ if not succeeded:
2941
+ exit_status = 1
2942
+ elif arguments.command == "help":
2943
+ message = _show_help(parser, arguments.topic, stdin, stdout)
2944
+ elif arguments.command == "version":
2945
+ message = __version__
2946
+ else:
2947
+ parser.error(f"unknown command: {arguments.command}")
2948
+ return 2
2949
+ except (
2950
+ ConfigurationError,
2951
+ RuleError,
2952
+ PatternOperandError,
2953
+ SelectionError,
2954
+ SnapshotError,
2955
+ TransportError,
2956
+ TransferError,
2957
+ ) as error:
2958
+ print(f"hlsync: error: {error}", file=stderr)
2959
+ return 1
2960
+ if message is not None:
2961
+ print(message, file=stdout)
2962
+ return exit_status
2963
+
2964
+
2965
+ def main(argv: Sequence[str] | None = None) -> int:
2966
+ return run(argv)