-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgit-pool-wrapper.py
More file actions
executable file
·1943 lines (1695 loc) · 83.5 KB
/
Copy pathgit-pool-wrapper.py
File metadata and controls
executable file
·1943 lines (1695 loc) · 83.5 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
"""
Git Wrapper - 全局对象池共享方案(自驱 submodule 版)
拦截 clone / submodule update --init / fetch / gc / prune,实现跨仓库对象共享
"""
import os
import sys
import subprocess
import tempfile
if sys.platform != 'win32':
import fcntl
else:
import msvcrt
import re
import shutil
import threading
import time
import unicodedata
from pathlib import Path
from typing import List, Optional, Tuple, Dict, NamedTuple
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
class GitCommandError(Exception):
"""git 子进程(经 run_git(check=True) 调用)失败,或 wrapper 层参数校验失败时抛出。
携带退出码与(capture 模式下捕获到的)stderr,供顶层 main() 统一打印并退出。
library 层不再直接 sys.exit,退出决策集中在 main();exec 系函数
(_exec_passthrough)例外——它们的语义就是终止当前进程。
"""
def __init__(self, message: str, returncode: int = 1, stderr: str = ''):
super().__init__(message)
self.returncode = returncode
self.stderr = stderr
# ---------- 配置 ----------
GIT_POOL = os.environ.get('GIT_POOL', os.path.expanduser('~/.git-pool'))
POOL_DIR: Path = Path(GIT_POOL)
# 全局唯一对象池:所有 clone / fetch / submodule / migrate 共享同一裸仓
POOL_PATH: Path = POOL_DIR / 'pool.git'
POOL_LOCK_PATH = POOL_DIR / '.lock'
REGISTRY_FILE = POOL_DIR / '.registered_shells'
GC_LAST_RUN_FILE = POOL_DIR / '.gc_last_run'
GC_MIN_INTERVAL = 3600 # 全池 GC 最小间隔(秒):不足 1 小时则跳过
_REMOTE_NAME_MAX_LEN = 100
_SHALLOW_BYPASS_FLAGS = {'--unshallow', '--update-shallow'}
POOL_GC_WHITELIST = {'--quiet', '--no-quiet', '--auto',
'--aggressive', '--prune=now', '--prune=never'}
def _resolve_system_git() -> str:
"""解析系统原生 git 路径,避免解析到 wrapper 自身。
优先级:SYSTEM_GIT 环境变量 > PATH 中第一个非 wrapper 的 git > /usr/bin/git
"""
from_env = os.environ.get('SYSTEM_GIT')
if from_env:
return from_env
# shutil.which 可能找到 wrapper 自身(如 PATH 中有同名 shim),需跳过
wrapper_path = os.path.realpath(__file__)
candidate = shutil.which('git')
if candidate:
if os.path.realpath(candidate) != wrapper_path:
return candidate
return '/usr/bin/git'
SYSTEM_GIT = _resolve_system_git()
_registry_lock = threading.Lock()
# 可重入的池锁:threading.RLock 保证同进程多线程互斥(同线程可嵌套),
# fcntl.flock 仅在最外层 acquire 时获取,保证跨进程互斥;最外层 release 时才释放。
_pool_lock_rlock = threading.RLock()
_pool_lock_state = {'fd': None, 'count': 0}
# 本进程内已成功 fetch 过的 URL 集合:同 URL 在同一进程内只 fetch 一次(去重加速)。
_fetched_urls: set = set()
# ---------- 工具函数 ----------
def _ensure_pool_dir():
"""确保池目录骨架存在:GIT_POOL 目录、registry 文件、锁文件。"""
POOL_DIR.mkdir(parents=True, exist_ok=True)
REGISTRY_FILE.touch(exist_ok=True)
POOL_LOCK_PATH.touch(exist_ok=True)
def _sanitize_url(url: str) -> str:
"""去除 URL 中可能混入的零宽字符及不可见控制字符(Unicode category Cf/Cc/Cs)。"""
return ''.join(c for c in url if unicodedata.category(c)
not in ('Cf', 'Cc', 'Cs'))
def normalize_url(url: str) -> str:
"""归一化 URL 为真实路径分级风格的 key(不做 URL 编码)。
例:
https://host/storage/foo.git -> host/storage/foo
git@host:storage/foo.git -> host/storage/foo
ssh://git@host/storage/foo.git -> host/storage/foo
"""
# 1) 去掉协议前缀
url = re.sub(r'^[a-zA-Z][a-zA-Z0-9+.-]*://', '', url)
# 2) 去掉 user@ 前缀
url = re.sub(r'^[^@/]+@', '', url)
# 3) host:path -> host/path(仅替换首个 ':',且仅当 ':' 出现在第一个 '/' 之前)
slash_idx = url.find('/')
colon_idx = url.find(':')
if colon_idx != -1 and (slash_idx == -1 or colon_idx < slash_idx):
after_colon = url[colon_idx + 1:]
# 若 ':' 后紧接纯数字(端口号,如 host:443/path),丢弃端口而非转路径段
port_end = 0
while port_end < len(after_colon) and after_colon[port_end].isdigit():
port_end += 1
if port_end > 0 and (port_end == len(after_colon)
or after_colon[port_end] == '/'):
# host:443 -> host / host:443/path -> host/path
url = url[:colon_idx] + after_colon[port_end:]
else:
# 标准 SCP 语法 git@host:org/repo -> host/org/repo
url = url[:colon_idx] + '/' + after_colon
# 4) 去掉末尾 / 再去掉末尾 .git
url = url.rstrip('/')
url = re.sub(r'\.git$', '', url)
# 5) 折叠多余连续斜杠
url = re.sub(r'/+', '/', url)
return url.lower().strip('/')
def _url_to_remote_name(url: str) -> str:
"""将 URL 转成池内合法且尽量唯一的 remote 名。
复用 normalize_url 得到 host/path 形式(已去协议/用户/端口/.git),再把所有非
字母数字字符折叠为 '-',去首尾 '-',并截断到合理长度,保证可作为 git remote 名。
例:https://host/storage/foo.git -> host-storage-foo
"""
key = normalize_url(_sanitize_url(url))
name = re.sub(r'[^a-zA-Z0-9]+', '-', key).strip('-')
if not name:
name = 'pool'
return name[:_REMOTE_NAME_MAX_LEN]
@contextmanager
def pool_lock():
"""全局对象池可重入锁上下文管理器:用 `with pool_lock():` 包裹临界区。
threading.RLock 保证同进程多线程互斥(同线程可嵌套),文件锁仅在最外层
进入时获取、最外层退出时释放,保证跨进程互斥。
open()/lock() 失败时回滚已自增的计数并释放 rlock(顺带关闭已打开的 fd),
避免把 fd=None 的半成品状态留给退出逻辑而崩溃。
"""
_pool_lock_rlock.acquire()
_pool_lock_state['count'] += 1
if _pool_lock_state['count'] == 1:
fd = None
try:
if sys.platform == 'win32':
# Windows: 用 os.open 获取原始文件描述符,再用 msvcrt.locking 锁定
raw_fd = os.open(str(POOL_LOCK_PATH), os.O_CREAT | os.O_RDWR)
# msvcrt.locking 需要文件非空才能锁定 byte range 0..1
if os.lseek(raw_fd, 0, os.SEEK_END) == 0:
os.write(raw_fd, b'\x00')
os.lseek(raw_fd, 0, os.SEEK_SET)
try:
msvcrt.locking(raw_fd, msvcrt.LK_LOCK, 1)
except OSError:
os.close(raw_fd)
raise
fd = raw_fd
else:
fd = open(POOL_LOCK_PATH, 'w')
fcntl.flock(fd, fcntl.LOCK_EX)
except BaseException:
if fd is not None:
if sys.platform == 'win32':
os.close(fd)
else:
fd.close()
_pool_lock_state['count'] -= 1
_pool_lock_rlock.release()
raise
_pool_lock_state['fd'] = fd
try:
yield
finally:
_pool_lock_state['count'] -= 1
if _pool_lock_state['count'] == 0:
fd = _pool_lock_state['fd']
_pool_lock_state['fd'] = None
try:
if sys.platform == 'win32':
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
else:
fcntl.flock(fd, fcntl.LOCK_UN)
finally:
if sys.platform == 'win32':
os.close(fd)
else:
fd.close()
_pool_lock_rlock.release()
def run_git(args: List[str], capture=False, check=True, env=None):
"""运行系统 git 子命令。
错误处理 invariant:
- 默认 check=True:git 退出码非 0 时抛 GitCommandError(携带 returncode 与
stderr)。library 层不再 sys.exit,退出统一由 main() 决定;exec 系函数
(_exec_passthrough)例外——它们的语义就是终止当前进程。
- 仅在「确实需要忽略错误、由调用方自行判断 returncode/stdout」的探测型调用上
显式传 check=False,并在调用点附注释说明原因(例如 config --get / rev-parse /
cat-file -e / for-each-ref 等查询失败属正常分支)。
- capture=True 时捕获 stdout/stderr,供调用方读取;capture=False 时 stderr
继承父进程,git 进度信息直接可见,stdout 也继承父进程。
"""
if env is None:
env = os.environ.copy()
# 屏蔽 nvm 相关变量:git repack 内部会 fork /bin/sh,nvm.sh 在非交互式环境下
# 可能向 stderr 打印噪声(如 "type: manpath: not found"),导致 fatal: bad revision。
for _nvm_key in ('NVM_DIR', 'NVM_BIN', 'NVM_INC', 'NVM_RC_VERSION', 'NVM_CD_FLAGS'):
env.pop(_nvm_key, None)
cmd = [SYSTEM_GIT] + args
if capture:
result = subprocess.run(
cmd,
env=env,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL)
else:
# stderr 继承父进程,让 git clone/fetch 的进度信息直接可见;stdout 也继承父进程。
result = subprocess.run(
cmd,
env=env,
stderr=None,
stdin=subprocess.DEVNULL)
if check and result.returncode != 0:
stderr = result.stderr if result.stderr else ''
raise GitCommandError(
f'git {" ".join(args)} 失败(exit {result.returncode})',
returncode=result.returncode, stderr=stderr)
return result
def _exec_passthrough(argv: List[str]):
"""将当前进程替换为原生 git(Unix execvp),或在 Windows 下以子进程运行后退出。"""
if sys.platform == 'win32':
result = subprocess.run([SYSTEM_GIT] + argv)
sys.exit(result.returncode)
os.execvp(SYSTEM_GIT, [SYSTEM_GIT] + argv)
def register_shell(gitdir: str):
gitdir = os.path.abspath(gitdir)
if not os.path.isdir(gitdir):
return
with pool_lock():
with _registry_lock:
if REGISTRY_FILE.exists():
with open(REGISTRY_FILE, 'r') as f:
lines = [line.strip() for line in f if line.strip()]
else:
lines = []
if gitdir not in lines:
with open(REGISTRY_FILE, 'a') as f:
f.write(gitdir + '\n')
def clean_registry():
if not REGISTRY_FILE.exists():
return
with pool_lock():
with _registry_lock:
with open(REGISTRY_FILE, 'r') as f:
lines = [line.strip() for line in f if line.strip()]
# 保留条件:绝对路径,且(确实是目录,或路径项存在但当前无法解析为存在的目标=
# 疑似临时不可达,保留)。普通存在的非目录文件、不存在且无 lexists 的条目,剔除。
valid = [
l for l in lines
if os.path.isabs(l) and (
os.path.isdir(l) or (
os.path.lexists(l) and not os.path.exists(l))
)
]
with open(REGISTRY_FILE, 'w') as f:
f.write('\n'.join(valid) + ('\n' if valid else ''))
def resolve_gitdir(dot_git_path: str) -> str:
"""把一个 `.git` 路径解析为真实的 gitdir 绝对路径。
- `.git` 为目录:返回其 resolve() 后的绝对路径;
- `.git` 为文件(worktree / submodule 的 `gitdir: <path>` 指针):读取指针,
相对路径相对 `.git` 所在目录解析为绝对路径后返回;
- 其他情况(不存在、非 gitdir 指针文件):返回空串。
供 find_gitdir 与 do_clone 共用,避免两处各写一遍 `.git` 文件解析逻辑。
"""
p = Path(dot_git_path)
if p.is_dir():
return str(p.resolve())
if p.is_file():
with open(p, 'r') as f:
content = f.read().strip()
if content.startswith('gitdir:'):
gitdir = content[len('gitdir:'):].strip()
if not os.path.isabs(gitdir):
gitdir = str((p.parent / gitdir).resolve())
return gitdir
return ''
def find_gitdir(path: str) -> Optional[str]:
path = Path(path).resolve()
while path != path.parent:
gitpath = path / '.git'
if gitpath.exists():
resolved = resolve_gitdir(str(gitpath))
if resolved:
return resolved
path = path.parent
return None
def parse_git_options(args: List[str], options_with_value: set, flags: Optional[set] = None,
stop_at_positional: bool = False, strict: bool = False) -> Tuple[List[str], List[str]]:
"""通用 git 风格 argv 分词器:被 clone / global / submodule / migrate 解析复用。
把 args 切分为 (options, rest):
- options:识别到的选项 token,`--opt value` 空格形式会把值一并保留为相邻元素。
- rest:未被当作选项消费的剩余部分。
参数:
- options_with_value:取值选项名集合(空格形式吞掉下一个 token;`--opt=value`
形式自带值,不再吞 token)。
- flags:已知无值开关集合(仅在 strict=True 时用于「识别/未识别」判定)。
- stop_at_positional:遇到第一个非选项(positional)即停止,把它及其后所有 token
作为 rest 返回(global 解析借此定位 subcommand 边界)。
- strict:遇到第一个「未识别」的 `-` token(既不是已知 flag,也不是已知取值选项)
即停止,把它及其后的 token 作为 rest 返回(global 解析借此对未知全局选项
fallthrough 到原生 git)。
非 strict / 非 stop 模式:`--` 之后的所有 token 均作为 positionals(`--` 本身丢弃),
与 clone / submodule 原有行为一致;strict / stop 模式把 `--` 连同其后内容作为 rest 返回。
"""
flags = flags or set()
options: List[str] = []
positionals: List[str] = []
i = 0
n = len(args)
while i < n:
arg = args[i]
if arg == '--':
if stop_at_positional or strict:
return options, args[i:]
positionals.extend(args[i + 1:])
return options, positionals
if not arg.startswith('-'):
if stop_at_positional:
return options, args[i:]
positionals.append(arg)
i += 1
continue
opt = arg.split('=', 1)[0]
if strict and not (arg in flags or opt in options_with_value):
return options, args[i:]
options.append(arg)
if '=' not in arg and opt in options_with_value and i + 1 < n:
i += 1
options.append(args[i])
i += 1
return options, positionals
def parse_git_option_values(
args: List[str], options_with_value: set) -> Tuple[Dict[str, object], List[str]]:
"""plain 模式单趟解析,直接产出「选项 -> 值」字典,调用方一步取值无需二次循环。
返回 (values, positionals):
- values:取值选项(空格形式 `--opt value` 或等号形式 `--opt=value`)映射到其
字符串值;无值开关映射到 True。同名选项重复出现时后者覆盖前者,与原 parse_git_options
+ 调用方二次循环「后值生效」的语义一致。
- positionals:非选项参数;`--` 之后内容并入 positionals(`--` 本身丢弃)。
仅供「需要按名取值」的 value-consumer 复用(submodule update / migrate)。clone /
global 需要把原始 token(含重复 -c)原样转发给原生 git,故仍走 parse_git_options。
"""
values: Dict[str, object] = {}
positionals: List[str] = []
i = 0
n = len(args)
while i < n:
arg = args[i]
if arg == '--':
positionals.extend(args[i + 1:])
break
if not arg.startswith('-'):
positionals.append(arg)
i += 1
continue
if '=' in arg:
name, val = arg.split('=', 1)
values[name] = val
elif arg in options_with_value and i + 1 < n:
i += 1
values[arg] = args[i]
else:
values[arg] = True
i += 1
return values, positionals
def parse_clone_args(args: List[str]) -> Tuple[List[str], List[str]]:
options_with_value = {
'-b', '--branch', '--depth', '--origin', '-o', '--template', '--reference',
'--reference-if-able', '--separate-git-dir', '-c', '--config', '--server-option',
'--jobs', '-j', '--filter', '--shallow-since', '--shallow-exclude',
'--upload-pack', '-u'
}
return parse_git_options(args, options_with_value)
def has_option(args: List[str], name: str) -> bool:
return any(arg == name or arg.startswith(name + '=') for arg in args)
class ParsedCommand(NamedTuple):
"""parse_global_options 的解析结果:全局选项 + subcommand + 其参数。"""
global_opts: List[str]
subcmd: str
subcmd_args: List[str]
def parse_global_options(argv: List[str]) -> Optional[ParsedCommand]:
"""解析 git 全局选项并定位 subcommand。
返回 ParsedCommand(global_opts, subcmd, subcmd_args);返回 None 表示应
fallthrough 到原生 git(无 subcommand / `git -- ...` / 未识别全局选项)。
解析函数不再自行 execvp,进程替换的副作用上移到 main(),使「解析」保持纯函数
语义、控制流可读。
"""
args = argv[1:]
options_with_value = {
'-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--exec-path', '--list-cmds'
}
flag_options = {
'-v', '--version', '--help', '--html-path', '--man-path', '--info-path', '-p', '--paginate',
'-P', '--no-pager', '--no-replace-objects', '--bare', '--no-optional-locks', '--no-advice'
}
global_opts, rest = parse_git_options(
args, options_with_value, flags=flag_options,
stop_at_positional=True, strict=True)
if not rest:
return None
head = rest[0]
# 空 token / `git -- ...`(-- 作全局 separator,语义不明)/ 未识别全局选项:
# 均 fallthrough 到原生 git(行为与原 execvp 一致)。
if not head or head == '--' or head.startswith('-'):
return None
return ParsedCommand(global_opts, head, rest[1:])
def effective_cwd(global_opts: List[str]) -> str:
cwd = os.getcwd()
i = 0
while i < len(global_opts):
arg = global_opts[i]
if arg == '-C' and i + 1 < len(global_opts):
path = global_opts[i + 1]
cwd = path if os.path.isabs(
path) else os.path.abspath(os.path.join(cwd, path))
i += 1
i += 1
return cwd
def _init_or_repair_pool() -> None:
"""确保 POOL_PATH 是一个有效的裸仓:不存在则 init;探测损坏则归档重建。
调用方须持有 pool_lock。
"""
if not POOL_PATH.exists():
print(f'[Wrapper] 初始化全局对象池: {POOL_PATH}', file=sys.stderr)
run_git(['init', '--bare', str(POOL_PATH)])
run_git(['--git-dir', str(POOL_PATH), 'config', 'gc.auto', '0'], check=False)
return
# check=False: 用 rev-parse 探测裸仓是否完好,失败即视作损坏并重建
result = run_git(['--git-dir', str(POOL_PATH), 'rev-parse', '--git-dir'],
capture=True, check=False)
if result.returncode == 0:
return
# 探测到一次损坏即归档重建:进程级计数无法跨进程累计,
# 「连续 N 次」语义不成立,故一旦探测失败立刻归档并重建。
broken_path = POOL_PATH.parent / \
f'{POOL_PATH.name}.broken.{int(time.time() * 1000)}'
print(f'[Wrapper] warning: 全局对象池损坏(探测失败),'
f'归档到 {broken_path} 后重新初始化: {POOL_PATH}', file=sys.stderr)
POOL_PATH.rename(broken_path)
init_res = run_git(
['init', '--bare', str(POOL_PATH)], capture=True, check=False)
if init_res.returncode != 0:
print(f'[Wrapper] warning: 池 init --bare 失败,回滚归档: '
f'{(init_res.stderr or "").strip()}', file=sys.stderr)
broken_path.rename(POOL_PATH)
def _register_pool_remote(url: str) -> str:
"""为 url 生成唯一 remote 名并登记/更新到池中,返回该 remote 名。
不存在则 add,URL 变化则 set-url。调用方须持有 pool_lock。
"""
name = _url_to_remote_name(url)
# check=False: remote 不存在时 get-url 返回非零,按「需新增」处理
existing = run_git(['--git-dir', str(POOL_PATH), 'remote', 'get-url', name],
capture=True, check=False)
if existing.returncode != 0:
run_git(['--git-dir', str(POOL_PATH), 'remote', 'add', name, url])
print(f'[Wrapper] 预热对象池(首次拉取 {url} 可能需要较长时间)...', file=sys.stderr)
elif existing.stdout.strip() != url:
run_git(['--git-dir', str(POOL_PATH),
'remote', 'set-url', name, url])
return name
def _fetch_pool_remote(name: str, url: str) -> None:
"""fetch 指定池内 remote(--prune)。进程内同 URL 只 fetch 一次(去重加速)。
调用方须持有 pool_lock。
"""
global _fetched_urls
# 进程内去重:同一 URL 在本次进程内只 fetch 一次,避免递归 submodule 重复拉取
if url in _fetched_urls:
return
# check=False: 池更新失败不应中断主命令(worktree 仍可用旧对象 + 后续直连)
run_git(['--git-dir', str(POOL_PATH), 'fetch', name, '--prune', '--progress'],
capture=False, check=False)
_fetched_urls.add(url)
def ensure_pool_bare_repo(url: str):
"""全局唯一池裸仓 ensure:固定使用 POOL_PATH。并发安全(文件锁)。
编排四个职责单一的步骤:
1) 初始化池目录骨架(_ensure_pool_dir)
2) 确保池裸仓有效(_init_or_repair_pool:不存在则 init,损坏则归档重建)
3) 登记/更新该 URL 的池内 remote(_register_pool_remote)
4) fetch 该 remote(_fetch_pool_remote,进程内同 URL 去重)
"""
_ensure_pool_dir()
with pool_lock():
_init_or_repair_pool()
name = _register_pool_remote(url)
_fetch_pool_remote(name, url)
def should_skip_pool(url: str) -> bool:
"""返回 True 表示该 URL 不应进池(本地路径 / file:// / 相对路径)。
注意:相对 URL 在调用前应先用 resolve_relative_url 解析为绝对 URL。"""
if not url:
return True
# 统一基于 expanded 判断:os.path.expanduser 仅展开 ~ 前缀,file:// 等不含 ~ 的串
# 原样返回,故 expanded.startswith('file://') 与 url.startswith('file://') 等价,
# 这里统一用 expanded,避免 url / expanded 混用。
expanded = os.path.expanduser(url)
return (expanded.startswith('./') or expanded.startswith('../') or
expanded.startswith('/') or expanded.startswith('file://'))
def replace_alternates(gitdir: str, pool_path: Path):
objects_dir = Path(gitdir) / 'objects'
info_dir = objects_dir / 'info'
info_dir.mkdir(parents=True, exist_ok=True)
alternates = info_dir / 'alternates'
alternate = str(pool_path / 'objects')
# 幂等:文件已存在且内容完全匹配,跳过写入
if alternates.exists():
try:
existing = [l.strip() for l in alternates.read_text().splitlines() if l.strip()]
if existing == [alternate]:
return
except OSError:
pass
tmp = info_dir / 'alternates.tmp'
with open(tmp, 'w') as f:
f.write(alternate + '\n')
os.replace(str(tmp), str(alternates))
# ---------- clone / fetch ----------
def do_clone(global_opts: List[str], subcmd_args: List[str]):
clone_args = subcmd_args
if (has_option(clone_args, '--reference-if-able') or has_option(clone_args, '--reference') or
has_option(clone_args, '--shared') or
has_option(clone_args, '--bare') or has_option(clone_args, '--mirror') or
has_option(clone_args, '--dissociate')):
return _exec_passthrough(global_opts + ['clone'] + subcmd_args)
passthrough_args, positionals = parse_clone_args(clone_args)
if not positionals:
return _exec_passthrough(global_opts + ['clone'] + subcmd_args)
url = _sanitize_url(positionals[0])
if should_skip_pool(url):
return _exec_passthrough(global_opts + ['clone'] + subcmd_args)
dest = positionals[1] if len(positionals) > 1 else os.path.basename(
url.rstrip('/')).removesuffix('.git')
ensure_pool_bare_repo(url)
new_argv = global_opts + ['clone']
new_argv.extend(passthrough_args)
new_argv.append(f'--reference={str(POOL_PATH)}')
new_argv.append(url)
new_argv.append(dest)
run_git(new_argv, capture=False)
clone_cwd = effective_cwd(global_opts)
new_dot_git = os.path.join(
os.path.abspath(
os.path.join(
clone_cwd,
dest)),
'.git')
# 复用 resolve_gitdir 解析 `.git`(目录或 gitdir: 指针文件),不再在此重复解析逻辑
real_gitdir = resolve_gitdir(new_dot_git)
if real_gitdir:
register_shell(real_gitdir)
def extract_gitdir_override(global_opts: List[str]) -> Optional[str]:
i = 0
while i < len(global_opts):
arg = global_opts[i]
if arg == '--git-dir' and i + 1 < len(global_opts):
return global_opts[i + 1]
if arg.startswith('--git-dir='):
return arg.split('=', 1)[1]
i += 1
return None
def _current_tracking_remote(gitdir: str) -> str:
"""返回当前分支配置的 tracking remote 名(branch.<name>.remote);无法确定时返回空串。"""
# check=False: detached HEAD / 无分支时返回非零,按「无 tracking remote」处理
br = run_git(['--git-dir', gitdir, 'symbolic-ref', '--short', '-q', 'HEAD'],
capture=True, check=False)
branch = br.stdout.strip()
if br.returncode != 0 or not branch:
return ''
# check=False: 分支未配置 remote 时返回非零,按空串处理
rm = run_git(['--git-dir', gitdir, 'config', '--get', f'branch.{branch}.remote'],
capture=True, check=False)
return rm.stdout.strip() if rm.returncode == 0 else ''
def _snapshot_objects(gd: str) -> Tuple:
pack_dir = os.path.join(gd, 'objects', 'pack')
obj_dir = os.path.join(gd, 'objects')
try:
packs = frozenset(
f for f in os.listdir(pack_dir) if f.endswith('.pack')
) if os.path.isdir(pack_dir) else frozenset()
except OSError:
packs = frozenset()
try:
obj_mtime = os.stat(obj_dir).st_mtime_ns
except OSError:
obj_mtime = 0
try:
pack_mtime = os.stat(pack_dir).st_mtime_ns if os.path.isdir(
pack_dir) else 0
except OSError:
pack_mtime = 0
return (packs, obj_mtime, pack_mtime)
def do_fetch(global_opts: List[str], subcmd_args: List[str]):
gitdir_override = extract_gitdir_override(global_opts)
gitdir = gitdir_override if gitdir_override else find_gitdir(
effective_cwd(global_opts))
if not gitdir:
return _exec_passthrough(global_opts + ['fetch'] + subcmd_args)
# --unshallow / --update-shallow / --dry-run 不触发池处理,直接透传(进程替换)
if _SHALLOW_BYPASS_FLAGS.intersection(subcmd_args):
return _exec_passthrough(global_opts + ['fetch'] + subcmd_args)
if '--dry-run' in subcmd_args:
return _exec_passthrough(global_opts + ['fetch'] + subcmd_args)
# 注意:_exec_passthrough 是进程替换/退出,会导致后续池处理无法执行。
# 改为先用 run_git 执行原生 fetch(透传用户参数),再做池后处理,最后按其退出码退出。
# check=False: fetch 失败也要回收退出码,由本函数末尾 sys.exit 统一返回
# 已 migrate 时:fetch 前快照本地 objects 状态,用于事后判断是否真的有新对象落地。
# pack 文件名(含 SHA1)变化 → 有新 pack 进来;objects/ 目录 mtime 变化 → 有 loose object 落地。
# 两者都没变说明 fetch 没带来任何新对象,可安全跳过 _fetch_local_to_pool + repack。
target_objects = os.path.abspath(str(POOL_PATH / 'objects'))
already_migrated = target_objects in read_alternates(gitdir)
pre_snapshot = _snapshot_objects(gitdir) if already_migrated else None
# 已 migrate 的 submodule 的 pre-snapshot:与主仓库 pre-snapshot 同一时机采集。
# `git fetch --all` 会拉所有已初始化 submodule 的新对象,落到各自 .git/modules/<sub>/objects/,
# 不进池。这里枚举 .git/modules/ 下已 migrate 的 submodule gitdir,事后判断是否需要进池。
sub_pre_snapshots: Dict[str, tuple] = {}
modules_dir = Path(gitdir) / 'modules'
if already_migrated and modules_dir.is_dir():
for sub_gitdir in _scan_orphan_module_gitdirs(modules_dir):
sub_gitdir_str = str(sub_gitdir)
# 未 migrate 的 submodule 跳过
if target_objects in read_alternates(sub_gitdir_str):
sub_pre_snapshots[sub_gitdir_str] = _snapshot_objects(
sub_gitdir_str)
fetch_result = run_git(global_opts + ['fetch'] + subcmd_args, check=False)
if already_migrated:
post_snapshot = _snapshot_objects(gitdir)
objects_changed = (pre_snapshot != post_snapshot)
if objects_changed:
# fetch 带来了新对象:搬进池,再 repack 收缩本地存储
_fetch_local_to_pool(gitdir, POOL_PATH)
# check=False: repack 失败仅打印警告,不抛
res = run_git(['--git-dir', gitdir, 'repack', '-a',
'-d', '--local'], capture=True, check=False)
if res.returncode != 0:
print(
f'[Wrapper] warning: fetch 后 repack 失败: {res.stderr.strip()}',
file=sys.stderr)
# objects_changed == False:up-to-date,无新对象落地,跳过后处理
# 已 migrate 的 submodule 的对称后处理:对比前后快照,有变化才进池 + repack。
# 失败仅 warning,不影响主仓库 fetch 退出码。
for sub_gitdir_str, sub_pre in sub_pre_snapshots.items():
try:
sub_post = _snapshot_objects(sub_gitdir_str)
if sub_pre == sub_post:
continue
_fetch_local_to_pool(sub_gitdir_str, POOL_PATH)
res = run_git(['--git-dir', sub_gitdir_str, 'repack', '-a',
'-d', '--local'], capture=True, check=False)
if res.returncode != 0:
print(
f'[Wrapper] warning: submodule {sub_gitdir_str} fetch 后 repack 失败: {res.stderr.strip()}',
file=sys.stderr)
except Exception as e:
print(
f'[Wrapper] warning: submodule {sub_gitdir_str} 池后处理失败: {e}',
file=sys.stderr)
else:
# 未 migrate:取 remote URL(当前分支 tracking remote → fallback origin),预热全局池
remote_url = ''
tracking = _current_tracking_remote(gitdir)
if tracking:
remote_url = get_remote_url_by_name(gitdir, tracking)
if not remote_url:
remote_url = get_remote_url_by_name(gitdir, 'origin')
if remote_url and not should_skip_pool(remote_url):
ensure_pool_bare_repo(remote_url)
sys.exit(fetch_result.returncode)
# ---------- submodule(自驱方案) ----------
def resolve_relative_url(parent_remote: str, sub_url: str) -> str:
"""相对 URL(./../)相对父仓 remote.origin.url 解析为绝对 URL。
parent_remote 为空时返回原相对路径,should_skip_pool 会将其判定为本地路径跳过池化,
属预期安全降级。
"""
if not (sub_url.startswith('./') or sub_url.startswith('../')):
return sub_url
if not parent_remote:
return sub_url
base = parent_remote.rstrip('/')
parts = sub_url.split('/')
for p in parts:
if p == '..':
base = base.rsplit('/', 1)[0]
elif p == '.' or p == '':
continue
else:
base = base + '/' + p
return base
def parse_gitmodules(worktree_root: str) -> List[Dict[str, str]]:
"""解析 .gitmodules,返回 submodule 条目列表。"""
gm = os.path.join(worktree_root, '.gitmodules')
if not os.path.exists(gm):
return []
# check=False: 无 .gitmodules 或解析失败时按「无 submodule」处理,返回空列表
res = run_git(['-C', worktree_root, 'config', '--file', '.gitmodules', '--list'],
capture=True, check=False)
if res.returncode != 0:
return []
subs: Dict[str, Dict[str, str]] = {}
for line in res.stdout.splitlines():
if '=' not in line:
continue
key, val = line.split('=', 1)
if not key.startswith('submodule.'):
continue
# key 形如 submodule.<name>.<attr>,name 中可能含 '.'
rest = key[len('submodule.'):]
idx = rest.rfind('.')
if idx < 0:
continue
name = rest[:idx]
attr = rest[idx + 1:]
subs.setdefault(name, {})[attr] = val
result = []
for name, attrs in subs.items():
if 'path' not in attrs or 'url' not in attrs:
continue
result.append({
'name': name,
'path': attrs['path'],
'url': attrs['url'],
'update': attrs.get('update', 'checkout'),
'branch': attrs.get('branch', ''),
})
return result
def submodule_target_commit(parent_worktree: str,
sub_path: str) -> Optional[str]:
"""从父 worktree 取 submodule 在 HEAD 中的目标 commit。"""
# check=False: HEAD 缺失 / 路径非 submodule 时返回非零,按「无目标 commit」处理
res = run_git(['-C', parent_worktree, 'ls-tree', 'HEAD', sub_path],
capture=True, check=False)
if res.returncode != 0:
return None
line = res.stdout.strip()
if not line:
return None
# "<mode> <type> <hash>\t<path>"
head, _tab, _rest = line.partition('\t')
parts = head.split()
if len(parts) < 3 or parts[0] != '160000':
return None
return parts[2]
def get_remote_url(worktree: str) -> str:
# check=False: 未配置 remote.origin.url 时返回非零,按空 URL 处理
res = run_git(['-C', worktree, 'config', '--get', 'remote.origin.url'],
capture=True, check=False)
return _sanitize_url(res.stdout.strip())
def get_remote_url_by_name(gitdir: str, remote_name: str) -> str:
"""返回指定 remote 名的 fetch URL(已 sanitize);不存在或为本地路径时返回空串。"""
# check=False: remote 不存在时返回非零,按空 URL 处理
r = run_git(['--git-dir', gitdir, 'remote', 'get-url',
remote_name], capture=True, check=False)
if r.returncode != 0:
return ''
return _sanitize_url(r.stdout.strip())
def run_native_submodule_update(parent_worktree: str, sub_path: str,
recursive: bool, depth: Optional[str], buf: List[str]):
"""update=rebase/merge 等场景:池已预热后,调原生 git 执行原始策略。"""
args = ['-C', parent_worktree, 'submodule', 'update', '--init']
if recursive:
args.append('--recursive')
if depth:
args.extend(['--depth', str(depth)])
args.append('--')
args.append(sub_path)
res = subprocess.run(
[SYSTEM_GIT] + args,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL)
if res.stdout:
buf.append(res.stdout.rstrip('\n'))
if res.stderr:
buf.append(res.stderr.rstrip('\n'))
def _resolve_submodule_gitdir(parent_worktree: str, toplevel_gitdir: Optional[str],
name: str) -> Optional[str]:
"""计算 submodule 的 gitdir 路径(<toplevel-or-parent>/.git/modules/<name>)。
无法定位父仓 gitdir 时返回 None。
"""
if toplevel_gitdir:
return os.path.join(toplevel_gitdir, 'modules', name)
parent_gd = find_gitdir(parent_worktree)
if not parent_gd:
return None
return os.path.join(parent_gd, 'modules', name)
def _detect_submodule_initialized(sub_worktree: str, gitdir: str, sub_path: str,
buf: List[str]) -> Tuple[bool, str]:
"""判断 submodule 是否已初始化,返回 (initialized, gitdir)。
三种「已初始化」情形(命中即按更新路径处理):
a) worktree 与计算出的 gitdir 都存在,且 worktree 内有 .git(指针文件或目录);
b) worktree 内有 .git(可能由父 submodule 递归初始化),gitdir 以真实解析为准;
c) worktree 已存在且非空但无 .git:跳过 clone,按已初始化走更新路径。
"""
wt_git = os.path.join(sub_worktree, '.git')
has_dot_git = os.path.isfile(wt_git) or os.path.isdir(wt_git)
# 情形 a:保留计算出的 gitdir(更新路径会再用 find_gitdir 校正)
if os.path.isdir(sub_worktree) and os.path.isdir(gitdir) and has_dot_git:
return True, gitdir
# 情形 b:worktree 已由外部建好,找真实 gitdir
if os.path.isdir(sub_worktree) and has_dot_git:
return True, find_gitdir(sub_worktree) or gitdir
# 情形 c:非空 worktree 无 .git,跳过 clone
if os.path.isdir(sub_worktree) and os.listdir(sub_worktree):
buf.append(
f"[Wrapper] submodule {sub_path}: worktree 已存在且非空,跳过 clone")
return True, find_gitdir(sub_worktree) or gitdir
return False, gitdir
def _clone_submodule(url: str, gitdir: str, sub_worktree: str, sub_path: str,
depth: Optional[str], filter_: Optional[str], buf: List[str]) -> bool:
"""首次初始化:clone 进池(--no-checkout --separate-git-dir --reference 池)。
成功返回 True,失败记录错误并返回 False。
"""
ensure_pool_bare_repo(url)
os.makedirs(os.path.dirname(gitdir), exist_ok=True)
os.makedirs(sub_worktree, exist_ok=True)
clone_args = ['clone', '--no-checkout',
'--separate-git-dir', gitdir,
'--reference', str(POOL_PATH)]
if depth:
clone_args.extend(['--depth', str(depth)])
if filter_:
clone_args.extend([f'--filter={filter_}'])
clone_args.extend([url, sub_worktree])
# check=False: clone 失败由下方据 returncode 显式记录错误并跳过该 submodule
res = run_git(clone_args, capture=True, check=False)
if res.returncode != 0:
if res.stderr:
buf.append(res.stderr.rstrip('\n'))
buf.append(
f"[Wrapper] error: clone failed for submodule {sub_path}")
return False
replace_alternates(gitdir, POOL_PATH)
register_shell(gitdir)
return True
def _update_existing_submodule(url: str, gitdir: str, sub_worktree: str,
commit: str) -> str:
"""已存在:定位真实 gitdir + 池预热 + alternates + fetch,返回最终 gitdir。
快路径:已 alternates 到池且目标 commit 本地可达时,跳过 pool fetch 和 gitdir fetch。
"""
existing_gd = find_gitdir(sub_worktree)
if existing_gd:
gitdir = existing_gd
target_objects = os.path.abspath(str(POOL_PATH / 'objects'))
already_in_pool = target_objects in read_alternates(gitdir)
commit_reachable = (
already_in_pool and
run_git(['--git-dir', gitdir, 'cat-file', '-e', commit],
capture=True, check=False).returncode == 0
)
if commit_reachable:
# alternates 已是目标池,幂等调用直接返回;register_shell 同样幂等
replace_alternates(gitdir, POOL_PATH)
register_shell(gitdir)
else:
ensure_pool_bare_repo(url)
replace_alternates(gitdir, POOL_PATH)
register_shell(gitdir)
# check=False: 池已预热,fetch 更新失败不应中断 checkout(可用已有对象)
run_git(['--git-dir', gitdir, 'fetch', '--all', '--prune'],
capture=True, check=False)
return gitdir
def _checkout_submodule(gitdir: str, sub_worktree: str, sub_path: str,
commit: str, buf: List[str]) -> bool:
"""checkout --detach 到目标 commit;本地缺对象时 fetch 兜底并重试一次。
成功返回 True,失败记录错误并返回 False。
"""
# check=False: 首次 checkout 失败后走 fetch 兜底重试,故此处忽略错误
res = run_git(['--git-dir', gitdir, '--work-tree', sub_worktree,
'checkout', '--detach', commit, '-q'],
capture=True, check=False)
if res.returncode != 0:
# 若本地缺该 commit,再 fetch 一次
# check=False: fetch 兜底,失败由下方二次 checkout 的 returncode 统一判定