-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsyncmac.py
More file actions
executable file
·1286 lines (1099 loc) · 52.4 KB
/
Copy pathsyncmac.py
File metadata and controls
executable file
·1286 lines (1099 loc) · 52.4 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
"""
syncmac - 个人文件同步工具 v2
========================================
基于 rsync 的高效文件同步工具,专为 macOS 设计。
特性:
- 双向同步(通过别名 sb/sbr 切换方向)
- 实时进度显示(ANSI 动画)
- DOTFILES 模式(仅同步 .* 文件和目录)
- 组合目标(kernel, all)
- Dry-run 模式(安全预演)
- 失败文件显示(rsync code 23)
- 大文件传输进度(实际同步时显示百分比和速度)
依赖:
- Python 3.8+
- rsync(macOS 自带)
@author Hao Feng (F1)
@copyright 2020-2026, RDS
@license Internal use only
版本历史:
v2.5.1 (2026-04-24)
- 修复:在 add_synced() 和 set_transferring() 中使用 force=True 强制更新显示
- 原因:解决大文件传输时偶尔出现多行"正在同步..."的问题
- 原理:状态变化时立即更新,减少与后台 spinner 线程的竞争窗口
v2.5.0 (2026-04-23)
- 修复:回到最简单的方案,每次都清除并重新打印所有内容
- 原因:之前的分离方案过于复杂,光标位置控制容易出错
- 效果:简单可靠,每次都完整重绘,不会有残留或顺序混乱
- 优点:代码简洁,逻辑清晰,易于维护
- 重大改进:分离主状态行和文件列表区域的显示管理
- 主状态行:使用 \r 单行覆盖(高频更新,每次 spinner 变化)
- 文件列表区域:固定 7 行,只在内容变化时更新(低频更新)
- 效果:主状态行实时更新,文件列表按需显示,没有多行堆叠
- 优点:清晰分离,高效更新,用户体验更好
v2.4.0 (2026-04-23)
- 修复:恢复"最近同步"的多行显示
- 改进:使用固定 7 行显示区域,避免清除问题
- 效果:显示主状态行 + "最近同步"标题 + 最多 5 个文件名
- 原理:每次更新都清除 7 行,打印内容(填充到 7 行)
- 改进:当没有文件传输时(全部最新),显示已用时间
- 效果:用户能清楚看到程序在运行,不是卡住
- 显示:"(已检查 X.Xs)" 而不是 "(0 个文件, 0 文件/秒)"
- 重大修改:同步阶段改为单行动态更新(与扫描阶段一致)
- 效果:只显示主状态行,不显示"最近同步"等多行
- 原理:使用 \r 覆盖同一行,避免多行堆叠
- 优点:简洁、可靠、不会出现多行残留
v2.3.1 (2026-04-23)
- 修复:start_sync 不清除之前的输出(目标信息等)
- 改进:使用只增不减的行数管理(max(_lines_printed, len(lines)))
- 效果:保留前面的信息,进度显示区域动态扩展但不会收缩
- 原理:第一次打印 1 行,后续扩展到 7 行后始终保持 7 行清除
- 修复:使用固定的清除行数(10 行),避免行数变化导致清除不完整
- 原因:之前 _lines_printed 根据实际行数变化,导致清除行数不一致
- 效果:每次更新都清除 10 行,然后打印内容,_lines_printed 始终为 10
- 改进:完全简化逻辑,不再需要复杂的空行管理
- 修复:引入 _has_separator 标志,确保空行分隔的一致性
- 原因:之前 need_separator 每次重新计算,导致有时有空行有时没有
- 效果:进入 sync 阶段后始终保持空行分隔,避免 _lines_printed 不一致
- 改进:简化逻辑,使用状态标志而不是复杂的条件判断
- 修复:简化同步阶段的显示更新逻辑,统一处理所有更新
- 原因:之前的逻辑在 start_sync 中重置 _last_phase,导致 phase_changed 判断错误
- 效果:每次更新都正确清除所有之前的行,避免多行堆叠
- 改进:不再区分阶段切换和普通更新,统一处理
- 重构:引入 GLOBAL_EXCLUDES 常量,集中管理全局排除规则
- 效果:所有任务自动排除 Caches 和 __pycache__ 目录,包括未来新增任务
- 改进:简化排除规则管理,避免重复代码
- 修复:_lines_printed 始终设置为实际打印的行数,避免多行堆叠
- 原因:当文件列表从空变为有内容时,len(lines) 增加,但 _lines_printed 没有更新
- 效果:同步进度永远只显示一个屏幕区域,不会出现多行"正在同步..."
- 修复:传输状态切换时正确重置 _lines_printed,避免多行"正在传输..."
- 改进:添加 _was_transferring 状态跟踪,精确检测传输状态变化
- 效果:大文件传输时只显示单行进度,不再出现多行重复
v2.3.0 (2026-04-22)
- 修复:过滤失败文件列表,只显示实际存在的文件(排除软链接目标不存在的情况)
- 修复:传输状态和普通同步状态切换时保留足够的清除空间,避免多行残留
- 修复:阶段切换时正确清除之前的输出,避免"正在同步"多行残留
- 修复:移除 --progress 100% 时的重复计数,修复单个文件被计为 2 个的问题
- 优化:调整空行分隔逻辑,确保每个阶段之间只有 1 个空行
- 效果:目标信息 → 空行 → 扫描进度 → 完成信息 → 空行 → 同步进度
- 改进:在"正在扫描目录"开始前也添加空行分隔
- 效果:每个阶段之间都有空行,输出更清晰
- 修复:扫描阶段使用 \r 单行动态更新,永远只显示一行
- 改进:移除扫描阶段的"最近扫描"显示,避免多行混乱
- 修复:确保 print() 正常换行,清除逻辑使用 \033[1A\033[2K
- 新增:创建 test_display.py 用于测试终端 ANSI 转义码支持
v2.2.1 (2026-04-21)
- 修复:ANSI 转义码顺序错误,改为 \033[1A\033[2K(先上移再清除)
- 修复:移除 progress.start_scan() 前的 print(),避免光标位置错乱
- 根本原因:空行导致 _lines_printed 与实际行数不匹配
- 修复:清除所有之前打印的行,避免显示残留导致的多行输出
- 修复:扫描期间定期更新显示,避免长时间显示 "0 个文件"
- 修复:优化行清除逻辑,避免显示残留导致的额外字符
- 修复:添加完整的锁保护显示更新,避免多线程竞争
- 改进:解决进度行末尾出现多余内容的问题
- 修复:减少进度显示更新频率(200ms 最小间隔)
- 改进:解决窗口切换时显示多行重复内容的问题
- 修复:lib 目标排除 CloudStorage 目录,避免云存储超时
- 新增:全局排除 Caches 目录(所有任务)
- 新增:全局排除 __pycache__ 目录(所有任务)
- 新增:大文件传输时显示实时进度(百分比和速度)
- 改进:添加 --progress 选项获取实时传输信息
- 实现:解析 rsync progress 输出,显示当前传输文件
v2.2.0 (2026-04-21)
- 修复: 进度显示问题
- 修复:显示最近同步的 5 个文件名
- 修复:一旦开始同步,不再清除前面的输出信息
- 改进:使用安全的阶段切换逻辑,确保多行显示正确更新
"""
import argparse
import os
import re
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Optional
#===============================================================================
# 配置常量 (CONFIGURATION)
#===============================================================================
VERSION = "2.5.3"
DEFAULT_HOME = "fengh"
DEFAULT_UDIR = "/Users"
DEFAULT_UDISK = "/Volumes/BACKUP/MAC"
# rsync 默认选项
# -a: 归档模式(保留权限、时间戳等)
# -v: 详细输出(显示传输的文件)
# -K: 保留指向目录的软链接
# -z: 压缩传输
# --delete: 删除目标中多余的文件
# --itemize-changes: 输出每个文件的变更详情
# --progress: 显示传输进度(用于大文件传输时的实时显示)
DEFAULT_RSYNC_OPTS = "-aKvz --delete --itemize-changes --progress --ignore-missing-args"
# 全局排除规则(所有任务都应用)
GLOBAL_EXCLUDES = [
"Caches", # 缓存目录
"__pycache__", # Python 字节码缓存
"node_modules", # Node.js 依赖
".venv", # Python 虚拟环境
]
#===============================================================================
# ANSI 转义码 (ANSI ESCAPE CODES)
#===============================================================================
class ANSI:
"""ANSI 转义码常量"""
RESET = "\033[0m"
CLEAR_LINE = "\033[2K"
UP = "\033[1A"
# 颜色
GREEN = "\033[32m"
YELLOW = "\033[33m"
CYAN = "\033[36m"
DIM = "\033[2m"
BOLD = "\033[1m"
#===============================================================================
# 进度显示类 (PROGRESS DISPLAY)
#===============================================================================
class ProgressDisplay:
"""实时进度显示"""
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
def __init__(self, quiet: bool = False):
self.quiet = quiet
self.recent_scanned: list[str] = []
self.recent_synced: list[str] = []
self.scan_count = 0
self.sync_count = 0
self.lock = threading.Lock()
self.spinner_idx = 0
self.start_time: Optional[float] = None
self._last_display_hash = ""
self._lines_printed = 0
self._max_lines_printed = 0
self._last_phase = ""
self._last_update_time = 0.0 # 最后更新显示的时间
self.current_transferring: Optional[str] = None # 当前正在传输的大文件
self.transfer_progress: str = "" # 传输进度信息
self._was_transferring: bool = False # 上一次是否是传输状态(用于检测状态变化)
def add_scanned(self, filepath: str) -> None:
"""添加扫描的文件"""
with self.lock:
self.recent_scanned.append(filepath)
if len(self.recent_scanned) > 5:
self.recent_scanned.pop(0)
self.scan_count += 1
def add_synced(self, filepath: str) -> None:
"""添加同步的文件"""
with self.lock:
self.recent_synced.append(filepath)
if len(self.recent_synced) > 5:
self.recent_synced.pop(0)
self.sync_count += 1
# 清除当前传输文件标记
self.current_transferring = None
self.transfer_progress = ""
# 在锁外强制更新显示,避免死锁
self._update_display("sync", force=True)
def set_transferring(self, filepath: str, progress_info: str = "") -> None:
"""设置当前正在传输的文件"""
with self.lock:
self.current_transferring = filepath
self.transfer_progress = progress_info
# 在锁外强制更新显示,避免死锁
self._update_display("sync", force=True)
def _shorten_path(self, path: str, max_length: int = 60) -> str:
"""缩短路径显示"""
if len(path) <= max_length:
return path
# 保留文件名,缩短路径
parts = path.split("/")
filename = parts[-1]
available = max_length - len(filename) - 5 # 5 for ".../"
if available < 10:
return f".../{filename}"
# 构建缩短路径
result_parts = ["..."]
current_length = 4
for part in reversed(parts[:-1]):
part_len = len(part)
if current_length + part_len + 1 > available:
break
result_parts.insert(1, part)
current_length += part_len + 1
return "/".join(result_parts) + "/" + filename
def _get_display_hash(self) -> str:
"""计算显示内容的哈希,用于检测变化"""
recent_scan = "\n".join(self._shorten_path(p) for p in self.recent_scanned[-5:])
recent_sync = "\n".join(self._shorten_path(p) for p in self.recent_synced[-5:])
return f"{self.scan_count}:{self.sync_count}:{recent_scan}:{recent_sync}"
def _update_display(self, phase: str, message: str = "", force: bool = False, spinner_only: bool = False) -> None:
"""更新显示内容(多行格式,但使用安全的清除方式)
Args:
phase: 当前阶段 ("scan" 或 "sync")
message: 额外消息
force: 是否强制更新(忽略时间限制)
spinner_only: 是否只更新 spinner(不重新计算哈希)
"""
if self.quiet:
return
# 使用锁保护整个显示更新过程,避免多线程竞争
with self.lock:
# 最小更新间隔:200ms(避免过于频繁的更新)
# spinner_only 模式可以使用更短的间隔
current_time = time.time()
if spinner_only:
min_interval = 0.1 # spinner 只需要 100ms 间隔
else:
min_interval = 0.2
if not force and current_time - self._last_update_time < min_interval:
return
# spinner_only 模式跳过哈希检查
if not spinner_only:
current_hash = self._get_display_hash()
if current_hash == self._last_display_hash and phase == self._last_phase:
return # 内容未变化,不更新
self._last_display_hash = current_hash
# 记录是否阶段改变了(在更新 _last_phase 之前检查)
phase_changed = (phase != self._last_phase and self._last_phase != "")
self._last_phase = phase
self._last_update_time = current_time
spinner = self.SPINNER_FRAMES[self.spinner_idx % len(self.SPINNER_FRAMES)]
self.spinner_idx += 1
# 构建要显示的行
lines = []
if phase == "scan":
# 扫描阶段:只显示主进度行,不显示"最近扫描"(避免多行混乱)
lines.append(f"{ANSI.CYAN}{spinner} 正在扫描目录...{ANSI.RESET} ({self.scan_count} 个文件)")
elif phase == "sync":
elapsed = time.time() - self.start_time if self.start_time else 0
speed = f"{self.sync_count / max(elapsed, 1):.0f} 文件/秒" if elapsed > 0 else ""
# 显示主状态行
if self.current_transferring:
if self.transfer_progress:
lines.append(f"{ANSI.GREEN}{spinner} 正在传输...{ANSI.RESET} ({self.transfer_progress})")
else:
lines.append(f"{ANSI.GREEN}{spinner} 正在传输大文件...{ANSI.RESET}")
else:
if self.sync_count > 0:
lines.append(f"{ANSI.GREEN}{spinner} 正在同步...{ANSI.RESET} ({self.sync_count} 个文件{speed and ', ' + speed})")
else:
# 没有文件传输时,显示已用时间,让用户知道程序在运行
lines.append(f"{ANSI.GREEN}{spinner} 正在同步...{ANSI.RESET} (已检查 {elapsed:.1f}s)")
# 添加最近同步的文件列表(最多 5 个)
if self.recent_synced:
lines.append(f"{ANSI.DIM} 最近同步:{ANSI.RESET}")
for f in self.recent_synced[-5:]:
lines.append(f" {ANSI.DIM}{self._shorten_path(f)}{ANSI.RESET}")
# 添加额外消息
if message:
lines.append(message)
# 检测传输状态变化(用于正确的行数管理)
is_transfer_state = bool(phase == "sync" and self.current_transferring)
was_transfer_state = self._last_phase == "sync" and hasattr(self, '_was_transferring') and self._was_transferring
transfer_state_changed = is_transfer_state != was_transfer_state
# 打印内容
if phase == "scan":
# 扫描阶段:单行动态更新
if self._lines_printed == 0:
print() # 空行分隔
if lines:
print(f"\r\033[K{lines[0]}", end="", flush=True)
self._lines_printed = 1
self._was_transferring = False
elif phase == "sync":
# 同步阶段:多行显示(主状态 + 最近 5 个文件)
# 清除之前的行
clear_count = self._lines_printed
if clear_count > 0:
for _ in range(clear_count):
print("\033[1A\033[2K", end="", flush=True)
# 第一次调用时添加空行分隔
if self._lines_printed == 0:
print() # 空行分隔
# 打印主状态行和文件列表
for line in lines:
print(line, flush=True)
# 计算打印的行数
self._lines_printed = len(lines)
self._was_transferring = False
else:
# 其他阶段
for line in lines:
print(line, flush=True)
self._lines_printed = len(lines)
# 确保所有输出都被刷新
sys.stdout.flush()
def update_sync_spinner_only(self) -> None:
"""只更新 spinner 动画(用于后台线程)"""
self._update_display("sync", spinner_only=True)
def start_scan(self) -> None:
"""开始扫描阶段"""
self.start_time = time.time()
self.scan_count = 0
self.recent_scanned = []
# 重置行数,但保留 _last_phase 以便检测阶段切换
self._lines_printed = 0
# 重置传输状态
self._was_transferring = False
# 调用 _update_display 来显示初始状态
self._update_display("scan", force=True)
def update_scan(self) -> None:
"""更新扫描显示"""
self._update_display("scan")
def start_sync(self) -> None:
"""开始同步阶段 - 初始化同步状态并显示"""
self.start_time = time.time()
self.sync_count = 0
self.recent_synced = []
# 输出换行符,确保光标从扫描阶段的覆盖模式(\r)正确移动到新行
# 否则后续的 \033[1A 会向上移动到错误的位置
if not self.quiet:
print()
# 不清除之前的输出(目标信息、扫描完成信息等)
# 只重置行数,表示新的进度显示区域开始
self._lines_printed = 0
# 重要:也重置 _max_lines_printed,避免清除到之前阶段的内容
self._max_lines_printed = 0
# 重置传输状态
self._was_transferring = False
# 调用 _update_display 来显示初始状态
self._update_display("sync", force=True)
def update_sync(self) -> None:
"""更新同步显示"""
self._update_display("sync")
def finish_scan(self, total_files: int) -> None:
"""完成扫描"""
if not self.quiet:
elapsed = time.time() - (self.start_time or 0)
# 结束单行模式并添加空行分隔(与"正在同步"之间)
print() # 结束 \r 模式
print(f"{ANSI.GREEN}✓ 扫描完成{ANSI.RESET} ({total_files} 个文件, {elapsed:.1f}s)", flush=True)
# 更新 _lines_printed 为实际打印的行数(2 行:换行符 + 完成信息)
self._lines_printed = 2
# 注意:不在这里添加空行,让 sync 阶段的 _update_display 添加
def finish_sync(self, total_files: int, transferred: int = 0) -> None:
"""完成同步"""
if not self.quiet:
elapsed = time.time() - (self.start_time or 0)
# 先换行(结束进度显示的 \r 覆盖)
print() # 换行
if transferred > 0:
# 有文件被传输
speed = f"{transferred / max(elapsed, 1):.0f} 文件/秒" if elapsed > 0 else ""
print(f"{ANSI.GREEN}✓ 同步完成{ANSI.RESET} ({transferred} 个文件, {elapsed:.1f}s{speed and ', ' + speed})")
elif total_files > 0:
# 检查了文件但没有传输(全部最新)
print(f"{ANSI.GREEN}✓ 检查完成{ANSI.RESET} ({total_files} 个文件, 全部最新, {elapsed:.1f}s)")
else:
print(f"{ANSI.GREEN}✓ 完成{ANSI.RESET} ({elapsed:.1f}s)")
#===============================================================================
# 配置管理 (CONFIGURATION MANAGEMENT)
#===============================================================================
def get_target_config(target: str, home: str) -> str:
"""获取目标配置
返回格式: source_path|destination_path|excludes
"""
configs = {
# 单个目标
"dot": f"{home}/|{home}|DOTFILES",
"doc": f"{home}/Documents|{home}",
"init": f"{home}/Init|{home}",
"lib": f"{home}/Library|{home}|Mobile Documents CloudStorage",
"pic": f"{home}/Pictures|{home}|iPod Photo Cache",
"res": f"{home}/Research|{home}",
"work": f"{home}/Work|{home}",
"journal": "JOURNALS|",
"movie": "Movies|",
"opt": "opt|",
"elib": "RDS Library|",
"teaching": "Teaching|",
"fengh": f"{home}|",
}
return configs.get(target, "")
def get_composite_target(target: str) -> str:
"""获取组合目标的子目标列表"""
composite_targets = {
"kernel": "dot doc init lib pic res work",
"all": "kernel journal movie opt elib teaching",
}
return composite_targets.get(target, "")
def is_valid_target(target: str) -> bool:
"""检查目标是否有效"""
return bool(get_target_config(target, DEFAULT_HOME) or get_composite_target(target))
def list_targets(home: str, udir: str, udisk: str) -> None:
"""列出所有可用的同步目标"""
print(f"{ANSI.CYAN}=== 组合目标 ==={ANSI.RESET}")
composite = get_composite_target("kernel")
if composite:
print(f" kernel : {composite}")
composite = get_composite_target("all")
if composite:
print(f" all : {composite}")
print()
print(f"{ANSI.CYAN}=== 单个目标 ==={ANSI.RESET}")
print(f" {'任务':<10} {'源目录':<45} {'目标目录':<45}")
print(f" {'-'*10} {'-'*45} {'-'*45}")
targets = [
("dot", f"{udir}/{home}/.*", f"{udisk}/{home}"),
("doc", f"{udir}/{home}/Documents", f"{udisk}/{home}"),
("init", f"{udir}/{home}/Init", f"{udisk}/{home}"),
("lib", f"{udir}/{home}/Library", f"{udisk}/{home} (排除 Mobile Documents, CloudStorage)"),
("pic", f"{udir}/{home}/Pictures", f"{udisk}/{home}"),
("res", f"{udir}/{home}/Research", f"{udisk}/{home}"),
("work", f"{udir}/{home}/Work", f"{udisk}/{home}"),
("journal", f"{udir}/JOURNALS", f"{udisk}"),
("movie", f"{udir}/Movies", f"{udisk}"),
("opt", f"{udir}/opt", f"{udisk}"),
("elib", f"{udir}/RDS Library", f"{udisk}"),
("teaching", f"{udir}/Teaching", f"{udisk}"),
("fengh", f"{udir}/{home}", f"{udisk}"),
]
for name, src, dst in targets:
print(f" {name:<10} {src:<45} {dst:<45}")
#===============================================================================
# 文件扫描 (FILE SCANNING)
#===============================================================================
def count_files_in_directory(scan_path: str,
progress: Optional[ProgressDisplay] = None,
excludes: Optional[list[str]] = None,
update_interval: int = 100) -> int:
"""统计目录中的文件数量(用于进度显示)
Args:
scan_path: 要扫描的目录路径
progress: 进度显示器
excludes: 要排除的目录名列表
update_interval: 每扫描多少个文件更新一次显示
"""
count = 0
excludes = excludes or []
last_update_count = 0
try:
for entry in os.listdir(scan_path):
full_path = os.path.join(scan_path, entry)
# 跳过排除的目录
if entry in excludes:
continue
# 跳过 . 和 ..
if entry in ('.', '..'):
continue
try:
is_symlink = os.path.islink(full_path)
is_dir = os.path.isdir(full_path) if not is_symlink else False
except (PermissionError, OSError):
continue
if is_symlink or os.path.isfile(full_path):
count += 1
if progress:
progress.add_scanned(entry)
elif is_dir:
# 递归统计子目录
count += count_files_in_directory(full_path, progress, excludes, update_interval)
# 定期更新显示(每 update_interval 个文件)
if progress and (count - last_update_count) >= update_interval:
progress._update_display("scan", force=True)
last_update_count = count
except (PermissionError, FileNotFoundError, InterruptedError):
pass
return count
#===============================================================================
# DOTFILES 扫描 (DOTFILES SCANNING)
#===============================================================================
def scan_dotfiles_directory(scan_path: str, base_path: str,
progress: Optional[ProgressDisplay] = None,
update_interval: int = 50) -> list[str]:
"""递归扫描 .* 目录,非递归扫描其他目录
Args:
scan_path: 要扫描的目录路径
base_path: 基础路径(用于计算相对路径)
progress: 进度显示器
update_interval: 每扫描多少个文件更新一次显示
返回: 文件路径列表(相对于 scan_path)
"""
result = []
last_update_count = 0
try:
entries = os.listdir(scan_path)
except (PermissionError, FileNotFoundError, InterruptedError):
return result
for entry in entries:
full_path = os.path.join(scan_path, entry)
# 计算相对于扫描路径的相对路径(用于 --files-from)
rel_path = os.path.relpath(full_path, scan_path)
display_path = f"./{rel_path}" if not rel_path.startswith('.') else rel_path
# 跳过 . 和 ..
if entry in ('.', '..'):
continue
# 获取文件信息
try:
is_symlink = os.path.islink(full_path)
is_dir = os.path.isdir(full_path) if not is_symlink else False
except (PermissionError, OSError):
continue
# 递归处理 .* 目录
if entry.startswith('.'):
if is_symlink:
# 软链接:添加到列表,不添加尾斜杠
result.append(display_path)
elif is_dir:
# 目录:先添加目录本身(带尾斜杠),然后递归扫描内容
result.append(f"{display_path}/")
result.extend(scan_dotfiles_directory(full_path, scan_path, progress))
else:
# 文件:添加到列表
result.append(display_path)
else:
# 非 .* 目录:跳过
continue
# 更新进度
if progress:
progress.add_scanned(entry)
# 定期更新显示(每 update_interval 个条目)
if progress and (len(result) - last_update_count) >= update_interval:
progress._update_display("scan", force=True)
last_update_count = len(result)
return result
#===============================================================================
# 路径处理 (PATH HANDLING)
#===============================================================================
def build_full_path(base_path: str, config_path: str) -> str:
"""构建完整路径"""
if not config_path:
return base_path
if config_path.startswith('/'):
return f"{base_path}{config_path}"
return f"{base_path}/{config_path}"
def ensure_target_directory(target_path: str) -> bool:
"""确保目标目录存在,不存在则创建"""
if os.path.exists(target_path):
return True
try:
os.makedirs(target_path, exist_ok=True)
return True
except (PermissionError, OSError) as e:
print(f"{ANSI.YELLOW}警告: 无法创建目标目录 {target_path}: {e}{ANSI.RESET}", file=sys.stderr)
return False
#===============================================================================
# 同步功能 (SYNCHRONIZATION FUNCTIONS)
#===============================================================================
def parse_config(config: str) -> tuple:
"""解析配置字符串
返回: (source_path, destination_path, excludes)
"""
parts = config.split('|')
src = parts[0] if len(parts) > 0 else ""
dst = parts[1] if len(parts) > 1 else ""
excludes = parts[2] if len(parts) > 2 else ""
return src, dst, excludes
def sync_target(name: str, home: str, from_path: str, to_path: str,
dry_run: bool = False, quiet: bool = False,
rsync_opts: str = DEFAULT_RSYNC_OPTS) -> bool:
"""同步单个目标
返回: 是否成功
"""
# 获取配置
config = get_target_config(name, home)
if not config:
print(f"{ANSI.YELLOW}警告: 未知目标: {name}{ANSI.RESET}", file=sys.stderr)
return False
src_path, dst_path, excludes = parse_config(config)
includes = ""
# 检查 DOTFILES 特殊模式
if excludes == "DOTFILES":
includes = "DOTFILES"
excludes = ""
# 构建完整路径
full_src = build_full_path(from_path, src_path)
full_dst = build_full_path(to_path, dst_path)
# DOTFILES 模式确保源路径有尾斜杠
if includes == "DOTFILES" and not full_src.endswith('/'):
full_src += '/'
# 验证源路径
if not os.path.exists(full_src):
print(f"{ANSI.YELLOW}警告: 源路径不存在: {full_src}{ANSI.RESET}", file=sys.stderr)
return False
# 确保目标目录存在
if not ensure_target_directory(full_dst):
return False
if not quiet:
print(f"{ANSI.CYAN}→ 同步: {name}{ANSI.RESET}")
print(f" 源: {full_src}")
print(f" 目标: {full_dst}")
# 构建排除规则
filter_args = []
dotfiles_temp_path = None
progress = None # 将在后面初始化
if includes == "DOTFILES":
# DOTFILES 特殊模式
progress = ProgressDisplay(quiet=quiet)
if not quiet:
progress.start_scan()
# 扫描 dotfiles
dotfiles = scan_dotfiles_directory(full_src.rstrip('/'), from_path, progress)
if not quiet:
progress.finish_scan(len(dotfiles))
if not dotfiles:
print(" 没有找到需要同步的文件")
return True
# 过滤掉扫描后已不存在的文件(避免 code 23 错误)
filtered_dotfiles = []
for filepath in dotfiles:
# 去掉 "./" 前缀(注意:不能用 lstrip,它会逐个剥离字符)
if filepath.startswith("./"):
stripped = filepath[2:]
elif filepath.startswith("."):
stripped = filepath
else:
stripped = filepath.lstrip("./")
if stripped.endswith("/"):
stripped = stripped.rstrip("/")
full = os.path.join(full_src.rstrip("/"), stripped)
if os.path.exists(full) or os.path.islink(full):
filtered_dotfiles.append(filepath)
dotfiles = filtered_dotfiles
if not dotfiles:
print(" 没有找到需要同步的文件")
return True
# 保存文件数量用于后续显示
estimated_files = len(dotfiles)
# 创建临时文件列表
import tempfile
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f:
dotfiles_temp_path = f.name
for filepath in dotfiles:
f.write(f"{filepath}\n")
# 使用 --files-from
filter_args.append(f"--files-from={dotfiles_temp_path}")
# 排除规则
filter_args.extend([
"--exclude=.Trash/",
"--exclude=.cache/",
"--exclude=.venv/",
"--exclude=.DS_Store",
"--exclude=.localized",
"--exclude=.Mail/",
"--exclude=.mbsync/",
"--exclude=.SynologyDrive/",
])
elif includes:
# 自定义包含规则
for inc in includes.split():
filter_args.append(f"--include={inc}")
filter_args.append("--exclude=*")
# 添加排除规则
if excludes:
for excl in excludes.split():
filter_args.append(f"--exclude={excl}")
# 应用全局排除规则(所有任务)
for excl in GLOBAL_EXCLUDES:
filter_args.append(f"--exclude={excl}")
# lib 目标:排除所有 Docker 相关目录和文件
if name == "lib":
filter_args.extend([
"--exclude=.docker",
"--exclude=Docker",
"--exclude=DockerDesktop",
"--exclude=docker",
"--exclude=*.docker",
"--exclude=*.dockerenv",
"--exclude=*.dockerignore",
"--exclude=*.dockerfile",
"--exclude=Dockerfile",
"--exclude=docker-compose.yml",
"--exclude=docker-compose.yaml",
"--exclude=compose.yml",
"--exclude=compose.yaml",
"--exclude=Containers",
"--exclude=containerd",
"--exclude=container",
"--exclude=*.img",
])
# 扫描阶段(非 DOTFILES 模式)
# 对于 DOTFILES 模式,progress 已经在上面创建
if progress is None:
progress = ProgressDisplay(quiet=quiet)
# 对于 DOTFILES 模式,estimated_files 已经在前面设置
# 对于其他模式,初始化为 0
if includes != "DOTFILES":
estimated_files = 0
if includes != "DOTFILES" and os.path.isdir(full_src) and not quiet:
# 不打印空行,让进度显示器自己处理布局
progress.start_scan()
# 统计文件数量
exclude_list = list(GLOBAL_EXCLUDES) # 复制全局排除列表
if excludes:
exclude_list.extend(excludes.split()) # 添加任务特定排除
estimated_files = count_files_in_directory(full_src, progress, exclude_list)
progress.finish_scan(estimated_files)
# 构建 rsync 命令
cmd = ["rsync"]
if dry_run:
cmd.append("--dry-run")
# 添加基本选项
cmd.extend(rsync_opts.split())
# 添加过滤规则
cmd.extend(filter_args)
# 添加源和目标
cmd.extend([full_src, full_dst])
# 执行同步
# 如果 progress 还没有被创建(非 DOTFILES 模式),则创建它
if 'progress' not in locals() or progress is None:
progress = ProgressDisplay(quiet=quiet)
success = True
try:
if not quiet:
progress.start_sync()
# 启动进度更新线程
stop_progress = threading.Event()
def update_progress():
while not stop_progress.is_set():
progress._update_display("sync", spinner_only=True)
time.sleep(0.15)
progress_thread = threading.Thread(target=update_progress, daemon=True)
progress_thread.start()
# 执行 rsync(使用非阻塞读取,避免真正卡住)
# 使用 select 来实现带超时的读取,而不是强制终止进程
import select
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding='utf-8',
errors='replace', # 替换无法解码的字符,避免 UnicodeDecodeError
bufsize=1 # 行缓冲
)
# 用于监控线程的状态和当前处理的文件
monitor_state = {
"warned": False,
"start_time": time.time(),
"current_file": None, # 当前正在处理的文件
"current_progress": "" # 当前进度信息
}
def monitor_process():
"""监控 rsync 进程,如果 60 秒无输出则提示一次"""
while not stop_progress.is_set():
time.sleep(5)
if not monitor_state["warned"] and process.poll() is None:
elapsed = time.time() - monitor_state["start_time"]
if elapsed > 60:
if not quiet:
print(f"\n{ANSI.YELLOW}⚠ 提示: rsync 已运行 {int(elapsed)} 秒,可能正在处理大文件{ANSI.RESET}", file=sys.stderr)
if monitor_state["current_file"]:
progress_info = monitor_state["current_progress"]
if progress_info:
print(f"{ANSI.DIM} 当前文件: {monitor_state['current_file']} ({progress_info}){ANSI.RESET}", file=sys.stderr)
else:
print(f"{ANSI.DIM} 当前文件: {monitor_state['current_file']}{ANSI.RESET}", file=sys.stderr)
else:
print(f"{ANSI.DIM} 进程正常运行中,请耐心等待...{ANSI.RESET}", file=sys.stderr)
monitor_state["warned"] = True
monitor_thread = threading.Thread(target=monitor_process, daemon=True)
monitor_thread.start()
# 使用 select 实现带超时的非阻塞读取
# 超时设置:每次读取最多等待 5 秒,但会循环直到进程结束
READ_TIMEOUT = 5
stdout_lines = []
stderr_lines = []
sync_count = 0
failed_files = []
last_file_seen = None # 记录最后看到的文件名
try:
# 设置 stdout 和 stderr 为非阻塞模式
import fcntl
for fd in [process.stdout.fileno(), process.stderr.fileno()]:
fl = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
# 循环读取输出,直到进程结束
while process.poll() is None:
# 使用 select 等待数据可读
readable, _, _ = select.select(
[process.stdout, process.stderr],
[], [],
READ_TIMEOUT
)
for stream in readable:
if stream == process.stdout:
line = stream.readline()
if line:
stdout_lines.append(line)
# 立即解析并更新显示
line_stripped = line.strip()
# 跳过空行和统计信息
if not line_stripped or line_stripped.startswith('sent') or line_stripped.startswith('total'):
continue
# 检查是否是 --progress 输出(包含百分比)
if '%' in line_stripped and ('MB/s' in line_stripped or 'KB/s' in line_stripped or 'GB/s' in line_stripped or 'B/s' in line_stripped or 'kB/s' in line_stripped):
parts = line_stripped.split()
if len(parts) >= 3:
try:
percent = parts[1]
speed = parts[2]
progress_info = f"{percent} {speed}"
monitor_state["current_progress"] = progress_info
if last_file_seen:
monitor_state["current_file"] = last_file_seen
progress.set_transferring(last_file_seen, progress_info)
except (ValueError, IndexError):
pass
continue
# 解析 itemize-changes 格式
if line_stripped.startswith('*deleting '):
filename = line_stripped[9:].strip()
sync_count += 1
progress.add_synced(filename)
monitor_state["current_file"] = filename
progress._update_display("sync", force=True)
elif line_stripped and line_stripped[0] in ('>', '<', 'c', '.', '*', 'h'):
parts = line_stripped.split(None, 1)
if len(parts) > 1:
filename = parts[1]
if ' -> ' in filename:
filename = filename.split(' -> ')[0]
last_file_seen = filename
monitor_state["current_file"] = filename
if line_stripped.startswith('>f') or line_stripped.startswith('<f') or line_stripped.startswith('c'):