git-clone-resume 0.1.2 → 0.1.4

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.
@@ -1,1421 +1,1685 @@
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
-
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
+ [ValidateSet("zh-CN", "en-US")]
109
+ [string]$Language = "zh-CN",
110
+
111
+ [switch]$ResumeLast,
112
+
113
+ [switch]$Help
114
+ )
115
+
116
+ Set-StrictMode -Version Latest
117
+ $ErrorActionPreference = "Stop"
118
+
119
+ try { $null = cmd /c "chcp 65001 >NUL" } catch { }
120
+ try {
121
+ [Console]::InputEncoding = [System.Text.Encoding]::UTF8
122
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
123
+ } catch { }
124
+ $OutputEncoding = [System.Text.Encoding]::UTF8
125
+ if (-not $env:LC_ALL) { $env:LC_ALL = "C.UTF-8" }
126
+ if (-not $env:LANG) { $env:LANG = "C.UTF-8" }
127
+ if (-not $env:GIT_HTTP_LOW_SPEED_LIMIT) { $env:GIT_HTTP_LOW_SPEED_LIMIT = "1024" }
128
+ if (-not $env:GIT_HTTP_LOW_SPEED_TIME) { $env:GIT_HTTP_LOW_SPEED_TIME = "60" }
129
+ $env:GIT_FLUSH = "1"
130
+
131
+ $script:Utf8NoBom = New-Object System.Text.UTF8Encoding $false
132
+ $script:RepoRoot = $null
133
+ $script:LogFile = $null
134
+ $script:GitExe = $null
135
+ $script:StateDirName = "partial-resume"
136
+ $script:GcrTuiWanted = $false
137
+ $script:GcrExitCode = 0
138
+ $script:GcrUserStop = $false
139
+ $script:GcrLanguage = $Language
140
+
141
+ function Convert-GcrText {
142
+ param([AllowNull()][string]$Text)
143
+ if ($null -eq $Text -or $script:GcrLanguage -ne "en-US") { return $Text }
144
+ $full = [ordered]@{
145
+ "断点续传克隆 · partial clone + 按批 checkout" = "Resumable clone · partial clone + batch checkout"
146
+ "选择界面语言。可随时按 L 在中文和英文之间切换。" = "Interface language. Press L to switch between Chinese and English."
147
+ "续传时对已有文件做 hash-object 校验,哈希不一致则重新下载。Space 开关。" = "Verify existing files with hash-object when resuming. Re-download mismatches. Space toggles."
148
+ "浅克隆深度。留空则拉完整 commit 历史(仍然不拉 blob)。" = "Shallow clone depth. Leave empty for full commit history (blobs are still deferred)."
149
+ "跳过匹配的路径,例如 *.bin,*.zip。可与「只含路径」同时使用。" = "Skip matching paths, such as *.bin,*.zip. Can be combined with Include paths."
150
+ "只下载匹配的路径,逗号分隔通配符,例如 src/*,docs/*。空表示全部。" = "Download only matching paths, comma-separated patterns such as src/*,docs/*. Empty means all."
151
+ "单文件失败后的最大重试次数。← → 调整。网络不稳时可调大。" = "Maximum retries after a single-file failure. Use Left/Right to adjust. Increase for unstable networks."
152
+ "每批 checkout 的文件数。越大越快,中断粒度越粗。← → 调整。" = "Files checked out per batch. Larger is faster but less granular. Use Left/Right to adjust."
153
+ "分支、标签或 commit SHA。默认远程 HEAD。" = "Branch, tag, or commit SHA. Defaults to remote HEAD."
154
+ "远程仓库地址,支持 https、ssh、git@ 以及本地路径。Ctrl+V 从剪贴板粘贴。" = "Remote repository URL. Supports https, ssh, git@, and local paths. Ctrl+V pastes from the clipboard."
155
+ "工作区目录。留空则用仓库名。已有 .git/partial-resume 时自动续传。" = "Workspace directory. Leave empty to use the repository name. Existing .git/partial-resume state resumes automatically."
156
+ "强制重新 fetch 目标 ref。换分支或更新到最新 commit 时打开。Space 开关。" = "Force-fetch the target ref. Enable when changing branches or updating to the latest commit. Space toggles."
157
+ "只列出将要处理的文件,不下载 blob。适合先看清单。Space 开关。" = "List files without downloading blobs. Useful for previewing the file list. Space toggles."
158
+ "按上面的设置开始或继续克隆。Enter 启动。中断后重跑即可续传。" = "Start or resume with the settings above. Press Enter to begin; rerun after interruption."
159
+ "将在当前 git 命令结束后停止。Ctrl+C 再按一次立即结束。" = "Stopping after the current git command. Press Ctrl+C again to force stop."
160
+ "已暂停:当前批次结束后停住。Space 继续,Q 停止。" = "Paused after the current batch. Space resumes; Q stops."
161
+ "正在拉取 commit/tree 元数据(blob:none)。文件内容会在下一步按批下载。" = "Fetching commit/tree metadata (blob:none). File contents download in batches next."
162
+ "按批 checkout 文件。中断后重跑同一命令即可续传。" = "Check out files in batches. Rerun the same command after an interruption to resume."
163
+ "脚本模式:加 -NoTui。强制界面:加 -Tui。" = "Script mode: add -NoTui. Force the interface: add -Tui."
164
+ "续传:重新运行同一条命令。进度在 .git/partial-resume/" = "Resume: run the same command again. Progress is stored in .git/partial-resume/"
165
+ "任意键关闭帮助" = "Press any key to close help"
166
+ "当前 git 命令结束后生效;再按一次强制结束" = "takes effect after the current git command; press again to force stop"
167
+ "当前批次结束后暂停" = "pause after the current batch"
168
+ "从暂停恢复" = "resume from pause"
169
+ "切换失败文件列表" = "toggle failed files"
170
+ "滚动活动日志" = "scroll activity log"
171
+ "跟随最新日志" = "follow latest log"
172
+ "打开或关闭本帮助" = "toggle this help"
173
+ "键盘" = "Keyboard"
174
+ "仓库" = "Repository"
175
+ "目录" = "Directory"
176
+ "引用" = "Ref"
177
+ "阶段" = "Phase"
178
+ "当前" = "Current"
179
+ "失败" = "failed"
180
+ "批次" = "batch"
181
+ "暂停" = "pause"
182
+ "停止" = "stop"
183
+ "帮助" = "help"
184
+ "日志" = "log"
185
+ "继续" = "resume"
186
+ "(无。完成一次克隆后会出现在这里)" = "(None. Completed clones appear here)"
187
+ "初始化本地仓库" = "Initialize repository"
188
+ "快捷键说明。任意键关闭此帮助。" = "Keyboard help. Press any key to close."
189
+ "失败文件列表。F 返回活动日志,再次运行同一命令会重试。" = "Failed files. Press F to return to the activity log; run the same command to retry."
190
+ "初始化本地仓库并配置 partial clone(只拉元数据,不拉文件内容)。" = "Initialize the local repository and configure partial clone (metadata only, no file contents)."
191
+ "枚举仓库文件树。不会为了拿大小去拉全部 blob。" = "List the repository file tree without downloading all blobs just to determine their sizes."
192
+ "扫描工作区,跳过已经落盘的文件,其余进入待下载队列。" = "Scan the workspace, skip files already on disk, and queue the rest for download."
193
+ "全部完成。工作区已可用。" = "Everything is complete. The workspace is ready."
194
+ "出错或未完成。重新运行同一命令即可从断点继续。" = "Failed or incomplete. Run the same command to resume."
195
+ "退出向导?未开始的克隆不会写入进度。Enter 确定,Esc 取消。" = "Quit the wizard? No progress is written before a clone starts. Enter confirms; Esc cancels."
196
+ "正在编辑。Enter 确认,Esc 取消,Ctrl+V 粘贴。光标用 ← → Home End。" = "Editing. Enter confirms, Esc cancels, Ctrl+V pastes. Move with Left/Right, Home, or End."
197
+ "最近任务。Enter 填入 URL/目录/分支,可直接续传未完成的克隆。" = "Recent tasks. Enter fills in the URL, directory, and branch to resume an incomplete clone."
198
+ "暂无失败文件。" = "No failed files."
199
+ " Enter 关闭 · Q 退出 · ? 帮助" = " Enter close · Q quit · ? help"
200
+ " Ctrl+C 再按一次强制结束 · ? 帮助" = " Ctrl+C again to force stop · ? help"
201
+ " Enter 编辑/开始 · Space 开关 · ←→ 改批次 · Ctrl+V 粘贴 · Q 退出" = " Enter edit/start · Space toggle · Left/Right batch · Ctrl+V paste · Q quit"
202
+ " Enter 确定退出 · Esc 返回" = " Enter confirm quit · Esc back"
203
+ "本地目录" = "Local directory"
204
+ "请填写仓库 URL(Ctrl+V 可从剪贴板粘贴)" = "Enter a repository URL (Ctrl+V pastes from the clipboard)"
205
+ "Enter 编辑 · Space 开关 · Tab 最近任务 · S 开始 · Q 退出" = "Enter edit · Space toggle · Tab recent tasks · S start · Q quit"
206
+ "git-clone-resume 交互设置" = "git-clone-resume interactive setup"
207
+ "直接回车使用括号里的默认值。空 URL 则退出。" = "Press Enter to use the defaults in brackets. An empty URL exits."
208
+ '每批文件数 [$batch]' = 'Files per batch [$batch]'
209
+ '分支/标签/commit [$defRef]' = 'Branch/tag/commit [$defRef]'
210
+ '本地目录 [$defDir]' = 'Local directory [$defDir]'
211
+ }
212
+ $specific = [ordered]@{
213
+ "断点续传克隆。Q 停止 · P 暂停 · ? 帮助" = "Resumable clone. Q stop · P pause · ? help"
214
+ "初始化本地仓库并配置 partial clone(只拉元数据,不拉文件内容)。" = "Initialize the local repository and configure partial clone (metadata only, no file contents)."
215
+ "枚举仓库文件树。不会为了拿大小去拉全部 blob。" = "List the repository file tree without downloading all blobs just to determine their sizes."
216
+ "修复 Windows 上可能被弄乱的 git index。" = "Repair the Git index if Windows left it inconsistent."
217
+ "出错或未完成。重新运行同一命令即可从断点继续。" = "Failed or incomplete. Run the same command to resume."
218
+ "最近任务。Enter 填入 URL/目录/分支,可直接续传未完成的克隆。" = "Recent tasks. Enter fills in the URL, directory, and branch to resume an incomplete clone."
219
+ "↑↓ 选择选项,Enter 编辑或开始。每个选项的说明会显示在这一行。" = "Up/Down select; Enter edits or starts. The selected option's guide appears here."
220
+ " Enter 关闭 · Q 退出 · ? 帮助" = " Enter close · Q quit · ? help"
221
+ " Ctrl+C 再按一次强制结束 · ? 帮助" = " Ctrl+C again to force stop · ? help"
222
+ " 最近任务 (Tab 切换 · Enter 填入)" = " Recent tasks (Tab switch · Enter fill in)"
223
+ " Enter 编辑/开始 · Space 开关 · ←→ 改批次 · Ctrl+V 粘贴 · Q 退出" = " Enter edit/start · Space toggle · Left/Right batch · Ctrl+V paste · Q quit"
224
+ " Enter 确认 · Esc 取消 · Ctrl+V 粘贴" = " Enter confirm · Esc cancel · Ctrl+V paste"
225
+ " Enter 确定退出 · Esc 返回" = " Enter confirm quit · Esc back"
226
+ "初始化仓库" = "Initialize repository"
227
+ "拉取元数据" = "Fetch metadata"
228
+ "枚举文件树" = "List file tree"
229
+ "扫描已有文件" = "Scan existing files"
230
+ "下载文件" = "Download files"
231
+ "修复 git index" = "Repair Git index"
232
+ "就绪" = "Ready"
233
+ "设置" = "Setup"
234
+ "出错" = "Error"
235
+ "最近任务" = "Recent tasks"
236
+ "暂无失败文件。" = "No failed files."
237
+ "快捷键说明。任意键关闭此帮助。" = "Keyboard help. Press any key to close."
238
+ "失败文件列表。F 返回活动日志,再次运行同一命令会重试。" = "Failed files. Press F to return to the activity log; run the same command to retry."
239
+ "本次运行已结束。Enter 关闭界面,进度保留在 .git/partial-resume/。" = "This run has ended. Press Enter to close; progress remains in .git/partial-resume/."
240
+ "请填写仓库 URL(Ctrl+V 可从剪贴板粘贴)" = "Enter a repository URL (Ctrl+V pastes from the clipboard)"
241
+ "无法开始: " = "Could not start: "
242
+ '本地目录 [$defDir]' = 'Local directory [$defDir]'
243
+ '分支/标签/commit [$defRef]' = 'Branch/tag/commit [$defRef]'
244
+ '每批文件数 [$batch]' = 'Files per batch [$batch]'
245
+ "本地目录" = "Local directory"
246
+ "分支/标签" = "Branch/tag"
247
+ "每批文件" = "Files per batch"
248
+ "重试次数" = "Retries"
249
+ "只含路径" = "Include paths"
250
+ "排除路径" = "Exclude paths"
251
+ "浅克隆深度" = "Clone depth"
252
+ "哈希校验" = "Hash verification"
253
+ "强制 refetch" = "Force refetch"
254
+ "开始克隆" = "Start clone"
255
+ "(自动)" = "(auto)"
256
+ "(全部)" = "(all)"
257
+ "(无)" = "(none)"
258
+ "(完整历史)" = "(full history)"
259
+ "{0} 分钟前" = "{0} minutes ago"
260
+ "{0} 小时前" = "{0} hours ago"
261
+ "{0} 天前" = "{0} days ago"
262
+ "全部完成。工作区已可用。" = "Everything is complete. The workspace is ready."
263
+ "(无。完成一次克隆后会出现在这里)" = "(None. Completed clones appear here)"
264
+ "单文件失败后的最大重试次数。← → 调整。网络不稳时可调大。" = "Maximum retries after a single-file failure. Use Left/Right to adjust. Increase for unstable networks."
265
+ "跳过匹配的路径,例如 *.bin,*.zip。可与「只含路径」同时使用。" = "Skip matching paths, such as *.bin,*.zip. Can be combined with Include paths."
266
+ "浅克隆深度。留空则拉完整 commit 历史(仍然不拉 blob)。" = "Shallow clone depth. Leave empty for full commit history (blobs are still deferred)."
267
+ "选择界面语言。可随时按 L 在中文和英文之间切换。" = "Interface language. Press L to switch between Chinese and English."
268
+ "按上面的设置开始或继续克隆。Enter 启动。中断后重跑即可续传。" = "Start or resume with the settings above. Press Enter to begin; rerun after interruption."
269
+ "Enter 编辑 · Space 开关 · Tab 最近任务 · S 开始 · Q 退出" = "Enter edit · Space toggle · Tab recent tasks · S start · Q quit"
270
+ "git-clone-resume 交互设置" = "git-clone-resume interactive setup"
271
+ "语言" = "Language"
272
+ "英文" = "English"
273
+ "中文" = "Chinese"
274
+ "" = "On"
275
+ "关" = "Off"
276
+ }
277
+ $translations = @{}
278
+ foreach ($key in $full.Keys) { $translations[$key] = [string]$full[$key] }
279
+ foreach ($key in $specific.Keys) { $translations[$key] = [string]$specific[$key] }
280
+ foreach ($key in @($translations.Keys | Sort-Object Length -Descending)) {
281
+ $Text = $Text.Replace($key, $translations[$key])
282
+ }
283
+ $pairs = @(
284
+ @("未找到 git。请先安装 Git for Windows", "Git was not found. Install Git for Windows"),
285
+ @("错误:", "Error:"), @("必须提供仓库 URL。", "A repository URL is required."),
286
+ @("无法读取历史记录。", "Could not read history."),
287
+ @("没有可恢复的历史记录。请先启动过一次克隆。", "No resumable history was found. Start a clone first."),
288
+ @("向导失败:", "Wizard failed:"), @("无法开始克隆", "Could not start clone"),
289
+ @("当前终端无法进入全屏 TUI,改用日志模式。Windows Terminal 下再试,或去掉 -Tui。", "This terminal cannot enter fullscreen TUI; using log mode. Try Windows Terminal or remove -Tui."),
290
+ @("已由用户停止。", "Stopped by user."), @("再次运行同一命令即可续传。", "Run the same command again to resume."),
291
+ @("使用 ", "Using "), @("仓库:", "Repository:"), @("目录:", "Directory:"), @("引用:", "Ref:"),
292
+ @("语言", "Language"), @("中文", "Chinese"), @("英文", "English"),
293
+ @("目标 commit:", "Target commit:"), @("fetch 元数据:", "Fetching metadata:"), @("枚举文件树", "Enumerating file tree"),
294
+ @("树中条目:", "Tree entries:"), @("待处理文件:", "Pending files:"), @("工作区:", "Workspace:"),
295
+ @("全部文件已就绪。", "All files are ready."), @("完成", "Complete"), @("克隆完成", "Clone complete"),
296
+ @("部分文件失败", "Some files failed"), @("失败列表:", "Failure list:"), @("仍失败:", "Still failed:"),
297
+ @("批次", "Batch"), @("失败", "failed"), @("单文件失败:", "Single-file failure:"), @("出错", "Error"),
298
+ @("已停止", "Stopped"), @("中断后续传: ", "Resume after interruption: "), @("仓库目录:", "Repository directory:"),
299
+ @("文件数:", "Files:"), @("清单文件:", "List file:"), @("DryRun 完成", "DryRun complete"),
300
+ @("DryRun 结束,清单:", "DryRun finished; list:"),
301
+ @("(blob 体积在 checkout 后统计,避免 ls-tree -l 把全部 blob 拉下来)", "(blob sizes are measured during checkout; ls-tree -l is avoided to prevent downloading all blobs)"),
302
+ @("未下载 blob。去掉 -DryRun 后开始/继续克隆。", "No blobs were downloaded. Remove -DryRun to start or resume."),
303
+ @("检查工作区已有文件", "Checking existing workspace files"), @("进度: 已记录", "Progress: recorded"),
304
+ @("分 ", "Downloading in "), @(" 批下载,每批最多 ", " batches, up to "), @(" 个文件", " files each"),
305
+ @("跳过", "Skipped"), @("个子模块(gitlink)。需要的话请在对应目录单独再跑本脚本。", " submodules (gitlinks). Run this script separately if needed.")
306
+ ,@("断点续传克隆", "Resumable clone"), @("按批 checkout", "batch checkout"), @("刚刚", "just now"),
307
+ @("分钟前", " minutes ago"), @("小时前", " hours ago"), @("天前", " days ago"),
308
+ @("就绪", "Ready"), @("设置", "Setup"), @("初始化仓库", "Initialize repository"), @("拉取元数据", "Fetch metadata"),
309
+ @("枚举文件树", "List file tree"), @("扫描已有文件", "Scan existing files"), @("下载文件", "Download files"),
310
+ @("修复 git index", "Repair git index"), @("快捷键说明。任意键关闭此帮助。", "Keyboard help. Press any key to close."),
311
+ @("失败文件列表。F 返回活动日志,再次运行同一命令会重试。", "Failed files. Press F to return; run the same command to retry."),
312
+ @("本次运行已结束。Enter 关闭界面,进度保留在 .git/partial-resume/。", "This run has ended. Press Enter to close; progress remains in .git/partial-resume/."),
313
+ @("已暂停:当前批次结束后停住。Space 继续,Q 停止。", "Paused after the current batch. Space resumes; Q stops."),
314
+ @("全部完成。工作区已可用。", "Everything is complete. The workspace is ready."),
315
+ @("出错或未完成。重新运行同一命令即可从断点继续。", "Failed or incomplete. Run the same command to resume."),
316
+ @("远程仓库地址,支持 https、ssh、git@ 以及本地路径。Ctrl+V 从剪贴板粘贴。", "Remote repository URL. Supports https, ssh, git@, and local paths. Ctrl+V pastes from the clipboard."),
317
+ @("工作区目录。留空则用仓库名。已有 .git/partial-resume 时自动续传。", "Workspace directory. Leave empty to use the repository name. Existing .git/partial-resume state resumes automatically."),
318
+ @("选择界面语言。可随时按 L 在中文和英文之间切换。", "Interface language. Press L at any time to switch between Chinese and English."),
319
+ @("最近任务", "Recent tasks"), @("切换", "switch"), @("填入", "fill in"), @("无。完成一次克隆后会出现在这里", "None. Completed clones appear here"),
320
+ @("Enter 编辑/开始", "Enter edit/start"), @("Space 开关", "Space toggle"), @("改批次", "change batch"), @("粘贴", "paste"), @("退出", "quit"),
321
+ @("按上面的设置开始或继续克隆。Enter 启动。中断后重跑即可续传。", "Start or resume with the settings above. Press Enter to begin; rerun after interruption."),
322
+ @("请填写仓库 URL(Ctrl+V 可从剪贴板粘贴)", "Enter a repository URL (Ctrl+V pastes from the clipboard)"),
323
+ @("语言", "Language"), @("中文", "Chinese"), @("英文", "English")
324
+ ,@(" 仓库 ", " Repository "), @(" 目录 ", " Directory "), @(" 引用 ", " Ref "), @(" 阶段 ", " Phase "), @(" 当前 ", " Current "),
325
+ @("键盘", "Keyboard"), @("暂无失败文件。", "No failed files."), @("停止", "stop"), @("暂停", "pause"), @("帮助", "help"), @("日志", "log")
326
+ ,@("仓库 URL", "Repository URL"), @("本地目录", "Local directory"), @("分支/标签", "Branch/tag"), @("每批文件", "Files per batch"),
327
+ @("重试次数", "Retries"), @("只含路径", "Include paths"), @("排除路径", "Exclude paths"), @("浅克隆深度", "Clone depth"),
328
+ @("哈希校验", "Hash verification"), @("强制 refetch", "Force refetch"), @("开始克隆", "Start clone"),
329
+ @("(自动)", "(auto)"), @("(全部)", "(all)"), @("(无)", "(none)"), @("(完整历史)", "(full history)"),
330
+ @("强制重新 fetch 目标 ref。换分支或更新到最新 commit 时打开。Space 开关。", "Force-fetch the target ref. Enable when changing branches or updating to the latest commit. Space toggles."),
331
+ @("只列出将要处理的文件,不下载 blob。适合先看清单。Space 开关。", "List files without downloading blobs. Useful for previewing the file list. Space toggles."),
332
+ @("按上面的设置开始或继续克隆。Enter 启动。中断后重跑即可续传。", "Start or resume with the settings above. Press Enter to begin; rerun after interruption."),
333
+ @("Enter 编辑/开始", "Enter edit/start"), @("Space 开关", "Space toggle"), @("←→ 改批次", "Left/Right change batch"),
334
+ @("Ctrl+V 粘贴", "Ctrl+V paste"), @("Q 退出", "Q quit"), @("Enter 确认", "Enter confirm"), @("Esc 取消", "Esc cancel"),
335
+ @("退出向导?未开始的克隆不会写入进度。Enter 确定,Esc 取消。", "Quit the wizard? No progress is written before a clone starts. Enter confirms; Esc cancels."),
336
+ @("↑↓ 选择选项,Enter 编辑或开始。每个选项的说明会显示在这一行。", "Up/Down select; Enter edits or starts. The selected option's guide appears here."),
337
+ @("将在当前 git 命令结束后停止。Ctrl+C 再按一次立即结束。", "Stopping after the current git command. Press Ctrl+C again to force stop."),
338
+ @("初始化本地仓库并配置 partial clone(只拉元数据,不拉文件内容)。", "Initialize the repository and configure partial clone (metadata only)."),
339
+ @("正在拉取 commit/tree 元数据(blob:none)。文件内容会在下一步按批下载。", "Fetching commit/tree metadata (blob:none). File contents download in batches next."),
340
+ @("扫描工作区,跳过已经落盘的文件,其余进入待下载队列。", "Scan the workspace, skip files already on disk, and queue the rest."),
341
+ @("按批 checkout 文件。中断后重跑同一命令即可续传。", "Check out files in batches. Rerun the same command after an interruption to resume."),
342
+ @("修复 Windows 上可能被弄乱的 git index。", "Repair the Git index if Windows left it inconsistent."),
343
+ @("Q 停止 · P 暂停 · ? 帮助", "Q stop · P pause · ? help")
344
+ )
345
+ $result = $Text
346
+ $direct = [ordered]@{
347
+ "断点续传克隆" = "Resumable clone"
348
+ "按批 checkout" = "batch checkout"
349
+ "仓库 URL" = "Repository URL"
350
+ "本地目录" = "Local directory"
351
+ "分支/标签" = "Branch/tag"
352
+ "每批文件" = "Files per batch"
353
+ "重试次数" = "Retries"
354
+ "只含路径" = "Include paths"
355
+ "排除路径" = "Exclude paths"
356
+ "浅克隆深度" = "Clone depth"
357
+ "哈希校验" = "Hash verification"
358
+ "开始克隆" = "Start clone"
359
+ "强制重新 fetch 目标 ref。换分支或更新到最新 commit 时打开。Space 开关。" = "Force-fetch the target ref. Enable when changing branches or updating to the latest commit. Space toggles."
360
+ "只列出将要处理的文件,不下载 blob。适合先看清单。Space 开关。" = "List files without downloading blobs. Useful for previewing the file list. Space toggles."
361
+ "按上面的设置开始或继续克隆。Enter 启动。中断后重跑即可续传。" = "Start or resume with the settings above. Press Enter to begin; rerun after interruption."
362
+ "Enter 编辑/开始" = "Enter edit/start"
363
+ "Space 开关" = "Space toggle"
364
+ "←→ 改批次" = "Left/Right change batch"
365
+ "Ctrl+V 粘贴" = "Ctrl+V paste"
366
+ "Q 退出" = "Q quit"
367
+ "Language" = "Language"
368
+ }
369
+ foreach ($key in $direct.Keys) { $result = $result.Replace($key, [string]$direct[$key]) }
370
+ $items = @($pairs)
371
+ for ($i = 0; $i -lt $items.Count;) {
372
+ if ($items[$i] -is [array] -and @($items[$i]).Count -ge 2) {
373
+ $from = [string]@($items[$i])[0]
374
+ $to = [string]@($items[$i])[1]
375
+ $i++
376
+ } elseif ($i + 1 -lt $items.Count) {
377
+ $from = [string]$items[$i]
378
+ $to = [string]$items[$i + 1]
379
+ $i += 2
380
+ } else {
381
+ break
382
+ }
383
+ if (-not [string]::IsNullOrEmpty($from)) { $result = $result.Replace($from, $to) }
384
+ }
385
+ return $result
386
+ }
387
+
388
+ $script:GcrTuiFile = Join-Path $PSScriptRoot "git-clone-resume.tui.ps1"
389
+ if (-not $PSScriptRoot) {
390
+ $script:GcrTuiFile = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "git-clone-resume.tui.ps1"
391
+ }
392
+ if (Test-Path -LiteralPath $script:GcrTuiFile) {
393
+ . $script:GcrTuiFile
394
+ } else {
395
+ function Test-GcrTuiActive { return $false }
396
+ function Test-GcrTuiAvailable { return $false }
397
+ function Test-GcrTuiQuit { return $false }
398
+ function Test-GcrTuiForceQuit { return $false }
399
+ function Invoke-GcrTuiTick { }
400
+ function Write-GcrNewline { Write-Host "" }
401
+ function Initialize-GcrTui { return $false }
402
+ function Close-GcrTui { }
403
+ function Set-GcrTuiPhase { }
404
+ function Set-GcrTuiRepo { }
405
+ function Update-GcrTuiProgress { }
406
+ function Add-GcrTuiLog { }
407
+ function Add-GcrTuiFailure { }
408
+ function Add-GcrGitOutput { }
409
+ function Receive-GcrGitBytes { }
410
+ function Wait-GcrTuiPaused { }
411
+ function Show-GcrTuiResult { }
412
+ function Save-GcrHistory { }
413
+ }
414
+
415
+ if (-not $PSBoundParameters.ContainsKey("Language") -and (Get-Command Get-GcrLanguagePreference -ErrorAction SilentlyContinue)) {
416
+ $savedLanguage = Get-GcrLanguagePreference
417
+ if ($savedLanguage) { $script:GcrLanguage = $savedLanguage }
418
+ }
419
+ if ($PSBoundParameters.ContainsKey("Language") -and (Get-Command Save-GcrLanguagePreference -ErrorAction SilentlyContinue)) {
420
+ Save-GcrLanguagePreference -Language $Language
421
+ }
422
+
423
+ function Write-Log {
424
+ param(
425
+ $Message,
426
+ [string]$Level = "INFO"
427
+ )
428
+ if (@("INFO", "WARN", "ERROR", "OK", "STEP") -notcontains $Level) { $Level = "INFO" }
429
+ $text = ""
430
+ try {
431
+ if ($null -eq $Message) { $text = "" }
432
+ elseif ($Message -is [System.Array]) { $text = (@($Message | ForEach-Object { "$_" }) -join " ") }
433
+ else { $text = [string]$Message }
434
+ } catch { $text = "$Message" }
435
+ $text = Convert-GcrText $text
436
+ $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
437
+ $color = switch ($Level) {
438
+ "INFO" { "Gray" }
439
+ "WARN" { "Yellow" }
440
+ "ERROR" { "Red" }
441
+ "OK" { "Green" }
442
+ "STEP" { "Cyan" }
443
+ default { "Gray" }
444
+ }
445
+ $line = "[$ts][$Level] $text"
446
+ try {
447
+ if (Test-GcrTuiActive) {
448
+ Add-GcrTuiLog -Level $Level -Message $text
449
+ Invoke-GcrTuiTick
450
+ } else {
451
+ Write-Host $line -ForegroundColor $color
452
+ }
453
+ } catch { }
454
+ if ($script:LogFile) {
455
+ try {
456
+ [System.IO.File]::AppendAllText($script:LogFile, $line + [Environment]::NewLine, $script:Utf8NoBom)
457
+ } catch { }
458
+ }
459
+ }
460
+
461
+ function Show-Usage {
462
+ Write-Host "Git resume clone for Windows / PowerShell 5.1+"
463
+ Write-Host ""
464
+ Write-Host "Usage:"
465
+ Write-Host " .\git-clone-resume.ps1 <repo-url> [options]"
466
+ Write-Host " git-clone-resume.cmd <repo-url> [options]"
467
+ Write-Host ""
468
+ Write-Host "Options:"
469
+ Write-Host " -OutDir <dir> Local directory (default: from URL)"
470
+ Write-Host " -Ref <branch/tag/sha> Default: remote HEAD"
471
+ Write-Host " -BatchSize <N> Files per batch, default 32"
472
+ Write-Host " -MaxRetries <N> Retries per failed batch/file, default 8"
473
+ Write-Host " -Include a,b Only download matching paths"
474
+ Write-Host " -Exclude a,b Skip matching paths"
475
+ Write-Host " -Depth <N> Optional shallow clone depth"
476
+ Write-Host " -Verify Hash-check local files on resume"
477
+ Write-Host " -ForceRefetch Always fetch the target ref"
478
+ Write-Host " -DryRun List files, do not checkout blobs"
479
+ Write-Host " -Tui Force fullscreen TUI"
480
+ Write-Host " -NoTui Disable TUI (script/CI mode)"
481
+ Write-Host " -Language zh-CN|en-US User interface language"
482
+ Write-Host " -ResumeLast Resume the latest history entry"
483
+ Write-Host " -Help Show this help"
484
+ Write-Host ""
485
+ Write-Host "Interactive: run with no URL to open the TUI wizard."
486
+ Write-Host "Resume: run the same command again. State is in .git/partial-resume/"
487
+ Write-Host "Keys: Q stop P pause F failures ? help Ctrl+C twice to kill git"
488
+ }
489
+
490
+ if ($Help) {
491
+ Show-Usage
492
+ exit 0
493
+ }
494
+
495
+ function Get-RepoFolderName {
496
+ param([string]$Url)
497
+ $s = $Url.Trim().TrimEnd([char]47, [char]92)
498
+ if ($s.Length -ge 4 -and $s.EndsWith(".git", [System.StringComparison]::OrdinalIgnoreCase)) {
499
+ $s = $s.Substring(0, $s.Length - 4)
500
+ }
501
+ $s = $s.Replace([char]92, [char]47)
502
+ $i = $s.LastIndexOf([char]47)
503
+ if ($i -ge 0) { $s = $s.Substring($i + 1) }
504
+ $colon = $s.LastIndexOf([char]58)
505
+ if ($colon -ge 0) { $s = $s.Substring($colon + 1) }
506
+ if ([string]::IsNullOrWhiteSpace($s)) { return "repo" }
507
+ return $s
508
+ }
509
+
510
+ function Convert-ToFullPath {
511
+ param([string]$Path)
512
+ if ([System.IO.Path]::IsPathRooted($Path)) {
513
+ return [System.IO.Path]::GetFullPath($Path)
514
+ }
515
+ return [System.IO.Path]::GetFullPath((Join-Path (Get-Location).Path $Path))
516
+ }
517
+
518
+ function Get-WorktreePath {
519
+ param([string]$Root, [string]$Rel)
520
+ $acc = $Root
521
+ foreach ($p in ($Rel -split "[\\/]")) {
522
+ if ([string]::IsNullOrEmpty($p) -or $p -eq ".") { continue }
523
+ $acc = Join-Path $acc $p
524
+ }
525
+ return $acc
526
+ }
527
+
528
+ function Test-GitAvailable {
529
+ try {
530
+ $null = Get-Command git -ErrorAction Stop
531
+ } catch {
532
+ throw "未找到 git。请先安装 Git for Windows: https://git-scm.com/download/win"
533
+ }
534
+ $verText = (& git --version 2>$null | Out-String).Trim()
535
+ Write-Log "使用 $verText" "INFO"
536
+ if ($verText -match "git version (\d+)\.(\d+)") {
537
+ $major = [int]$Matches[1]
538
+ $minor = [int]$Matches[2]
539
+ if ($major -lt 2 -or ($major -eq 2 -and $minor -lt 19)) {
540
+ Write-Log "partial clone 需要 Git >= 2.19,当前: $verText 。将继续尝试,失败请升级 Git。" "WARN"
541
+ }
542
+ }
543
+ }
544
+
545
+ function Get-GitExePath {
546
+ $cmd = Get-Command git -ErrorAction Stop
547
+ if ($cmd.Source) { return $cmd.Source }
548
+ if ($cmd.Path) { return $cmd.Path }
549
+ return "git"
550
+ }
551
+
552
+ function Convert-ToGitArgumentString {
553
+ param([string[]]$GitArgs)
554
+ $quoted = New-Object System.Text.StringBuilder
555
+ foreach ($a in $GitArgs) {
556
+ if ($null -eq $a) { continue }
557
+ if ($quoted.Length -gt 0) { [void]$quoted.Append(" ") }
558
+ $needsQuote = ($a -match "\s") -or ($a -match '"') -or ($a.Length -eq 0)
559
+ if ($needsQuote) {
560
+ $escaped = $a.Replace([string][char]34, [string][char]92 + [string][char]34)
561
+ [void]$quoted.Append('"').Append($escaped).Append('"')
562
+ } else {
563
+ [void]$quoted.Append($a)
564
+ }
565
+ }
566
+ return $quoted.ToString()
567
+ }
568
+
569
+ function Invoke-GitProcess {
570
+ param(
571
+ [Parameter(Mandatory = $true)][string[]]$GitArgs,
572
+ [string]$WorkDir,
573
+ [int]$TimeoutMs = 0,
574
+ [switch]$ExpectFail,
575
+ [switch]$InheritConsole,
576
+ [string]$Heartbeat
577
+ )
578
+ if (-not $script:GitExe) { $script:GitExe = Get-GitExePath }
579
+
580
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
581
+ $psi.FileName = $script:GitExe
582
+ $psi.UseShellExecute = $false
583
+ $psi.CreateNoWindow = -not $InheritConsole
584
+ $psi.RedirectStandardInput = $true
585
+ if ($InheritConsole) {
586
+ $psi.RedirectStandardOutput = $false
587
+ $psi.RedirectStandardError = $false
588
+ } else {
589
+ $psi.RedirectStandardOutput = $true
590
+ $psi.RedirectStandardError = $true
591
+ $psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
592
+ $psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
593
+ }
594
+ if ($WorkDir) { $psi.WorkingDirectory = $WorkDir }
595
+ $psi.Arguments = Convert-ToGitArgumentString -GitArgs $GitArgs
596
+
597
+ $proc = New-Object System.Diagnostics.Process
598
+ $proc.StartInfo = $psi
599
+ $stdout = ""
600
+ $stderr = ""
601
+ $script:GcrCurrentProc = $proc
602
+ try {
603
+ [void]$proc.Start()
604
+ $proc.StandardInput.Close()
605
+ $waitSlice = 80
606
+ if (-not (Test-GcrTuiActive)) { $waitSlice = 500 }
607
+ $waited = 0
608
+ $hbSec = 2
609
+ $useStream = (Test-GcrTuiActive) -and (-not $InheritConsole)
610
+ $stdoutTask = $null
611
+ $stderrTask = $null
612
+ $outBuf = $null
613
+ $errBuf = $null
614
+ $outCarry = $null
615
+ $errCarry = $null
616
+ $outRead = $null
617
+ $errRead = $null
618
+ $stdoutSb = New-Object System.Text.StringBuilder
619
+ $stderrSb = New-Object System.Text.StringBuilder
620
+ if ($useStream) {
621
+ $outBuf = New-Object byte[] 4096
622
+ $errBuf = New-Object byte[] 4096
623
+ $outCarry = New-Object System.Text.StringBuilder
624
+ $errCarry = New-Object System.Text.StringBuilder
625
+ $outRead = $proc.StandardOutput.BaseStream.ReadAsync($outBuf, 0, $outBuf.Length)
626
+ $errRead = $proc.StandardError.BaseStream.ReadAsync($errBuf, 0, $errBuf.Length)
627
+ } elseif (-not $InheritConsole) {
628
+ $stdoutTask = $proc.StandardOutput.ReadToEndAsync()
629
+ $stderrTask = $proc.StandardError.ReadToEndAsync()
630
+ }
631
+ while (-not $proc.HasExited) {
632
+ if ($useStream) {
633
+ if ($null -ne $outRead -and $outRead.IsCompleted) {
634
+ $n = 0
635
+ try { $n = [int]$outRead.Result } catch { $n = 0 }
636
+ if ($n -gt 0) {
637
+ $chunk = [System.Text.Encoding]::UTF8.GetString($outBuf, 0, $n)
638
+ [void]$stdoutSb.Append($chunk)
639
+ $outRead = $proc.StandardOutput.BaseStream.ReadAsync($outBuf, 0, $outBuf.Length)
640
+ } else { $outRead = $null }
641
+ }
642
+ if ($null -ne $errRead -and $errRead.IsCompleted) {
643
+ $n = 0
644
+ try { $n = [int]$errRead.Result } catch { $n = 0 }
645
+ if ($n -gt 0) {
646
+ [void]$stderrSb.Append([System.Text.Encoding]::UTF8.GetString($errBuf, 0, $n))
647
+ Receive-GcrGitBytes -Buffer $errBuf -Count $n -Carry $errCarry -IsStdErr
648
+ $errRead = $proc.StandardError.BaseStream.ReadAsync($errBuf, 0, $errBuf.Length)
649
+ } else { $errRead = $null }
650
+ }
651
+ }
652
+ if (-not $proc.WaitForExit($waitSlice)) {
653
+ $waited += $waitSlice
654
+ if ($TimeoutMs -gt 0 -and $waited -ge $TimeoutMs) {
655
+ try { $proc.Kill() } catch { }
656
+ throw "git 命令超时 (" + $TimeoutMs + "ms): git " + $psi.Arguments
657
+ }
658
+ if ($Heartbeat -and ($waited % ($hbSec * 1000) -lt $waitSlice)) {
659
+ $sec = [int]($waited / 1000)
660
+ if (Test-GcrTuiActive) {
661
+ Set-GcrTuiPhase -Detail ($Heartbeat + " ... " + $sec + "s")
662
+ } else {
663
+ Write-Host ("`r[WAIT] " + $Heartbeat + " ... " + $sec + "s ") -NoNewline
664
+ }
665
+ }
666
+ }
667
+ if (Test-GcrTuiActive) {
668
+ Invoke-GcrTuiTick
669
+ if (Test-GcrTuiForceQuit) {
670
+ try { $proc.Kill() } catch { }
671
+ $script:GcrUserStop = $true
672
+ throw "已由用户停止。"
673
+ }
674
+ }
675
+ }
676
+ if ($Heartbeat -and -not (Test-GcrTuiActive)) { Write-Host "" }
677
+ if ($useStream) {
678
+ if ($null -ne $outRead) {
679
+ try {
680
+ $n = [int]$outRead.Result
681
+ if ($n -gt 0) { [void]$stdoutSb.Append([System.Text.Encoding]::UTF8.GetString($outBuf, 0, $n)) }
682
+ } catch { }
683
+ }
684
+ if ($null -ne $errRead) {
685
+ try {
686
+ $n = [int]$errRead.Result
687
+ if ($n -gt 0) {
688
+ [void]$stderrSb.Append([System.Text.Encoding]::UTF8.GetString($errBuf, 0, $n))
689
+ Receive-GcrGitBytes -Buffer $errBuf -Count $n -Carry $errCarry -IsStdErr
690
+ }
691
+ } catch { }
692
+ }
693
+ if ($errCarry -and $errCarry.Length -gt 0) { Add-GcrGitOutput -Text $errCarry.ToString() }
694
+ $stdout = $stdoutSb.ToString()
695
+ $stderr = $stderrSb.ToString()
696
+ } elseif (-not $InheritConsole) {
697
+ [void]$stdoutTask.Wait()
698
+ [void]$stderrTask.Wait()
699
+ $stdout = $stdoutTask.Result
700
+ $stderr = $stderrTask.Result
701
+ }
702
+ if ($script:GcrUserStop) { throw "已由用户停止。" }
703
+ $code = $proc.ExitCode
704
+ } finally {
705
+ $script:GcrCurrentProc = $null
706
+ $proc.Dispose()
707
+ }
708
+
709
+ if ($null -eq $stdout) { $stdout = "" }
710
+ if ($null -eq $stderr) { $stderr = "" }
711
+
712
+ if (-not $ExpectFail -and $code -ne 0) {
713
+ $err = $stderr
714
+ if ([string]::IsNullOrWhiteSpace($err)) { $err = $stdout }
715
+ $msg = "git 失败 (exit $code): git " + $psi.Arguments
716
+ if (-not [string]::IsNullOrWhiteSpace($err)) { $msg = $msg + [Environment]::NewLine + $err.Trim() }
717
+ throw $msg
718
+ }
719
+ return [pscustomobject]@{
720
+ ExitCode = $code
721
+ StdOut = $stdout
722
+ StdErr = $stderr
723
+ Args = $GitArgs
724
+ }
725
+ }
726
+
727
+ function Invoke-Git {
728
+ param(
729
+ [Parameter(Mandatory = $true)][string[]]$GitArgs,
730
+ [string]$WorkDir
731
+ )
732
+ $r = Invoke-GitProcess -GitArgs $GitArgs -WorkDir $WorkDir
733
+ return $r.StdOut
734
+ }
735
+
736
+ function Clear-StaleIndexLock {
737
+ param([string]$RepoRoot)
738
+ if (-not $RepoRoot) { return }
739
+ $lock = Join-Path $RepoRoot ".git\index.lock"
740
+ if (Test-Path -LiteralPath $lock) {
741
+ $age = (Get-Date) - (Get-Item -LiteralPath $lock).LastWriteTime
742
+ if ($age.TotalMinutes -ge 2) {
743
+ Write-Log ("删除过期 index.lock (" + [int]$age.TotalMinutes + " 分钟)") "WARN"
744
+ Remove-Item -LiteralPath $lock -Force -ErrorAction SilentlyContinue
745
+ } else {
746
+ Write-Log ("检测到 index.lock (" + [int]$age.TotalSeconds + "s)。若确认没有其它 git 进程,请手动删除: " + $lock) "WARN"
747
+ }
748
+ }
749
+ }
750
+
751
+ function Invoke-GitRetry {
752
+ param(
753
+ [Parameter(Mandatory = $true)][string[]]$GitArgs,
754
+ [string]$WorkDir,
755
+ [string]$What,
756
+ [int]$TimeoutMs = 0,
757
+ [switch]$InheritConsole,
758
+ [string]$Heartbeat,
759
+ [int]$Retries = -1
760
+ )
761
+ $attempt = 0
762
+ $delay = [Math]::Max(0, $RetryDelaySeconds)
763
+ $lastError = $null
764
+ $limit = $MaxRetries
765
+ if ($Retries -ge 0) { $limit = $Retries }
766
+ $limit = [Math]::Max(1, $limit)
767
+ while ($attempt -lt $limit) {
768
+ $attempt++
769
+ try {
770
+ $r = Invoke-GitProcess -GitArgs $GitArgs -WorkDir $WorkDir -TimeoutMs $TimeoutMs -ExpectFail -InheritConsole:$InheritConsole -Heartbeat $Heartbeat
771
+ if ($script:GcrUserStop) { throw "已由用户停止。" }
772
+ if ($r.ExitCode -eq 0) { return $r }
773
+ $lastError = "exit " + $r.ExitCode
774
+ $tail = $r.StdErr
775
+ if ([string]::IsNullOrWhiteSpace($tail)) { $tail = $r.StdOut }
776
+ if (-not [string]::IsNullOrWhiteSpace($tail)) { $lastError = $lastError + " : " + $tail.Trim() }
777
+ } catch {
778
+ if ($script:GcrUserStop) { throw }
779
+ $lastError = $_.Exception.Message
780
+ }
781
+ if ($attempt -ge $limit) { break }
782
+ Write-Log ($What + " 失败 (第 " + $attempt + "/" + $limit + " 次): " + $lastError + " ;" + $delay + "s 后重试") "WARN"
783
+ Start-Sleep -Seconds $delay
784
+ $delay = [Math]::Min(60, [Math]::Max(1, $delay * 2))
785
+ Clear-StaleIndexLock -RepoRoot $WorkDir
786
+ }
787
+ throw ($What + " 在 " + $limit + " 次重试后仍失败: " + $lastError)
788
+ }
789
+
790
+ function Save-TextFile {
791
+ param([string]$Path, [string]$Content)
792
+ $dir = Split-Path -Parent $Path
793
+ if ($dir -and -not (Test-Path -LiteralPath $dir)) {
794
+ New-Item -ItemType Directory -Path $dir -Force | Out-Null
795
+ }
796
+ [System.IO.File]::WriteAllText($Path, $Content, $script:Utf8NoBom)
797
+ }
798
+
799
+ function Read-Meta {
800
+ param([string]$MetaPath)
801
+ $map = @{}
802
+ if (-not (Test-Path -LiteralPath $MetaPath)) { return $map }
803
+ foreach ($line in [System.IO.File]::ReadAllLines($MetaPath, $script:Utf8NoBom)) {
804
+ $eq = $line.IndexOf("=")
805
+ if ($eq -lt 1) { continue }
806
+ if ($line.StartsWith("#")) { continue }
807
+ $k = $line.Substring(0, $eq).Trim()
808
+ $v = $line.Substring($eq + 1)
809
+ $map[$k] = $v
810
+ }
811
+ return $map
812
+ }
813
+
814
+ function Write-Meta {
815
+ param([string]$MetaPath, [hashtable]$Map)
816
+ $sb = New-Object System.Text.StringBuilder
817
+ [void]$sb.AppendLine("# git-clone-resume state")
818
+ foreach ($k in ($Map.Keys | Sort-Object)) {
819
+ [void]$sb.AppendLine($k + "=" + $Map[$k])
820
+ }
821
+ Save-TextFile -Path $MetaPath -Content $sb.ToString()
822
+ }
823
+
824
+ function Test-WildcardMatch {
825
+ param([string]$Path, [string[]]$Patterns)
826
+ if (-not $Patterns -or @($Patterns).Count -eq 0) { return $false }
827
+ $norm = $Path.Replace("\", "/")
828
+ foreach ($p in @($Patterns)) {
829
+ if ([string]::IsNullOrWhiteSpace($p)) { continue }
830
+ $pat = $p.Trim().Replace("\", "/")
831
+ if ($norm -like $pat) { return $true }
832
+ $prefix = $pat.TrimEnd("/")
833
+ if ($prefix -and $norm -like ($prefix + "/*")) { return $true }
834
+ }
835
+ return $false
836
+ }
837
+
838
+ function Initialize-PartialRepo {
839
+ param([string]$RepoRoot, [string]$Url)
840
+ $gitDir = Join-Path $RepoRoot ".git"
841
+ if (-not (Test-Path -LiteralPath $gitDir)) {
842
+ if (-not (Test-Path -LiteralPath $RepoRoot)) {
843
+ New-Item -ItemType Directory -Path $RepoRoot -Force | Out-Null
844
+ }
845
+ Write-Log "git init $RepoRoot" "STEP"
846
+ Invoke-GitRetry -What "git init" -WorkDir $RepoRoot -GitArgs @("init") | Out-Null
847
+ }
848
+
849
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "core.longpaths", "true") | Out-Null
850
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "core.quotepath", "false") | Out-Null
851
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "core.precomposeunicode", "true") | Out-Null
852
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "i18n.logOutputEncoding", "utf-8") | Out-Null
853
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "gc.auto", "0") | Out-Null
854
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "http.version", "HTTP/1.1") | Out-Null
855
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "http.postBuffer", "524288000") | Out-Null
856
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "http.lowSpeedLimit", "1024") | Out-Null
857
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "http.lowSpeedTime", "60") | Out-Null
858
+
859
+ $existingRemote = ""
860
+ $remoteProbe = Invoke-GitProcess -WorkDir $RepoRoot -ExpectFail -GitArgs @("remote", "get-url", "origin")
861
+ if ($remoteProbe.ExitCode -eq 0) { $existingRemote = $remoteProbe.StdOut.Trim() }
862
+
863
+ if ([string]::IsNullOrWhiteSpace($existingRemote)) {
864
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("remote", "add", "origin", $Url) | Out-Null
865
+ } else {
866
+ $a = $existingRemote.Trim().TrimEnd("/")
867
+ $b = $Url.Trim().TrimEnd("/")
868
+ if ($a -ne $b -and ($a + ".git") -ne $b -and $a -ne ($b + ".git")) {
869
+ Write-Log "已有 origin=$existingRemote ,与本次 URL 不同,改为 $Url" "WARN"
870
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("remote", "set-url", "origin", $Url) | Out-Null
871
+ }
872
+ }
873
+
874
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "remote.origin.promisor", "true") | Out-Null
875
+ Invoke-Git -WorkDir $RepoRoot -GitArgs @("config", "remote.origin.partialclonefilter", "blob:none") | Out-Null
876
+ }
877
+
878
+ function Set-DetachedHead {
879
+ param([string]$RepoRoot, [string]$Sha)
880
+ $headFile = Join-Path $RepoRoot ".git\HEAD"
881
+ Save-TextFile -Path $headFile -Content ($Sha + [Environment]::NewLine)
882
+ }
883
+
884
+ function Get-TreeEntries {
885
+ param([string]$RepoRoot, [string]$Sha)
886
+ # Write git stdout to a file as raw bytes. Capturing via StreamReader in PS 5.1
887
+ # can collapse the whole tree into one fake path.
888
+ # Do NOT use -l (blob:none would fetch every blob for sizes) or -z (NUL truncation).
889
+ $gitDir = Join-Path $RepoRoot ".git"
890
+ $stateDir = Join-Path $gitDir $script:StateDirName
891
+ if (-not (Test-Path -LiteralPath $stateDir)) {
892
+ New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
893
+ }
894
+ $outFile = Join-Path $stateDir "ls-tree.raw"
895
+ $errFile = Join-Path $stateDir "ls-tree.err"
896
+ if (Test-Path -LiteralPath $outFile) { Remove-Item -LiteralPath $outFile -Force }
897
+ if (Test-Path -LiteralPath $errFile) { Remove-Item -LiteralPath $errFile -Force }
898
+ if (-not $script:GitExe) { $script:GitExe = Get-GitExePath }
899
+
900
+ $arg = "-c core.quotepath=false ls-tree -r " + $Sha
901
+ $p = Start-Process -FilePath $script:GitExe -WorkingDirectory $RepoRoot `
902
+ -ArgumentList $arg `
903
+ -RedirectStandardOutput $outFile -RedirectStandardError $errFile `
904
+ -Wait -NoNewWindow -PassThru
905
+ if ($p.ExitCode -ne 0) {
906
+ $err = ""
907
+ if (Test-Path -LiteralPath $errFile) {
908
+ $err = [System.IO.File]::ReadAllText($errFile, $script:Utf8NoBom)
909
+ }
910
+ throw ("ls-tree failed (exit " + $p.ExitCode + "): " + $err.Trim())
911
+ }
912
+
913
+ $entries = New-Object System.Collections.Generic.List[object]
914
+ if (-not (Test-Path -LiteralPath $outFile)) { return ,$entries }
915
+ $bytes = [System.IO.File]::ReadAllBytes($outFile)
916
+ if ($null -eq $bytes -or $bytes.Length -eq 0) { return ,$entries }
917
+ $raw = [System.Text.Encoding]::UTF8.GetString($bytes)
918
+
919
+ foreach ($rec in $raw.Split(@([char]10), [System.StringSplitOptions]::None)) {
920
+ $rec = $rec.TrimEnd([char]13, [char]0)
921
+ if ([string]::IsNullOrWhiteSpace($rec)) { continue }
922
+ $tab = $rec.IndexOf([char]9)
923
+ if ($tab -lt 0) { continue }
924
+ $metaBits = $rec.Substring(0, $tab)
925
+ $path = $rec.Substring($tab + 1).TrimEnd()
926
+ $bits = @($metaBits -split "\s+", 3)
927
+ if ($bits.Count -lt 3) { continue }
928
+ if ([string]::IsNullOrWhiteSpace($path)) { continue }
929
+ [void]$entries.Add([pscustomobject]@{
930
+ Mode = $bits[0]
931
+ Type = $bits[1]
932
+ Blob = $bits[2]
933
+ Size = 0L
934
+ Path = $path
935
+ })
936
+ }
937
+ Write-Log ("ls-tree bytes=" + $bytes.Length + " files=" + $entries.Count) "INFO"
938
+ return ,$entries
939
+ }
940
+
941
+ function Get-LocalBlobHash {
942
+ param([string]$RepoRoot, [string]$RelPath)
943
+ $full = Get-WorktreePath -Root $RepoRoot -Rel $RelPath
944
+ if (-not (Test-Path -LiteralPath $full)) { return $null }
945
+ $r = Invoke-GitProcess -WorkDir $RepoRoot -ExpectFail -GitArgs @("hash-object", "--path", $RelPath, "--", $RelPath)
946
+ if ($r.ExitCode -eq 0) { return $r.StdOut.Trim() }
947
+ return $null
948
+ }
949
+
950
+ function Test-FileComplete {
951
+ param(
952
+ [string]$RepoRoot,
953
+ $Entry,
954
+ [switch]$HashVerify
955
+ )
956
+ $full = Get-WorktreePath -Root $RepoRoot -Rel $Entry.Path
957
+ if (-not (Test-Path -LiteralPath $full)) { return $false }
958
+ $item = Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
959
+ if (-not $item -or $item.PSIsContainer) { return $false }
960
+
961
+ if ($HashVerify) {
962
+ $h = Get-LocalBlobHash -RepoRoot $RepoRoot -RelPath $Entry.Path
963
+ return ($h -eq $Entry.Blob)
964
+ }
965
+ # Do not compare raw byte length to ls-tree size: core.autocrlf on Windows
966
+ # makes working-tree files larger than the blob.
967
+ return $true
968
+ }
969
+
970
+ function Split-Batches {
971
+ param(
972
+ [object[]]$Items,
973
+ [int]$MaxCount,
974
+ [int]$MaxChars
975
+ )
976
+ $batches = New-Object System.Collections.Generic.List[object]
977
+ $cur = New-Object System.Collections.Generic.List[object]
978
+ $chars = 0
979
+ foreach ($it in $Items) {
980
+ $add = $it.Path.Length + 3
981
+ if ($cur.Count -gt 0 -and (($cur.Count -ge $MaxCount) -or ($chars + $add -gt $MaxChars))) {
982
+ [void]$batches.Add((New-BatchCopy -Items $cur))
983
+ $cur = New-Object System.Collections.Generic.List[object]
984
+ $chars = 0
985
+ }
986
+ [void]$cur.Add($it)
987
+ $chars += $add
988
+ }
989
+ if ($cur.Count -gt 0) {
990
+ [void]$batches.Add((New-BatchCopy -Items $cur))
991
+ }
992
+ return ,$batches
993
+ }
994
+
995
+ function New-BatchCopy {
996
+ param($Items)
997
+ $copy = New-Object System.Collections.Generic.List[object]
998
+ foreach ($it in $Items) { [void]$copy.Add($it) }
999
+ return ,$copy
1000
+ }
1001
+
1002
+ function Format-Bytes {
1003
+ param([int64]$n)
1004
+ if ($n -lt 1024) { return "$n B" }
1005
+ if ($n -lt 1MB) { return ("{0:N1} KB" -f ($n / 1KB)) }
1006
+ if ($n -lt 1GB) { return ("{0:N1} MB" -f ($n / 1MB)) }
1007
+ return ("{0:N2} GB" -f ($n / 1GB))
1008
+ }
1009
+
1010
+ function Add-DonePaths {
1011
+ param([string]$DonePath, [string[]]$Paths)
1012
+ if (-not $Paths -or @($Paths).Count -eq 0) { return }
1013
+ $text = (@($Paths) -join [Environment]::NewLine) + [Environment]::NewLine
1014
+ [System.IO.File]::AppendAllText($DonePath, $text, $script:Utf8NoBom)
1015
+ }
1016
+
1017
+ function Add-FailedPath {
1018
+ param([string]$FailedPath, [string]$RelPath, [string]$Reason)
1019
+ $safe = $Reason -replace "[\r\n]+", " "
1020
+ $line = (Get-Date -Format o) + [char]9 + $RelPath + [char]9 + $safe + [Environment]::NewLine
1021
+ [System.IO.File]::AppendAllText($FailedPath, $line, $script:Utf8NoBom)
1022
+ }
1023
+
1024
+ function Load-DoneSet {
1025
+ param([string]$DonePath)
1026
+ $set = New-Object "System.Collections.Generic.HashSet[string]" ([System.StringComparer]::Ordinal)
1027
+ if (Test-Path -LiteralPath $DonePath) {
1028
+ foreach ($line in [System.IO.File]::ReadAllLines($DonePath, $script:Utf8NoBom)) {
1029
+ $p = $line.Trim()
1030
+ if ($p) { [void]$set.Add($p) }
1031
+ }
1032
+ }
1033
+ # unary comma: stop PowerShell from enumerating the HashSet (empty => $null)
1034
+ return ,$set
1035
+ }
1036
+
1037
+ function Invoke-CheckoutBatch {
1038
+ param(
1039
+ [string]$RepoRoot,
1040
+ [string]$Sha,
1041
+ $Batch
1042
+ )
1043
+ $gitArgsList = New-Object System.Collections.Generic.List[string]
1044
+ foreach ($x in @("-c", "core.quotepath=false", "-c", "core.longpaths=true", "-c", "advice.detachedHead=false", "checkout", "--progress", $Sha, "--")) {
1045
+ [void]$gitArgsList.Add([string]$x)
1046
+ }
1047
+ $n = 0
1048
+ foreach ($e in $Batch) {
1049
+ [void]$gitArgsList.Add([string]$e.Path)
1050
+ $n++
1051
+ }
1052
+ if ($n -le 0) { return }
1053
+ Ensure-ParentDirectories -RepoRoot $RepoRoot -Batch $Batch
1054
+ $first = [string]$Batch[0].Path
1055
+ $hb = "downloading " + $n + " file(s), e.g. " + $first
1056
+ # Multi-file checkout: try once. Retrying the same 64-file batch on Windows
1057
+ # wastes minutes (sha1 missing + cannot create directory) and can desync the index.
1058
+ $tries = 1
1059
+ if ($n -eq 1) { $tries = $MaxRetries }
1060
+ Invoke-GitRetry -What ("checkout " + $n + " files") -WorkDir $RepoRoot -Heartbeat $hb -Retries $tries -GitArgs $gitArgsList.ToArray() | Out-Null
1061
+ }
1062
+
1063
+ function Ensure-ParentDirectories {
1064
+ param([string]$RepoRoot, $Batch)
1065
+ $seen = New-Object "System.Collections.Generic.HashSet[string]" ([System.StringComparer]::OrdinalIgnoreCase)
1066
+ foreach ($e in $Batch) {
1067
+ $rel = [string]$e.Path
1068
+ $slash = $rel.LastIndexOf([char]47)
1069
+ if ($slash -lt 1) { continue }
1070
+ $parentRel = $rel.Substring(0, $slash)
1071
+ if (-not $seen.Add($parentRel)) { continue }
1072
+ $full = Get-WorktreePath -Root $RepoRoot -Rel $parentRel
1073
+ if (Test-Path -LiteralPath $full -PathType Leaf) {
1074
+ Write-Log ("parent path is a file, removing so a directory can be created: " + $parentRel) "WARN"
1075
+ Remove-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
1076
+ }
1077
+ if (-not (Test-Path -LiteralPath $full)) {
1078
+ New-Item -ItemType Directory -Path $full -Force | Out-Null
1079
+ }
1080
+ }
1081
+ }
1082
+
1083
+ function Confirm-BatchFiles {
1084
+ param(
1085
+ [string]$RepoRoot,
1086
+ $Batch,
1087
+ [switch]$HashVerify
1088
+ )
1089
+ $ok = New-Object System.Collections.Generic.List[object]
1090
+ $bad = New-Object System.Collections.Generic.List[object]
1091
+ foreach ($e in $Batch) {
1092
+ if (Test-FileComplete -RepoRoot $RepoRoot -Entry $e -HashVerify:$HashVerify) {
1093
+ [void]$ok.Add($e)
1094
+ } else {
1095
+ [void]$bad.Add($e)
1096
+ }
1097
+ }
1098
+ return @{ Ok = $ok; Bad = $bad }
1099
+ }
1100
+
1101
+ function Repair-GitIndex {
1102
+ param([string]$RepoRoot, [string]$Sha)
1103
+ # Failed multi-path checkout on Windows can drop index entries while leaving
1104
+ # the files on disk (status: D + ??). Re-add existing files; re-checkout missing ones.
1105
+ $porcelain = Invoke-GitProcess -WorkDir $RepoRoot -ExpectFail -GitArgs @(
1106
+ "-c", "core.quotepath=false", "status", "--porcelain", "-uall"
1107
+ )
1108
+ if ($porcelain.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace($porcelain.StdOut)) { return }
1109
+ $add = New-Object System.Collections.Generic.List[string]
1110
+ $needCheckout = New-Object System.Collections.Generic.List[string]
1111
+ foreach ($line in ($porcelain.StdOut -split [char]10)) {
1112
+ $line = $line.TrimEnd([char]13)
1113
+ if ($line.Length -lt 4) { continue }
1114
+ $code = $line.Substring(0, 2)
1115
+ $path = $line.Substring(3).Trim()
1116
+ if ($path.StartsWith(".git/")) { continue }
1117
+ $full = Get-WorktreePath -Root $RepoRoot -Rel $path
1118
+ $exists = Test-Path -LiteralPath $full -PathType Leaf
1119
+ if ($code -eq "D " -or $code -eq " D" -or $code -eq "??") {
1120
+ if ($exists) { [void]$add.Add($path) } else { [void]$needCheckout.Add($path) }
1121
+ }
1122
+ }
1123
+ if ($add.Count -gt 0) {
1124
+ Write-Log ("repair index: git add " + $add.Count + " files that exist on disk") "WARN"
1125
+ $args = New-Object System.Collections.Generic.List[string]
1126
+ foreach ($x in @("-c", "core.quotepath=false", "add", "-f", "--")) { [void]$args.Add($x) }
1127
+ foreach ($p in $add) { [void]$args.Add($p) }
1128
+ Invoke-GitProcess -WorkDir $RepoRoot -ExpectFail -GitArgs $args.ToArray() | Out-Null
1129
+ }
1130
+ if ($needCheckout.Count -gt 0) {
1131
+ Write-Log ("repair index: re-checkout " + $needCheckout.Count + " missing files") "WARN"
1132
+ $args = New-Object System.Collections.Generic.List[string]
1133
+ foreach ($x in @("-c", "core.quotepath=false", "checkout", $Sha, "--")) { [void]$args.Add($x) }
1134
+ foreach ($p in $needCheckout) { [void]$args.Add($p) }
1135
+ Invoke-GitProcess -WorkDir $RepoRoot -ExpectFail -GitArgs $args.ToArray() | Out-Null
1136
+ }
1137
+ }
1138
+
1139
+ function Format-Eta {
1140
+ param([TimeSpan]$Elapsed, [int]$DoneThisRun, [int]$Remain)
1141
+ if ($Elapsed.TotalSeconds -lt 1 -or $DoneThisRun -le 0 -or $Remain -le 0) { return "--:--:--" }
1142
+ $rate = $DoneThisRun / $Elapsed.TotalSeconds
1143
+ if ($rate -le 0) { return "--:--:--" }
1144
+ $sec = [Math]::Min(864000, $Remain / $rate)
1145
+ $ts = [TimeSpan]::FromSeconds($sec)
1146
+ return ("{0:00}:{1:00}:{2:00}" -f [int]$ts.TotalHours, $ts.Minutes, $ts.Seconds)
1147
+ }
1148
+
1149
+ function Get-WorktreeBytes {
1150
+ param([string]$RepoRoot, [string]$RelPath)
1151
+ $full = Get-WorktreePath -Root $RepoRoot -Rel $RelPath
1152
+ if (-not (Test-Path -LiteralPath $full)) { return 0L }
1153
+ $item = Get-Item -LiteralPath $full -Force -ErrorAction SilentlyContinue
1154
+ if (-not $item -or $item.PSIsContainer) { return 0L }
1155
+ return [int64]$item.Length
1156
+ }
1157
+
1158
+ function Write-DownloadProgress {
1159
+ param(
1160
+ [int]$OkCount,
1161
+ [int]$TotalCount,
1162
+ [int]$FailCount,
1163
+ [int64]$DoneBytes,
1164
+ [TimeSpan]$Elapsed,
1165
+ [int]$DoneThisRun,
1166
+ [string]$CurrentFile,
1167
+ [int]$BarWidth = 28
1168
+ )
1169
+ $pct = 0.0
1170
+ if ($TotalCount -gt 0) { $pct = 100.0 * $OkCount / $TotalCount }
1171
+ $filled = 0
1172
+ if ($TotalCount -gt 0) {
1173
+ $filled = [int][Math]::Round($BarWidth * $OkCount / $TotalCount)
1174
+ if ($filled -gt $BarWidth) { $filled = $BarWidth }
1175
+ }
1176
+ $empty = $BarWidth - $filled
1177
+ $bar = ("#" * $filled) + ("-" * $empty)
1178
+ $remain = $TotalCount - $OkCount - $FailCount
1179
+ $eta = Format-Eta -Elapsed $Elapsed -DoneThisRun $DoneThisRun -Remain $remain
1180
+ $rate = 0.0
1181
+ if ($Elapsed.TotalSeconds -gt 0.5 -and $DoneThisRun -gt 0) {
1182
+ $rate = $DoneThisRun / $Elapsed.TotalSeconds
1183
+ }
1184
+ $name = [string]$CurrentFile
1185
+ if ($name.Length -gt 48) { $name = "..." + $name.Substring($name.Length - 45) }
1186
+ $line = ("[{0}] {1,5:N1}% {2}/{3} fail {4} {5} {6:N1} files/s ETA {7} {8}" -f @(
1187
+ $bar, $pct, $OkCount, $TotalCount, $FailCount, (Format-Bytes $DoneBytes), $rate, $eta, $name
1188
+ ))
1189
+ $width = 120
1190
+ try {
1191
+ $w = [int]$Host.UI.RawUI.WindowSize.Width
1192
+ if ($w -gt 20) { $width = $w - 1 }
1193
+ } catch { }
1194
+ $out = $line
1195
+ if ($out.Length -lt $width) { $out = $out.PadRight($width) }
1196
+ elseif ($out.Length -gt $width) { $out = $out.Substring(0, $width) }
1197
+ if (Test-GcrTuiActive) {
1198
+ Update-GcrTuiProgress -OkCount $OkCount -TotalCount $TotalCount -FailCount $FailCount -DoneBytes $DoneBytes -Rate $rate -Eta $eta -CurrentFile $CurrentFile
1199
+ Invoke-GcrTuiTick
1200
+ } else {
1201
+ Write-Host ("`r" + $out) -NoNewline
1202
+ Write-Progress -Activity "git-clone-resume" -Status $line -PercentComplete ([Math]::Min(100, [int]$pct))
1203
+ }
1204
+ }
1205
+
1206
+ function Assert-GcrContinue {
1207
+ if (-not (Get-Command Test-GcrTuiActive -ErrorAction SilentlyContinue)) { return }
1208
+ if (-not (Test-GcrTuiActive)) { return }
1209
+ Invoke-GcrTuiTick
1210
+ Wait-GcrTuiPaused
1211
+ Invoke-GcrTuiTick
1212
+ if (Test-GcrTuiQuit) {
1213
+ $script:GcrUserStop = $true
1214
+ throw "已由用户停止。再次运行同一命令即可续传。"
1215
+ }
1216
+ }
1217
+
1218
+ $script:GcrInteractive = $false
1219
+ try { $script:GcrInteractive = [Environment]::UserInteractive } catch { }
1220
+ if ($NoTui) {
1221
+ $script:GcrTuiWanted = $false
1222
+ } elseif ($Tui) {
1223
+ $script:GcrTuiWanted = $true
1224
+ } elseif ($script:GcrInteractive -and (Get-Command Test-GcrTuiAvailable -ErrorAction SilentlyContinue) -and (Test-GcrTuiAvailable)) {
1225
+ $script:GcrTuiWanted = $true
1226
+ }
1227
+
1228
+ if ($ResumeLast) {
1229
+ if (-not (Get-Command Get-GcrHistoryLast -ErrorAction SilentlyContinue)) {
1230
+ Write-Host (Convert-GcrText "错误: 无法读取历史记录。") -ForegroundColor Red
1231
+ exit 2
1232
+ }
1233
+ $last = Get-GcrHistoryLast
1234
+ if ($null -eq $last) {
1235
+ Write-Host (Convert-GcrText "错误: 没有可恢复的历史记录。请先启动过一次克隆。") -ForegroundColor Red
1236
+ exit 2
1237
+ }
1238
+ if ([string]::IsNullOrWhiteSpace($RepoUrl) -and $last.url) { $RepoUrl = [string]$last.url }
1239
+ if ([string]::IsNullOrWhiteSpace($OutDir) -and $last.outDir) { $OutDir = [string]$last.outDir }
1240
+ if (($Ref -eq "HEAD" -or [string]::IsNullOrWhiteSpace($Ref)) -and $last.ref) { $Ref = [string]$last.ref }
1241
+ }
1242
+
1243
+ if ([string]::IsNullOrWhiteSpace($RepoUrl)) {
1244
+ if ($NoTui -or -not $script:GcrInteractive) {
1245
+ Show-Usage
1246
+ Write-Host (Convert-GcrText "错误: 必须提供仓库 URL。") -ForegroundColor Red
1247
+ exit 2
1248
+ }
1249
+ $defaults = @{
1250
+ RepoUrl = $RepoUrl
1251
+ OutDir = $OutDir
1252
+ Ref = $Ref
1253
+ BatchSize = $BatchSize
1254
+ MaxRetries = $MaxRetries
1255
+ Include = $Include
1256
+ Exclude = $Exclude
1257
+ Verify = [bool]$Verify
1258
+ ForceRefetch = [bool]$ForceRefetch
1259
+ DryRun = [bool]$DryRun
1260
+ }
1261
+ if ($PSBoundParameters.ContainsKey("Depth")) { $defaults["Depth"] = $Depth }
1262
+ if (-not (Get-Command Show-GcrInteractiveSetup -ErrorAction SilentlyContinue)) {
1263
+ Show-Usage
1264
+ Write-Host (Convert-GcrText "错误: 必须提供仓库 URL。") -ForegroundColor Red
1265
+ exit 2
1266
+ }
1267
+ $wiz = $null
1268
+ try {
1269
+ $wiz = Show-GcrInteractiveSetup -Defaults $defaults
1270
+ if (Get-Command ConvertFrom-GcrWizardOutput -ErrorAction SilentlyContinue) {
1271
+ $unwrapped = ConvertFrom-GcrWizardOutput $wiz
1272
+ if ($null -ne $unwrapped) { $wiz = $unwrapped }
1273
+ }
1274
+ } catch {
1275
+ if (Get-Command Close-GcrTui -ErrorAction SilentlyContinue) { Close-GcrTui }
1276
+ Write-Host ("向导失败: " + $_.Exception.Message) -ForegroundColor Red
1277
+ exit 1
1278
+ }
1279
+ if ($null -eq $wiz) {
1280
+ if (Get-Command Close-GcrTui -ErrorAction SilentlyContinue) { Close-GcrTui }
1281
+ exit 0
1282
+ }
1283
+ try {
1284
+ $RepoUrl = [string]$wiz.RepoUrl
1285
+ if ($wiz.Language -eq "en-US" -or $wiz.Language -eq "zh-CN") {
1286
+ Set-GcrLanguage -Language ([string]$wiz.Language)
1287
+ }
1288
+ if ($wiz.OutDir) { $OutDir = [string]$wiz.OutDir }
1289
+ if ($wiz.Ref) { $Ref = [string]$wiz.Ref }
1290
+ if ($wiz.BatchSize) { $BatchSize = [int]$wiz.BatchSize }
1291
+ if ($wiz.MaxRetries) { $MaxRetries = [int]$wiz.MaxRetries }
1292
+ if ($null -ne $wiz.Include) { $Include = @($wiz.Include | Where-Object { $_ }) }
1293
+ if ($null -ne $wiz.Exclude) { $Exclude = @($wiz.Exclude | Where-Object { $_ }) }
1294
+ $Verify = [bool]$wiz.Verify
1295
+ $ForceRefetch = [bool]$wiz.ForceRefetch
1296
+ $DryRun = [bool]$wiz.DryRun
1297
+ if ($null -ne $wiz.Depth -and [string]$wiz.Depth -ne "") {
1298
+ $Depth = [int]$wiz.Depth
1299
+ $PSBoundParameters["Depth"] = $Depth
1300
+ }
1301
+ } catch {
1302
+ if (Test-GcrTuiActive) {
1303
+ Show-GcrTuiResult -Title "无法开始克隆" -Body @($_.Exception.Message) -Kind error
1304
+ Close-GcrTui
1305
+ } else {
1306
+ Write-Host ("无法开始克隆: " + $_.Exception.Message) -ForegroundColor Red
1307
+ }
1308
+ exit 1
1309
+ }
1310
+ }
1311
+
1312
+ if ([string]::IsNullOrWhiteSpace($RepoUrl)) {
1313
+ if (Get-Command Close-GcrTui -ErrorAction SilentlyContinue) { Close-GcrTui }
1314
+ Show-Usage
1315
+ Write-Host (Convert-GcrText "错误: 必须提供仓库 URL。") -ForegroundColor Red
1316
+ exit 2
1317
+ }
1318
+
1319
+ if ($script:GcrTuiWanted -and (Get-Command Initialize-GcrTui -ErrorAction SilentlyContinue)) {
1320
+ if (-not (Test-GcrTuiActive)) { [void](Initialize-GcrTui) }
1321
+ if ($Tui -and -not (Test-GcrTuiActive)) {
1322
+ Write-Host (Convert-GcrText "当前终端无法进入全屏 TUI,改用日志模式。Windows Terminal 下再试,或去掉 -Tui。") -ForegroundColor Yellow
1323
+ }
1324
+ }
1325
+
1326
+ try {
1327
+ if (-not $OutDir) { $OutDir = Get-RepoFolderName -Url $RepoUrl }
1328
+ $repoRoot = Convert-ToFullPath -Path $OutDir
1329
+ $script:RepoRoot = $repoRoot
1330
+
1331
+ if (Test-GcrTuiActive) {
1332
+ $resumeHint = Test-Path -LiteralPath (Join-Path $repoRoot ".git\$($script:StateDirName)\meta.txt")
1333
+ Set-GcrTuiRepo -Url $RepoUrl -OutDir $repoRoot -Ref $Ref -Resume:$resumeHint
1334
+ Set-GcrTuiPhase -Name "init" -Detail ""
1335
+ if (Get-Command Save-GcrHistory -ErrorAction SilentlyContinue) {
1336
+ Save-GcrHistory -Url $RepoUrl -OutDir $repoRoot -Ref $Ref -Status "running"
1337
+ }
1338
+ Invoke-GcrTuiTick -Force
1339
+ }
1340
+
1341
+ Test-GitAvailable
1342
+ $script:GitExe = Get-GitExePath
1343
+
1344
+ Write-Log "仓库: $RepoUrl" "STEP"
1345
+ Write-Log "目录: $repoRoot" "INFO"
1346
+ Write-Log "引用: $Ref" "INFO"
1347
+
1348
+ if (-not (Test-Path -LiteralPath $repoRoot)) {
1349
+ New-Item -ItemType Directory -Path $repoRoot -Force | Out-Null
1350
+ }
1351
+ Initialize-PartialRepo -RepoRoot $repoRoot -Url $RepoUrl
1352
+
1353
+ $gitDir = Join-Path $repoRoot ".git"
1354
+ $stateDir = Join-Path $gitDir $script:StateDirName
1355
+ if (-not (Test-Path -LiteralPath $stateDir)) {
1356
+ New-Item -ItemType Directory -Path $stateDir -Force | Out-Null
1357
+ }
1358
+ $metaPath = Join-Path $stateDir "meta.txt"
1359
+ $listPath = Join-Path $stateDir "files.tsv"
1360
+ $donePath = Join-Path $stateDir "done.txt"
1361
+ $failedPath = Join-Path $stateDir "failed.txt"
1362
+ $script:LogFile = Join-Path $stateDir "log.txt"
1363
+
1364
+ Clear-StaleIndexLock -RepoRoot $repoRoot
1365
+
1366
+ $fetchArgs = @(
1367
+ "-c", "http.version=HTTP/1.1",
1368
+ "fetch", "--filter=blob:none", "--progress", "--no-recurse-submodules"
1369
+ )
1370
+ if ($PSBoundParameters.ContainsKey("Depth")) {
1371
+ $fetchArgs += @("--depth", [string]$Depth)
1372
+ }
1373
+ $fetchArgs += @("origin", $Ref)
1374
+
1375
+ $needFetch = $true
1376
+ $pinnedSha = $null
1377
+ $meta = Read-Meta -MetaPath $metaPath
1378
+ if (-not $ForceRefetch -and $meta.ContainsKey("commit") -and $meta["ref"] -eq $Ref) {
1379
+ $trySha = $meta["commit"]
1380
+ $chk = Invoke-GitProcess -WorkDir $repoRoot -ExpectFail -GitArgs @("cat-file", "-t", $trySha)
1381
+ if ($chk.ExitCode -eq 0 -and $chk.StdOut.Trim() -eq "commit") {
1382
+ $needFetch = $false
1383
+ $pinnedSha = $trySha
1384
+ $short = $trySha.Substring(0, [Math]::Min(12, $trySha.Length))
1385
+ Write-Log "本地已有 commit $short ,跳过 fetch(需要更新请加 -ForceRefetch)" "OK"
1386
+ }
1387
+ }
1388
+
1389
+ if ($needFetch) {
1390
+ Write-Log "fetch 元数据: git fetch --filter=blob:none origin $Ref" "STEP"
1391
+ Set-GcrTuiPhase -Name "fetch" -Detail ("origin " + $Ref)
1392
+ Assert-GcrContinue
1393
+ $inheritFetch = -not (Test-GcrTuiActive)
1394
+ Invoke-GitRetry -What "git fetch" -WorkDir $repoRoot -GitArgs $fetchArgs -InheritConsole:$inheritFetch | Out-Null
1395
+ $rev = Invoke-GitRetry -What "rev-parse FETCH_HEAD" -WorkDir $repoRoot -GitArgs @("rev-parse", "FETCH_HEAD")
1396
+ $pinnedSha = $rev.StdOut.Trim()
1397
+ if ([string]::IsNullOrWhiteSpace($pinnedSha)) {
1398
+ throw "无法解析 FETCH_HEAD,fetch 可能失败。"
1399
+ }
1400
+ }
1401
+
1402
+ $type = (Invoke-Git -WorkDir $repoRoot -GitArgs @("cat-file", "-t", $pinnedSha)).Trim()
1403
+ if ($type -ne "commit") {
1404
+ throw "目标 $Ref 解析为 $type ($pinnedSha),需要 commit。"
1405
+ }
1406
+ Set-DetachedHead -RepoRoot $repoRoot -Sha $pinnedSha
1407
+ Write-Log "目标 commit: $pinnedSha" "OK"
1408
+ Set-GcrTuiRepo -Commit $pinnedSha
1409
+
1410
+ Write-Meta -MetaPath $metaPath -Map @{
1411
+ url = $RepoUrl
1412
+ ref = $Ref
1413
+ commit = $pinnedSha
1414
+ batch = [string]$BatchSize
1415
+ updated = (Get-Date -Format o)
1416
+ hostname = [string]$env:COMPUTERNAME
1417
+ }
1418
+
1419
+ Write-Log "枚举文件树 git ls-tree -r $pinnedSha" "STEP"
1420
+ Set-GcrTuiPhase -Name "list" -Detail $pinnedSha
1421
+ Assert-GcrContinue
1422
+ $all = Get-TreeEntries -RepoRoot $repoRoot -Sha $pinnedSha
1423
+ if ($null -eq $all) { $all = New-Object System.Collections.Generic.List[object] }
1424
+ Write-Log ("树中条目: " + $all.Count) "INFO"
1425
+
1426
+ $skippedSubmodule = 0
1427
+ $filtered = New-Object System.Collections.Generic.List[object]
1428
+ $includeArr = @($Include | Where-Object { $_ })
1429
+ $excludeArr = @($Exclude | Where-Object { $_ })
1430
+ foreach ($e in $all) {
1431
+ if ($e.Type -eq "commit" -or $e.Mode -eq "160000") {
1432
+ $skippedSubmodule++
1433
+ continue
1434
+ }
1435
+ if ($e.Type -ne "blob") { continue }
1436
+ if ($includeArr.Count -gt 0 -and -not (Test-WildcardMatch -Path $e.Path -Patterns $includeArr)) { continue }
1437
+ if ($excludeArr.Count -gt 0 -and (Test-WildcardMatch -Path $e.Path -Patterns $excludeArr)) { continue }
1438
+ [void]$filtered.Add($e)
1439
+ }
1440
+ if ($skippedSubmodule -gt 0) {
1441
+ Write-Log "跳过 $skippedSubmodule 个子模块(gitlink)。需要的话请在对应目录单独再跑本脚本。" "WARN"
1442
+ }
1443
+
1444
+ Write-Log ("待处理文件: " + $filtered.Count + " (blob 体积在 checkout 后统计,避免 ls-tree -l 把全部 blob 拉下来)") "INFO"
1445
+
1446
+ $tsv = New-Object System.Text.StringBuilder
1447
+ [void]$tsv.AppendLine("mode" + [char]9 + "type" + [char]9 + "blob" + [char]9 + "size" + [char]9 + "path")
1448
+ foreach ($e in $filtered) {
1449
+ [void]$tsv.AppendLine($e.Mode + [char]9 + $e.Type + [char]9 + $e.Blob + [char]9 + $e.Size + [char]9 + $e.Path)
1450
+ }
1451
+ Save-TextFile -Path $listPath -Content $tsv.ToString()
1452
+
1453
+ $okCount = 0
1454
+ $failCount = 0
1455
+ $totalCount = $filtered.Count
1456
+ $doneBytes = 0L
1457
+ $skipDownload = $false
1458
+ $resultKind = "done"
1459
+ $resultTitle = ""
1460
+ $resultBody = New-Object System.Collections.Generic.List[string]
1461
+
1462
+ if ($DryRun) {
1463
+ Write-Log "DryRun 结束,清单: $listPath" "OK"
1464
+ $nshow = [Math]::Min(30, $filtered.Count)
1465
+ for ($i = 0; $i -lt $nshow; $i++) {
1466
+ $e = $filtered[$i]
1467
+ if (Test-GcrTuiActive) { Add-GcrTuiLog -Level "INFO" -Message $e.Path }
1468
+ else { Write-Host (" " + $e.Path) }
1469
+ }
1470
+ if ($filtered.Count -gt 30) {
1471
+ $more = (" ... 另有 {0} 个文件" -f ($filtered.Count - 30))
1472
+ if (Test-GcrTuiActive) { Add-GcrTuiLog -Level "INFO" -Message $more.Trim() }
1473
+ else { Write-Host $more }
1474
+ }
1475
+ $script:GcrExitCode = 0
1476
+ $skipDownload = $true
1477
+ $resultTitle = "DryRun 完成"
1478
+ [void]$resultBody.Add(("清单文件: " + $listPath))
1479
+ [void]$resultBody.Add(("文件数: " + $filtered.Count))
1480
+ [void]$resultBody.Add("未下载 blob。去掉 -DryRun 后开始/继续克隆。")
1481
+ }
1482
+
1483
+ if (-not $skipDownload) {
1484
+ Set-GcrTuiPhase -Name "scan" -Detail "检查工作区已有文件"
1485
+ $doneSet = Load-DoneSet -DonePath $donePath
1486
+ $pending = New-Object System.Collections.Generic.List[object]
1487
+ $skippedDone = 0
1488
+ $skippedExist = 0
1489
+ $reverify = [bool]$Verify
1490
+ $scanTotal = $filtered.Count
1491
+ $scanIndex = 0
1492
+ $scanStarted = Get-Date
1493
+
1494
+ foreach ($e in $filtered) {
1495
+ $scanIndex++
1496
+ if (($scanIndex % 200) -eq 0 -or $scanIndex -eq $scanTotal) {
1497
+ Write-DownloadProgress -OkCount $scanIndex -TotalCount $scanTotal -FailCount 0 -DoneBytes 0L -Elapsed ((Get-Date) - $scanStarted) -DoneThisRun $scanIndex -CurrentFile ("scan " + $e.Path)
1498
+ Assert-GcrContinue
1499
+ }
1500
+ $complete = Test-FileComplete -RepoRoot $repoRoot -Entry $e -HashVerify:$reverify
1501
+ if ($complete) {
1502
+ if ($doneSet.Contains($e.Path)) {
1503
+ $skippedDone++
1504
+ } else {
1505
+ [void]$doneSet.Add($e.Path)
1506
+ Add-DonePaths -DonePath $donePath -Paths @($e.Path)
1507
+ $skippedExist++
1508
+ }
1509
+ continue
1510
+ }
1511
+ if ($doneSet.Contains($e.Path)) {
1512
+ [void]$doneSet.Remove($e.Path)
1513
+ }
1514
+ [void]$pending.Add($e)
1515
+ }
1516
+ Write-GcrNewline
1517
+
1518
+ Write-Log ("进度: 已记录 " + $skippedDone + " ,工作区已存在 " + $skippedExist + " ,剩余 " + $pending.Count) "INFO"
1519
+
1520
+ $okCount = $skippedDone + $skippedExist
1521
+ $totalCount = $filtered.Count
1522
+ foreach ($e in $filtered) {
1523
+ if ($doneSet.Contains($e.Path)) { $doneBytes += (Get-WorktreeBytes -RepoRoot $repoRoot -RelPath $e.Path) }
1524
+ }
1525
+
1526
+ if ($pending.Count -eq 0) {
1527
+ Write-Log "全部文件已就绪。" "OK"
1528
+ Write-Log "工作区: $repoRoot" "OK"
1529
+ $script:GcrExitCode = 0
1530
+ $skipDownload = $true
1531
+ $resultTitle = "全部文件已就绪"
1532
+ [void]$resultBody.Add(("工作区: " + $repoRoot))
1533
+ [void]$resultBody.Add(("文件: {0}/{1}" -f $okCount, $totalCount))
1534
+ }
1535
+ }
1536
+
1537
+ if (-not $skipDownload) {
1538
+ $batches = Split-Batches -Items $pending.ToArray() -MaxCount $BatchSize -MaxChars $MaxArgChars
1539
+ Write-Log ("分 " + $batches.Count + " 批下载,每批最多 " + $BatchSize + " 个文件") "STEP"
1540
+ Set-GcrTuiPhase -Name "download" -Detail ("batch 1/" + $batches.Count)
1541
+ Assert-GcrContinue
1542
+
1543
+ $started = Get-Date
1544
+ $failCount = 0
1545
+ $processedThisRun = 0
1546
+ Write-DownloadProgress -OkCount $okCount -TotalCount $totalCount -FailCount 0 -DoneBytes $doneBytes -Elapsed ([TimeSpan]::Zero) -DoneThisRun 0 -CurrentFile "starting download"
1547
+
1548
+ $batchIndex = 0
1549
+ foreach ($batch in $batches) {
1550
+ Assert-GcrContinue
1551
+ $batchIndex++
1552
+ Set-GcrTuiPhase -Name "download" -Detail ("batch " + $batchIndex + "/" + $batches.Count)
1553
+ $okThis = New-Object System.Collections.Generic.List[object]
1554
+ $badThis = New-Object System.Collections.Generic.List[object]
1555
+ $preview = $batch[0].Path
1556
+ Write-DownloadProgress -OkCount $okCount -TotalCount $totalCount -FailCount $failCount -DoneBytes $doneBytes -Elapsed ((Get-Date) - $started) -DoneThisRun $processedThisRun -CurrentFile $preview
1557
+
1558
+ try {
1559
+ Invoke-CheckoutBatch -RepoRoot $repoRoot -Sha $pinnedSha -Batch $batch
1560
+ $result = Confirm-BatchFiles -RepoRoot $repoRoot -Batch $batch
1561
+ foreach ($x in $result.Ok) { [void]$okThis.Add($x) }
1562
+ foreach ($x in $result.Bad) { [void]$badThis.Add($x) }
1563
+ } catch {
1564
+ if ($script:GcrUserStop) { throw }
1565
+ $nBatch = @($batch).Count
1566
+ if ($nBatch -gt 1) {
1567
+ Write-Log ("批次 " + $batchIndex + "/" + $batches.Count + " 失败(" + $nBatch + " 个文件),立即拆成单文件,不再整批重试: " + $_.Exception.Message) "WARN"
1568
+ } else {
1569
+ Write-Log ("单文件失败: " + $_.Exception.Message) "WARN"
1570
+ }
1571
+ foreach ($x in $batch) { [void]$badThis.Add($x) }
1572
+ }
1573
+
1574
+ $retry = New-Object System.Collections.Generic.List[object]
1575
+ foreach ($e in $badThis) { [void]$retry.Add($e) }
1576
+ foreach ($e in $retry) {
1577
+ Assert-GcrContinue
1578
+ $oneOk = $false
1579
+ $one = New-Object System.Collections.Generic.List[object]
1580
+ [void]$one.Add($e)
1581
+ try {
1582
+ Invoke-CheckoutBatch -RepoRoot $repoRoot -Sha $pinnedSha -Batch $one
1583
+ if (Test-FileComplete -RepoRoot $repoRoot -Entry $e) { $oneOk = $true }
1584
+ } catch {
1585
+ if ($script:GcrUserStop) { throw }
1586
+ Add-FailedPath -FailedPath $failedPath -RelPath $e.Path -Reason $_.Exception.Message
1587
+ }
1588
+ if ($oneOk) {
1589
+ [void]$okThis.Add($e)
1590
+ } else {
1591
+ $failCount++
1592
+ Add-GcrTuiFailure -Path $e.Path
1593
+ $fullFail = Get-WorktreePath -Root $repoRoot -Rel $e.Path
1594
+ $why = "no worktree file"
1595
+ if (Test-Path -LiteralPath $fullFail) { $why = "worktree file present but still incomplete" }
1596
+ Write-Log ("仍失败: " + $e.Path + " (" + $why + ")") "ERROR"
1597
+ }
1598
+ }
1599
+
1600
+ $okPaths = New-Object System.Collections.Generic.List[string]
1601
+ foreach ($x in $okThis) { [void]$okPaths.Add([string]$x.Path) }
1602
+ if ($okPaths.Count -gt 0) {
1603
+ Add-DonePaths -DonePath $donePath -Paths $okPaths.ToArray()
1604
+ foreach ($p in $okPaths) { [void]$doneSet.Add($p) }
1605
+ }
1606
+
1607
+ $processedThisRun += $okThis.Count
1608
+ $okCount += $okThis.Count
1609
+ foreach ($e in $okThis) { $doneBytes += (Get-WorktreeBytes -RepoRoot $repoRoot -RelPath $e.Path) }
1610
+
1611
+ $lastName = $batch[$batch.Count - 1].Path
1612
+ Write-DownloadProgress -OkCount $okCount -TotalCount $totalCount -FailCount $failCount -DoneBytes $doneBytes -Elapsed ((Get-Date) - $started) -DoneThisRun $processedThisRun -CurrentFile $lastName
1613
+ if (($batchIndex % 20) -eq 0 -or $okCount -eq $totalCount) {
1614
+ $pctNow = 0.0
1615
+ if ($totalCount -gt 0) { $pctNow = 100.0 * $okCount / $totalCount }
1616
+ Write-GcrNewline
1617
+ Write-Log (("checkpoint {0}/{1} {2:N1}% fail {3} {4}" -f $okCount, $totalCount, $pctNow, $failCount, (Format-Bytes $doneBytes))) "INFO"
1618
+ }
1619
+ }
1620
+
1621
+ Write-GcrNewline
1622
+ if (-not (Test-GcrTuiActive)) {
1623
+ Write-Progress -Activity "git-clone-resume" -Completed
1624
+ }
1625
+ Set-GcrTuiPhase -Name "repair" -Detail "git index"
1626
+ Repair-GitIndex -RepoRoot $repoRoot -Sha $pinnedSha
1627
+ $elapsed = (Get-Date) - $started
1628
+ $elapsedText = "{0:00}:{1:00}:{2:00}" -f [int]$elapsed.TotalHours, $elapsed.Minutes, $elapsed.Seconds
1629
+ Write-Log ("完成: 成功 {0}/{1} ,失败 {2} ,耗时 {3}" -f $okCount, $totalCount, $failCount, $elapsedText) "OK"
1630
+ Write-Log "工作区: $repoRoot" "OK"
1631
+ [void]$resultBody.Add(("工作区: " + $repoRoot))
1632
+ [void]$resultBody.Add(("成功 {0}/{1} ,失败 {2} ,耗时 {3}" -f $okCount, $totalCount, $failCount, $elapsedText))
1633
+ if ($failCount -gt 0) {
1634
+ Write-Log "失败列表: $failedPath (再次运行本脚本会重试未完成文件)" "WARN"
1635
+ [void]$resultBody.Add(("失败列表: " + $failedPath))
1636
+ [void]$resultBody.Add("再次运行同一命令会重试未完成文件。")
1637
+ $script:GcrExitCode = 1
1638
+ $resultKind = "error"
1639
+ $resultTitle = "部分文件失败"
1640
+ } else {
1641
+ $script:GcrExitCode = 0
1642
+ $resultTitle = "克隆完成"
1643
+ }
1644
+ }
1645
+
1646
+ if (Get-Command Save-GcrHistory -ErrorAction SilentlyContinue) {
1647
+ $histStatus = "complete"
1648
+ if ($script:GcrExitCode -ne 0) { $histStatus = "failed" }
1649
+ if ($DryRun) { $histStatus = "dryrun" }
1650
+ Save-GcrHistory -Url $RepoUrl -OutDir $repoRoot -Ref $Ref -Commit $pinnedSha -Status $histStatus -Ok $okCount -Total $totalCount -Fail $failCount
1651
+ }
1652
+ if (Test-GcrTuiActive) {
1653
+ if (-not $resultTitle) { $resultTitle = "完成" }
1654
+ Show-GcrTuiResult -Title $resultTitle -Body $resultBody.ToArray() -Kind $resultKind
1655
+ }
1656
+ }
1657
+ catch {
1658
+ $err = ""
1659
+ try { $err = [string]$_.Exception.Message } catch { }
1660
+ if (-not $err) { try { $err = [string]$_ } catch { $err = "unknown error" } }
1661
+ Write-Log $err "ERROR"
1662
+ if ($_.ScriptStackTrace -and -not $script:GcrUserStop) { Write-Log ([string]$_.ScriptStackTrace) "ERROR" }
1663
+ if ($script:RepoRoot) {
1664
+ Write-Log ("中断后续传: 重新执行同一命令即可。仓库目录: " + $script:RepoRoot) "WARN"
1665
+ }
1666
+ if (Get-Command Save-GcrHistory -ErrorAction SilentlyContinue -and $script:RepoRoot) {
1667
+ $st = "failed"
1668
+ if ($script:GcrUserStop) { $st = "partial" }
1669
+ Save-GcrHistory -Url $RepoUrl -OutDir $script:RepoRoot -Ref $Ref -Status $st
1670
+ }
1671
+ if (Test-GcrTuiActive) {
1672
+ $body = @($err)
1673
+ if ($script:RepoRoot) { $body += ("仓库目录: " + $script:RepoRoot) }
1674
+ $body += "再次运行同一命令即可续传。"
1675
+ $title = $(if ($script:GcrUserStop) { "已停止" } else { "出错" })
1676
+ Show-GcrTuiResult -Title $title -Body $body -Kind "error"
1677
+ }
1678
+ $script:GcrExitCode = 1
1679
+ }
1680
+ finally {
1681
+ if (Get-Command Close-GcrTui -ErrorAction SilentlyContinue) { Close-GcrTui }
1682
+ }
1683
+
1684
+ exit $script:GcrExitCode
1685
+