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