@@ -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