Skip to content

Commit da6000e

Browse files
committed
Add a customized GitRepositoryError and funnel all Git operations through a method that throws one of those if the operation fails. This makes sure that Git failures clients can distinguish from other Process errors (so failure handling can be customized), and that they all have a location.
1 parent 88d2516 commit da6000e

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)