Compare commits

30 Commits

Author SHA1 Message Date
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
c07a0ef13b feat: Working on configuration client 2024-12-23 11:57:48 -05:00
d716348088 feat: Begin working on configuration client. 2024-12-22 23:30:25 -05:00
9c62c06ebe feat: Renames some modules, updates plugins to stop using deprecated components. 2024-12-22 12:29:12 -05:00
84ac4a6a12 feat: Moving things into separate modules. 2024-12-22 11:32:52 -05:00
0484f45d57 feat: Working on updates to sem var and git client. 2024-12-22 10:38:53 -05:00
7959ec274b feat: Integrates cli-client dependency into cli-version executable 2024-12-21 13:14:31 -05:00
ba7d63606c feat: Adds capturing file client, for tests. Updates bump version tests 2024-12-21 09:11:59 -05:00
59bc977788 feat: Updates dependencies and cli commands to be async. 2024-12-21 00:07:01 -05:00
b7ac6ee9d1 feat: Working on bump version. 2024-12-20 23:02:22 -05:00
2ca83829e0 feat: Working on cli-client. 2024-12-20 20:21:03 -05:00
847ddbc7b5 feat: Begins update for more modern swift-dependencies implementation. 2024-12-20 12:54:10 -05:00
61 changed files with 3137 additions and 665 deletions

10
.bump-version.json Normal file
View File

@@ -0,0 +1,10 @@
{
"target" : {
"module" : { "name" : "cli-version" }
},
"strategy" : {
"semvar" : {
"strategy" : { "gitTag": { "exactMatch": false } }
}
}
}

