git-clone-resume 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1421 @@
1
+ #Requires -Version 5.1
2
+ <#
3
+ .SYNOPSIS
4
+ Git 仓库 Windows 断点续传克隆(partial clone + 按批 checkout)。
5
+
6
+ .DESCRIPTION
7
+ 针对 GitHub 等不稳定网络:先只拉 commit/tree 元数据(--filter=blob:none),
8
+ 再分批把工作区文件 checkout 下来。中断后用同一命令重跑即可续传。
9
+
10
+ 进度保存在仓库 .git/partial-resume/ 下,不污染工作区。
11
+ 已成功落盘且大小(可选哈希)匹配的文件会自动跳过。
12
+
13
+ .PARAMETER RepoUrl
14
+ 仓库地址。支持 https / ssh / git@ 以及本地路径。
15
+
16
+ .PARAMETER OutDir
17
+ 本地目录。默认取 URL 最后一段(去掉 .git)。
18
+
19
+ .PARAMETER Ref
20
+ 分支、标签或 commit。默认远程 HEAD。
21
+
22
+ .PARAMETER BatchSize
23
+ 每批最多 checkout 的文件数。越大越快,中断粒度越粗。默认 32。
24
+
25
+ .PARAMETER MaxArgChars
26
+ 单次 git 命令行参数最大字符数,避免超过 Windows 限制。默认 6000。
27
+
28
+ .PARAMETER MaxRetries
29
+ 单个批次/文件失败后的最大重试次数。默认 8。
30
+
31
+ .PARAMETER RetryDelaySeconds
32
+ 首次重试等待秒数,之后指数退避(封顶 60 秒)。默认 2。
33
+
34
+ .PARAMETER Include
35
+ 只下载匹配这些通配符的路径(相对仓库根,支持 * 和 ?)。可重复。
36
+
37
+ .PARAMETER Exclude
38
+ 排除匹配这些通配符的路径。可重复。
39
+
40
+ .PARAMETER Depth
41
+ 可选浅克隆深度。不指定则拉完整 commit 历史(仍不拉 blob)。
42
+
43
+ .PARAMETER Verify
44
+ 续传时对已存在文件做 hash-object 校验,哈希不一致则重下。
45
+
46
+ .PARAMETER ForceRefetch
47
+ 强制重新 fetch 目标 ref(默认仅在本地还没有该 commit 时 fetch)。
48
+
49
+ .PARAMETER DryRun
50
+ 只列出将要处理的文件,不 checkout。
51
+
52
+ .PARAMETER Tui
53
+ 强制进入全屏 TUI(交互向导 + 进度面板)。
54
+
55
+ .PARAMETER NoTui
56
+ 禁用 TUI,使用原来的纯日志输出(脚本/CI 推荐)。
57
+
58
+ .PARAMETER ResumeLast
59
+ 从本机历史记录里恢复最近一次未完成(或最近一次)克隆。
60
+
61
+ .EXAMPLE
62
+ .\git-clone-resume.ps1 https://github.com/chaihahaha/git-cheatsheet.git
63
+
64
+ .EXAMPLE
65
+ .\git-clone-resume.ps1 https://github.com/user/repo.git -Ref main -OutDir D:\src\repo -BatchSize 64
66
+
67
+ .EXAMPLE
68
+ .\git-clone-resume.ps1 https://github.com/user/repo.git -Include src/* -Exclude *.bin
69
+ #>
70
+ [CmdletBinding()]
71
+ param(
72
+ [Parameter(Position = 0)]
73
+ [string]$RepoUrl,
74
+
75
+ [string]$OutDir,
76
+
77
+ [string]$Ref = "HEAD",
78
+
79
+ [ValidateRange(1, 5000)]
80
+ [int]$BatchSize = 32,
81
+
82
+ [ValidateRange(512, 30000)]
83
+ [int]$MaxArgChars = 6000,
84
+
85
+ [ValidateRange(1, 100)]
86
+ [int]$MaxRetries = 8,
87
+
88
+ [ValidateRange(0, 600)]
89
+ [int]$RetryDelaySeconds = 2,
90
+
91
+ [string[]]$Include,
92
+
93
+ [string[]]$Exclude,
94
+
95
+ [ValidateRange(1, 1000000)]
96
+ [int]$Depth,
97
+
98
+ [switch]$Verify,
99
+
100
+ [switch]$ForceRefetch,
101
+
102
+ [switch]$DryRun,
103
+
104
+ [switch]$Tui,
105
+
106
+ [switch]$NoTui,
107
+
108
+ [switch]$ResumeLast,
109
+
110
+ [switch]$Help
111
+ )
112
+
113
+ Set-StrictMode -Version Latest
114
+ $ErrorActionPreference = "Stop"
115
+
116
+ try { $null = cmd /c "chcp 65001 >NUL" } catch { }
117
+ try {
118
+ [Console]::InputEncoding = [System.Text.Encoding]::UTF8
119
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
120
+ } catch { }
121
+ $OutputEncoding = [System.Text.Encoding]::UTF8
122
+ if (-not $env:LC_ALL) { $env:LC_ALL = "C.UTF-8" }
123
+ if (-not $env:LANG) { $env:LANG = "C.UTF-8" }
124
+ if (-not $env:GIT_HTTP_LOW_SPEED_LIMIT) { $env:GIT_HTTP_LOW_SPEED_LIMIT = "1024" }
125
+ if (-not $env:GIT_HTTP_LOW_SPEED_TIME) { $env:GIT_HTTP_LOW_SPEED_TIME = "60" }
126
+ $env:GIT_FLUSH = "1"
127
+
128
+ $script:Utf8NoBom = New-Object System.Text.UTF8Encoding $false
129
+ $script:RepoRoot = $null
130
+ $script:LogFile = $null
131
+ $script:GitExe = $null
132
+ $script:StateDirName = "partial-resume"
133
+ $script:GcrTuiWanted = $false
134
+ $script:GcrExitCode = 0
135
+ $script:GcrUserStop = $false
136
+
137
+ $script:GcrTuiFile = Join-Path $PSScriptRoot "git-clone-resume.tui.ps1"
138
+ if (-not $PSScriptRoot) {
139
+ $script:GcrTuiFile = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "git-clone-resume.tui.ps1"
140
+ }
141
+ if (Test-Path -LiteralPath $script:GcrTuiFile) {
142
+ . $script:GcrTuiFile
143
+ } else {
144
+ function Test-GcrTuiActive { return $false }
145
+ function Test-GcrTuiAvailable { return $false }
146
+ function Test-GcrTuiQuit { return $false }
147
+ function Test-GcrTuiForceQuit { return $false }
148
+ function Invoke-GcrTuiTick { }
149
+ function Write-GcrNewline { Write-Host "" }
150
+ function Initialize-GcrTui { return $false }
151
+ function Close-GcrTui { }
152
+ function Set-GcrTuiPhase { }
153
+ function Set-GcrTuiRepo { }
154
+ function Update-GcrTuiProgress { }
155
+ function Add-GcrTuiLog { }
156
+ function Add-GcrTuiFailure { }
157
+ function Add-GcrGitOutput { }
158
+ function Receive-GcrGitBytes { }
159
+ function Wait-GcrTuiPaused { }
160
+ function Show-GcrTuiResult { }
161
+ function Save-GcrHistory { }
162
+ }
163
+
164
+ function Write-Log {
165
+ param(
166
+ $Message,
167
+ [string]$Level = "INFO"
168
+ )
169
+ if (@("INFO", "WARN", "ERROR", "OK", "STEP") -notcontains $Level) { $Level = "INFO" }
170
+ $text = ""
171
+ try {
172
+ if ($null -eq $Message) { $text = "" }
173
+ elseif ($Message -is [System.Array]) { $text = (@($Message | ForEach-Object { "$_" }) -join " ") }
174
+ else { $text = [string]$Message }
175
+ } catch { $text = "$Message" }
176
+ $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
177
+ $color = switch ($Level) {
178
+ "INFO" { "Gray" }
179
+ "WARN" { "Yellow" }
180
+ "ERROR" { "Red" }
181
+ "OK" { "Green" }
182
+ "STEP" { "Cyan" }
183
+ default { "Gray" }
184
+ }
185
+ $line = "[$ts][$Level] $text"
186
+ try {
187
+ if (Test-GcrTuiActive) {
188
+ Add-GcrTuiLog -Level $Level -Message $text
189
+ Invoke-GcrTuiTick
190
+ } else {
191
+ Write-Host $line -ForegroundColor $color
192
+ }
193
+ } catch { }
194
+ if ($script:LogFile) {
195
+ try {
196
+ [System.IO.File]::AppendAllText($script:LogFile, $line + [Environment]::NewLine, $script:Utf8NoBom)
197
+ } catch { }
198
+ }
199
+ }
200
+
201
+ function Show-Usage {
202
+ Write-Host "Git resume clone for Windows / PowerShell 5.1+"
203
+ Write-Host ""
204
+ Write-Host "Usage:"
205
+ Write-Host " .\git-clone-resume.ps1 <repo-url> [options]"
206
+ Write-Host " git-clone-resume.cmd <repo-url> [options]"
207
+ Write-Host ""
208
+ Write-Host "Options:"
209
+ Write-Host " -OutDir <dir> Local directory (default: from URL)"
210
+ Write-Host " -Ref <branch/tag/sha> Default: remote HEAD"
211
+ Write-Host " -BatchSize <N> Files per batch, default 32"
212
+ Write-Host " -MaxRetries <N> Retries per failed batch/file, default 8"
213
+ Write-Host " -Include a,b Only download matching paths"
214
+ Write-Host " -Exclude a,b Skip matching paths"
215
+ Write-Host " -Depth <N> Optional shallow clone depth"
216
+ Write-Host " -Verify Hash-check local files on resume"
217
+ Write-Host " -ForceRefetch Always fetch the target ref"
218
+ Write-Host " -DryRun List files, do not checkout blobs"
219
+ Write-Host " -Tui Force fullscreen TUI"
220
+ Write-Host " -NoTui Disable TUI (script/CI mode)"
221
+ Write-Host " -ResumeLast Resume the latest history entry"
222
+ Write-Host " -Help Show this help"
223
+ Write-Host ""
224
+ Write-Host "Interactive: run with no URL to open the TUI wizard."
225
+ Write-Host "Resume: run the same command again. State is in .git/partial-resume/"
226
+ Write-Host "Keys: Q stop P pause F failures ? help Ctrl+C twice to kill git"
227
+ }
228
+
229
+ if ($Help) {
230
+ Show-Usage
231
+ exit 0
232
+ }
233
+
234
+ function Get-RepoFolderName {
235
+ param([string]$Url)
236
+ $s = $Url.Trim().TrimEnd([char]47, [char]92)
237
+ if ($s.Length -ge 4 -and $s.EndsWith(".git", [System.StringComparison]::OrdinalIgnoreCase)) {
238
+ $s = $s.Substring(0, $s.Length - 4)
239
+ }
240
+ $s = $s.Replace([char]92, [char]47)
241
+ $i = $s.LastIndexOf([char]47)
242
+ if ($i -ge 0) { $s = $s.Substring($i + 1) }
243
+ $colon = $s.LastIndexOf([char]58)
244
+ if ($colon -ge 0) { $s = $s.Substring($colon + 1) }
245
+ if ([string]::IsNullOrWhiteSpace($s)) { return "repo" }
246
+ return $s
247
+ }
248
+
249
+ function Convert-ToFullPath {
250
+ param([string]$Path)
251
+ if ([System.IO.Path]::IsPathRooted($Path)) {
252
+ return [System.IO.Path]::GetFullPath($Path)
253
+ }
254
+ return [System.IO.Path]::GetFullPath((Join-Path (Get-Location).Path $Path))
255
+ }
256
+
257
+ function Get-WorktreePath {
258
+ param([string]$Root, [string]$Rel)
259
+ $acc = $Root
260
+ foreach ($p in ($Rel -split "[\\/]")) {
261
+ if ([string]::IsNullOrEmpty($p) -or $p -eq ".") { continue }
262
+ $acc = Join-Path $acc $p
263
+ }
264
+ return $acc
265
+ }
266
+
267
+ function Test-GitAvailable {
268
+ try {
269
+ $null = Get-Command git -ErrorAction Stop
270
+ } catch {
271
+ throw "未找到 git。请先安装 Git for Windows: https://git-scm.com/download/win"
272
+ }
273
+ $verText = (& git --version 2>$null | Out-String).Trim()
274
+ Write-Log "使用 $verText" "INFO"
275
+ if ($verText -match "git version (\d+)\.(\d+)") {
276
+ $major = [int]$Matches[1]
277
+ $minor = [int]$Matches[2]
278
+ if ($major -lt 2 -or ($major -eq 2 -and $minor -lt 19)) {
279
+ Write-Log "partial clone 需要 Git >= 2.19,当前: $verText 。将继续尝试,失败请升级 Git。" "WARN"
280
+ }
281
+ }
282
+ }
283
+
284
+ function Get-GitExePath {
285
+ $cmd = Get-Command git -ErrorAction Stop
286
+ if ($cmd.Source) { return $cmd.Source }
287
+ if ($cmd.Path) { return $cmd.Path }
288
+ return "git"
289
+ }
290
+
291
+ function Convert-ToGitArgumentString {
292
+ param([string[]]$GitArgs)
293
+ $quoted = New-Object System.Text.StringBuilder
294
+ foreach ($a in $GitArgs) {
295
+ if ($null -eq $a) { continue }
296
+ if ($quoted.Length -gt 0) { [void]$quoted.Append(" ") }
297
+ $needsQuote = ($a -match "\s") -or ($a -match '"') -or ($a.Length -eq 0)
298
+ if ($needsQuote) {
299
+ $escaped = $a.Replace([string][char]34, [string][char]92 + [string][char]34)
300
+ [void]$quoted.Append('"').Append($escaped).Append('"')
301
+ } else {
302
+ [void]$quoted.Append($a)
303
+ }
304
+ }
305
+ return $quoted.ToString()
306
+ }
307
+
308
+ function Invoke-GitProcess {
309
+ param(
310
+ [Parameter(Mandatory = $true)][string[]]$GitArgs,
311
+ [string]$WorkDir,
312
+ [int]$TimeoutMs = 0,
313
+ [switch]$ExpectFail,
314
+ [switch]$InheritConsole,
315
+ [string]$Heartbeat
316
+ )
317
+ if (-not $script:GitExe) { $script:GitExe = Get-GitExePath }
318
+
319
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
320
+ $psi.FileName = $script:GitExe
321
+ $psi.UseShellExecute = $false
322
+ $psi.CreateNoWindow = -not $InheritConsole
323
+ $psi.RedirectStandardInput = $true
324
+ if ($InheritConsole) {
325
+ $psi.RedirectStandardOutput = $false
326
+ $psi.RedirectStandardError = $false
327
+ } else {
328
+ $psi.RedirectStandardOutput = $true
329
+ $psi.RedirectStandardError = $true
330
+ $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
331
+ $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
332
+ }
333
+ if ($WorkDir) { $psi.WorkingDirectory = $WorkDir }
334
+ $psi.Arguments = Convert-ToGitArgumentString -GitArgs $GitArgs
335
+
336
+ $proc = New-Object System.Diagnostics.Process
337
+ $proc.StartInfo = $psi
338
+ $stdout = ""
339
+ $stderr = ""
340
+ $script:GcrCurrentProc = $proc
341
+ try {
342
+ [void]$proc.Start()
343
+ $proc.StandardInput.Close()
344
+ $waitSlice = 80
345
+ if (-not (Test-GcrTuiActive)) { $waitSlice = 500 }
346
+ $waited = 0
347
+ $hbSec = 2
348
+ $useStream = (Test-GcrTuiActive) -and (-not $InheritConsole)
349
+ $stdoutTask = $null
350
+ $stderrTask = $null
351
+ $outBuf = $null
352
+ $errBuf = $null
353
+ $outCarry = $null
354
+ $errCarry = $null
355
+ $outRead = $null
356
+ $errRead = $null
357
+ $stdoutSb = New-Object System.Text.StringBuilder
358
+ $stderrSb = New-Object System.Text.StringBuilder
359
+ if ($useStream) {
360
+ $outBuf = New-Object byte[] 4096
361
+ $errBuf = New-Object byte[] 4096
362
+ $outCarry = New-Object System.Text.StringBuilder
363
+ $errCarry = New-Object System.Text.StringBuilder
364
+ $outRead = $proc.StandardOutput.BaseStream.ReadAsync($outBuf, 0, $outBuf.Length)
365
+ $errRead = $proc.StandardError.BaseStream.ReadAsync($errBuf, 0, $errBuf.Length)
366
+ } elseif (-not $InheritConsole) {
367
+ $stdoutTask = $proc.StandardOutput.ReadToEndAsync()
368
+ $stderrTask = $proc.StandardError.ReadToEndAsync()
369
+ }
370
+ while (-not $proc.HasExited) {
371
+ if ($useStream) {
372
+ if ($null -ne $outRead -and $outRead.IsCompleted) {
373
+ $n = 0
374
+ try { $n = [int]$outRead.Result } catch { $n = 0 }
375
+ if ($n -gt 0) {
376
+ $chunk = [System.Text.Encoding]::UTF8.GetString($outBuf, 0, $n)
377
+ [void]$stdoutSb.Append($chunk)
378
+ $outRead = $proc.StandardOutput.BaseStream.ReadAsync($outBuf, 0, $outBuf.Length)
379
+ } else { $outRead = $null }
380
+ }
381
+ if ($null -ne $errRead -and $errRead.IsCompleted) {
382
+ $n = 0
383
+ try { $n = [int]$errRead.Result } catch { $n = 0 }
384
+ if ($n -gt 0) {
385
+ [void]$stderrSb.Append([System.Text.Encoding]::UTF8.GetString($errBuf, 0, $n))
386
+ Receive-GcrGitBytes -Buffer $errBuf -Count $n -Carry $errCarry -IsStdErr
387
+ $errRead = $proc.StandardError.BaseStream.ReadAsync($errBuf, 0, $errBuf.Length)
388
+ } else { $errRead = $null }
389
+ }
390
+ }
391
+ if (-not $proc.WaitForExit($waitSlice)) {
392
+ $waited += $waitSlice
393
+ if ($TimeoutMs -gt 0 -and $waited -ge $TimeoutMs) {
394
+ try { $proc.Kill() } catch { }
395
+ throw "git 命令超时 (" + $TimeoutMs + "ms): git " + $psi.Arguments
396
+ }
397
+ if ($Heartbeat -and ($waited % ($hbSec * 1000) -lt $waitSlice)) {
398
+ $sec = [int]($waited / 1000)
399
+ if (Test-GcrTuiActive) {
400
+ Set-GcrTuiPhase -Detail ($Heartbeat + " ... " + $sec + "s")
401
+ } else {
402
+ Write-Host ("`r[WAIT] " + $Heartbeat + " ... " + $sec + "s ") -NoNewline
403
+ }
404
+ }
405
+ }
406
+ if (Test-GcrTuiActive) {
407
+ Invoke-GcrTuiTick
408
+ if (Test-GcrTuiForceQuit) {
409
+ try { $proc.Kill() } catch { }
410
+ $script:GcrUserStop = $true
411
+ throw "已由用户停止。"
412
+ }
413
+ }
414
+ }
415
+ if ($Heartbeat -and -not (Test-GcrTuiActive)) { Write-Host "" }
416
+ if ($useStream) {
417
+ if ($null -ne $outRead) {
418
+ try {
419
+ $n = [int]$outRead.Result
420
+ if ($n -gt 0) { [void]$stdoutSb.Append([System.Text.Encoding]::UTF8.GetString($outBuf, 0, $n)) }
421
+ } catch { }
422
+ }
423
+ if ($null -ne $errRead) {
424
+ try {
425
+ $n = [int]$errRead.Result
426
+ if ($n -gt 0) {
427
+ [void]$stderrSb.Append([System.Text.Encoding]::UTF8.GetString($errBuf, 0, $n))
428
+ Receive-GcrGitBytes -Buffer $errBuf -Count $n -Carry $errCarry -IsStdErr
429
+ }
430
+ } catch { }
431
+ }
432
+ if ($errCarry -and $errCarry.Length -gt 0) { Add-GcrGitOutput -Text $errCarry.ToString() }
433
+ $stdout = $stdoutSb.ToString()
434
+ $stderr = $stderrSb.ToString()
435
+ } elseif (-not $InheritConsole) {
436
+ [void]$stdoutTask.Wait()
437
+ [void]$stderrTask.Wait()
438
+ $stdout = $stdoutTask.Result
439
+ $stderr = $stderrTask.Result
440
+ }
441
+ if ($script:GcrUserStop) { throw "已由用户停止。" }
442
+ $code = $proc.ExitCode
443
+ } finally {
444
+ $script:GcrCurrentProc = $null
445
+ $proc.Dispose()
446
+ }
447
+
448
+ if ($null -eq $stdout) { $stdout = "" }
449
+ if ($null -eq $stderr) { $stderr = "" }
450
+
451
+ if (-not $ExpectFail -and $code -ne 0) {
452
+ $err = $stderr
453
+ if ([string]::IsNullOrWhiteSpace($err)) { $err = $stdout }
454
+ $msg = "git 失败 (exit $code): git " + $psi.Arguments
455
+ if (-not [string]::IsNullOrWhiteSpace($err)) { $msg = $msg + [Environment]::NewLine + $err.Trim() }
456
+ throw $msg
457
+ }
458
+ return [pscustomobject]@{
459
+ ExitCode = $code
460
+ StdOut = $stdout
461
+ StdErr = $stderr
462
+ Args = $GitArgs
463
+ }
464
+ }
465
+
466
+ function Invoke-Git {
467
+ param(
468
+ [Parameter(Mandatory = $true)][string[]]$GitArgs,
469
+ [string]$WorkDir
470
+ )
471
+ $r = Invoke-GitProcess -GitArgs $GitArgs -WorkDir $WorkDir
472
+ return $r.StdOut
473
+ }
474
+
475
+ function Clear-StaleIndexLock {
476
+ param([string]$RepoRoot)
477
+ if (-not $RepoRoot) { return }
478
+ $lock = Join-Path $RepoRoot ".git\index.lock"
479
+ if (Test-Path -LiteralPath $lock) {
480
+ $age = (Get-Date) - (Get-Item -LiteralPath $lock).LastWriteTime
481
+ if ($age.TotalMinutes -ge 2) {
482
+ Write-Log ("删除过期 index.lock (" + [int]$age.TotalMinutes + " 分钟)") "WARN"
483
+ Remove-Item -LiteralPath $lock -Force -ErrorAction SilentlyContinue
484
+ } else {
485
+ Write-Log ("检测到 index.lock (" + [int]$age.TotalSeconds + "s)。若确认没有其它 git 进程,请手动删除: " + $lock) "WARN"
486
+ }
487
+ }
488
+ }
489
+
490
+ function Invoke-GitRetry {
491
+ param(
492
+ [Parameter(Mandatory = $true)][string[]]$GitArgs,
493
+ [string]$WorkDir,
494
+ [string]$What,
495
+ [int]$TimeoutMs = 0,
496
+ [switch]$InheritConsole,
497
+ [string]$Heartbeat,
498
+ [int]$Retries = -1
499
+ )
500
+ $attempt = 0
501
+ $delay = [Math]::Max(0, $RetryDelaySeconds)
502
+ $lastError = $null
503
+ $limit = $MaxRetries
504
+ if ($Retries -ge 0) { $limit = $Retries }
505
+ $limit = [Math]::Max(1, $limit)
506
+ while ($attempt -lt $limit) {
507
+ $attempt++
508
+ try {
509
+ $r = Invoke-GitProcess -GitArgs $GitArgs -WorkDir $WorkDir -TimeoutMs $TimeoutMs -ExpectFail -InheritConsole:$InheritConsole -Heartbeat $Heartbeat
510
+ if ($script:GcrUserStop) { throw "已由用户停止。" }
511
+ if ($r.ExitCode -eq 0) { return $r }
512
+ $lastError = "exit " + $r.ExitCode
513
+ $tail = $r.StdErr
514
+ if ([string]::IsNullOrWhiteSpace($tail)) { $tail = $r.StdOut }
515
+ if (-not [string]::IsNullOrWhiteSpace($tail)) { $lastError = $lastError + " : " + $tail.Trim() }
516
+ } catch {
517
+ if ($script:GcrUserStop) { throw }
518
+ $lastError = $_.Exception.Message
519
+ }
520
+ if ($attempt -ge $limit) { break }
521
+ Write-Log ($What + " 失败 (第 " + $attempt + "/" + $limit + " 次): " + $lastError + " ;" + $delay + "s 后重试") "WARN"
522
+ Start-Sleep -Seconds $delay
523
+ $delay = [Math]::Min(60, [Math]::Max(1, $delay * 2))
524
+ Clear-StaleIndexLock -RepoRoot $WorkDir
525
+ }
526
+ throw ($What + " 在 " + $limit + " 次重试后仍失败: " + $lastError)
527
+ }
528
+
529
+ function Save-TextFile {
530
+ param([string]$Path, [string]$Content)
531
+ $dir = Split-Path -Parent $Path
532
+ if ($dir -and -not (Test-Path -LiteralPath $dir)) {
533
+ New-Item -ItemType Directory -Path $dir -Force | Out-Null
534
+ }
535
+ [System.IO.File]::WriteAllText($Path, $Content, $script:Utf8NoBom)
536
+ }
537
+
538
+ function Read-Meta {
539
+ param([string]$MetaPath)
540
+ $map = @{}
541
+ if (-not (Test-Path -LiteralPath $MetaPath)) { return $map }
542
+ foreach ($line in [System.IO.File]::ReadAllLines($MetaPath, $script:Utf8NoBom)) {
543
+ $eq = $line.IndexOf("=")
544
+ if ($eq -lt 1) { continue }
545
+ if ($line.StartsWith("#")) { continue }
546
+ $k = $line.Substring(0, $eq).Trim()
547
+ $v = $line.Substring($eq + 1)
548
+ $map[$k] = $v
549
+ }
550
+ return $map
551
+ }
552
+
553
+ function Write-Meta {
554
+ param([string]$MetaPath, [hashtable]$Map)
555
+ $sb = New-Object System.Text.StringBuilder
556
+ [void]$sb.AppendLine("# git-clone-resume state")
557
+ foreach ($k in ($Map.Keys | Sort-Object)) {
558
+ [void]$sb.AppendLine($k + "=" + $Map[$k])
559
+ }
560
+ Save-TextFile -Path $MetaPath -Content $sb.ToString()
561
+ }
562
+
563
+ function Test-WildcardMatch {
564
+ param([string]$Path, [string[]]$Patterns)
565
+ if (-not $Patterns -or @($Patterns).Count -eq 0) { return $false }
566
+ $norm = $Path.Replace("\", "/")
567
+ foreach ($p in @($Patterns)) {
568
+ if ([string]::IsNullOrWhiteSpace($p)) { continue }
569
+ $pat = $p.Trim().Replace("\", "/")
570
+ if ($norm -like $pat) { return $true }
571
+ $prefix = $pat.TrimEnd("/")
572
+ if ($prefix -and $norm -like ($prefix + "/*")) { return $true }
573
+ }
574
+ return $false
575
+ }
576
+
577
+ function Initialize-PartialRepo {
578
+ param([string]$RepoRoot, [string]$Url)
579
+ $gitDir = Join-Path $RepoRoot ".git"
580
+ if (-not (Test-Path -LiteralPath $gitDir)) {
581
+ if (-not (Test-Path -LiteralPath $RepoRoot)) {
582
+ New-Item -ItemType Directory -Path $RepoRoot -Force | Out-Null
583
+ }
584
+ Write-Log "git init $RepoRoot" "STEP"
585
+ Invoke-GitRetry -What "git init" -WorkDir $RepoRoot -GitArgs @("init") | Out-Null
586
+ }
587
+
588
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "core.longpaths", "true") | Out-Null
589
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "core.quotepath", "false") | Out-Null
590
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "core.precomposeunicode", "true") | Out-Null
591
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "i18n.logOutputEncoding", "utf-8") | Out-Null
592
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "gc.auto", "0") | Out-Null
593
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "http.version", "HTTP/1.1") | Out-Null
594
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "http.postBuffer", "524288000") | Out-Null
595
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "http.lowSpeedLimit", "1024") | Out-Null
596
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "http.lowSpeedTime", "60") | Out-Null
597
+
598
+ $existingRemote = ""
599
+ $remoteProbe = Invoke-GitProcess -WorkDir $RepoRoot -ExpectFail -GitArgs @("remote", "get-url", "origin")
600
+ if ($remoteProbe.ExitCode -eq 0) { $existingRemote = $remoteProbe.StdOut.Trim() }
601
+
602
+ if ([string]::IsNullOrWhiteSpace($existingRemote)) {
603
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("remote", "add", "origin", $Url) | Out-Null
604
+ } else {
605
+ $a = $existingRemote.Trim().TrimEnd("/")
606
+ $b = $Url.Trim().TrimEnd("/")
607
+ if ($a -ne $b -and ($a + ".git") -ne $b -and $a -ne ($b + ".git")) {
608
+ Write-Log "已有 origin=$existingRemote ,与本次 URL 不同,改为 $Url" "WARN"
609
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("remote", "set-url", "origin", $Url) | Out-Null
610
+ }
611
+ }
612
+
613
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "remote.origin.promisor", "true") | Out-Null
614
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "remote.origin.partialclonefilter", "blob:none") | Out-Null
615
+ }
616
+
617
+ function Set-DetachedHead {
618
+ param([string]$RepoRoot, [string]$Sha)
619
+ $headFile = Join-Path $RepoRoot ".git\HEAD"
620
+ Save-TextFile -Path $headFile -Content ($Sha + [Environment]::NewLine)
621
+ }
622
+
623
+ function Get-TreeEntries {
624
+ param([string]$RepoRoot, [string]$Sha)
625
+ # Write git stdout to a file as raw bytes. Capturing via StreamReader in PS 5.1
626
+ # can collapse the whole tree into one fake path.
627
+ # Do NOT use -l (blob:none would fetch every blob for sizes) or -z (NUL truncation).
628
+ $gitDir = Join-Path $RepoRoot ".git"
629
+ $stateDir = Join-Path $gitDir $script:StateDirName
630
+ if (-not (Test-Path -LiteralPath $stateDir)) {
631
+ New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
632
+ }
633
+ $outFile = Join-Path $stateDir "ls-tree.raw"
634
+ $errFile = Join-Path $stateDir "ls-tree.err"
635
+ if (Test-Path -LiteralPath $outFile) { Remove-Item -LiteralPath $outFile -Force }
636
+ if (Test-Path -LiteralPath $errFile) { Remove-Item -LiteralPath $errFile -Force }
637
+ if (-not $script:GitExe) { $script:GitExe = Get-GitExePath }
638
+
639
+ $arg = "-c core.quotepath=false ls-tree -r " + $Sha
640
+ $p = Start-Process -FilePath $script:GitExe -WorkingDirectory $RepoRoot `
641
+ -ArgumentList $arg `
642
+ -RedirectStandardOutput $outFile -RedirectStandardError $errFile `
643
+ -Wait -NoNewWindow -PassThru
644
+ if ($p.ExitCode -ne 0) {
645
+ $err = ""
646
+ if (Test-Path -LiteralPath $errFile) {
647
+ $err = [System.IO.File]::ReadAllText($errFile, $script:Utf8NoBom)
648
+ }
649
+ throw ("ls-tree failed (exit " + $p.ExitCode + "): " + $err.Trim())
650
+ }
651
+
652
+ $entries = New-Object System.Collections.Generic.List[object]
653
+ if (-not (Test-Path -LiteralPath $outFile)) { return ,$entries }
654
+ $bytes = [System.IO.File]::ReadAllBytes($outFile)
655
+ if ($null -eq $bytes -or $bytes.Length -eq 0) { return ,$entries }
656
+ $raw = [System.Text.Encoding]::UTF8.GetString($bytes)
657
+
658
+ foreach ($rec in $raw.Split(@([char]10), [System.StringSplitOptions]::None)) {
659
+ $rec = $rec.TrimEnd([char]13, [char]0)
660
+ if ([string]::IsNullOrWhiteSpace($rec)) { continue }
661
+ $tab = $rec.IndexOf([char]9)
662
+ if ($tab -lt 0) { continue }
663
+ $metaBits = $rec.Substring(0, $tab)
664
+ $path = $rec.Substring($tab + 1).TrimEnd()
665
+ $bits = @($metaBits -split "\s+", 3)
666
+ if ($bits.Count -lt 3) { continue }
667
+ if ([string]::IsNullOrWhiteSpace($path)) { continue }
668
+ [void]$entries.Add([pscustomobject]@{
669
+ Mode = $bits[0]
670
+ Type = $bits[1]
671
+ Blob = $bits[2]
672
+ Size = 0L
673
+ Path = $path
674
+ })
675
+ }
676
+ Write-Log ("ls-tree bytes=" + $bytes.Length + " files=" + $entries.Count) "INFO"
677
+ return ,$entries
678
+ }
679
+
680
+ function Get-LocalBlobHash {
681
+ param([string]$RepoRoot, [string]$RelPath)
682
+ $full = Get-WorktreePath -Root $RepoRoot -Rel $RelPath
683
+ if (-not (Test-Path -LiteralPath $full)) { return $null }
684
+ $r = Invoke-GitProcess -WorkDir $RepoRoot -ExpectFail -GitArgs @("hash-object", "--path", $RelPath, "--", $RelPath)
685
+ if ($r.ExitCode -eq 0) { return $r.StdOut.Trim() }
686
+ return $null
687
+ }
688
+
689
+ function Test-FileComplete {
690
+ param(
691
+ [string]$RepoRoot,
692
+ $Entry,
693
+ [switch]$HashVerify
694
+ )
695
+ $full = Get-WorktreePath -Root $RepoRoot -Rel $Entry.Path
696
+ if (-not (Test-Path -LiteralPath $full)) { return $false }
697
+ $item = Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
698
+ if (-not $item -or $item.PSIsContainer) { return $false }
699
+
700
+ if ($HashVerify) {
701
+ $h = Get-LocalBlobHash -RepoRoot $RepoRoot -RelPath $Entry.Path
702
+ return ($h -eq $Entry.Blob)
703
+ }
704
+ # Do not compare raw byte length to ls-tree size: core.autocrlf on Windows
705
+ # makes working-tree files larger than the blob.
706
+ return $true
707
+ }
708
+
709
+ function Split-Batches {
710
+ param(
711
+ [object[]]$Items,
712
+ [int]$MaxCount,
713
+ [int]$MaxChars
714
+ )
715
+ $batches = New-Object System.Collections.Generic.List[object]
716
+ $cur = New-Object System.Collections.Generic.List[object]
717
+ $chars = 0
718
+ foreach ($it in $Items) {
719
+ $add = $it.Path.Length + 3
720
+ if ($cur.Count -gt 0 -and (($cur.Count -ge $MaxCount) -or ($chars + $add -gt $MaxChars))) {
721
+ [void]$batches.Add((New-BatchCopy -Items $cur))
722
+ $cur = New-Object System.Collections.Generic.List[object]
723
+ $chars = 0
724
+ }
725
+ [void]$cur.Add($it)
726
+ $chars += $add
727
+ }
728
+ if ($cur.Count -gt 0) {
729
+ [void]$batches.Add((New-BatchCopy -Items $cur))
730
+ }
731
+ return ,$batches
732
+ }
733
+
734
+ function New-BatchCopy {
735
+ param($Items)
736
+ $copy = New-Object System.Collections.Generic.List[object]
737
+ foreach ($it in $Items) { [void]$copy.Add($it) }
738
+ return ,$copy
739
+ }
740
+
741
+ function Format-Bytes {
742
+ param([int64]$n)
743
+ if ($n -lt 1024) { return "$n B" }
744
+ if ($n -lt 1MB) { return ("{0:N1} KB" -f ($n / 1KB)) }
745
+ if ($n -lt 1GB) { return ("{0:N1} MB" -f ($n / 1MB)) }
746
+ return ("{0:N2} GB" -f ($n / 1GB))
747
+ }
748
+
749
+ function Add-DonePaths {
750
+ param([string]$DonePath, [string[]]$Paths)
751
+ if (-not $Paths -or @($Paths).Count -eq 0) { return }
752
+ $text = (@($Paths) -join [Environment]::NewLine) + [Environment]::NewLine
753
+ [System.IO.File]::AppendAllText($DonePath, $text, $script:Utf8NoBom)
754
+ }
755
+
756
+ function Add-FailedPath {
757
+ param([string]$FailedPath, [string]$RelPath, [string]$Reason)
758
+ $safe = $Reason -replace "[\r\n]+", " "
759
+ $line = (Get-Date -Format o) + [char]9 + $RelPath + [char]9 + $safe + [Environment]::NewLine
760
+ [System.IO.File]::AppendAllText($FailedPath, $line, $script:Utf8NoBom)
761
+ }
762
+
763
+ function Load-DoneSet {
764
+ param([string]$DonePath)
765
+ $set = New-Object "System.Collections.Generic.HashSet[string]" ([System.StringComparer]::Ordinal)
766
+ if (Test-Path -LiteralPath $DonePath) {
767
+ foreach ($line in [System.IO.File]::ReadAllLines($DonePath, $script:Utf8NoBom)) {
768
+ $p = $line.Trim()
769
+ if ($p) { [void]$set.Add($p) }
770
+ }
771
+ }
772
+ # unary comma: stop PowerShell from enumerating the HashSet (empty => $null)
773
+ return ,$set
774
+ }
775
+
776
+ function Invoke-CheckoutBatch {
777
+ param(
778
+ [string]$RepoRoot,
779
+ [string]$Sha,
780
+ $Batch
781
+ )
782
+ $gitArgsList = New-Object System.Collections.Generic.List[string]
783
+ foreach ($x in @("-c", "core.quotepath=false", "-c", "core.longpaths=true", "-c", "advice.detachedHead=false", "checkout", "--progress", $Sha, "--")) {
784
+ [void]$gitArgsList.Add([string]$x)
785
+ }
786
+ $n = 0
787
+ foreach ($e in $Batch) {
788
+ [void]$gitArgsList.Add([string]$e.Path)
789
+ $n++
790
+ }
791
+ if ($n -le 0) { return }
792
+ Ensure-ParentDirectories -RepoRoot $RepoRoot -Batch $Batch
793
+ $first = [string]$Batch[0].Path
794
+ $hb = "downloading " + $n + " file(s), e.g. " + $first
795
+ # Multi-file checkout: try once. Retrying the same 64-file batch on Windows
796
+ # wastes minutes (sha1 missing + cannot create directory) and can desync the index.
797
+ $tries = 1
798
+ if ($n -eq 1) { $tries = $MaxRetries }
799
+ Invoke-GitRetry -What ("checkout " + $n + " files") -WorkDir $RepoRoot -Heartbeat $hb -Retries $tries -GitArgs $gitArgsList.ToArray() | Out-Null
800
+ }
801
+
802
+ function Ensure-ParentDirectories {
803
+ param([string]$RepoRoot, $Batch)
804
+ $seen = New-Object "System.Collections.Generic.HashSet[string]" ([System.StringComparer]::OrdinalIgnoreCase)
805
+ foreach ($e in $Batch) {
806
+ $rel = [string]$e.Path
807
+ $slash = $rel.LastIndexOf([char]47)
808
+ if ($slash -lt 1) { continue }
809
+ $parentRel = $rel.Substring(0, $slash)
810
+ if (-not $seen.Add($parentRel)) { continue }
811
+ $full = Get-WorktreePath -Root $RepoRoot -Rel $parentRel
812
+ if (Test-Path -LiteralPath $full -PathType Leaf) {
813
+ Write-Log ("parent path is a file, removing so a directory can be created: " + $parentRel) "WARN"
814
+ Remove-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
815
+ }
816
+ if (-not (Test-Path -LiteralPath $full)) {
817
+ New-Item -ItemType Directory -Path $full -Force | Out-Null
818
+ }
819
+ }
820
+ }
821
+
822
+ function Confirm-BatchFiles {
823
+ param(
824
+ [string]$RepoRoot,
825
+ $Batch,
826
+ [switch]$HashVerify
827
+ )
828
+ $ok = New-Object System.Collections.Generic.List[object]
829
+ $bad = New-Object System.Collections.Generic.List[object]
830
+ foreach ($e in $Batch) {
831
+ if (Test-FileComplete -RepoRoot $RepoRoot -Entry $e -HashVerify:$HashVerify) {
832
+ [void]$ok.Add($e)
833
+ } else {
834
+ [void]$bad.Add($e)
835
+ }
836
+ }
837
+ return @{ Ok = $ok; Bad = $bad }
838
+ }
839
+
840
+ function Repair-GitIndex {
841
+ param([string]$RepoRoot, [string]$Sha)
842
+ # Failed multi-path checkout on Windows can drop index entries while leaving
843
+ # the files on disk (status: D + ??). Re-add existing files; re-checkout missing ones.
844
+ $porcelain = Invoke-GitProcess -WorkDir $RepoRoot -ExpectFail -GitArgs @(
845
+ "-c", "core.quotepath=false", "status", "--porcelain", "-uall"
846
+ )
847
+ if ($porcelain.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($porcelain.StdOut)) { return }
848
+ $add = New-Object System.Collections.Generic.List[string]
849
+ $needCheckout = New-Object System.Collections.Generic.List[string]
850
+ foreach ($line in ($porcelain.StdOut -split [char]10)) {
851
+ $line = $line.TrimEnd([char]13)
852
+ if ($line.Length -lt 4) { continue }
853
+ $code = $line.Substring(0, 2)
854
+ $path = $line.Substring(3).Trim()
855
+ if ($path.StartsWith(".git/")) { continue }
856
+ $full = Get-WorktreePath -Root $RepoRoot -Rel $path
857
+ $exists = Test-Path -LiteralPath $full -PathType Leaf
858
+ if ($code -eq "D " -or $code -eq " D" -or $code -eq "??") {
859
+ if ($exists) { [void]$add.Add($path) } else { [void]$needCheckout.Add($path) }
860
+ }
861
+ }
862
+ if ($add.Count -gt 0) {
863
+ Write-Log ("repair index: git add " + $add.Count + " files that exist on disk") "WARN"
864
+ $args = New-Object System.Collections.Generic.List[string]
865
+ foreach ($x in @("-c", "core.quotepath=false", "add", "-f", "--")) { [void]$args.Add($x) }
866
+ foreach ($p in $add) { [void]$args.Add($p) }
867
+ Invoke-GitProcess -WorkDir $RepoRoot -ExpectFail -GitArgs $args.ToArray() | Out-Null
868
+ }
869
+ if ($needCheckout.Count -gt 0) {
870
+ Write-Log ("repair index: re-checkout " + $needCheckout.Count + " missing files") "WARN"
871
+ $args = New-Object System.Collections.Generic.List[string]
872
+ foreach ($x in @("-c", "core.quotepath=false", "checkout", $Sha, "--")) { [void]$args.Add($x) }
873
+ foreach ($p in $needCheckout) { [void]$args.Add($p) }
874
+ Invoke-GitProcess -WorkDir $RepoRoot -ExpectFail -GitArgs $args.ToArray() | Out-Null
875
+ }
876
+ }
877
+
878
+ function Format-Eta {
879
+ param([TimeSpan]$Elapsed, [int]$DoneThisRun, [int]$Remain)
880
+ if ($Elapsed.TotalSeconds -lt 1 -or $DoneThisRun -le 0 -or $Remain -le 0) { return "--:--:--" }
881
+ $rate = $DoneThisRun / $Elapsed.TotalSeconds
882
+ if ($rate -le 0) { return "--:--:--" }
883
+ $sec = [Math]::Min(864000, $Remain / $rate)
884
+ $ts = [TimeSpan]::FromSeconds($sec)
885
+ return ("{0:00}:{1:00}:{2:00}" -f [int]$ts.TotalHours, $ts.Minutes, $ts.Seconds)
886
+ }
887
+
888
+ function Get-WorktreeBytes {
889
+ param([string]$RepoRoot, [string]$RelPath)
890
+ $full = Get-WorktreePath -Root $RepoRoot -Rel $RelPath
891
+ if (-not (Test-Path -LiteralPath $full)) { return 0L }
892
+ $item = Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
893
+ if (-not $item -or $item.PSIsContainer) { return 0L }
894
+ return [int64]$item.Length
895
+ }
896
+
897
+ function Write-DownloadProgress {
898
+ param(
899
+ [int]$OkCount,
900
+ [int]$TotalCount,
901
+ [int]$FailCount,
902
+ [int64]$DoneBytes,
903
+ [TimeSpan]$Elapsed,
904
+ [int]$DoneThisRun,
905
+ [string]$CurrentFile,
906
+ [int]$BarWidth = 28
907
+ )
908
+ $pct = 0.0
909
+ if ($TotalCount -gt 0) { $pct = 100.0 * $OkCount / $TotalCount }
910
+ $filled = 0
911
+ if ($TotalCount -gt 0) {
912
+ $filled = [int][Math]::Round($BarWidth * $OkCount / $TotalCount)
913
+ if ($filled -gt $BarWidth) { $filled = $BarWidth }
914
+ }
915
+ $empty = $BarWidth - $filled
916
+ $bar = ("#" * $filled) + ("-" * $empty)
917
+ $remain = $TotalCount - $OkCount - $FailCount
918
+ $eta = Format-Eta -Elapsed $Elapsed -DoneThisRun $DoneThisRun -Remain $remain
919
+ $rate = 0.0
920
+ if ($Elapsed.TotalSeconds -gt 0.5 -and $DoneThisRun -gt 0) {
921
+ $rate = $DoneThisRun / $Elapsed.TotalSeconds
922
+ }
923
+ $name = [string]$CurrentFile
924
+ if ($name.Length -gt 48) { $name = "..." + $name.Substring($name.Length - 45) }
925
+ $line = ("[{0}] {1,5:N1}% {2}/{3} fail {4} {5} {6:N1} files/s ETA {7} {8}" -f @(
926
+ $bar, $pct, $OkCount, $TotalCount, $FailCount, (Format-Bytes $DoneBytes), $rate, $eta, $name
927
+ ))
928
+ $width = 120
929
+ try {
930
+ $w = [int]$Host.UI.RawUI.WindowSize.Width
931
+ if ($w -gt 20) { $width = $w - 1 }
932
+ } catch { }
933
+ $out = $line
934
+ if ($out.Length -lt $width) { $out = $out.PadRight($width) }
935
+ elseif ($out.Length -gt $width) { $out = $out.Substring(0, $width) }
936
+ if (Test-GcrTuiActive) {
937
+ Update-GcrTuiProgress -OkCount $OkCount -TotalCount $TotalCount -FailCount $FailCount -DoneBytes $DoneBytes -Rate $rate -Eta $eta -CurrentFile $CurrentFile
938
+ Invoke-GcrTuiTick
939
+ } else {
940
+ Write-Host ("`r" + $out) -NoNewline
941
+ Write-Progress -Activity "git-clone-resume" -Status $line -PercentComplete ([Math]::Min(100, [int]$pct))
942
+ }
943
+ }
944
+
945
+ function Assert-GcrContinue {
946
+ if (-not (Get-Command Test-GcrTuiActive -ErrorAction SilentlyContinue)) { return }
947
+ if (-not (Test-GcrTuiActive)) { return }
948
+ Invoke-GcrTuiTick
949
+ Wait-GcrTuiPaused
950
+ Invoke-GcrTuiTick
951
+ if (Test-GcrTuiQuit) {
952
+ $script:GcrUserStop = $true
953
+ throw "已由用户停止。再次运行同一命令即可续传。"
954
+ }
955
+ }
956
+
957
+ $script:GcrInteractive = $false
958
+ try { $script:GcrInteractive = [Environment]::UserInteractive } catch { }
959
+ if ($NoTui) {
960
+ $script:GcrTuiWanted = $false
961
+ } elseif ($Tui) {
962
+ $script:GcrTuiWanted = $true
963
+ } elseif ($script:GcrInteractive -and (Get-Command Test-GcrTuiAvailable -ErrorAction SilentlyContinue) -and (Test-GcrTuiAvailable)) {
964
+ $script:GcrTuiWanted = $true
965
+ }
966
+
967
+ if ($ResumeLast) {
968
+ if (-not (Get-Command Get-GcrHistoryLast -ErrorAction SilentlyContinue)) {
969
+ Write-Host "错误: 无法读取历史记录。" -ForegroundColor Red
970
+ exit 2
971
+ }
972
+ $last = Get-GcrHistoryLast
973
+ if ($null -eq $last) {
974
+ Write-Host "错误: 没有可恢复的历史记录。请先启动过一次克隆。" -ForegroundColor Red
975
+ exit 2
976
+ }
977
+ if ([string]::IsNullOrWhiteSpace($RepoUrl) -and $last.url) { $RepoUrl = [string]$last.url }
978
+ if ([string]::IsNullOrWhiteSpace($OutDir) -and $last.outDir) { $OutDir = [string]$last.outDir }
979
+ if (($Ref -eq "HEAD" -or [string]::IsNullOrWhiteSpace($Ref)) -and $last.ref) { $Ref = [string]$last.ref }
980
+ }
981
+
982
+ if ([string]::IsNullOrWhiteSpace($RepoUrl)) {
983
+ if ($NoTui -or -not $script:GcrInteractive) {
984
+ Show-Usage
985
+ Write-Host "错误: 必须提供仓库 URL。" -ForegroundColor Red
986
+ exit 2
987
+ }
988
+ $defaults = @{
989
+ RepoUrl = $RepoUrl
990
+ OutDir = $OutDir
991
+ Ref = $Ref
992
+ BatchSize = $BatchSize
993
+ MaxRetries = $MaxRetries
994
+ Include = $Include
995
+ Exclude = $Exclude
996
+ Verify = [bool]$Verify
997
+ ForceRefetch = [bool]$ForceRefetch
998
+ DryRun = [bool]$DryRun
999
+ }
1000
+ if ($PSBoundParameters.ContainsKey("Depth")) { $defaults["Depth"] = $Depth }
1001
+ if (-not (Get-Command Show-GcrInteractiveSetup -ErrorAction SilentlyContinue)) {
1002
+ Show-Usage
1003
+ Write-Host "错误: 必须提供仓库 URL。" -ForegroundColor Red
1004
+ exit 2
1005
+ }
1006
+ $wiz = $null
1007
+ try {
1008
+ $wiz = Show-GcrInteractiveSetup -Defaults $defaults
1009
+ if (Get-Command ConvertFrom-GcrWizardOutput -ErrorAction SilentlyContinue) {
1010
+ $unwrapped = ConvertFrom-GcrWizardOutput $wiz
1011
+ if ($null -ne $unwrapped) { $wiz = $unwrapped }
1012
+ }
1013
+ } catch {
1014
+ if (Get-Command Close-GcrTui -ErrorAction SilentlyContinue) { Close-GcrTui }
1015
+ Write-Host ("向导失败: " + $_.Exception.Message) -ForegroundColor Red
1016
+ exit 1
1017
+ }
1018
+ if ($null -eq $wiz) {
1019
+ if (Get-Command Close-GcrTui -ErrorAction SilentlyContinue) { Close-GcrTui }
1020
+ exit 0
1021
+ }
1022
+ try {
1023
+ $RepoUrl = [string]$wiz.RepoUrl
1024
+ if ($wiz.OutDir) { $OutDir = [string]$wiz.OutDir }
1025
+ if ($wiz.Ref) { $Ref = [string]$wiz.Ref }
1026
+ if ($wiz.BatchSize) { $BatchSize = [int]$wiz.BatchSize }
1027
+ if ($wiz.MaxRetries) { $MaxRetries = [int]$wiz.MaxRetries }
1028
+ if ($null -ne $wiz.Include) { $Include = @($wiz.Include | Where-Object { $_ }) }
1029
+ if ($null -ne $wiz.Exclude) { $Exclude = @($wiz.Exclude | Where-Object { $_ }) }
1030
+ $Verify = [bool]$wiz.Verify
1031
+ $ForceRefetch = [bool]$wiz.ForceRefetch
1032
+ $DryRun = [bool]$wiz.DryRun
1033
+ if ($null -ne $wiz.Depth -and [string]$wiz.Depth -ne "") {
1034
+ $Depth = [int]$wiz.Depth
1035
+ $PSBoundParameters["Depth"] = $Depth
1036
+ }
1037
+ } catch {
1038
+ if (Test-GcrTuiActive) {
1039
+ Show-GcrTuiResult -Title "无法开始克隆" -Body @($_.Exception.Message) -Kind error
1040
+ Close-GcrTui
1041
+ } else {
1042
+ Write-Host ("无法开始克隆: " + $_.Exception.Message) -ForegroundColor Red
1043
+ }
1044
+ exit 1
1045
+ }
1046
+ }
1047
+
1048
+ if ([string]::IsNullOrWhiteSpace($RepoUrl)) {
1049
+ if (Get-Command Close-GcrTui -ErrorAction SilentlyContinue) { Close-GcrTui }
1050
+ Show-Usage
1051
+ Write-Host "错误: 必须提供仓库 URL。" -ForegroundColor Red
1052
+ exit 2
1053
+ }
1054
+
1055
+ if ($script:GcrTuiWanted -and (Get-Command Initialize-GcrTui -ErrorAction SilentlyContinue)) {
1056
+ if (-not (Test-GcrTuiActive)) { [void](Initialize-GcrTui) }
1057
+ if ($Tui -and -not (Test-GcrTuiActive)) {
1058
+ Write-Host "当前终端无法进入全屏 TUI,改用日志模式。Windows Terminal 下再试,或去掉 -Tui。" -ForegroundColor Yellow
1059
+ }
1060
+ }
1061
+
1062
+ try {
1063
+ if (-not $OutDir) { $OutDir = Get-RepoFolderName -Url $RepoUrl }
1064
+ $repoRoot = Convert-ToFullPath -Path $OutDir
1065
+ $script:RepoRoot = $repoRoot
1066
+
1067
+ if (Test-GcrTuiActive) {
1068
+ $resumeHint = Test-Path -LiteralPath (Join-Path $repoRoot ".git\$($script:StateDirName)\meta.txt")
1069
+ Set-GcrTuiRepo -Url $RepoUrl -OutDir $repoRoot -Ref $Ref -Resume:$resumeHint
1070
+ Set-GcrTuiPhase -Name "init" -Detail ""
1071
+ if (Get-Command Save-GcrHistory -ErrorAction SilentlyContinue) {
1072
+ Save-GcrHistory -Url $RepoUrl -OutDir $repoRoot -Ref $Ref -Status "running"
1073
+ }
1074
+ Invoke-GcrTuiTick -Force
1075
+ }
1076
+
1077
+ Test-GitAvailable
1078
+ $script:GitExe = Get-GitExePath
1079
+
1080
+ Write-Log "仓库: $RepoUrl" "STEP"
1081
+ Write-Log "目录: $repoRoot" "INFO"
1082
+ Write-Log "引用: $Ref" "INFO"
1083
+
1084
+ if (-not (Test-Path -LiteralPath $repoRoot)) {
1085
+ New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
1086
+ }
1087
+ Initialize-PartialRepo -RepoRoot $repoRoot -Url $RepoUrl
1088
+
1089
+ $gitDir = Join-Path $repoRoot ".git"
1090
+ $stateDir = Join-Path $gitDir $script:StateDirName
1091
+ if (-not (Test-Path -LiteralPath $stateDir)) {
1092
+ New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
1093
+ }
1094
+ $metaPath = Join-Path $stateDir "meta.txt"
1095
+ $listPath = Join-Path $stateDir "files.tsv"
1096
+ $donePath = Join-Path $stateDir "done.txt"
1097
+ $failedPath = Join-Path $stateDir "failed.txt"
1098
+ $script:LogFile = Join-Path $stateDir "log.txt"
1099
+
1100
+ Clear-StaleIndexLock -RepoRoot $repoRoot
1101
+
1102
+ $fetchArgs = @(
1103
+ "-c", "http.version=HTTP/1.1",
1104
+ "fetch", "--filter=blob:none", "--progress", "--no-recurse-submodules"
1105
+ )
1106
+ if ($PSBoundParameters.ContainsKey("Depth")) {
1107
+ $fetchArgs += @("--depth", [string]$Depth)
1108
+ }
1109
+ $fetchArgs += @("origin", $Ref)
1110
+
1111
+ $needFetch = $true
1112
+ $pinnedSha = $null
1113
+ $meta = Read-Meta -MetaPath $metaPath
1114
+ if (-not $ForceRefetch -and $meta.ContainsKey("commit") -and $meta["ref"] -eq $Ref) {
1115
+ $trySha = $meta["commit"]
1116
+ $chk = Invoke-GitProcess -WorkDir $repoRoot -ExpectFail -GitArgs @("cat-file", "-t", $trySha)
1117
+ if ($chk.ExitCode -eq 0 -and $chk.StdOut.Trim() -eq "commit") {
1118
+ $needFetch = $false
1119
+ $pinnedSha = $trySha
1120
+ $short = $trySha.Substring(0, [Math]::Min(12, $trySha.Length))
1121
+ Write-Log "本地已有 commit $short ,跳过 fetch(需要更新请加 -ForceRefetch)" "OK"
1122
+ }
1123
+ }
1124
+
1125
+ if ($needFetch) {
1126
+ Write-Log "fetch 元数据: git fetch --filter=blob:none origin $Ref" "STEP"
1127
+ Set-GcrTuiPhase -Name "fetch" -Detail ("origin " + $Ref)
1128
+ Assert-GcrContinue
1129
+ $inheritFetch = -not (Test-GcrTuiActive)
1130
+ Invoke-GitRetry -What "git fetch" -WorkDir $repoRoot -GitArgs $fetchArgs -InheritConsole:$inheritFetch | Out-Null
1131
+ $rev = Invoke-GitRetry -What "rev-parse FETCH_HEAD" -WorkDir $repoRoot -GitArgs @("rev-parse", "FETCH_HEAD")
1132
+ $pinnedSha = $rev.StdOut.Trim()
1133
+ if ([string]::IsNullOrWhiteSpace($pinnedSha)) {
1134
+ throw "无法解析 FETCH_HEAD,fetch 可能失败。"
1135
+ }
1136
+ }
1137
+
1138
+ $type = (Invoke-Git -WorkDir $repoRoot -GitArgs @("cat-file", "-t", $pinnedSha)).Trim()
1139
+ if ($type -ne "commit") {
1140
+ throw "目标 $Ref 解析为 $type ($pinnedSha),需要 commit。"
1141
+ }
1142
+ Set-DetachedHead -RepoRoot $repoRoot -Sha $pinnedSha
1143
+ Write-Log "目标 commit: $pinnedSha" "OK"
1144
+ Set-GcrTuiRepo -Commit $pinnedSha
1145
+
1146
+ Write-Meta -MetaPath $metaPath -Map @{
1147
+ url = $RepoUrl
1148
+ ref = $Ref
1149
+ commit = $pinnedSha
1150
+ batch = [string]$BatchSize
1151
+ updated = (Get-Date -Format o)
1152
+ hostname = [string]$env:COMPUTERNAME
1153
+ }
1154
+
1155
+ Write-Log "枚举文件树 git ls-tree -r $pinnedSha" "STEP"
1156
+ Set-GcrTuiPhase -Name "list" -Detail $pinnedSha
1157
+ Assert-GcrContinue
1158
+ $all = Get-TreeEntries -RepoRoot $repoRoot -Sha $pinnedSha
1159
+ if ($null -eq $all) { $all = New-Object System.Collections.Generic.List[object] }
1160
+ Write-Log ("树中条目: " + $all.Count) "INFO"
1161
+
1162
+ $skippedSubmodule = 0
1163
+ $filtered = New-Object System.Collections.Generic.List[object]
1164
+ $includeArr = @($Include | Where-Object { $_ })
1165
+ $excludeArr = @($Exclude | Where-Object { $_ })
1166
+ foreach ($e in $all) {
1167
+ if ($e.Type -eq "commit" -or $e.Mode -eq "160000") {
1168
+ $skippedSubmodule++
1169
+ continue
1170
+ }
1171
+ if ($e.Type -ne "blob") { continue }
1172
+ if ($includeArr.Count -gt 0 -and -not (Test-WildcardMatch -Path $e.Path -Patterns $includeArr)) { continue }
1173
+ if ($excludeArr.Count -gt 0 -and (Test-WildcardMatch -Path $e.Path -Patterns $excludeArr)) { continue }
1174
+ [void]$filtered.Add($e)
1175
+ }
1176
+ if ($skippedSubmodule -gt 0) {
1177
+ Write-Log "跳过 $skippedSubmodule 个子模块(gitlink)。需要的话请在对应目录单独再跑本脚本。" "WARN"
1178
+ }
1179
+
1180
+ Write-Log ("待处理文件: " + $filtered.Count + " (blob 体积在 checkout 后统计,避免 ls-tree -l 把全部 blob 拉下来)") "INFO"
1181
+
1182
+ $tsv = New-Object System.Text.StringBuilder
1183
+ [void]$tsv.AppendLine("mode" + [char]9 + "type" + [char]9 + "blob" + [char]9 + "size" + [char]9 + "path")
1184
+ foreach ($e in $filtered) {
1185
+ [void]$tsv.AppendLine($e.Mode + [char]9 + $e.Type + [char]9 + $e.Blob + [char]9 + $e.Size + [char]9 + $e.Path)
1186
+ }
1187
+ Save-TextFile -Path $listPath -Content $tsv.ToString()
1188
+
1189
+ $okCount = 0
1190
+ $failCount = 0
1191
+ $totalCount = $filtered.Count
1192
+ $doneBytes = 0L
1193
+ $skipDownload = $false
1194
+ $resultKind = "done"
1195
+ $resultTitle = ""
1196
+ $resultBody = New-Object System.Collections.Generic.List[string]
1197
+
1198
+ if ($DryRun) {
1199
+ Write-Log "DryRun 结束,清单: $listPath" "OK"
1200
+ $nshow = [Math]::Min(30, $filtered.Count)
1201
+ for ($i = 0; $i -lt $nshow; $i++) {
1202
+ $e = $filtered[$i]
1203
+ if (Test-GcrTuiActive) { Add-GcrTuiLog -Level "INFO" -Message $e.Path }
1204
+ else { Write-Host (" " + $e.Path) }
1205
+ }
1206
+ if ($filtered.Count -gt 30) {
1207
+ $more = (" ... 另有 {0} 个文件" -f ($filtered.Count - 30))
1208
+ if (Test-GcrTuiActive) { Add-GcrTuiLog -Level "INFO" -Message $more.Trim() }
1209
+ else { Write-Host $more }
1210
+ }
1211
+ $script:GcrExitCode = 0
1212
+ $skipDownload = $true
1213
+ $resultTitle = "DryRun 完成"
1214
+ [void]$resultBody.Add(("清单文件: " + $listPath))
1215
+ [void]$resultBody.Add(("文件数: " + $filtered.Count))
1216
+ [void]$resultBody.Add("未下载 blob。去掉 -DryRun 后开始/继续克隆。")
1217
+ }
1218
+
1219
+ if (-not $skipDownload) {
1220
+ Set-GcrTuiPhase -Name "scan" -Detail "检查工作区已有文件"
1221
+ $doneSet = Load-DoneSet -DonePath $donePath
1222
+ $pending = New-Object System.Collections.Generic.List[object]
1223
+ $skippedDone = 0
1224
+ $skippedExist = 0
1225
+ $reverify = [bool]$Verify
1226
+ $scanTotal = $filtered.Count
1227
+ $scanIndex = 0
1228
+ $scanStarted = Get-Date
1229
+
1230
+ foreach ($e in $filtered) {
1231
+ $scanIndex++
1232
+ if (($scanIndex % 200) -eq 0 -or $scanIndex -eq $scanTotal) {
1233
+ Write-DownloadProgress -OkCount $scanIndex -TotalCount $scanTotal -FailCount 0 -DoneBytes 0L -Elapsed ((Get-Date) - $scanStarted) -DoneThisRun $scanIndex -CurrentFile ("scan " + $e.Path)
1234
+ Assert-GcrContinue
1235
+ }
1236
+ $complete = Test-FileComplete -RepoRoot $repoRoot -Entry $e -HashVerify:$reverify
1237
+ if ($complete) {
1238
+ if ($doneSet.Contains($e.Path)) {
1239
+ $skippedDone++
1240
+ } else {
1241
+ [void]$doneSet.Add($e.Path)
1242
+ Add-DonePaths -DonePath $donePath -Paths @($e.Path)
1243
+ $skippedExist++
1244
+ }
1245
+ continue
1246
+ }
1247
+ if ($doneSet.Contains($e.Path)) {
1248
+ [void]$doneSet.Remove($e.Path)
1249
+ }
1250
+ [void]$pending.Add($e)
1251
+ }
1252
+ Write-GcrNewline
1253
+
1254
+ Write-Log ("进度: 已记录 " + $skippedDone + " ,工作区已存在 " + $skippedExist + " ,剩余 " + $pending.Count) "INFO"
1255
+
1256
+ $okCount = $skippedDone + $skippedExist
1257
+ $totalCount = $filtered.Count
1258
+ foreach ($e in $filtered) {
1259
+ if ($doneSet.Contains($e.Path)) { $doneBytes += (Get-WorktreeBytes -RepoRoot $repoRoot -RelPath $e.Path) }
1260
+ }
1261
+
1262
+ if ($pending.Count -eq 0) {
1263
+ Write-Log "全部文件已就绪。" "OK"
1264
+ Write-Log "工作区: $repoRoot" "OK"
1265
+ $script:GcrExitCode = 0
1266
+ $skipDownload = $true
1267
+ $resultTitle = "全部文件已就绪"
1268
+ [void]$resultBody.Add(("工作区: " + $repoRoot))
1269
+ [void]$resultBody.Add(("文件: {0}/{1}" -f $okCount, $totalCount))
1270
+ }
1271
+ }
1272
+
1273
+ if (-not $skipDownload) {
1274
+ $batches = Split-Batches -Items $pending.ToArray() -MaxCount $BatchSize -MaxChars $MaxArgChars
1275
+ Write-Log ("分 " + $batches.Count + " 批下载,每批最多 " + $BatchSize + " 个文件") "STEP"
1276
+ Set-GcrTuiPhase -Name "download" -Detail ("batch 1/" + $batches.Count)
1277
+ Assert-GcrContinue
1278
+
1279
+ $started = Get-Date
1280
+ $failCount = 0
1281
+ $processedThisRun = 0
1282
+ Write-DownloadProgress -OkCount $okCount -TotalCount $totalCount -FailCount 0 -DoneBytes $doneBytes -Elapsed ([TimeSpan]::Zero) -DoneThisRun 0 -CurrentFile "starting download"
1283
+
1284
+ $batchIndex = 0
1285
+ foreach ($batch in $batches) {
1286
+ Assert-GcrContinue
1287
+ $batchIndex++
1288
+ Set-GcrTuiPhase -Name "download" -Detail ("batch " + $batchIndex + "/" + $batches.Count)
1289
+ $okThis = New-Object System.Collections.Generic.List[object]
1290
+ $badThis = New-Object System.Collections.Generic.List[object]
1291
+ $preview = $batch[0].Path
1292
+ Write-DownloadProgress -OkCount $okCount -TotalCount $totalCount -FailCount $failCount -DoneBytes $doneBytes -Elapsed ((Get-Date) - $started) -DoneThisRun $processedThisRun -CurrentFile $preview
1293
+
1294
+ try {
1295
+ Invoke-CheckoutBatch -RepoRoot $repoRoot -Sha $pinnedSha -Batch $batch
1296
+ $result = Confirm-BatchFiles -RepoRoot $repoRoot -Batch $batch
1297
+ foreach ($x in $result.Ok) { [void]$okThis.Add($x) }
1298
+ foreach ($x in $result.Bad) { [void]$badThis.Add($x) }
1299
+ } catch {
1300
+ if ($script:GcrUserStop) { throw }
1301
+ $nBatch = @($batch).Count
1302
+ if ($nBatch -gt 1) {
1303
+ Write-Log ("批次 " + $batchIndex + "/" + $batches.Count + " 失败(" + $nBatch + " 个文件),立即拆成单文件,不再整批重试: " + $_.Exception.Message) "WARN"
1304
+ } else {
1305
+ Write-Log ("单文件失败: " + $_.Exception.Message) "WARN"
1306
+ }
1307
+ foreach ($x in $batch) { [void]$badThis.Add($x) }
1308
+ }
1309
+
1310
+ $retry = New-Object System.Collections.Generic.List[object]
1311
+ foreach ($e in $badThis) { [void]$retry.Add($e) }
1312
+ foreach ($e in $retry) {
1313
+ Assert-GcrContinue
1314
+ $oneOk = $false
1315
+ $one = New-Object System.Collections.Generic.List[object]
1316
+ [void]$one.Add($e)
1317
+ try {
1318
+ Invoke-CheckoutBatch -RepoRoot $repoRoot -Sha $pinnedSha -Batch $one
1319
+ if (Test-FileComplete -RepoRoot $repoRoot -Entry $e) { $oneOk = $true }
1320
+ } catch {
1321
+ if ($script:GcrUserStop) { throw }
1322
+ Add-FailedPath -FailedPath $failedPath -RelPath $e.Path -Reason $_.Exception.Message
1323
+ }
1324
+ if ($oneOk) {
1325
+ [void]$okThis.Add($e)
1326
+ } else {
1327
+ $failCount++
1328
+ Add-GcrTuiFailure -Path $e.Path
1329
+ $fullFail = Get-WorktreePath -Root $repoRoot -Rel $e.Path
1330
+ $why = "no worktree file"
1331
+ if (Test-Path -LiteralPath $fullFail) { $why = "worktree file present but still incomplete" }
1332
+ Write-Log ("仍失败: " + $e.Path + " (" + $why + ")") "ERROR"
1333
+ }
1334
+ }
1335
+
1336
+ $okPaths = New-Object System.Collections.Generic.List[string]
1337
+ foreach ($x in $okThis) { [void]$okPaths.Add([string]$x.Path) }
1338
+ if ($okPaths.Count -gt 0) {
1339
+ Add-DonePaths -DonePath $donePath -Paths $okPaths.ToArray()
1340
+ foreach ($p in $okPaths) { [void]$doneSet.Add($p) }
1341
+ }
1342
+
1343
+ $processedThisRun += $okThis.Count
1344
+ $okCount += $okThis.Count
1345
+ foreach ($e in $okThis) { $doneBytes += (Get-WorktreeBytes -RepoRoot $repoRoot -RelPath $e.Path) }
1346
+
1347
+ $lastName = $batch[$batch.Count - 1].Path
1348
+ Write-DownloadProgress -OkCount $okCount -TotalCount $totalCount -FailCount $failCount -DoneBytes $doneBytes -Elapsed ((Get-Date) - $started) -DoneThisRun $processedThisRun -CurrentFile $lastName
1349
+ if (($batchIndex % 20) -eq 0 -or $okCount -eq $totalCount) {
1350
+ $pctNow = 0.0
1351
+ if ($totalCount -gt 0) { $pctNow = 100.0 * $okCount / $totalCount }
1352
+ Write-GcrNewline
1353
+ Write-Log (("checkpoint {0}/{1} {2:N1}% fail {3} {4}" -f $okCount, $totalCount, $pctNow, $failCount, (Format-Bytes $doneBytes))) "INFO"
1354
+ }
1355
+ }
1356
+
1357
+ Write-GcrNewline
1358
+ if (-not (Test-GcrTuiActive)) {
1359
+ Write-Progress -Activity "git-clone-resume" -Completed
1360
+ }
1361
+ Set-GcrTuiPhase -Name "repair" -Detail "git index"
1362
+ Repair-GitIndex -RepoRoot $repoRoot -Sha $pinnedSha
1363
+ $elapsed = (Get-Date) - $started
1364
+ $elapsedText = "{0:00}:{1:00}:{2:00}" -f [int]$elapsed.TotalHours, $elapsed.Minutes, $elapsed.Seconds
1365
+ Write-Log ("完成: 成功 {0}/{1} ,失败 {2} ,耗时 {3}" -f $okCount, $totalCount, $failCount, $elapsedText) "OK"
1366
+ Write-Log "工作区: $repoRoot" "OK"
1367
+ [void]$resultBody.Add(("工作区: " + $repoRoot))
1368
+ [void]$resultBody.Add(("成功 {0}/{1} ,失败 {2} ,耗时 {3}" -f $okCount, $totalCount, $failCount, $elapsedText))
1369
+ if ($failCount -gt 0) {
1370
+ Write-Log "失败列表: $failedPath (再次运行本脚本会重试未完成文件)" "WARN"
1371
+ [void]$resultBody.Add(("失败列表: " + $failedPath))
1372
+ [void]$resultBody.Add("再次运行同一命令会重试未完成文件。")
1373
+ $script:GcrExitCode = 1
1374
+ $resultKind = "error"
1375
+ $resultTitle = "部分文件失败"
1376
+ } else {
1377
+ $script:GcrExitCode = 0
1378
+ $resultTitle = "克隆完成"
1379
+ }
1380
+ }
1381
+
1382
+ if (Get-Command Save-GcrHistory -ErrorAction SilentlyContinue) {
1383
+ $histStatus = "complete"
1384
+ if ($script:GcrExitCode -ne 0) { $histStatus = "failed" }
1385
+ if ($DryRun) { $histStatus = "dryrun" }
1386
+ Save-GcrHistory -Url $RepoUrl -OutDir $repoRoot -Ref $Ref -Commit $pinnedSha -Status $histStatus -Ok $okCount -Total $totalCount -Fail $failCount
1387
+ }
1388
+ if (Test-GcrTuiActive) {
1389
+ if (-not $resultTitle) { $resultTitle = "完成" }
1390
+ Show-GcrTuiResult -Title $resultTitle -Body $resultBody.ToArray() -Kind $resultKind
1391
+ }
1392
+ }
1393
+ catch {
1394
+ $err = ""
1395
+ try { $err = [string]$_.Exception.Message } catch { }
1396
+ if (-not $err) { try { $err = [string]$_ } catch { $err = "unknown error" } }
1397
+ Write-Log $err "ERROR"
1398
+ if ($_.ScriptStackTrace -and -not $script:GcrUserStop) { Write-Log ([string]$_.ScriptStackTrace) "ERROR" }
1399
+ if ($script:RepoRoot) {
1400
+ Write-Log ("中断后续传: 重新执行同一命令即可。仓库目录: " + $script:RepoRoot) "WARN"
1401
+ }
1402
+ if (Get-Command Save-GcrHistory -ErrorAction SilentlyContinue -and $script:RepoRoot) {
1403
+ $st = "failed"
1404
+ if ($script:GcrUserStop) { $st = "partial" }
1405
+ Save-GcrHistory -Url $RepoUrl -OutDir $script:RepoRoot -Ref $Ref -Status $st
1406
+ }
1407
+ if (Test-GcrTuiActive) {
1408
+ $body = @($err)
1409
+ if ($script:RepoRoot) { $body += ("仓库目录: " + $script:RepoRoot) }
1410
+ $body += "再次运行同一命令即可续传。"
1411
+ $title = $(if ($script:GcrUserStop) { "已停止" } else { "出错" })
1412
+ Show-GcrTuiResult -Title $title -Body $body -Kind "error"
1413
+ }
1414
+ $script:GcrExitCode = 1
1415
+ }
1416
+ finally {
1417
+ if (Get-Command Close-GcrTui -ErrorAction SilentlyContinue) { Close-GcrTui }
1418
+ }
1419
+
1420
+ exit $script:GcrExitCode
1421
+