Compare commits

...

10 Commits

Author SHA1 Message Date
1972260317 feat: Adds git client tests, restructures files.
Some checks failed
CI / macOS (debug, 16.1) (push) Has been cancelled
CI / macOS (release, 16.1) (push) Has been cancelled
CI / Ubuntu (push) Failing after 5s
2024-12-24 14:42:28 -05:00
8aa4b73cab feat: Updating command options. 2024-12-24 10:39:56 -05:00
f2a2374c4f feat: Updates configuration, uses json for configuration files and drops toml support. 2024-12-24 09:02:38 -05:00
7d23172c71 feat: Updating configuration. 2024-12-23 22:46:20 -05:00
11ec829a90 feat: Adds linux tests to ci 2024-12-23 16:49:47 -05:00
2152388ee3 feat: Adds linux tests to ci 2024-12-23 16:48:33 -05:00
0836dbd6da feat: Adds linux tests to ci 2024-12-23 16:39:49 -05:00
e2309f5635 feat: Integrates configuration-client. 2024-12-23 16:30:15 -05:00
c7d349e3cc feat: Updates ci workflows 2024-12-23 14:34:48 -05:00
9b60ba0e6c feat: Commit pre integrating configuration client into cli-client. 2024-12-23 14:26:41 -05:00
36 changed files with 1051 additions and 751 deletions

View File

@@ -3,13 +3,11 @@ requireExistingFile = true
requireExistingSemVar = true
[strategy.semvar.preRelease]
prefix = 'rc'
style = 'custom'
usesGitTag = false
style = 'branch'
[strategy.semvar.preRelease.branch]
includeCommitSha = true
[target.module]
fileName = 'Version.swift'
name = 'cli-version'
name = 'cli-version'

12
.bump-version.json Normal file
View File

@@ -0,0 +1,12 @@
{
"strategy" : {
"branch" : {
"includeCommitSha": true
}
},
"target" : {
"module" : {
"name" : "cli-version"
}
}
}

View File

@@ -7,13 +7,13 @@ on:
jobs:
mac:
name: macOS
runs-on: macos-12
runs-on: macos-15
strategy:
matrix:
xcode: ['14.2']
xcode: ['16.1']
config: ['debug', 'release']
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Select Xcode ${{ matrix.xcode }}
run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
- name: Swift Version
@@ -21,13 +21,16 @@ jobs:
- name: Run ${{ matrix.xcode }} Tests
run: make CONFIG=${{ matrix.config }} test-library
# ubuntu:
# name: Ubuntu
# runs-on: ubuntu-20.04
# steps:
# - uses: swift-actions/setup-swift@v1
# with:
# swift-version: 5.7
# - uses: actions/checkout@v3
# - name: Run Tests
# run: make DOCKER_PLATFORM=linux/amd64 test-linux
ubuntu:
name: Ubuntu
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Setup just.
uses: extractions/setup-just@v2
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup buildx
uses: docker/setup-buildx-action@v3
- name: Run tests.
run: just test-docker

View File

@@ -1,6 +1,6 @@
PLATFORM_MACOS = macOS
CONFIG := debug
DOCC_TARGET ?= CliVersion
DOCC_TARGET ?= CliClient
DOCC_BASEPATH = $(shell basename "$(PWD)")
DOCC_DIR ?= ./docs
SWIFT_VERSION ?= "5.10"

View File

