From b0559d97261f32125d289026b0c46b015a37d469 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Sun, 12 Mar 2023 23:02:11 -0400 Subject: [PATCH] "wip" --- Makefile | 24 +++ Package.resolved | 27 +++ Package.swift | 16 +- Sources/GitVersion/Client.swift | 38 ---- Sources/GitVersion/FileClient.swift | 50 ++++-- Sources/GitVersion/GitVersionClient.swift | 163 +++++++++++++++++ Sources/GitVersion/LiveKey.swift | 80 --------- Sources/GitVersion/SwiftBuild.swift | 167 ++++++++++++++++++ Sources/build-example/Build.swift | 18 ++ Sources/example/Version.swift | 2 + Sources/example/example.swift | 24 +++ .../swift-git-version/swift_git_version.swift | 19 -- 12 files changed, 472 insertions(+), 156 deletions(-) create mode 100644 Makefile delete mode 100644 Sources/GitVersion/Client.swift create mode 100644 Sources/GitVersion/GitVersionClient.swift delete mode 100644 Sources/GitVersion/LiveKey.swift create mode 100644 Sources/GitVersion/SwiftBuild.swift create mode 100644 Sources/build-example/Build.swift create mode 100644 Sources/example/Version.swift create mode 100644 Sources/example/example.swift delete mode 100644 Sources/swift-git-version/swift_git_version.swift diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..84d036a --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +DOCC_TARGET ?= GitVersion +DOCC_BASEPATH = $(shell basename "$(PWD)") +DOCC_DIR ?= ./docs + +build-and-run: + swift run build-example + ./.build/debug/example --help + ./.build/debug/example + +build-documentation: + swift package \ + --allow-writing-to-directory "$(DOCC_DIR)" \ + generate-documentation \ + --target "$(DOCC_TARGET)" \ + --disable-indexing \ + --transform-for-static-hosting \ + --hosting-base-path "$(DOCC_BASEPATH)" \ + --output-path "$(DOCC_DIR)" + +preview-documentation: + swift package \ + --disable-sandbox \ + preview-documentation \ + --target "$(DOCC_TARGET)" diff --git a/Package.resolved b/Package.resolved index 90864bc..2d20565 100644 --- a/Package.resolved +++ b/Package.resolved @@ -18,6 +18,15 @@ "version" : "4.0.1" } }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", + "version" : "1.2.2" + } + }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", @@ -36,6 +45,24 @@ "version" : "0.2.0" } }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin.git", + "state" : { + "revision" : "10bc670db657d11bdd561e07de30a9041311b2b1", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index f02a232..b269f97 100644 --- a/Package.swift +++ b/Package.swift @@ -7,8 +7,13 @@ let package = Package( platforms: [ .macOS(.v10_15) ], + products: [ + .library(name: "GitVersion", targets: ["GitVersion"]) + ], dependencies: [ - .package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.1.0") + .package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.1.0"), + .package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.2"), ], targets: [ .target( @@ -18,11 +23,18 @@ let package = Package( ] ), .executableTarget( - name: "swift-git-version", + name: "build-example", dependencies: [ "GitVersion" ] ), + .executableTarget( + name: "example", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "ShellClient", package: "swift-shell-client") + ] + ), .testTarget( name: "GitVersionTests", dependencies: ["GitVersion"] diff --git a/Sources/GitVersion/Client.swift b/Sources/GitVersion/Client.swift deleted file mode 100644 index b3393f9..0000000 --- a/Sources/GitVersion/Client.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif -import ShellClient -import XCTestDynamicOverlay - -public struct GitVersionClient { - private var currentVersion: (String?) throws -> String - - public init(currentVersion: @escaping (String?) throws -> String) { - self.currentVersion = currentVersion - } - - public func currentVersion(in gitDirectory: String? = nil) throws -> String { - try self.currentVersion(gitDirectory) - } - - public mutating func override(with version: String) { - self.currentVersion = { _ in version } - } -} - -extension GitVersionClient: TestDependencyKey { - - public static let testValue = GitVersionClient( - currentVersion: unimplemented("\(Self.self).currentVersion", placeholder: "") - ) - -} - -extension DependencyValues { - - public var gitVersionClient: GitVersionClient { - get { self[GitVersionClient.self] } - set { self[GitVersionClient.self] = newValue } - } -} diff --git a/Sources/GitVersion/FileClient.swift b/Sources/GitVersion/FileClient.swift index 95495c3..6f5e0ad 100644 --- a/Sources/GitVersion/FileClient.swift +++ b/Sources/GitVersion/FileClient.swift @@ -1,19 +1,26 @@ import Dependencies import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif import XCTestDynamicOverlay +/// Represents the interactions with the file system. It is able +/// to read from and write to files. +/// +/// ```swift +/// @Dependency(\.fileClient) var fileClient +/// ``` +/// public struct FileClient { - + /// Read the file contents from the given `URL` as `Data`. /// public private(set) var read: (URL) throws -> Data - + /// Write `Data` to a file `URL`. public private(set) var write: (Data, URL) throws -> Void - + /// Create a new ``GitVersion/FileClient`` instance. /// /// This is generally not interacted with directly, instead access as a dependency. @@ -32,7 +39,7 @@ public struct FileClient { self.read = read self.write = write } - + /// Read a file at the given path. /// /// - Parameters: @@ -41,7 +48,7 @@ public struct FileClient { let url = try url(for: path) return try self.read(url) } - + /// Read the file as a string. /// /// - Parameters: @@ -50,7 +57,7 @@ public struct FileClient { let data = try read(url) return String(decoding: data, as: UTF8.self) } - + /// Read the file as a string /// /// - Parameters: @@ -58,10 +65,10 @@ public struct FileClient { public func readAsString(path: String) throws -> String { try self.readAsString(url: url(for: path)) } - + /// Read the contents of a file and decode as the decodable type. /// - /// - Parameters: +/// - Parameters: /// - decodable: The type to decode. /// - url: The file url. /// - decoder: The decoder to use. @@ -73,7 +80,7 @@ public struct FileClient { let data = try read(url) return try decoder.decode(D.self, from: data) } - + /// Read the contents of a file and decode as the decodable type. /// /// - Parameters: @@ -88,7 +95,7 @@ public struct FileClient { let data = try read(path: path) return try decoder.decode(D.self, from: data) } - + /// Write the data to a file at the given path. /// /// - Parameters: @@ -98,32 +105,41 @@ public struct FileClient { let url = try url(for: path) try self.write(data, url) } + + public func write(string: String, to url: URL) throws { + try self.write(Data(string.utf8), url) + } + + public func write(string: String, to path: String) throws { + let url = try url(for: path) + try self.write(string: string, to: url) + } } extension FileClient: DependencyKey { - + /// A ``FileClient`` that does not do anything. public static let noop = FileClient.init( read: { _ in Data() }, write: { _, _ in } ) - + /// An `unimplemented` ``FileClient``. public static let testValue = FileClient( read: unimplemented("\(Self.self).read", placeholder: Data()), write: unimplemented("\(Self.self).write") ) - + /// The live ``FileClient`` public static let liveValue = FileClient( read: { try Data(contentsOf: $0) }, write: { try $0.write(to: $1, options: .atomic) } ) - + } extension DependencyValues { - + /// Access a basic ``FileClient`` that can read / write data to the file system. /// public var fileClient: FileClient { @@ -134,7 +150,7 @@ extension DependencyValues { // MARK: - Overrides extension FileClient { - + /// Override the data that get's returned when a `read` operation is called. /// /// This is useful in a testing context. diff --git a/Sources/GitVersion/GitVersionClient.swift b/Sources/GitVersion/GitVersionClient.swift new file mode 100644 index 0000000..f278699 --- /dev/null +++ b/Sources/GitVersion/GitVersionClient.swift @@ -0,0 +1,163 @@ +import Foundation +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif +import ShellClient +import XCTestDynamicOverlay + +/// A client that can retrieve the current version from a git directory. +/// It will use the current `tag`, or if the current git tree does not +/// point to a commit that is tagged, it will use the `branch git-sha` as +/// the version. +/// +/// This is often not used directly, instead it is used with ``SwiftBuild``er +/// that is supplied with this library. The use case is to set the version of a command line +/// tool based on the current git tag. +/// +/// ```swift +/// @main +/// public struct Build { +/// public static func main() throws { +/// @Dependency(\.shellClient) var shell: ShellClient +/// +/// try shell.replacingNilWithVersionString( +/// in: "Sources/example/Version.swift", +/// build: SwiftBuild.release() +/// ) +/// } +/// } +/// ``` +public struct GitVersionClient { + + /// The closure to run that returns the current version from a given + /// git directory. + private var currentVersion: (String?) throws -> String + + /// Create a new ``GitVersionClient`` instance. + /// + /// This is normally not interacted with directly, instead access the client through the dependency system. + /// ```swift + /// @Dependency(\.gitVersionClient) + /// ``` + /// + /// - Parameters: + /// - currentVersion: The closure that returns the current version. + /// + public init(currentVersion: @escaping (String?) throws -> String) { + self.currentVersion = currentVersion + } + + /// Get the current version from the `git tag` in the given directory. + /// If a directory is not passed in, then we will use the current working directory. + /// + /// - Parameters: + /// - gitDirectory: The directory to run the command in. + public func currentVersion(in gitDirectory: String? = nil) throws -> String { + try self.currentVersion(gitDirectory) + } + + /// Override the `currentVersion` command and return the passed in version string. + /// + /// This is useful for testing purposes. + /// + /// - Parameters: + /// - version: The version string to return when `currentVersion` is called. + public mutating func override(with version: String) { + self.currentVersion = { _ in version } + } +} + +extension GitVersionClient: TestDependencyKey { + + /// The ``GitVersionClient`` used in test / debug builds. + public static let testValue = GitVersionClient( + currentVersion: unimplemented("\(Self.self).currentVersion", placeholder: "") + ) + + /// The ``GitVersionClient`` used in release builds. + public static var liveValue: GitVersionClient { + .init(currentVersion: { gitDirectory in + try GitVersion(workingDirectory: gitDirectory).currentVersion() + }) + } +} + +extension DependencyValues { + + /// A ``GitVersionClient`` that can retrieve the current version from a + /// git directory. + public var gitVersionClient: GitVersionClient { + get { self[GitVersionClient.self] } + set { self[GitVersionClient.self] = newValue } + } +} + +// MARK: - Private +fileprivate struct GitVersion { + @Dependency(\.logger) var logger: Logger + @Dependency(\.shellClient) var shell: ShellClient + + let workingDirectory: String? + + func currentVersion() throws -> String { + logger.debug("\("Fetching current version".bold)") + do { + logger.debug("Checking for tag.") + return try run(command: command(for: .describe)) + } catch { + logger.debug("\("No tag found, deferring to branch & git sha".red)") + let branch = try run(command: command(for: .branch)) + let commit = try run(command: command(for: .commit)) + return "\(branch) \(commit)" + } + } + + internal func command(for argument: VersionArgs) -> ShellCommand { + .init( + shell: .env, + environment: nil, + in: workingDirectory, + argument.arguments + ) + } +} + +fileprivate extension GitVersion { + func run(command: ShellCommand) throws -> String { + try shell.background(command, trimmingCharactersIn: .whitespacesAndNewlines) + } + + enum VersionArgs { + case branch + case commit + case describe + + var arguments: [Args] { + switch self { + case .branch: + return [.git, .symbolicRef, .quiet, .short, .head] + case .commit: + return [.git, .revParse, .short, .head] + case .describe: + return [.git, .describe, .tags, .exactMatch] + } + } + + enum Args: String, CustomStringConvertible { + case git + case describe + case tags = "--tags" + case exactMatch = "--exact-match" + case quiet = "--quiet" + case symbolicRef = "symbolic-ref" + case revParse = "rev-parse" + case short = "--short" + case head = "HEAD" + } + + } +} + +extension RawRepresentable where RawValue == String, Self: CustomStringConvertible { + var description: String { rawValue } +} diff --git a/Sources/GitVersion/LiveKey.swift b/Sources/GitVersion/LiveKey.swift deleted file mode 100644 index a3ef61e..0000000 --- a/Sources/GitVersion/LiveKey.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation -import ShellClient - -extension GitVersionClient: DependencyKey { - - public static var liveValue: GitVersionClient { - .init(currentVersion: { gitDirectory in - try GitVersion(workingDirectory: gitDirectory).currentVersion() - }) - } -} - -fileprivate struct GitVersion { - @Dependency(\.logger) var logger: Logger - @Dependency(\.shellClient) var shell: ShellClient - - let workingDirectory: String? - - func currentVersion() throws -> String { - logger.debug("\("Fetching current version".bold)") - do { - logger.debug("Checking for tag.") - return try run(command: command(for: .describe)) - } catch { - logger.debug("\("No tag found, deferring to branch & git sha".red)") - let branch = try run(command: command(for: .branch)) - let commit = try run(command: command(for: .commit)) - return "\(branch) \(commit)" - } - } - - private func command(for argument: VersionArgs) -> ShellCommand { - .init( - shell: .env, - environment: nil, - in: workingDirectory, - arguments: argument.arguments - ) - } -} - -fileprivate extension GitVersion { - func run(command: ShellCommand) throws -> String { - try shell.background(command, trimmingCharactersIn: .whitespacesAndNewlines) - } - - enum VersionArgs { - case branch - case commit - case describe - - var arguments: [Args] { - switch self { - case .branch: - return [.git, .symbolicRef, .quiet, .short, .head] - case .commit: - return [.git, .revParse, .short, .head] - case .describe: - return [.git, .describe, .tags, .exactMatch] - } - } - - enum Args: String, CustomStringConvertible { - case git - case describe - case tags = "--tags" - case exactMatch = "--exact-match" - case quiet = "--quiet" - case symbolicRef = "symbolic-ref" - case revParse = "rev-parse" - case short = "--short" - case head = "HEAD" - } - - } -} - -fileprivate extension RawRepresentable where RawValue == String, Self: CustomStringConvertible { - var description: String { rawValue } -} diff --git a/Sources/GitVersion/SwiftBuild.swift b/Sources/GitVersion/SwiftBuild.swift new file mode 100644 index 0000000..06c87a7 --- /dev/null +++ b/Sources/GitVersion/SwiftBuild.swift @@ -0,0 +1,167 @@ +import Dependencies +import Foundation +import ShellClient + +extension ShellCommand { + + /// Create a ``ShellCommand`` instance that's a wrapper for the `swift build` + /// command. + /// + /// - Parameters: + /// - build: The build command to use. + public static func swiftBuild(_ build: SwiftBuild) -> Self { + build.command + } +} + +/// A wrapper around the `swift build` command. This allows you to build a project from +/// swift. +/// +public struct SwiftBuild { + @Dependency(\.logger) var logger + @Dependency(\.shellClient) var shell + + /// Represents the default arguments for the build command. + public static let defaults: [Argument] = [ + .disableSandBox, + .xSwiftC("-cross-module-optimization") + ] + + /// The arguments for the build command. + public let arguments: [Argument] + + /// The configuration for the build command. + public let configuration: Configuration + + /// Create a ``SwiftBuild`` instance for the ``Configuration/debug`` configuration. + /// + /// - Parameters: + /// - arguments: The arguments for the `swift build` command. + public static func debug(_ arguments: [Argument] = Self.defaults) -> Self { + .init(configuration: .debug, arguments: arguments) + } + + /// Create a ``SwiftBuild`` instance for the ``Configuration/release`` configuration. + /// + /// - Parameters: + /// - arguments: The arguments for the `swift build` command. + public static func release(_ arguments: [Argument] = Self.defaults) -> Self { + .init(configuration: .release, arguments: arguments) + } + + internal init( + configuration: Configuration = .debug, + arguments: [Argument] = Self.defaults + ) { + self.arguments = arguments + self.configuration = configuration + } + + internal var command: ShellCommand { + .init( + shell: .env, + [ + "swift", + "build", + "--configuration", + "\(configuration)" + ] + + arguments.strings + ) + } + + internal func run() throws { + logger.info("\("Building.".blue)") + + try withDependencies { + $0.logger.logLevel = .debug + } operation: { + try shell.foreground(command) + } + } + + /// Represents an argument for the ``SwiftBuild`` command. + public enum Argument { + /// Disable the sandbox. + case disableSandBox + /// Pass a string through to the `swift build` command. + case custom([String]) + /// Pass an `-Xswiftc` compiler flag through to the `swift build` command. + case xSwiftC(String) + } + + /// Represents the configuration for the ``SwiftBuild`` command. + public enum Configuration: String, CustomStringConvertible, CaseIterable { + /// The debug configuration. + case debug + /// The release configuration. + case release + public var description: String { rawValue } + } +} + +extension ShellClient { + /// Replace nil in the given file path and then run the given closure. + /// + /// > Note: The file contents will be reset back to nil after the closure operation. + /// + /// - Parameters: + /// - filePath: The file path to replace nil in. + /// - workingDirectory: Customize the working directory for the command. + /// - build: The swift builder to use. + public func replacingNilWithVersionString( + in filePath: String, + from workingDirectory: String? = nil, + _ closure: @escaping () throws -> Void + ) throws { + @Dependency(\.fileClient) var fileClient: FileClient + @Dependency(\.gitVersionClient) var gitClient: GitVersionClient + + let currentVersion = try gitClient.currentVersion(in: workingDirectory) + let originalContents = try fileClient.readAsString(path: filePath) + + let updatedContents = originalContents + .replacingOccurrences(of: "nil", with: "\"\(currentVersion)\"") + try fileClient.write(string: updatedContents, to: filePath) + defer { try! fileClient.write(string: originalContents, to: filePath) } + + try closure() + } + + /// Replace nil in the given file path and then build the project, using the + /// given builder. + /// + /// > Note: The file contents will be reset back to nil after the build operation. + /// + /// - Parameters: + /// - filePath: The file path to replace nil in. + /// - workingDirectory: Customize the working directory for the command. + /// - build: The swift builder to use. + public func replacingNilWithVersionString( + in filePath: String, + from workingDirectory: String? = nil, + build: SwiftBuild + ) throws { + try replacingNilWithVersionString( + in: filePath, + from: workingDirectory, + build.run + ) + } +} + +fileprivate extension Array where Element == SwiftBuild.Argument { + + var strings: [String] { + reduce(into: [String]()) { current, argument in + switch argument { + case .disableSandBox: + current.append("--disable-sandbox") + case .custom(let strings): + current += strings + case .xSwiftC(let value): + current += ["-Xswiftc", value] + } + } + } +} diff --git a/Sources/build-example/Build.swift b/Sources/build-example/Build.swift new file mode 100644 index 0000000..2c3ecdf --- /dev/null +++ b/Sources/build-example/Build.swift @@ -0,0 +1,18 @@ +import Foundation +import GitVersion +import ShellClient + +/// Shows the intended use-case for building a command line tool that set's the version +/// based on the tag in the git worktree. + +@main +public struct Build { + public static func main() throws { + @Dependency(\.shellClient) var shell: ShellClient + + try shell.replacingNilWithVersionString( + in: "Sources/example/Version.swift", + build: SwiftBuild.release() + ) + } +} diff --git a/Sources/example/Version.swift b/Sources/example/Version.swift new file mode 100644 index 0000000..2a8f24d --- /dev/null +++ b/Sources/example/Version.swift @@ -0,0 +1,2 @@ +// Do not change this value, it is set by the build script. +let VERSION: String? = nil diff --git a/Sources/example/example.swift b/Sources/example/example.swift new file mode 100644 index 0000000..3876a07 --- /dev/null +++ b/Sources/example/example.swift @@ -0,0 +1,24 @@ +import ArgumentParser +import ShellClient + +/// An example of using the git version client with a command line tool +/// The ``VERSION`` variable get's set during the build process. + +@main +public struct Example: ParsableCommand { + + public static let configuration: CommandConfiguration = .init( + abstract: "An example of using the `GitVersion` command to set the version for a command line app.", + version: VERSION ?? "0.0.0" + ) + + public init() { } + + public func run() throws { + @Dependency(\.logger) var logger: Logger + + let version = (VERSION ?? "0.0.0").blue + logger.info("Version: \(version)") + } + +} diff --git a/Sources/swift-git-version/swift_git_version.swift b/Sources/swift-git-version/swift_git_version.swift deleted file mode 100644 index 01648c5..0000000 --- a/Sources/swift-git-version/swift_git_version.swift +++ /dev/null @@ -1,19 +0,0 @@ -import GitVersion -import ShellClient - -// An example of using the git version client. - -@main -public struct swift_git_version { - public static func main() { - @Dependency(\.logger) var logger - @Dependency(\.gitVersionClient) var client - - do { - let version = try client.currentVersion() - logger.info("Version: \(version.blue)") - } catch { - logger.info("\("Oops, something went wrong".red)") - } - } -}