p2_import_legacy.py
22.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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
P2 第一步:把老工程 zteits-clouds 的核心模块搬进 parking-cloud。
## 设计原则(对应 AGENTS.md §3「搬迁存量代码的顺序」)
1. **只搬不改**——包名、类名、mapper XML 的 namespace / resultMap type 全部原样保留。
老 XML 里 `namespace="com.zteits.park.mapper.TpPRegionAreaMapper"`、
`type="com.zteits.park.domain.TpPRegionArea"` 用的是全限定名,改包名会牵动
上千个 XML 与实体,收益为零、风险极高。因此新模块**沿用老包名**
(`com.zteits.park.*` / `com.zteits.order.*` / `com.clouds.common.*` /
`com.zteits.clouds.api.*`),搬迁动作只是「把文件挪到另一个 Maven 模块」。
2. **COPY 而非 MOVE**——老仓库保持可运行、可回溯,直到 P8 下线。
3. **不搬**:启动类(会与 ParkingServerApplication 抢 @SpringBootApplication)、
Dubbo 过滤器、代码生成器、`application*.properties` / `logback-spring.xml`
(环境配置以新工程为准,见红线第 9 条)、`context/*.xml`(里面是
DataSource/SqlSessionFactory 的旧装配,新工程由 parking-spring-boot-starter-mybatis 接管)、
`dubbo/*.xml`(要删的东西)、`generator/`、证书文件(红线第 11 条:不提交真实密钥)。
## ⚠️ 一次真实事故,以及本脚本现在的删除策略
本脚本原先为了「可重复执行」会 `shutil.rmtree(目标目录)`。但
`parking-module-system` 和 `parking-module-api` 这两个模块**同时装着 P1 手写的
新代码和迁移进来的老代码**,于是那一次执行把 P1 写的 96 个类(`com.zteits.parking.*`
的 system 域 + apibase 契约)连同老代码一起删了,只剩 `target/classes` 里的
.class 文件(后由 `tools/recover/` 从会话记录恢复,详见 AGENTS.md)。
因此现在的规则是:
* **默认只增不删**——直接覆盖同名文件,不删除任何东西。这是安全默认值。
* `--prune` 才会删,且**只删「本次导入会写出去的同一批相对路径」**,也就是
目的目录里确实由老工程镜像过来的文件;凡是老工程里没有对应源的(= 手写的),
一律留着。这样既保住了「改名/删除后不留残骸」,又不可能再误删新代码。
* 另有一层兜底:任何落在 PROTECTED_PREFIXES 下的路径,即使理论上算「待删」也拒绝删。
用法: python3 tools/p2_import_legacy.py [--dry-run] [--prune]
"""
import argparse
import os
import shutil
import sys
LEGACY = "/Users/wangbiao/IdeaProjects/parking/zteits-clouds"
CLOUD = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# ---------------------------------------------------------------------------
# 模块映射:新模块 → (老模块, 要搬的相对路径列表)
# ---------------------------------------------------------------------------
SOURCES = [
# 契约层:416 个 Dubbo 接口 + 947 个 DTO,包名 com.zteits.clouds.api.* 原样保留
("parking-module-api", "zteits-api", ["src/main/java"]),
# 框架层:com.clouds.common.*,单独一个 Maven 模块,不往 parking-common 里揉
("parking-framework/parking-legacy-common", "zteits-framework", ["src/main/java"]),
# 业务模块
("parking-module-park", "zteits-park", ["src/main/java", "src/main/resources/mybatis"]),
("parking-module-order", "zteits-order", ["src/main/java", "src/main/resources/mybatis"]),
("parking-module-pay", "zteits-pay", ["src/main/java", "src/main/resources/mybatis"]),
("parking-module-bill", "zteits-bill", ["src/main/java", "src/main/resources/mybatis"]),
("parking-module-charge", "zteits-charge", ["src/main/java", "src/main/resources/mybatis"]),
("parking-module-business", "zteits-business", ["src/main/java", "src/main/resources/mybatis"]),
# 系统域:老 zteits-sys 并进已存在的 parking-module-system
("parking-module-system", "zteits-sys", ["src/main/java", "src/main/resources/mybatis"]),
]
# 老工程里 com/clouds/common 下需要额外搬的资源(PDF 字体、二维码底图等运行时资源)
FRAMEWORK_RESOURCES = [
"fdfs_client.conf",
"fonts.xml",
"images",
"reportpdf",
]
# ---------------------------------------------------------------------------
# 按文件精选的随迁清单(不是整目录搬)
# ---------------------------------------------------------------------------
# 用于「老模块整体不搬,但其中有一小撮类被核心模块在运行期调用」的场景。
# 每条 = (目标模块, 老模块, [相对 src/main/java 的文件路径...])。
#
# 目前只有企业微信一处。老工程 zteits-wxcp-portal 是**独立 JVM**(端口 18031),
# 通过 Dubbo 向 park / order / pay / bill 提供
# `com.zteits.clouds.api.service.wxcp.SendMessageService`(收费员越界、设备故障、
# 优惠券到期等告警)。合成单 Jar、Dubbo 移除后这些调用变成同进程注入,
# 实现必须搬进来 —— 否则启动即
# `No qualifying bean of type ...SendMessageService`(有 4 处是裸 @Autowired 的调用方)。
#
# 为什么不整目录搬:这个模块里大部分是**门户 Web 层**
# (`controller/`、`SwaggerConfig`、`FilterRegistrationConfig` + `filter/`、启动类),
# 属于方案 §4.1「portal 不整体搬迁」的范畴;且 `SwaggerConfig` 走 springfox,
# 而新栈是 springdoc,搬过来只会多一个编译不过的类。
# 所以只搬「企业微信服务子系统」:config + handler + builder + service + utils。
EXPLICIT_SOURCES = [
("parking-module-system", "zteits-wxcp-portal", [
# 配置:WxCpProperties 是 @ConfigurationProperties(wechat.cp);
# WxCpConfiguration 负责建 WxCpService,并对外提供静态
# getCpService(agentId) —— 4 个发送实现全靠它拿 service。
"com/zteits/wxcp/config/WxCpConfiguration.java",
"com/zteits/wxcp/config/WxCpProperties.java",
# 消息路由处理器:WxCpConfiguration 构造器注入其中 7 个(都带 @Component),
# EnterAgentHandler 由路由直接 new。它们服务的是**接收**侧(消息回调),
# 但路由本身挂在 getCpService 的同一个类里,属于同一子系统,故一并搬。
"com/zteits/wxcp/handler/AbstractHandler.java",
"com/zteits/wxcp/handler/ContactChangeHandler.java",
"com/zteits/wxcp/handler/EnterAgentHandler.java",
"com/zteits/wxcp/handler/LocationHandler.java",
"com/zteits/wxcp/handler/LogHandler.java",
"com/zteits/wxcp/handler/MenuHandler.java",
"com/zteits/wxcp/handler/MsgHandler.java",
"com/zteits/wxcp/handler/NullHandler.java",
"com/zteits/wxcp/handler/ScanHandler.java",
"com/zteits/wxcp/handler/SubscribeHandler.java",
"com/zteits/wxcp/handler/UnsubscribeHandler.java",
# MsgHandler 回复消息时用到的响应构造器
"com/zteits/wxcp/builder/AbstractBuilder.java",
"com/zteits/wxcp/builder/ImageBuilder.java",
"com/zteits/wxcp/builder/TextBuilder.java",
# 发送实现:SendMessageService 的实现类 + 按消息类型分派的 4 个内部分支
"com/zteits/wxcp/service/impl/SendMessageServiceImpl.java",
"com/zteits/wxcp/service/impl/inner/InnerMessageSendService.java",
"com/zteits/wxcp/service/impl/inner/impl/NewsMSendMessageServiceImpl.java",
"com/zteits/wxcp/service/impl/inner/impl/PnewsMSendMessageServiceImpl.java",
"com/zteits/wxcp/service/impl/inner/impl/TextCardMsgSendServiceImpl.java",
"com/zteits/wxcp/service/impl/inner/impl/TextSendMessageServiceImpl.java",
# 工具与常量:JsonUtils 被 WxCpProperties.toString() 用,走 gson(已在 classpath)
"com/zteits/wxcp/utils/JsonUtils.java",
"com/zteits/wxcp/utils/constants/InnerMessageSendRoutes.java",
]),
]
# ---------------------------------------------------------------------------
# 排除规则
# ---------------------------------------------------------------------------
# 整文件排除(按文件名)
SKIP_FILENAMES = {
# 启动类:与 ParkingServerApplication 冲突
"ParkApplication.java",
"OrderApplication.java",
"PayApplication.java",
"BillApplication.java",
"ChargeApplication.java",
"BusinessApplication.java",
"Application.java", # zteits-sys/src/main/java/com/zteits/sys/Application.java
"RefreshCacheApplication.java",
"FrameworkMain.java",
# 代码生成器:新工程不再用(P6 统一进 parking-module-infra)
"MybatisGenerateRun.java",
"MybatisGenerateRun2.java",
# 上面两个入口类的伴生类(mybatis-generator-core 的 PluginAdapter / IntrospectedTable 等)。
# 入口类不搬之后这些成了孤儿:全仓 grep 过,除了彼此没有别的引用。
# 留着会把 mybatis-generator-core 拖进单 Jar(只为一段跑不到的代码),所以一并排除。
"MybatisGeneratorCommon.java",
"ServiceAndControllerGeneratorPlugin.java",
"OverWriteIsMergeablePlugin.java",
# Zookeeper 分布式锁客户端:唯一使用者是 park 的 RefreshCache,
# 后者已改用 Redisson(去掉 Dubbo/ZK 的必然动作),此类随之作废。
"ZkClientFactory.java",
# 错放在 src/main/java 里的 JUnit 测试类(两个 @Test 方法、全仓 0 引用、
# 类注释自己写着 TEST-JOB)。老 zteits-order/pom.xml 里 starter-test 是
# <scope>test</scope>,所以它在老仓库的 main 编译里**也编译不过**,
# 只在 IDE 里跑得起来。搬过来会逼着把 junit 提成 compile 依赖、进而混进单 Jar,
# 只为两个跑不到的调试方法。测试代码的迁移是 P4/P5 的事(按页面与用例重建)。
"CloudsJobTest.java",
# Dubbo 过滤器:Dubbo 整体废弃
"RPCConsumerFilter.java",
"RPCProviderFilter.java",
# 老 mybatis 装配(DataSource/SqlSessionFactory),新工程由 starter 接管
"core-context-sys.xml",
# 老 Redis 装配:jedis 版 RedisConnectionFactory + RedisTemplate,
# 全仓无人引用,新工程由 parking-spring-boot-starter-redis 提供(且新栈是
# Lettuce/Redisson,不是 jedis)。留着会多出一个 RedisConnectionFactory Bean,
# 且它头上还挂着 @EnableAutoConfiguration。
"RedisConfig.java",
# 老 Redisson 装配:类上 @Configuration,但 redissonClient() **没挂 @Bean**,
# 即注册不出任何 Bean;老仓库 + 新仓库双向 grep `RedissonConfig` / `redissonClient()`
# 均为零引用(`RedissonClient` 靠 redisson-spring-boot-starter 的自动配置提供,
# 存量 20+ 处 @Autowired RedissonClient 与它无关)。
# ⚠️ 它同时是 `spring.redis.password` 这个占位符的**唯一**索取者,而本地 Redis 无密码,
# 必须"整行不定义"才能避免 Redisson 发 AUTH ""(见 application-local.yaml 的注释),
# 两者不可兼得 —— 删掉这个死类,占位符需求随之消失。详见 AGENTS.md 坑 15。
"RedissonConfig.java",
# Boot 1.x 专有错误处理:继承 org.springframework.boot.autoconfigure.web.AbstractErrorController、
# 用 autoconfigure.web.ErrorAttributes —— 这两个类在 Boot 2 已挪到
# org.springframework.boot.web.servlet.error.*。全仓无人引用,异常统一由
# starter-web 的 GlobalExceptionHandler + GlobalResponseBodyHandler 承担
# (与方案 §4.5「响应语义收敛到 CommonResult」一致),故不再搬运。
"ControllerExceptionHandler.java",
# 老 MVC 装配:@Configuration + **@EnableWebMvc** + 继承 WebMvcConfigurerAdapter。
# @EnableWebMvc 的语义就是「关掉 Boot 的 MVC 自动配置」,而新框架的
# GlobalExceptionHandler / Jackson 配置 / Knife4j / URL 前缀映射全靠 Boot 那套,
# 扫进来等于把整个 web 层拆了。老工程的 portal 启动类也是靠
# excludeFilters={@Filter(ASSIGNABLE_TYPE, value=WebMvcConfigurer.class)} 把它排除的。
# 引用者只有未搬迁的 portal / data-collection / glock / lz 的启动类,
# 7 个业务模块全程不引用(已全仓 + 老仓库双向 grep 核对)。
"WebMvcConfigurer.java",
# 老数据源装配(每个存量模块各一份,老仓库共 10 份)。在单 Jar 里就是
# **7 个同名 @Configuration + 7 个同名 dataSource() Bean**,直接
# ConflictingBeanDefinitionException。且新工程的数据源由
# druid-spring-boot-starter(application-local.yaml 的 spring.datasource.druid)
# + parking-spring-boot-starter-mybatis 接管,这些类里 @AutoConfigureBefore(
# DataSourceAutoConfiguration.class) 的老写法在 Boot 2.7 也已无意义
# (@AutoConfigureBefore 只在 spring.factories/AutoConfiguration.imports 里才生效)。
# ⚠️ 代价:老 DruidAutoConfiguration 里手工设的池参数(initialSize/maxActive/
# validationQuery/filters=stat,wall 等)不再生效,需在 P3 按新 yaml 对齐,
# 已记入 AGENTS.md 待办。
"DruidAutoConfiguration.java",
"DruidProperties.java",
# 老 xxl-job 装配(老仓库:XxlJobConfig ×4、XxlJobConfiguration ×6、XxlJobCondition ×6)。
# 单 Jar 只允许**一个**执行器,而这 4 份 XxlJobConfig 逐字相同、只差包名,
# 且各自 @Value 取的是**各模块专属**的部署参数(xxl.job.executor.appname=zteits-park /
# zteits-order、port=9991/9992、logpath=/home/zteits/...)。
# 合并后 appname/port/logpath 该取什么值是**新部署的决策**,不是搬迁能定的:
# * 保留任意一份 → 必须新造一套 xxl.job.* 配置(还要知道新环境的 admin 地址),
# 且会把「park 的执行器」当成整个平台的执行器,语义也不对;
# * 全不保留 → 105 个 @JobHandler 照常编译、只是没有执行器去拉起它们。
# 方案里 xxl-job → Quartz 的收敛是 P5,届时统一落一份平台级配置,故此处全部排除。
# ⚠️ 代价:这 105 个定时任务在 P5 之前**不会运行**,已记入 AGENTS.md 待办。
"XxlJobConfig.java",
"XxlJobConfiguration.java",
"XxlJobCondition.java",
}
# 整目录排除(相对 src/main/java 或 src/main/resources 的路径片段)
SKIP_DIR_PARTS = {
"META-INF", # zteits-order/src/main/java/META-INF/MANIFEST.MF
}
# 非 Java/XML 的资源后缀一律不搬(证书、私钥)
SKIP_SUFFIXES = (".p12", ".jks", ".pkcs12", ".keystore")
# 手写代码所在路径:这些目录下的文件**永远**不删(--prune 也不例外)。
# com/zteits/parking 是新工程的包名,老工程里根本不存在,所以「老工程里没有对应源」
# 这条规则本身就足以保护它;这里再列一遍是为了防住「万一以后有人往老工程也放了同名包」。
PROTECTED_PREFIXES = (
"com/zteits/parking",
)
def is_protected(rel):
return any(rel.replace(os.sep, "/").startswith(p) for p in PROTECTED_PREFIXES)
def should_skip(path):
name = os.path.basename(path)
if name in SKIP_FILENAMES:
return True
if name.endswith(SKIP_SUFFIXES):
return True
parts = set(path.split(os.sep))
if parts & SKIP_DIR_PARTS:
return True
return False
def source_rels(src):
"""老工程这一份源码对应的全部相对路径(应用排除规则后),即「本次导入管得着的文件」。"""
out = set()
for root, dirs, files in os.walk(src):
dirs[:] = [d for d in dirs if d not in SKIP_DIR_PARTS]
for f in files:
rel = os.path.relpath(os.path.join(root, f), src)
if not should_skip(rel):
out.add(rel)
return out
def prune_mirror(src, dst, dry, stats):
"""删掉目的目录里「老工程镜像过来的、但本次不再产出」的文件(改名/删除的残骸)。
判据是**相对路径是否在老工程源码里**:
* 在 → 这个文件由导入负责,老工程里已没了,可以删
* 不在 → 手写文件(如 com/zteits/parking/**),绝不删
"""
keep = source_rels(src)
stale = []
for root, _, files in os.walk(dst):
for f in files:
rel = os.path.relpath(os.path.join(root, f), dst)
if rel in keep or should_skip(rel):
continue
# 本次会重新产出的路径不删;排除掉的文件也不管(本来就不该由导入管理)
if is_protected(rel):
stats["protected"] += 1
continue
stale.append(os.path.join(root, f))
for p in stale:
stats["pruned"] += 1
if dry:
print(f" prune {os.path.relpath(p, dst)}")
else:
os.remove(p)
# 删空目录(老代码改名后可能留下空包目录)
if not dry:
for root, dirs, _ in os.walk(dst, topdown=False):
for d in dirs:
p = os.path.join(root, d)
if is_protected(os.path.relpath(p, dst)):
continue
if not os.listdir(p):
os.rmdir(p)
def copy_tree(src, dst, dry, stats):
"""拷贝 src → dst,逐文件应用排除规则。"""
for root, dirs, files in os.walk(src):
dirs[:] = [d for d in dirs if d not in SKIP_DIR_PARTS]
for f in files:
s = os.path.join(root, f)
rel = os.path.relpath(s, src)
t = os.path.join(dst, rel)
if should_skip(rel):
stats["skipped"] += 1
if dry:
print(f" skip {rel}")
continue
stats["copied"] += 1
if not dry:
os.makedirs(os.path.dirname(t), exist_ok=True)
shutil.copy2(s, t)
def copy_explicit(dry, stats):
"""按 EXPLICIT_SOURCES 逐文件拷贝(老模块不整体搬,只挑若干类)。
与 copy_tree 的区别:只搬列出来的文件,**不碰**目的目录里其它任何东西;
因此一个源文件被删掉时这里只会「少拷贝一个」,不会清理残骸 —— 需要清残骸时
仍走 copy_tree + --prune 的那一套。少而明确,所以够用。
"""
for dst_mod, src_mod, rels in EXPLICIT_SOURCES:
print(f"\n=== {src_mod} -> {dst_mod}(按文件精选)===")
base = os.path.join(LEGACY, src_mod, "src/main/java")
for rel in rels:
s = os.path.join(base, rel)
if not os.path.isfile(s):
print(f" !! 源文件不存在: {src_mod}/{rel}")
continue
if should_skip(rel):
stats["skipped"] += 1
print(f" skip {rel}")
continue
stats["copied"] += 1
# 与 SOURCES 的落位保持一致:老模块的 src/main/java 对应新模块的 src/main/java
t = os.path.join(CLOUD, dst_mod, "src/main/java", rel)
print(f" {'(dry)' if dry else 'copy'} {rel}")
if not dry:
os.makedirs(os.path.dirname(t), exist_ok=True)
shutil.copy2(s, t)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true")
ap.add_argument(
"--prune",
action="store_true",
help="删除老工程镜像过来、但本次已不产出的文件(只删这一类,手写代码不动)",
)
ap.add_argument(
"--only-explicit",
action="store_true",
help="只跑 EXPLICIT_SOURCES 的按文件精选项,不重新覆盖 SOURCES 的整目录镜像",
)
args = ap.parse_args()
if not os.path.isdir(LEGACY):
sys.exit(f"老工程目录不存在: {LEGACY}")
grand = {"copied": 0, "skipped": 0, "pruned": 0, "protected": 0}
# ⚠️ --only-explicit 存在的理由:整目录镜像(SOURCES)是**覆盖式**的,
# 凡是在镜像目录里手改过的文件(已发生过的例子:com/zteits/charge/config/
# CallKeLinServiceProxyConfiguration.java 删掉无参 @ComponentScan、9 个 mapper 的
# 显式 bean 名)再跑一次就会被老源码原样盖回去。只想补跑按文件精选项时用它。
if args.only_explicit:
copy_explicit(args.dry_run, grand)
print(f"\n合计:拷贝 {grand['copied']} 个文件,排除 {grand['skipped']} 个。")
if args.dry_run:
print("(--dry-run,未落盘)")
return
for dst_mod, src_mod, rels in SOURCES:
dst_root = os.path.join(CLOUD, dst_mod)
src_root = os.path.join(LEGACY, src_mod)
if not os.path.isdir(src_root):
print(f"!! 跳过 {src_mod}(不存在)")
continue
print(f"\n=== {src_mod} -> {dst_mod} ===")
for rel in rels:
s = os.path.join(src_root, rel)
if not os.path.isdir(s):
continue
d = os.path.join(dst_root, rel)
stats = {"copied": 0, "skipped": 0, "pruned": 0, "protected": 0}
# 先清残骸再拷(--prune 才清;默认只增不删)
if args.prune and os.path.isdir(d):
prune_mirror(s, d, args.dry_run, stats)
copy_tree(s, d, args.dry_run, stats)
msg = f" {rel:<34} 拷贝 {stats['copied']:>5} 排除 {stats['skipped']:>3}"
if args.prune:
msg += f" 清理残骸 {stats['pruned']:>4}"
if stats["protected"]:
msg += f" 保护未删 {stats['protected']}"
print(msg)
for k in grand:
grand[k] += stats[k]
# 按文件精选的随迁(见 EXPLICIT_SOURCES 的注释)
copy_explicit(args.dry_run, grand)
# 框架资源
print("\n=== zteits-framework 运行时资源 ===")
for r in FRAMEWORK_RESOURCES:
s = os.path.join(LEGACY, "zteits-framework", "src/main/resources", r)
if not os.path.exists(s):
continue
d = os.path.join(CLOUD, "parking-framework/parking-legacy-common/src/main/resources", r)
if not args.dry_run:
shutil.rmtree(d, ignore_errors=True) if os.path.isdir(d) else None
os.makedirs(os.path.dirname(d), exist_ok=True)
if os.path.isdir(s):
shutil.copytree(s, d)
else:
shutil.copy2(s, d)
print(f" {r}")
print(f"\n合计:拷贝 {grand['copied']} 个文件,排除 {grand['skipped']} 个。")
if args.prune:
print(
f"清理老代码残骸 {grand['pruned']} 个;"
f"因属手写代码而保护未删 {grand['protected']} 个。"
)
else:
print("(未传 --prune:只增不删,不会删除任何现有文件)")
if args.dry_run:
print("(--dry-run,未落盘)")
if __name__ == "__main__":
main()