Repository navigation
Expand file tree
/
Copy pathdirsync.py
More file actions
2157 lines (1872 loc) · 88.6 KB
/
Copy pathdirsync.py
File metadata and controls
2157 lines (1872 loc) · 88.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
dirsync.py
----------
A zero-dependency, interactive file synchronizer for any two directories.
Designed to work on any platform: Linux, macOS, Windows, Android (Termux).
Sync directions:
src → dst Source to Destination (default)
dst → src Destination to Source (reverse)
smart Bidirectional — newest file wins
Selection modes:
all Apply all detected changes (default)
--pick Choose files one by one
--ext Filter by file extension(s)
Usage:
python dirsync.py # interactive mode
python dirsync.py --dry-run # simulate, no changes made
python dirsync.py --auto # sync without confirmation prompt
python dirsync.py --direction smart # bidirectional sync
python dirsync.py --direction dst # reverse sync
python dirsync.py --pick # manual file selection
python dirsync.py --ext jsx css js # filter by extension(s)
python dirsync.py --watch 5 # watch mode, sync every 5 seconds
python dirsync.py --log # save sync history to a log file
python dirsync.py --source /a --dest /b # override paths on the fly
python dirsync.py --no-color # disable color output (pipes, CI)
python dirsync.py --config # open settings menu
python dirsync.py --backups # browse and restore backups
python dirsync.py --version
python dirsync.py --help
"""
__version__ = "2.1.0"
__author__ = "FuegoDev"
__license__ = "MIT"
import os
import sys
import json
import stat
import copy
import shutil
import atexit
import fnmatch
import hashlib
import argparse
import tempfile
import time
from pathlib import Path
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor
# ─────────────────────────────────────────────────────────────────
# CONFIGURATION
# ─────────────────────────────────────────────────────────────────
SCRIPT_DIR = Path(__file__).resolve().parent
# Android's shared storage (/storage/emulated/0/, /sdcard/) uses a FUSE
# layer that can silently drop writes or hide files on the next read.
# Termux's private home directory is always reliable. When the script
# lives in shared storage, we store config and log in home instead.
_SHARED_ROOTS = ("/storage/", "/sdcard/", "/mnt/sdcard/")
_in_shared = any(str(SCRIPT_DIR).startswith(r) for r in _SHARED_ROOTS)
CONFIG_DIR = Path.home() if _in_shared else SCRIPT_DIR
CONFIG_FILE = CONFIG_DIR / ".dirsync_config.json"
LOG_FILE = CONFIG_DIR / ".dirsync.log"
LOCK_FILE = CONFIG_DIR / ".dirsync.lock"
DEFAULT_CONFIG = {
"source": "",
"destination": "",
"ignore_patterns": [
"node_modules", ".git", ".vite", "dist", "build",
".cache", "__pycache__", "*.log", "package-lock.json",
".DS_Store", "Thumbs.db",
],
"delete_orphans": False,
"backup_before_sync": False,
"backup_mode": "full",
}
# Populated in main() based on TTY detection and --no-color flag.
# All cprint() calls read this; nothing else should touch it directly.
_USE_COLOR = True
# ─────────────────────────────────────────────────────────────────
# TERMINAL COLORS
# ─────────────────────────────────────────────────────────────────
class Colors:
RESET = "\033[0m"
BOLD = "\033[1m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
RED = "\033[91m"
CYAN = "\033[96m"
MAGENTA = "\033[95m"
BLUE = "\033[94m"
GREY = "\033[90m"
WHITE = "\033[97m"
def cprint(msg, color="", bold=False):
if _USE_COLOR:
prefix = Colors.BOLD if bold else ""
print(f"{prefix}{color}{msg}{Colors.RESET}")
else:
print(msg)
def print_rule(color=Colors.GREY):
cprint("─" * 52, color)
def print_section(label, color=Colors.CYAN):
cprint(f"── {label} ──", color, bold=True)
def banner():
inner = f"dirsync v{__version__} by FuegoDev"
subtitle = "Two-directory file synchronizer"
width = max(len(inner), len(subtitle)) + 8
print()
cprint("╔" + "═" * width + "╗", Colors.CYAN, bold=True)
cprint("║" + inner.center(width) + "║", Colors.CYAN, bold=True)
cprint("║" + subtitle.center(width) + "║", Colors.CYAN, bold=True)
cprint("╚" + "═" * width + "╝", Colors.CYAN, bold=True)
print()
# ─────────────────────────────────────────────────────────────────
# EXCEPTIONS
# ─────────────────────────────────────────────────────────────────
class UserCancelled(Exception):
"""Raised anywhere the user intentionally aborts an operation."""
# ─────────────────────────────────────────────────────────────────
# LOCK FILE
# ─────────────────────────────────────────────────────────────────
def _pid_alive(pid):
"""Return True if the given PID is a running process."""
try:
os.kill(pid, 0)
return True
except OSError:
# ProcessLookupError (no such process) or PermissionError (exists but
# we can't signal it) both resolve the same way for our purposes.
return False
def acquire_lock():
"""
Create a PID lock file to prevent concurrent runs against the same config.
Returns True if acquired, False if another live instance is already running.
"""
if LOCK_FILE.exists():
try:
pid = int(LOCK_FILE.read_text().strip())
if _pid_alive(pid):
return False
except (ValueError, IOError):
pass # stale or corrupt — overwrite it
try:
LOCK_FILE.write_text(str(os.getpid()))
atexit.register(_release_lock)
except IOError:
pass # lock is best-effort; don't abort a sync over a write failure
return True
def _release_lock():
try:
LOCK_FILE.unlink()
except OSError:
pass
# ─────────────────────────────────────────────────────────────────
# CONFIG — LOAD / SAVE / DISPLAY
# ─────────────────────────────────────────────────────────────────
def load_config():
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
config = json.load(f)
# Backfill any keys added in newer versions of the script.
# deepcopy prevents sharing mutable defaults (e.g. the patterns list)
# between the loaded config and DEFAULT_CONFIG.
for key, val in DEFAULT_CONFIG.items():
config.setdefault(key, copy.deepcopy(val))
# Defensive check: a comma inside a stored pattern is almost
# certainly leftover corruption from the pre-TICKET-10 "Add a
# pattern" prompt, which never split multi-pattern input. This
# is informational only — the config on disk is never rewritten
# automatically; fixing it is left to explicit user action via
# the menu.
suspicious = [p for p in config["ignore_patterns"] if "," in p]
if suspicious:
cprint(f"[!] Suspicious ignore pattern(s) containing a comma: {', '.join(suspicious)}", Colors.YELLOW)
cprint(" These likely came from a previous multi-pattern entry that wasn't split.", Colors.YELLOW)
cprint(" Fix them via --config → [6] Manage ignored patterns.", Colors.YELLOW)
return config
except (json.JSONDecodeError, IOError) as e:
cprint(f"[!] Corrupted config ({e}), using defaults.", Colors.YELLOW)
return copy.deepcopy(DEFAULT_CONFIG)
def save_config(config):
"""
Write config atomically: write to a sibling temp file, then rename.
This prevents a half-written config if the process is killed mid-save.
Permissions are set to 0o600 — config contains file paths that other
users on the same machine don't need to read.
"""
tmp_path = None
try:
fd, tmp_path = tempfile.mkstemp(dir=CONFIG_DIR, prefix=".dirsync_tmp_")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, CONFIG_FILE)
try:
os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass # best-effort; not fatal on Windows or restricted environments
cprint(f"[✓] Config saved → {CONFIG_FILE}", Colors.GREEN)
except IOError as e:
cprint(f"[✗] Could not save config: {e}", Colors.RED)
cprint("[!] Make sure the script directory is writable.", Colors.YELLOW)
if tmp_path:
try:
os.unlink(tmp_path)
except OSError:
pass
def show_config(config):
print_section("Current configuration")
src = config["source"] or "(not set)"
dst = config["destination"] or "(not set)"
cprint(f" Source : {src}", Colors.BLUE)
cprint(f" Destination : {dst}", Colors.BLUE)
cprint(f" Ignored : {', '.join(config['ignore_patterns'])}", Colors.GREY)
cprint(f" Delete orphans : {'Yes' if config['delete_orphans'] else 'No'}", Colors.GREY)
cprint(f" Auto backup : {'Yes' if config['backup_before_sync'] else 'No'}", Colors.GREY)
cprint(f" Backup mode : {config['backup_mode']}", Colors.GREY)
print()
def _prompt_config_path(current, label):
"""
Prompt for a new value for a path-typed config field (source/destination).
Resolves the input via realpath/expanduser and checks existence. A
nonexistent path is allowed but requires explicit confirmation (default
no), since it may simply not have been created yet. Resolution failures
(e.g. invalid characters, embedded NUL bytes) are caught and re-prompted
instead of crashing the menu.
Returns the resolved path string to assign, or None if the field should
be left unchanged (empty input, or declined confirmation on a missing
path) — both cases are treated identically, per spec.
"""
while True:
v = input(f"New {label} [{current}]: ").strip()
if not v:
return None
try:
resolved = os.path.realpath(os.path.expanduser(v))
except (OSError, ValueError) as e:
cprint(f"[✗] Invalid path: {e}", Colors.RED)
continue
if not Path(resolved).exists():
cprint(f"[!] This path does not exist: {resolved}", Colors.YELLOW)
confirm = input("Save it anyway? (y/n) [n]: ").strip().lower()
if confirm not in ("y", "yes"):
return None
return resolved
def run_config_menu(config):
"""
Interactive settings menu. Loops until the user presses 0 or Ctrl+C.
Saves only when at least one value was actually changed.
"""
changed = False
while True:
show_config(config)
cprint("What would you like to change?", Colors.YELLOW, bold=True)
print(" [1] Source path")
print(" [2] Destination path")
print(" [3] Delete orphan files")
print(" [4] Enable auto-backup before sync")
print(" [5] Backup mode (full / focus)")
print(" [6] Manage ignored patterns")
print(" [0] Back")
print()
try:
choice = input("Your choice: ").strip()
except KeyboardInterrupt:
print()
break
if choice == "0":
break
try:
if choice == "1":
v = _prompt_config_path(config["source"], "source path")
if v is not None:
config["source"] = v
changed = True
elif choice == "2":
v = _prompt_config_path(config["destination"], "destination path")
if v is not None:
config["destination"] = v
changed = True
elif choice == "3":
cur = "Yes" if config["delete_orphans"] else "No"
v = input(f"Delete orphan files? (y/n) [{cur}]: ").strip().lower()
if v in ("y", "yes"):
config["delete_orphans"] = True
changed = True
elif v in ("n", "no"):
config["delete_orphans"] = False
changed = True
elif choice == "4":
cur = "Yes" if config["backup_before_sync"] else "No"
v = input(f"Enable auto-backup? (y/n) [{cur}]: ").strip().lower()
if v in ("y", "yes"):
config["backup_before_sync"] = True
changed = True
elif v in ("n", "no"):
config["backup_before_sync"] = False
changed = True
elif choice == "5":
cprint(f"\nCurrent backup mode: {config['backup_mode']}", Colors.GREY)
print(" [1] full")
print(" [2] focus")
print(" [0] Cancel")
try:
sub = input("Choice: ").strip()
except KeyboardInterrupt:
print()
continue
if sub == "1":
config["backup_mode"] = "full"
changed = True
cprint("[✓] Backup mode set to 'full'.", Colors.GREEN)
elif sub == "2":
config["backup_mode"] = "focus"
changed = True
cprint("[✓] Backup mode set to 'focus'.", Colors.GREEN)
elif sub == "0":
pass
else:
cprint("[?] Invalid choice.", Colors.GREY)
elif choice == "6":
print("\nCurrent patterns:", ", ".join(config["ignore_patterns"]))
print(" [a] Add a pattern")
print(" [r] Remove a pattern")
try:
sub = input("Choice: ").strip().lower()
except KeyboardInterrupt:
print()
continue
if sub == "a":
print("Pattern(s) to add — separate multiple with commas.")
print("Use '/' for an exact path (e.g. exports/projects), or a bare name")
raw = input("to match anywhere (e.g. node_modules): ").strip()
if raw:
# A bare single-pattern entry (no comma) is just a
# one-element list here — same code path, no special case.
candidates = [p.strip() for p in raw.split(",")]
candidates = [p for p in candidates if p] # drop empties from stray/trailing commas
added, skipped = [], []
for p in candidates:
if p not in config["ignore_patterns"]:
config["ignore_patterns"].append(p)
added.append(p)
changed = True
else:
skipped.append(p)
if added:
cprint(f"[+] {len(added)} pattern(s) added: {', '.join(added)}", Colors.GREEN)
if skipped:
cprint(f"[i] Already present, skipped: {', '.join(skipped)}", Colors.GREY)
elif sub == "r":
p = input("Pattern to remove: ").strip()
if p in config["ignore_patterns"]:
config["ignore_patterns"].remove(p)
cprint(f"[-] '{p}' removed.", Colors.YELLOW)
changed = True
else:
cprint("[?] Pattern not found.", Colors.GREY)
except KeyboardInterrupt:
print()
continue
if changed:
save_config(config)
else:
cprint("[i] No changes made.", Colors.GREY)
# ─────────────────────────────────────────────────────────────────
# FIRST-RUN WIZARD
# ─────────────────────────────────────────────────────────────────
def _prompt_path(label):
"""Ask for a directory path, re-prompting until a non-empty value is given."""
while True:
try:
value = input(f" {label}: ").strip()
except KeyboardInterrupt:
print()
raise UserCancelled("Setup interrupted. No config saved.")
if value:
return os.path.realpath(os.path.expanduser(value))
cprint(" [!] This field is required — please enter a path.", Colors.YELLOW)
def first_run_wizard(config):
"""
Guide the user through setting source and destination paths.
Both fields are mandatory — the wizard re-prompts until filled.
Only saves config once both paths are provided.
"""
cprint("Let's configure your sync paths.", Colors.CYAN, bold=True)
cprint(f"(Config will be saved to: {CONFIG_FILE})", Colors.GREY)
print()
if not config["source"]:
config["source"] = _prompt_path("Source directory path ")
if not config["destination"]:
config["destination"] = _prompt_path("Destination directory path")
save_config(config)
print()
return config
# ─────────────────────────────────────────────────────────────────
# FILE UTILITIES
# ─────────────────────────────────────────────────────────────────
def file_hash(filepath):
"""Return the MD5 hex digest of a file's content, or None on any read error."""
h = hashlib.md5()
try:
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
except (IOError, PermissionError):
return None
def should_ignore(path_str, ignore_patterns):
"""
Return True if path_str matches an ignore pattern.
Two matching modes, selected per-pattern by the presence of '/':
- No '/' in the pattern: matched against each individual path
component via fnmatch (legacy behavior, unchanged) — e.g.
"node_modules" or "*.log" match at any depth.
- '/' present in the pattern: matched against the FULL relative
path (POSIX-style) via fnmatch — e.g. "exports/projects" only
excludes that exact path, leaving any other "projects" folder
elsewhere in the tree untouched. A leading or trailing '/' is
stripped before matching, since relative paths from the scan
never start or end with one — "/exports/projects" and
"exports/projects/" both behave identically to
"exports/projects".
Path-mode patterns must always use forward slashes, even on
Windows — relative paths are normalized to POSIX style throughout
the codebase (see collect_files()/_scan_tree()) specifically so a
single pattern works identically on every platform.
"""
rel_posix = Path(path_str).as_posix()
parts = Path(path_str).parts
for pattern in ignore_patterns:
if "/" in pattern:
pat = pattern.strip("/")
if fnmatch.fnmatch(rel_posix, pat):
return True
else:
if any(fnmatch.fnmatch(part, pattern) for part in parts):
return True
return False
def _scan_tree(dir_path, ignore_patterns, rel_prefix=""):
"""
Recursively yield (full_path, stat_result, rel) for every regular file
under dir_path, using os.scandir instead of Path.iterdir() + separate
is_*()/stat() calls.
Each os.DirEntry caches type information from the underlying readdir()
call (d_type on most POSIX systems, a native cache on Windows), so
is_symlink()/is_dir()/is_file() are answered without a fresh stat() per
property. The single entry.stat() taken here for regular files is the
same stat() reused by the caller for size/mtime — never a second,
redundant stat() call.
Symlinks are skipped entirely (strict, unchanged behavior) before any
stat() is attempted, so a broken symlink or one pointing at a directory
never reaches stat(). Directories that raise PermissionError on
os.scandir() are skipped silently rather than aborting the whole scan.
Directory pruning: a directory is not descended into if its own
relative path already matches ignore_patterns (via should_ignore(),
same per-component or full-path fnmatch logic used for files). This
is a pure speed optimization, not a behavior change — for
component-mode patterns, should_ignore() checks EVERY component of
a path, so any file underneath a matched directory would already
have that matched component in its own path and would have been
filtered out downstream anyway. The same guarantee holds for
full-path patterns (those containing '/'): should_ignore() is
called at every level of the descent with the relative path
accumulated so far, so a pattern like "exports/projects" triggers
pruning at the exact moment the walk reaches that specific
directory — no sooner, no later — and every file beneath it would
have matched too. Pruning just skips the os.scandir() work of
walking into directories like node_modules, .git, dist, build,
__pycache__, .cache before throwing their contents away.
"""
try:
with os.scandir(dir_path) as it:
for entry in it:
try:
if entry.is_symlink():
continue
rel = f"{rel_prefix}/{entry.name}" if rel_prefix else entry.name
if entry.is_dir():
if should_ignore(rel, ignore_patterns):
continue # pruned — no file below can escape this filter anyway
yield from _scan_tree(entry.path, ignore_patterns, rel)
elif entry.is_file():
try:
st = entry.stat()
except OSError:
# Race: file vanished between scandir() and stat().
continue
yield entry.path, st, rel
except OSError:
# Race: entry vanished before its type could be queried.
continue
except PermissionError:
pass
def _files_differ(a, b):
"""
Return True if two file entries represent different content.
Size is checked first (O(1), no I/O). Hash is computed lazily and
cached in the entry dict, so each file is read at most once per scan.
If either file is unreadable (hash returns None), the pair is treated
as different rather than silently skipping a potential change.
"""
if a["size"] != b["size"]:
return True
if "hash" not in a:
a["hash"] = file_hash(a["full"])
if "hash" not in b:
b["hash"] = file_hash(b["full"])
ha, hb = a["hash"], b["hash"]
if ha is None or hb is None:
# At least one file is unreadable — flag as different so the user
# sees an error during apply rather than a silent skip.
return True
return ha != hb
def collect_files(root_dir, ignore_patterns, ext_filter=None):
"""
Walk root_dir and return a dict keyed by POSIX-style relative path:
{ "sub/dir/file.txt": { size, mtime_raw, mtime, full } }
Using .as_posix() for keys guarantees forward slashes on all platforms,
so the same key works for both sides of a comparison on Windows.
Hash values are NOT computed here — they are populated on demand by
_files_differ() to avoid reading files that haven't changed.
ext_filter: list of lowercase extensions to keep (e.g. ['.jsx', '.css']).
_scan_tree() already prunes ignored directories during the walk, but
should_ignore() is still applied here to each file's own relative
path — a file's own name can match a pattern (*.log,
package-lock.json, .DS_Store, ...) independently of any directory
it lives in, and that check isn't covered by directory pruning.
"""
root = Path(root_dir)
files = {}
if not root.exists():
return files
for full_path, st, rel in _scan_tree(str(root), ignore_patterns):
if should_ignore(rel, ignore_patterns):
continue
if ext_filter and Path(full_path).suffix.lower() not in ext_filter:
continue
files[rel] = {
"size": st.st_size,
"mtime_raw": st.st_mtime,
"mtime": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
"full": full_path,
}
return files
# ─────────────────────────────────────────────────────────────────
# CHANGE DETECTION
# ─────────────────────────────────────────────────────────────────
def _max_hash_workers():
"""
Bound the hashing thread pool.
Termux/Android often runs on constrained hardware, over shared
storage with FUSE overhead — cap harder there via TERMUX_VERSION
detection. Elsewhere, hashing is I/O-bound (disk reads) and
hashlib's OpenSSL backend releases the GIL on large chunks, so
real parallelism is achieved with threads; oversubscribing cores
(2x) helps hide read latency, capped at 16 to avoid exhausting
file descriptors on very large trees.
"""
is_termux = bool(os.environ.get("TERMUX_VERSION"))
base = os.cpu_count() or 2
return 4 if is_termux else min(16, base * 2)
def _prehash_candidates(common, src_files, dst_files):
"""
Warm the per-entry hash cache for every same-size candidate pair in
`common`, in parallel, before the sequential comparison passes run.
Pass 1 (no I/O): a pair whose sizes already differ is already known
to be "different" — no hash is needed, so it's skipped here
entirely. This preserves the existing lazy-hashing rule: only
same-size pairs ever get hashed.
Pass 2 (bounded parallel I/O): every entry that still needs a hash
is collected into a dict keyed by id(entry) — using the entry
object's identity (not its path) guarantees each physical file is
submitted to the pool at most once, even if it were reachable from
more than one candidate pair. Each task calls the existing
file_hash(), which already returns None on any read error instead
of raising, so no exception can escape a task.
After this returns, _files_differ() runs normally in sequence and
always finds a warm cache (entry["hash"] already set) — its
behavior, its return value, and the caller-visible output of
detect_changes() are all unchanged. Only *when* each hash gets
computed has changed, not *whether* or *how many times*.
"""
candidates = {}
for rel in common:
s, d = src_files[rel], dst_files[rel]
if s["size"] != d["size"]:
continue # size alone already proves "different" — no hash needed
candidates[id(s)] = s
candidates[id(d)] = d
if not candidates:
return # nothing to hash — pool is never created, zero overhead
def _hash_entry(entry):
entry["hash"] = file_hash(entry["full"])
with ThreadPoolExecutor(max_workers=_max_hash_workers()) as ex:
# list() forces full consumption so the `with` block's clean
# shutdown/join happens only after every task has completed —
# including on Ctrl+C, where the context manager still waits
# for in-flight tasks before the KeyboardInterrupt propagates.
list(ex.map(_hash_entry, candidates.values()))
def detect_changes(src_files, dst_files, direction="src"):
"""
Compare src_files and dst_files according to the sync direction.
direction:
'src' → source is authoritative (src → dst)
'dst' → destination is authoritative (dst → src)
'smart' → newest file on either side wins (bidirectional)
Before the per-direction comparison below, _prehash_candidates()
pre-populates the hash cache for every common, same-size pair in a
bounded thread pool. This is a pure speed optimization: the
sequential _files_differ() calls that follow are unchanged and
simply find the cache already warm, so the returned to_copy /
to_delete are identical to the fully-sequential implementation.
Returns:
to_copy : dict of files to copy, keyed by relative path
to_delete : sorted list of relative paths to remove
"""
src_set = set(src_files.keys())
dst_set = set(dst_files.keys())
common = src_set & dst_set
to_copy = {}
to_delete = []
_prehash_candidates(common, src_files, dst_files)
if direction == "src":
for rel in sorted(src_set - dst_set):
to_copy[rel] = {
"from": src_files[rel]["full"],
"tag": "new",
"info": src_files[rel],
}
for rel in sorted(common):
if _files_differ(src_files[rel], dst_files[rel]):
to_copy[rel] = {
"from": src_files[rel]["full"],
"tag": "modified",
"src_info": src_files[rel],
"dst_info": dst_files[rel],
}
to_delete = sorted(dst_set - src_set)
elif direction == "dst":
for rel in sorted(dst_set - src_set):
to_copy[rel] = {
"from": dst_files[rel]["full"],
"tag": "new",
"info": dst_files[rel],
}
for rel in sorted(common):
if _files_differ(src_files[rel], dst_files[rel]):
to_copy[rel] = {
"from": dst_files[rel]["full"],
"tag": "modified",
"src_info": dst_files[rel],
"dst_info": src_files[rel],
}
to_delete = sorted(src_set - dst_set)
elif direction == "smart":
for rel in sorted(src_set - dst_set):
to_copy[rel] = {
"from": src_files[rel]["full"],
"tag": "new (src)",
"info": src_files[rel],
"winner": "source",
}
for rel in sorted(dst_set - src_set):
to_copy[rel] = {
"from": dst_files[rel]["full"],
"tag": "new (dst)",
"info": dst_files[rel],
"winner": "destination",
}
for rel in sorted(common):
src_e = src_files[rel]
dst_e = dst_files[rel]
if not _files_differ(src_e, dst_e):
continue
if src_e["mtime_raw"] >= dst_e["mtime_raw"]:
winner, frm, win_info, lose_info = "source", src_e["full"], src_e, dst_e
else:
winner, frm, win_info, lose_info = "destination", dst_e["full"], dst_e, src_e
to_copy[rel] = {
"from": frm,
"tag": "modified",
"winner": winner,
"win_info": win_info,
"lose_info": lose_info,
}
return to_copy, to_delete
# ─────────────────────────────────────────────────────────────────
# CHANGE REPORT
# ─────────────────────────────────────────────────────────────────
def print_report(to_copy, to_delete, direction, delete_orphans, src_files, dst_files):
"""Print a detailed change report. Returns True if actionable changes exist."""
total = len(to_copy) + (len(to_delete) if delete_orphans else 0)
dir_labels = {
"src": "Source ➜ Destination",
"dst": "Destination ➜ Source",
"smart": "Bidirectional smart (newest wins)",
}
cprint(f" Mode: {dir_labels.get(direction, direction)}", Colors.MAGENTA)
print()
if not to_copy and not to_delete:
cprint("✓ No changes — both directories are identical.", Colors.GREEN, bold=True)
return False
new_files = {r: v for r, v in to_copy.items() if "new" in v["tag"]}
modified = {r: v for r, v in to_copy.items() if v["tag"] == "modified"}
print_section(f"{total} change(s) detected", Colors.YELLOW)
print()
if new_files:
cprint(f" [+] {len(new_files)} new file(s):", Colors.GREEN, bold=True)
for rel, v in new_files.items():
info = v.get("info", {})
label = f" [{v['winner']}]" if "winner" in v else ""
cprint(f" + {rel}{label}", Colors.GREEN)
cprint(f" Added on : {info.get('mtime', '?')}", Colors.GREY)
print()
if modified:
cprint(f" [~] {len(modified)} modified file(s):", Colors.YELLOW, bold=True)
for rel, v in modified.items():
if direction == "smart":
win_info = v.get("win_info", {})
lose_info = v.get("lose_info", {})
loser = "source" if v["winner"] == "destination" else "destination"
cprint(f" ~ {rel}", Colors.YELLOW)
cprint(f" ✓ {v['winner']:11s} (kept) : {win_info.get('mtime', '?')}", Colors.GREEN)
cprint(f" ✗ {loser:11s} (skipped) : {lose_info.get('mtime', '?')}", Colors.GREY)
else:
src_info = v.get("src_info", {})
dst_info = v.get("dst_info", {})
cprint(f" ~ {rel}", Colors.YELLOW)
cprint(f" Source modified : {src_info.get('mtime', '?')}", Colors.GREY)
cprint(f" Destination modified : {dst_info.get('mtime', '?')}", Colors.GREY)
print()
if to_delete:
ref = dst_files if direction in ("src", "smart") else src_files
if delete_orphans:
cprint(f" [-] {len(to_delete)} orphan file(s) to delete:", Colors.RED, bold=True)
else:
cprint(f" [?] {len(to_delete)} orphan file(s) (deletion disabled):", Colors.GREY, bold=True)
for rel in to_delete:
info = ref.get(rel, {})
color = Colors.RED if delete_orphans else Colors.GREY
mark = "-" if delete_orphans else "?"
cprint(f" {mark} {rel}", color)
cprint(f" Last modified : {info.get('mtime', '?')}", Colors.GREY)
print()
return True
# ─────────────────────────────────────────────────────────────────
# MANUAL FILE SELECTION (--pick)
# ─────────────────────────────────────────────────────────────────
def pick_files(to_copy, to_delete):
"""
Let the user choose exactly which files to include in the sync.
Returns filtered (to_copy, to_delete).
Raises UserCancelled if the user explicitly aborts.
"""
items = [("copy", rel, v) for rel, v in to_copy.items()]
items += [("delete", rel, None) for rel in to_delete]
if not items:
return to_copy, to_delete
print_section("Manual file selection", Colors.CYAN)
cprint(" Enter numbers to include (e.g. 1 3 5), 'all', or '0' to cancel.", Colors.WHITE)
print()
for i, (action, rel, v) in enumerate(items, 1):
if action == "copy":
tag = v.get("tag", "?")
sym = "+" if "new" in tag else "~"
color = Colors.GREEN if "new" in tag else Colors.YELLOW
cprint(f" [{i:2d}] {sym} {rel}", color)
else:
cprint(f" [{i:2d}] - {rel} (orphan)", Colors.RED)
print()
try:
raw = input("Selected numbers (or 'all'): ").strip().lower()
except KeyboardInterrupt:
print()
raise UserCancelled("Selection cancelled.")
if raw == "0":
raise UserCancelled("Selection cancelled.")
if raw in ("all", "a"):
return to_copy, to_delete
try:
indices = {int(x) for x in raw.split()}
except ValueError:
cprint("[!] Invalid input — all files will be processed.", Colors.YELLOW)
return to_copy, to_delete
selected_copy = {}
selected_delete = []
for i, (action, rel, v) in enumerate(items, 1):
if i not in indices:
continue
if action == "copy":
selected_copy[rel] = v
else:
selected_delete.append(rel)
cprint(f"\n[✓] {len(selected_copy) + len(selected_delete)} file(s) selected.", Colors.CYAN)
print()
return selected_copy, selected_delete
# ─────────────────────────────────────────────────────────────────
# BACKUP
# ─────────────────────────────────────────────────────────────────
# Characters invalid in Windows filenames — also stripped on other
# platforms for a single, predictable naming convention everywhere.
_INVALID_NAME_CHARS = '<>:"/\\|?*'
def backup_root_dir():
"""
Centralized root for all backups (full and, later, focus), placed
under CONFIG_DIR rather than as a sibling of the backed-up target.
CONFIG_DIR already carries the Termux/shared-storage detection
(_in_shared) used for the config and lock files, so centralizing
backups here makes them Termux-safe for free, with no new FUSE
detection logic to write or keep in sync.
"""
root = CONFIG_DIR / "dirsync_backups"
root.mkdir(parents=True, exist_ok=True)
return root
def _sanitize_project_name(name):
"""Replace characters invalid in Windows filenames with '_'."""
for ch in _INVALID_NAME_CHARS:
name = name.replace(ch, "_")
return name
def _backup_name(target_path, mode, direction):
"""
Build a backup folder name following the shared full/focus convention:
{project}_{mode}_{direction}_{YYYYMMDD_HHMMSS_ffffff}
Microseconds are included so rapid successive calls (e.g. watch mode)
never collide on the same name.
"""
project = _sanitize_project_name(Path(target_path).name)
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
return f"{project}_{mode}_{direction}_{ts}"
def _write_backup_meta(backup_dir, mode, direction, original_path, source, destination):
"""
Write the .dirsync_meta.json sidecar at the root of a backup folder.
Uses the same atomic temp-file + os.replace() pattern as save_config()
to avoid ever leaving a half-written metadata file behind.
"""
meta = {
"mode": mode,
"direction": direction,
"original_path": str(original_path),
"created_at": datetime.now().isoformat(),
"config_source": str(source),
"config_destination": str(destination),
}
meta_path = backup_dir / ".dirsync_meta.json"
tmp_path = None
try:
fd, tmp_path = tempfile.mkstemp(dir=backup_dir, prefix=".dirsync_meta_tmp_")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(meta, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, meta_path)
except (IOError, OSError) as e:
cprint(f"[!] Could not write backup metadata: {e}", Colors.YELLOW)
if tmp_path:
try:
os.unlink(tmp_path)
except OSError:
pass
def create_full_backup(path, direction, source, destination):
"""
Copy the entire target directory tree under the centralized backup
root, using the shared naming convention, then write the metadata
sidecar. Replaces the old create_backup() for the "full" backup mode.
"""
p = Path(path)