@@ -1,5 +1,5 @@
{
"originHash" : "b7e59cc29214df682ee1aa756371133e94660f8aabd03c193d455ba057ed5d17",
"originHash" : "6ab0a9c883cfa1490d249a344074ad27369033fab78e1a90272ef07339a8c0ab",
"pins" : [
{
"identity" : "combine-schedulers",
@@ -46,6 +46,15 @@
"version" : "1.3.1"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump.git",
"state" : {
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
"version" : "1.3.3"
}
},
{
"identity" : "swift-dependencies",
"kind" : "remoteSourceControl",
@@ -96,8 +105,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/m-housh/swift-shell-client.git",
"state" : {
"revision" : "ea819f41b87aa94e792f028a031f5db786e79a94",
"version" : "0.2.1"
"revision" : "d67f693f92428ef8adecb48c2b26ccac0d2f98cb",
"version" : "0.2.2"
}
},
{
@@ -109,15 +118,6 @@
"version" : "600.0.1"
}
},
{
"identity" : "tomlkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/LebJe/TOMLKit.git",
"state" : {
"revision" : "ec6198d37d495efc6acd4dffbd262cdca7ff9b3f",
"version" : "0.6.0"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",

View File

@@ -9,29 +9,34 @@ let package = Package(
],
products: [
.library(name: "CliClient", targets: ["CliClient"]),
.library(name: "ConfigurationClient", targets: ["ConfigurationClient"]),
.library(name: "FileClient", targets: ["FileClient"]),
.library(name: "GitClient", targets: ["GitClient"]),
.plugin(name: "BuildWithVersionPlugin", targets: ["BuildWithVersionPlugin"]),
.plugin(name: "GenerateVersionPlugin", targets: ["GenerateVersionPlugin"]),
.plugin(name: "UpdateVersionPlugin", targets: ["UpdateVersionPlugin"])
],
dependencies: [
.package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.6.2"),
.package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.2.0"),
.package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.2.2"),
.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.0.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"),
.package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.5.0")
.package(url: "https://github.com/pointfreeco/swift-custom-dump.git", from: "1.3.3")
],
targets: [
.executableTarget(
name: "cli-version",
dependencies: [
"CliClient",
.product(name: "ArgumentParser", package: "swift-argument-parser")
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "CustomDump", package: "swift-custom-dump")
]
),
.target(
name: "CliClient",
dependencies: [
"ConfigurationClient",
"FileClient",
"GitClient",
.product(name: "Logging", package: "swift-log")
@@ -45,9 +50,9 @@ let package = Package(
name: "ConfigurationClient",
dependencies: [
"FileClient",
.product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "TOMLKit", package: "TOMLKit")
.product(name: "DependenciesMacros", package: "swift-dependencies")
]
),
.testTarget(
@@ -70,6 +75,10 @@ let package = Package(
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.testTarget(
name: "GitClientTests",
dependencies: ["GitClient"]
),
.target(name: "TestSupport"),
.plugin(
name: "BuildWithVersionPlugin",

View File

@@ -1,3 +1,4 @@
import ConfigurationClient
import Dependencies
import DependenciesMacros
import FileClient
@@ -26,68 +27,65 @@ public struct CliClient: Sendable {
/// Generate a version file with an optional version that can be set manually.
public var generate: @Sendable (SharedOptions) async throws -> String
/// Parse the configuration options.
public var parsedConfiguration: @Sendable (SharedOptions) async throws -> Configuration
public enum BumpOption: Sendable, CaseIterable {
case major, minor, patch, preRelease
}
public enum PreReleaseStrategy: Equatable, Sendable {
/// Use output of tag, with branch and commit sha.
case branchAndCommit
/// Provide a custom pre-release tag.
indirect case custom(String, PreReleaseStrategy? = nil)
/// Use the output of `git describe --tags`
case tag
}
public enum VersionStrategy: Equatable, Sendable {
case branchAndCommit
case semVar(SemVarOptions)
public struct SemVarOptions: Equatable, Sendable {
let preReleaseStrategy: PreReleaseStrategy?
let requireExistingFile: Bool
let requireExistingSemVar: Bool
public init(
preReleaseStrategy: PreReleaseStrategy? = nil,
requireExistingFile: Bool = true,
requireExistingSemVar: Bool = true
) {
self.preReleaseStrategy = preReleaseStrategy
self.requireExistingFile = requireExistingFile
self.requireExistingSemVar = requireExistingSemVar
}
}
}
public struct SharedOptions: Equatable, Sendable {
let allowPreReleaseTag: Bool
let dryRun: Bool
let gitDirectory: String?
let logLevel: Logger.Level
let preReleaseStrategy: PreReleaseStrategy?
let target: String
let versionStrategy: VersionStrategy
let target: Configuration.Target?
let branch: Configuration.Branch?
let semvar: Configuration.SemVar?
let configurationFile: String?
public init(
allowPreReleaseTag: Bool = true,
dryRun: Bool = false,
gitDirectory: String? = nil,
logLevel: Logger.Level = .debug,
preReleaseStrategy: PreReleaseStrategy? = nil,
target: String,
versionStrategy: VersionStrategy = .semVar(.init())
target: Configuration.Target? = nil,
branch: Configuration.Branch? = nil,
semvar: Configuration.SemVar? = nil,
configurationFile: String? = nil
) {
self.allowPreReleaseTag = allowPreReleaseTag
self.dryRun = dryRun
self.target = target
self.gitDirectory = gitDirectory
self.logLevel = logLevel
self.preReleaseStrategy = preReleaseStrategy
self.versionStrategy = versionStrategy
self.target = target
self.branch = branch
self.semvar = semvar
self.configurationFile = configurationFile
}
var allowPreReleaseTag: Bool { preReleaseStrategy != nil }
public init(
allowPreReleaseTag: Bool = true,
dryRun: Bool = false,
gitDirectory: String? = nil,
verbose: Int,
target: Configuration.Target? = nil,
branch: Configuration.Branch? = nil,
semvar: Configuration.SemVar? = nil,
configurationFile: String? = nil
) {
self.init(
allowPreReleaseTag: allowPreReleaseTag,
dryRun: dryRun,
gitDirectory: gitDirectory,
logLevel: .init(verbose: verbose),
target: target,
branch: branch,
semvar: semvar,
configurationFile: configurationFile
)
}
}
}
@@ -99,7 +97,10 @@ extension CliClient: DependencyKey {
.init(
build: { try await $0.build(environment) },
bump: { try await $1.bump($0) },
generate: { try await $0.generate() }
generate: { try await $0.generate() },
parsedConfiguration: { options in
try await options.withMergedConfiguration { $0 }
}
)
}

View File

@@ -7,25 +7,25 @@ Learn how to integrate the plugins into your project
Use the plugins by including as a package to your project and declaring in the `plugins` section of
your target.
> Note: You must use swift-tools version 5.6 or greater for package plugins and
> target `macOS(.v10_15)` or greater.
> Note: You must use swift-tools version 5.6 or greater for package plugins and target `macOS(.v13)`
> or greater.
```swift
// swift-tools-version: 5.7
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
platforms:[.macOS(.v10_15)],
platforms:[.macOS(.v13)],
dependencies: [
...,
.package(url: "https://github.com/m-housh/swift-cli-version.git", from: "0.1.0")
.package(url: "https://github.com/m-housh/swift-cli-version.git", from: "0.2.0")
],
targets: [
.executableTarget(
name: "<target name>",
dependencies: [...],
plugins: [
plugins: [
.plugin(name: "BuildWithVersionPlugin", package: "swift-cli-version")
]
)
@@ -33,8 +33,8 @@ let package = Package(
)
```
The above example uses the build tool plugin. The `BuildWithVersionPlugin` will give you access
to a `VERSION` variable in your project that you can use to supply the version of the tool.
The above example uses the build tool plugin. The `BuildWithVersionPlugin` will give you access to a
`VERSION` variable in your project that you can use to supply the version of the tool.
### Example
@@ -42,27 +42,27 @@ to a `VERSION` variable in your project that you can use to supply the version o
import ArgumentParser
@main
struct MyCliTool: ParsableCommand {
struct MyCliTool: ParsableCommand {
static let configuration = CommandConfiguration(
abstract: "My awesome cli tool",
version: VERSION
)
func run() throws {
func run() throws {
print("Version: \(VERSION)")
}
}
```
After you enable the plugin, you will have access to the `VERSION` string variable even though it is
After you enable the plugin, you will have access to the `VERSION` string variable even though it is
not declared in your source files.
![Trust & Enable](trust)
> Note: If your `DerivedData` folder lives in a directory that is a mounted volume / or somewhere
> that is not under your home folder then you may get build failures using the build tool
> plugin, it will work if you build from the command line and pass the `--disable-sandbox` flag to the
> build command or use one of the manual methods.
> that is not under your home folder then you may get build failures using the build tool plugin, it
> will work if you build from the command line and pass the `--disable-sandbox` flag to the build
> command or use one of the manual methods.
## See Also

View File

@@ -1,6 +1,6 @@
# ``CliVersion``
# CliClient
Derive a version for a command line tool from git tags or a git sha.
Derive a version for a command-line tool from git tags or a git sha.
## Additional Resources
@@ -9,15 +9,10 @@ Derive a version for a command line tool from git tags or a git sha.
## Overview
This tool exposes several plugins that can be used to derive a version for a command line program at
build time or by manually running the plugin. The version is derived from git tags and falling back to
the branch and git sha if a tag is not set for the current worktree state.
build time or by manually running the plugin. The version is derived from git tags and falling back
to the branch and git sha if a tag is not set for the current worktree state.
## Articles
- <doc:GettingStarted>
- <doc:ManualPlugins>
## Api
- ``FileClient``
- ``GitVersionClient``

View File

@@ -0,0 +1,70 @@
import ConfigurationClient
import Dependencies
import FileClient
import Foundation
extension Configuration {
func mergingTarget(_ otherTarget: Configuration.Target?) -> Self {
.init(
target: otherTarget ?? target,
strategy: strategy
)
}
func mergingStrategy(_ otherStrategy: Configuration.VersionStrategy?) -> Self {
.init(
target: target,
strategy: strategy?.merging(otherStrategy)
)
}
}
extension Configuration.Branch {
func merging(_ other: Self?) -> Self {
return .init(includeCommitSha: other?.includeCommitSha ?? includeCommitSha)
}
}
extension Configuration.SemVar {
func merging(_ other: Self?) -> Self {
.init(
preRelease: other?.preRelease ?? preRelease,
requireExistingFile: other?.requireExistingFile ?? requireExistingFile,
requireExistingSemVar: other?.requireExistingSemVar ?? requireExistingSemVar
)
}
}
extension Configuration.VersionStrategy {
func merging(_ other: Self?) -> Self {
guard let branch else {
guard let semvar else { return self }
return .semvar(semvar.merging(other?.semvar))
}
return .branch(branch.merging(other?.branch))
}
}
extension Configuration {
func merging(_ other: Self?) -> Self {
var output = self
output = output.mergingTarget(other?.target)
output = output.mergingStrategy(other?.strategy)
return output
}
}
@discardableResult
func withConfiguration<T>(
path: String?,
_ operation: (Configuration) async throws -> T
) async throws -> T {
@Dependency(\.configurationClient) var configurationClient
let configuration = try await configurationClient.findAndLoad(
path != nil ? URL(filePath: path!) : nil
)
return try await operation(configuration)
}

View File

@@ -0,0 +1,195 @@
import ConfigurationClient
import Dependencies
import Foundation
import GitClient
extension Configuration {
func targetUrl(gitDirectory: String?) throws -> URL {
guard let target else {
throw ConfigurationParsingError.targetNotFound
}
return try target.url(gitDirectory: gitDirectory)
}
func currentVersion(targetUrl: URL, gitDirectory: String?) async throws -> CurrentVersionContainer {
guard let strategy else {
throw ConfigurationParsingError.versionNotFound
}
return try await strategy.currentVersion(
targetUrl: targetUrl,
gitDirectory: gitDirectory
)
}
}
extension Configuration.Target {
func url(gitDirectory: String?) throws -> URL {
@Dependency(\.logger) var logger
let filePath: String
if let path {
filePath = path
} else {
guard let module else {
throw ConfigurationParsingError.pathOrModuleNotSet
}
var path = module.name
logger.debug("module.name: \(path)")
if path.hasPrefix("./") {
path = String(path.dropFirst(2))
}
if !path.hasPrefix("Sources") {
logger.debug("no prefix")
path = "Sources/\(path)"
}
filePath = "\(path)/\(module.fileName)"
}
if let gitDirectory {
return URL(filePath: "\(gitDirectory)/\(filePath)")
}
return URL(filePath: filePath)
}
}
extension GitClient {
func version(branch: Configuration.Branch, gitDirectory: String?) async throws -> String {
@Dependency(\.gitClient) var gitClient
return try await gitClient.version(.init(
gitDirectory: gitDirectory,
style: .branch(commitSha: branch.includeCommitSha)
)).description
}
}
extension Configuration.PreRelease {
func preReleaseString(gitDirectory: String?) async throws -> String {
@Dependency(\.gitClient) var gitClient
let preReleaseString: String
if let branch = strategy?.branch {
preReleaseString = try await gitClient.version(branch: branch, gitDirectory: gitDirectory)
} else {
preReleaseString = try await gitClient.version(.init(
gitDirectory: gitDirectory,
style: .tag(exactMatch: false)
)).description
}
if let prefix {
return "\(prefix)-\(preReleaseString)"
}
return preReleaseString
}
}
@_spi(Internal)
public extension Configuration.SemVar {
private func applyingPreRelease(_ semVar: SemVar, _ gitDirectory: String?) async throws -> SemVar {
@Dependency(\.logger) var logger
logger.trace("Start apply pre-release to: \(semVar)")
guard let preReleaseStrategy = self.preRelease else {
logger.trace("No pre-release strategy, returning original semvar.")
return semVar
}
let preRelease = try await preReleaseStrategy.preReleaseString(gitDirectory: gitDirectory)
logger.trace("Pre-release string: \(preRelease)")
return semVar.applyingPreRelease(preRelease)
}
func currentVersion(file: URL, gitDirectory: String? = nil) async throws -> CurrentVersionContainer.Version {
@Dependency(\.fileClient) var fileClient
@Dependency(\.gitClient) var gitClient
@Dependency(\.logger) var logger
let fileOutput = try? await fileClient.semVar(file: file, gitDirectory: gitDirectory)
var semVar = fileOutput?.semVar
logger.trace("file output semvar: \(String(describing: semVar))")
let usesOptionalType = fileOutput?.usesOptionalType
// We parsed a semvar from the existing file, use it.
if semVar != nil {
return try await .semVar(
applyingPreRelease(semVar!, gitDirectory),
usesOptionalType: usesOptionalType ?? false
)
}
if requireExistingFile {
logger.debug("Failed to parse existing file, and caller requires it.")
throw CliClientError.fileDoesNotExist(path: file.cleanFilePath)
}
logger.trace("Does not require existing file, checking git-tag.")
// Didn't have existing semVar loaded from file, so check for git-tag.
semVar = try await gitClient.version(.init(
gitDirectory: gitDirectory,
style: .tag(exactMatch: false)
)).semVar
if semVar != nil {
return try await .semVar(
applyingPreRelease(semVar!, gitDirectory),
usesOptionalType: usesOptionalType ?? false
)
}
if requireExistingSemVar {
logger.trace("Caller requires existing semvar and it was not found in file or git-tag.")
throw CliClientError.semVarNotFound
}
// Semvar doesn't exist, so create a new one.
logger.trace("Generating new semvar.")
return try await .semVar(
applyingPreRelease(.init(), gitDirectory),
usesOptionalType: usesOptionalType ?? false
)
}
}
extension Configuration.VersionStrategy {
func currentVersion(targetUrl: URL, gitDirectory: String?) async throws -> CurrentVersionContainer {
@Dependency(\.gitClient) var gitClient
guard let branch else {
guard let semvar else {
throw ConfigurationParsingError.versionStrategyError(
message: "Neither branch nor semvar set on configuration."
)
}
return try await .init(
targetUrl: targetUrl,
version: semvar.currentVersion(file: targetUrl, gitDirectory: gitDirectory)
)
}
return try await .init(
targetUrl: targetUrl,
version: .string(
gitClient.version(branch: branch, gitDirectory: gitDirectory)
)
)
}
}
enum ConfigurationParsingError: Error {
case targetNotFound
case pathOrModuleNotSet
case versionStrategyError(message: String)
case versionNotFound
}

View File

@@ -34,6 +34,7 @@ public extension FileClient {
guard let versionString else {
throw CliClientError.failedToParseVersionFile
}
logger.debug("Parsed version string: \(versionString)")
return (String(versionString), isOptional)
}
@@ -41,8 +42,11 @@ public extension FileClient {
file: URL,
gitDirectory: String?
) async throws -> (semVar: SemVar?, usesOptionalType: Bool) {
@Dependency(\.logger) var logger
let (string, usesOptionalType) = try await getVersionString(fileUrl: file, gitDirectory: gitDirectory)
return (SemVar(string: string), usesOptionalType)
let semvar = SemVar(string: string)
logger.debug("Semvar: \(String(describing: semvar))")
return (semvar, usesOptionalType)
}
}

View File

@@ -1,3 +1,4 @@
import ConfigurationClient
import Dependencies
import FileClient
import Foundation
@@ -6,28 +7,23 @@ import GitClient
@_spi(Internal)
public extension CliClient.SharedOptions {
func parseTargetUrl() async throws -> URL {
@Dependency(\.fileClient) var fileClient
// Merges any configuration set via the passed in options.
@discardableResult
func withMergedConfiguration<T>(
operation: (Configuration) async throws -> T
) async throws -> T {
try await withConfiguration(path: configurationFile) { configuration in
var configuration = configuration
configuration = configuration.mergingTarget(target)
let target = target.hasPrefix(".") ? String(target.dropFirst()) : target
let targetHasSources = target.hasPrefix("Sources") || target.hasPrefix("/Sources")
var url = url(for: gitDirectory ?? (targetHasSources ? target : "Sources"))
if gitDirectory != nil {
if !targetHasSources {
url.appendPathComponent("Sources")
if configuration.strategy?.branch != nil, let branch {
configuration = configuration.mergingStrategy(.branch(branch))
} else if let semvar {
configuration = configuration.mergingStrategy(.semvar(semvar))
}
url.appendPathComponent(target)
return try await operation(configuration)
}
let isDirectory = try await fileClient.isDirectory(url.cleanFilePath)
if isDirectory {
url.appendPathComponent(Constants.defaultFileName)
}
return url
}
@discardableResult
@@ -37,19 +33,27 @@ public extension CliClient.SharedOptions {
try await withDependencies {
$0.logger.logLevel = logLevel
} operation: {
@Dependency(\.logger) var logger
// Load the default configuration, if it exists.
try await withMergedConfiguration { configuration in
@Dependency(\.logger) var logger
let targetUrl = try await parseTargetUrl()
logger.debug("Target: \(targetUrl.cleanFilePath)")
logger.debug("Configuration: \(configuration)")
try await operation(
versionStrategy.currentVersion(
file: targetUrl,
gitDirectory: gitDirectory
// This will fail if the target url is not set properly.
let targetUrl = try configuration.targetUrl(gitDirectory: gitDirectory)
logger.debug("Target: \(targetUrl.cleanFilePath)")
// Perform the operation, which generates the new version and writes it.
try await operation(
configuration.currentVersion(
targetUrl: targetUrl,
gitDirectory: gitDirectory
)
)
)
return targetUrl.cleanFilePath
// Return the file path we wrote the version to.
return targetUrl.cleanFilePath
}
}
}
@@ -124,9 +128,11 @@ extension CliClient.SharedOptions {
switch container.version {
case .string: // When we did not parse a semVar, just write whatever we parsed for the current version.
logger.debug("Failed to parse semvar, but got current version string.")
try await write(container)
case let .semVar(semVar, usesOptionalType: usesOptionalType):
logger.debug("Semvar prior to bumping: \(semVar)")
let bumped = semVar.bump(type, preRelease: nil) // preRelease is already set on semVar.
let version = bumped.versionString(withPreReleaseTag: allowPreReleaseTag)
logger.debug("Bumped version: \(version)")

View File

@@ -37,16 +37,30 @@ public struct SemVar: CustomStringConvertible, Equatable, Sendable {
}
public init?(string: String) {
@Dependency(\.logger) var logger
logger.trace("Parsing semvar from: \(string)")
let parts = string.split(separator: ".")
logger.trace("parts: \(parts)")
guard parts.count >= 3 else {
return nil
}
let major = Int(String(parts[0].replacingOccurrences(of: "\"", with: "")))
let minor = Int(String(parts[1]))
let patchParts = parts[2].split(separator: "-")
let major = Int(String(parts[0].replacingOccurrences(of: "\"", with: "")))
logger.trace("major: \(String(describing: major))")
let minor = Int(String(parts[1]))
logger.trace("minor: \(String(describing: minor))")
let patchParts = parts[2].replacingOccurrences(of: "\"", with: "").split(separator: "-")
logger.trace("patchParts: \(String(describing: patchParts))")
let patch = Int(patchParts.first ?? "0")
logger.trace("patch: \(String(describing: patch))")
let preRelease = String(patchParts.dropFirst().joined(separator: "-"))
logger.trace("preRelease: \(String(describing: preRelease))")
self.init(
major: major ?? 0,

View File

@@ -1,103 +0,0 @@
import Dependencies
import FileClient
import struct Foundation.URL
import GitClient
@_spi(Internal)
public extension CliClient.PreReleaseStrategy {
func preReleaseString(gitDirectory: String?) async throws -> String {
@Dependency(\.gitClient) var gitClient
switch self {
case let .custom(string, child):
guard let child else { return string }
return try await "\(string)-\(child.preReleaseString(gitDirectory: gitDirectory))"
case .tag:
return try await gitClient.version(.init(
gitDirectory: gitDirectory,
style: .tag(exactMatch: false)
)).description
case .branchAndCommit:
return try await gitClient.version(.init(
gitDirectory: gitDirectory,
style: .branch(commitSha: true)
)).description
}
}
}
@_spi(Internal)
public extension CliClient.VersionStrategy.SemVarOptions {
private func applyingPreRelease(_ semVar: SemVar, _ gitDirectory: String?) async throws -> SemVar {
guard let preReleaseStrategy else { return semVar }
let preRelease = try await preReleaseStrategy.preReleaseString(gitDirectory: gitDirectory)
return semVar.applyingPreRelease(preRelease)
}
func currentVersion(file: URL, gitDirectory: String? = nil) async throws -> CurrentVersionContainer.Version {
@Dependency(\.fileClient) var fileClient
@Dependency(\.gitClient) var gitClient
let fileOutput = try? await fileClient.semVar(file: file, gitDirectory: gitDirectory)
var semVar = fileOutput?.semVar
let usesOptionalType = fileOutput?.usesOptionalType
if requireExistingFile {
guard let semVar else {
throw CliClientError.fileDoesNotExist(path: file.cleanFilePath)
}
return try await .semVar(
applyingPreRelease(semVar, gitDirectory),
usesOptionalType: usesOptionalType ?? false
)
}
// Didn't have existing semVar loaded from file, so check for git-tag.
semVar = try await gitClient.version(.init(
gitDirectory: gitDirectory,
style: .tag(exactMatch: false)
)).semVar
if requireExistingSemVar {
guard let semVar else {
fatalError()
}
return try await .semVar(
applyingPreRelease(semVar, gitDirectory),
usesOptionalType: usesOptionalType ?? false
)
}
return try await .semVar(
applyingPreRelease(.init(), gitDirectory),
usesOptionalType: usesOptionalType ?? false
)
}
}
@_spi(Internal)
public extension CliClient.VersionStrategy {
func currentVersion(file: URL, gitDirectory: String?) async throws -> CurrentVersionContainer {
@Dependency(\.gitClient) var gitClient
switch self {
case .branchAndCommit:
return try await .init(
targetUrl: file,
version: .string(
gitClient.version(.init(
gitDirectory: gitDirectory,
style: .branch(commitSha: true)
)).description
)
)
case let .semVar(options):
return try await .init(
targetUrl: file,
version: options.currentVersion(file: file, gitDirectory: gitDirectory)
)
}
}
}

View File

@@ -1,7 +1,6 @@
import Dependencies
import DependenciesMacros
import Foundation
import TOMLKit
public extension DependencyValues {
var coders: Coders {
@@ -14,17 +13,13 @@ public extension DependencyValues {
public struct Coders: Sendable {
public var jsonDecoder: @Sendable () -> JSONDecoder = { .init() }
public var jsonEncoder: @Sendable () -> JSONEncoder = { .init() }
public var tomlDecoder: @Sendable () -> TOMLDecoder = { .init() }
public var tomlEncoder: @Sendable () -> TOMLEncoder = { .init() }
}
extension Coders: DependencyKey {
public static var testValue: Coders {
.init(
jsonDecoder: { .init() },
jsonEncoder: { defaultJsonEncoder },
tomlDecoder: { .init() },
tomlEncoder: { .init() }
jsonEncoder: { defaultJsonEncoder }
)
}

View File

@@ -1,5 +1,5 @@
import CustomDump
import Foundation
import TOMLKit
/// Represents configuration that can be set via a file, generally in the root of the
/// project directory.
@@ -15,24 +15,26 @@ public struct Configuration: Codable, Equatable, Sendable {
public init(
target: Target? = nil,
strategy: VersionStrategy? = .init(semvar: .init())
strategy: VersionStrategy? = .semvar(.init())
) {
self.target = target
self.strategy = strategy
}
public static var mock: Self {
public static var `default`: Self { .mock(module: "<my-tool>") }
public static func mock(module: String = "cli-version") -> Self {
.init(
target: .init(module: .init("cli-version")),
strategy: .init()
target: .init(module: .init(module)),
strategy: .semvar(.init())
)
}
public static var customPreRelease: Self {
.init(
target: .init(module: .init("cli-version")),
strategy: .init(semvar: .init(
preRelease: .customBranchPrefix("rc")
strategy: .semvar(.init(
preRelease: .init(prefix: "rc", strategy: .branch())
))
)
}
@@ -42,12 +44,12 @@ public extension Configuration {
/// Represents a branch version or pre-release strategy.
///
/// This derives the version from the branch name and short version
/// of the commit sha if configured.
/// This derives the version or pre-release suffix from the branch name and
/// optionally the short version of the commit sha.
struct Branch: Codable, Equatable, Sendable {
/// Include the commit sha in the output for this strategy.
let includeCommitSha: Bool
public let includeCommitSha: Bool
/// Create a new branch strategy.
///
@@ -63,67 +65,29 @@ public extension Configuration {
/// This appends a suffix to the version that get's generated from the version strategy.
/// For example: `1.0.0-rc-1`
///
struct PreReleaseStrategy: Codable, Equatable, Sendable {
struct PreRelease: Codable, Equatable, Sendable {
/// Use branch and commit sha as pre-release suffix.
public let branch: Branch?
/// Use a custom prefix string.
public let prefix: String?
public let strategy: Strategy?
/// An identifier for the type of pre-release.
public let style: StyleId
/// Whether we use `git describe --tags` for part of the suffix, this is only used
/// if we have a custom style.
public let usesGitTag: Bool
init(
style: StyleId,
branch: Branch? = nil,
public init(
prefix: String? = nil,
usesGitTag: Bool = false
strategy: Strategy? = nil
) {
self.branch = branch
self.prefix = prefix
self.style = style
self.usesGitTag = usesGitTag
self.strategy = strategy
}
/// Represents a pre-release strategy that is derived from calling
/// `git describe --tags`.
public static let gitTag = Self(style: StyleId.gitTag)
/// Represents a pre-release strategy that is derived from the branch and commit sha.
public static func branch(_ branch: Branch = .init()) -> Self {
.init(style: .branch, branch: branch)
}
/// Represents a custom strategy that uses the given value, not deriving any other
/// data.
public static func custom(_ prefix: String) -> Self {
.init(style: .custom, prefix: prefix)
}
/// Represents a custom strategy that uses a prefix along with the branch and
/// commit sha.
public static func customBranchPrefix(
_ prefix: String,
branch: Branch = .init()
) -> Self {
.init(style: .custom, branch: branch, prefix: prefix)
}
/// Represents a custom strategy that uses a prefix along with the output from
/// calling `git describe --tags`.
public static func customGitTagPrefix(_ prefix: String) -> Self {
.init(style: StyleId.custom, prefix: prefix, usesGitTag: true)
}
public enum StyleId: String, Codable, Sendable {
case branch
case custom
public enum Strategy: Codable, Equatable, Sendable {
case branch(includeCommitSha: Bool = true)
case command(arguments: [String])
case gitTag
public var branch: Branch? {
guard case let .branch(includeCommitSha) = self
else { return nil }
return .init(includeCommitSha: includeCommitSha)
}
}
}
@@ -134,7 +98,7 @@ public extension Configuration {
struct SemVar: Codable, Equatable, Sendable {
/// Optional pre-releas suffix strategy.
public let preRelease: PreReleaseStrategy?
public let preRelease: PreRelease?
/// Fail if an existing version file does not exist in the target.
public let requireExistingFile: Bool
@@ -143,7 +107,7 @@ public extension Configuration {
public let requireExistingSemVar: Bool
public init(
preRelease: PreReleaseStrategy? = nil,
preRelease: PreRelease? = nil,
requireExistingFile: Bool = true,
requireExistingSemVar: Bool = true
) {
@@ -158,7 +122,7 @@ public extension Configuration {
///
/// This can either be a path to a version file or a module used to
/// locate the version file.
struct Target: Codable, Equatable, Sendable {
struct Target: Codable, Equatable, Sendable, CustomDumpReflectable {
/// The path to a version file.
public let path: String?
@@ -206,6 +170,27 @@ public extension Configuration {
self.name = name
self.fileName = fileName
}
}
public var customDumpMirror: Mirror {
guard let module else {
guard let path else { return .init(reflecting: self) }
return .init(
self,
children: [
"path": path
],
displayStyle: .struct
)
}
return .init(
self,
children: [
"module": module
],
displayStyle: .struct
)
}
}
@@ -214,31 +199,52 @@ public extension Configuration {
/// Typically a `SemVar` strategy or `Branch`.
///
///
struct VersionStrategy: Codable, Equatable, Sendable {
enum VersionStrategy: Codable, Equatable, Sendable, CustomDumpReflectable {
case branch(includeCommitSha: Bool = true)
/// Set if we're using the branch and commit sha to derive the version.
let branch: Branch?
case semvar(
preRelease: PreRelease? = nil,
requireExistingFile: Bool? = nil,
requireExistingSemVar: Bool? = nil
)
/// Set if we're using semvar to derive the version.
let semvar: SemVar?
public var branch: Branch? {
guard case let .branch(includeCommitSha) = self
else { return nil }
/// Create a new version strategy that uses branch and commit sha to derive the version.
///
/// - Parameters:
/// - branch: The branch strategy options.
public init(branch: Branch) {
self.branch = branch
self.semvar = nil
return .init(includeCommitSha: includeCommitSha)
}
/// Create a new version strategy that uses semvar to derive the version.
///
/// - Parameters:
/// - semvar: The semvar strategy options.
public init(semvar: SemVar = .init()) {
self.branch = nil
self.semvar = semvar
public var semvar: SemVar? {
guard case let .semvar(preRelease, requireExistingFile, requireExistingSemVar) = self
else { return nil }
return .init(
preRelease: preRelease,
requireExistingFile: requireExistingFile ?? false,
requireExistingSemVar: requireExistingSemVar ?? false
)
}
public static func branch(_ branch: Branch) -> Self {
.branch(includeCommitSha: branch.includeCommitSha)
}
public static func semvar(_ value: SemVar) -> Self {
.semvar(
preRelease: value.preRelease,
requireExistingFile: value.requireExistingFile,
requireExistingSemVar: value.requireExistingSemVar
)
}
public var customDumpMirror: Mirror {
switch self {
case .branch:
return .init(self, children: ["branch": branch!], displayStyle: .struct)
case .semvar:
return .init(self, children: ["semvar": semvar!], displayStyle: .struct)
}
}
}
}

View File

@@ -17,20 +17,20 @@ public extension DependencyValues {
public struct ConfigurationClient: Sendable {
/// Find a configuration file in the given directory or in current working directory.
public var find: @Sendable (URL?) async throws -> ConfigruationFile?
public var find: @Sendable (URL?) async throws -> URL?
/// Load a configuration file.
public var load: @Sendable (ConfigruationFile) async throws -> Configuration
public var load: @Sendable (URL) async throws -> Configuration
/// Write a configuration file.
public var write: @Sendable (Configuration, ConfigruationFile) async throws -> Void
public var write: @Sendable (Configuration, URL) async throws -> Void
/// Find a configuration file and load it if found.
public func findAndLoad(_ url: URL? = nil) async throws -> Configuration {
guard let url = try await find(url) else {
guard let url = try? await find(url) else {
throw ConfigurationClientError.configurationNotFound
}
return try await load(url)
return (try? await load(url)) ?? .default
}
}
@@ -40,43 +40,48 @@ extension ConfigurationClient: DependencyKey {
public static var liveValue: ConfigurationClient {
.init(
find: { try await findConfiguration($0) },
load: { try await $0.load() ?? .mock },
write: { try await $1.write($0) }
load: { try await loadConfiguration($0) },
write: { try await writeConfiguration($0, to: $1) }
)
}
}
private func findConfiguration(_ url: URL?) async throws -> ConfigruationFile? {
private func findConfiguration(_ url: URL?) async throws -> URL? {
@Dependency(\.fileClient) var fileClient
let defaultFileName = ConfigurationClient.Constants.defaultFileNameWithoutExtension
var url: URL! = url
if url == nil {
url = try await URL(filePath: fileClient.currentDirectory())
}
// Check if url is a valid configuration url.
var configurationFile = ConfigruationFile(url: url)
if let configurationFile { return configurationFile }
guard try await fileClient.isDirectory(url.cleanFilePath) else {
throw ConfigurationClientError.invalidConfigurationDirectory(path: url.cleanFilePath)
if try await fileClient.isDirectory(url.cleanFilePath) {
url = url.appending(path: "\(defaultFileName).json")
}
// Check for toml file.
let tomlUrl = url.appending(path: "\(ConfigurationClient.Constants.defaultFileNameWithoutExtension).toml")
configurationFile = ConfigruationFile(url: tomlUrl)
if let configurationFile { return configurationFile }
// Check for json file.
let jsonUrl = url.appending(path: "\(ConfigurationClient.Constants.defaultFileNameWithoutExtension).json")
configurationFile = ConfigruationFile(url: jsonUrl)
if let configurationFile { return configurationFile }
// Couldn't find valid configuration file.
if fileClient.fileExists(url) {
return url
}
return nil
}
private func loadConfiguration(_ url: URL) async throws -> Configuration {
@Dependency(\.coders.jsonDecoder) var jsonDecoder
@Dependency(\.fileClient) var fileClient
let string = try await fileClient.read(url.cleanFilePath)
return try jsonDecoder().decode(Configuration.self, from: Data(string.utf8))
}
enum ConfigurationClientError: Error {
case configurationNotFound
case invalidConfigurationDirectory(path: String)
}
private func writeConfiguration(_ configuration: Configuration, to url: URL) async throws {
@Dependency(\.fileClient) var fileClient
@Dependency(\.coders.jsonEncoder) var jsonEncoder
let data = try jsonEncoder().encode(configuration)
try await fileClient.write(data, url)
}

View File

@@ -1,76 +0,0 @@
import Dependencies
import FileClient
import Foundation
/// Represents a configuration file type and location.
public enum ConfigruationFile: Equatable, Sendable {
/// A json configuration file.
case json(URL)
/// A toml configuration file.
case toml(URL)
/// Default configuration file, which is a toml file
/// with the name of '.bump-version.toml'
public static var `default`: Self {
.toml(URL(
filePath: "\(ConfigurationClient.Constants.defaultFileNameWithoutExtension).toml"
))
}
/// Create a new file location from the given url.
///
/// - Parameters:
/// - url: The url for the file.
public init?(url: URL) {
if url.pathExtension == "toml" {
self = .toml(url)
} else if url.pathExtension == "json" {
self = .json(url)
} else {
return nil
}
}
/// The url of the file.
var url: URL {
switch self {
case let .json(url): return url
case let .toml(url): return url
}
}
}
extension ConfigruationFile {
func load() async throws -> Configuration? {
@Dependency(\.coders) var coders
@Dependency(\.fileClient) var fileClient
switch self {
case .json:
let data = try await Data(fileClient.read(url.cleanFilePath).utf8)
return try? coders.jsonDecoder().decode(Configuration.self, from: data)
case .toml:
let string = try await fileClient.read(url.cleanFilePath)
return try? coders.tomlDecoder().decode(Configuration.self, from: string)
}
}
func write(_ configuration: Configuration) async throws {
@Dependency(\.coders) var coders
@Dependency(\.fileClient) var fileClient
let data: Data
switch self {
case .json:
data = try coders.jsonEncoder().encode(configuration)
case .toml:
data = try Data(coders.tomlEncoder().encode(configuration).utf8)
}
try await fileClient.write(data, url)
}
}

View File

@@ -0,0 +1,17 @@
import ArgumentParser
import Foundation
@main
struct Application: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "bump-version",
version: VERSION ?? "0.0.0",
subcommands: [
BuildCommand.self,
BumpCommand.self,
GenerateCommand.self,
UtilsCommand.self
],
defaultSubcommand: BumpCommand.self
)
}

View File

@@ -1,47 +0,0 @@
import ArgumentParser
import CliClient
import Foundation
import ShellClient
extension CliVersionCommand {
struct Build: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
abstract: "Used for the build with version plugin.",
discussion: "This should generally not be interacted with directly, outside of the build plugin.",
subcommands: [BranchStyle.self, SemVarStyle.self],
defaultSubcommand: SemVarStyle.self
)
}
}
extension CliVersionCommand.Build {
struct BranchStyle: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "branch",
abstract: "Build using branch and commit sha as the version.",
discussion: "This should generally not be interacted with directly, outside of the plugin usage context."
)
@OptionGroup var globals: GlobalBranchOptions
func run() async throws {
try await globals.shared().run(\.build)
}
}
struct SemVarStyle: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "semvar",
abstract: "Generates a version file with SemVar style.",
discussion: "This should generally not be interacted with directly, outside of the plugin usage context."
)
@OptionGroup var globals: GlobalSemVarOptions
func run() async throws {
try await globals.shared().run(\.build)
}
}
}

View File

@@ -1,55 +0,0 @@
import ArgumentParser
import CliClient
import Dependencies
extension CliVersionCommand {
struct Bump: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "bump",
abstract: "Bump version of a command-line tool.",
subcommands: [
SemVarStyle.self,
BranchStyle.self
],
defaultSubcommand: SemVarStyle.self
)
}
}
extension CliVersionCommand.Bump {
struct BranchStyle: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "branch",
abstract: "Bump using the current branch and commit sha."
)
@OptionGroup var globals: GlobalBranchOptions
func run() async throws {
try await globals.shared().run(\.bump, args: nil)
}
}
struct SemVarStyle: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "semvar",
abstract: "Bump using semvar style options."
)
@OptionGroup var globals: GlobalSemVarOptions
@Flag
var bumpOption: CliClient.BumpOption = .patch
func run() async throws {
try await globals.shared().run(\.bump, args: bumpOption)
}
}
}
extension CliClient.BumpOption: EnumerableFlag {}

View File

@@ -1,16 +0,0 @@
import ArgumentParser
import Foundation
@main
struct CliVersionCommand: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "cli-version",
version: VERSION ?? "0.0.0",
subcommands: [
Build.self,
Bump.self,
Generate.self
],
defaultSubcommand: Bump.self
)
}

View File

@@ -0,0 +1,19 @@
import ArgumentParser
import CliClient
import Foundation
import ShellClient
struct BuildCommand: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "build",
abstract: "Used for the build with version plugin.",
discussion: "This should generally not be interacted with directly, outside of the build plugin.",
shouldDisplay: false
)
@OptionGroup var globals: GlobalOptions
func run() async throws {
try await globals.run(\.build)
}
}

View File

@@ -0,0 +1,26 @@
import ArgumentParser
import CliClient
import Dependencies
struct BumpCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "bump",
abstract: "Bump version of a command-line tool."
)
@OptionGroup var globals: GlobalOptions
@Flag(
help: """
The semvar bump option, this is ignored if the configuration is set to use a branch/commit sha strategy.
"""
)
var bumpOption: CliClient.BumpOption = .patch
func run() async throws {
try await globals.run(\.bump, args: bumpOption)
}
}
extension CliClient.BumpOption: EnumerableFlag {}

View File

@@ -0,0 +1,19 @@
import ArgumentParser
import CliClient
import Dependencies
import Foundation
import ShellClient
struct GenerateCommand: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "generate",
abstract: "Generates a version file in a command line tool that can be set via the git tag or git sha.",
discussion: "This command can be interacted with directly, outside of the plugin usage context."
)
@OptionGroup var globals: GlobalOptions
func run() async throws {
try await globals.run(\.generate)
}
}

View File

@@ -0,0 +1,95 @@
import ArgumentParser
import ConfigurationClient
import CustomDump
import Dependencies
import FileClient
import Foundation
struct UtilsCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "utils",
abstract: "Utility commands",
subcommands: [
DumpConfig.self,
GenerateConfig.self
]
)
}
extension UtilsCommand {
struct DumpConfig: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "dump-config",
abstract: "Show the parsed configuration.",
aliases: ["dc"]
)
@OptionGroup var globals: GlobalOptions
func run() async throws {
let configuration = try await globals.runClient(\.parsedConfiguration)
customDump(configuration)
}
}
struct GenerateConfig: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "generate-config",
abstract: "Generate a configuration file.",
aliases: ["gc"]
)
@OptionGroup var configOptions: ConfigurationOptions
@Flag(
help: "The style of the configuration."
)
var style: Style = .semvar
@Argument(
help: """
Arguments / options used for custom pre-release, options / flags must proceed a '--' in
the command. These are ignored if the `--custom` flag is not set.
"""
)
var extraOptions: [String] = []
func run() async throws {
try await withSetupDependencies {
@Dependency(\.configurationClient) var configurationClient
let strategy: Configuration.VersionStrategy
switch style {
case .branch:
strategy = .branch(includeCommitSha: configOptions.commitSha)
case .semvar:
strategy = try .semvar(configOptions.semvarOptions(extraOptions: extraOptions))
}
let configuration = try Configuration(
target: configOptions.target(),
strategy: strategy
)
let url: URL
switch configOptions.configurationFile {
case let .some(path):
url = URL(filePath: path)
case .none:
url = URL(filePath: ".bump-version.json")
}
try await configurationClient.write(configuration, url)
print(url.cleanFilePath)
}
}
}
}
extension UtilsCommand.GenerateConfig {
enum Style: EnumerableFlag {
case branch, semvar
}
}

View File

@@ -0,0 +1,5 @@
# Application
## Articles
- <doc:Installation>

View File

@@ -0,0 +1,3 @@
# Installation
You can install the command-line application by...

View File

@@ -1,52 +0,0 @@
import ArgumentParser
import CliClient
import Dependencies
import Foundation
import ShellClient
extension CliVersionCommand {
struct Generate: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
abstract: "Generates a version file in a command line tool that can be set via the git tag or git sha.",
discussion: "This command can be interacted with directly, outside of the plugin usage context.",
subcommands: [BranchStyle.self, SemVarStyle.self],
defaultSubcommand: SemVarStyle.self
)
}
}
extension CliVersionCommand.Generate {
struct BranchStyle: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "branch",
abstract: "Generates a version file with branch and commit sha as the version.",
discussion: "This command can be interacted with directly, outside of the plugin usage context."
)
@OptionGroup var globals: GlobalBranchOptions
func run() async throws {
try await globals.shared().run(\.generate)
}
}
struct SemVarStyle: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "semvar",
abstract: "Generates a version file with SemVar style.",
discussion: "This command can be interacted with directly, outside of the plugin usage context."
)
@OptionGroup var globals: GlobalSemVarOptions
func run() async throws {
try await globals.shared().run(\.generate)
}
}
}
private enum GenerationError: Error {
case fileExists(path: String)
}

View File

@@ -1,13 +1,14 @@
import ArgumentParser
@_spi(Internal) import CliClient
import ConfigurationClient
import Dependencies
import Foundation
import Rainbow
struct GlobalOptions<Child: ParsableArguments>: ParsableArguments {
struct GlobalOptions: ParsableArguments {
@OptionGroup var targetOptions: TargetOptions
@OptionGroup var child: Child
@OptionGroup
var configOptions: ConfigurationOptions
@Option(
name: .customLong("git-directory"),
@@ -27,9 +28,38 @@ struct GlobalOptions<Child: ParsableArguments>: ParsableArguments {
)
var verbose: Int
@Argument(
help: """
Arguments / options used for custom pre-release, options / flags must proceed a '--' in
the command. These are ignored if the `--custom` flag is not set.
"""
)
var extraOptions: [String] = []
}
struct Empty: ParsableArguments {}
struct ConfigurationOptions: ParsableArguments {
@Option(
name: .shortAndLong,
help: "Specify the path to a configuration file.",
completion: .file(extensions: ["json"])
)
var configurationFile: String?
@OptionGroup var targetOptions: TargetOptions
@OptionGroup var semvarOptions: SemVarOptions
@Flag(
name: .long,
inversion: .prefixedNo,
help: """
Include the short commit sha in version or pre-release branch style output.
"""
)
var commitSha: Bool = true
}
struct TargetOptions: ParsableArguments {
@Option(
@@ -53,6 +83,13 @@ struct TargetOptions: ParsableArguments {
}
struct PreReleaseOptions: ParsableArguments {
@Flag(
name: .shortAndLong,
help: ""
)
var disablePreRelease: Bool = false
@Flag(
name: [.customShort("s"), .customLong("pre-release-branch-style")],
help: """
@@ -70,13 +107,21 @@ struct PreReleaseOptions: ParsableArguments {
var useTagAsPreRelease: Bool = false
@Option(
name: .shortAndLong,
name: .long,
help: """
Apply custom pre-release suffix, can also use branch or tag along with this
option as a prefix, used if branch is not set. (example: \"rc\")
Add / use a pre-release prefix string.
"""
)
var custom: String?
var preReleasePrefix: String?
@Flag(
name: .long,
help: """
Apply custom pre-release suffix, using extra options / arguments passed in after a '--'.
"""
)
var customPreRelease: Bool = false
}
struct SemVarOptions: ParsableArguments {
@@ -97,114 +142,3 @@ struct SemVarOptions: ParsableArguments {
@OptionGroup var preRelease: PreReleaseOptions
}
typealias GlobalSemVarOptions = GlobalOptions<SemVarOptions>
typealias GlobalBranchOptions = GlobalOptions<Empty>
extension GlobalSemVarOptions {
func shared() throws -> CliClient.SharedOptions {
try shared(.semVar(child.semVarOptions()))
}
}
extension GlobalBranchOptions {
func shared() throws -> CliClient.SharedOptions {
try shared(.branchAndCommit)
}
}
extension CliClient.SharedOptions {
func run(_ keyPath: KeyPath<CliClient, @Sendable (Self) async throws -> String>) async throws {
try await withDependencies {
$0.fileClient = .liveValue
$0.gitClient = .liveValue
$0.cliClient = .liveValue
} operation: {
@Dependency(\.cliClient) var cliClient
let output = try await cliClient[keyPath: keyPath](self)
print(output)
}
}
func run<T>(
_ keyPath: KeyPath<CliClient, @Sendable (T, Self) async throws -> String>,
args: T
) async throws {
try await withDependencies {
$0.fileClient = .liveValue
$0.gitClient = .liveValue
$0.cliClient = .liveValue
} operation: {
@Dependency(\.cliClient) var cliClient
let output = try await cliClient[keyPath: keyPath](args, self)
print(output)
}
}
}
extension GlobalOptions {
func shared(_ versionStrategy: CliClient.VersionStrategy) throws -> CliClient.SharedOptions {
try .init(
dryRun: dryRun,
gitDirectory: gitDirectory,
logLevel: .init(verbose: verbose),
target: targetOptions.target(),
versionStrategy: versionStrategy
)
}
}
// MARK: - Helpers
private extension TargetOptions {
func target() throws -> String {
guard let path else {
guard let module else {
print("Neither target path or module was set.")
throw InvalidTargetOption()
}
return "\(module)/\(fileName)"
}
return path
}
struct InvalidTargetOption: Error {}
}
extension PreReleaseOptions {
func preReleaseStrategy() throws -> CliClient.PreReleaseStrategy? {
guard let custom else {
if useBranchAsPreRelease {
return .branchAndCommit
} else if useTagAsPreRelease {
return .tag
} else {
return nil
}
}
if useBranchAsPreRelease {
return .custom(custom, .branchAndCommit)
} else if useTagAsPreRelease {
return .custom(custom, .tag)
} else {
return .custom(custom, nil)
}
}
}
extension SemVarOptions {
func semVarOptions() throws -> CliClient.VersionStrategy.SemVarOptions {
try .init(
preReleaseStrategy: preRelease.preReleaseStrategy(),
requireExistingFile: requireExistingFile,
requireExistingSemVar: requireExistingSemvar
)
}
}

View File

@@ -0,0 +1,123 @@
import CliClient
import ConfigurationClient
import Dependencies
import FileClient
import GitClient
@discardableResult
func withSetupDependencies<T>(
_ operation: () async throws -> T
) async throws -> T {
try await withDependencies {
$0.fileClient = .liveValue
$0.gitClient = .liveValue
$0.cliClient = .liveValue
$0.configurationClient = .liveValue
} operation: {
try await operation()
}
}
extension GlobalOptions {
func runClient<T>(
_ keyPath: KeyPath<CliClient, @Sendable (CliClient.SharedOptions) async throws -> T>
) async throws -> T {
try await withSetupDependencies {
@Dependency(\.cliClient) var cliClient
return try await cliClient[keyPath: keyPath](shared())
}
}
func runClient<A, T>(
_ keyPath: KeyPath<CliClient, @Sendable (A, CliClient.SharedOptions) async throws -> T>,
args: A
) async throws -> T {
try await withSetupDependencies {
@Dependency(\.cliClient) var cliClient
return try await cliClient[keyPath: keyPath](args, shared())
}
}
func run(
_ keyPath: KeyPath<CliClient, @Sendable (CliClient.SharedOptions) async throws -> String>
) async throws {
let output = try await runClient(keyPath)
print(output)
}
func run<T>(
_ keyPath: KeyPath<CliClient, @Sendable (T, CliClient.SharedOptions) async throws -> String>,
args: T
) async throws {
let output = try await runClient(keyPath, args: args)
print(output)
}
func shared() throws -> CliClient.SharedOptions {
try .init(
allowPreReleaseTag: !configOptions.semvarOptions.preRelease.disablePreRelease,
dryRun: dryRun,
gitDirectory: gitDirectory,
verbose: verbose,
target: configOptions.target(),
branch: .init(includeCommitSha: configOptions.commitSha),
semvar: configOptions.semvarOptions(extraOptions: extraOptions),
configurationFile: configOptions.configurationFile
)
}
}
private extension TargetOptions {
func configTarget() throws -> Configuration.Target? {
guard let path else {
guard let module else {
return nil
}
return .init(module: .init(module, fileName: fileName))
}
return .init(path: path)
}
}
extension PreReleaseOptions {
func configPreReleaseStrategy(includeCommitSha: Bool, extraOptions: [String]) throws -> Configuration.PreRelease? {
if useBranchAsPreRelease {
return .init(prefix: preReleasePrefix, strategy: .branch(includeCommitSha: includeCommitSha))
} else if useTagAsPreRelease {
return .init(prefix: preReleasePrefix, strategy: .gitTag)
} else if customPreRelease {
guard extraOptions.count > 0 else {
throw ExtraOptionsEmpty()
}
return .init(prefix: preReleasePrefix, strategy: .command(arguments: extraOptions))
}
return nil
}
}
extension SemVarOptions {
func configSemVarOptions(includeCommitSha: Bool, extraOptions: [String]) throws -> Configuration.SemVar {
try .init(
preRelease: preRelease.configPreReleaseStrategy(includeCommitSha: includeCommitSha, extraOptions: extraOptions),
requireExistingFile: requireExistingFile,
requireExistingSemVar: requireExistingSemvar
)
}
}
extension ConfigurationOptions {
func target() throws -> Configuration.Target? {
try targetOptions.configTarget()
}
func semvarOptions(extraOptions: [String]) throws -> Configuration.SemVar {
try semvarOptions.configSemVarOptions(includeCommitSha: commitSha, extraOptions: extraOptions)
}
}
struct ExtraOptionsEmpty: Error {}

View File

@@ -1,4 +1,5 @@
@_spi(Internal) import CliClient
import ConfigurationClient
import Dependencies
import FileClient
import Foundation
@@ -20,7 +21,7 @@ struct CliClientTests {
@Dependency(\.cliClient) var client
let output = try await client.build(.testOptions(
target: target,
versionStrategy: .semVar(.init(requireExistingFile: false))
versionStrategy: .semvar(requireExistingFile: false)
))
#expect(output == "/baz/Sources/bar/Version.swift")
}
@@ -65,7 +66,7 @@ struct CliClientTests {
@Dependency(\.cliClient) var client
let output = try await client.build(.testOptions(
target: target,
versionStrategy: .semVar(.init(requireExistingFile: false))
versionStrategy: .semvar(requireExistingFile: false)
))
#expect(output == "/baz/Sources/bar/Version.swift")
}
@@ -96,6 +97,8 @@ struct CliClientTests {
$0.fileClient.fileExists = { _ in false }
$0.gitClient = .mock(.tag("1.0.0"))
$0.cliClient = .liveValue
$0.configurationClient = .liveValue
$0.configurationClient.find = { _ in URL(filePath: "/") }
setupDependencies(&$0)
} operation: {
try await operation()
@@ -113,7 +116,7 @@ struct CliClientTests {
}
enum TestArguments {
static let testCases = ["bar", "Sources/bar", "/Sources/bar", "./Sources/bar"]
static let testCases = ["bar", "Sources/bar", "./Sources/bar"]
static let bumpCases = CliClient.BumpOption.allCases.reduce(into: [(CliClient.BumpOption, Bool)]()) {
$0.append(($1, true))
$0.append(($1, false))
@@ -130,14 +133,15 @@ extension CliClient.SharedOptions {
dryRun: Bool = false,
target: String = "bar",
logLevel: Logger.Level = .trace,
versionStrategy: CliClient.VersionStrategy = .semVar(.init())
versionStrategy: Configuration.VersionStrategy = .semvar(.init())
) -> Self {
.init(
return .init(
dryRun: dryRun,
gitDirectory: gitDirectory,
logLevel: logLevel,
target: target,
versionStrategy: versionStrategy
target: .init(module: .init(target)),
branch: versionStrategy.branch,
semvar: versionStrategy.semvar
)
}
}

View File

@@ -29,16 +29,16 @@ final class GitVersionTests: XCTestCase {
.cleanFilePath
}
#if !os(Linux)
func test_live() async throws {
@Dependency(\.gitClient) var versionClient: GitClient
let version = try await versionClient.currentVersion(in: gitDir)
print("VERSION: \(version)")
// can't really have a predictable result for the live client.
XCTAssertNotEqual(version, "blob")
}
#endif
// #if !os(Linux)
// func test_live() async throws {
// @Dependency(\.gitClient) var versionClient: GitClient
//
// let version = try await versionClient.currentVersion(in: gitDir)
// print("VERSION: \(version)")
// // can't really have a predictable result for the live client.
// XCTAssertNotEqual(version, "blob")
// }
// #endif
func test_file_client() async throws {
try await withTemporaryDirectory { tmpDir in

View File

@@ -2,6 +2,7 @@ import ConfigurationClient
import Dependencies
import Foundation
import Testing
import TestSupport
@Suite("ConfigurationClientTests")
struct ConfigurationClientTests {
@@ -17,27 +18,58 @@ struct ConfigurationClientTests {
let decoded = try coders.jsonDecoder().decode(Configuration.self, from: encoded)
#expect(decoded == configuration)
let tomlEncoded = try coders.tomlEncoder().encode(configuration)
let tomlDecoded = try coders.tomlDecoder().decode(
Configuration.self,
from: tomlEncoded
)
#expect(tomlDecoded == configuration)
}
}
@Test(arguments: ["foo", ".foo"])
func configurationFile(fileName: String) {
for ext in ["toml", "json", "bar"] {
let file = ConfigruationFile(url: URL(filePath: "\(fileName).\(ext)"))
switch ext {
case "toml":
#expect(file == .toml(URL(filePath: "\(fileName).toml")))
case "json":
#expect(file == .json(URL(filePath: "\(fileName).json")))
default:
#expect(file == nil)
@Test
func writeAndLoad() async throws {
try await withTemporaryDirectory { url in
try await run {
@Dependency(\.configurationClient) var configurationClient
for ext in ["json"] {
let fileUrl = url.appending(path: "test.\(ext)")
let configuration = Configuration.mock()
try await configurationClient.write(configuration, fileUrl)
let loaded = try await configurationClient.load(fileUrl)
#expect(loaded == configuration)
let findAndLoaded = try await configurationClient.findAndLoad(fileUrl)
#expect(findAndLoaded == configuration)
try FileManager.default.removeItem(at: fileUrl)
}
}
}
}
@Test
func findAndLoad() async throws {
try await withTemporaryDirectory { url in
try await run {
@Dependency(\.configurationClient) var configurationClient
let shouldBeNil = try await configurationClient.find(url)
#expect(shouldBeNil == nil)
do {
_ = try await configurationClient.findAndLoad(url)
#expect(Bool(false))
} catch {
#expect(Bool(true))
}
for ext in ["json"] {
let fileUrl = url.appending(path: ".bump-version.\(ext)")
let configuration = Configuration.mock()
try await configurationClient.write(configuration, fileUrl)
let loaded = try await configurationClient.findAndLoad(fileUrl)
#expect(loaded == configuration)
try FileManager.default.removeItem(at: fileUrl)
}
}
}
}

View File

@@ -0,0 +1,59 @@
import Dependencies
import GitClient
import ShellClient
import Testing
@Suite("GitClientTests")
struct GitClientTests {
@Test(arguments: GitClientVersionTestArgument.testCases)
func testGitClient(input: GitClientVersionTestArgument) async throws {
let arguments = try await run {
@Dependency(\.gitClient) var gitClient
_ = try await gitClient.version(.init(style: input.style))
}
#expect(arguments == input.expected)
}
func run(
_ operation: () async throws -> Void
) async throws -> [[String]] {
let captured = CapturedCommand()
try await withDependencies {
$0.asyncShellClient = .capturing(captured)
$0.fileClient = .noop
$0.gitClient = .liveValue
} operation: {
try await operation()
}
return await captured.commands.map(\.arguments)
}
}
struct GitClientVersionTestArgument {
let style: GitClient.CurrentVersionOption.Style
let expected: [[String]]
static let testCases: [Self] = [
.init(
style: .tag(exactMatch: true),
expected: [["git", "describe", "--tags", "--exact-match"]]
),
.init(
style: .tag(exactMatch: false),
expected: [["git", "describe", "--tags"]]
),
.init(
style: .branch(commitSha: false),
expected: [["git", "symbolic-ref", "--quiet", "--short", "HEAD"]]
),
.init(
style: .branch(commitSha: true),
expected: [
["git", "rev-parse", "--short", "HEAD"],
["git", "symbolic-ref", "--quiet", "--short", "HEAD"]
]
)
]
}