Skip to content

Commit 2677d2a

Browse files
committed
Add PackageSearchClient
This offers a generic interface which allows clients to find packages via exact matching of registry identities or URLs, as well as search through collections and index if there are no exact matches.
1 parent ce9189b commit 2677d2a

File tree

3 files changed

+177
-0
lines changed

3 files changed

+177
-0
lines changed

Package.swift

+11
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ let swiftPMDataModelProduct = (
2828
"PackageCollectionsModel",
2929
"PackageGraph",
3030
"PackageLoading",
31+
"PackageMetadata",
3132
"PackageModel",
3233
"SourceControl",
3334
"Workspace",
@@ -335,6 +336,16 @@ let package = Package(
335336
],
336337
exclude: ["CMakeLists.txt"]
337338
),
339+
.target(
340+
// ** High level interface for package discovery */
341+
name: "PackageMetadata",
342+
dependencies: [
343+
"Basics",
344+
"PackageCollections",
345+
"PackageModel",
346+
"PackageRegistry",
347+
]
348+
),
338349

339350
// MARK: Commands
340351

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2022 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Basics
14+
import Dispatch
15+
import PackageCollections
16+
import PackageModel
17+
import PackageRegistry
18+
import SourceControl
19+
20+
import struct Foundation.URL
21+
import protocol TSCBasic.FileSystem
22+
import func TSCBasic.withTemporaryDirectory
23+
24+
public struct Package {
25+
public let identity: PackageIdentity
26+
public let location: String?
27+
public let branches: [String]
28+
public let versions: [Version]
29+
public let readmeURL: URL?
30+
31+
fileprivate init(identity: PackageIdentity, location: String? = nil, branches: [String] = [], versions: [Version], readmeURL: URL? = nil) {
32+
self.identity = identity
33+
self.location = location
34+
self.branches = branches
35+
self.versions = versions
36+
self.readmeURL = readmeURL
37+
}
38+
}
39+
40+
public struct PackageSearchClient {
41+
private let registryClient: RegistryClient
42+
private let fileSystem: FileSystem
43+
private let observabilityScope: ObservabilityScope
44+
45+
public init(
46+
registryClient: RegistryClient,
47+
fileSystem: FileSystem,
48+
observabilityScope: ObservabilityScope
49+
) {
50+
self.registryClient = registryClient
51+
self.fileSystem = fileSystem
52+
self.observabilityScope = observabilityScope
53+
}
54+
55+
var indexAndCollections: PackageIndexAndCollections {
56+
return .init(fileSystem: fileSystem, observabilityScope: observabilityScope)
57+
}
58+
59+
var repositoryProvider: RepositoryProvider {
60+
return GitRepositoryProvider()
61+
}
62+
63+
// FIXME: This matches the current implementation, but we may want be smarter about it?
64+
private func guessReadMeURL(baseURL: URL, defaultBranch: String) -> URL {
65+
return baseURL.appendingPathComponent("raw").appendingPathComponent(defaultBranch).appendingPathComponent("README.md")
66+
}
67+
68+
public func findPackages(
69+
_ query: String,
70+
callback: @escaping (Result<[Package], Error>) -> Void
71+
) {
72+
let identity = PackageIdentity.plain(query)
73+
let isRegistryIdentity = identity.scopeAndName != nil
74+
75+
// Search the package index and collections for a search term.
76+
let search = { (error: Error?) in
77+
self.indexAndCollections.findPackages(query) { result in
78+
do {
79+
let packages = try result.get().items.map { $0.package }.map { Package(identity: $0.identity,
80+
location: $0.location,
81+
versions: $0.versions.map { $0.version },
82+
readmeURL: $0.readmeURL) }
83+
if packages.isEmpty, let error = error {
84+
// If the search result is empty and we had a previous error, emit it now.
85+
return callback(.failure(error))
86+
} else {
87+
return callback(.success(packages))
88+
}
89+
} catch {
90+
return callback(.failure(error))
91+
}
92+
}
93+
}
94+
95+
// Interpret the given search term as a URL and fetch the corresponding Git repository to determine the available version tags and branches. If the search term cannot be interpreted as a URL or there are any errors during the process, we fall back to searching the configured index or package collections.
96+
let fetchStandalonePackageByURL = { (error: Error?) in
97+
guard let url = URL(string: query) else {
98+
return search(error)
99+
}
100+
101+
do {
102+
try withTemporaryDirectory(removeTreeOnDeinit: true) {
103+
let tempPath = $0.appending(component: url.lastPathComponent)
104+
do {
105+
let repositorySpecifier = RepositorySpecifier(url: url)
106+
try self.repositoryProvider.fetch(repository: repositorySpecifier, to: tempPath, progressHandler: nil)
107+
if self.repositoryProvider.isValidDirectory(tempPath), let repository = try self.repositoryProvider.open(repository: repositorySpecifier, at: tempPath) as? GitRepository {
108+
let branches = try repository.getBranches()
109+
let versions = try repository.getTags().compactMap { Version($0) }
110+
let package = Package(identity: .init(url: url),
111+
location: url.absoluteString,
112+
branches: branches,
113+
versions: versions,
114+
readmeURL: self.guessReadMeURL(baseURL: url, defaultBranch: try repository.getDefaultBranch()))
115+
return callback(.success([package]))
116+
}
117+
} catch {
118+
return search(error)
119+
}
120+
}
121+
} catch {
122+
return search(error)
123+
}
124+
}
125+
126+
// If the given search term can be interpreted as a registry identity, try to get package metadata for it from the configured registry. If there are any errors or the search term does not work as a registry identity, we will fall back on `fetchStandalonePackageByURL`.
127+
if isRegistryIdentity {
128+
return self.registryClient.getPackageMetadata(package: identity, observabilityScope: observabilityScope, callbackQueue: DispatchQueue.sharedConcurrent) { result in
129+
do {
130+
let metadata = try result.get()
131+
let readmeURL: URL?
132+
if let alternateURL = metadata.alternateLocations?.first {
133+
// FIXME: This is pretty crude, we should let the registry metadata provide the value instead.
134+
readmeURL = guessReadMeURL(baseURL: alternateURL, defaultBranch: "main")
135+
} else {
136+
readmeURL = nil
137+
}
138+
return callback(.success([Package(identity: identity,
139+
versions: metadata.versions,
140+
readmeURL: readmeURL
141+
)]))
142+
} catch {
143+
return fetchStandalonePackageByURL(error)
144+
}
145+
}
146+
} else {
147+
return fetchStandalonePackageByURL(nil)
148+
}
149+
}
150+
}

