Skip to content

Commit 315119f

Browse files
tgymnichabertelrud
andauthored
Git Progress (#3094)
Add a callback and delegate methods to report the progress of Git fetch operations in the SwiftPM CLI. Co-authored-by: Anders Bertelrud <anders@apple.com>
1 parent ae2b052 commit 315119f

File tree

9 files changed

+307
-27
lines changed

9 files changed

+307
-27
lines changed

Sources/Commands/SwiftTool.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ private class ToolWorkspaceDelegate: WorkspaceDelegate {
3636
/// The progress animation for downloads.
3737
private let downloadAnimation: NinjaProgressAnimation
3838

39+
/// The progress animation for repository fetches.
40+
private let fetchAnimation: NinjaProgressAnimation
41+
3942
/// Wether the tool is in a verbose mode.
4043
private let isVerbose: Bool
4144

@@ -44,9 +47,17 @@ private class ToolWorkspaceDelegate: WorkspaceDelegate {
4447
let totalBytesToDownload: Int64
4548
}
4649

50+
private struct FetchProgress {
51+
let objectsFetched: Int
52+
let totalObjectsToFetch: Int
53+
}
54+
4755
/// The progress of each individual downloads.
4856
private var downloadProgress: [String: DownloadProgress] = [:]
4957

58+
/// The progress of each individual fetch operation
59+
private var fetchProgress: [String: FetchProgress] = [:]
60+
5061
private let queue = DispatchQueue(label: "org.swift.swiftpm.commands.tool-workspace-delegate")
5162
private let diagnostics: DiagnosticsEngine
5263

@@ -55,6 +66,7 @@ private class ToolWorkspaceDelegate: WorkspaceDelegate {
5566
// https://forums.swift.org/t/allow-self-x-in-class-convenience-initializers/15924
5667
self.stdoutStream = stdoutStream as? ThreadSafeOutputByteStream ?? ThreadSafeOutputByteStream(stdoutStream)
5768
self.downloadAnimation = NinjaProgressAnimation(stream: self.stdoutStream)
69+
self.fetchAnimation = NinjaProgressAnimation(stream: self.stdoutStream)
5870
self.isVerbose = isVerbose
5971
self.diagnostics = diagnostics
6072
}
@@ -74,6 +86,18 @@ private class ToolWorkspaceDelegate: WorkspaceDelegate {
7486

7587
func fetchingDidFinish(repository: String, fetchDetails: RepositoryManager.FetchDetails?, diagnostic: Diagnostic?, duration: DispatchTimeInterval) {
7688
queue.async {
89+
if self.diagnostics.hasErrors {
90+
self.fetchAnimation.clear()
91+
}
92+
93+
let step = self.fetchProgress.values.reduce(0) { $0 + $1.objectsFetched }
94+
let total = self.fetchProgress.values.reduce(0) { $0 + $1.totalObjectsToFetch }
95+
96+
if step == total && !self.fetchProgress.isEmpty {
97+
self.fetchAnimation.complete(success: true)
98+
self.fetchProgress.removeAll()
99+
}
100+
77101
self.stdoutStream <<< "Fetched \(repository) (\(duration.descriptionInSeconds))"
78102
self.stdoutStream <<< "\n"
79103
self.stdoutStream.flush()
@@ -195,6 +219,18 @@ private class ToolWorkspaceDelegate: WorkspaceDelegate {
195219
}
196220
}
197221

222+
func fetchingRepository(from repository: String, objectsFetched: Int, totalObjectsToFetch: Int) {
223+
queue.async {
224+
self.fetchProgress[repository] = FetchProgress(
225+
objectsFetched: objectsFetched,
226+
totalObjectsToFetch: totalObjectsToFetch)
227+
228+
let step = self.fetchProgress.values.reduce(0) { $0 + $1.objectsFetched }
229+
let total = self.fetchProgress.values.reduce(0) { $0 + $1.totalObjectsToFetch }
230+
self.fetchAnimation.update(step: step, total: total, text: "Fetching objects")
231+
}
232+
}
233+
198234
// noop
199235

200236
func willLoadManifest(packagePath: AbsolutePath, url: String, version: Version?, packageKind: PackageReference.Kind) {}

Sources/SPMTestSupport/InMemoryGitRepository.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ public final class InMemoryGitRepositoryProvider: RepositoryProvider {
397397
// MARK: - RepositoryProvider conformance
398398
// Note: These methods use force unwrap (instead of throwing) to honor their preconditions.
399399

400-
public func fetch(repository: RepositorySpecifier, to path: AbsolutePath) throws {
400+
public func fetch(repository: RepositorySpecifier, to path: AbsolutePath, progressHandler: FetchProgress.Handler? = nil) throws {
401401
let repo = specifierMap[RepositorySpecifier(url: repository.url)]!
402402
fetchedMap[path] = try repo.copy()
403403
add(specifier: RepositorySpecifier(url: path.asURL.absoluteString), repository: repo)

Sources/SPMTestSupport/MockWorkspace.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,9 @@ public final class MockWorkspaceDelegate: WorkspaceDelegate {
646646
self.append("fetching repo: \(repository)")
647647
}
648648

649+
public func fetchingRepository(from repository: String, objectsFetched: Int, totalObjectsToFetch: Int) {
650+
}
651+
649652
public func fetchingDidFinish(repository: String, fetchDetails: RepositoryManager.FetchDetails?, diagnostic: Diagnostic?, duration: DispatchTimeInterval) {
650653
self.append("finished fetching repo: \(repository)")
651654
}

Sources/SourceControl/GitRepository.swift

Lines changed: 207 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ private struct GitShellHelper {
2727
/// Private function to invoke the Git tool with its default environment and given set of arguments. The specified
2828
/// failure message is used only in case of error. This function waits for the invocation to finish and returns the
2929
/// output as a string.
30-
func run(_ args: [String], environment: [String: String] = Git.environment) throws -> String {
31-
let process = Process(arguments: [Git.tool] + args, environment: environment, outputRedirection: .collect)
30+
func run(_ args: [String], environment: [String: String] = Git.environment, outputRedirection: Process.OutputRedirection = .collect) throws -> String {
31+
let process = Process(arguments: [Git.tool] + args, environment: environment, outputRedirection: outputRedirection)
3232
let result: ProcessResult
3333
do {
3434
try self.processSet?.add(process)
@@ -66,15 +66,33 @@ public struct GitRepositoryProvider: RepositoryProvider {
6666
private func callGit(_ args: String...,
6767
environment: [String: String] = Git.environment,
6868
repository: RepositorySpecifier,
69-
failureMessage: String = "") throws -> String {
70-
do {
71-
return try self.git.run(args, environment: environment)
72-
} catch let error as GitShellError {
73-
throw GitCloneError(repository: repository, message: failureMessage, result: error.result)
69+
failureMessage: String = "",
70+
progress: FetchProgress.Handler? = nil) throws -> String {
71+
if let progress = progress {
72+
var stdoutBytes: [UInt8] = [], stderrBytes: [UInt8] = []
73+
do {
74+
// Capture stdout and stderr from the Git subprocess invocation, but also pass along stderr to the handler. We count on it being line-buffered.
75+
let outputHandler = Process.OutputRedirection.stream(stdout: { stdoutBytes += $0 }, stderr: {
76+
stderrBytes += $0
77+
gitFetchStatusFilter($0, progress: progress)
78+
})
79+
return try self.git.run(args + ["--progress"], environment: environment, outputRedirection: outputHandler)
80+
}
81+
catch let error as GitShellError {
82+
let result = ProcessResult(arguments: error.result.arguments, environment: error.result.environment, exitStatus: error.result.exitStatus, output: .success(stdoutBytes), stderrOutput: .success(stderrBytes))
83+
throw GitCloneError(repository: repository, message: failureMessage, result: result)
84+
}
85+
}
86+
else {
87+
do {
88+
return try self.git.run(args, environment: environment)
89+
} catch let error as GitShellError {
90+
throw GitCloneError(repository: repository, message: failureMessage, result: error.result)
91+
}
7492
}
7593
}
7694

77-
public func fetch(repository: RepositorySpecifier, to path: AbsolutePath) throws {
95+
public func fetch(repository: RepositorySpecifier, to path: AbsolutePath, progressHandler: FetchProgress.Handler? = nil) throws {
7896
// Perform a bare clone.
7997
//
8098
// NOTE: We intentionally do not create a shallow clone here; the
@@ -87,7 +105,8 @@ public struct GitRepositoryProvider: RepositoryProvider {
87105
// NOTE: Explicitly set `core.symlinks=true` on `git clone` to ensure that symbolic links are correctly resolved.
88106
try self.callGit("clone", "-c", "core.symlinks=true", "--mirror", repository.url, path.pathString,
89107
repository: repository,
90-
failureMessage: "Failed to clone repository \(repository.url)")
108+
failureMessage: "Failed to clone repository \(repository.url)",
109+
progress: progressHandler)
91110
}
92111

93112
public func isValidDirectory(_ directory: String) -> Bool {
@@ -302,11 +321,29 @@ public final class GitRepository: Repository, WorkingCheckout {
302321
@discardableResult
303322
private func callGit(_ args: String...,
304323
environment: [String: String] = Git.environment,
305-
failureMessage: String = "") throws -> String {
306-
do {
307-
return try self.git.run(["-C", self.path.pathString] + args, environment: environment)
308-
} catch let error as GitShellError {
309-
throw GitRepositoryError(path: self.path, message: failureMessage, result: error.result)
324+
failureMessage: String = "",
325+
progress: FetchProgress.Handler? = nil) throws -> String {
326+
if let progress = progress {
327+
var stdoutBytes: [UInt8] = [], stderrBytes: [UInt8] = []
328+
do {
329+
// Capture stdout and stderr from the Git subprocess invocation, but also pass along stderr to the handler. We count on it being line-buffered.
330+
let outputHandler = Process.OutputRedirection.stream(stdout: { stdoutBytes += $0 }, stderr: {
331+
stderrBytes += $0
332+
gitFetchStatusFilter($0, progress: progress)
333+
})
334+
return try self.git.run(["-C", self.path.pathString] + args, environment: environment, outputRedirection: outputHandler)
335+
}
336+
catch let error as GitShellError {
337+
let result = ProcessResult(arguments: error.result.arguments, environment: error.result.environment, exitStatus: error.result.exitStatus, output: .success(stdoutBytes), stderrOutput: .success(stderrBytes))
338+
throw GitRepositoryError(path: self.path, message: failureMessage, result: result)
339+
}
340+
}
341+
else {
342+
do {
343+
return try self.git.run(["-C", self.path.pathString] + args, environment: environment)
344+
} catch let error as GitShellError {
345+
throw GitRepositoryError(path: self.path, message: failureMessage, result: error.result)
346+
}
310347
}
311348
}
312349

@@ -365,10 +402,14 @@ public final class GitRepository: Repository, WorkingCheckout {
365402
}
366403

367404
public func fetch() throws {
405+
try fetch(progress: nil)
406+
}
407+
408+
public func fetch(progress: FetchProgress.Handler? = nil) throws {
368409
// use barrier for write operations
369410
try self.lock.withLock {
370-
try callGit("remote", "update", "-p",
371-
failureMessage: "Couldn’t fetch updates from remote repositories")
411+
try callGit("remote", "-v", "update", "-p",
412+
failureMessage: "Couldn’t fetch updates from remote repositories", progress: progress)
372413
self.cachedTags.clear()
373414
}
374415
}
@@ -922,3 +963,153 @@ public struct GitCloneError: Error, CustomStringConvertible, DiagnosticLocationP
922963
return "\(self.message):\n\(output)"
923964
}
924965
}
966+
967+
public enum GitProgressParser: FetchProgress {
968+
case enumeratingObjects(currentObjects: Int)
969+
case countingObjects(progress: Double, currentObjects: Int, totalObjects: Int)
970+
case compressingObjects(progress: Double, currentObjects: Int, totalObjects: Int)
971+
case receivingObjects(progress: Double, currentObjects: Int, totalObjects: Int, downloadProgress: String?, downloadSpeed: String?)
972+
case resolvingDeltas(progress: Double, currentObjects: Int, totalObjects: Int)
973+
974+
/// The pattern used to match git output. Caputre groups are labled from ?<i0> to ?<i19>.
975+
static let pattern = #"""
976+
(?xi)
977+
(?:
978+
remote: \h+ (?<i0>Enumerating \h objects): \h+ (?<i1>[0-9]+)
979+
)|
980+
(?:
981+
remote: \h+ (?<i2>Counting \h objects): \h+ (?<i3>[0-9]+)% \h+ \((?<i4>[0-9]+)\/(?<i5>[0-9]+)\)
982+
)|
983+
(?:
984+
remote: \h+ (?<i6>Compressing \h objects): \h+ (?<i7>[0-9]+)% \h+ \((?<i8>[0-9]+)\/(?<i9>[0-9]+)\)
985+
)|
986+
(?:
987+
(?<i10>Resolving \h deltas): \h+ (?<i11>[0-9]+)% \h+ \((?<i12>[0-9]+)\/(?<i13>[0-9]+)\)
988+
)|
989+
(?:
990+
(?<i14>Receiving \h objects): \h+ (?<i15>[0-9]+)% \h+ \((?<i16>[0-9]+)\/(?<i17>[0-9]+)\)
991+
(?:, \h+ (?<i18>[0-9]+.?[0-9]+ \h [A-Z]iB) \h+ \| \h+ (?<i19>[0-9]+.?[0-9]+ \h [A-Z]iB\/s))?
992+
)
993+
"""#
994+
static let regex = try? RegEx(pattern: pattern)
995+
996+
init?(from string: String) {
997+
guard let matches = GitProgressParser.regex?.matchGroups(in: string).first, matches.count == 20 else { return nil }
998+
999+
if matches[0] == "Enumerating objects" {
1000+
guard let currentObjects = Int(matches[1]) else { return nil }
1001+
1002+
self = .enumeratingObjects(currentObjects: currentObjects)
1003+
} else if matches[2] == "Counting objects" {
1004+
guard let progress = Double(matches[3]),
1005+
let currentObjects = Int(matches[4]),
1006+
let totalObjects = Int(matches[5]) else { return nil }
1007+
1008+
self = .countingObjects(progress: progress / 100, currentObjects: currentObjects, totalObjects: totalObjects)
1009+
1010+
} else if matches[6] == "Compressing objects" {
1011+
guard let progress = Double(matches[7]),
1012+
let currentObjects = Int(matches[8]),
1013+
let totalObjects = Int(matches[9]) else { return nil }
1014+
1015+
self = .compressingObjects(progress: progress / 100, currentObjects: currentObjects, totalObjects: totalObjects)
1016+
1017+
} else if matches[10] == "Resolving deltas" {
1018+
guard let progress = Double(matches[11]),
1019+
let currentObjects = Int(matches[12]),
1020+
let totalObjects = Int(matches[13]) else { return nil }
1021+
1022+
self = .resolvingDeltas(progress: progress / 100, currentObjects: currentObjects, totalObjects: totalObjects)
1023+
1024+
} else if matches[14] == "Receiving objects" {
1025+
guard let progress = Double(matches[15]),
1026+
let currentObjects = Int(matches[16]),
1027+
let totalObjects = Int(matches[17]) else { return nil }
1028+
1029+
let downloadProgress = matches[18]
1030+
let downloadSpeed = matches[19]
1031+
1032+
self = .receivingObjects(progress: progress / 100, currentObjects: currentObjects, totalObjects: totalObjects, downloadProgress: downloadProgress, downloadSpeed: downloadSpeed)
1033+
1034+
} else {
1035+
return nil
1036+
}
1037+
}
1038+
1039+
public var message: String {
1040+
switch self {
1041+
case .enumeratingObjects: return "Enumerating objects"
1042+
case .countingObjects: return "Counting objects"
1043+
case .compressingObjects: return "Compressing objects"
1044+
case .receivingObjects: return "Receiving objects"
1045+
case .resolvingDeltas: return "Resolving deltas"
1046+
}
1047+
}
1048+
1049+
public var step: Int {
1050+
switch self {
1051+
case .enumeratingObjects(let currentObjects):
1052+
return currentObjects
1053+
case .countingObjects(_, let currentObjects, _):
1054+
return currentObjects
1055+
case .compressingObjects(_, let currentObjects, _):
1056+
return currentObjects
1057+
case .receivingObjects(_, let currentObjects, _, _, _):
1058+
return currentObjects
1059+
case .resolvingDeltas(_, let currentObjects, _):
1060+
return currentObjects
1061+
}
1062+
}
1063+
1064+
public var totalSteps: Int? {
1065+
switch self {
1066+
case .enumeratingObjects:
1067+
return 0
1068+
case .countingObjects(_, _, let totalObjects):
1069+
return totalObjects
1070+
case .compressingObjects(_, _, let totalObjects):
1071+
return totalObjects
1072+
case .receivingObjects(_, _, let totalObjects, _, _):
1073+
return totalObjects
1074+
case .resolvingDeltas(_, _, let totalObjects):
1075+
return totalObjects
1076+
}
1077+
}
1078+
1079+
public var downloadProgress: String? {
1080+
switch self {
1081+
case .receivingObjects(_, _, _, let downloadProgress, _):
1082+
return downloadProgress
1083+
default:
1084+
return nil
1085+
}
1086+
}
1087+
1088+
public var downloadSpeed: String? {
1089+
switch self {
1090+
case .receivingObjects(_, _, _, _, let downloadSpeed):
1091+
return downloadSpeed
1092+
default:
1093+
return nil
1094+
}
1095+
}
1096+
}
1097+
1098+
/// Processes stdout output and calls the progress callback with `GitStatus` objects.
1099+
fileprivate func gitFetchStatusFilter(_ bytes: [UInt8], progress: FetchProgress.Handler) {
1100+
guard let string = String(bytes: bytes, encoding: .utf8) else { return }
1101+
let lines = string
1102+
.split { $0.isNewline }
1103+
.map { String($0) }
1104+
1105+
for line in lines {
1106+
if let status = GitProgressParser(from: line) {
1107+
switch status {
1108+
case .receivingObjects:
1109+
progress(status)
1110+
default:
1111+
continue
1112+
}
1113+
}
1114+
}
1115+
}

0 commit comments

Comments
 (0)