Compare commits

24 Commits

Author SHA1 Message Date
47bad744d5 feat: Removes some old todo comments.
All checks were successful
CI / Ubuntu (push) Successful in 2m21s
Release / release (push) Successful in 11s
2024-12-26 14:04:37 -05:00
2650bdc670 feat: Better checks on if semvar has changes prior to writing to a file.
All checks were successful
CI / Ubuntu (push) Successful in 2m51s
2024-12-26 13:06:31 -05:00
a0f8611a76 feat: Working on command-line documentation.
All checks were successful
CI / Ubuntu (push) Successful in 2m52s
2024-12-26 12:13:42 -05:00
86a344fa9f feat: Removes old configuration merging file from cli-client.
All checks were successful
CI / Ubuntu (push) Successful in 2m42s
2024-12-26 07:58:01 -05:00
56359f3488 feat: Moves logging extensions into it's own module, moves configuration merging into config-client, fixes merging bug. 2024-12-25 20:06:52 -05:00
fbb4a22e98 feat: Updates configuration commands and parsing strategy.
All checks were successful
CI / Ubuntu (push) Successful in 2m59s
2024-12-25 17:31:38 -05:00
3cfd882f38 feat: Some variable renaming for consistency.
All checks were successful
CI / Ubuntu (push) Successful in 2m58s
2024-12-24 23:16:13 -05:00
a885e3dfa3 feat: Updates logging configuration. 2024-12-24 21:32:14 -05:00
04bfd4a6ae feat: Make configuration module file-name optional, so that it is not required by a config file.
All checks were successful
CI / Ubuntu (push) Successful in 2m35s
2024-12-24 17:13:54 -05:00
fb60c22257 feat: Updates justfile in prep for homebrew
Some checks failed
CI / Ubuntu (push) Failing after 10s
2024-12-24 16:55:04 -05:00
0148ad6df2 feat: Adds release workflow to gitea
All checks were successful
CI / Ubuntu (push) Successful in 2m51s
2024-12-24 16:44:28 -05:00
322327d6d7 feat: Renaming to bump-version
All checks were successful
CI / Ubuntu (push) Successful in 2m55s
2024-12-24 16:39:38 -05:00
9ea0ab566e feat: Removes old toml configuration example
All checks were successful
CI / Ubuntu (push) Successful in 2m49s
2024-12-24 15:14:02 -05:00
4362ad9c3f feat: Updates gitea ci.
All checks were successful
CI / Ubuntu (push) Successful in 3m7s
2024-12-24 15:11:56 -05:00
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
48 changed files with 2302 additions and 998 deletions

11
.bump-version.json Normal file
View File

@@ -0,0 +1,11 @@
{
"target" : {
"module" : { "name" : "bump-version" }
},
"strategy" : {
"semvar" : {
"preRelease" : { "strategy": { "gitTag" : {} } },
"strategy" : { "gitTag": { "exactMatch": false } }
}
}
}

View File

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

20
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,20 @@
name: CI
on:
push:
workflow_dispatch:
jobs:
ubuntu:
name: Ubuntu
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Setup just.
uses: https://git.housh.dev/actions/setup-just.git@v1
- 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

@@ -0,0 +1,17 @@
---
name: Release
on:
push:
tags:
- "*.*.*"
- "v*.*.*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Release
uses: softprops/action-gh-release@v2

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" : "3640b52c8069b868611efbfbd9b7545872526454802225747f7cd878062df1db",
"pins" : [
{
"identity" : "combine-schedulers",
@@ -28,6 +28,15 @@
"version" : "1.5.0"
}
},
{
"identity" : "swift-cli-doc",
"kind" : "remoteSourceControl",
"location" : "https://github.com/m-housh/swift-cli-doc.git",
"state" : {
"revision" : "bbace73d974fd3e6985461431692bea773c7c5d8",
"version" : "0.2.1"
}
},
{
"identity" : "swift-clocks",
"kind" : "remoteSourceControl",
@@ -46,6 +55,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 +114,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 +127,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

@@ -8,33 +8,44 @@ let package = Package(
.macOS(.v13)
],
products: [
.executable(name: "bump-version", targets: ["bump-version"]),
.library(name: "CliClient", targets: ["CliClient"]),
.library(name: "ConfigurationClient", targets: ["ConfigurationClient"]),
.library(name: "FileClient", targets: ["FileClient"]),
.library(name: "GitClient", targets: ["GitClient"]),
.library(name: "LoggingExtensions", targets: ["LoggingExtensions"]),
.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/m-housh/swift-cli-doc.git", from: "0.2.1"),
.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",
name: "bump-version",
dependencies: [
"CliClient",
.product(name: "ArgumentParser", package: "swift-argument-parser")
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "CliDoc", package: "swift-cli-doc")
]
),
.target(
name: "CliClient",
dependencies: [
"ConfigurationClient",
"FileClient",
"GitClient",
.product(name: "Logging", package: "swift-log")
"LoggingExtensions",
.product(name: "Logging", package: "swift-log"),
.product(name: "CustomDump", package: "swift-custom-dump")
]
),
.testTarget(
@@ -45,9 +56,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,12 +81,23 @@ let package = Package(
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.testTarget(
name: "GitClientTests",
dependencies: ["GitClient"]
),
.target(
name: "LoggingExtensions",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.target(name: "TestSupport"),
.plugin(
name: "BuildWithVersionPlugin",
capability: .buildTool(),
dependencies: [
"cli-version"
"bump-version"
]
),
.plugin(
@@ -90,7 +112,7 @@ let package = Package(
]
),
dependencies: [
"cli-version"
"bump-version"
]
),
.plugin(
@@ -105,7 +127,7 @@ let package = Package(
]
),
dependencies: [
"cli-version"
"bump-version"
]
)
]

View File

@@ -1,8 +1,10 @@
import ConfigurationClient
import Dependencies
import DependenciesMacros
import FileClient
import Foundation
import GitClient
import LoggingExtensions
import ShellClient
public extension DependencyValues {
@@ -26,68 +28,43 @@ 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 loggingOptions: LoggingOptions
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())
loggingOptions: LoggingOptions,
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.loggingOptions = loggingOptions
self.target = target
self.branch = branch
self.semvar = semvar
self.configurationFile = configurationFile
}
var allowPreReleaseTag: Bool { preReleaseStrategy != nil }
}
}
@@ -99,7 +76,12 @@ 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.loggingOptions.withLogger {
try await options.withMergedConfiguration { $0 }
}
}
)
}

View File