7
.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
root = true
[*.swift]
indent_style = space
indent_size = 2
tab_width = 2
trim_trailing_whitespace = true

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

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
.nvim/*

View File

@@ -1,4 +1,4 @@
version: 1
builder:
configs:
documentation_targets: [CliVersion]
- documentation_targets: [CliVersion]

11
.swiftformat Normal file
View File

@@ -0,0 +1,11 @@
--self init-only
--indent 2
--ifdef indent
--trimwhitespace always
--wraparguments before-first
--wrapparameters before-first
--wrapcollections preserve
--wrapconditions after-first
--typeblanklines preserve
--commas inline
--stripunusedargs closure-only

11
.swiftlint.yml Normal file
View File

@@ -0,0 +1,11 @@
disabled_rules:
- closing_brace
- fuction_body_length
- opening_brace
- nesting
included:
- Sources
- Tests
ignore_multiline_statement_conditions: true

View File

@@ -90,13 +90,28 @@
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CliClient"
BuildableName = "CliClient"
BlueprintName = "CliClient"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
shouldUseLaunchSchemeArgsEnv = "YES"
codeCoverageEnabled = "YES">
<Testables>
<TestableReference
skipped = "NO">
@@ -108,6 +123,16 @@
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "ConfigurationClientTests"
BuildableName = "ConfigurationClientTests"
BlueprintName = "ConfigurationClientTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction

8
Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
ARG SWIFT_VERSION="6.0.3"
FROM swift:${SWIFT_VERSION}
WORKDIR /app
COPY ./Package.* ./
RUN swift package resolve
COPY . .
RUN swift build
CMD ["swift", "test"]

View File

@@ -1,8 +1,9 @@
PLATFORM_MACOS = macOS
CONFIG := debug
DOCC_TARGET ?= CliVersion
DOCC_TARGET ?= CliClient
DOCC_BASEPATH = $(shell basename "$(PWD)")
DOCC_DIR ?= ./docs
SWIFT_VERSION ?= "5.10"
clean:
rm -rf .build
@@ -27,7 +28,7 @@ test-linux:
docker run --rm \
--volume "$(PWD):$(PWD)" \
--workdir "$(PWD)" \
swift:5.7-focal \
"swift:$(SWIFT_VERSION)" \
swift test
test-library:
@@ -39,4 +40,3 @@ update-version:
--allow-writing-to-package-directory \
update-version \
git-version

View File

@@ -1,12 +1,13 @@
{
"originHash" : "6ab0a9c883cfa1490d249a344074ad27369033fab78e1a90272ef07339a8c0ab",
"pins" : [
{
"identity" : "combine-schedulers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/combine-schedulers",
"state" : {
"revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb",
"version" : "1.0.0"
"revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13",
"version" : "1.0.2"
}
},
{
@@ -23,8 +24,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser.git",
"state" : {
"revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531",
"version" : "1.2.3"
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
"version" : "1.5.0"
}
},
{
@@ -32,8 +33,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-clocks",
"state" : {
"revision" : "d1fd837326aa719bee979bdde1f53cd5797443eb",
"version" : "1.0.0"
"revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53",
"version" : "1.0.5"
}
},
{
@@ -41,8 +42,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "ea631ce892687f5432a833312292b80db238186a",
"version" : "1.0.0"
"revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8",
"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"
}
},
{
@@ -50,8 +60,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-dependencies.git",
"state" : {
"revision" : "4e1eb6e28afe723286d8cc60611237ffbddba7c5",
"version" : "1.0.0"
"revision" : "5526c8a27675dc7b18d6fa643abfb64bcb200b77",
"version" : "1.6.2"
}
},
{
@@ -59,14 +69,14 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-docc-plugin.git",
"state" : {
"revision" : "26ac5758409154cc448d7ab82389c520fa8a8247",
"version" : "1.3.0"
"revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64",
"version" : "1.4.3"
}
},
{
"identity" : "swift-docc-symbolkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-docc-symbolkit",
"location" : "https://github.com/swiftlang/swift-docc-symbolkit",
"state" : {
"revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
"version" : "1.0.0"
@@ -77,8 +87,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed",
"version" : "1.5.3"
"revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91",
"version" : "1.6.2"
}
},
{
@@ -95,8 +105,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/m-housh/swift-shell-client.git",
"state" : {
"revision" : "9c0c4757d0a2e313d1a4a28e60418a753d3e4230",
"version" : "0.1.4"
"revision" : "d67f693f92428ef8adecb48c2b26ccac0d2f98cb",
"version" : "0.2.2"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "0687f71944021d616d34d922343dcef086855920",
"version" : "600.0.1"
}
},
{
@@ -104,10 +123,10 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631",
"version" : "1.0.2"
"revision" : "a3f634d1a409c7979cabc0a71b3f26ffa9fc8af1",
"version" : "1.4.3"
}
}
],
"version" : 2
"version" : 3
}

View File

@@ -1,46 +1,92 @@
// swift-tools-version: 5.6
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "swift-cli-version",
platforms: [
.macOS(.v10_15)
.macOS(.v13)
],
products: [
.library(name: "CliVersion", targets: ["CliVersion"]),
.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"]),
.plugin(name: "BuildWithVersionPlugin", targets: ["BuildWithVersionPlugin"]),
.plugin(name: "GenerateVersionPlugin", targets: ["GenerateVersionPlugin"]),
.plugin(name: "UpdateVersionPlugin", targets: ["UpdateVersionPlugin"])
],
dependencies: [
.package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.1.3"),
.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.2"),
.package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.2")
.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/pointfreeco/swift-custom-dump.git", from: "1.3.3")
],
targets: [
.executableTarget(
name: "cli-version",
name: "bump-version",
dependencies: [
"CliVersion",
.product(name: "ArgumentParser", package: "swift-argument-parser")
"CliClient",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "CustomDump", package: "swift-custom-dump")
]
),
.target(
name: "CliVersion",
name: "CliClient",
dependencies: [
.product(name: "ShellClient", package: "swift-shell-client")
"ConfigurationClient",
"FileClient",
"GitClient",
.product(name: "Logging", package: "swift-log"),
.product(name: "CustomDump", package: "swift-custom-dump")
]
),
.testTarget(
name: "CliVersionTests",
dependencies: ["CliVersion"]
dependencies: ["CliClient", "TestSupport"]
),
.target(
name: "ConfigurationClient",
dependencies: [
"FileClient",
.product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies")
]
),
.testTarget(
name: "ConfigurationClientTests",
dependencies: ["ConfigurationClient", "TestSupport"]
),
.target(
name: "FileClient",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies")
]
),
.target(
name: "GitClient",
dependencies: [
"FileClient",
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.testTarget(
name: "GitClientTests",
dependencies: ["GitClient"]
),
.target(name: "TestSupport"),
.plugin(
name: "BuildWithVersionPlugin",
capability: .buildTool(),
dependencies: [
"cli-version"
"bump-version"
]
),
.plugin(
@@ -55,7 +101,7 @@ let package = Package(
]
),
dependencies: [
"cli-version"
"bump-version"
]
),
.plugin(
@@ -70,7 +116,7 @@ let package = Package(
]
),
dependencies: [
"cli-version"
"bump-version"
]
)
]

View File

@@ -7,24 +7,28 @@ struct GenerateVersionBuildPlugin: BuildToolPlugin {
context: PackagePlugin.PluginContext,
target: PackagePlugin.Target
) async throws -> [PackagePlugin.Command] {
guard let target = target as? SourceModuleTarget else { return [] }
guard let target = target as? SwiftSourceModuleTarget else { return [] }
let gitDirectoryPath = target.directory
.removingLastComponent()
.removingLastComponent()
let gitDirectoryPath = target.directoryURL
.deletingLastPathComponent()
.deletingLastPathComponent()
let tool = try context.tool(named: "cli-version")
let outputPath = context.pluginWorkDirectory
let outputPath = context.pluginWorkDirectoryURL
let outputFile = outputPath.appending("Version.swift")
let outputFile = outputPath.appending(path: "Version.swift")
return [
.buildCommand(
displayName: "Build With Version Plugin",
executable: tool.path,
arguments: ["build", "--verbose", "--git-directory", gitDirectoryPath.string, outputPath.string],
displayName: "Build with Version Plugin",
executable: tool.url,
arguments: [
"build", "--verbose",
"--git-directory", gitDirectoryPath.absoluteString,
"--target", outputPath.absoluteString
],
environment: [:],
inputFiles: target.sourceFiles.map(\.path),
inputFiles: target.sourceFiles.map(\.url),
outputFiles: [outputFile]
)
]

View File

@@ -1,5 +1,5 @@
import PackagePlugin
import Foundation
import PackagePlugin
@main
struct GenerateVersionPlugin: CommandPlugin {
@@ -15,7 +15,7 @@ struct GenerateVersionPlugin: CommandPlugin {
else { continue }
let process = Process()
process.executableURL = URL(fileURLWithPath: gitVersion.path.string)
process.executableURL = gitVersion.url
process.arguments = arguments
try process.run()
process.waitUntilExit()
@@ -27,4 +27,3 @@ struct GenerateVersionPlugin: CommandPlugin {
}
}
}

View File

@@ -1,5 +1,5 @@
import PackagePlugin
import Foundation
import PackagePlugin
@main
struct UpdateVersionPlugin: CommandPlugin {
@@ -15,7 +15,7 @@ struct UpdateVersionPlugin: CommandPlugin {
else { continue }
let process = Process()
process.executableURL = URL(fileURLWithPath: gitVersion.path.string)
process.executableURL = gitVersion.url
process.arguments = arguments
try process.run()
process.waitUntilExit()

View File

@@ -0,0 +1,106 @@
import ConfigurationClient
import Dependencies
import DependenciesMacros
import FileClient
import Foundation
import GitClient
import ShellClient
public extension DependencyValues {
var cliClient: CliClient {
get { self[CliClient.self] }
set { self[CliClient.self] = newValue }
}
}
/// Handles the command-line commands.
@DependencyClient
public struct CliClient: Sendable {
/// Build and update the version based on the git tag, or branch + sha.
public var build: @Sendable (SharedOptions) async throws -> String
/// Bump the existing version.
public var bump: @Sendable (BumpOption?, SharedOptions) async throws -> String
/// 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
}
// TODO: Need a quiet option, as default log level is warning, need a way to set it to ignore logs.
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 struct SharedOptions: Equatable, Sendable {
let allowPreReleaseTag: Bool
let dryRun: Bool
let gitDirectory: String?
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,
loggingOptions: LoggingOptions,
target: Configuration.Target? = nil,
branch: Configuration.Branch? = nil,
semvar: Configuration.SemVar? = nil,
configurationFile: String? = nil
) {
self.allowPreReleaseTag = allowPreReleaseTag
self.dryRun = dryRun
self.gitDirectory = gitDirectory
self.loggingOptions = loggingOptions
self.target = target
self.branch = branch
self.semvar = semvar
self.configurationFile = configurationFile
}
}
}
extension CliClient: DependencyKey {
public static let testValue: CliClient = Self()
public static func live(environment: [String: String]) -> Self {
.init(
build: { try await $0.build(environment) },
bump: { try await $1.bump($0) },
generate: { try await $0.generate() },
parsedConfiguration: { options in
try await options.withMergedConfiguration { $0 }
}
)
}
public static var liveValue: CliClient {
.live(environment: ProcessInfo.processInfo.environment)
}
}

View File

@@ -0,0 +1,8 @@
enum CliClientError: Error {
case gitDirectoryNotFound
case fileExists(path: String)
case fileDoesNotExist(path: String)
case failedToParseVersionFile
case semVarNotFound
case preReleaseParsingError(String)
}

View File

@@ -7,19 +7,19 @@ 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(
@@ -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
@@ -60,9 +60,9 @@ 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

@@ -0,0 +1,18 @@
# CliClient
Derive a version for a command-line tool from git tags or a git sha.
## Additional Resources
[Github Repo](https://github.com/m-housh/swift-cli-version)
## 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.
## Articles
- <doc:GettingStarted>
- <doc:ManualPlugins>

View File

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 206 KiB

View File

@@ -0,0 +1,158 @@
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 {
try await withConfiguration(path: configurationFile) { configuration in
var configuration = configuration
configuration = configuration.mergingTarget(target)
if configuration.strategy?.branch != nil, let branch {
configuration = configuration.mergingStrategy(.branch(branch))
} else if let semvar {
configuration = configuration.mergingStrategy(.semvar(semvar))
}
return try await operation(configuration)
}
}
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("\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)
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.
logger.debug("Failed to parse semvar, but got current version string.")
try await write(container)
case let .semvar(semvar, usesOptionalType: usesOptionalType):
logger.debug("Semvar prior to bumping: \(semvar)")
let bumped = semvar.bump(type)
let version = bumped.versionString(withPreReleaseTag: allowPreReleaseTag)
guard bumped != semvar 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,82 @@
import ConfigurationClient
import Dependencies
import FileClient
import Foundation
extension Configuration {
func mergingTarget(_ otherTarget: Configuration.Target?) -> Self {
.init(
target: otherTarget ?? target,
strategy: strategy
)
}
func mergingStrategy(_ otherStrategy: Configuration.VersionStrategy?) -> Self {
.init(
target: target,
strategy: strategy?.merging(otherStrategy)
)
}
}
extension Configuration.PreRelease {
func merging(_ other: Self?) -> Self {
.init(
prefix: other?.prefix ?? prefix,
strategy: other?.strategy ?? strategy
)
}
}
extension Configuration.Branch {
func merging(_ other: Self?) -> Self {
return .init(includeCommitSha: other?.includeCommitSha ?? includeCommitSha)
}
}
extension Configuration.SemVar {
// TODO: Merge strategy ??
func merging(_ other: Self?) -> Self {
.init(
allowPreRelease: other?.allowPreRelease ?? allowPreRelease,
preRelease: preRelease?.merging(other?.preRelease),
requireExistingFile: other?.requireExistingFile ?? requireExistingFile,
requireExistingSemVar: other?.requireExistingSemVar ?? requireExistingSemVar,
strategy: other?.strategy ?? strategy
)
}
}
extension Configuration.VersionStrategy {
func merging(_ other: Self?) -> Self {
guard let branch else {
guard let semvar else { return self }
return .semvar(semvar.merging(other?.semvar))
}
return .branch(branch.merging(other?.branch))
}
}
extension Configuration {
func merging(_ other: Self?) -> Self {
var output = self
output = output.mergingTarget(other?.target)
output = output.mergingStrategy(other?.strategy)
return output
}
}
@discardableResult
func withConfiguration<T>(
path: String?,
_ operation: (Configuration) async throws -> T
) async throws -> T {
@Dependency(\.configurationClient) var configurationClient
let configuration = try await configurationClient.findAndLoad(
path != nil ? URL(filePath: path!) : nil
)
return try await operation(configuration)
}

View File

@@ -0,0 +1,242 @@
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 {
return try await .semvar(
applyingPreRelease(semVar!, gitDirectory),
usesOptionalType: usesOptionalType ?? false
)
}
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 {
return try await .semvar(
applyingPreRelease(semVar!, gitDirectory),
usesOptionalType: usesOptionalType ?? false
)
}
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
)
}
}
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

@@ -0,0 +1,3 @@
enum Constants {
static let defaultFileName = "Version.swift"
}

View File

@@ -0,0 +1,52 @@
import Dependencies
import FileClient
import Foundation
import GitClient
@_spi(Internal)
public extension FileClient {
private func getVersionString(
fileUrl: URL,
gitDirectory: String?
) async throws -> (version: String, usesOptionalType: Bool) {
@Dependency(\.gitClient) var gitClient
@Dependency(\.logger) var logger
let targetUrl = fileUrl
guard fileExists(targetUrl) else {
throw CliClientError.fileDoesNotExist(path: fileUrl.cleanFilePath)
}
let contents = try await read(targetUrl)
let versionLine = contents.split(separator: "\n")
.first { $0.hasPrefix("let VERSION:") }
guard let versionLine else {
throw CliClientError.failedToParseVersionFile
}
logger.debug("Version line: \(versionLine)")
let isOptional = versionLine.contains("String?")
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(
file: URL,
gitDirectory: String?
) async throws -> (semVar: SemVar?, usesOptionalType: Bool) {
@Dependency(\.logger) var logger
let (string, usesOptionalType) = try await getVersionString(fileUrl: file, gitDirectory: gitDirectory)
let semvar = SemVar(string: string)
logger.debug("Semvar: \(String(describing: semvar))")
return (semvar, usesOptionalType)
}
}

View File

@@ -0,0 +1,172 @@
import Dependencies
import Foundation
import Logging
import LoggingFormatAndPipe
import Rainbow
import ShellClient
extension String {
var orange: Self {
bit24(255, 165, 0)
}
var magena: Self {
// bit24(186, 85, 211)
bit24(238, 130, 238)
}
}
@_spi(Internal)
public extension Logger.Level {
init(verbose: Int) {
switch verbose {
case 1: self = .debug
case 2...: self = .trace
default: self = .warning
}
}
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)"
}
}
}
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 CliClient.LoggingOptions {
func makeLogger() -> Logger {
let formatters: [LogComponent] = [
.text(executableName.magena),
.text(command.blue),
.level,
.group([
.text("-> "),
.message
])
]
return Logger(label: executableName) { _ in
LoggingFormatAndPipe.Handler(
formatter: LevelFormatter(basic: BasicFormatter(
formatters,
separator: " | "
)),
pipe: LoggerTextOutputStreamPipe.standardOutput
)
}
}
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,137 @@
import Dependencies
import FileClient
import Foundation
import GitClient
// Container for sem-var version.
@_spi(Internal)
public struct SemVar: CustomStringConvertible, Equatable, Sendable {
/// The major version.
public let major: Int
/// The minor version.
public let minor: Int
/// The patch version.
public let patch: Int
/// Extra pre-release tag.
public let preRelease: String?
public init(
major: Int,
minor: Int,
patch: Int,
preRelease: String? = nil
) {
self.major = major
self.minor = minor
self.patch = patch
self.preRelease = preRelease
}
public init(preRelease: String? = nil) {
self.init(
major: 0,
minor: 0,
patch: 0,
preRelease: preRelease
)
}
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: "")))
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,
minor: minor ?? 0,
patch: patch ?? 0,
preRelease: preRelease
)
}
public var description: String { versionString() }
// Create a version string, optionally appending a suffix.
public func versionString(withPreReleaseTag: Bool = true) -> String {
let string = "\(major).\(minor).\(patch)"
guard withPreReleaseTag else { return string }
guard let suffix = preRelease, suffix.count > 0 else {
return string
}
if !suffix.hasPrefix("-") {
return "\(string)-\(suffix)"
}
return "\(string)\(suffix)"
}
// Bumps the sem-var by the given option (major, minor, patch)
public func bump(_ option: CliClient.BumpOption, preRelease: String? = nil) -> Self {
switch option {
case .major:
return .init(
major: major + 1,
minor: 0,
patch: 0,
preRelease: preRelease
)
case .minor:
return .init(
major: major,
minor: minor + 1,
patch: 0,
preRelease: preRelease
)
case .patch:
return .init(
major: major,
minor: minor,
patch: patch + 1,
preRelease: preRelease
)
case .preRelease:
guard let preRelease else { return self }
return applyingPreRelease(preRelease)
}
}
public func applyingPreRelease(_ preRelease: String) -> Self {
.init(
major: major,
minor: minor,
patch: patch,
preRelease: preRelease
)
}
}
@_spi(Internal)
public extension GitClient.Version {
var semVar: SemVar? {
.init(string: description)
}
}

View File

@@ -0,0 +1,30 @@
@_spi(Internal)
public struct Template: Sendable {
let type: TemplateType
let version: String?
enum TemplateType: String, Sendable {
case optionalString = "String?"
case string = "String"
}
var value: String {
let versionString = version != nil ? "\"\(version!)\"" : "nil"
return """
// Do not set this variable, it is set during the build process.
let VERSION: \(type.rawValue) = \(versionString)
"""
}
public static func build(_ version: String? = nil) -> String {
nonOptional(version)
}
public static func nonOptional(_ version: String? = nil) -> String {
Self(type: .string, version: version).value
}
public static func optional(_ version: String? = nil) -> String {
Self(type: .optionalString, version: version).value
}
}

View File

@@ -1,23 +0,0 @@
# ``CliVersion``
Derive a version for a command line tool from git tags or a git sha.
## Additional Resources
[Github Repo](https://github.com/m-housh/swift-cli-version)
## 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.
## Articles
- <doc:GettingStarted>
- <doc:ManualPlugins>
## Api
- ``FileClient``
- ``GitVersionClient``

View File

@@ -1,98 +0,0 @@
import Dependencies
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import XCTestDynamicOverlay
/// Represents the interactions with the file system. It is able
/// to read from and write to files.
///
///
/// ```swift
/// @Dependency(\.fileClient) var fileClient
/// ```
///
public struct FileClient {
/// Write `Data` to a file `URL`.
public private(set) var write: (Data, URL) throws -> Void
/// Create a new ``GitVersion/FileClient`` instance.
///
/// This is generally not interacted with directly, instead access as a dependency.
///
///```swift
/// @Dependency(\.fileClient) var fileClient
///```
///
/// - Parameters:
/// - write: Write the data to a file.
public init(
write: @escaping (Data, URL) throws -> Void
) {
self.write = write
}
/// Write's the the string to a file path.
///
/// - Parameters:
/// - string: The string to write to the file.
/// - path: The file path.
public func write(string: String, to path: String) throws {
let url = try url(for: path)
try self.write(string: string, to: url)
}
/// Write's the the string to a file path.
///
/// - Parameters:
/// - string: The string to write to the file.
/// - url: The file url.
public func write(string: String, to url: URL) throws {
try self.write(Data(string.utf8), url)
}
}
extension FileClient: DependencyKey {
/// A ``FileClient`` that does not do anything.
public static let noop = FileClient.init(
write: { _, _ in }
)
/// An `unimplemented` ``FileClient``.
public static let testValue = FileClient(
write: unimplemented("\(Self.self).write")
)
/// The live ``FileClient``
public static let liveValue = FileClient(
write: { try $0.write(to: $1, options: .atomic) }
)
}
extension DependencyValues {
/// Access a basic ``FileClient`` that can read / write data to the file system.
///
public var fileClient: FileClient {
get { self[FileClient.self] }
set { self[FileClient.self] = newValue }
}
}
// MARK: - Private
fileprivate func url(for path: String) throws -> URL {
#if os(Linux)
return URL(fileURLWithPath: path)
#else
if #available(macOS 13.0, *) {
return URL(filePath: path)
} else {
// Fallback on earlier versions
return URL(fileURLWithPath: path)
}
#endif
}

View File

@@ -1,164 +0,0 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import ShellClient
import XCTestDynamicOverlay
/// A client that can retrieve the current version from a git directory.
/// It will use the current `tag`, or if the current git tree does not
/// point to a commit that is tagged, it will use the `branch git-sha` as
/// the version.
///
/// This is often not used directly, instead it is used with one of the plugins
/// that is supplied with this library. The use case is to set the version of a command line
/// tool based on the current git tag.
///
public struct GitVersionClient {
/// The closure to run that returns the current version from a given
/// git directory.
private var currentVersion: (String?) throws -> String
/// Create a new ``GitVersionClient`` instance.
///
/// This is normally not interacted with directly, instead access the client through the dependency system.
/// ```swift
/// @Dependency(\.gitVersionClient)
/// ```
///
/// - Parameters:
/// - currentVersion: The closure that returns the current version.
///
public init(currentVersion: @escaping (String?) throws -> String) {
self.currentVersion = currentVersion
}
/// Get the current version from the `git tag` in the given directory.
/// If a directory is not passed in, then we will use the current working directory.
///
/// - Parameters:
/// - gitDirectory: The directory to run the command in.
public func currentVersion(in gitDirectory: String? = nil) throws -> String {
try self.currentVersion(gitDirectory)
}
/// Override the `currentVersion` command and return the passed in version string.
///
/// This is useful for testing purposes.
///
/// - Parameters:
/// - version: The version string to return when `currentVersion` is called.
public mutating func override(with version: String) {
self.currentVersion = { _ in version }
}
}
extension GitVersionClient: TestDependencyKey {
/// The ``GitVersionClient`` used in test / debug builds.
public static let testValue = GitVersionClient(
currentVersion: unimplemented("\(Self.self).currentVersion", placeholder: "")
)
/// The ``GitVersionClient`` used in release builds.
public static var liveValue: GitVersionClient {
.init(currentVersion: { gitDirectory in
try GitVersion(workingDirectory: gitDirectory).currentVersion()
})
}
}
extension DependencyValues {
/// A ``GitVersionClient`` that can retrieve the current version from a
/// git directory.
public var gitVersionClient: GitVersionClient {
get { self[GitVersionClient.self] }
set { self[GitVersionClient.self] = newValue }
}
}
extension ShellCommand {
public static func gitCurrentSha(gitDirectory: String? = nil) -> Self {
GitVersion(workingDirectory: gitDirectory).command(for: .commit)
}
public static func gitCurrentBranch(gitDirectory: String? = nil) -> Self {
GitVersion(workingDirectory: gitDirectory).command(for: .branch)
}
public static func gitCurrentTag(gitDirectory: String? = nil) -> Self {
GitVersion(workingDirectory: gitDirectory).command(for: .describe)
}
}
// MARK: - Private
fileprivate struct GitVersion {
@Dependency(\.logger) var logger: Logger
@Dependency(\.shellClient) var shell: ShellClient
let workingDirectory: String?
func currentVersion() throws -> String {
logger.debug("\("Fetching current version".bold)")
do {
logger.debug("Checking for tag.")
return try run(command: command(for: .describe))
} catch {
logger.debug("\("No tag found, deferring to branch & git sha".red)")
let branch = try run(command: command(for: .branch))
let commit = try run(command: command(for: .commit))
return "\(branch) \(commit)"
}
}
internal func command(for argument: VersionArgs) -> ShellCommand {
.init(
shell: .env,
environment: nil,
in: workingDirectory ?? FileManager.default.currentDirectoryPath,
argument.arguments
)
}
}
fileprivate extension GitVersion {
func run(command: ShellCommand) throws -> String {
try shell.background(command, trimmingCharactersIn: .whitespacesAndNewlines)
}
enum VersionArgs {
case branch
case commit
case describe
var arguments: [Args] {
switch self {
case .branch:
return [.git, .symbolicRef, .quiet, .short, .head]
case .commit:
return [.git, .revParse, .short, .head]
case .describe:
return [.git, .describe, .tags, .exactMatch]
}
}
enum Args: String, CustomStringConvertible {
case git
case describe
case tags = "--tags"
case exactMatch = "--exact-match"
case quiet = "--quiet"
case symbolicRef = "symbolic-ref"
case revParse = "rev-parse"
case short = "--short"
case head = "HEAD"
}
}
}
extension RawRepresentable where RawValue == String, Self: CustomStringConvertible {
var description: String { rawValue }
}

View File

@@ -0,0 +1,33 @@
import Dependencies
import DependenciesMacros
import Foundation
public extension DependencyValues {
var coders: Coders {
get { self[Coders.self] }
set { self[Coders.self] = newValue }
}
}
@DependencyClient
public struct Coders: Sendable {
public var jsonDecoder: @Sendable () -> JSONDecoder = { .init() }
public var jsonEncoder: @Sendable () -> JSONEncoder = { .init() }
}
extension Coders: DependencyKey {
public static var testValue: Coders {
.init(
jsonDecoder: { .init() },
jsonEncoder: { defaultJsonEncoder }
)
}
public static var liveValue: Coders { .testValue }
private static let defaultJsonEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}()
}

View File

@@ -0,0 +1,286 @@
import CustomDump
import Foundation
/// Represents configuration that can be set via a file, generally in the root of the
/// project directory.
///
///
public struct Configuration: Codable, Equatable, Sendable {
/// The target for the version.
public let target: Target?
/// The strategy used for deriving the version.
public let strategy: VersionStrategy?
public init(
target: Target? = nil,
strategy: VersionStrategy? = .semvar(.init())
) {
self.target = target
self.strategy = strategy
}
public static var `default`: Self { .mock(module: "<my-tool>") }
public static func mock(module: String = "cli-version") -> Self {
.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: .semvar(.init(
preRelease: .init(prefix: "rc", strategy: .branch())
))
)
}
}
public extension Configuration {
/// Represents a branch version or pre-release strategy.
///
/// 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.
public let includeCommitSha: Bool
/// Create a new branch strategy.
///
/// - Parameters:
/// - includeCommitSha: Whether to include the commit sha.
public init(includeCommitSha: Bool = true) {
self.includeCommitSha = includeCommitSha
}
}
/// Represents version strategy for pre-release.
///
/// This appends a suffix to the version that get's generated from the version strategy.
/// For example: `1.0.0-rc-1`
///
struct PreRelease: Codable, Equatable, Sendable {
public let prefix: String?
// TODO: Remove optional.
public let strategy: Strategy?
public init(
prefix: String? = nil,
strategy: Strategy? = nil
) {
self.prefix = prefix
self.strategy = strategy
}
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)
}
}
}
/// Represents a semvar version strategy.
///
/// ## Example: 1.0.0
///
struct SemVar: Codable, Equatable, Sendable {
public let allowPreRelease: Bool?
/// Optional pre-releas suffix strategy.
public let preRelease: PreRelease?
/// Fail if an existing version file does not exist in the target.
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 strategy: Strategy?
public init(
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)
}
}
/// Represents the target where we will bump the version in.
///
/// This can either be a path to a version file or a module used to
/// locate the version file.
struct Target: Codable, Equatable, Sendable, CustomDumpReflectable {
/// The path to a version file.
public let path: String?
/// A module to find the version file in.
public let module: Module?
/// Create a target for the given path.
///
/// - Parameters:
/// - path: The path to the version file.
public init(path: String) {
self.path = path
self.module = nil
}
/// Create a target for the given module.
///
/// - Parameters:
/// - module: The module for the version file.
public init(module: Module) {
self.path = nil
self.module = module
}
/// Represents a module target for a version file.
///
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?
/// Create a new module target.
///
/// - Parameters:
/// - name: The module directory name.
/// - fileName: The file name located in the module directory.
public init(
_ name: String,
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
)
}
}
/// Strategy used to generate a version.
///
/// Typically a `SemVar` strategy or `Branch`.
///
///
enum VersionStrategy: Codable, Equatable, Sendable, CustomDumpReflectable {
case branch(includeCommitSha: Bool = true)
case semvar(
allowPreRelease: Bool? = nil,
preRelease: PreRelease? = nil,
requireExistingFile: Bool? = nil,
requireExistingSemVar: Bool? = nil,
strategy: SemVar.Strategy? = nil
)
public var branch: Branch? {
guard case let .branch(includeCommitSha) = self
else { return nil }
return .init(includeCommitSha: includeCommitSha)
}
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

@@ -0,0 +1,87 @@
import Dependencies
import DependenciesMacros
import FileClient
import Foundation
public extension DependencyValues {
/// Perform operations with configuration files.
var configurationClient: ConfigurationClient {
get { self[ConfigurationClient.self] }
set { self[ConfigurationClient.self] = newValue }
}
}
/// Handles interactions with configuration files.
@DependencyClient
public struct ConfigurationClient: Sendable {
/// Find a configuration file in the given directory or in current working directory.
public var find: @Sendable (URL?) async throws -> URL?
/// Load a configuration file.
public var load: @Sendable (URL) async throws -> Configuration
/// Write a configuration file.
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 {
throw ConfigurationClientError.configurationNotFound
}
return (try? await load(url)) ?? .default
}
}
extension ConfigurationClient: DependencyKey {
public static let testValue: ConfigurationClient = Self()
public static var liveValue: ConfigurationClient {
.init(
find: { try await findConfiguration($0) },
load: { try await loadConfiguration($0) },
write: { try await writeConfiguration($0, to: $1) }
)
}
}
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())
}
if try await fileClient.isDirectory(url.cleanFilePath) {
url = url.appending(path: "\(defaultFileName).json")
}
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

@@ -0,0 +1,7 @@
import Foundation
extension ConfigurationClient {
enum Constants {
static let defaultFileNameWithoutExtension = ".bump-version"
}
}

View File

@@ -0,0 +1,138 @@
import Dependencies
import DependenciesMacros
import Foundation
public extension DependencyValues {
/// Access a basic ``FileClient`` that can read / write data to the file system.
///
var fileClient: FileClient {
get { self[FileClient.self] }
set { self[FileClient.self] = newValue }
}
}
/// Represents the interactions with the file system. It is able
/// to read from and write to files.
///
///
/// ```swift
/// @Dependency(\.fileClient) var fileClient
/// ```
///
@DependencyClient
public struct FileClient: Sendable {
/// Return the current working directory.
public var currentDirectory: @Sendable () async throws -> String
/// Check if a file exists at the given url.
public var fileExists: @Sendable (URL) -> Bool = { _ in true }
/// Check if a url is a directory.
public var isDirectory: @Sendable (String) async throws -> Bool
/// Read the contents of a file.
public var read: @Sendable (URL) async throws -> String
/// Write `Data` to a file `URL`.
public var write: @Sendable (Data, URL) async throws -> Void
/// Read the contents of a file at the given path.
///
/// - Parameters:
/// - path: The file path to read from.
public func read(_ path: String) async throws -> String {
try await read(url(for: path))
}
/// Write's the the string to a file path.
///
/// - Parameters:
/// - string: The string to write to the file.
/// - path: The file path.
public func write(string: String, to path: String) async throws {
try await write(string: string, to: url(for: path))
}
/// Write's the the string to a file path.
///
/// - Parameters:
/// - string: The string to write to the file.
/// - url: The file url.
public func write(string: String, to url: URL) async throws {
try await write(Data(string.utf8), url)
}
}
extension FileClient: DependencyKey {
/// A ``FileClient`` that does not do anything.
public static let noop = FileClient(
currentDirectory: { "./" },
fileExists: { _ in true },
isDirectory: { _ in true },
read: { _ in "" },
write: { _, _ in }
)
/// An `unimplemented` ``FileClient``.
public static let testValue = FileClient()
/// The live ``FileClient``
public static let liveValue = FileClient(
currentDirectory: { FileManager.default.currentDirectoryPath },
fileExists: { FileManager.default.fileExists(atPath: $0.cleanFilePath) },
isDirectory: { path in
var isDirectory: ObjCBool = false
FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
return isDirectory.boolValue
},
read: { try String(contentsOf: $0, encoding: .utf8) },
write: { try $0.write(to: $1, options: .atomic) }
)
public static func capturing(
_ captured: CapturingWrite
) -> Self {
.init(
currentDirectory: { "./" },
fileExists: { _ in true },
isDirectory: { _ in true },
read: { _ in "" },
write: { await captured.set($0, $1) }
)
}
}
public actor CapturingWrite: Sendable {
public private(set) var data: Data?
public private(set) var url: URL?
public init() {}
func set(_ data: Data, _ url: URL) {
self.data = data
self.url = url
}
}
public extension URL {
var cleanFilePath: String {
absoluteString.replacingOccurrences(of: "file://", with: "")
}
}
public func url(for path: String) -> URL {
#if os(Linux)
return URL(fileURLWithPath: path)
#else
if #available(macOS 13.0, *) {
return URL(filePath: path)
} else {
// Fallback on earlier versions
return URL(fileURLWithPath: path)
}
#endif
}

View File

@@ -0,0 +1,262 @@
import Dependencies
import DependenciesMacros
import FileClient
import Foundation
import ShellClient
public extension DependencyValues {
/// A ``GitVersionClient`` that can retrieve the current version from a
/// git directory.
var gitClient: GitClient {
get { self[GitClient.self] }
set { self[GitClient.self] = newValue }
}
}
/// A client that can retrieve the current version from a git directory.
/// It will use the current `tag`, or if the current git tree does not
/// point to a commit that is tagged, it will use the `branch git-sha` as
/// the version.
///
/// This is often not used directly, instead it is used with one of the plugins
/// that is supplied with this library. The use case is to set the version of a command line
/// tool based on the current git tag.
///
@DependencyClient
public struct GitClient: Sendable {
/// The closure to run that returns the current version from a given
/// git directory.
@available(*, deprecated, message: "Use version.")
public var currentVersion: @Sendable (String?, Bool) async throws -> String
/// Get the current version from the `git tag` in the given directory.
/// If a directory is not passed in, then we will use the current working directory.
///
/// - Parameters:
/// - gitDirectory: The directory to run the command in.
@available(*, deprecated, message: "Use version.")
public func currentVersion(in gitDirectory: String? = nil, exactMatch: Bool = true) async throws -> String {
try await currentVersion(gitDirectory, exactMatch)
}
public var version: @Sendable (CurrentVersionOption) async throws -> Version
}
public extension GitClient {
struct CurrentVersionOption: Sendable {
let gitDirectory: String?
let style: Style
public init(
gitDirectory: String? = nil,
style: Style
) {
self.gitDirectory = gitDirectory
self.style = style
}
public enum Style: Sendable {
case tag(exactMatch: Bool = false)
case branch(commitSha: Bool = true)
}
}
enum Version: Sendable, CustomStringConvertible {
case branch(String)
case tag(String)
public var description: String {
switch self {
case let .branch(string): return string
case let .tag(string): return string
}
}
}
}
extension GitClient: TestDependencyKey {
/// The ``GitVersionClient`` used in test / debug builds.
public static let testValue = GitClient()
/// The ``GitVersionClient`` used in release builds.
public static var liveValue: GitClient {
.init(
currentVersion: { gitDirectory, exactMatch in
try await GitVersion(workingDirectory: gitDirectory).currentVersion(exactMatch)
},
version: { try await $0.run() }
)
}
/// Create a mock git client, that always returns the given value.
///
/// - Parameters:
/// - value: The value to return.
public static func mock(_ value: Version) -> Self {
.init(
currentVersion: { _, _ in value.description },
version: { _ in value }
)
}
}
// MARK: - Private
private extension GitClient.CurrentVersionOption {
func run() async throws -> GitClient.Version {
switch style {
case let .tag(exactMatch: exactMatch):
return try await .tag(runCommand(.describeTag(exactMatch: exactMatch)))
case let .branch(commitSha: withCommit):
async let branch = try await runCommand(.branch)
if withCommit {
let commit = try await runCommand(.commit)
return try await .branch("\(branch)-\(commit)")
}
return try await .branch(branch)
}
}
func runCommand(_ versionArgs: VersionArgs) async throws -> String {
@Dependency(\.asyncShellClient) var shell
@Dependency(\.fileClient) var fileClient
var gitDirectory: String! = self.gitDirectory
if gitDirectory == nil {
gitDirectory = try await fileClient.currentDirectory()
}
return try await shell.background(
.init(
shell: .env,
environment: ProcessInfo.processInfo.environment,
in: gitDirectory,
versionArgs().map(\.rawValue)
),
trimmingCharactersIn: .whitespacesAndNewlines
)
}
enum VersionArgs {
case branch
case commit
case describeTag(exactMatch: Bool)
func callAsFunction() -> [Args] {
switch self {
case .branch:
return [.git, .symbolicRef, .quiet, .short, .head]
case .commit:
return [.git, .revParse, .short, .head]
case let .describeTag(exactMatch):
var args = [Args.git, .describe, .tags]
if exactMatch {
args.append(.exactMatch)
}
return args
}
}
enum Args: String, CustomStringConvertible {
case git
case describe
case tags = "--tags"
case exactMatch = "--exact-match"
case quiet = "--quiet"
case symbolicRef = "symbolic-ref"
case revParse = "rev-parse"
case short = "--short"
case head = "HEAD"
}
}
}
private struct GitVersion {
@Dependency(\.logger) var logger: Logger
@Dependency(\.asyncShellClient) var shell
let workingDirectory: String?
func currentVersion(_ exactMatch: Bool) async throws -> String {
logger.debug("\("Fetching current version".bold)")
do {
logger.debug("Checking for tag.")
return try await run(command: command(for: .describe(exactMatch: exactMatch)))
} catch {
logger.debug("\("No tag found, deferring to branch & git sha".red)")
let branch = try await run(command: command(for: .branch))
let commit = try await run(command: command(for: .commit))
return "\(branch) \(commit)"
}
}
func command(for argument: VersionArgs) -> ShellCommand {
.init(
shell: .env,
environment: nil,
in: workingDirectory ?? FileManager.default.currentDirectoryPath,
argument.arguments.map(\.rawValue)
)
}
func run(command: ShellCommand) async throws -> String {
try await shell.background(command, trimmingCharactersIn: .whitespacesAndNewlines)
}
enum VersionArgs {
case branch
case commit
case describe(exactMatch: Bool)
var arguments: [Args] {
switch self {
case .branch:
return [.git, .symbolicRef, .quiet, .short, .head]
case .commit:
return [.git, .revParse, .short, .head]
case let .describe(exactMatch):
var args = [Args.git, .describe, .tags]
if exactMatch {
args.append(.exactMatch)
}
return args
}
}
enum Args: String, CustomStringConvertible {
case git
case describe
case tags = "--tags"
case exactMatch = "--exact-match"
case quiet = "--quiet"
case symbolicRef = "symbolic-ref"
case revParse = "rev-parse"
case short = "--short"
case head = "HEAD"
}
}
}
extension RawRepresentable where RawValue == String, Self: CustomStringConvertible {
var description: String { rawValue }
}
public extension GitClient.Version {
static var mocks: [Self] {
[
.tag("1.0.0"),
.tag("1.0.0-4-g59bc977"),
.branch("dev-g59bc977")
]
}
}

View File

@@ -0,0 +1,23 @@
import Foundation
// swiftlint:disable force_try
/// Helper to create a temporary directory for running tests in.
///
/// The temporary directory will be removed after the operation has ran.
///
/// - Parameters:
/// - operation: The operation to run with the temporary directory.
public func withTemporaryDirectory(
_ operation: (URL) async throws -> Void
) async rethrows {
let tempUrl = FileManager.default
.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
try! FileManager.default.createDirectory(at: tempUrl, withIntermediateDirectories: false)
try await operation(tempUrl)
try! FileManager.default.removeItem(at: tempUrl)
}
// swiftlint:enable force_try

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,171 @@
import ArgumentParser
import CliClient
import ConfigurationClient
import CustomDump
import Dependencies
import FileClient
import Foundation
struct ConfigCommand: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "config",
abstract: "Configuration commands",
subcommands: [
DumpConfig.self,
GenerateConfig.self
]
)
}
extension ConfigCommand {
struct DumpConfig: AsyncParsableCommand {
static let commandName = "dump"
static let configuration = CommandConfiguration(
commandName: Self.commandName,
abstract: "Inspect the parsed configuration.",
discussion: """
This will load any configuration and merge the options passed in. Then print it to stdout.
The default style is to print the output in `swift`, however you can use the `--print` flag to
print the output in `json`.
""",
aliases: ["d"]
)
@OptionGroup var globals: ConfigCommandOptions
func run() async throws {
let configuration = try await globals
.shared(command: Self.commandName)
.runClient(\.parsedConfiguration)
try globals.printConfiguration(configuration)
}
}
struct GenerateConfig: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "generate",
abstract: "Generate a configuration file.",
aliases: ["g"]
)
@Flag(
help: "The style of the configuration."
)
var style: ConfigCommand.Style = .semvar
@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 globals.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
)
}
}
@dynamicMemberLookup
struct ConfigCommandOptions: ParsableArguments {
@Flag(
name: .customLong("print"),
help: "Print style to stdout."
)
var printJson: Bool = false
@OptionGroup var configOptions: ConfigurationOptions
@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.ConfigCommandOptions {
func shared(command: String) throws -> CliClient.SharedOptions {
try configOptions.shared(command: command, extraOptions: extraOptions)
}
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) throws {
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,21 @@
import ArgumentParser
import CliClient
import Dependencies
import Foundation
import ShellClient
struct GenerateCommand: AsyncParsableCommand {
static let commandName = "generate"
static let configuration: CommandConfiguration = .init(
commandName: Self.commandName,
abstract: "Generates a version file in a command line tool that can be set via the git tag or git sha.",
discussion: "This command can be interacted with directly, outside of the plugin usage context."
)
@OptionGroup var globals: GlobalOptions
func run() async throws {
try await globals.run(\.generate, 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,167 @@
import ArgumentParser
@_spi(Internal) import CliClient
import ConfigurationClient
import Dependencies
import Foundation
import Rainbow
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.",
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,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: .init(includeCommitSha: commitSha),
semvar: semvarOptions(extraOptions: extraOptions),
configurationFile: configurationFile
)
}
}
struct ExtraOptionsEmpty: Error {}

View File

@@ -1,2 +1,2 @@
// Do not set this variable, it is set during the build process.
let VERSION: String? = "0.1.0"
let VERSION: String? = "0.1.1"

View File

@@ -1,50 +0,0 @@
import ArgumentParser
import Foundation
import CliVersion
import ShellClient
extension CliVersionCommand {
struct Build: ParsableCommand {
static var 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."
)
@OptionGroup var shared: SharedOptions
@Option(
name: .customLong("git-directory"),
help: "The git directory for the version."
)
var gitDirectory: String
func run() throws {
try withDependencies {
$0.logger.logLevel = .debug
$0.fileClient = .liveValue
$0.gitVersionClient = .liveValue
} operation: {
@Dependency(\.gitVersionClient) var gitVersion
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger: Logger
logger.info("Building with git-directory: \(gitDirectory)")
let fileUrl = URL(fileURLWithPath: shared.target)
.appendingPathComponent(shared.fileName)
let fileString = fileUrl.fileString()
logger.info("File Url: \(fileString)")
let currentVersion = try gitVersion.currentVersion(in: gitDirectory)
let fileContents = buildTemplate
.replacingOccurrences(of: "nil", with: "\"\(currentVersion)\"")
try fileClient.write(string: fileContents, to: fileUrl)
logger.info("Updated version file: \(fileString)")
}
}
}
}

View File

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

View File

@@ -1,46 +0,0 @@
import ArgumentParser
import Dependencies
import Foundation
import CliVersion
import ShellClient
extension CliVersionCommand {
struct Generate: ParsableCommand {
static var 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.",
version: VERSION ?? "0.0.0"
)
@OptionGroup var shared: SharedOptions
func run() throws {
@Dependency(\.logger) var logger: Logger
@Dependency(\.fileClient) var fileClient
let targetUrl = parseTarget(shared.target)
let fileUrl = targetUrl.appendingPathComponent(shared.fileName)
let fileString = fileUrl.fileString()
guard !FileManager.default.fileExists(atPath: fileUrl.absoluteString) else {
logger.info("File already exists at path.")
throw GenerationError.fileExists(path: fileString)
}
if !shared.dryRun {
try fileClient.write(string: optionalTemplate, to: fileUrl)
logger.info("Generated file at: \(fileString)")
} else {
logger.info("Would generate file at: \(fileString)")
}
}
}
}
fileprivate enum GenerationError: Error {
case fileExists(path: String)
}

View File

@@ -1,51 +0,0 @@
import ArgumentParser
import Foundation
func parseTarget(_ target: String) -> URL {
let url = URL(fileURLWithPath: target)
let urlTest = url
.deletingLastPathComponent()
guard urlTest.lastPathComponent == "Sources" else {
return URL(fileURLWithPath: "Sources")
.appendingPathComponent(target)
}
return url
}
extension URL {
func fileString() -> String {
self.absoluteString
.replacingOccurrences(of: "file://", with: "")
}
}
let optionalTemplate = """
// Do not set this variable, it is set during the build process.
let VERSION: String? = nil
"""
let buildTemplate = """
// Do not set this variable, it is set during the build process.
let VERSION: String = nil
"""
struct SharedOptions: ParsableArguments {
@Argument(help: "The target for the version file.")
var target: String
@Option(
name: .customLong("filename"),
help: "Specify the file name for the version file."
)
var fileName: String = "Version.swift"
@Flag(name: .customLong("dry-run"))
var dryRun: Bool = false
@Flag(name: .long, help: "Increase logging level.")
var verbose: Bool = false
}

View File

@@ -1,59 +0,0 @@
import ArgumentParser
import Foundation
import CliVersion
import ShellClient
extension CliVersionCommand {
struct Update: ParsableCommand {
static var configuration: CommandConfiguration = .init(
abstract: "Updates a version string to the git tag or git sha.",
discussion: "This command can be interacted with directly outside of the plugin context."
)
@OptionGroup var shared: SharedOptions
@Option(
name: .customLong("git-directory"),
help: "The git directory for the version."
)
var gitDirectory: String? = nil
func run() throws {
try withDependencies {
$0.logger.logLevel = shared.verbose ? .debug : .info
$0.fileClient = .liveValue
$0.gitVersionClient = .liveValue
$0.shellClient = .liveValue
} operation: {
@Dependency(\.gitVersionClient) var gitVersion
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger
@Dependency(\.shellClient) var shell
let targetUrl = parseTarget(shared.target)
let fileUrl = targetUrl
.appendingPathComponent(shared.fileName)
let fileString = fileUrl.fileString()
let currentVersion = try gitVersion.currentVersion(in: gitDirectory)
let fileContents = optionalTemplate
.replacingOccurrences(of: "nil", with: "\"\(currentVersion)\"")
if !shared.dryRun {
try fileClient.write(string: fileContents, to: fileUrl)
logger.info("Updated version file: \(fileString)")
} else {
logger.info("Would update file contents to:")
logger.info("\(fileContents)")
}
}
}
}
}
fileprivate enum UpdateError: Error {
case versionFileDoesNotExist(path: String)
}

View File

@@ -0,0 +1,148 @@
@_spi(Internal) import CliClient
import ConfigurationClient
import Dependencies
import FileClient
import Foundation
import GitClient
import Logging
import Testing
import TestSupport
@Suite("CliClientTests")
struct CliClientTests {
@Test(
arguments: TestArguments.testCases
)
func testBuild(target: String) async throws {
try await run {
$0.fileClient.fileExists = { _ in true }
} operation: {
@Dependency(\.cliClient) var client
let output = try await client.build(.testOptions(
target: target,
versionStrategy: .semvar(requireExistingFile: false)
))
#expect(output == "/baz/Sources/bar/Version.swift")
}
}
@Test(
arguments: TestArguments.bumpCases
)
func bump(type: CliClient.BumpOption, optional: Bool) async throws {
let template = optional ? Template.optional("1.0.0-4-g59bc977") : Template.build("1.0.0")
try await run {
$0.fileClient.fileExists = { _ in true }
$0.fileClient.read = { @Sendable _ in template }
} operation: {
@Dependency(\.cliClient) var client
let output = try await client.bump(type, .testOptions())
#expect(output == "/baz/Sources/bar/Version.swift")
} assert: { string, _ in
if type != .preRelease {
#expect(string != nil)
}
let typeString = optional ? "String?" : "String"
switch type {
case .major:
#expect(string!.contains("let VERSION: \(typeString) = \"2.0.0\""))
case .minor:
#expect(string!.contains("let VERSION: \(typeString) = \"1.1.0\""))
case .patch:
#expect(string!.contains("let VERSION: \(typeString) = \"1.0.1\""))
case .preRelease:
// do something
#expect(Bool(true))
}
}
}
@Test(
arguments: TestArguments.testCases
)
func generate(target: String) async throws {
try await run {
@Dependency(\.cliClient) var client
let output = try await client.build(.testOptions(
target: target,
versionStrategy: .semvar(requireExistingFile: false)
))
#expect(output == "/baz/Sources/bar/Version.swift")
}
}
@Test(arguments: GitClient.Version.mocks)
func gitVersionToSemVar(version: GitClient.Version) {
let semVar = version.semVar
if semVar != nil {
#expect(semVar!.versionString(withPreReleaseTag: false) == "1.0.0")
#expect(semVar!.versionString(withPreReleaseTag: true) == version.description)
} else {
let semVar = SemVar(preRelease: version.description)
#expect(semVar.versionString(withPreReleaseTag: true) == "0.0.0-\(version.description)")
}
}
func run(
setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in },
operation: @Sendable @escaping () async throws -> Void,
assert: @escaping (String?, URL?) -> Void = { _, _ in }
) async throws {
let captured = CapturingWrite()
try await withDependencies {
$0.logger.logLevel = .debug
$0.fileClient = .capturing(captured)
$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()
}
let data = await captured.data
let url = await captured.url
var string: String?
if let data {
string = String(bytes: data, encoding: .utf8)
}
assert(string, url)
}
}
enum TestArguments {
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))
}
static let updateCases = testCases.map { ($0, Bool.random()) }
}
struct TestError: Error {}
extension CliClient.SharedOptions {
static func testOptions(
gitDirectory: String? = "/baz",
dryRun: Bool = false,
target: String = "bar",
versionStrategy: Configuration.VersionStrategy = .semvar(.init())
) -> Self {
return .init(
dryRun: dryRun,
gitDirectory: gitDirectory,
loggingOptions: .init(command: "test", verbose: 2),
target: .init(module: .init(target)),
branch: versionStrategy.branch,
semvar: versionStrategy.semvar
)
}
}

View File

@@ -1,15 +1,20 @@
import XCTest
import CliVersion
@_spi(Internal) import CliClient
import Dependencies
import FileClient
import GitClient
import ShellClient
import TestSupport
import XCTest
// TODO: Remove
final class GitVersionTests: XCTestCase {
override func invokeTest() {
withDependencies({
$0.logger.logLevel = .debug
$0.logger = .liveValue
$0.shellClient = .liveValue
$0.gitVersionClient = .liveValue
$0.asyncShellClient = .liveValue
$0.gitClient = .liveValue
$0.fileClient = .liveValue
}, operation: {
super.invokeTest()
@@ -21,82 +26,46 @@ final class GitVersionTests: XCTestCase {
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.absoluteString
.replacingOccurrences(of: "file://", with: "")
.cleanFilePath
}
func test_overrides_work() throws {
try withDependencies {
$0.gitVersionClient.override(with: "blob")
} operation: {
@Dependency(\.gitVersionClient) var versionClient
// #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
let version = try versionClient.currentVersion()
XCTAssertEqual(version, "blob")
}
}
func test_live() throws {
@Dependency(\.gitVersionClient) var versionClient: GitVersionClient
let version = try versionClient.currentVersion(in: gitDir)
print("VERSION: \(version)")
// can't really have a predictable result for the live client.
XCTAssertNotEqual(version, "blob")
}
func test_commands() throws {
@Dependency(\.shellClient) var shellClient: ShellClient
XCTAssertNoThrow(
try shellClient.background(
.gitCurrentBranch(gitDirectory: gitDir),
trimmingCharactersIn: .whitespacesAndNewlines
)
)
XCTAssertNoThrow(
try shellClient.background(
.gitCurrentSha(gitDirectory: gitDir),
trimmingCharactersIn: .whitespacesAndNewlines
)
)
}
func test_file_client() throws {
func test_file_client() async throws {
try await withTemporaryDirectory { tmpDir in
@Dependency(\.fileClient) var fileClient
let tmpDir = FileManager.default.temporaryDirectory
.appendingPathComponent("file-client-test")
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
defer { try! FileManager.default.removeItem(at: tmpDir) }
let filePath = tmpDir.appendingPathComponent("blob.txt")
try fileClient.write(string: "Blob", to: filePath)
try await fileClient.write(string: "Blob", to: filePath)
let contents = try String(contentsOf: filePath)
.trimmingCharacters(in: .whitespacesAndNewlines)
XCTAssertEqual(contents, "Blob")
}
func test_file_client_with_string_path() throws {
@Dependency(\.fileClient) var fileClient
let tmpDir = FileManager.default.temporaryDirectory
.appendingPathComponent("file-client-string-test")
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
defer { try! FileManager.default.removeItem(at: tmpDir) }
let filePath = tmpDir.appendingPathComponent("blob.txt")
let fileString = filePath.absoluteString.replacingOccurrences(of: "file://", with: "")
try fileClient.write(string: "Blob", to: fileString)
let contents = try String(contentsOf: filePath)
let contents = try await fileClient.read(filePath)
.trimmingCharacters(in: .whitespacesAndNewlines)
XCTAssertEqual(contents, "Blob")
}
}
func test_file_client_with_string_path() async throws {
try await withTemporaryDirectory { tmpDir in
@Dependency(\.fileClient) var fileClient
let filePath = tmpDir.appendingPathComponent("blob.txt")
let fileString = filePath.cleanFilePath
try await fileClient.write(string: "Blob", to: fileString)
let contents = try await fileClient.read(fileString)
.trimmingCharacters(in: .whitespacesAndNewlines)
XCTAssertEqual(contents, "Blob")
}
}
}

View File

@@ -0,0 +1,90 @@
import ConfigurationClient
import Dependencies
import Foundation
import Testing
import TestSupport
@Suite("ConfigurationClientTests")
struct ConfigurationClientTests {
@Test
func codable() async throws {
try await run {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.coders) var coders
let configuration = Configuration.customPreRelease
let encoded = try coders.jsonEncoder().encode(configuration)
let decoded = try coders.jsonDecoder().decode(Configuration.self, from: encoded)
#expect(decoded == configuration)
}
}
@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)
}
}
}
}
func run(
setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in },
operation: () async throws -> Void
) async throws {
try await withDependencies {
$0.coders = .liveValue
$0.fileClient = .liveValue
$0.configurationClient = .liveValue
setupDependencies(&$0)
} operation: {
try await operation()
}
}
}

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

66
justfile Normal file
View File

@@ -0,0 +1,66 @@
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 \
--disable-sandbox \
--configuration {{configuration}} \
--product {{product}}
alias b := build
# Build a docker image.
build-docker configuration="debug":
@docker build -t {{docker_image}}:{{docker_tag}} .
# Run the command-line tool.
run *ARGS:
@swift run {{product}} {{ARGS}}
# Clean the build folder.
clean:
rm -rf .build
# Clean and build.
clean-build configuration="debug": clean (build configuration)
alias cb := clean-build
# Test locally.
test *ARGS:
@swift test {{ARGS}}
# Build docker test container and run tests.
test-docker: build-docker
@docker run --rm {{docker_image}}:{{docker_tag}}
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"