forked from OneDragon-Anything/ZenlessZoneZero-OneDragon
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgit_service.py
More file actions
1940 lines (1676 loc) · 76.3 KB
/
Copy pathgit_service.py
File metadata and controls
1940 lines (1676 loc) · 76.3 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
import contextlib
import os
import queue
import re
import shutil
import stat
import sys
import tempfile
import threading
import time
import uuid
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
import psutil
import yaml
from packaging import version
from pygit2 import (
Blob,
Commit,
Oid,
Remote,
RemoteCallbacks,
Repository,
Walker,
discover_repository,
init_repository,
settings,
)
from pygit2.enums import (
CheckoutStrategy,
ConfigLevel,
ReferenceType,
ResetMode,
SortMode,
)
from one_dragon.base.config.config_item import ConfigItem
from one_dragon.envs.env_config import EnvConfig
from one_dragon.envs.repo_config import RepoConfig, RepositoryItem
from one_dragon.utils import os_utils
from one_dragon.utils.i18_utils import gt
from one_dragon.utils.log_utils import log
REMOTE_FETCH_INITIAL_TIMEOUT = 10.0
REMOTE_FETCH_IDLE_TIMEOUT = 30.0
REMOTE_FETCH_TIMEOUT = 120.0
_FETCH_TIMEOUT_SETTING_NAMES = ('server_connect_timeout', 'server_timeout')
_fetch_timeout_settings_lock = threading.Lock()
_fetch_timeout_settings_configured = False
_fetch_temp_cleanup_lock = threading.Lock()
_fetch_temp_cleanup_roots: set[Path] = set()
class GitSyncStatus(StrEnum):
"""Git 代码同步结果。枚举值为面向用户的中性结果文案。"""
SUCCESS = '更新完成'
UP_TO_DATE = '当前已是最新版本'
RUNTIME_INCOMPATIBLE = '新版本需要更新启动器才能使用'
BUILTIN_TAG_UNAVAILABLE = '暂时无法获取当前版本所需文件'
REMOTE_UNAVAILABLE = '暂时无法获取更新'
LOCAL_CHANGES = '检测到程序文件有改动,未自动更新'
LOCAL_UPDATE_FAILED = '更新没有完成'
FAILED = '更新失败'
class _LocalGitMetadataError(RuntimeError):
"""本地 Git 元数据损坏,不能通过切换代码源修复。"""
def _get_repository_objects_path(repo: Repository) -> Path:
"""获取仓库实际使用的 Git 对象目录,兼容 linked worktree。"""
repo_path = Path(repo.path)
commondir_path = repo_path / 'commondir'
if commondir_path.is_file():
common_dir_value = commondir_path.read_text(encoding='utf-8').strip()
common_dir = Path(common_dir_value)
if not common_dir.is_absolute():
common_dir = repo_path / common_dir
return common_dir.resolve() / 'objects'
return repo_path / 'objects'
def _get_repository_shallow_path(repo: Repository) -> Path:
"""获取仓库级 shallow 文件路径。"""
return _get_repository_objects_path(repo).parent / 'shallow'
def _read_shallow_snapshot(shallow_path: Path) -> bytes | None:
"""二进制读取 shallow 快照;文件不存在表示无浅边界。"""
return shallow_path.read_bytes() if shallow_path.is_file() else None
def _parse_shallow_snapshot(snapshot: bytes | None) -> list[str]:
"""校验 shallow 快照并按原顺序返回 OID。"""
if not snapshot:
return []
if b'\r' in snapshot or not snapshot.endswith(b'\n'):
raise ValueError('shallow 文件必须使用 LF 行尾')
oids: list[str] = []
for raw_line in snapshot.splitlines():
try:
oid = Oid(hex=raw_line.decode('ascii'))
except (UnicodeDecodeError, ValueError) as error:
raise ValueError('shallow 文件包含无效 OID') from error
oids.append(str(oid))
return oids
def _write_shallow_snapshot(shallow_path: Path, snapshot: bytes | None) -> None:
"""原子写入 shallow 快照;None 表示删除文件。"""
if snapshot is None:
shallow_path.unlink(missing_ok=True)
return
temporary_path = shallow_path.with_name(
f'{shallow_path.name}.one-dragon-{uuid.uuid4().hex}.tmp'
)
try:
temporary_path.write_bytes(snapshot)
os.replace(temporary_path, shallow_path)
finally:
temporary_path.unlink(missing_ok=True)
def _merge_shallow_snapshots(
original_snapshot: bytes,
fetched_snapshot: bytes | None,
) -> bytes:
"""完整继承失败时只追加新边界,不删除正式仓库原边界。"""
merged_oids = _parse_shallow_snapshot(original_snapshot)
known_oids = set(merged_oids)
for oid in _parse_shallow_snapshot(fetched_snapshot):
if oid not in known_oids:
merged_oids.append(oid)
known_oids.add(oid)
return b''.join(f'{oid}\n'.encode('ascii') for oid in merged_oids)
def _sync_shallow_file(
repo: Repository,
temp_repo_dir: str,
original_snapshot: bytes | None,
authoritative: bool,
) -> None:
"""把临时仓库的整仓 shallow 状态同步到正式仓库。"""
fetched_snapshot = _read_shallow_snapshot(Path(temp_repo_dir) / 'shallow')
if authoritative or not original_snapshot:
_parse_shallow_snapshot(fetched_snapshot)
final_snapshot = fetched_snapshot or None
else:
final_snapshot = _merge_shallow_snapshots(original_snapshot, fetched_snapshot)
_write_shallow_snapshot(_get_repository_shallow_path(repo), final_snapshot)
def _configure_alternate_objects(temp_repo: Repository, source_objects_dir: str | None) -> bool:
"""让临时仓库只读复用正式仓库的 Git 对象。"""
if not source_objects_dir:
return False
source_path = Path(source_objects_dir).resolve()
if not source_path.is_dir():
return False
alternates_path = Path(temp_repo.path) / 'objects' / 'info' / 'alternates'
alternates_path.parent.mkdir(parents=True, exist_ok=True)
alternates_path.write_text(f'{source_path}\n', encoding='utf-8')
return True
@contextlib.contextmanager
def _temporary_fetch_timeout_context() -> Iterator[None]:
"""设置 pygit2 的连接和网络读写超时,只设置一次且不恢复。"""
global _fetch_timeout_settings_configured
with _fetch_timeout_settings_lock:
if not _fetch_timeout_settings_configured:
timeout_ms = int(REMOTE_FETCH_IDLE_TIMEOUT * 1000)
effective_values: dict[str, int] = {}
for setting_name in _FETCH_TIMEOUT_SETTING_NAMES:
if hasattr(settings, setting_name):
setattr(settings, setting_name, timeout_ms)
effective_values[setting_name] = int(getattr(settings, setting_name))
log.info(
'Git fetch 超时设置: server_connect_timeout=%sms, server_timeout=%sms',
effective_values.get('server_connect_timeout', '不可用'),
effective_values.get('server_timeout', '不可用'),
)
_fetch_timeout_settings_configured = True
yield
def _remove_temp_repo(temp_repo_dir: str) -> None:
"""删除 fetch 临时仓库,兼容只读文件和 Windows 文件占用。"""
def remove_readonly(
func: Callable[[str], object],
path: str,
_exc_info: object,
) -> None:
Path(path).chmod(stat.S_IWRITE)
func(path)
try:
shutil.rmtree(temp_repo_dir, onerror=remove_readonly)
except FileNotFoundError:
return
except PermissionError as error:
if sys.platform == 'win32' and error.winerror in (5, 32):
log.info(f'Git fetch 临时仓库仍被占用,将在下次启动时清理: {temp_repo_dir}')
return
log.warning(f'清理 Git fetch 临时仓库失败: {temp_repo_dir}', exc_info=True)
except Exception:
log.warning(f'清理 Git fetch 临时仓库失败: {temp_repo_dir}', exc_info=True)
def _is_process_running(process_id: int) -> bool:
"""判断临时仓库所属进程是否仍在运行;无效 PID 按仍在运行处理。"""
return process_id <= 0 or psutil.pid_exists(process_id)
def _cleanup_stale_fetch_repositories(temp_root: Path) -> None:
"""清理已退出进程遗留的新格式 fetch 临时仓库。"""
if not temp_root.is_dir():
return
for temp_repo_dir in temp_root.glob('fetch_*'):
if not temp_repo_dir.is_dir():
continue
name_parts = temp_repo_dir.name.split('_', 2)
if len(name_parts) == 3 and name_parts[1].isdigit():
if _is_process_running(int(name_parts[1])):
continue
_remove_temp_repo(str(temp_repo_dir))
def _cleanup_stale_fetch_repositories_once(temp_root: Path) -> None:
"""同一进程对同一临时根目录只执行一次遗留目录清理。"""
resolved_root = temp_root.resolve()
with _fetch_temp_cleanup_lock:
if resolved_root in _fetch_temp_cleanup_roots:
return
_cleanup_stale_fetch_repositories(resolved_root)
_fetch_temp_cleanup_roots.add(resolved_root)
def _send_fetch_worker_message(
message_callback: Callable[[dict[str, object]], None],
message: dict[str, object],
) -> None:
"""向 fetch worker 的线程消息队列发送消息。"""
message_callback(message)
def _restore_temp_fetch_state(
temp_repo: Repository,
temp_repo_dir: str,
shallow_snapshot: bytes | None,
refs_to_delete: set[str],
) -> Repository:
"""恢复 fallback 前的临时 shallow 状态,并移除协商引用。"""
with contextlib.suppress(Exception):
temp_repo.free()
_write_shallow_snapshot(Path(temp_repo_dir) / 'shallow', shallow_snapshot)
restored_repo = Repository(temp_repo_dir)
for ref_name in refs_to_delete:
if ref_name in restored_repo.references:
restored_repo.references.delete(ref_name)
return restored_repo
def _fetch_remote_worker(
temp_repo_dir: str,
source_objects_dir: str | None,
remote_url: str,
branch_name: str,
depth: int,
proxy: str | None,
message_callback: Callable[[dict[str, object]], None],
abandoned: threading.Event,
fetch_ref: str | None = None,
base_ref: str | None = None,
base_oid: str | None = None,
fetch_primary_branch: bool = False,
shallow_snapshot: bytes | None = None,
) -> None:
"""在线程中执行网络 fetch,作废后只清理自己的临时仓库。"""
temp_repo: Repository | None = None
try:
GitService._ensure_config_search_path()
temp_repo = init_repository(temp_repo_dir, bare=True)
target_ref = fetch_ref or f'refs/heads/{branch_name}'
target_refspec = f'+{target_ref}:{target_ref}'
actual_depth = depth
primary_branch_incremental = False
alternate_ready = _configure_alternate_objects(temp_repo, source_objects_dir)
inherited_shallow_snapshot: bytes | None = None
shallow_authoritative = not shallow_snapshot
if shallow_snapshot:
_parse_shallow_snapshot(shallow_snapshot)
if alternate_ready:
inherited_shallow_snapshot = shallow_snapshot
_write_shallow_snapshot(
Path(temp_repo_dir) / 'shallow',
inherited_shallow_snapshot,
)
shallow_authoritative = True
if actual_depth == 0 and not alternate_ready:
actual_depth = 1
log.warning('正式仓库对象目录不可用,降级为 shallow fetch')
elif actual_depth == 0 and base_ref is not None and base_oid is not None:
try:
temp_repo.references.create(base_ref, Oid(hex=base_oid), force=True)
primary_branch_incremental = fetch_primary_branch
except Exception:
actual_depth = 1
with contextlib.suppress(Exception):
temp_repo.references.delete(base_ref)
log.warning('增量基线不可用,降级为 shallow fetch', exc_info=True)
if alternate_ready or inherited_shallow_snapshot is not None or base_ref is not None:
temp_repo.free()
temp_repo = Repository(temp_repo_dir)
refspecs = [target_refspec]
if primary_branch_incremental:
assert base_ref is not None
refspecs.insert(0, f'+{base_ref}:{base_ref}')
temp_repo.remotes.create('origin', remote_url)
temp_repo.config['remote.origin.tagopt'] = '--no-tags'
remote = temp_repo.remotes['origin']
def report_progress(progress: float, message: str) -> None:
_send_fetch_worker_message(
message_callback,
{'type': 'progress', 'progress': progress, 'message': message},
)
callbacks = _FetchProgressRemoteCallbacks(report_progress)
try:
log.info(
f'worker 开始 Git fetch: branch={branch_name}, '
f'depth={actual_depth}, proxy={bool(proxy)}'
)
with _temporary_fetch_timeout_context():
remote.fetch(
refspecs=refspecs,
proxy=proxy,
depth=actual_depth,
callbacks=callbacks,
)
callbacks.flush_sideband_progress()
log.info(f'worker Git fetch 已返回: branch={branch_name}, depth={actual_depth}')
except Exception as error:
can_fallback = primary_branch_incremental or (
isinstance(error, KeyError)
and 'object not found' in str(error)
and actual_depth == 0
)
if not can_fallback:
raise
if primary_branch_incremental:
log.warning('基于项目主分支的增量 fetch 失败,降级为 shallow fetch', exc_info=True)
refs_to_delete = {target_ref}
if base_ref is not None:
refs_to_delete.add(base_ref)
temp_repo = _restore_temp_fetch_state(
temp_repo,
temp_repo_dir,
inherited_shallow_snapshot,
refs_to_delete,
)
remote = temp_repo.remotes['origin']
primary_branch_incremental = False
callbacks = _FetchProgressRemoteCallbacks(report_progress)
with _temporary_fetch_timeout_context():
remote.fetch(
refspecs=[target_refspec],
proxy=proxy,
depth=1,
callbacks=callbacks,
)
callbacks.flush_sideband_progress()
actual_depth = 1
result: dict[str, object] = {
'type': 'result',
'success': True,
'depth': actual_depth,
'shallow_authoritative': shallow_authoritative,
}
if primary_branch_incremental:
result['primary_branch_incremental'] = True
_send_fetch_worker_message(message_callback, result)
except Exception as error:
_send_fetch_worker_message(
message_callback,
{'type': 'result', 'success': False, 'error': repr(error)},
)
finally:
if temp_repo is not None:
with contextlib.suppress(Exception):
temp_repo.free()
if abandoned.is_set():
_remove_temp_repo(temp_repo_dir)
@dataclass
class GitLog:
"""Git 提交日志"""
commit_id: str
author: str
commit_time: str
commit_message: str
class _FetchProgressRemoteCallbacks(RemoteCallbacks):
"""转发 Git 传输进度、服务端消息和引用更新信息。"""
def __init__(
self,
progress_callback: Callable[[float, str], None] | None,
timeout: float | None = REMOTE_FETCH_TIMEOUT,
) -> None:
super().__init__()
self._progress_callback: Callable[[float, str], None] | None = progress_callback
self._timeout: float | None = timeout
self._started_at: float = time.monotonic()
self._progress: float = 0.0
self._last_transfer_messages: dict[str, str] = {}
self._last_transfer_log_at: dict[str, float] = {}
self._last_sideband_message: str | None = None
self._sideband_buffer: str = ''
def _check_timeout(self) -> None:
if self._timeout is not None and time.monotonic() - self._started_at >= self._timeout:
raise TimeoutError(f'Git 远程拉取超过 {self._timeout:g} 秒')
def _emit(self, message: str, progress: float | None = None) -> None:
if self._progress_callback is not None:
self._progress_callback(self._progress if progress is None else progress, message)
else:
log.info(message)
def _report_transfer_progress(
self,
stage: str,
label: str,
current: int,
total: int,
) -> None:
progress = min(max(current / total, 0.0), 1.0)
is_final = current >= total
message = f'{label} {current}/{total} ({round(progress * 100)}%)'
if is_final:
message = f'{message}, done.'
self._progress = progress
if message == self._last_transfer_messages.get(stage):
return
now = time.monotonic()
last_log_at = self._last_transfer_log_at.get(stage)
if not is_final and last_log_at is not None and now - last_log_at < 0.2:
return
self._last_transfer_log_at[stage] = now
self._last_transfer_messages[stage] = message
self._emit(message, progress)
def _report_received_bytes(self, received_bytes: int) -> None:
progress = 0.0
received_mb = received_bytes / 1024 / 1024
message = f'{gt("拉取对象")} {received_mb:.2f} MB'
self._progress = progress
if message == self._last_transfer_messages.get('objects'):
return
now = time.monotonic()
last_log_at = self._last_transfer_log_at.get('objects')
if last_log_at is not None and now - last_log_at < 0.2:
return
self._last_transfer_log_at['objects'] = now
self._last_transfer_messages['objects'] = message
self._emit(message, progress)
def transfer_progress(self, stats: object) -> None:
self._check_timeout()
total_objects = int(getattr(stats, 'total_objects', 0) or 0)
received_objects = int(getattr(stats, 'received_objects', 0) or 0)
total_deltas = int(getattr(stats, 'total_deltas', 0) or 0)
indexed_deltas = int(getattr(stats, 'indexed_deltas', 0) or 0)
received_bytes = int(getattr(stats, 'received_bytes', 0) or 0)
if total_objects > 0:
self._report_transfer_progress(
'objects',
gt('拉取对象'),
received_objects,
total_objects,
)
else:
self._report_received_bytes(received_bytes)
if total_objects > 0 and received_objects >= total_objects and total_deltas > 0:
self._report_transfer_progress(
'deltas',
gt('处理增量'),
indexed_deltas,
total_deltas,
)
def _emit_sideband_message(self, message: str) -> None:
if not message.strip():
return
progress_prefixes = (
('Enumerating objects:', gt('枚举对象:')),
('Counting objects:', gt('统计对象:')),
('Compressing objects:', gt('压缩对象:')),
)
for original_prefix, translated_prefix in progress_prefixes:
if message.startswith(original_prefix):
message = f'{translated_prefix}{message[len(original_prefix):]}'
break
if message == self._last_sideband_message:
return
self._last_sideband_message = message
self._emit(f'远程消息: {message}')
def sideband_progress(self, string: str) -> None:
self._check_timeout()
buffer = f'{self._sideband_buffer}{string}'
message_start = 0
for index, character in enumerate(buffer):
if character not in ('\r', '\n'):
continue
self._emit_sideband_message(buffer[message_start:index])
message_start = index + 1
self._sideband_buffer = buffer[message_start:]
def flush_sideband_progress(self) -> None:
"""输出 fetch 退出时仍未带行尾的远端文本。"""
message = self._sideband_buffer
self._sideband_buffer = ''
self._emit_sideband_message(message)
def update_tips(self, refname: str, old: Oid, new: Oid) -> None:
self._check_timeout()
self.flush_sideband_progress()
self._emit(f'更新引用: {refname}')
class GitService:
def __init__(
self,
env_config: EnvConfig,
repo_config: RepoConfig,
repo_dir: str | None = None,
) -> None:
self.env_config: EnvConfig = env_config
self.repo_config: RepoConfig = repo_config
if repo_dir:
if not Path(repo_dir).is_absolute():
repo_dir = str(Path(os_utils.get_work_dir()) / repo_dir)
else:
repo_dir = os_utils.get_work_dir()
self.repo_dir: str = repo_dir
self._repo: Repository | None = None
self._rebuilding_repository: bool = False
self._ensure_config_search_path()
# ================== 私有辅助方法 ==================
@staticmethod
def _ensure_config_search_path() -> None:
"""
通过设置配置搜索路径为空字符串,忽略用户的系统级和全局级 git 配置。
这可以避免用户的全局配置(如 http.proxy、user.name、SSL 证书路径等)影响程序的 git 操作。
同时忽略用户可能残留的无效 SSL 证书配置,让 libgit2 使用系统默认的证书验证机制,避免 SSL 证书问题。
"""
settings.search_path[ConfigLevel.PROGRAMDATA] = '' # 机器范围 (C:\ProgramData\Git\config)
settings.search_path[ConfigLevel.SYSTEM] = '' # 系统级 (如 C:\Program Files\Git\mingw64\etc\gitconfig)
settings.search_path[ConfigLevel.GLOBAL] = '' # 用户全局 (%USERPROFILE%\.gitconfig)
settings.search_path[ConfigLevel.XDG] = '' # XDG 配置 (%USERPROFILE%\.config\git\config)
settings.owner_validation = False # 禁用仓库所有权验证
def _open_repo(self, refresh: bool = False) -> Repository:
"""打开仓库(带缓存)"""
if refresh:
self._repo = None
if self._repo is None:
# 检查是否是有效的 git 仓库
git_dir = discover_repository(self.repo_dir)
if not git_dir:
raise ValueError(f'目录 {self.repo_dir} 不是有效的 Git 仓库')
try:
self._repo = Repository(git_dir)
except Exception:
try:
_parse_shallow_snapshot(
_read_shallow_snapshot(Path(git_dir) / 'shallow')
)
except ValueError as shallow_error:
raise _LocalGitMetadataError('shallow metadata corrupted') from shallow_error
raise
return self._repo
def _ensure_remote(self, remote_url: str | None = None) -> Remote:
"""确保指定远程仓库地址配置到当前本地 remote。"""
if remote_url is None:
remote_url = self._get_git_repository()
if not remote_url:
raise ValueError('未能获取有效的远程仓库地址')
repo = self._open_repo()
remote_name = self.env_config.git_remote
if remote_name in repo.remotes.names():
remote = repo.remotes[remote_name]
if remote.url == remote_url:
return remote
log.info(f'更新远程仓库地址: {remote.url} -> {remote_url}')
repo.remotes.set_url(remote_name, remote_url)
return repo.remotes[remote_name]
log.info(f'创建远程仓库: {remote_name} -> {remote_url}')
repo.remotes.create(remote_name, remote_url)
return repo.remotes[remote_name]
def _get_repository_item(self, repository: RepositoryItem) -> ConfigItem:
"""获取代码源配置项。"""
return repository.config_item
def _find_repository(self, value: str) -> RepositoryItem | None:
"""按仓库 ID、显示标题或 URL 查找代码源。"""
return self.repo_config.find_repository(value)
def _get_repository_url(self, repository: RepositoryItem, use_gh_proxy: bool = True) -> str:
"""获取指定代码源的 HTTPS 地址。"""
repository_url = repository.url
if use_gh_proxy and repository.use_proxy and self.env_config.is_gh_proxy:
return f'{self.env_config.gh_proxy_url.rstrip("/")}/{repository_url}'
return repository_url
def _get_repository_candidates(self) -> list[tuple[RepositoryItem, str]]:
"""按用户选择、上次成功源和 YAML 声明顺序生成候选列表。"""
repository_url = self.env_config.repository_url
preferred_repository = self._find_repository(repository_url)
if preferred_repository is None and repository_url != RepoConfig.AUTO_REPOSITORY_VALUE:
self.env_config.repository_url = RepoConfig.AUTO_REPOSITORY_VALUE
if preferred_repository is None:
preferred_repository = self._find_repository(self.env_config.last_repository_url)
candidates: list[tuple[RepositoryItem, str]] = []
for repository in [preferred_repository, *self.repo_config.repositories]:
if repository is None or any(candidate[0] is repository for candidate in candidates):
continue
repository_url = self._get_repository_url(repository)
if repository_url:
candidates.append((repository, repository_url))
return candidates
def _get_git_repository(self) -> str:
"""获取当前选择模式下首个候选代码源地址。"""
candidates = self._get_repository_candidates()
if not candidates:
raise ValueError('未能获取有效的远程仓库地址')
return candidates[0][1]
def _restore_origin(self) -> bool:
"""将当前本地 remote 恢复为项目主仓库 HTTPS 地址。"""
primary_url = self._get_repository_url(self.repo_config.primary_repository, use_gh_proxy=False)
if not primary_url:
return False
try:
self._ensure_remote(primary_url)
return True
except Exception:
log.error('恢复主仓库远程地址失败', exc_info=True)
return False
@contextlib.contextmanager
def _temporary_fetch_timeout(self) -> Iterator[None]:
"""设置 pygit2 的连接和网络读写超时,只设置一次且不恢复。"""
with _temporary_fetch_timeout_context():
yield
def _get_proxy_address(self) -> str | None:
"""获取代理地址"""
if not self.env_config.is_personal_proxy:
return None
proxy = self.env_config.personal_proxy.strip()
if not proxy:
return None
if proxy.startswith(('http://', 'https://', 'socks5://')):
return proxy
return f'http://{proxy}'
def _create_fetch_callbacks(
self,
progress_callback: Callable[[float, str], None] | None,
stage_start: float,
stage_end: float,
) -> RemoteCallbacks:
"""创建远程拉取回调。"""
def stage_progress_callback(progress: float, message: str) -> None:
mapped_progress = stage_start + (stage_end - stage_start) * progress
if progress_callback is not None:
progress_callback(mapped_progress, message)
return _FetchProgressRemoteCallbacks(stage_progress_callback, REMOTE_FETCH_TIMEOUT)
def _validate_history(self, repo: Repository, start_oid: Oid) -> None:
"""确认提交历史可遍历到完整根或合法 shallow 边界。"""
try:
for _ in repo.walk(start_oid, SortMode.TOPOLOGICAL):
pass
except KeyError as error:
if self._is_missing_object_error(error):
raise _LocalGitMetadataError('commit history has undeclared gap') from error
raise
def _derive_single_shallow_boundary(self, repo: Repository, start_oid: Oid) -> Oid:
"""沿项目主分支第一父链推导唯一 shallow 边界。"""
current_oid = start_oid
child_oid: Oid | None = None
latest_merge_oid: Oid | None = None
latest_merge_child_oid: Oid | None = None
while True:
try:
commit = repo[current_oid]
except KeyError as error:
if self._is_missing_object_error(error):
raise _LocalGitMetadataError('commit history has undeclared gap') from error
raise
if len(commit.parent_ids) > 1:
latest_merge_oid = current_oid
latest_merge_child_oid = child_oid
if not commit.parent_ids:
break
try:
parent = commit.parents[0]
except KeyError as error:
if self._is_missing_object_error(error):
break
raise
child_oid = current_oid
current_oid = parent.id
if latest_merge_oid is not None:
if latest_merge_child_oid is not None:
return latest_merge_child_oid
return latest_merge_oid
return current_oid
def _repair_primary_branch_shallow(
self,
repo: Repository,
start_oid: Oid,
shallow_snapshot: bytes | None,
primary_branch: str,
) -> tuple[Repository, bytes | None]:
"""把主分支缺口前的单个可达提交追加为 shallow 边界。"""
boundary_oid = self._derive_single_shallow_boundary(repo, start_oid)
shallow_path = _get_repository_shallow_path(repo)
shallow_oids = _parse_shallow_snapshot(shallow_snapshot)
if str(boundary_oid) not in shallow_oids:
shallow_oids.append(str(boundary_oid))
repaired_snapshot = b''.join(
f'{oid}\n'.encode('ascii')
for oid in shallow_oids
)
_write_shallow_snapshot(shallow_path, repaired_snapshot)
self._repo = None
with contextlib.suppress(Exception):
repo.free()
repaired_repo = self._open_repo()
try:
self._validate_history(repaired_repo, start_oid)
except BaseException as error:
with contextlib.suppress(Exception):
_write_shallow_snapshot(shallow_path, shallow_snapshot)
self._repo = None
with contextlib.suppress(Exception):
repaired_repo.free()
with contextlib.suppress(Exception):
self._open_repo()
raise _LocalGitMetadataError('commit history has undeclared gap') from error
log.warning(
f'检测到项目主分支 {primary_branch} 存在未声明历史缺口,'
f'已将 {boundary_oid} 记录为 shallow 边界;如需完整历史,请执行 '
f'git fetch --unshallow {self.env_config.git_remote} {primary_branch}'
)
return repaired_repo, repaired_snapshot
def _get_primary_branch_oid(
self,
repo: Repository,
primary_branch: str,
) -> Oid | None:
"""获取可用于其他分支增量 fetch 的项目主分支 tip。"""
remote_ref = f'refs/remotes/{self.env_config.git_remote}/{primary_branch}'
local_ref = f'refs/heads/{primary_branch}'
for ref_name in (remote_ref, local_ref):
if ref_name not in repo.references:
continue
primary_branch_oid = repo.references[ref_name].target
if primary_branch_oid is not None:
return primary_branch_oid
return None
def _import_fetch_result(
self,
temp_repo_dir: str,
progress_callback: Callable[[float, str], None] | None,
stage_start: float,
stage_end: float,
tag_name: str | None = None,
import_primary_branch: bool = False,
original_shallow_snapshot: bytes | None = None,
shallow_authoritative: bool = True,
) -> None:
"""将临时仓库导入正式仓库,并同步整仓 shallow 状态。"""
repo = self._open_repo()
active_repo = repo
branch_name = self.env_config.git_branch
remote_name = f'one-dragon-fetch-{uuid.uuid4().hex}'
# 直接使用本地原生路径作为 remote,不能转成 file:// URI:
# UNC 路径(\\server\share\...)转出的 file://server/share/... 带远程主机部分,
# libgit2 的本地 transport 只接受 file:/// 绝对路径或 file://localhost/,
# 会把整个 URI 当成文件系统路径导致 "failed to resolve path"。
remote_path = str(Path(temp_repo_dir).resolve())
remote_branch_ref = f'refs/remotes/{self.env_config.git_remote}/{branch_name}'
if tag_name is None:
source_ref = f'refs/heads/{branch_name}'
target_ref = remote_branch_ref
else:
source_ref = f'refs/tags/{tag_name}'
target_ref = source_ref
refspecs = [f'+{source_ref}:{target_ref}']
affected_refs = {target_ref}
if tag_name is not None:
affected_refs.add(remote_branch_ref)
if import_primary_branch and tag_name is None:
primary_branch = self.repo_config.primary_branch
if branch_name != primary_branch:
primary_source_ref = f'refs/heads/{primary_branch}'
primary_target_ref = (
f'refs/remotes/{self.env_config.git_remote}/{primary_branch}'
)
refspecs.insert(0, f'+{primary_source_ref}:{primary_target_ref}')
affected_refs.add(primary_target_ref)
original_ref_targets: dict[str, Oid | None] = {}
for ref_name in affected_refs:
original_ref_targets[ref_name] = (
repo.references[ref_name].target
if ref_name in repo.references
else None
)
shallow_path = _get_repository_shallow_path(repo)
def report_progress(progress: float, message: str) -> None:
if progress_callback is not None:
progress_callback(
stage_start + (stage_end - stage_start) * progress,
message,
)
callbacks = _FetchProgressRemoteCallbacks(report_progress, timeout=None)
hidden_refs: list[tuple[str, Oid]] = []
try:
log.info(f'开始导入临时 Git 仓库: {remote_path}')
hidden_refs = self._hide_non_commit_refs(active_repo)
active_repo.remotes.create(remote_name, remote_path)
active_repo.config[f'remote.{remote_name}.tagopt'] = '--no-tags'
remote = active_repo.remotes[remote_name]
remote.fetch(refspecs=refspecs, depth=0, callbacks=callbacks)
callbacks.flush_sideband_progress()
_sync_shallow_file(
active_repo,
temp_repo_dir,
original_shallow_snapshot,
shallow_authoritative,
)
active_repo.remotes.delete(remote_name)
self._repo = None
active_repo.free()
active_repo = self._open_repo()
if tag_name is not None:
tag_object = active_repo.revparse_single(target_ref)
tag_commit = tag_object.peel(Commit)
active_repo.references.create(remote_branch_ref, tag_commit.id, force=True)
log.info(f'临时 Git 仓库导入完成: branch={branch_name}')
except BaseException:
with contextlib.suppress(Exception):
if remote_name in active_repo.remotes.names():
active_repo.remotes.delete(remote_name)
with contextlib.suppress(Exception):
active_repo.free()
self._repo = None
try:
_write_shallow_snapshot(shallow_path, original_shallow_snapshot)
rollback_repo = self._open_repo()
if remote_name in rollback_repo.remotes.names():
rollback_repo.remotes.delete(remote_name)
for ref_name, original_target in original_ref_targets.items():
if original_target is None:
if ref_name in rollback_repo.references:
rollback_repo.references.delete(ref_name)
else:
rollback_repo.references.create(
ref_name,
original_target,
force=True,
)
except Exception:
log.error('恢复 Git 导入前状态失败', exc_info=True)
raise
finally:
if hidden_refs:
try:
restore_repo = self._open_repo()
self._restore_hidden_refs(restore_repo, hidden_refs)
restore_repo.free()
except Exception:
log.error(
'恢复导入前隐藏的非 commit 引用失败,对象仍保留在对象库,'
'可用 git fsck --lost-found 找回',
exc_info=True,
)
def _fetch_remote_once(
self,
remote_url: str,
progress_callback: Callable[[float, str], None] | None,
stage_start: float,
stage_end: float,
tag_name: str | None = None,
) -> None:
"""在线程中拉取单个代码源,超时后作废本次尝试。"""
repo = self._open_repo()
branch_name = self.env_config.git_branch
primary_branch = self.repo_config.primary_branch
local_ref = f'refs/heads/{branch_name}'
try:
shallow_snapshot = _read_shallow_snapshot(_get_repository_shallow_path(repo))
_parse_shallow_snapshot(shallow_snapshot)
except ValueError as error:
raise _LocalGitMetadataError('shallow metadata corrupted') from error
base_ref: str | None = None
base_oid: Oid | None = None
fetch_primary_branch = False
local_oid = (
repo.references[local_ref].target
if local_ref in repo.references
else None
)
if tag_name is not None:
depth = 1
elif local_oid is not None:
self._validate_history(repo, local_oid)
depth = 0
base_ref = local_ref
base_oid = local_oid
elif branch_name != primary_branch:
base_oid = self._get_primary_branch_oid(repo, primary_branch)
if base_oid is None:
depth = 1
else:
try:
self._validate_history(repo, base_oid)
except _LocalGitMetadataError:
repo, shallow_snapshot = self._repair_primary_branch_shallow(
repo,
base_oid,