@@ -4,4 +4,5 @@ enum CliClientError: Error {
case fileDoesNotExist(path: String)
case failedToParseVersionFile
case semVarNotFound
case preReleaseParsingError(String)
}

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,174 @@
import ConfigurationClient
import CustomDump
import Dependencies
import FileClient
import Foundation
import GitClient
@_spi(Internal)
public extension CliClient.SharedOptions {
/// All cli-client calls should run through this, it set's up logging,
/// loads configuration, and generates the current version based on the
/// configuration.
@discardableResult
func run(
_ operation: (CurrentVersionContainer) async throws -> Void
) async rethrows -> String {
try await loggingOptions.withLogger {
// Load the default configuration, if it exists.
try await withMergedConfiguration { configuration in
@Dependency(\.logger) var logger
var configurationString = ""
customDump(configuration, to: &configurationString)
logger.trace("\nConfiguration: \(configurationString)")
// 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 the file path we wrote the version to.
return targetUrl.cleanFilePath
}
}
}
// Merges any configuration set via the passed in options.
@discardableResult
func withMergedConfiguration<T>(
operation: (Configuration) async throws -> T
) async throws -> T {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.logger) var logger
var strategy: Configuration.VersionStrategy?
if let branch {
logger.trace("Merging branch strategy.")
strategy = .branch(branch)
} else if let semvar {
logger.trace("Merging semvar strategy.")
var semvarString = ""
customDump(semvar, to: &semvarString)
logger.trace("\(semvarString)")
strategy = .semvar(semvar)
}
let configuration = Configuration(
target: target,
strategy: strategy
)
return try await configurationClient.withConfiguration(
path: configurationFile,
merging: configuration,
operation: operation
)
}
func write(_ string: String, to url: URL) async throws {
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger
if !dryRun {
try await fileClient.write(string: string, to: url)
} else {
logger.debug("Skipping, due to dry-run being passed.")
logger.info("\n\(string)\n")
}
}
func write(_ currentVersion: CurrentVersionContainer) async throws {
@Dependency(\.logger) var logger
let version = try currentVersion.version.string(allowPreReleaseTag: allowPreReleaseTag)
logger.debug("Version: \(version)")
let template = currentVersion.usesOptionalType ? Template.optional(version) : Template.nonOptional(version)
logger.trace("Template string: \(template)")
try await write(template, to: currentVersion.targetUrl)
}
}
@_spi(Internal)
public struct CurrentVersionContainer: Sendable {
let targetUrl: URL
let version: Version
var usesOptionalType: Bool {
switch version {
case .string: return false
case let .semvar(_, usesOptionalType, _): return usesOptionalType
}
}
public enum Version: Sendable {
case string(String)
case semvar(SemVar, usesOptionalType: Bool = true, hasChanges: Bool)
func string(allowPreReleaseTag: Bool) throws -> String {
switch self {
case let .string(string):
return string
case let .semvar(semvar, usesOptionalType: _, hasChanges: _):
return semvar.versionString(withPreReleaseTag: allowPreReleaseTag)
}
}
}
}
extension CliClient.SharedOptions {
func build(_ environment: [String: String]) async throws -> String {
try await run { currentVersion in
try await write(currentVersion)
}
}
func bump(_ type: CliClient.BumpOption?) async throws -> String {
guard let type else {
return try await generate()
}
return try await run { container in
@Dependency(\.logger) var logger
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, hasChanges: hasChanges):
logger.debug("Semvar prior to bumping: \(semvar)")
let bumped = semvar.bump(type)
let version = bumped.versionString(withPreReleaseTag: allowPreReleaseTag)
guard bumped != semvar || hasChanges else {
logger.debug("No change, skipping.")
return
}
logger.debug("Bumped version: \(version)")
let template = usesOptionalType ? Template.optional(version) : Template.build(version)
try await write(template, to: container.targetUrl)
}
}
}
func generate() async throws -> String {
try await run { currentVersion in
try await write(currentVersion)
}
}
}

View File

@@ -0,0 +1,248 @@
import ConfigurationClient
import Dependencies
import Foundation
import GitClient
import ShellClient
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.fileNameOrDefault)"
}
if let gitDirectory {
return URL(filePath: "\(gitDirectory)/\(filePath)")
}
return URL(filePath: filePath)
}
}
extension GitClient {
func version(includeCommitSha: Bool, gitDirectory: String?) async throws -> String {
@Dependency(\.gitClient) var gitClient
return try await gitClient.version(.init(
gitDirectory: gitDirectory,
style: .branch(commitSha: includeCommitSha)
)).description
}
}
extension Configuration.PreRelease {
// FIX: This needs to handle the pre-release type appropriatly.
func preReleaseString(gitDirectory: String?) async throws -> PreReleaseString? {
guard let strategy else { return nil }
@Dependency(\.asyncShellClient) var asyncShellClient
@Dependency(\.gitClient) var gitClient
@Dependency(\.logger) var logger
var preReleaseString: String
var suffix = true
var allowsPrefix = true
switch strategy {
case let .branch(includeCommitSha: includeCommitSha):
logger.trace("Branch pre-release strategy, includeCommitSha: \(includeCommitSha).")
preReleaseString = try await gitClient.version(
includeCommitSha: includeCommitSha,
gitDirectory: gitDirectory
)
case let .command(arguments: arguments):
logger.trace("Command pre-release strategy, arguments: \(arguments).")
// TODO: What to do with allows prefix? Need a configuration setting for commands.
preReleaseString = try await asyncShellClient.background(.init(arguments))
case .gitTag:
logger.trace("Git tag pre-release strategy.")
logger.trace("This will ignore any set prefix.")
suffix = false
allowsPrefix = false
preReleaseString = try await gitClient.version(.init(
gitDirectory: gitDirectory,
style: .tag(exactMatch: false)
)).description
}
if let prefix {
if allowsPrefix {
preReleaseString = "\(prefix)-\(preReleaseString)"
} else {
logger.warning("Found prefix, but pre-release strategy may not work properly, ignoring prefix.")
}
}
guard suffix else { return .semvar(preReleaseString) }
return .suffix(preReleaseString)
// return preReleaseString
}
enum PreReleaseString: Sendable {
case suffix(String)
case semvar(String)
}
}
@_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,
let preRelease = try await preReleaseStrategy.preReleaseString(gitDirectory: gitDirectory)
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)")
switch preRelease {
case let .suffix(string):
return semvar.applyingPreRelease(string)
case let .semvar(string):
guard let semvar = SemVar(string: string) else {
throw CliClientError.preReleaseParsingError(string)
}
return semvar
}
// 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 {
let semvarWithPreRelease = try await applyingPreRelease(semVar!, gitDirectory)
return .semvar(
semvarWithPreRelease,
usesOptionalType: usesOptionalType ?? false,
hasChanges: semvarWithPreRelease != semVar
)
}
if requireExistingFile == true {
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 {
let semvarWithPreRelease = try await applyingPreRelease(semVar!, gitDirectory)
return .semvar(
semvarWithPreRelease,
usesOptionalType: usesOptionalType ?? false,
hasChanges: semvarWithPreRelease != semVar
)
}
if requireExistingSemVar == true {
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,
hasChanges: true
)
}
}
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(includeCommitSha: branch.includeCommitSha, gitDirectory: gitDirectory)
)
)
}
}
enum ConfigurationParsingError: Error {
case targetNotFound
case pathOrModuleNotSet
case versionStrategyError(message: String)
case versionNotFound
}

