Skip to content

Commit a5824b1

Browse files
authored
Merge pull request #2987 from abertelrud/eng/git-repository-invocation-cleanup
Add a customized GitRepositoryError and funnel all Git operations thr ough a method that throws one of those if the operation fails
2 parents 27aecca + da6000e commit a5824b1

File tree

4 files changed

+160
-87
lines changed

4 files changed

+160
-87
lines changed

Sources/PackageGraph/RepositoryPackageContainerProvider.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ public class RepositoryPackageContainer: BasePackageContainer, CustomStringConve
400400
}.1
401401
} catch {
402402
// Examine the error to see if we can come up with a more informative and actionable error message. We know that the revision is expected to be a branch name or a hash (tags are handled through a different code path).
403-
if let gitInvocationError = error as? ProcessResult.Error, gitInvocationError.description.contains("Needed a single revision") {
403+
if let error = error as? GitRepositoryError, error.description.contains("Needed a single revision") {
404404
// It was a Git process invocation error. Take a look at the repository to see if we can come up with a reasonable diagnostic.
405405
if let rev = try? repository.resolveRevision(identifier: revision), repository.exists(revision: rev) {
406406
// Revision does exist, so something else must be wrong.

Sources/SourceControl/GitRepository.swift

Lines changed: 138 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2014 - 2017 Apple Inc. and the Swift project authors
4+
Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See http://swift.org/LICENSE.txt for license information
@@ -12,21 +12,6 @@ import TSCBasic
1212
import Dispatch
1313
import TSCUtility
1414

15-
public struct GitCloneError: Swift.Error, CustomStringConvertible {
16-
17-
/// The repository that was being cloned.
18-
public let repository: String
19-
20-
/// The process result.
21-
public let result: ProcessResult
22-
23-
public var description: String {
24-
let stdout = (try? result.utf8Output()) ?? ""
25-
let stderr = (try? result.utf8stderrOutput()) ?? ""
26-
let output = (stdout + stderr).spm_chomp().spm_multilineIndent(count: 4)
27-
return "Failed to clone \(repository):\n\(output)"
28-
}
29-
}
3015

3116
/// A `git` repository provider.
3217
public class GitRepositoryProvider: RepositoryProvider {
@@ -38,6 +23,29 @@ public class GitRepositoryProvider: RepositoryProvider {
3823
self.processSet = processSet
3924
}
4025

26+
/// Private function to invoke the Git tool with its default environment and given set of arguments. The specified
27+
/// failure message is used only in case of error. This function waits for the invocation to finish and returns the
28+
/// output as a string.
29+
@discardableResult
30+
private func callGit(_ args: String..., environment: [String: String] = Git.environment, failureMessage: String = "", repository: RepositorySpecifier) throws -> String {
31+
let process = Process(arguments: [Git.tool] + args, environment: environment, outputRedirection: .collect)
32+
let result: ProcessResult
33+
do {
34+
try processSet?.add(process)
35+
try process.launch()
36+
result = try process.waitUntilExit()
37+
}
38+
catch {
39+
// Handle a failure to even launch the Git tool by synthesizing a result that we can wrap an error around.
40+
result = ProcessResult(arguments: process.arguments, environment: process.environment,
41+
exitStatus: .terminated(code: -1), output: .failure(error), stderrOutput: .failure(error))
42+
}
43+
guard result.exitStatus == .terminated(code: 0) else {
44+
throw GitCloneError(repository: repository, message: failureMessage, result: result)
45+
}
46+
return try result.utf8Output()
47+
}
48+
4149
public func fetch(repository: RepositorySpecifier, to path: AbsolutePath) throws {
4250
// Perform a bare clone.
4351
//
@@ -47,24 +55,9 @@ public class GitRepositoryProvider: RepositoryProvider {
4755

4856
precondition(!localFileSystem.exists(path))
4957

50-
// FIXME: We need infrastructure in this subsystem for reporting
51-
// status information.
52-
53-
let process = Process(
54-
args: Git.tool, "clone", "--mirror", repository.url, path.pathString, environment: Git.environment)
55-
// Add to process set.
56-
try processSet?.add(process)
57-
58-
try process.launch()
59-
let result = try process.waitUntilExit()
60-
61-
// Throw if cloning failed.
62-
guard result.exitStatus == .terminated(code: 0) else {
63-
throw GitCloneError(
64-
repository: repository.url,
65-
result: result
66-
)
67-
}
58+
// FIXME: Ideally we should pass `--progress` here and report status regularly. We currently don't have callbacks for that.
59+
try callGit("clone", "--mirror", repository.url, path.pathString,
60+
failureMessage: "Failed to clone repository \(repository.url)", repository: repository)
6861
}
6962

7063
public func open(repository: RepositorySpecifier, at path: AbsolutePath) -> Repository {
@@ -82,8 +75,8 @@ public class GitRepositoryProvider: RepositoryProvider {
8275
// For editable clones, i.e. the user is expected to directly work on them, first we create
8376
// a clone from our cache of repositories and then we replace the remote to the one originally
8477
// present in the bare repository.
85-
try Process.checkNonZeroExit(args:
86-
Git.tool, "clone", "--no-checkout", sourcePath.pathString, destinationPath.pathString)
78+
try callGit("clone", "--no-checkout", sourcePath.pathString, destinationPath.pathString,
79+
failureMessage: "Failed to clone repository \(repository.url)", repository: repository)
8780
// The default name of the remote.
8881
let origin = "origin"
8982
// In destination repo remove the remote which will be pointing to the source repo.
@@ -101,8 +94,8 @@ public class GitRepositoryProvider: RepositoryProvider {
10194
// re-resolve such that the objects in this repository changed, we would
10295
// only ever expect to get back a revision that remains present in the
10396
// object storage.
104-
try Process.checkNonZeroExit(args:
105-
Git.tool, "clone", "--shared", "--no-checkout", sourcePath.pathString, destinationPath.pathString)
97+
try callGit("clone", "--shared", "--no-checkout", sourcePath.pathString, destinationPath.pathString,
98+
failureMessage: "Failed to clone repository \(repository.url)", repository: repository)
10699
}
107100
}
108101

@@ -118,6 +111,33 @@ public class GitRepositoryProvider: RepositoryProvider {
118111
}
119112
}
120113

114+
115+
public struct GitCloneError: Error, CustomStringConvertible, DiagnosticLocationProviding {
116+
public let repository: RepositorySpecifier
117+
public let message: String
118+
public let result: ProcessResult
119+
120+
public struct Location: DiagnosticLocation {
121+
public let repository: RepositorySpecifier
122+
public var description: String {
123+
return repository.url
124+
}
125+
}
126+
127+
public var diagnosticLocation: DiagnosticLocation? {
128+
return Location(repository: repository)
129+
}
130+
131+
public var description: String {
132+
let stdout = (try? result.utf8Output()) ?? ""
133+
let stderr = (try? result.utf8stderrOutput()) ?? ""
134+
let output = (stdout + stderr).spm_chomp().spm_multilineIndent(count: 4)
135+
return "\(message):\n\(output)"
136+
}
137+
}
138+
139+
140+
121141
enum GitInterfaceError: Swift.Error {
122142
/// This indicates a problem communicating with the `git` tool.
123143
case malformedResponse(String)
@@ -135,7 +155,7 @@ enum GitInterfaceError: Swift.Error {
135155
// abstract, and change the provider to just return an adaptor around this
136156
// class.
137157
//
138-
/// A basic `git` repository. This class is thread safe.
158+
/// A basic Git repository in the local file system (almost always a clone of a remote). This class is thread safe.
139159
public class GitRepository: Repository, WorkingCheckout {
140160
/// A hash object.
141161
public struct Hash: Hashable {
@@ -225,7 +245,7 @@ public class GitRepository: Repository, WorkingCheckout {
225245
public let contents: [Entry]
226246
}
227247

228-
/// The path of the repository on disk.
248+
/// The path of the repository in the local file system.
229249
public let path: AbsolutePath
230250

231251
/// The (serial) queue to execute git cli on.
@@ -238,13 +258,34 @@ public class GitRepository: Repository, WorkingCheckout {
238258
self.path = path
239259
self.isWorkingRepo = isWorkingRepo
240260
do {
241-
let isBareRepo = try Process.checkNonZeroExit(
242-
args: Git.tool, "-C", path.pathString, "rev-parse", "--is-bare-repository").spm_chomp() == "true"
261+
let isBareRepo = try callGit("rev-parse", "--is-bare-repository").spm_chomp() == "true"
243262
assert(isBareRepo != isWorkingRepo)
244263
} catch {
245264
// Ignore if we couldn't run popen for some reason.
246265
}
247266
}
267+
268+
/// Private function to invoke the Git tool with its default environment and given set of arguments, specifying the
269+
/// path of the repository as the one to operate on. The specified failure message is used only in case of error.
270+
/// This function waits for the invocation to finish and returns the output as a string.
271+
@discardableResult
272+
private func callGit(_ args: String..., environment: [String: String] = Git.environment, failureMessage: String = "") throws -> String {
273+
let process = Process(arguments: [Git.tool, "-C", path.pathString] + args, environment: environment, outputRedirection: .collect)
274+
let result: ProcessResult
275+
do {
276+
try process.launch()
277+
result = try process.waitUntilExit()
278+
}
279+
catch {
280+
// Handle a failure to even launch the Git tool by synthesizing a result that we can wrap an error around.
281+
result = ProcessResult(arguments: process.arguments, environment: process.environment,
282+
exitStatus: .terminated(code: -1), output: .failure(error), stderrOutput: .failure(error))
283+
}
284+
guard result.exitStatus == .terminated(code: 0) else {
285+
throw GitRepositoryError(path: self.path, message: failureMessage, result: result)
286+
}
287+
return try result.utf8Output()
288+
}
248289

249290
/// Changes URL for the remote.
250291
///
@@ -253,8 +294,8 @@ public class GitRepository: Repository, WorkingCheckout {
253294
/// - url: The new url of the remote.
254295
public func setURL(remote: String, url: String) throws {
255296
try queue.sync {
256-
try Process.checkNonZeroExit(
257-
args: Git.tool, "-C", path.pathString, "remote", "set-url", remote, url)
297+
try callGit("remote", "set-url", remote, url,
298+
failureMessage: "Couldn’t set the URL of the remote\(remote)’ to ‘\(url)")
258299
return
259300
}
260301
}
@@ -265,13 +306,13 @@ public class GitRepository: Repository, WorkingCheckout {
265306
public func remotes() throws -> [(name: String, url: String)] {
266307
return try queue.sync {
267308
// Get the remote names.
268-
let remoteNamesOutput = try Process.checkNonZeroExit(
269-
args: Git.tool, "-C", path.pathString, "remote").spm_chomp()
309+
let remoteNamesOutput = try callGit("remote",
310+
failureMessage: "Couldn’t get the list of remotes").spm_chomp()
270311
let remoteNames = remoteNamesOutput.split(separator: "\n").map(String.init)
271312
return try remoteNames.map({ name in
272313
// For each remote get the url.
273-
let url = try Process.checkNonZeroExit(
274-
args: Git.tool, "-C", path.pathString, "config", "--get", "remote.\(name).url").spm_chomp()
314+
let url = try callGit("config", "--get", "remote.\(name).url",
315+
failureMessage: "Couldn’t get the URL of the remote\(name)").spm_chomp()
275316
return (name, url)
276317
})
277318
}
@@ -297,8 +338,8 @@ public class GitRepository: Repository, WorkingCheckout {
297338
/// Returns the tags present in repository.
298339
private func getTags() -> [String] {
299340
// FIXME: Error handling.
300-
let tagList = try! Process.checkNonZeroExit(
301-
args: Git.tool, "-C", path.pathString, "tag", "-l").spm_chomp()
341+
let tagList = try! callGit("tag", "-l",
342+
failureMessage: "Couldn’t get the list of tags").spm_chomp()
302343
return tagList.split(separator: "\n").map(String.init)
303344
}
304345

@@ -312,20 +353,17 @@ public class GitRepository: Repository, WorkingCheckout {
312353

313354
public func fetch() throws {
314355
try queue.sync {
315-
try Process.checkNonZeroExit(
316-
args: Git.tool, "-C", path.pathString, "remote", "update", "-p", environment: Git.environment)
356+
try callGit("remote", "update", "-p",
357+
failureMessage: "Couldn’t fetch updates from remote repositories")
317358
self.tagsCache = nil
318359
}
319360
}
320361

321362
public func hasUncommittedChanges() -> Bool {
322-
// Only a work tree can have changes.
363+
// Only a working repository can have changes.
323364
guard isWorkingRepo else { return false }
324365
return queue.sync {
325-
// Check nothing has been changed
326-
guard let result = try? Process.checkNonZeroExit(
327-
args: Git.tool, "-C", path.pathString, "status", "-s") else
328-
{
366+
guard let result = try? callGit("status", "-s") else {
329367
return false
330368
}
331369
return !result.spm_chomp().isEmpty
@@ -340,26 +378,25 @@ public class GitRepository: Repository, WorkingCheckout {
340378

341379
public func hasUnpushedCommits() throws -> Bool {
342380
return try queue.sync {
343-
let hasOutput = try Process.checkNonZeroExit(
344-
args: Git.tool, "-C", path.pathString, "log", "--branches", "--not", "--remotes").spm_chomp().isEmpty
381+
let hasOutput = try callGit("log", "--branches", "--not", "--remotes",
382+
failureMessage: "Couldn’t check for unpushed commits").spm_chomp().isEmpty
345383
return !hasOutput
346384
}
347385
}
348386

349387
public func getCurrentRevision() throws -> Revision {
350388
return try queue.sync {
351-
return try Revision(
352-
identifier: Process.checkNonZeroExit(
353-
args: Git.tool, "-C", path.pathString, "rev-parse", "--verify", "HEAD").spm_chomp())
389+
return try Revision(identifier: callGit("rev-parse", "--verify", "HEAD",
390+
failureMessage: "Couldn’t get current revision").spm_chomp())
354391
}
355392
}
356393

357394
public func checkout(tag: String) throws {
358395
// FIXME: Audit behavior with off-branch tags in remote repositories, we
359396
// may need to take a little more care here.
360397
try queue.sync {
361-
try Process.checkNonZeroExit(
362-
args: Git.tool, "-C", path.pathString, "reset", "--hard", tag)
398+
try callGit("reset", "--hard", tag,
399+
failureMessage: "Couldn’t check out tag ‘\(tag)")
363400
try self.updateSubmoduleAndClean()
364401
}
365402
}
@@ -368,34 +405,32 @@ public class GitRepository: Repository, WorkingCheckout {
368405
// FIXME: Audit behavior with off-branch tags in remote repositories, we
369406
// may need to take a little more care here.
370407
try queue.sync {
371-
try Process.checkNonZeroExit(
372-
args: Git.tool, "-C", path.pathString, "checkout", "-f", revision.identifier)
408+
try callGit("checkout", "-f", revision.identifier,
409+
failureMessage: "Couldn’t check out revision ‘\(revision.identifier)")
373410
try self.updateSubmoduleAndClean()
374411
}
375412
}
376413

377414
/// Initializes and updates the submodules, if any, and cleans left over the files and directories using git-clean.
378415
private func updateSubmoduleAndClean() throws {
379-
try Process.checkNonZeroExit(args: Git.tool,
380-
"-C", path.pathString, "submodule", "update", "--init", "--recursive", environment: Git.environment)
381-
try Process.checkNonZeroExit(args: Git.tool,
382-
"-C", path.pathString, "clean", "-ffdx")
416+
try callGit("submodule", "update", "--init", "--recursive",
417+
failureMessage: "Couldn’t update repository submodules")
418+
try callGit("clean", "-ffdx",
419+
failureMessage: "Couldn’t clean repository submodules")
383420
}
384421

385422
/// Returns true if a revision exists.
386423
public func exists(revision: Revision) -> Bool {
387424
return queue.sync {
388-
let result = try? Process.popen(
389-
args: Git.tool, "-C", path.pathString, "rev-parse", "--verify", revision.identifier)
390-
return result?.exitStatus == .terminated(code: 0)
425+
return (try? callGit("rev-parse", "--verify", revision.identifier)) != nil
391426
}
392427
}
393428

394429
public func checkout(newBranch: String) throws {
395-
precondition(isWorkingRepo, "This operation should run in a working repo.")
430+
precondition(isWorkingRepo, "This operation is only valid in a working repository")
396431
try queue.sync {
397-
try Process.checkNonZeroExit(
398-
args: Git.tool, "-C", path.pathString, "checkout", "-b", newBranch)
432+
try callGit("checkout", "-b", newBranch,
433+
failureMessage: "Couldn’t check out new branch ‘\(newBranch)")
399434
return
400435
}
401436
}
@@ -459,8 +494,8 @@ public class GitRepository: Repository, WorkingCheckout {
459494
}
460495
let response = try queue.sync {
461496
try cachedHashes.memo(key: specifier) {
462-
try Process.checkNonZeroExit(
463-
args: Git.tool, "-C", path.pathString, "rev-parse", "--verify", specifier).spm_chomp()
497+
try callGit("rev-parse", "--verify", specifier,
498+
failureMessage: "Couldn’t get revision ‘\(specifier)").spm_chomp()
464499
}
465500
}
466501
if let hash = Hash(response) {
@@ -754,3 +789,28 @@ private class GitFileSystemView: FileSystem {
754789
fatalError("will never be supported")
755790
}
756791
}
792+
793+
794+
public struct GitRepositoryError: Error, CustomStringConvertible, DiagnosticLocationProviding {
795+
public let path: AbsolutePath
796+
public let message: String
797+
public let result: ProcessResult
798+
799+
public struct Location: DiagnosticLocation {
800+
public let path: AbsolutePath
801+
public var description: String {
802+
return path.pathString
803+
}
804+
}
805+
806+
public var diagnosticLocation: DiagnosticLocation? {
807+
return Location(path: path)
808+
}
809+
810+
public var description: String {
811+
let stdout = (try? result.utf8Output()) ?? ""
812+
let stderr = (try? result.utf8stderrOutput()) ?? ""
813+
let output = (stdout + stderr).spm_chomp().spm_multilineIndent(count: 4)
814+
return "\(message):\n\(output)"
815+
}
816+
}

0 commit comments

Comments
 (0)