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