View File

@@ -28,21 +28,25 @@ public extension FileClient {
logger.debug("Version line: \(versionLine)")
let isOptional = versionLine.contains("String?")
logger.debug("Uses optional: \(isOptional)")
logger.trace("Uses optional: \(isOptional)")
let versionString = versionLine.split(separator: "let VERSION: \(isOptional ? "String?" : "String") = ").last
guard let versionString else {
throw CliClientError.failedToParseVersionFile
}
logger.trace("Parsed version string: \(versionString)")
return (String(versionString), isOptional)
}
func semVar(
func semvar(
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,144 +0,0 @@
import Dependencies
import FileClient
import Foundation
import GitClient
@_spi(Internal)
public extension CliClient.SharedOptions {
func parseTargetUrl() async throws -> URL {
@Dependency(\.fileClient) var fileClient
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")
}
url.appendPathComponent(target)
}
let isDirectory = try await fileClient.isDirectory(url.cleanFilePath)
if isDirectory {
url.appendPathComponent(Constants.defaultFileName)
}
return url
}
@discardableResult
func run(
_ operation: (CurrentVersionContainer) async throws -> Void
) async rethrows -> String {
try await withDependencies {
$0.logger.logLevel = logLevel
} operation: {
@Dependency(\.logger) var logger
let targetUrl = try await parseTargetUrl()
logger.debug("Target: \(targetUrl.cleanFilePath)")
try await operation(
versionStrategy.currentVersion(
file: targetUrl,
gitDirectory: gitDirectory
)
)
return targetUrl.cleanFilePath
}
}
func write(_ string: String, to url: URL) async throws {
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger
if !dryRun {
try await fileClient.write(string: string, to: url)
} else {
logger.debug("Skipping, due to dry-run being passed.")
logger.debug("\(string)")
}
}
func write(_ currentVersion: CurrentVersionContainer) async throws {
@Dependency(\.logger) var logger
let version = try currentVersion.version.string(allowPreReleaseTag: allowPreReleaseTag)
logger.debug("Version: \(version)")
let template = currentVersion.usesOptionalType ? Template.optional(version) : Template.nonOptional(version)
logger.trace("Template string: \(template)")
try await write(template, to: currentVersion.targetUrl)
}
}
@_spi(Internal)
public struct CurrentVersionContainer: Sendable {
let targetUrl: URL
let version: Version
var usesOptionalType: Bool {
switch version {
case .string: return false
case let .semVar(_, usesOptionalType): return usesOptionalType
}
}
public enum Version: Sendable {
case string(String)
case semVar(SemVar, usesOptionalType: Bool = true)
func string(allowPreReleaseTag: Bool) throws -> String {
switch self {
case let .string(string):
return string
case let .semVar(semVar, usesOptionalType: _):
return semVar.versionString(withPreReleaseTag: allowPreReleaseTag)
}
}
}
}
extension CliClient.SharedOptions {
func build(_ environment: [String: String]) async throws -> String {
try await run { currentVersion in
try await write(currentVersion)
}
}
func bump(_ type: CliClient.BumpOption?) async throws -> String {
guard let type else {
return try await generate()
}
return try await run { container in
@Dependency(\.logger) var logger
switch container.version {
case .string: // When we did not parse a semVar, just write whatever we parsed for the current version.
try await write(container)
case let .semVar(semVar, usesOptionalType: usesOptionalType):
let bumped = semVar.bump(type, preRelease: nil) // preRelease is already set on semVar.
let version = bumped.versionString(withPreReleaseTag: allowPreReleaseTag)
logger.debug("Bumped version: \(version)")
let template = usesOptionalType ? Template.optional(version) : Template.build(version)
try await write(template, to: container.targetUrl)
}
}
}
func generate() async throws -> String {
try await run { currentVersion in
try await write(currentVersion)
}
}
}

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,
@@ -76,7 +90,7 @@ public struct SemVar: CustomStringConvertible, Equatable, Sendable {
}
// Bumps the sem-var by the given option (major, minor, patch)
public func bump(_ option: CliClient.BumpOption, preRelease: String?) -> Self {
public func bump(_ option: CliClient.BumpOption, preRelease: String? = nil) -> Self {
switch option {
case .major:
return .init(

View File

@@ -13,6 +13,7 @@ public struct Template: Sendable {
return """
// Do not set this variable, it is set during the build process.
let VERSION: \(type.rawValue) = \(versionString)
"""
}

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

@@ -0,0 +1,71 @@
import Dependencies
import FileClient
import Foundation
@_spi(Internal)
public extension Configuration {
func merging(_ other: Self?) -> Self {
mergingTarget(other?.target).mergingStrategy(other?.strategy)
}
private func mergingTarget(_ otherTarget: Configuration.Target?) -> Self {
.init(
target: otherTarget ?? target,
strategy: strategy
)
}
private func mergingStrategy(_ otherStrategy: Configuration.VersionStrategy?) -> Self {
.init(
target: target,
strategy: strategy?.merging(otherStrategy)
)
}
}
@_spi(Internal)
public extension Configuration.PreRelease {
func merging(_ other: Self?) -> Self {
return .init(
prefix: other?.prefix ?? prefix,
strategy: other?.strategy ?? strategy
)
}
}
@_spi(Internal)
public extension Configuration.Branch {
func merging(_ other: Self?) -> Self {
return .init(includeCommitSha: other?.includeCommitSha ?? includeCommitSha)
}
}
@_spi(Internal)
public extension Configuration.SemVar {
func merging(_ other: Self?) -> Self {
.init(
allowPreRelease: other?.allowPreRelease ?? allowPreRelease,
preRelease: preRelease == nil ? other?.preRelease : preRelease!.merging(other?.preRelease),
requireExistingFile: other?.requireExistingFile ?? requireExistingFile,
requireExistingSemVar: other?.requireExistingSemVar ?? requireExistingSemVar,
strategy: other?.strategy ?? strategy
)
}
}
@_spi(Internal)
public extension Configuration.VersionStrategy {
func merging(_ other: Self?) -> Self {
guard let other else { return self }
switch other {
case .branch:
guard let branch else { return other }
return .branch(branch.merging(other.branch))
case .semvar:
guard let semvar else { return other }
return .semvar(semvar.merging(other.semvar))
}
}
}

View File

@@ -1,5 +1,7 @@
import CustomDump
import Foundation
import TOMLKit
// TODO: Add pre-release strategy that just bumps an integer.
/// Represents configuration that can be set via a file, generally in the root of the
/// project directory.
@@ -15,24 +17,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(strategy: .gitTag(exactMatch: false)))
)
}
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 +46,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 +67,31 @@ 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?
// TODO: Remove optional.
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])
// TODO: Remove.
case gitTag
public var branch: Branch? {
guard case let .branch(includeCommitSha) = self
else { return nil }
return .init(includeCommitSha: includeCommitSha)
}
}
}
@@ -133,23 +101,36 @@ public extension Configuration {
///
struct SemVar: Codable, Equatable, Sendable {
public let allowPreRelease: Bool?
/// 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
public let requireExistingFile: Bool?
/// Fail if an existing semvar is not parsed from the file or version generation strategy.
public let requireExistingSemVar: Bool
public let requireExistingSemVar: Bool?
public let strategy: Strategy?
public init(
preRelease: PreReleaseStrategy? = nil,
requireExistingFile: Bool = true,
requireExistingSemVar: Bool = true
allowPreRelease: Bool? = true,
preRelease: PreRelease? = nil,
requireExistingFile: Bool? = true,
requireExistingSemVar: Bool? = true,
strategy: Strategy? = nil
) {
self.allowPreRelease = allowPreRelease
self.preRelease = preRelease
self.requireExistingFile = requireExistingFile
self.requireExistingSemVar = requireExistingSemVar
self.strategy = strategy
}
public enum Strategy: Codable, Equatable, Sendable {
case command(arguments: [String])
case gitTag(exactMatch: Bool? = false)
}
}
@@ -158,7 +139,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?
@@ -186,13 +167,13 @@ public extension Configuration {
/// Represents a module target for a version file.
///
public struct Module: Codable, Equatable, Sendable {
public struct Module: Codable, Equatable, Sendable, CustomDumpReflectable {
/// The module directory name.
public let name: String
/// The version file name located in the module directory.
public let fileName: String
public let fileName: String?
/// Create a new module target.
///
@@ -201,11 +182,47 @@ public extension Configuration {
/// - fileName: The file name located in the module directory.
public init(
_ name: String,
fileName: String = "Version.swift"
fileName: String? = nil
) {
self.name = name
self.fileName = fileName
}
public var fileNameOrDefault: String {
fileName ?? "Version.swift"
}
public var customDumpMirror: Mirror {
.init(
self,
children: [
"name": name,
"fileName": fileNameOrDefault
],
displayStyle: .struct
)
}
}
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 +231,58 @@ 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(
allowPreRelease: Bool? = nil,
preRelease: PreRelease? = nil,
requireExistingFile: Bool? = nil,
requireExistingSemVar: Bool? = nil,
strategy: SemVar.Strategy? = 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(allowPreRelease, preRelease, requireExistingFile, requireExistingSemVar, strategy) = self
else { return nil }
return .init(
allowPreRelease: allowPreRelease,
preRelease: preRelease,
requireExistingFile: requireExistingFile ?? false,
requireExistingSemVar: requireExistingSemVar ?? false,
strategy: strategy
)
}
public static func branch(_ branch: Branch) -> Self {
.branch(includeCommitSha: branch.includeCommitSha)
}
public static func semvar(_ value: SemVar) -> Self {
.semvar(
allowPreRelease: value.allowPreRelease,
preRelease: value.preRelease,
requireExistingFile: value.requireExistingFile,
requireExistingSemVar: value.requireExistingSemVar,
strategy: value.strategy
)
}
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

@@ -16,21 +16,43 @@ public extension DependencyValues {
@DependencyClient
public struct ConfigurationClient: Sendable {
/// The default file name for a configuration file.
public var defaultFileName: @Sendable () -> String = { "test.json" }
/// 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
}
/// Loads configuration from the given path, or searches for the default file and loads it.
/// Optionally merges other configuration, then perform an operation with the loaded configuration.
///
/// - Parameters:
/// - path: Optional file path of the configuration to load.
/// - other: Optional configuration to merge with the loaded configuration.
/// - operation: The operation to perform with the loaded configuration.
@discardableResult
public func withConfiguration<T>(
path: String?,
merging other: Configuration? = nil,
operation: (Configuration) async throws -> T
) async throws -> T {
let configuration = try await findAndLoad(
path != nil ? URL(filePath: path!) : nil
)
return try await operation(configuration.merging(other))
}
}
@@ -39,44 +61,50 @@ extension ConfigurationClient: DependencyKey {
public static var liveValue: ConfigurationClient {
.init(
defaultFileName: { "\(Constants.defaultFileNameWithoutExtension).json" },
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,156 @@
import Dependencies
import Foundation
import Logging
import LoggingFormatAndPipe
import Rainbow
import ShellClient
// MARK: Custom colors.
extension String {
var orange: Self {
bit24(255, 165, 0)
}
var magenta: Self {
bit24(238, 130, 238)
}
}
extension Logger.Level {
var coloredString: String {
switch self {
case .info:
return "\(self)".cyan
case .warning:
return "\(self)".orange.bold
case .debug:
return "\(self)".green
case .trace:
return "\(self)".yellow
case .error:
return "\(self)".red.bold
default:
return "\(self)"
}
}
}
private struct LevelFormatter: LoggingFormatAndPipe.Formatter {
let basic: BasicFormatter
var timestampFormatter: DateFormatter { basic.timestampFormatter }
// swiftlint:disable function_parameter_count
func processLog(
level: Logger.Level,
message: Logger.Message,
prettyMetadata: String?,
file: String,
function: String,
line: UInt
) -> String {
let now = Date()
return basic.format.map { component -> String in
return processComponent(
component,
now: now,
level: level,
message: message,
prettyMetadata: prettyMetadata,
file: file,
function: function,
line: line
)
}
.filter { $0.count > 0 }
.joined(separator: basic.separator ?? "")
}
public func processComponent(
_ component: LogComponent,
now: Date,
level: Logger.Level,
message: Logger.Message,
prettyMetadata: String?,
file: String,
function: String,
line: UInt
) -> String {
switch component {
case .level:
let maxLen = "\(Logger.Level.warning)".count
let paddingCount = (maxLen - "\(level)".count) / 2
var padding = ""
for _ in 0 ... paddingCount {
padding += " "
}
return "\(padding)\(level.coloredString)\(padding)"
case let .group(components):
return components.map { component -> String in
self.processComponent(
component,
now: now,
level: level,
message: message,
prettyMetadata: prettyMetadata,
file: file,
function: function,
line: line
)
}.joined()
case .message:
return basic.processComponent(
component,
now: now,
level: level,
message: message,
prettyMetadata: prettyMetadata,
file: file,
function: function,
line: line
).italic
default:
return basic.processComponent(
component,
now: now,
level: level,
message: message,
prettyMetadata: prettyMetadata,
file: file,
function: function,
line: line
)
}
}
// swiftlint:enable function_parameter_count
}
extension LoggingOptions {
func makeLogger() -> Logger {
let formatters: [LogComponent] = [
.text(executableName.magenta),
.text(command.blue),
.level,
.group([
.text("-> "),
.message
])
]
return Logger(label: executableName) { _ in
LoggingFormatAndPipe.Handler(
formatter: LevelFormatter(basic: BasicFormatter(
formatters,
separator: " | "
)),
pipe: LoggerTextOutputStreamPipe.standardOutput
)
}
}
}

View File

@@ -1,14 +1,12 @@
import Logging
// TODO: Move.
@_spi(Internal)
public extension Logger.Level {
init(verbose: Int) {
switch verbose {
case 1: self = .warning
case 2: self = .debug
case 3...: self = .trace
case 1: self = .debug
case 2...: self = .trace
default: self = .info
}
}

View File

@@ -0,0 +1,28 @@
import Dependencies
import ShellClient
public struct LoggingOptions: Equatable, Sendable {
let command: String
let executableName: String
let verbose: Int
public init(
executableName: String = "bump-version",
command: String,
verbose: Int
) {
self.executableName = executableName
self.command = command
self.verbose = verbose
}
public func withLogger<T>(_ operation: () async throws -> T) async rethrows -> T {
try await withDependencies {
$0.logger = makeLogger()
$0.logger.logLevel = .init(verbose: verbose)
} operation: {
try await operation()
}
}
}

View File

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

View File

@@ -0,0 +1,25 @@
import ArgumentParser
import CliClient
import CliDoc
import Foundation
import ShellClient
// NOTE: This command is only used with the build with version plugin.
struct BuildCommand: AsyncParsableCommand {
static let commandName = "build"
static let configuration: CommandConfiguration = .init(
commandName: Self.commandName,
abstract: Abstract.default("Used for the build with version plugin.").render(),
discussion: 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, command: Self.commandName)
}
}

View File

@@ -0,0 +1,42 @@
import ArgumentParser
import CliClient
import CliDoc
import Dependencies
struct BumpCommand: CommandRepresentable {
static let commandName = "bump"
static let configuration = CommandConfiguration(
commandName: Self.commandName,
abstract: Abstract.default("Bump version of a command-line tool."),
usage: Usage.default(commandName: nil),
discussion: Discussion.default(examples: [
makeExample(
label: "Basic usage, bump the minor version.",
example: "--minor",
includesAppName: false
),
makeExample(
label: "Dry run, just show what the bumped version would be.",
example: "--minor --dry-run",
includesAppName: false
)
])
)
@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, command: Self.commandName, args: bumpOption)
}
}
extension CliClient.BumpOption: EnumerableFlag {}

View File

@@ -0,0 +1,232 @@
import ArgumentParser
import CliClient
import CliDoc
import ConfigurationClient
import CustomDump
import Dependencies
import FileClient
import Foundation
struct ConfigCommand: AsyncParsableCommand {
static let commandName = "config"
static let configuration = CommandConfiguration(
commandName: commandName,
abstract: Abstract.default("Configuration commands").render(),
subcommands: [
DumpConfig.self,
GenerateConfig.self
]
)
}
extension ConfigCommand {
struct DumpConfig: CommandRepresentable {
static let commandName = "dump"
static let parentCommand = ConfigCommand.commandName
static let configuration = CommandConfiguration(
commandName: Self.commandName,
abstract: Abstract.default("Inspect the parsed configuration."),
usage: Usage.default(parentCommand: ConfigCommand.commandName, commandName: Self.commandName),
discussion: Discussion.default(
notes: [
"""
The default style is to print the output in `json`, however you can use the `--swift` flag to
print the output in `swift`.
"""
],
examples: [
makeExample(label: "Show the project configuration.", example: ""),
makeExample(
label: "Update a configuration file with the dumped output",
example: "--disable-pre-release > .bump-version.prod.json"
)
]
) {
"""
Loads the project configuration file (if applicable) and merges the options passed in,
then prints the configuration to stdout.
"""
},
aliases: ["d"]
)
@Flag(
help: "Change the style of what get's printed."
)
fileprivate var printStyle: PrintStyle = .json
@OptionGroup var globals: ConfigCommandOptions
func run() async throws {
let configuration = try await globals
.shared(command: Self.commandName)
.runClient(\.parsedConfiguration)
try globals.printConfiguration(configuration, style: printStyle)
}
}
struct GenerateConfig: CommandRepresentable {
static let commandName = "generate"
static let parentCommand = ConfigCommand.commandName
static let configuration: CommandConfiguration = .init(
commandName: commandName,
abstract: Abstract.default("Generate a configuration file, based on the given options.").render(),
usage: Usage.default(parentCommand: ConfigCommand.commandName, commandName: commandName),
discussion: Discussion.default(examples: [
makeExample(
label: "Generate a configuration file for the 'foo' target.",
example: "-m foo"
),
makeExample(
label: "Show the output and don't write to a file.",
example: "-m foo --print"
)
]),
aliases: ["g"]
)
@Flag(
help: "The style of the configuration."
)
var style: ConfigCommand.Style = .semvar
@Flag(
name: .customLong("print"),
help: "Print json to stdout."
)
var printJson: Bool = false
@OptionGroup var globals: ConfigCommandOptions
func run() async throws {
try await withSetupDependencies {
@Dependency(\.configurationClient) var configurationClient
let configuration = try style.parseConfiguration(
configOptions: globals.configOptions,
extraOptions: globals.extraOptions
)
switch printJson {
case true:
try globals.handlePrintJson(configuration)
case false:
let url = globals.configFileUrl
try await configurationClient.write(configuration, url)
print(url.cleanFilePath)
}
}
}
}
}
extension ConfigCommand {
enum Style: EnumerableFlag {
case branch, semvar
func parseConfiguration(
configOptions: ConfigurationOptions,
extraOptions: [String]
) throws -> Configuration {
let strategy: Configuration.VersionStrategy
switch self {
case .branch:
strategy = .branch(includeCommitSha: configOptions.commitSha)
case .semvar:
strategy = try .semvar(configOptions.semvarOptions(extraOptions: extraOptions))
}
return try Configuration(
target: configOptions.target(),
strategy: strategy
)
}
}
// TODO: Add verbose.
@dynamicMemberLookup
struct ConfigCommandOptions: ParsableArguments {
@OptionGroup var configOptions: ConfigurationOptions
@Flag(
name: .shortAndLong,
help: "Increase logging level, can be passed multiple times (example: -vvv)."
)
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-command` or `--custom-pre-release` flag is not set.
"""
)
var extraOptions: [String] = []
subscript<T>(dynamicMember keyPath: KeyPath<ConfigurationOptions, T>) -> T {
configOptions[keyPath: keyPath]
}
}
}
private extension ConfigCommand.DumpConfig {
enum PrintStyle: EnumerableFlag {
case json, swift
}
}
private extension ConfigCommand.ConfigCommandOptions {
func shared(command: String) throws -> CliClient.SharedOptions {
try configOptions.shared(command: command, extraOptions: extraOptions, verbose: verbose)
}
func handlePrintJson(_ configuration: Configuration) throws {
@Dependency(\.coders) var coders
@Dependency(\.logger) var logger
let data = try coders.jsonEncoder().encode(configuration)
guard let string = String(bytes: data, encoding: .utf8) else {
logger.error("Error encoding configuration to json.")
throw ConfigurationEncodingError()
}
print(string)
}
func printConfiguration(
_ configuration: Configuration,
style: ConfigCommand.DumpConfig.PrintStyle
) throws {
switch style {
case .json:
try handlePrintJson(configuration)
case .swift:
customDump(configuration)
}
// guard printJson else {
// customDump(configuration)
// return
// }
// try handlePrintJson(configuration)
}
}
private extension ConfigurationOptions {
var configFileUrl: URL {
switch configurationFile {
case let .some(path):
return URL(filePath: path)
case .none:
return URL(filePath: ".bump-version.json")
}
}
}
struct ConfigurationEncodingError: Error {}

View File

@@ -0,0 +1,29 @@
import ArgumentParser
import CliClient
import CliDoc
import Dependencies
import Foundation
import ShellClient
struct GenerateCommand: CommandRepresentable {
static let commandName = "generate"
static let configuration: CommandConfiguration = .init(
commandName: Self.commandName,
abstract: Abstract.default("Generates a version file in your project."),
usage: Usage.default(commandName: Self.commandName),
discussion: Discussion.default(
examples: [
makeExample(label: "Basic usage.", example: "")
]
) {
"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, command: Self.commandName)
}
}

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

@@ -0,0 +1,169 @@
import ArgumentParser
@_spi(Internal) import CliClient
import ConfigurationClient
import Dependencies
import Foundation
import Rainbow
// TODO: Add an option to not load project configuration.
struct GlobalOptions: ParsableArguments {
@OptionGroup
var configOptions: ConfigurationOptions
@Option(
name: .customLong("git-directory"),
help: "The git directory for the version (default: current directory)"
)
var gitDirectory: String?
@Flag(
name: .customLong("dry-run"),
help: "Print's what would be written to a target version file."
)
var dryRun: Bool = false
@Flag(
name: .shortAndLong,
help: "Increase logging level, can be passed multiple times (example: -vvv)."
)
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` or `--custom-pre-release` flag is not set.
"""
)
var extraOptions: [String] = []
}
struct ConfigurationOptions: ParsableArguments {
@Option(
name: [.customShort("f"), .long],
help: "Specify the path to a configuration file. (default: .bump-version.json)",
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(
name: [.customShort("p"), .long],
help: "Path to the version file, not required if module is set."
)
var targetFilePath: String?
@Option(
name: [.customShort("m"), .long],
help: "The target module name or directory path, not required if path is set."
)
var targetModule: String?
@Option(
name: [.customShort("n"), .long],
help: "The file name inside the target module. (defaults to: \"Version.swift\")."
)
var targetFileName: String?
}
struct PreReleaseOptions: ParsableArguments {
@Flag(
name: .shortAndLong,
help: ""
)
var disablePreRelease: Bool = false
@Flag(
name: [.customShort("b"), .customLong("pre-release-branch-style")],
help: """
Use branch name and commit sha for pre-release suffix, ignored if branch is set.
"""
)
var useBranchAsPreRelease: Bool = false
@Flag(
name: [.customShort("g"), .customLong("pre-release-git-tag-style")],
help: """
Use `git describe --tags` for pre-release suffix, ignored if branch is set.
"""
)
var useTagAsPreRelease: Bool = false
@Option(
name: .long,
help: """
Add / use a pre-release prefix 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
}
// TODO: Add custom command strategy.
struct SemVarOptions: ParsableArguments {
@Flag(
name: .long,
inversion: .prefixedEnableDisable,
help: "Use git-tag strategy for semvar."
)
var gitTag: Bool = true
@Flag(
name: .long,
help: "Require exact match for git tag strategy."
)
var requireExactMatch: Bool = false
@Flag(
name: .long,
help: """
Fail if an existing version file does not exist, \("ignored if:".yellow.bold) \("branch is set".italic).
"""
)
var requireExistingFile: Bool = false
@Flag(
name: .long,
help: "Fail if a semvar is not parsed from existing file or git tag, used if branch is not set."
)
var requireExistingSemvar: Bool = false
@Flag(
name: .shortAndLong,
help: """
Custom command strategy, uses extra-options to call an external command.
The external command should return a semvar that is used.
"""
)
var customCommand: Bool = false
@OptionGroup var preRelease: PreReleaseOptions
}

View File

@@ -0,0 +1,311 @@
/*
This file contains helpers for generating the documentation for the commands.
*/
import ArgumentParser
import CliDoc
import Rainbow
protocol CommandRepresentable: AsyncParsableCommand {
static var commandName: String { get }
static var parentCommand: String? { get }
}
extension CommandRepresentable {
static var parentCommand: String? { nil }
static func makeExample(
label: String,
example: String,
includesAppName: Bool = true
) -> AppExample {
.init(
label: label,
parentCommand: parentCommand,
commandName: commandName,
includesAppName: includesAppName,
example: example
)
}
}
extension Abstract where Content == String {
static func `default`(_ string: String) -> Self {
.init { string.blue }
}
}
struct Note<Content: TextNode>: TextNode {
let content: Content
init(
@TextBuilder _ content: () -> Content
) {
self.content = content()
}
var body: some TextNode {
LabeledContent {
content.italic()
} label: {
"Note:".yellow.bold
}
.style(.vertical())
}
}
extension Note where Content == AnyTextNode {
static func `default`(
notes: [String],
usesConfigurationFileNote: Bool = true,
usesConfigurationMergingNote: Bool = true
) -> Self {
var notes = notes
if usesConfigurationFileNote {
notes.insert(
"Most options are not required when a configuration file is setup for your project.",
at: 0
)
}
if usesConfigurationMergingNote {
if usesConfigurationFileNote {
notes.insert(
"Any configuration options get merged with the loaded project configuration file.",
at: 1
)
} else {
notes.insert(
"Any configuration options get merged with the loaded project configuration file.",
at: 0
)
}
}
return .init {
VStack {
notes.enumerated().map { "\($0 + 1). \($1)" }
}
.eraseToAnyTextNode()
}
}
}
extension Discussion where Content == AnyTextNode {
static func `default`<Preamble: TextNode, Trailing: TextNode>(
notes: [String] = [],
examples: [AppExample]? = nil,
usesExtraOptions: Bool = true,
usesConfigurationFileNote: Bool = true,
usesConfigurationMergingNote: Bool = true,
@TextBuilder preamble: () -> Preamble,
@TextBuilder trailing: () -> Trailing
) -> Self {
Discussion {
VStack {
preamble().italic()
Note.default(
notes: notes,
usesConfigurationFileNote: usesConfigurationFileNote,
usesConfigurationMergingNote: usesConfigurationMergingNote
)
if let examples {
ExampleSection.default(examples: examples, usesExtraOptions: usesExtraOptions)
}
trailing()
}
.separator(.newLine(count: 2))
.eraseToAnyTextNode()
}
}
static func `default`<Preamble: TextNode>(
notes: [String] = [],
examples: [AppExample]? = nil,
usesExtraOptions: Bool = true,
usesConfigurationFileNote: Bool = true,
usesConfigurationMergingNote: Bool = true,
@TextBuilder preamble: () -> Preamble
) -> Self {
.default(
notes: notes,
examples: examples,
usesExtraOptions: usesExtraOptions,
usesConfigurationFileNote: usesConfigurationFileNote,
usesConfigurationMergingNote: usesConfigurationMergingNote,
preamble: preamble,
trailing: {
if usesExtraOptions {
ImportantNote.extraOptionsNote
} else {
Empty()
}
}
)
}
static func `default`(
notes: [String] = [],
examples: [AppExample]? = nil,
usesExtraOptions: Bool = true,
usesConfigurationFileNote: Bool = true,
usesConfigurationMergingNote: Bool = true
) -> Self {
.default(
notes: notes,
examples: examples,
usesExtraOptions: usesExtraOptions,
usesConfigurationFileNote: usesConfigurationFileNote,
usesConfigurationMergingNote: usesConfigurationMergingNote,
preamble: { Empty() },
trailing: { Empty() }
)
}
}
extension ExampleSection where Header == String, Label == String {
static func `default`(
examples: [AppExample] = [],
usesExtraOptions: Bool = true
) -> some TextNode {
var examples: [AppExample] = examples
if usesExtraOptions {
examples = examples.appendingExtraOptionsExample()
}
return Self(
"Examples:",
label: "A few common usage examples.",
examples: examples.map(\.example)
)
.style(AppExampleSectionStyle())
}
}
struct AppExampleSectionStyle: ExampleSectionStyle {
func render(content: ExampleSectionConfiguration) -> some TextNode {
Section {
VStack {
content.examples.map { example in
VStack {
example.label.color(.green).bold()
ShellCommand(example.example).style(.default)
}
}
}
.separator(.newLine(count: 2))
} header: {
HStack {
content.title.color(.blue).bold()
content.label.italic()
}
}
}
}
struct AppExample {
let label: String
let parentCommand: String?
let commandName: String
let includesAppName: Bool
let exampleText: String
init(
label: String,
parentCommand: String? = nil,
commandName: String,
includesAppName: Bool = true,
example exampleText: String
) {
self.label = label
self.parentCommand = parentCommand
self.commandName = commandName
self.includesAppName = includesAppName
self.exampleText = exampleText
}
var example: Example {
var exampleString = "\(commandName) \(exampleText)"
if let parentCommand {
exampleString = "\(parentCommand) \(exampleString)"
}
if includesAppName {
exampleString = "\(Application.commandName) \(exampleString)"
}
return (label: label, example: exampleString)
}
}
extension Array where Element == AppExample {
func appendingExtraOptionsExample() -> Self {
guard let first = first else { return self }
var output = self
output.append(.init(
label: "Passing extra options to custom strategy.",
parentCommand: first.parentCommand,
commandName: first.commandName,
includesAppName: first.includesAppName,
example: "--custom-command -- git describe --tags --exact-match"
))
return output
}
}
struct ImportantNote<Content: TextNode>: TextNode {
let content: Content
init(
@TextBuilder _ content: () -> Content
) {
self.content = content()
}
var body: some TextNode {
LabeledContent {
content.italic()
} label: {
"Important Note:".red.bold
}
.style(.vertical())
}
}
extension ImportantNote where Content == String {
static var extraOptionsNote: Self {
.init {
"""
Extra options / flags for custom strategies must proceed a `--` or you may get an undefined option error.
"""
}
}
}
extension Usage where Content == AnyTextNode {
static func `default`(parentCommand: String? = nil, commandName: String?) -> Self {
var commandString = commandName == nil ? "" : "\(commandName!)"
if let parentCommand {
commandString = "\(parentCommand) \(commandString)"
}
commandString = commandString == "" ? "\(Application.commandName)" : "\(Application.commandName) \(commandString)"
return .init {
HStack {
commandString.blue
"[<options>]".green
"--"
"[<extra-options> ...]".cyan
}
.eraseToAnyTextNode()
}
}
}

View File

@@ -0,0 +1,190 @@
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 CliClient.SharedOptions {
func runClient<T>(
_ keyPath: KeyPath<CliClient, @Sendable (Self) async throws -> T>
) async throws -> T {
try await withSetupDependencies {
@Dependency(\.cliClient) var cliClient
return try await cliClient[keyPath: keyPath](self)
}
}
func runClient<A, T>(
_ keyPath: KeyPath<CliClient, @Sendable (A, Self) async throws -> T>,
args: A
) async throws -> T {
try await withSetupDependencies {
@Dependency(\.cliClient) var cliClient
return try await cliClient[keyPath: keyPath](args, self)
}
}
}
extension GlobalOptions {
func run(
_ keyPath: KeyPath<CliClient, @Sendable (CliClient.SharedOptions) async throws -> String>,
command: String
) async throws {
let output = try await shared(command: command).runClient(keyPath)
print(output)
}
func run<T>(
_ keyPath: KeyPath<CliClient, @Sendable (T, CliClient.SharedOptions) async throws -> String>,
command: String,
args: T
) async throws {
let output = try await shared(command: command).runClient(keyPath, args: args)
print(output)
}
func shared(command: String) throws -> CliClient.SharedOptions {
try configOptions.shared(
command: command,
dryRun: dryRun,
extraOptions: extraOptions,
gitDirectory: gitDirectory,
verbose: verbose
)
}
}
private extension TargetOptions {
func configTarget() throws -> Configuration.Target? {
guard let targetFilePath else {
guard let targetModule else {
return nil
}
return .init(module: .init(targetModule, fileName: targetFileName))
}
return .init(path: targetFilePath)
}
}
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))
} else if let preReleasePrefix {
return .init(prefix: preReleasePrefix, strategy: nil)
}
return nil
}
}
extension SemVarOptions {
func parseStrategy(extraOptions: [String]) throws -> Configuration.SemVar.Strategy? {
@Dependency(\.logger) var logger
guard customCommand else {
guard gitTag else { return nil }
return .gitTag(exactMatch: requireExactMatch)
}
guard extraOptions.count > 0 else {
logger.error("""
Extra options are empty, this does not make sense when using a custom command
strategy.
""")
throw ExtraOptionsEmpty()
}
return .command(arguments: extraOptions)
}
func configSemVarOptions(
includeCommitSha: Bool,
extraOptions: [String]
) throws -> Configuration.SemVar {
@Dependency(\.logger) var logger
// TODO: Update when / if there's an update config command.
if customCommand && preRelease.customPreRelease {
logger.warning("""
Custom pre-release can not be used at same time as custom command.
Ignoring pre-release...
""")
}
return try .init(
allowPreRelease: !preRelease.disablePreRelease,
preRelease: customCommand ? nil : preRelease.configPreReleaseStrategy(
includeCommitSha: includeCommitSha,
extraOptions: extraOptions
),
// Use nil here if false, which makes them not get used in json / file output, which makes
// user config smaller.
requireExistingFile: requireExistingFile ? true : nil,
requireExistingSemVar: requireExistingSemvar ? true : nil,
strategy: parseStrategy(extraOptions: extraOptions)
)
}
}
extension ConfigurationOptions {
func target() throws -> Configuration.Target? {
try targetOptions.configTarget()
}
func semvarOptions(
extraOptions: [String]
) throws -> Configuration.SemVar {
try semvarOptions.configSemVarOptions(
includeCommitSha: commitSha,
extraOptions: extraOptions
)
}
func shared(
command: String,
dryRun: Bool = true,
extraOptions: [String] = [],
gitDirectory: String? = nil,
verbose: Int = 0
) throws -> CliClient.SharedOptions {
try .init(
allowPreReleaseTag: !semvarOptions.preRelease.disablePreRelease,
dryRun: dryRun,
gitDirectory: gitDirectory,
loggingOptions: .init(command: command, verbose: verbose),
target: target(),
branch: semvarOptions.gitTag ? nil : .init(includeCommitSha: commitSha),
semvar: semvarOptions(extraOptions: extraOptions),
configurationFile: configurationFile
)
}
}
struct ExtraOptionsEmpty: Error {}

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

@@ -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,210 +0,0 @@
import ArgumentParser
@_spi(Internal) import CliClient
import Dependencies
import Foundation
import Rainbow
struct GlobalOptions<Child: ParsableArguments>: ParsableArguments {
@OptionGroup var targetOptions: TargetOptions
@OptionGroup var child: Child
@Option(
name: .customLong("git-directory"),
help: "The git directory for the version (default: current directory)"
)
var gitDirectory: String?
@Flag(
name: .customLong("dry-run"),
help: "Print's what would be written to a target version file."
)
var dryRun: Bool = false
@Flag(
name: .shortAndLong,
help: "Increase logging level, can be passed multiple times (example: -vvv)."
)
var verbose: Int
}
struct Empty: ParsableArguments {}
struct TargetOptions: ParsableArguments {
@Option(
name: .shortAndLong,
help: "Path to the version file, not required if module is set."
)
var path: String?
@Option(
name: .shortAndLong,
help: "The target module name or directory path, not required if path is set."
)
var module: String?
@Option(
name: [.customShort("n"), .long],
help: "The file name inside the target module, required if module is set."
)
var fileName: String = "Version.swift"
}
struct PreReleaseOptions: ParsableArguments {
@Flag(
name: [.customShort("s"), .customLong("pre-release-branch-style")],
help: """
Use branch name and commit sha for pre-release suffix, ignored if branch is set.
"""
)
var useBranchAsPreRelease: Bool = false
@Flag(
name: [.customShort("g"), .customLong("pre-release-git-tag-style")],
help: """
Use `git describe --tags` for pre-release suffix, ignored if branch is set.
"""
)
var useTagAsPreRelease: Bool = false
@Option(
name: .shortAndLong,
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\")
"""
)
var custom: String?
}
struct SemVarOptions: ParsableArguments {
@Flag(
name: .long,
help: """
Fail if an existing version file does not exist, \("ignored if:".yellow.bold) \("branch is set".italic).
"""
)
var requireExistingFile: Bool = false
@Flag(
name: .long,
help: "Fail if a sem-var is not parsed from existing file or git tag, used if branch is not set."
)
var requireExistingSemvar: Bool = false
@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

@@ -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")
}
@@ -40,7 +41,9 @@ struct CliClientTests {
#expect(output == "/baz/Sources/bar/Version.swift")
} assert: { string, _ in
#expect(string != nil)
if type != .preRelease {
#expect(string != nil)
}
let typeString = optional ? "String?" : "String"
switch type {
@@ -65,7 +68,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 +99,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 +118,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))
@@ -129,15 +134,15 @@ extension CliClient.SharedOptions {
gitDirectory: String? = "/baz",
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
loggingOptions: .init(command: "test", verbose: 2),
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

@@ -1,7 +1,8 @@
import ConfigurationClient
@_spi(Internal) import ConfigurationClient
import Dependencies
import Foundation
import Testing
import TestSupport
@Suite("ConfigurationClientTests")
struct ConfigurationClientTests {
@@ -17,31 +18,120 @@ 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)
}
}
}
}
@Test
func mergingBranch() {
let branch = Configuration.Branch(includeCommitSha: false)
let branch2 = Configuration.Branch(includeCommitSha: true)
let merged = branch.merging(branch2)
#expect(merged == branch2)
let merged2 = branch.merging(nil)
#expect(merged2 == branch)
}
@Test
func mergingSemvar() {
let strategy1 = Configuration.VersionStrategy.semvar(.init())
let other = Configuration.VersionStrategy.semvar(.init(
allowPreRelease: true,
preRelease: .init(prefix: "foo", strategy: .gitTag),
requireExistingFile: true,
requireExistingSemVar: true,
strategy: .gitTag()
))
let merged = strategy1.merging(other)
#expect(merged == other)
let otherMerged = other.merging(strategy1)
#expect(otherMerged == other)
}
@Test
func mergingTarget() {
let config1 = Configuration(target: .init(path: "foo"))
let config2 = Configuration(target: .init(module: .init("bar")))
let merged = config1.merging(config2)
#expect(merged.target! == .init(module: .init("bar")))
let merged2 = merged.merging(config1)
#expect(merged2.target! == .init(path: "foo"))
let merged3 = merged2.merging(nil)
#expect(merged3 == merged2)
}
@Test
func mergingVersionStrategy() {
let version = Configuration.VersionStrategy.semvar(.init())
let version2 = Configuration.VersionStrategy.branch(.init())
let merged = version.merging(version2)
#expect(merged == version2)
let merged2 = merged.merging(.branch(includeCommitSha: false))
#expect(merged2.branch!.includeCommitSha == false)
let merged3 = version2.merging(version)
#expect(merged3 == version)
}
func run(
setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in },
operation: () async throws -> Void

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"]
]
)
]
}

View File

@@ -1,11 +1,24 @@
product := "cli-version"
docker_image := "cli-version"
product := "bump-version"
docker_image := "bump-version"
docker_tag := "test"
tap_url := "https://git.housh.dev/michael/homebrew-formula"
tap := "michael/formula"
formula := "bump-version"
release_base_url := "https://git.housh.dev/michael/bump-version/archive"
[private]
default:
@just --list
# Build and bottle homebrew formula.
bottle:
@brew uninstall {{formula}} || true
@brew tap {{tap}} {{tap_url}}
@brew install --build-bottle {{tap}}/{{formula}}
@brew bottle {{formula}}
bottle="$(ls *.gz)" && mv "${bottle}" "${bottle/--/-}"
# Build locally.
build configuration="debug":
@swift build \
@@ -42,3 +55,12 @@ test-docker: build-docker
test-docker-without-building:
@docker run --rm {{docker_image}}:{{docker_tag}} swift test
# Get the sha256 sum of the release and copy to clipboard.
get-release-sha prefix="": (build "release")
version=$(.build/release/hpa --version) && \
url="{{release_base_url}}/{{prefix}}${version}.tar.gz" && \
sha=$(curl "$url" | shasum -a 256) && \
stripped="${sha% *}" && \
echo "$stripped" | pbcopy && \
echo "Copied sha to clipboard: $stripped"