Sources/SourceControl/GitRepository.swift

+16
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,7 @@ public final class GitRepository: Repository, WorkingCheckout {
342342
private var cachedBlobs = ThreadSafeKeyValueStore<Hash, ByteString>()
343343
private var cachedTrees = ThreadSafeKeyValueStore<String, Tree>()
344344
private var cachedTags = ThreadSafeBox<[String]>()
345+
private var cachedBranches = ThreadSafeBox<[String]>()
345346

346347
public convenience init(path: AbsolutePath, isWorkingRepo: Bool = true, cancellator: Cancellator? = .none) {
347348
// used in one-off operations on git repo, as such the terminator is not ver important
@@ -424,6 +425,21 @@ public final class GitRepository: Repository, WorkingCheckout {
424425
}
425426
}
426427

428+
// MARK: Helpers for package search functionality
429+
430+
public func getDefaultBranch() throws -> String {
431+
return try callGit("rev-parse", "--abbrev-ref", "HEAD", failureMessage: "Couldn’t get the default branch")
432+
}
433+
434+
public func getBranches() throws -> [String] {
435+
try self.cachedBranches.memoize {
436+
try self.lock.withLock {
437+
let branches = try callGit("branch", "-l", failureMessage: "Couldn’t get the list of branches")
438+
return branches.split(separator: "\n").map { $0.dropFirst(2) }.map(String.init)
439+
}
440+
}
441+
}
442+
427443
// MARK: Repository Interface
428444

429445
/// Returns the tags present in repository.

0 commit comments

Comments
 (0)