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

@@ -0,0 +1,687 @@
1
+ Metadata-Version: 2.4
2
+ Name: hlsync
3
+ Version: 1.17.2
4
+ Summary: Synchronize local files with remote servers over explicit FTPS
5
+ Author: HLSync contributors
6
+ License-Expression: LicenseRef-PolyForm-Strict-1.0.0
7
+ Project-URL: Homepage, https://github.com/ediril/hlsync
8
+ Project-URL: Changelog, https://github.com/ediril/hlsync/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/ediril/hlsync/issues
10
+ Project-URL: Repository, https://github.com/ediril/hlsync.git
11
+ Keywords: deployment,ftp,ftps,synchronization
12
+ Classifier: Development Status :: 2 - Pre-Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: System :: Archiving :: Backup
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: pathspec<2,>=0.12.1
26
+ Provides-Extra: dev
27
+ Requires-Dist: build<2,>=1; extra == "dev"
28
+ Requires-Dist: pyftpdlib[ssl]<3,>=2.0; extra == "dev"
29
+ Requires-Dist: pytest<10,>=8; extra == "dev"
30
+ Requires-Dist: ruff<1,>=0.12; extra == "dev"
31
+ Requires-Dist: setuptools>=77; extra == "dev"
32
+ Requires-Dist: twine<7,>=6; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # HLSync
36
+
37
+ HLSync (`hlsync`) is a command-line workflow for deploying local
38
+ projects to shared hosting over explicit FTP over TLS (FTPS): map once, preview
39
+ the diff, then push the intended files.
40
+
41
+ HLSync is pre-alpha software. Commands and configuration may change before a
42
+ stable release.
43
+
44
+ ## Who is it for?
45
+
46
+ HLSync is for people who still deploy websites or small projects to shared hosting over FTP.
47
+
48
+ If your current process is basically “figure out which files changed and upload them,” HLSync gives you a safer, more repeatable version of that workflow without requiring a full deployment system.
49
+
50
+ It works best when your local project is the source of truth and the remote server is simply where the project gets deployed.
51
+
52
+ ## Installation
53
+
54
+ HLSync requires Python 3.10 or newer. Install it from PyPI as an isolated
55
+ command-line tool with uv:
56
+
57
+ ```console
58
+ uv tool install hlsync
59
+ ```
60
+
61
+ If you previously installed `hook-line-sync`, switch with:
62
+
63
+ ```console
64
+ uv tool uninstall hook-line-sync
65
+ uv tool install hlsync
66
+ ```
67
+
68
+ Upgrade later with:
69
+
70
+ ```console
71
+ uv tool upgrade hlsync
72
+ ```
73
+
74
+ The FTPS server must support explicit TLS with protected data connections
75
+ (`AUTH TLS` and `PROT P`). HLSync prefers MLSD listings; servers without MLSD
76
+ use Unix-style LIST listings with SIZE and MDTM for exact file metadata.
77
+ LIST mode includes dotfiles and requires two extra requests per file. Push additionally
78
+ requires MFMT (or writable MDTM on vsFTPd) and MDTM read-back to apply and verify
79
+ uploaded-file timestamps.
80
+
81
+ ## Quick start
82
+
83
+ HLSync reads credentials from environment variables. The defaults are:
84
+
85
+ ```text
86
+ PROD_FTPS_USERNAME
87
+ PROD_FTPS_PASSWORD
88
+ ```
89
+
90
+ From the local project root:
91
+
92
+ ```console
93
+ hlsync create prod --host ftp.example.com --remote-root /public_html/site
94
+ hlsync connect
95
+ hlsync exclude .git node_modules
96
+ hlsync diff
97
+ hlsync push
98
+ ```
99
+
100
+ `create` proposes the current directory as the local root; pressing Enter accepts
101
+ it. Declining prompts for another local folder. Use `--local-root PATH` to
102
+ provide one directly. Both `diff` and `push` recurse by default. Use `-s` /
103
+ `--shallow` to process only the selected folder's immediate contents.
104
+
105
+ ## Profiles and mapping
106
+
107
+ A profile identifies one deployment target: protocol, host, port, remote root,
108
+ credential environment-variable names, and one local root. Multiple
109
+ profiles may use the same server. Credential values are never stored in
110
+ `~/.hlsync/configs.json`.
111
+
112
+ FTPS is the default and currently the only protocol. Override credential
113
+ variable names when creating a profile if needed:
114
+
115
+ ```console
116
+ hlsync create staging \
117
+ --host ftp.example.com \
118
+ --remote-root /public_html/staging \
119
+ --local-root /path/to/project \
120
+ --username-env STAGING_FTPS_USERNAME \
121
+ --password-env STAGING_FTPS_PASSWORD
122
+ ```
123
+
124
+ HLSync stores the canonical absolute local root and maps every descendant to
125
+ the same relative path under the remote root. Roots may not overlap across
126
+ profiles. Remapping requires confirmation and defaults to no.
127
+
128
+ Use `map` to change either side after creation. Omit `--local-root` to use the
129
+ current directory; changing only the remote root preserves the local root:
130
+
131
+ ```console
132
+ hlsync map staging --local-root /new/local/path
133
+ hlsync map staging --remote-root /new/remote/root
134
+ ```
135
+
136
+ The normal workflow is to run HLSync inside a mapped root or one of its
137
+ descendants. The current directory selects the containing profile and relative
138
+ local scope automatically:
139
+
140
+ ```console
141
+ hlsync profiles # all profiles; * marks the current one
142
+ hlsync profile # current profile name
143
+ hlsync profile --details # current profile details
144
+ hlsync profile staging # verify and print a named profile
145
+ hlsync profile staging --details
146
+ hlsync root staging # print the mapped local root only
147
+ hlsync connect # verify FTPS, then disconnect
148
+ hlsync remove staging # remove local configuration only
149
+ ```
150
+
151
+ Outside every mapped root, `hlsync profile` reports that no profile is active
152
+ and suggests `hlsync PROFILE COMMAND`. Commands that require a profile need
153
+ that explicit profile prefix. Named and inferred lookups both require
154
+ `--details` for the full view.
155
+
156
+ Because `root` writes only the canonical path, it can be composed with the
157
+ shell when you want to switch directories:
158
+
159
+ ```console
160
+ cd "$(hlsync root staging)"
161
+ ```
162
+
163
+ An unqualified profile-aware command fails outside every mapped root. When you
164
+ intentionally need to operate from elsewhere—or override the profile inferred
165
+ from the current directory—put the profile before the command. The override
166
+ lasts for that command only and uses the selected profile's local root as its
167
+ working directory:
168
+
169
+ ```console
170
+ hlsync staging list
171
+ hlsync staging diff templates
172
+ hlsync staging push templates -r
173
+ ```
174
+
175
+ No profile selection is persisted between commands.
176
+
177
+ ## Synchronization rules
178
+
179
+ HLSync automatically honors `.gitignore` files as local exclusions. When the
180
+ mapped local root is inside a Git repository, this includes all ancestor
181
+ `.gitignore` files up to the nearest repository root, plus files inside the
182
+ mapped tree. Without an enclosing repository, ignore scope starts at the mapped
183
+ root. Git worktrees are supported; no Git installation is needed.
184
+ Explicit HLSync rules override this baseline; profile rules override global rules.
185
+ To deploy a Git-ignored path, use `hlsync include vendor/` (or a specific file).
186
+ Ignore files above the repository root, Git's global excludes, and the Git index
187
+ are not consulted; matching tracked files are excluded too.
188
+
189
+ **Review `hlsync push --dry -p` before pruning:** with `-p`, locally excluded
190
+ files that are already deployed are deleted remotely. Without `-p`, their remote
191
+ copies are left alone; remote exclusion rules protect paths even when pruning.
192
+
193
+ Global rules live in `~/.hlsync/rules.json`, apply to every profile, and are
194
+ created with default exclusions for version-control metadata and common logs:
195
+
196
+ ```text
197
+ **/.git/**
198
+ **/.svn/**
199
+ **/.hg/**
200
+ **/.DS_Store
201
+ **/Thumbs.db
202
+ **/desktop.ini
203
+ **/.gitignore
204
+ **/.gitmodules
205
+ **/error_log
206
+ ```
207
+
208
+ Manage global exclusions and inclusions from any directory with `-g` /
209
+ `--global`. Global operands are reusable patterns rooted at every profile's
210
+ local root; append `/` to target a complete directory tree:
211
+
212
+ ```console
213
+ hlsync exclude -g --pattern '*.tmp'
214
+ hlsync include -g --pattern 'public/*.tmp'
215
+ hlsync rules -g # inspect global rules
216
+ hlsync rules rm g4 # g prefix selects global rules
217
+ ```
218
+
219
+ Global rules apply first and profile rules apply afterward, so an ordinary
220
+ profile inclusion can override a global exclusion. `hlsync rules` merges both
221
+ layers into one folder-grouped view, placing matching profile rules after
222
+ global rules. Global rules have `g`-prefixed IDs such as `g4`; profile rule IDs
223
+ remain numeric. IDs identify only current rules and may be reused after a rule
224
+ is removed. HLSync never silently adds new defaults to an existing global
225
+ rules file.
226
+
227
+ Exclude current paths permanently:
228
+
229
+ ```console
230
+ hlsync exclude .git node_modules composer.json composer.lock
231
+ hlsync exclude '*.md'
232
+ ```
233
+
234
+ Normal wildcard operands are expanded against the current local tree and
235
+ recorded as exact paths. Quote them so the shell does not reject or alter them;
236
+ quoted and shell-expanded matches produce the same path rules.
237
+
238
+ Use `--pattern` when future matching paths should also be covered:
239
+
240
+ ```console
241
+ hlsync exclude --pattern '*.md' # this directory only
242
+ hlsync exclude --pattern '**/*.log' # every directory below this point
243
+ hlsync include --pattern 'vendor/**' # re-include a subtree
244
+ ```
245
+
246
+ Use `--anywhere` (or `--any`) to match a name at every depth, including future
247
+ files. It works with both `exclude` and `include`, and replaces `--pattern`:
248
+
249
+ ```console
250
+ hlsync exclude --anywhere filename.ext # current directory and descendants
251
+ hlsync exclude --any '*.log' # quote wildcards to preserve the pattern
252
+ hlsync exclude -g --anywhere cache/ # matching directory trees in every profile
253
+ ```
254
+
255
+ `*` matches within one path segment; a complete `**` segment crosses directory
256
+ levels. Patterns are rooted where the command runs. HLSync rules are not
257
+ Gitignore syntax: `!`, `?`, bracket patterns, absolute paths, parent traversal,
258
+ and partial-segment `**` are rejected.
259
+
260
+ Local rules define the authoritative local set. Remote rules instead protect
261
+ server-side paths from synchronization:
262
+
263
+ ```console
264
+ hlsync exclude --remote subdomains
265
+ hlsync include --remote subdomains
266
+ ```
267
+
268
+ Remote operands are declarative and never require a connection when recorded.
269
+ `subdomains` and `subdomains/` store the same exact boundary. During diff or
270
+ push, an existing remote file is left untouched; an existing remote directory
271
+ and everything beneath it are left untouched without traversing the directory.
272
+ A matching remote inclusion removes or overrides that boundary and returns the
273
+ path to normal push policy. Remote directory boundaries cannot be pierced by a
274
+ more specific child inclusion. `hlsync rules` groups the two rule targets under
275
+ `Local` and `Remote`.
276
+
277
+ Local rules keep the original compact JSON form. A missing stored `target`
278
+ means local; only remote rules add `"target": "remote"`.
279
+
280
+ Rules have stable IDs and the highest matching ID wins. HLSync removes provably
281
+ redundant exact rules but preserves ambiguous wildcard overlaps.
282
+
283
+ ```console
284
+ hlsync rules
285
+ hlsync rules rm 3
286
+ ```
287
+
288
+ Multiple operands and comma-separated groups are accepted. A literal filename
289
+ containing a comma cannot be addressed because commas delimit groups.
290
+
291
+ ## Paths and traversal
292
+
293
+ Paths are relative to the current directory inside the mapped root. Multiple
294
+ paths and wildcard patterns form one deterministic selection. Absolute paths,
295
+ parent traversal, and paths escaping the mapped root are rejected.
296
+
297
+ | Command | No path | Explicit directory | Depth option |
298
+ | --- | --- | --- | --- |
299
+ | `list` / `list --remote` | Current directory, one level | Directory contents, one level | `-r` includes descendants |
300
+ | `diff` | Complete current subtree | Complete directory subtree | `-s` limits to immediate contents |
301
+ | `push` | Complete current subtree | Complete directory subtree | `-s` limits to immediate contents |
302
+ | `pull` | Error: path required | Directory contents, one level | `-r` includes descendants |
303
+
304
+ Diff and push accept `-s` / `--shallow`; `-r` / `--recursive` remains available
305
+ to explicitly select the default. The two flags cannot be combined.
306
+
307
+ `.` explicitly selects the current directory. An immediate child directory
308
+ outside the traversal depth appears with `▸` but is diagnostic-only: it is not
309
+ created, replaced, deleted, or entered. The selected directory itself may be
310
+ created when selected files require it as their parent. An explicitly selected
311
+ remote-only directory is the deletion target itself, so recursive push
312
+ enumerates that subtree and removes its contents deepest-first. With `-s`,
313
+ deeper folders and their deletion ancestors are retained.
314
+
315
+ ## Local and remote listing
316
+
317
+ `list` reads only the local filesystem, includes dotfiles, and applies the
318
+ configured rules:
319
+
320
+ ```console
321
+ hlsync list
322
+ hlsync list templates
323
+ hlsync list '*.md'
324
+ hlsync list -r
325
+ hlsync list -r -i
326
+ ```
327
+
328
+ Use `--remote`—or the `lsr` shorthand—to inspect the equivalent FTPS
329
+ tree with the same operands and traversal controls:
330
+
331
+ ```console
332
+ hlsync list --remote
333
+ hlsync list --remote templates -r
334
+ hlsync lsr
335
+ ```
336
+
337
+ Remote listing connects read-only. Remote-excluded paths use `#` and their
338
+ directories are shown as boundaries without being entered.
339
+
340
+ Directories appear before files at each level, with each group sorted by name.
341
+ Directories end in `/`; local exclusions use `x`, remote exclusions use `#`.
342
+ Single-side listings do not report whether the other side has a copy. `-i`, `--inc`, and
343
+ `--included-only` hide excluded paths. `hlsync ls` remains an unadvertised
344
+ compatibility spelling.
345
+
346
+ ## Diff
347
+
348
+ `diff` is read-only and shows what the corresponding push would do:
349
+
350
+ ```console
351
+ hlsync diff
352
+ hlsync diff templates
353
+ hlsync diff -s
354
+ hlsync diff templates --shallow
355
+ hlsync diff index.html app.js styles.css
356
+ hlsync diff '**/*.css'
357
+ ```
358
+
359
+ Local path existence decides what is uploaded or replaced. In the default
360
+ push view, remote-only paths are retained because push never deletes without
361
+ `-p`. Preview a pruning push with:
362
+
363
+ ```console
364
+ hlsync diff --prune
365
+ hlsync diff -p
366
+ ```
367
+
368
+ `--pull` changes the perspective for changed existing files and retains
369
+ remote-only paths.
370
+
371
+ By default, diff shows only what the matching push or pull would do: uploads,
372
+ updates, deletions, and conflicts, under their folder headings. A final
373
+ `Not shown:` line counts what was hidden. Widen the view with:
374
+
375
+ ```console
376
+ hlsync diff -x # also show excluded paths (--excluded)
377
+ hlsync diff -I # compare locally excluded paths as included (--include-excluded)
378
+ hlsync diff -a # show everything, including unchanged and kept paths (--all)
379
+ ```
380
+ Recursive diff keeps file-browser order in both views: directories and their
381
+ indented contents appear before files at the parent level.
382
+ Rows without status markers retain blank status columns so names stay aligned.
383
+ Status columns stay fixed at the left; tree indentation applies to path names.
384
+ The status has three slots: action (`+`, `~`, `-`, `?`, `=`), exclusion
385
+ (`x` local, `#` remote), and presence (`l`, `r`, or blank for both sides), so
386
+ `-x` marks an excluded path that `--prune` would delete.
387
+ The `▸` marker means contents were not inspected, including excluded folders.
388
+ Diff reads directories as it traverses the tree and groups their entries under
389
+ folder headings, without separate per-directory read announcements.
390
+
391
+ For pruning, a locally excluded file is treated as absent. If it exists
392
+ remotely, default diff marks it ` x` and retains it; `diff -p` marks its
393
+ deletion as `-x`. The presence column still reports the actual copies:
394
+ blank when both exist, `r` when only the remote copy exists. An excluded
395
+ directory is a traversal boundary and is retained with `x`. An explicit local
396
+ inclusion for a descendant permits traversal;
397
+ remote exclusions remain hard boundaries. Hiding exclusions never hides a
398
+ deletion: with `-p`, an excluded path that would be deleted always shows as `-x`.
399
+
400
+ `-I` / `--include-excluded` treats locally excluded paths as included for one
401
+ `diff`, `push`, or `pull`. Exclusion rules and `.gitignore` files are not
402
+ consulted, so nested paths such as `.git/` and `.DS_Store` count too. Remote
403
+ exclusions still apply.
404
+
405
+ ```console
406
+ hlsync diff -I collectiq/data
407
+ hlsync pull -I collectiq/data/.waitlist.sqlite
408
+ hlsync push -I collectiq/data --dry
409
+ ```
410
+
411
+ Diff enters eligible child folders by default. `diff -s` or
412
+ `diff templates --shallow` reads only immediate contents. Unentered child
413
+ folders retain the `▸` marker and are retained, matching `push -s`. Unentered
414
+ folders appear by default only when they could still hold changes; an
415
+ unentered remote-only folder appears as ` r folder/ ▸` with `-a`. Without
416
+ `-p`, diff does not enter remote-only folders, since push would neither change
417
+ nor delete their contents.
418
+
419
+ Show the current status and directory notation without connecting:
420
+
421
+ ```console
422
+ hlsync --legend
423
+ ```
424
+
425
+ Diff prints each directory as it is compared. For a shell-driven review,
426
+ `--paged` prints one directory, exits, and provides the exact stateless
427
+ `--resume` command for the next deterministic directory.
428
+
429
+ Colors are automatic on terminals, disabled for pipes and redirection, and
430
+ suppressed when `NO_COLOR` is set. Text markers retain the core meaning without
431
+ color. The left column reports action or status: `x` means locally excluded
432
+ and `#` means remotely excluded. The right column reports presence: `l` means
433
+ local only, `r` means remote only, and a blank means both sides. For example,
434
+ `x notes/ ▸` exists on both sides but is locally excluded, while
435
+ `# r millionminds/ ▸` exists only remotely and is remotely excluded. Exclusion
436
+ source never changes the presence column.
437
+
438
+ ## Push and pull
439
+
440
+ Push local changes or replace explicitly selected existing local files from the
441
+ remote side:
442
+
443
+ ```console
444
+ hlsync push
445
+ hlsync push templates
446
+ hlsync push templates -r
447
+ hlsync pull index.html
448
+ hlsync pull templates -r
449
+ ```
450
+
451
+ Push uploads local-only files and replaces changed remote files. Pull replaces
452
+ changed existing local files but never restores a missing local path. A changed
453
+ file is replaced only when the source copy is strictly newer than the
454
+ destination. If the destination is newer or the same age (for example, a file
455
+ edited on the server after your last push), diff shows `?` and push or pull
456
+ stops before changing anything, listing the conflicting paths. Use `--force` to
457
+ overwrite them, or select other paths. Because HLSync copies timestamps on every
458
+ transfer, a later edit on either side makes that side newer; the check assumes
459
+ the local and server clocks roughly agree. Locally
460
+ excluded paths are not uploaded or pulled unless `-I` / `--include-excluded` is
461
+ given, which applies to everything in scope, so review `push -I --dry` first:
462
+ a bare `push -I` would upload `.git/`, `.env`, and anything else your rules keep
463
+ out. Remote-excluded paths are never
464
+ traversed or changed. Push never deletes unless `-p` / `--prune` is given;
465
+ then it deletes selected remote-only paths, including remote copies of locally
466
+ excluded files. Excluded directories on
467
+ either side are retained and never entered; an explicit local inclusion beneath
468
+ an excluded local directory is the only reason to enter that local boundary.
469
+ Push recurses into selected directories by default, including remote-only
470
+ deletion targets when pruning. `-s` /
471
+ `--shallow` processes immediate contents without entering child folders,
472
+ creating them, or deleting them. A selected remote-only directory containing
473
+ an unentered child folder is also retained.
474
+
475
+ Preview the exact push scope and operation order without changing either side:
476
+
477
+ ```console
478
+ hlsync push --dry
479
+ hlsync push templates --dry
480
+ hlsync push templates -s --dry
481
+ ```
482
+
483
+ Both `diff` and `push --dry` inherit push's recursive default. Dry push
484
+ honors `-s`, `-p`, remote exclusions, and explicit directory scope exactly as a
485
+ real push would. Interrupted-upload recovery is projected and reported but not
486
+ performed. After planning, dry push runs the live transfer executor and skips
487
+ only its mutation calls, preserving preflight, operation order, feedback, and
488
+ counts.
489
+
490
+ Dry and live push identify each local and remote directory as they scan it. A
491
+ parent is fully classified before HLSync enters eligible children, and excluded
492
+ directories are never entered. Dry-run plans use diff's compact colored `+`,
493
+ `~`, and `-` action markers under an explicit dry-push heading.
494
+
495
+ Live transfers print the colored `+`, `~`, or `-` action and path after each
496
+ operation succeeds. Failures and skips appear immediately; the final summary
497
+ contains counts without repeating errors. Dry runs use the same feedback to
498
+ show planned operations without performing them.
499
+ When no operation is needed, HLSync prints `Nothing to push` or `Nothing to
500
+ pull` without announcing an empty transfer phase. An empty push also reports
501
+ how many included files are up to date in the selected scope. Push summaries
502
+ report the outcome without a retained-path reminder.
503
+
504
+ After scanning, push shows the planned upload count and total size. Interactive
505
+ terminals show remote reads as completed/discovered directories (such as 2/12)
506
+ and the current path, without animation. The discovered total grows as eligible
507
+ subdirectories are found. During uploads, interactive
508
+ terminals display a progress bar with bytes sent and files installed. File
509
+ counts advance only after timestamp verification and installation; failures
510
+ remain visible immediately. Redirected output keeps ordinary per-file logs.
511
+ Dry runs show planned totals without simulating byte progress.
512
+
513
+ Delete selected remote-only paths explicitly with:
514
+
515
+ ```console
516
+ hlsync push --prune
517
+ hlsync push -p 'generated/*.html'
518
+ ```
519
+
520
+ Deletion is limited to the selected scope, runs only after every upload
521
+ succeeds, and is suppressed after any upload failure. Pull never deletes remote
522
+ paths.
523
+
524
+ ### Safe file replacement
525
+
526
+ Remote ownership and permissions are controlled by the server. HLSync does not
527
+ apply local modes or issue remote chmod/chown commands. Staging files are created
528
+ beside their destinations and retain server-assigned permissions when installed.
529
+ For shared writable app-data trees (for example, `ftps-deploy` and `www-data`),
530
+ configure setgid directories and an appropriate umask or default ACL to create
531
+ `664` files and `2775` directories. Setgid alone does not grant group write;
532
+ replacement files receive the staging file's mode, not the old file's mode.
533
+
534
+ HLSync never uploads directly over a live destination. A direct FTP upload can
535
+ truncate or expose a partial live file when the connection fails. Instead,
536
+ HLSync:
537
+
538
+ 1. Uploads to a uniquely named staging file beside the destination.
539
+ 2. Verifies its size.
540
+ 3. Applies the local whole-second UTC timestamp with MFMT (writable MDTM on
541
+ vsFTPd) and independently
542
+ reads it back with MDTM.
543
+ 4. Moves the existing destination to a temporary backup.
544
+ 5. Renames the verified staging file into place, then removes the backup.
545
+
546
+ This makes each file replacement recoverable, not the complete project
547
+ transactional—FTPS has no project-wide transaction. A later failure does not
548
+ roll back files already installed.
549
+
550
+ During a push, HLSync recovers its exact reserved artifacts as each selected
551
+ remote directory is read. It does not perform a separate recursive cleanup
552
+ scan. Abandoned upload files are deleted; obsolete backups are deleted when
553
+ their destination exists; a sole backup is restored when its destination is
554
+ missing. This cleanup is independent of remote-only deletion policy and does
555
+ not affect ordinary remote-only files. Concurrent pushes to one profile are not
556
+ supported.
557
+
558
+ A path-scoped permission failure skips that path or unwritable subtree while
559
+ independent paths continue. The command exits nonzero and suppresses all
560
+ pruning. Type or symlink conflicts, connection failures, and failed replacement
561
+ recovery on selected paths stop the operation when continued state cannot be
562
+ trusted.
563
+
564
+ ## Command behavior
565
+
566
+ Commands accept the shortest unique prefix. Exact names win. An ambiguous
567
+ prefix prompts for a numbered choice on an interactive terminal and fails with
568
+ the candidate list in noninteractive use.
569
+
570
+ Use `hlsync help [command]`, `hlsync version`, and `hlsync --legend` for
571
+ built-in reference. `hlsync v`, `hlsync -v`, and `hlsync --version` also print
572
+ the installed version.
573
+
574
+ ## License
575
+
576
+ HLSync is source-available under the [PolyForm Strict License 1.0.0](LICENSE).
577
+ Use is free of charge for the purposes permitted by that license, including:
578
+
579
+ - Any noncommercial purpose.
580
+ - The personal uses listed in the license, such as personal study and hobby
581
+ projects, without any anticipated commercial application.
582
+ - Use by charitable organizations, educational institutions, public research
583
+ organizations, public safety or health organizations, environmental protection
584
+ organizations, and government institutions, regardless of funding source or
585
+ obligations resulting from funding.
586
+
587
+ This license grants no rights to modify or redistribute HLSync or create works
588
+ based on it. The full license governs; this summary does not grant additional
589
+ permissions.
590
+
591
+ Commercial use outside those permissions requires a separate paid license.
592
+ Contact [Emrah Diril](mailto:emrah@diril.org) for pricing and a commercial
593
+ agreement before beginning such use.
594
+
595
+ ## Development and maintenance
596
+
597
+ Install the development environment and run the test suite:
598
+
599
+ ```console
600
+ python -m pip install -e '.[dev]'
601
+ pytest
602
+ ```
603
+
604
+ To try local changes using your installed `hlsync` command, run this from the
605
+ repository root:
606
+
607
+ ```console
608
+ uv tool install --force --reinstall .
609
+ ```
610
+
611
+ This replaces the installed tool with a build of your current checkout. Rerun
612
+ it after making changes. Use `hlsync --legend` for a quick display check, or
613
+ run `hlsync diff` from a mapped project to preview its differences.
614
+
615
+ The PyPI distribution, installed command, and Python package are all `hlsync`.
616
+ See [`TODO.md`](TODO.md) for the ordered work queue and [`CHANGELOG.md`](CHANGELOG.md) for completed changes.
617
+ [`decisions/`](decisions/) holds substantial architectural choices and their
618
+ rationale. CLI conventions and current usage are documented here; smaller
619
+ changes are explained in commit messages.
620
+
621
+ The self-contained PHP 8.3 project site is in [`website/`](website/README.md).
622
+
623
+ ### Releasing to PyPI
624
+
625
+ Releases use `<major>.<work-day>.<increment>` without leading zeroes. Versions
626
+ advance from a starting version using Git history, not the build date:
627
+
628
+ - [`version.json`](version.json) assigns a starting version to a baseline
629
+ commit. The empty root commit starts the counter at `1.16.2`; the first
630
+ source snapshot is `1.16.3`, continuing the version 1 release sequence.
631
+ - Each subsequent date with commits advances the starting work-day number
632
+ by one, regardless of how many idle days pass.
633
+ - On the baseline day, the increment advances from its starting value for
634
+ each later commit. On subsequent work days, it starts at `1` and counts
635
+ commits on that date.
636
+ - Dates use each commit's recorded committer date and timezone. Only the
637
+ first-parent history of the current checkout counts; a merge counts once.
638
+ All commits count, including documentation and maintenance changes.
639
+
640
+ The first source snapshot is `1.16.3`. Another commit on the same date is
641
+ `1.16.4`. The next date with a commit produces `1.17.1`, even after a week
642
+ without changes.
643
+
644
+ Builds require history back to the baseline and reject backwards-moving commit
645
+ dates. Wheels and source archives preserve the computed version; installed
646
+ commands do not access Git. Reinstall editable checkouts after committing to
647
+ refresh their installed version. To select a new baseline, update `version.json`
648
+ with its starting `version` and an existing `commit` on the same first-parent
649
+ history. To start a new major, use `<major>.0.1` as its starting version.
650
+ Keep the baseline fixed between releases; ordinary commits advance the version.
651
+
652
+ Use an existing PyPI account with two-factor authentication and an API token.
653
+ Twine uses credentials from `~/.pypirc` or `TWINE_USERNAME`/`TWINE_PASSWORD`,
654
+ and prompts if needed. Do not store the token in the repository.
655
+
656
+ 1. Commit the changes to release. Use a checkout with history back to the
657
+ baseline in `version.json`. Do not edit the generated `_version.py`.
658
+ 2. Update [`CHANGELOG.md`](CHANGELOG.md) when useful. Release notes and dated
659
+ headings are optional and do not block publishing.
660
+ 3. Commit any release-note changes. From that clean checkout, with development
661
+ dependencies installed, run:
662
+
663
+ ```console
664
+ ./scripts/publish.sh
665
+ ```
666
+
667
+ The script uses the repository's `.venv/bin/python` when available, otherwise
668
+ `python3`; set `PYTHON` to select another interpreter. It builds through
669
+ helpers in `scripts/helpers/`, which run identity checks, lint, tests, isolated builds,
670
+ metadata checks, and a clean wheel installation, then uploads both artifacts.
671
+ If `dist/hlsync/<version>/` already exists, it reuses those prepared artifacts after
672
+ checking their metadata. New commits advance the version;
673
+ existing artifacts are not rebuilt automatically.
674
+
675
+ To prepare artifacts without publishing, run `./scripts/publish.sh --prepare`.
676
+
677
+ 4. A Git tag is not required by PyPI. It is strongly recommended for source
678
+ provenance: tag the release commit as `v<version>`, push that tag, and create
679
+ a corresponding GitHub Release.
680
+ 5. Install the published version in a clean environment and run `hlsync --version`
681
+ before announcing it.
682
+
683
+ PyPI does not allow a published version to be replaced. If publication fails
684
+ after accepting either artifact, create another commit (an empty release-retry
685
+ commit is sufficient) and build a new release; do not reuse the failed version.
686
+ Publish from one release history: separate branches can compute the same
687
+ version and are not separate publication streams.