Compare commits

38 Commits

Author SHA1 Message Date
016f0d6c3f feat: Some parameter renaming for consistency
All checks were successful
CI / Ubuntu (push) Successful in 2m36s
2024-12-31 08:31:41 -05:00
147f6df1b3 feat: Test updates
All checks were successful
CI / Ubuntu (push) Successful in 2m37s
2024-12-31 08:04:04 -05:00
5c22250a63 feat: Adds documentation for precedence.
All checks were successful
CI / Ubuntu (push) Successful in 2m29s
2024-12-29 00:01:18 -05:00
9dd30a1745 feat: Integrates precedence with command-line options, needs documentation.
All checks were successful
CI / Ubuntu (push) Successful in 2m31s
2024-12-28 23:43:56 -05:00
f1eb883b93 feat: Integrates a precedence configuration setting, needs a command-line option.
All checks were successful
CI / Ubuntu (push) Successful in 2m47s
2024-12-28 22:15:24 -05:00
9631c62ee3 feat: Updates internal version container used to derive next version in cli-client. 2024-12-28 17:05:33 -05:00
6fe459c39e feat: Prep to update current version container in cli-client. 2024-12-28 10:09:34 -05:00
b9cf913528 feat: Begin cleaning up cli-client for better separation for current and next versions.
All checks were successful
CI / Ubuntu (push) Successful in 2m33s
2024-12-28 00:12:49 -05:00
f9710e5992 feat: Pre-release prefix now works with git-tag straegy. Working on todo items.
All checks were successful
CI / Ubuntu (push) Successful in 2m12s
2024-12-27 17:34:25 -05:00
a26d8695f5 fix: Fixes github ci after removing Makefile
All checks were successful
CI / Ubuntu (push) Successful in 2m20s
2024-12-27 16:51:01 -05:00
20f430fb8f feat: Renames some options, adds a require-configuration option that fails if configuration is not found.
All checks were successful
CI / Ubuntu (push) Successful in 2m19s
2024-12-27 16:42:48 -05:00
4420bd428a feat: Working on command-line documentation.
Some checks failed
CI / Ubuntu (push) Has been cancelled
2024-12-27 14:51:55 -05:00
8d73287a60 feat: Renames update-version plugin to bump-version, removes generate plugin as the bump-version can be called with the generate arguments.
All checks were successful
CI / Ubuntu (push) Successful in 2m42s
2024-12-26 16:07:57 -05:00
5bac7aa577 feat: Adds release file for a reminder of whats needed, bumps our current version.
All checks were successful
CI / Ubuntu (push) Successful in 2m15s
2024-12-26 14:51:51 -05:00
47bad744d5 feat: Removes some old todo comments.
All checks were successful
CI / Ubuntu (push) Successful in 2m21s
Release / release (push) Successful in 11s
2024-12-26 14:04:37 -05:00
2650bdc670 feat: Better checks on if semvar has changes prior to writing to a file.
All checks were successful
CI / Ubuntu (push) Successful in 2m51s
2024-12-26 13:06:31 -05:00
a0f8611a76 feat: Working on command-line documentation.
All checks were successful
CI / Ubuntu (push) Successful in 2m52s
2024-12-26 12:13:42 -05:00
86a344fa9f feat: Removes old configuration merging file from cli-client.
All checks were successful
CI / Ubuntu (push) Successful in 2m42s
2024-12-26 07:58:01 -05:00
56359f3488 feat: Moves logging extensions into it's own module, moves configuration merging into config-client, fixes merging bug. 2024-12-25 20:06:52 -05:00
fbb4a22e98 feat: Updates configuration commands and parsing strategy.
All checks were successful
CI / Ubuntu (push) Successful in 2m59s
2024-12-25 17:31:38 -05:00
3cfd882f38 feat: Some variable renaming for consistency.
All checks were successful
CI / Ubuntu (push) Successful in 2m58s
2024-12-24 23:16:13 -05:00
a885e3dfa3 feat: Updates logging configuration. 2024-12-24 21:32:14 -05:00
04bfd4a6ae feat: Make configuration module file-name optional, so that it is not required by a config file.
All checks were successful
CI / Ubuntu (push) Successful in 2m35s
2024-12-24 17:13:54 -05:00
fb60c22257 feat: Updates justfile in prep for homebrew
Some checks failed
CI / Ubuntu (push) Failing after 10s
2024-12-24 16:55:04 -05:00
0148ad6df2 feat: Adds release workflow to gitea
All checks were successful
CI / Ubuntu (push) Successful in 2m51s
2024-12-24 16:44:28 -05:00
322327d6d7 feat: Renaming to bump-version
All checks were successful
CI / Ubuntu (push) Successful in 2m55s
2024-12-24 16:39:38 -05:00
9ea0ab566e feat: Removes old toml configuration example
All checks were successful
CI / Ubuntu (push) Successful in 2m49s
2024-12-24 15:14:02 -05:00
4362ad9c3f feat: Updates gitea ci.
All checks were successful
CI / Ubuntu (push) Successful in 3m7s
2024-12-24 15:11:56 -05:00
1972260317 feat: Adds git client tests, restructures files.
Some checks failed
CI / macOS (debug, 16.1) (push) Has been cancelled
CI / macOS (release, 16.1) (push) Has been cancelled
CI / Ubuntu (push) Failing after 5s
2024-12-24 14:42:28 -05:00
8aa4b73cab feat: Updating command options. 2024-12-24 10:39:56 -05:00
f2a2374c4f feat: Updates configuration, uses json for configuration files and drops toml support. 2024-12-24 09:02:38 -05:00
7d23172c71 feat: Updating configuration. 2024-12-23 22:46:20 -05:00
11ec829a90 feat: Adds linux tests to ci 2024-12-23 16:49:47 -05:00
2152388ee3 feat: Adds linux tests to ci 2024-12-23 16:48:33 -05:00
0836dbd6da feat: Adds linux tests to ci 2024-12-23 16:39:49 -05:00
e2309f5635 feat: Integrates configuration-client. 2024-12-23 16:30:15 -05:00
c7d349e3cc feat: Updates ci workflows 2024-12-23 14:34:48 -05:00
9b60ba0e6c feat: Commit pre integrating configuration client into cli-client. 2024-12-23 14:26:41 -05:00
67 changed files with 3373 additions and 1282 deletions

17
.bump-version.json Normal file
View File

@@ -0,0 +1,17 @@
{
"strategy" : {
"semvar" : {
"allowPreRelease" : true,
"strategy" : {
"gitTag" : {
"exactMatch" : false
}
}
}
},
"target" : {
"module" : {
"name" : "BumpVersion"
}
}
}

27
.bump-version.pre.json Normal file
View File

@@ -0,0 +1,27 @@
{
"strategy" : {
"semvar" : {
"allowPreRelease" : true,
"preRelease" : {
"prefix" : "rc",
"strategy" : {
"gitTag" : {
}
}
},
"requireExistingFile" : false,
"requireExistingSemVar" : false,
"strategy" : {
"gitTag" : {
"exactMatch" : false
}
}
}
},
"target" : {
"module" : {
"name" : "BumpVersion"
}
}
}

View File

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

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

@@ -0,0 +1,20 @@
name: CI
on:
push:
workflow_dispatch:
jobs:
ubuntu:
name: Ubuntu
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Setup just.
uses: https://git.housh.dev/actions/setup-just.git@v1
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup buildx
uses: docker/setup-buildx-action@v3
- name: Run tests.
run: just test-docker

View File

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

View File

@@ -7,27 +7,32 @@ 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: Install just.
run: brew install just
- name: Select Xcode ${{ matrix.xcode }}
run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app
- name: Swift Version
run: swift --version
- name: Run ${{ matrix.xcode }} Tests
run: make CONFIG=${{ matrix.config }} test-library
run: just test --configuration ${{ matrix.config }}
# 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
.prettierignore Normal file
View File

@@ -0,0 +1 @@
**/*.docc/*.md

View File

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

View File

@@ -1,42 +0,0 @@
PLATFORM_MACOS = macOS
CONFIG := debug
DOCC_TARGET ?= CliVersion
DOCC_BASEPATH = $(shell basename "$(PWD)")
DOCC_DIR ?= ./docs
SWIFT_VERSION ?= "5.10"
clean:
rm -rf .build
build-documentation:
swift package \
--allow-writing-to-directory "$(DOCC_DIR)" \
generate-documentation \
--target "$(DOCC_TARGET)" \
--disable-indexing \
--transform-for-static-hosting \
--hosting-base-path "$(DOCC_BASEPATH)" \
--output-path "$(DOCC_DIR)"
preview-documentation:
swift package \
--disable-sandbox \
preview-documentation \
--target "$(DOCC_TARGET)"
test-linux:
docker run --rm \
--volume "$(PWD):$(PWD)" \
--workdir "$(PWD)" \
"swift:$(SWIFT_VERSION)" \
swift test
test-library:
swift test -c $(CONFIG)
update-version:
swift package \
--disable-sandbox \
--allow-writing-to-package-directory \
update-version \
git-version

View File

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

View File

@@ -8,33 +8,43 @@ let package = Package(
.macOS(.v13)
],
products: [
.executable(name: "bump-version", targets: ["BumpVersion"]),
.library(name: "CliClient", targets: ["CliClient"]),
.library(name: "ConfigurationClient", targets: ["ConfigurationClient"]),
.library(name: "FileClient", targets: ["FileClient"]),
.library(name: "GitClient", targets: ["GitClient"]),
.library(name: "LoggingExtensions", targets: ["LoggingExtensions"]),
.plugin(name: "BuildWithVersionPlugin", targets: ["BuildWithVersionPlugin"]),
.plugin(name: "GenerateVersionPlugin", targets: ["GenerateVersionPlugin"]),
.plugin(name: "UpdateVersionPlugin", targets: ["UpdateVersionPlugin"])
.plugin(name: "BumpVersionPlugin", targets: ["BumpVersionPlugin"])
],
dependencies: [
.package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.6.2"),
.package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.2.0"),
.package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.2.2"),
.package(url: "https://github.com/m-housh/swift-cli-doc.git", from: "0.2.1"),
.package(url: "https://github.com/apple/swift-docc-plugin.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"),
.package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.5.0")
.package(url: "https://github.com/pointfreeco/swift-custom-dump.git", from: "1.3.3")
],
targets: [
.executableTarget(
name: "cli-version",
name: "BumpVersion",
dependencies: [
"CliClient",
.product(name: "ArgumentParser", package: "swift-argument-parser")
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "CliDoc", package: "swift-cli-doc")
]
),
.target(
name: "CliClient",
dependencies: [
"ConfigurationClient",
"FileClient",
"GitClient",
.product(name: "Logging", package: "swift-log")
"LoggingExtensions",
.product(name: "Logging", package: "swift-log"),
.product(name: "CustomDump", package: "swift-custom-dump")
]
),
.testTarget(
@@ -45,9 +55,10 @@ let package = Package(
name: "ConfigurationClient",
dependencies: [
"FileClient",
"LoggingExtensions",
.product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "TOMLKit", package: "TOMLKit")
.product(name: "DependenciesMacros", package: "swift-dependencies")
]
),
.testTarget(
@@ -70,42 +81,39 @@ let package = Package(
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.testTarget(
name: "GitClientTests",
dependencies: ["GitClient"]
),
.target(
name: "LoggingExtensions",
dependencies: [
.product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.target(name: "TestSupport"),
.plugin(
name: "BuildWithVersionPlugin",
capability: .buildTool(),
dependencies: [
"cli-version"
"BumpVersion"
]
),
.plugin(
name: "GenerateVersionPlugin",
name: "BumpVersionPlugin",
capability: .command(
intent: .custom(
verb: "generate-version",
description: "Generates a version file in the given target."
),
permissions: [
.writeToPackageDirectory(reason: "Generate a version file in the target's directory.")
]
),
dependencies: [
"cli-version"
]
),
.plugin(
name: "UpdateVersionPlugin",
capability: .command(
intent: .custom(
verb: "update-version",
description: "Updates a version file in the given target."
verb: "bump-version",
description: "Bumps a version file in the given target."
),
permissions: [
.writeToPackageDirectory(reason: "Update a version file in the target's directory.")
]
),
dependencies: [
"cli-version"
"BumpVersion"
]
)
]

View File

@@ -13,7 +13,7 @@ struct GenerateVersionBuildPlugin: BuildToolPlugin {
.deletingLastPathComponent()
.deletingLastPathComponent()
let tool = try context.tool(named: "cli-version")
let tool = try context.tool(named: "bump-version")
let outputPath = context.pluginWorkDirectoryURL
let outputFile = outputPath.appending(path: "Version.swift")
@@ -25,7 +25,7 @@ struct GenerateVersionBuildPlugin: BuildToolPlugin {
arguments: [
"build", "--verbose",
"--git-directory", gitDirectoryPath.absoluteString,
"--target", outputPath.absoluteString
"--target-file-path", outputPath.absoluteString
],
environment: [:],
inputFiles: target.sourceFiles.map(\.url),

View File

@@ -0,0 +1,24 @@
import Foundation
import PackagePlugin
@main
struct BumpVersionPlugin: CommandPlugin {
func performCommand(context: PluginContext, arguments: [String]) async throws {
print("Starting bump-version plugin")
let tool = try context.tool(named: "bump-version")
print("arguments: \(arguments)")
let process = Process()
process.executableURL = tool.url
process.arguments = arguments
try process.run()
process.waitUntilExit()
guard process.terminationReason == .exit && process.terminationStatus == 0 else {
Diagnostics.error("Reason: \(process.terminationReason), status: \(process.terminationStatus)")
return
}
}
}

View File

@@ -1,29 +0,0 @@
import Foundation
import PackagePlugin
@main
struct GenerateVersionPlugin: CommandPlugin {
func performCommand(context: PluginContext, arguments: [String]) async throws {
let gitVersion = try context.tool(named: "cli-version")
let arguments = ["generate"] + arguments
for target in context.package.targets {
guard let target = target as? SourceModuleTarget,
arguments.first(where: { $0.contains(target.name) }) != nil
else { continue }
let process = Process()
process.executableURL = gitVersion.url
process.arguments = arguments
try process.run()
process.waitUntilExit()
guard process.terminationReason == .exit && process.terminationStatus == 0 else {
Diagnostics.error("Reason: \(process.terminationReason), status: \(process.terminationStatus)")
return
}
}
}
}

View File

@@ -1,29 +0,0 @@
import Foundation
import PackagePlugin
@main
struct UpdateVersionPlugin: CommandPlugin {
func performCommand(context: PluginContext, arguments: [String]) async throws {
let gitVersion = try context.tool(named: "cli-version")
let arguments = ["update"] + arguments
for target in context.package.targets {
guard let target = target as? SourceModuleTarget,
arguments.first(where: { $0.contains(target.name) }) != nil
else { continue }
let process = Process()
process.executableURL = gitVersion.url
process.arguments = arguments
try process.run()
process.waitUntilExit()
guard process.terminationReason == .exit && process.terminationStatus == 0 else {
Diagnostics.error("Reason: \(process.terminationReason), status: \(process.terminationStatus)")
return
}
}
}
}

26
Release.md Normal file
View File

@@ -0,0 +1,26 @@
# Release Workflow Steps
This is a reminder of the steps used to create a release and update the homebrew formula.
> Note: These steps apply to the version hosted on `gitea`, on `github` more of these steps can be
> automated in `ci`, but there are no `macOS` host runners currently in `gitea`, so the bottles need
> built on `macOS`.
1. Update the version in `Sources/bump-version/Version.swift`.
1. Tag the commit with the next version tag.
1. Push the tagged commit, this will initiate the release being created.
1. Get the `sha` of the `*.tar.gz` in the release.
1. `just get-release-sha`
1. Update the homebrew formula url, sha256, and version at top of the homebrew formula.
1. `cd $(brew --repo michael/formula)`
1. Create a branch for a pull-request `git checkout -b bump-version`
1. `n Formula/bump-version.rb`
1. Build and generate a homebrew bottle.
1. `just bottle`
1. Update the `bottle do` section of homebrew formula with output during previous step.
1. Also make sure the `root_url` in the bottle section points to the new release.
1. Upload the bottle `*.tar.gz` file that was created to the release.
1. Generate a pull-request to the formula repo.
1. Generate a pull-request to this repo to merge into main.
1. Remove the bottle from current directory.
1. `just remove-bottles`

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
import ArgumentParser
import CliClient
import CliDoc
import Dependencies
import Foundation
import ShellClient
struct GenerateCommand: CommandRepresentable {
static let commandName = "generate"
static let configuration: CommandConfiguration = .init(
commandName: Self.commandName,
abstract: Abstract.default("Generates a version file in your project."),
usage: Usage.default(commandName: Self.commandName),
discussion: Discussion.default(
examples: [
makeExample(label: "Basic usage.", example: "")
]
) {
"This command can be interacted with directly, outside of the plugin usage context."
}
)
@OptionGroup var globals: GlobalOptions
func run() async throws {
try await globals.run(\.generate, command: Self.commandName)
}
}

View File

@@ -0,0 +1,103 @@
# Basic Configuration
Basic configuration examples.
## Overview
Generating a configuration file for your application is the easiest way to use the command-line
tool. The configuration specifies the location of the version file, either by a path to the file or
by the module that a `Version.swift` file resides in. It also declares the strategy for generating
new versions.
The command-line tool comes with a command to generate the configuration file for you, this should
be ran from the root of your project or by specifying the path to write the configuration file to
using the `-f | --configuration-file` option. The below examples assume that you're running in the
root project directory.
```bash
bump-version config generate --target-module my-tool
```
The above command produces the following in a file named `.bump-version.json` with the generated
default settings. This will generate semvar style version (example: `1.0.0`).
```json
{
"strategy": {
"semvar": {
"allowPreRelease": true,
"strategy": {
"gitTag": {
"exactMatch": false
}
}
}
},
"target": {
"module": {
"name": "my-tool"
}
}
}
```
> Note: The above does not add a pre-release strategy although it "allows" it if you pass an option
> to command later, if you set "allowPreRelease" to false it will ignore any attempts to add a
> pre-release strategy when bumping the version.
Most commands accept the same options for configuration as the above `config generate` command.
Those get merged with your project configuration when calling a command, that allows you to override
any of your defaults depending on your use case. You can also generate several configuration files
and specify them by passing the `-f | --configuration-file` to the command.
## Inspecting parsed configuration.
You can inspect the configuration that get's parsed by using the `config dump` command. The dump
command will print the parsed `json` to `stdout`, which can be helpful in confirming that your
configuration is valid and does not work unexpectedly.
```bash
bump-version config dump <options / overrides>
```
The dump command can also be used to generate a different configuration that is merged with your
default.
```bash
bump-version config dump --pre-release-git-tag-style > .bump-version.prerelease.json
```
Which would produce the following in `.bump-version.prerelease.json`
```json
{
"strategy": {
"semvar": {
"allowPreRelease": true,
"preRelease": {
"strategy": {
"gitTag": {}
}
},
"strategy": {
"gitTag": {
"exactMatch": false
}
}
}
},
"target": {
"module": {
"name": "my-tool"
}
}
}
```
You could then use this file when bumping your version.
```bash
bump-version bump -f .bump-version.prerelease.json
```
> See Also: <doc:ConfigurationReference>

View File

@@ -0,0 +1,115 @@
# Command Reference
Learn about the provided commands.
## Overview
The command-line tool provides the following commands.
> See Also:
>
> 1. <doc:BasicConfiguration>
> 1. <doc:ConfigurationReference>
All commands output the path of the file they generate or write to, to allow them to be piped into
other commands, however this will not work if you specify `--verbose` because then other output is
also written to `stdout`.
### Bump Command
This bumps the version to the next version based on the project configuration or passed in options.
This is the default command when calling the `bump-version` tool, so specifying the `bump` command
is not required, but will be shown in examples below for clarity.
> See Also: <doc:OptionsReference>
The following options are used to declare which part of a semvar to bump to the next version, they
are ignored if your configuration or options specify to use a `branch` strategy.
| Long | Description |
| ------------- | --------------------------------------- |
| --major | Bump the major portion of the semvar |
| --minor | Bump the minor portion of the semvar |
| --patch | Bump the patch portion of the semvar |
| --pre-release | Bump the pre-release suffix of a semvar |
#### Bump Command Usage Examples
```bash
bump-version bump --minor
```
If you want to use the default configuration without generating your own project configuration, then
you can specify the path or module to the bump command. The default configuration will use the
`gitTag` strategy without any pre-release strategy.
```bash
bump-version bump --minor --target-module my-tool
```
Show the output, but don't update the version file.
```bash
bump-version bump --major --print
```
### Generate Command
This generates a version file based on your configuration, setting it's initial value based on your
projects configuration strategy. This is generally only ran once after setting up a project.
```bash
bump-version generate
```
### Configuration Commands
The following commands are used to work with project configuration.
#### Generate Command
Generates a configuration file based on the passed in options.
> See Also: <doc:OptionsReference>
The following options are used to declare strategy used for deriving the version.
| Long | Description |
| -------- | --------------------------------- |
| --branch | Use the branch strategy |
| --semvar | Use the semvar strategy (default) |
##### Generate Configuration Example
```bash
bump-version config generate -m my-tool
```
The above generates a configuration file using the default version strategy for a target module
named 'my-tool'.
#### Dump Command
Dumps the parsed configuration to `stdout`.
> See Also: <doc:OptionsReference>
The following options are used to declare what output gets printed.
| Long | Description |
| ------- | -------------------- |
| --json | Print json (default) |
| --swift | Print swift struct |
##### Dump Configuration Example
```bash
bump-version config dump --disable-pre-release
```
This command can also be used to extend a configuration file with new configuration by sending the
output to a new file.
```bash
bump-version config dump --disable-pre-release > .bump-version.prod.json
```

View File

@@ -0,0 +1,226 @@
# Configuration Reference
Learn about the configuration.
## Target
The target declares where a version file lives that can be bumped by the command-line tool.
A target can be specified either as a path from the root of the project to a file that contains the
version or as a module that contains the version file.
> Note: A version file should not contain any other code aside from the version as the entire file
> contents get over written when bumping the version.
### Target - Path Example
```json
{
"target": {
"path": "Sources/my-tool/Version.swift"
}
}
```
### Target - Module Example
When using the module style a file name is not required if you use the default file name of
`Version.swift`, however it can be customized in your target specification.
```json
{
"target": {
"module": {
"fileName": "CustomVersion.swift",
"name": "my-tool"
}
}
}
```
The above will parse the path to the file as `Sources/my-tool/CustomVersion.swift`.
## Strategy
The strategy declares how to generate the next version of your project. There are currently two
strategies, `branch` and `semvar`, that we will discuss.
### Branch Strategy
This is the most basic strategy, which will derive the version via the git branch and optionally the
short version of the commit sha.
An example of this style may look like: `main-8d73287a60`.
```json
{
"strategy": {
"branch": {
"includeCommitSha": true
}
}
}
```
If you set `'"includeCommitSha" : false'` then only the branch name will be used.
### Semvar Strategy
This is the most common strategy to use. It has support for generating the next version using either
`gitTag` or a custom `command` strategy.
#### Git Tag Strategy
The `gitTag` strategy derives the next version using the output of `git describe --tags` command.
This requires a commit to have a semvar style tag in it's history, otherwise we will use `0.0.0` as
the tag until a commit is tagged.
```json
{
"strategy": {
"semvar": {
"allowPreRelease": true,
"strategy": {
"gitTag": {
"exactMatch": false
}
}
}
}
}
```
If you set `'"exactMatch": true'` then bumping the version will fail on commits that are not
specifically tagged.
#### Custom Command Strategy
The custom `command` strategy allows you to call an external command to derive the next version. The
external command should return something that can be parsed as a semvar.
```json
{
"strategy": {
"semvar": {
"allowPreRelease": true,
"strategy": {
"command": {
"arguments": ["my-command", "--some-option", "foo"]
}
}
}
}
}
```
> Note: All arguments to custom commands need to be a separate string in the arguments array
> otherwise they may not get passed appropriately to the command, so
> `"my-command --some-option foo"` will likely not work as expected.
#### Pre-Release
Semvar strategies can also include a pre-release strategy that adds a suffix to the semvar version
that can be used. In order for pre-release suffixes to be allowed the `'"allowPreRelease": true'`
must be set on the semvar strategy, you must also supply a pre-release strategy either when calling
the bump-version command or in your configuration.
Currently there are three pre-release strategies, `branch`, `gitTag`, and custom `command`, which we
will discuss.
A pre-release semvar example: `1.0.0-1-8d73287a60`
##### Branch
This will use the branch and optionally short version of the commit sha in order to derive the
pre-release suffix.
```json
{
"strategy": {
"semvar": {
"allowPreRelease": true,
"preRelease": {
"strategy": {
"branch": {
"includeCommitSha": true
}
}
},
...
}
}
}
```
This would produce something similar to: `1.0.0-main-8d73287a60`
##### Git Tag
This will use the full output of `git describe --tags` to include the pre-release suffix.
```json
{
"strategy" : {
"semvar" : {
"allowPreRelease" : true,
"preRelease" : {
"strategy" : {
"gitTag" : {}
}
},
...
}
}
}
```
This would produce something similar to: `1.0.0-10-8d73287a60`
##### Custom Command
This allows you to call an external command to generate the pre-release suffix. We will use whatever
the output is as the suffix.
```json
{
"strategy": {
"semvar": {
"allowPreRelease": true,
"preRelease": {
"strategy": {
"command": {
"arguments": ["my-command", "--some-option", "foo"],
"allowPrefix": true
}
}
}
}
}
}
```
> Note: All arguments to custom commands need to be a separate string in the arguments array
> otherwise they may not get passed appropriately to the command, so
> `"my-command --some-option foo"` will likely not work as expected.
##### Pre-Release Prefixes
All pre-release strategies can also accept a `prefix` that will appended prior to the generated
pre-release suffix. This can also be used without providing a pre-release strategy to only append
the `prefix` to the semvar.
```json
{
"strategy" : {
"semvar" : {
"allowPreRelease" : true,
"preRelease" : {
"prefix": "rc"
},
...
}
}
}
```
This would produce something similar to: `1.0.0-rc`

View File

@@ -0,0 +1,54 @@
# Options Reference
Common options used for the commands.
## Overview
The commands mostly all accept similar options, below is a list of those options and a description
of their usage.
### General Options
| Short | Long | Argument | Description |
| ----- | ------------------- | -------- | -------------------------------------------------------------------- |
| N/A | --print | N/A | Perform the command, but don't write any output files |
| N/A | --project-directory | <path> | The path to the root of your project, defaults to current directory |
| -h | --help | N/A | Show help for a command |
| -v | --verbose | N/A | Increase logging level, can be passed multiple times (example: -vvv) |
| N/A | --version | N/A | Show the version of the command line tool |
### Configuration Options
| Short | Long | Argument | Description |
| ----- | ---------------------------------- | ------------ | ----------------------------------------------------------------------------------- |
| -f | --configuration-file | <path> | The path to the configuration to use. |
| -m | --target-module | <name> | The target module name inside your project |
| -n | --target-file-name | <name> | The file name for the version to be found inside the module |
| -p | --target-file-path | <path> | Path to a version file in your project |
| N/A | --enable-git-tag/--disable-git-tag | N/A | Use the git-tag version strategy |
| N/A | --require-exact-match | N/A | Fail if a tag is not specifically set on the commit |
| N/A | --require-existing-semvar | N/A | Fail if an existing semvar is not found in the version file. |
| -c | --custom-command | <arguments> | Use a custom command strategy for the version (any options need to proceed a '--') |
| N/A | --commit-sha/--no-commit-sha | N/A | Use the commit sha with branch version or pre-release strategy |
| N/A | --require-configuration | N/A | Fail if a configuration file is not found |
| N/A | --precedence | <precedence> | The precedence for when a file exists (values: ['file', 'strategy'], default: file) |
> Note: Precedence is used as tie breaker if the version in the file does not agree with the version
> from the configured strategy. This can happen if a file was edited / bumped manually or the value
> returned from the external command is not similar to the version in the file. By default the file
> will take precedence over what is returned from the strategy.
#### Pre-Release Options
| Short | Long | Argument | Description |
| ----- | ---------------------------- | ----------- | ------------------------------------------------------------ |
| -d | --disable-pre-release | N/A | Disable pre-relase suffixes from being used |
| -b | --pre-release-branch-style | N/A | Use the branch and commit sha style for pre-release suffixes |
| N/A | --commit-sha/--no-commit-sha | N/A | Use the commit sha with branch pre-release strategy |
| -g | --pre-release-git-tag-style | N/A | Use the git tag style for pre-release suffixes |
| N/A | --pre-release-prefix | <prefix> | A prefix to use before a pre-release suffix |
| N/A | --custom-pre-release | <arguments> | Use custom command strategy for pre-release suffix |
> Note: When using one of the `--custom-*` options then any arguments passed will be used for
> arguments when calling your custom strategy, if the external tool you use requires options they
> must proceed a '--' otherwise you will get an error that an 'unexpected option' is being used.

View File

@@ -0,0 +1,77 @@
# Plugins Reference
Learn about using the provided package plugins.
## Overview
There are two provided plugins that can be used, this describes their usage.
### Build with Version
The `BuildWithVersion` plugin uses your project configuration to automatically generate a version
file when swift builds your project. You can use the plugin by declaring it as dependency in your
project.
```swift
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
platforms:[.macOS(.v13)],
dependencies: [
...,
.package(url: "https://github.com/m-housh/swift-bump-version.git", from: "0.2.0")
],
targets: [
.executableTarget(
name: "<target name>",
dependencies: [...],
plugins: [
.plugin(name: "BuildWithVersionPlugin", package: "swift-bump-version")
]
)
]
)
```
### Manual Plugin
There is also a `BumpVersionPlugin` that allows you to run the `bump-version` tool without
installing the command-line tool on your system, however it does make the usage much more verbose.
Include as dependency in your project.
```swift
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
platforms:[.macOS(.v13)],
dependencies: [
...,
.package(url: "https://github.com/m-housh/swift-bump-version.git", from: "0.2.0")
],
targets: [
...
]
)
```
Then you can use the manual plugin.
```
swift package \
--disable-sandbox \
--allow-writing-to-package-directory \
bump-version \
bump \
--minor
```
> Note: Anything after the `'bump-version'` in the above get's passed directly to the bump-version
> command-line tool, so you can use this to run any of the provided commands, the above shows
> bumping the minor semvar as a reference example.
>
> See Also: <doc:CommandReference>

View File

@@ -0,0 +1,62 @@
# ``BumpVersion``
@Metadata {
@DisplayName("bump-version")
@DocumentationExtension(mergeBehavior: override)
}
A command-line tool for managing swift application versions.
## Overview
This tool aims to provide a way to manage application versions in your build
pipeline. It can be used as a stand-alone command-line tool or by using one of
the provided swift package plugins.
## Installation
The command-line tool can be installed via homebrew.
```bash
brew tap m-housh/formula
brew install bump-version
```
## Package Plugins
Package plugins can be used in a swift package manager project.
```swift
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
platforms:[.macOS(.v13)],
dependencies: [
...,
.package(url: "https://github.com/m-housh/swift-bump-version.git", from: "0.2.0")
],
targets: [
.executableTarget(
name: "<target name>",
dependencies: [...],
plugins: [
.plugin(name: "BuildWithVersionPlugin", package: "swift-bump-version")
]
)
]
)
```
> See Also: <doc:PluginsReference>
## Topics
### Articles
- <doc:BasicConfiguration>
- <doc:ConfigurationReference>
- <doc:CommandReference>
- <doc:OptionsReference>
- <doc:PluginsReference>

View File

@@ -0,0 +1,191 @@
import ArgumentParser
@_spi(Internal) import CliClient
import ConfigurationClient
import Dependencies
import Foundation
import Rainbow
// TODO: Add an option to not load project configuration.
struct GlobalOptions: ParsableArguments {
@OptionGroup
var configOptions: ConfigurationOptions
@Option(
name: .customLong("project-directory"),
help: "The project directory. (default: current directory)"
)
var projectDirectory: String?
@Flag(
name: .customLong("print"),
help: "Print's what would be written to a target version file."
)
var dryRun: Bool = false
@Flag(
name: .shortAndLong,
help: "Increase logging level, can be passed multiple times (example: -vvv)."
)
var verbose: Int
@Argument(
help: """
Arguments / options used for custom pre-release, options / flags must proceed a '--' in
the command. These are ignored if the `--custom` or `--custom-pre-release` flag is not set.
"""
)
var extraOptions: [String] = []
}
struct ConfigurationOptions: ParsableArguments {
@Option(
name: [.customShort("f"), .long],
help: "Specify the path to a configuration file. (default: .bump-version.json)",
completion: .file(extensions: ["json"])
)
var configurationFile: String?
@OptionGroup var targetOptions: TargetOptions
@OptionGroup var semvarOptions: SemVarOptions
@Flag(
name: .long,
inversion: .prefixedNo,
help: """
Include the short commit sha in version or pre-release branch style output.
"""
)
var commitSha: Bool = true
@Flag(
name: .long,
help: """
Require a configuration file, otherwise fail.
"""
)
var requireConfiguration: Bool = false
}
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
}
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
@Option(
name: .long,
help: """
Set the precence to prefer version from file or strategy.
"""
)
var precedence: Configuration.SemVar.Precedence?
@OptionGroup var preRelease: PreReleaseOptions
}
extension Configuration.SemVar.Precedence: ExpressibleByArgument {
public init?(argument: String) {
self.init(rawValue: argument)
}
}

View File

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

View File

@@ -0,0 +1,200 @@
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: projectDirectory,
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
if customCommand && preRelease.customPreRelease {
logger.warning("""
Custom pre-release can not be used at same time as custom command.
Ignoring pre-release...
""")
}
logger.trace("precedence: \(String(describing: precedence))")
return try .init(
allowPreRelease: !preRelease.disablePreRelease,
precedence: precedence,
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
)
}
private func configurationToMerge(extraOptions: [String]) throws -> Configuration {
try .init(
target: target(),
strategy: semvarOptions.gitTag
? .semvar(semvarOptions(extraOptions: extraOptions))
: .branch(includeCommitSha: commitSha)
)
}
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,
projectDirectory: gitDirectory,
loggingOptions: .init(command: command, verbose: verbose),
configurationToMerge: configurationToMerge(extraOptions: extraOptions),
configurationFile: configurationFile,
requireConfigurationFile: requireConfiguration
)
}
}
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.1"
let VERSION: String? = "0.2.0-rc-5-ga26d869"

View File

@@ -1,12 +1,15 @@
import ConfigurationClient
import Dependencies
import DependenciesMacros
import FileClient
import Foundation
import GitClient
import LoggingExtensions
import ShellClient
public extension DependencyValues {
/// The cli-client that runs the command line tool commands.
var cliClient: CliClient {
get { self[CliClient.self] }
set { self[CliClient.self] = newValue }
@@ -21,85 +24,77 @@ public struct CliClient: Sendable {
public var build: @Sendable (SharedOptions) async throws -> String
/// Bump the existing version.
public var bump: @Sendable (BumpOption?, SharedOptions) async throws -> String
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
}
public enum PreReleaseStrategy: Equatable, Sendable {
/// Use output of tag, with branch and commit sha.
case branchAndCommit
/// Provide a custom pre-release tag.
indirect case custom(String, PreReleaseStrategy? = nil)
/// Use the output of `git describe --tags`
case tag
}
public enum VersionStrategy: Equatable, Sendable {
case branchAndCommit
case semVar(SemVarOptions)
public struct SemVarOptions: Equatable, Sendable {
let preReleaseStrategy: PreReleaseStrategy?
let requireExistingFile: Bool
let requireExistingSemVar: Bool
public init(
preReleaseStrategy: PreReleaseStrategy? = nil,
requireExistingFile: Bool = true,
requireExistingSemVar: Bool = true
) {
self.preReleaseStrategy = preReleaseStrategy
self.requireExistingFile = requireExistingFile
self.requireExistingSemVar = requireExistingSemVar
}
}
}
/// Represents options that are used by all the commands.
public struct SharedOptions: Equatable, Sendable {
/// Whether to allow pre-release suffixes.
let allowPreReleaseTag: Bool
/// Flag on if we write to files or not.
let dryRun: Bool
let gitDirectory: String?
let logLevel: Logger.Level
let preReleaseStrategy: PreReleaseStrategy?
let target: String
let versionStrategy: VersionStrategy
/// Specify a path to the project directory.
let projectDirectory: String?
/// The logging options to use.
let loggingOptions: LoggingOptions
/// Configuration that gets merged with the loaded (or default) configuration.
let configurationToMerge: Configuration?
/// Path to the configuration file to load.
let configurationFile: String?
/// Fail if a configuration file is not found.
let requireConfigurationFile: Bool
public init(
allowPreReleaseTag: Bool = true,
dryRun: Bool = false,
gitDirectory: String? = nil,
logLevel: Logger.Level = .debug,
preReleaseStrategy: PreReleaseStrategy? = nil,
target: String,
versionStrategy: VersionStrategy = .semVar(.init())
projectDirectory: String? = nil,
loggingOptions: LoggingOptions,
configurationToMerge: Configuration? = nil,
configurationFile: String? = nil,
requireConfigurationFile: Bool = false
) {
self.allowPreReleaseTag = allowPreReleaseTag
self.dryRun = dryRun
self.target = target
self.gitDirectory = gitDirectory
self.logLevel = logLevel
self.preReleaseStrategy = preReleaseStrategy
self.versionStrategy = versionStrategy
self.projectDirectory = projectDirectory
self.loggingOptions = loggingOptions
self.configurationFile = configurationFile
self.configurationToMerge = configurationToMerge
self.requireConfigurationFile = requireConfigurationFile
}
var allowPreReleaseTag: Bool { preReleaseStrategy != nil }
}
}
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() }
generate: { try await $0.generate() },
parsedConfiguration: { options in
try await options.loggingOptions.withLogger {
try await options.withMergedConfiguration { $0 }
}
}
)
}

View File

@@ -1,7 +1,12 @@
import ConfigurationClient
enum CliClientError: Error {
case gitDirectoryNotFound
case fileExists(path: String)
case fileDoesNotExist(path: String)
case failedToParseVersionFile
case semVarNotFound
case semVarNotFound(message: String)
case strategyNotFound(configuration: Configuration)
case preReleaseParsingError(String)
case versionStringNotFound
}

View File

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

View File

@@ -1,12 +1,12 @@
# Manual Plugins
There are two plugins that are included that can be ran manually, if the build tool plugin does not fit
your use case.
There are two plugins that are included that can be ran manually, if the build tool plugin does not
fit your use case.
## Generate Version
The `generate-version` plugin will create a `Version.swift` file in the given target. You can
run it by running the following command.
The `generate-version` plugin will create a `Version.swift` file in the given target. You can run it
by running the following command.
```bash
swift package --disable-sandbox \
@@ -32,21 +32,22 @@ swift package --disable-sandbox \
## Options
Both manual versions also allow the following options to customize the operation, the
options need to come after the plugin name.
Both manual versions also allow the following options to customize the operation, the options need
to come after the plugin name.
| Option | Description |
| ------ | ----------- |
| --dry-run | Do not write to any files, but describe where values would be written |
| --filename | Override the file name to be written in the target directory |
| --verbose | Increase the logging output |
| Option | Description |
| ---------- | --------------------------------------------------------------------- |
| --print | Do not write to any files, but describe where values would be written |
| --filename | Override the file name to be written in the target directory |
| --verbose | Increase the logging output |
### Example with options
```bash
swift package \
--allow-writing-to-package-directory \
generate-version \
--dry-run \
--print \
--verbose \
<target>
```

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 generally derived from git tags,
however it can be configured to run custom commands.
## Articles
- <doc:GettingStarted>
- <doc:ManualPlugins>

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

@@ -0,0 +1,185 @@
import ConfigurationClient
import CustomDump
import Dependencies
import FileClient
import Foundation
import GitClient
import LoggingExtensions
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: (VersionContainer) 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
guard let strategy = configuration.strategy else {
throw CliClientError.strategyNotFound(configuration: configuration)
}
logger.dump(configuration, level: .trace) {
"\nConfiguration: \($0)"
}
// This will fail if the target url is not set properly.
let targetUrl = try configuration.targetUrl(projectDirectory: projectDirectory)
logger.debug("Target: \(targetUrl.cleanFilePath)")
// Perform the operation, which generates the new version and writes it.
try await operation(
.load(projectDirectory: projectDirectory, strategy: strategy, url: targetUrl)
)
// Return the file path we wrote the version to.
return targetUrl.cleanFilePath
}
}
}
// Merges any configuration set via the passed in options.
@discardableResult
func withMergedConfiguration<T>(
operation: (Configuration) async throws -> T
) async throws -> T {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.logger) var logger
if configurationToMerge?.strategy?.branch != nil {
logger.trace("Merging branch strategy.")
// strategy = .branch(branch)
} else if let semvar = configurationToMerge?.strategy?.semvar {
logger.dump(semvar, level: .trace) {
"Merging semvar strategy:\n\($0)"
}
}
return try await configurationClient.withConfiguration(
path: configurationFile,
merging: configurationToMerge,
strict: requireConfigurationFile,
operation: operation
)
}
func write(_ string: String, to url: URL) async throws {
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger
if !dryRun {
try await fileClient.write(string: string, to: url)
} else {
logger.debug("Skipping, due to dry-run being passed.")
}
}
func write(_ currentVersion: VersionContainer) async throws {
@Dependency(\.logger) var logger
logger.trace("Begin writing version.")
// let hasChanges: Bool
let targetUrl: URL
let usesOptionalType: Bool
let versionString: String?
switch currentVersion {
case let .branch(branch):
// hasChanges = branch.hasChanges
targetUrl = branch.targetUrl
usesOptionalType = branch.usesOptionalType
versionString = branch.versionString
case let .semvar(semvar):
// hasChanges = semvar.hasChanges
targetUrl = semvar.targetUrl
usesOptionalType = semvar.usesOptionalType
versionString = semvar.versionString(withPreRelease: allowPreReleaseTag)
}
// if !hasChanges {
// logger.debug("No changes from loaded version, not writing next version.")
// return
// }
guard let versionString else {
throw CliClientError.versionStringNotFound
}
// let version = try currentVersion.version.string(allowPreReleaseTag: allowPreReleaseTag)
if !dryRun {
logger.debug("Version: \(versionString)")
} else {
logger.info("Version: \(versionString)")
}
let template = usesOptionalType ? Template.optional(versionString) : Template.nonOptional(versionString)
logger.trace("Template string: \(template)")
try await write(template, to: targetUrl)
}
}
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 {
return try await run { container in
@Dependency(\.logger) var logger
switch container {
case .branch: // 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):
let version: SemVar?
switch semvar.precedence ?? .default {
case .file:
version = semvar.loadedVersion ?? semvar.strategyVersion
case .strategy:
version = semvar.strategyVersion ?? semvar.loadedVersion
}
// let version = semvar.loadedVersion ?? semvar.nextVersion
guard let version else {
throw CliClientError.semVarNotFound(message: "Failed to parse a valid semvar to bump.")
}
logger.dump(version, level: .debug) { "Version prior to bumping:\n\($0)" }
let bumped = version.bump(type)
logger.dump(bumped, level: .trace) { "Bumped version:\n\($0)" }
try await write(.semvar(semvar.withUpdateNextVersion(bumped)))
}
}
}
func generate() async throws -> String {
try await run { currentVersion in
try await write(currentVersion)
}
}
}
private extension CurrentVersionContainer where Version == SemVar {
func withUpdateNextVersion(_ next: SemVar) -> Self {
.init(
targetUrl: targetUrl,
usesOptionalType: usesOptionalType,
loadedVersion: loadedVersion,
precedence: .strategy, // make sure to use the next version, since it was specified, as this is called from `bump`.
strategyVersion: next
)
}
}

View File

@@ -0,0 +1,57 @@
import ConfigurationClient
import CustomDump
import Dependencies
import Foundation
import GitClient
import ShellClient
extension Configuration {
func targetUrl(projectDirectory: String?) throws -> URL {
guard let target else {
throw ConfigurationParsingError.targetNotFound
}
return try target.url(projectDirectory: projectDirectory)
}
}
private extension Configuration.Target {
func url(projectDirectory: 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 projectDirectory {
return URL(filePath: "\(projectDirectory)/\(filePath)")
}
return URL(filePath: filePath)
}
}
enum ConfigurationParsingError: Error {
case targetNotFound
case pathOrModuleNotSet
case versionStrategyError(message: String)
case versionStrategyNotFound
}

View File

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

View File

@@ -5,9 +5,40 @@ import GitClient
@_spi(Internal)
public extension FileClient {
func branch(
file: URL,
projectDirectory: String?,
requireExistingFile: Bool
) async throws -> (string: String, usesOptionalType: Bool)? {
let loaded = try? await getVersionString(fileUrl: file, projectDirectory: projectDirectory)
guard let loaded else {
if requireExistingFile {
throw CliClientError.fileDoesNotExist(path: file.cleanFilePath)
}
return nil
}
return (loaded.0, loaded.1)
}
func semvar(
file: URL,
projectDirectory: String?,
requireExistingFile: Bool
) async throws -> (semVar: SemVar?, usesOptionalType: Bool)? {
let loaded = try? await getVersionString(fileUrl: file, projectDirectory: projectDirectory)
guard let loaded else {
if requireExistingFile {
throw CliClientError.fileDoesNotExist(path: file.cleanFilePath)
}
return nil
}
let semvar = SemVar(string: loaded.0)
return (semvar, loaded.1)
}
private func getVersionString(
fileUrl: URL,
gitDirectory: String?
projectDirectory: String?
) async throws -> (version: String, usesOptionalType: Bool) {
@Dependency(\.gitClient) var gitClient
@Dependency(\.logger) var logger
@@ -28,21 +59,14 @@ public extension FileClient {
logger.debug("Version line: \(versionLine)")
let isOptional = versionLine.contains("String?")
logger.debug("Uses optional: \(isOptional)")
logger.trace("Uses optional: \(isOptional)")
let versionString = versionLine.split(separator: "let VERSION: \(isOptional ? "String?" : "String") = ").last
guard let versionString else {
throw CliClientError.failedToParseVersionFile
}
logger.trace("Parsed version string: \(versionString)")
return (String(versionString), isOptional)
}
func semVar(
file: URL,
gitDirectory: String?
) async throws -> (semVar: SemVar?, usesOptionalType: Bool) {
let (string, usesOptionalType) = try await getVersionString(fileUrl: file, gitDirectory: gitDirectory)
return (SemVar(string: string), usesOptionalType)
}
}

View File

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

View File

@@ -37,16 +37,30 @@ public struct SemVar: CustomStringConvertible, Equatable, Sendable {
}
public init?(string: String) {
@Dependency(\.logger) var logger
logger.trace("Parsing semvar from: \(string)")
let parts = string.split(separator: ".")
logger.trace("parts: \(parts)")
guard parts.count >= 3 else {
return nil
}
let major = Int(String(parts[0].replacingOccurrences(of: "\"", with: "")))
let minor = Int(String(parts[1]))
let patchParts = parts[2].split(separator: "-")
let major = Int(String(parts[0].replacingOccurrences(of: "\"", with: "")))
logger.trace("major: \(String(describing: major))")
let minor = Int(String(parts[1]))
logger.trace("minor: \(String(describing: minor))")
let patchParts = parts[2].replacingOccurrences(of: "\"", with: "").split(separator: "-")
logger.trace("patchParts: \(String(describing: patchParts))")
let patch = Int(patchParts.first ?? "0")
logger.trace("patch: \(String(describing: patch))")
let preRelease = String(patchParts.dropFirst().joined(separator: "-"))
logger.trace("preRelease: \(String(describing: preRelease))")
self.init(
major: major ?? 0,
@@ -76,7 +90,7 @@ public struct SemVar: CustomStringConvertible, Equatable, Sendable {
}
// Bumps the sem-var by the given option (major, minor, patch)
public func bump(_ option: CliClient.BumpOption, preRelease: String?) -> Self {
public func bump(_ option: CliClient.BumpOption, preRelease: String? = nil) -> Self {
switch option {
case .major:
return .init(

View File

@@ -0,0 +1,90 @@
import ConfigurationClient
import Foundation
import GitClient
import LoggingExtensions
import ShellClient
extension SemVar {
static func nextVersion(
configuration: Configuration.SemVar,
projectDirectory: String?
) async throws -> Self? {
@Dependency(\.asyncShellClient) var asyncShellClient
@Dependency(\.gitClient) var gitClient
@Dependency(\.logger) var logger
guard let strategy = configuration.strategy else { return nil }
let semvarString: String
switch strategy {
case let .gitTag(exactMatch: exactMatch):
logger.trace("Loading semvar gitTag strategy...")
semvarString = try await gitClient.version(.init(
gitDirectory: projectDirectory,
style: .tag(exactMatch: exactMatch ?? false)
)).description
case let .command(arguments: arguments):
logger.trace("Loading semvar custom command strategy: \(arguments)")
semvarString = try await asyncShellClient.background(.init(arguments))
}
var preReleaseString: String?
if let preRelease = configuration.preRelease,
configuration.allowPreRelease ?? true
{
preReleaseString = try await preRelease.get(projectDirectory: projectDirectory)
}
let semvar = SemVar(string: semvarString)
if let preReleaseString {
return semvar?.applyingPreRelease(preReleaseString)
}
return semvar
}
}
private extension Configuration.PreRelease {
func get(projectDirectory: String?) async throws -> String? {
@Dependency(\.asyncShellClient) var asyncShellClient
@Dependency(\.gitClient) var gitClient
@Dependency(\.logger) var logger
var allowsPrefix = true
var preReleaseString: String
guard let strategy else { return nil }
switch strategy {
case let .branch(includeCommitSha: includeCommitSha):
logger.trace("Loading pre-relase branch strategy...")
preReleaseString = try await gitClient.version(.init(
gitDirectory: projectDirectory,
style: .branch(commitSha: includeCommitSha)
)).description
case .gitTag:
logger.trace("Loading pre-relase gitTag strategy...")
preReleaseString = try await gitClient.version(.init(
gitDirectory: projectDirectory,
style: .tag(exactMatch: false)
)).description
case let .command(arguments: arguments, allowPrefix: allowPrefix):
logger.trace("Loading pre-relase custom command strategy...")
allowsPrefix = allowPrefix ?? false
preReleaseString = try await asyncShellClient.background(.init(arguments))
}
if let prefix, allowsPrefix {
preReleaseString = "\(prefix)-\(preReleaseString)"
}
logger.trace("Pre-release string: \(preReleaseString)")
return preReleaseString
}
}

View File

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

View File

@@ -0,0 +1,172 @@
import ConfigurationClient
import CustomDump
import Dependencies
import FileClient
import Foundation
import LoggingExtensions
enum VersionContainer: Sendable {
case branch(CurrentVersionContainer<String>)
case semvar(CurrentVersionContainer<SemVar>)
static func load(
projectDirectory: String?,
strategy: Configuration.VersionStrategy,
url: URL
) async throws -> Self {
switch strategy {
case let .branch(includeCommitSha: includeCommitSha):
return try await .branch(.load(
branch: .init(includeCommitSha: includeCommitSha),
projectDirectory: projectDirectory,
url: url
))
case .semvar:
return try await .semvar(.load(
semvar: strategy.semvar!,
projectDirectory: projectDirectory,
url: url
))
}
}
}
// TODO: Add a precedence field for which version to prefer, should also be specified in
// configuration.
struct CurrentVersionContainer<Version> {
let targetUrl: URL
let usesOptionalType: Bool
let loadedVersion: Version?
let precedence: Configuration.SemVar.Precedence?
let strategyVersion: Version?
}
extension CurrentVersionContainer: Equatable where Version: Equatable {
var hasChanges: Bool {
switch (loadedVersion, strategyVersion) {
case (.none, .none):
return false
case (.some, .none),
(.none, .some):
return true
case let (.some(loaded), .some(next)):
return loaded == next
}
}
}
extension CurrentVersionContainer: Sendable where Version: Sendable {}
extension CurrentVersionContainer where Version == String {
static func load(
branch: Configuration.Branch,
projectDirectory: String?,
url: URL
) async throws -> Self {
@Dependency(\.fileClient) var fileClient
@Dependency(\.gitClient) var gitClient
let loaded = try await fileClient.branch(
file: url,
projectDirectory: projectDirectory,
requireExistingFile: false
)
let next = try await gitClient.version(.init(
gitDirectory: projectDirectory,
style: .branch(commitSha: branch.includeCommitSha)
))
return .init(
targetUrl: url,
usesOptionalType: loaded?.1 ?? true,
loadedVersion: loaded?.0,
precedence: nil,
strategyVersion: next.description
)
}
var versionString: String? {
loadedVersion ?? strategyVersion
}
}
extension CurrentVersionContainer where Version == SemVar {
// TODO: Update to use precedence and not fetch `nextVersion` if we loaded a file version.
static func load(semvar: Configuration.SemVar, projectDirectory: String?, url: URL) async throws -> Self {
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger
logger.trace("Begin loading semvar from: \(url.cleanFilePath)")
async let (loaded, usesOptionalType) = try await loadCurrentVersion(
semvar: semvar,
projectDirectory: projectDirectory,
url: url
)
async let next = try await loadNextVersion(semvar: semvar, projectDirectory: projectDirectory)
return try await .init(
targetUrl: url,
usesOptionalType: usesOptionalType,
loadedVersion: loaded,
precedence: semvar.precedence,
strategyVersion: next
)
}
static func loadCurrentVersion(
semvar: Configuration.SemVar,
projectDirectory: String?,
url: URL
) async throws -> (SemVar?, Bool) {
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger
logger.trace("Begin loading current version from: \(url.cleanFilePath)")
let loadedOptional = try await fileClient.semvar(
file: url,
projectDirectory: projectDirectory,
requireExistingFile: semvar.requireExistingFile ?? false
)
guard let loadedStrong = loadedOptional else {
if semvar.requireExistingFile ?? false {
throw CliClientError.semVarNotFound(message: "Required by configuration's 'requireExistingFile' variable.")
}
return (nil, true)
}
let (loaded, usesOptionalType) = loadedStrong
logger.dump(loaded) { "Loaded version:\n\($0)" }
return (loaded, usesOptionalType)
}
static func loadNextVersion(semvar: Configuration.SemVar, projectDirectory: String?) async throws -> SemVar? {
@Dependency(\.logger) var logger
let next = try await SemVar.nextVersion(
configuration: semvar,
projectDirectory: projectDirectory
)
logger.dump(next) { "Next version:\n\($0)" }
return next
}
func versionString(withPreRelease: Bool) -> String? {
let version: SemVar?
switch precedence ?? .default {
case .file:
version = loadedVersion ?? strategyVersion
case .strategy:
version = strategyVersion ?? loadedVersion
}
return version?.versionString(withPreReleaseTag: withPreRelease)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,76 @@
import Dependencies
import FileClient
import Foundation
import LoggingExtensions
@_spi(Internal)
public extension Configuration {
func merging(_ other: Self?) -> Self {
mergingTarget(other?.target).mergingStrategy(other?.strategy)
}
private func mergingTarget(_ otherTarget: Configuration.Target?) -> Self {
.init(
target: otherTarget ?? target,
strategy: strategy
)
}
private func mergingStrategy(_ otherStrategy: Configuration.VersionStrategy?) -> Self {
.init(
target: target,
strategy: strategy?.merging(otherStrategy)
)
}
}
@_spi(Internal)
public extension Configuration.PreRelease {
func merging(_ other: Self?) -> Self {
return .init(
prefix: other?.prefix ?? prefix,
strategy: other?.strategy ?? strategy
)
}
}
@_spi(Internal)
public extension Configuration.Branch {
func merging(_ other: Self?) -> Self {
return .init(includeCommitSha: other?.includeCommitSha ?? includeCommitSha)
}
}
@_spi(Internal)
public extension Configuration.SemVar {
func merging(_ other: Self?) -> Self {
@Dependency(\.logger) var logger
logger.dump(other, level: .trace) { "Merging semvar:\n\($0)" }
return .init(
allowPreRelease: other?.allowPreRelease ?? allowPreRelease,
precedence: other?.precedence ?? precedence,
preRelease: preRelease == nil ? other?.preRelease : preRelease!.merging(other?.preRelease),
requireExistingFile: other?.requireExistingFile ?? requireExistingFile,
requireExistingSemVar: other?.requireExistingSemVar ?? requireExistingSemVar,
strategy: other?.strategy ?? strategy
)
}
}
@_spi(Internal)
public extension Configuration.VersionStrategy {
func merging(_ other: Self?) -> Self {
guard let other else { return self }
switch other {
case .branch:
guard let branch else { return other }
return .branch(branch.merging(other.branch))
case .semvar:
guard let semvar else { return other }
return .semvar(semvar.merging(other.semvar))
}
}
}

View File

@@ -1,5 +1,7 @@
import CustomDump
import Foundation
import TOMLKit
// TODO: Add pre-release strategy that just bumps an integer.
/// Represents configuration that can be set via a file, generally in the root of the
/// project directory.
@@ -15,24 +17,26 @@ public struct Configuration: Codable, Equatable, Sendable {
public init(
target: Target? = nil,
strategy: VersionStrategy? = .init(semvar: .init())
strategy: VersionStrategy? = .semvar(.init())
) {
self.target = target
self.strategy = strategy
}
public static var mock: Self {
public static var `default`: Self { .mock(module: "<my-tool>") }
public static func mock(module: String = "cli-version") -> Self {
.init(
target: .init(module: .init("cli-version")),
strategy: .init()
target: .init(module: .init(module)),
strategy: .semvar(.init(strategy: .gitTag(exactMatch: false)))
)
}
public static var customPreRelease: Self {
.init(
target: .init(module: .init("cli-version")),
strategy: .init(semvar: .init(
preRelease: .customBranchPrefix("rc")
strategy: .semvar(.init(
preRelease: .init(prefix: "rc", strategy: .branch())
))
)
}
@@ -40,125 +44,11 @@ public struct Configuration: Codable, Equatable, Sendable {
public extension Configuration {
/// Represents a branch version or pre-release strategy.
///
/// This derives the version from the branch name and short version
/// of the commit sha if configured.
struct Branch: Codable, Equatable, Sendable {
/// Include the commit sha in the output for this strategy.
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 PreReleaseStrategy: Codable, Equatable, Sendable {
/// Use branch and commit sha as pre-release suffix.
public let branch: Branch?
/// Use a custom prefix string.
public let prefix: String?
/// An identifier for the type of pre-release.
public let style: StyleId
/// Whether we use `git describe --tags` for part of the suffix, this is only used
/// if we have a custom style.
public let usesGitTag: Bool
init(
style: StyleId,
branch: Branch? = nil,
prefix: String? = nil,
usesGitTag: Bool = false
) {
self.branch = branch
self.prefix = prefix
self.style = style
self.usesGitTag = usesGitTag
}
/// Represents a pre-release strategy that is derived from calling
/// `git describe --tags`.
public static let gitTag = Self(style: StyleId.gitTag)
/// Represents a pre-release strategy that is derived from the branch and commit sha.
public static func branch(_ branch: Branch = .init()) -> Self {
.init(style: .branch, branch: branch)
}
/// Represents a custom strategy that uses the given value, not deriving any other
/// data.
public static func custom(_ prefix: String) -> Self {
.init(style: .custom, prefix: prefix)
}
/// Represents a custom strategy that uses a prefix along with the branch and
/// commit sha.
public static func customBranchPrefix(
_ prefix: String,
branch: Branch = .init()
) -> Self {
.init(style: .custom, branch: branch, prefix: prefix)
}
/// Represents a custom strategy that uses a prefix along with the output from
/// calling `git describe --tags`.
public static func customGitTagPrefix(_ prefix: String) -> Self {
.init(style: StyleId.custom, prefix: prefix, usesGitTag: true)
}
public enum StyleId: String, Codable, Sendable {
case branch
case custom
case gitTag
}
}
/// Represents a semvar version strategy.
///
/// ## Example: 1.0.0
///
struct SemVar: Codable, Equatable, Sendable {
/// Optional pre-releas suffix strategy.
public let preRelease: PreReleaseStrategy?
/// 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 init(
preRelease: PreReleaseStrategy? = nil,
requireExistingFile: Bool = true,
requireExistingSemVar: Bool = true
) {
self.preRelease = preRelease
self.requireExistingFile = requireExistingFile
self.requireExistingSemVar = requireExistingSemVar
}
}
/// 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 {
struct Target: Codable, Equatable, Sendable, CustomDumpReflectable {
/// The path to a version file.
public let path: String?
@@ -186,13 +76,13 @@ public extension Configuration {
/// Represents a module target for a version file.
///
public struct Module: Codable, Equatable, Sendable {
public struct Module: Codable, Equatable, Sendable, CustomDumpReflectable {
/// The module directory name.
public let name: String
/// The version file name located in the module directory.
public let fileName: String
public let fileName: String?
/// Create a new module target.
///
@@ -201,11 +91,47 @@ public extension Configuration {
/// - fileName: The file name located in the module directory.
public init(
_ name: String,
fileName: String = "Version.swift"
fileName: String? = nil
) {
self.name = name
self.fileName = fileName
}
public var fileNameOrDefault: String {
fileName ?? "Version.swift"
}
public var customDumpMirror: Mirror {
.init(
self,
children: [
"name": name,
"fileName": fileNameOrDefault
],
displayStyle: .struct
)
}
}
public var customDumpMirror: Mirror {
guard let module else {
guard let path else { return .init(reflecting: self) }
return .init(
self,
children: [
"path": path
],
displayStyle: .struct
)
}
return .init(
self,
children: [
"module": module
],
displayStyle: .struct
)
}
}
@@ -214,31 +140,190 @@ public extension Configuration {
/// Typically a `SemVar` strategy or `Branch`.
///
///
struct VersionStrategy: Codable, Equatable, Sendable {
enum VersionStrategy: Codable, Equatable, Sendable, CustomDumpReflectable {
case branch(includeCommitSha: Bool = true)
/// Set if we're using the branch and commit sha to derive the version.
let branch: Branch?
case semvar(
allowPreRelease: Bool? = nil,
precedence: SemVar.Precedence? = nil,
preRelease: PreRelease? = nil,
requireExistingFile: Bool? = nil,
requireExistingSemVar: Bool? = nil,
strategy: SemVar.Strategy? = nil
)
/// Set if we're using semvar to derive the version.
let semvar: SemVar?
public var branch: Branch? {
guard case let .branch(includeCommitSha) = self
else { return nil }
/// Create a new version strategy that uses branch and commit sha to derive the version.
///
/// - Parameters:
/// - branch: The branch strategy options.
public init(branch: Branch) {
self.branch = branch
self.semvar = nil
return .init(includeCommitSha: includeCommitSha)
}
/// Create a new version strategy that uses semvar to derive the version.
public var semvar: SemVar? {
guard case let .semvar(
allowPreRelease,
precedence,
preRelease,
requireExistingFile, requireExistingSemVar, strategy
) = self
else { return nil }
return .init(
allowPreRelease: allowPreRelease,
precedence: precedence,
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,
precedence: value.precedence,
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)
}
}
}
/// 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:
/// - semvar: The semvar strategy options.
public init(semvar: SemVar = .init()) {
self.branch = nil
self.semvar = semvar
/// - 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?
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], allowPrefix: Bool? = nil)
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 {
/// Allow semvar to include a pre-release suffix.
public let allowPreRelease: Bool?
/// Set the precedence of version loaded from file versus
/// the version returned by the strategy.
///
/// These can not always agree / reflect the same version,
/// so the default is to give the file version precedence over
/// the strategy.
public let precedence: Precedence?
/// 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?
/// The strategy used to derive a version for the project.
public let strategy: Strategy?
public init(
allowPreRelease: Bool? = true,
precedence: Precedence? = nil,
preRelease: PreRelease? = nil,
requireExistingFile: Bool? = false,
requireExistingSemVar: Bool? = false,
strategy: Strategy? = nil
) {
self.allowPreRelease = allowPreRelease
self.precedence = precedence
self.preRelease = preRelease
self.requireExistingFile = requireExistingFile
self.requireExistingSemVar = requireExistingSemVar
self.strategy = strategy
}
/// Represents a strategy to derive a version for a project.
public enum Strategy: Codable, Equatable, Sendable {
/// A custom external command that should return a string that
/// can be parsed as a semvar.
case command(arguments: [String])
/// Use `git describe --tags` optionally as an exact match.
case gitTag(exactMatch: Bool? = false)
}
/// Represents the precedence for which version to use when a file
/// exists, as they don't always agree. For example, a file could be edited
/// manually or the tag doesn't represent what is parsed from calling the
/// strategy.
///
/// The default is to defer to the file (if it exists) as having precedence over
/// the strategy.
public enum Precedence: String, CaseIterable, Codable, Equatable, Sendable {
/// Give the file precedence over the strategy.
case file
/// Give the strategy precedence over the file.
case strategy
/// The default precedence.
public static var `default`: Self { .file }
}
}
}

View File

@@ -3,6 +3,8 @@ import DependenciesMacros
import FileClient
import Foundation
// TODO: Add a method to get a semvar / handle a version strategy's ??
public extension DependencyValues {
/// Perform operations with configuration files.
@@ -16,21 +18,66 @@ public extension DependencyValues {
@DependencyClient
public struct ConfigurationClient: Sendable {
fileprivate enum Constants {
static let defaultFileNameWithoutExtension = ".bump-version"
static let defaultExtension = "json"
static var defaultFileName: String { "\(defaultFileNameWithoutExtension).\(defaultExtension)" }
}
/// The default file name for a configuration file.
public var defaultFileName: @Sendable () -> String = { "test.json" }
/// Find a configuration file in the given directory or in current working directory.
public var find: @Sendable (URL?) async throws -> ConfigruationFile?
public var find: @Sendable (URL?) async throws -> URL?
/// Load a configuration file.
public var load: @Sendable (ConfigruationFile) async throws -> Configuration
public var load: @Sendable (URL) async throws -> Configuration
/// Write a configuration file.
public var write: @Sendable (Configuration, ConfigruationFile) async throws -> Void
public var write: @Sendable (Configuration, URL) async throws -> Void
/// Find a configuration file and load it if found.
public func findAndLoad(_ url: URL? = nil) async throws -> Configuration {
guard let url = try await find(url) else {
throw ConfigurationClientError.configurationNotFound
///
/// - Parameters:
/// - url: The optional path to the configuration file.
/// - strict: Fail if a configuration file is not found, otherwise return default configuration.
public func findAndLoad(_ url: URL? = nil, strict: Bool = true) async throws -> Configuration {
guard let url = try? await find(url) else {
if strict {
throw ConfigurationClientError.configurationNotFound
}
return .default
}
return try await load(url)
let loaded = try? await load(url)
guard let loaded else {
if strict {
throw ConfigurationClientError.configurationNotFound
}
return .default
}
return loaded
}
/// Loads configuration from the given path, or searches for the default file and loads it.
/// Optionally merges other configuration, then perform an operation with the loaded configuration.
///
/// - Parameters:
/// - path: Optional file path of the configuration to load.
/// - other: Optional configuration to merge with the loaded configuration.
/// - operation: The operation to perform with the loaded configuration.
@discardableResult
public func withConfiguration<T>(
path: String?,
merging other: Configuration? = nil,
strict: Bool = true,
operation: (Configuration) async throws -> T
) async throws -> T {
let configuration = try await findAndLoad(
path != nil ? URL(filePath: path!) : nil,
strict: strict
)
return try await operation(configuration.merging(other))
}
}
@@ -39,14 +86,15 @@ extension ConfigurationClient: DependencyKey {
public static var liveValue: ConfigurationClient {
.init(
defaultFileName: { "\(Constants.defaultFileNameWithoutExtension).json" },
find: { try await findConfiguration($0) },
load: { try await $0.load() ?? .mock },
write: { try await $1.write($0) }
load: { try await loadConfiguration($0) },
write: { try await writeConfiguration($0, to: $1) }
)
}
}
private func findConfiguration(_ url: URL?) async throws -> ConfigruationFile? {
private func findConfiguration(_ url: URL?) async throws -> URL? {
@Dependency(\.fileClient) var fileClient
var url: URL! = url
@@ -54,29 +102,32 @@ private func findConfiguration(_ url: URL?) async throws -> ConfigruationFile? {
url = try await URL(filePath: fileClient.currentDirectory())
}
// Check if url is a valid configuration url.
var configurationFile = ConfigruationFile(url: url)
if let configurationFile { return configurationFile }
guard try await fileClient.isDirectory(url.cleanFilePath) else {
throw ConfigurationClientError.invalidConfigurationDirectory(path: url.cleanFilePath)
if try await fileClient.isDirectory(url.cleanFilePath) {
url = url.appending(path: ConfigurationClient.Constants.defaultFileName)
}
// Check for toml file.
let tomlUrl = url.appending(path: "\(ConfigurationClient.Constants.defaultFileNameWithoutExtension).toml")
configurationFile = ConfigruationFile(url: tomlUrl)
if let configurationFile { return configurationFile }
// Check for json file.
let jsonUrl = url.appending(path: "\(ConfigurationClient.Constants.defaultFileNameWithoutExtension).json")
configurationFile = ConfigruationFile(url: jsonUrl)
if let configurationFile { return configurationFile }
// Couldn't find valid configuration file.
if fileClient.fileExists(url) {
return url
}
return nil
}
private func loadConfiguration(_ url: URL) async throws -> Configuration {
@Dependency(\.coders.jsonDecoder) var jsonDecoder
@Dependency(\.fileClient) var fileClient
let string = try await fileClient.read(url.cleanFilePath)
return try jsonDecoder().decode(Configuration.self, from: Data(string.utf8))
}
enum ConfigurationClientError: Error {
case configurationNotFound
case invalidConfigurationDirectory(path: String)
}
private func writeConfiguration(_ configuration: Configuration, to url: URL) async throws {
@Dependency(\.fileClient) var fileClient
@Dependency(\.coders.jsonEncoder) var jsonEncoder
let data = try jsonEncoder().encode(configuration)
try await fileClient.write(data, url)
}

View File

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

View File

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

View File

@@ -0,0 +1,156 @@
import Dependencies
import Foundation
import Logging
import LoggingFormatAndPipe
import Rainbow
import ShellClient
// MARK: Custom colors.
extension String {
var orange: Self {
bit24(255, 165, 0)
}
var magenta: Self {
bit24(238, 130, 238)
}
}
extension Logger.Level {
var coloredString: String {
switch self {
case .info:
return "\(self)".cyan
case .warning:
return "\(self)".orange.bold
case .debug:
return "\(self)".green
case .trace:
return "\(self)".yellow
case .error:
return "\(self)".red.bold
default:
return "\(self)"
}
}
}
private struct LevelFormatter: LoggingFormatAndPipe.Formatter {
let basic: BasicFormatter
var timestampFormatter: DateFormatter { basic.timestampFormatter }
// swiftlint:disable function_parameter_count
func processLog(
level: Logger.Level,
message: Logger.Message,
prettyMetadata: String?,
file: String,
function: String,
line: UInt
) -> String {
let now = Date()
return basic.format.map { component -> String in
return processComponent(
component,
now: now,
level: level,
message: message,
prettyMetadata: prettyMetadata,
file: file,
function: function,
line: line
)
}
.filter { $0.count > 0 }
.joined(separator: basic.separator ?? "")
}
public func processComponent(
_ component: LogComponent,
now: Date,
level: Logger.Level,
message: Logger.Message,
prettyMetadata: String?,
file: String,
function: String,
line: UInt
) -> String {
switch component {
case .level:
let maxLen = "\(Logger.Level.warning)".count
let paddingCount = (maxLen - "\(level)".count) / 2
var padding = ""
for _ in 0 ... paddingCount {
padding += " "
}
return "\(padding)\(level.coloredString)\(padding)"
case let .group(components):
return components.map { component -> String in
self.processComponent(
component,
now: now,
level: level,
message: message,
prettyMetadata: prettyMetadata,
file: file,
function: function,
line: line
)
}.joined()
case .message:
return basic.processComponent(
component,
now: now,
level: level,
message: message,
prettyMetadata: prettyMetadata,
file: file,
function: function,
line: line
).italic
default:
return basic.processComponent(
component,
now: now,
level: level,
message: message,
prettyMetadata: prettyMetadata,
file: file,
function: function,
line: line
)
}
}
// swiftlint:enable function_parameter_count
}
extension LoggingOptions {
func makeLogger() -> Logger {
let formatters: [LogComponent] = [
.text(executableName.magenta),
.text(command.blue),
.level,
.group([
.text("-> "),
.message
])
]
return Logger(label: executableName) { _ in
LoggingFormatAndPipe.Handler(
formatter: LevelFormatter(basic: BasicFormatter(
formatters,
separator: " | "
)),
pipe: LoggerTextOutputStreamPipe.standardOutput
)
}
}
}

View File

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

View File

@@ -0,0 +1,42 @@
import CustomDump
import Dependencies
import Logging
import ShellClient
public struct LoggingOptions: Equatable, Sendable {
let command: String
let executableName: String
let verbose: Int
public init(
executableName: String = "bump-version",
command: String,
verbose: Int
) {
self.executableName = executableName
self.command = command
self.verbose = verbose
}
public func withLogger<T>(_ operation: () async throws -> T) async rethrows -> T {
try await withDependencies {
$0.logger = makeLogger()
$0.logger.logLevel = .init(verbose: verbose)
} operation: {
try await operation()
}
}
}
public extension Logger {
func dump<T>(
_ type: T,
level: Level = .trace,
buildMessage: @escaping (String) -> String = { $0 }
) {
var message = ""
customDump(type, to: &message)
log(level: level, "\(buildMessage(message))")
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,210 +0,0 @@
import ArgumentParser
@_spi(Internal) import CliClient
import Dependencies
import Foundation
import Rainbow
struct GlobalOptions<Child: ParsableArguments>: ParsableArguments {
@OptionGroup var targetOptions: TargetOptions
@OptionGroup var child: Child
@Option(
name: .customLong("git-directory"),
help: "The git directory for the version (default: current directory)"
)
var gitDirectory: String?
@Flag(
name: .customLong("dry-run"),
help: "Print's what would be written to a target version file."
)
var dryRun: Bool = false
@Flag(
name: .shortAndLong,
help: "Increase logging level, can be passed multiple times (example: -vvv)."
)
var verbose: Int
}
struct Empty: ParsableArguments {}
struct TargetOptions: ParsableArguments {
@Option(
name: .shortAndLong,
help: "Path to the version file, not required if module is set."
)
var path: String?
@Option(
name: .shortAndLong,
help: "The target module name or directory path, not required if path is set."
)
var module: String?
@Option(
name: [.customShort("n"), .long],
help: "The file name inside the target module, required if module is set."
)
var fileName: String = "Version.swift"
}
struct PreReleaseOptions: ParsableArguments {
@Flag(
name: [.customShort("s"), .customLong("pre-release-branch-style")],
help: """
Use branch name and commit sha for pre-release suffix, ignored if branch is set.
"""
)
var useBranchAsPreRelease: Bool = false
@Flag(
name: [.customShort("g"), .customLong("pre-release-git-tag-style")],
help: """
Use `git describe --tags` for pre-release suffix, ignored if branch is set.
"""
)
var useTagAsPreRelease: Bool = false
@Option(
name: .shortAndLong,
help: """
Apply custom pre-release suffix, can also use branch or tag along with this
option as a prefix, used if branch is not set. (example: \"rc\")
"""
)
var custom: String?
}
struct SemVarOptions: ParsableArguments {
@Flag(
name: .long,
help: """
Fail if an existing version file does not exist, \("ignored if:".yellow.bold) \("branch is set".italic).
"""
)
var requireExistingFile: Bool = false
@Flag(
name: .long,
help: "Fail if a sem-var is not parsed from existing file or git tag, used if branch is not set."
)
var requireExistingSemvar: Bool = false
@OptionGroup var preRelease: PreReleaseOptions
}
typealias GlobalSemVarOptions = GlobalOptions<SemVarOptions>
typealias GlobalBranchOptions = GlobalOptions<Empty>
extension GlobalSemVarOptions {
func shared() throws -> CliClient.SharedOptions {
try shared(.semVar(child.semVarOptions()))
}
}
extension GlobalBranchOptions {
func shared() throws -> CliClient.SharedOptions {
try shared(.branchAndCommit)
}
}
extension CliClient.SharedOptions {
func run(_ keyPath: KeyPath<CliClient, @Sendable (Self) async throws -> String>) async throws {
try await withDependencies {
$0.fileClient = .liveValue
$0.gitClient = .liveValue
$0.cliClient = .liveValue
} operation: {
@Dependency(\.cliClient) var cliClient
let output = try await cliClient[keyPath: keyPath](self)
print(output)
}
}
func run<T>(
_ keyPath: KeyPath<CliClient, @Sendable (T, Self) async throws -> String>,
args: T
) async throws {
try await withDependencies {
$0.fileClient = .liveValue
$0.gitClient = .liveValue
$0.cliClient = .liveValue
} operation: {
@Dependency(\.cliClient) var cliClient
let output = try await cliClient[keyPath: keyPath](args, self)
print(output)
}
}
}
extension GlobalOptions {
func shared(_ versionStrategy: CliClient.VersionStrategy) throws -> CliClient.SharedOptions {
try .init(
dryRun: dryRun,
gitDirectory: gitDirectory,
logLevel: .init(verbose: verbose),
target: targetOptions.target(),
versionStrategy: versionStrategy
)
}
}
// MARK: - Helpers
private extension TargetOptions {
func target() throws -> String {
guard let path else {
guard let module else {
print("Neither target path or module was set.")
throw InvalidTargetOption()
}
return "\(module)/\(fileName)"
}
return path
}
struct InvalidTargetOption: Error {}
}
extension PreReleaseOptions {
func preReleaseStrategy() throws -> CliClient.PreReleaseStrategy? {
guard let custom else {
if useBranchAsPreRelease {
return .branchAndCommit
} else if useTagAsPreRelease {
return .tag
} else {
return nil
}
}
if useBranchAsPreRelease {
return .custom(custom, .branchAndCommit)
} else if useTagAsPreRelease {
return .custom(custom, .tag)
} else {
return .custom(custom, nil)
}
}
}
extension SemVarOptions {
func semVarOptions() throws -> CliClient.VersionStrategy.SemVarOptions {
try .init(
preReleaseStrategy: preRelease.preReleaseStrategy(),
requireExistingFile: requireExistingFile,
requireExistingSemVar: requireExistingSemvar
)
}
}

View File

@@ -1,4 +1,5 @@
@_spi(Internal) import CliClient
import ConfigurationClient
import Dependencies
import FileClient
import Foundation
@@ -14,13 +15,16 @@ struct CliClientTests {
arguments: TestArguments.testCases
)
func testBuild(target: String) async throws {
let template = 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.build(.testOptions(
target: target,
versionStrategy: .semVar(.init(requireExistingFile: false))
versionStrategy: .semvar(requireExistingFile: false)
))
#expect(output == "/baz/Sources/bar/Version.swift")
}
@@ -40,7 +44,9 @@ struct CliClientTests {
#expect(output == "/baz/Sources/bar/Version.swift")
} assert: { string, _ in
#expect(string != nil)
if type != .preRelease {
#expect(string != nil)
}
let typeString = optional ? "String?" : "String"
switch type {
@@ -65,7 +71,7 @@ struct CliClientTests {
@Dependency(\.cliClient) var client
let output = try await client.build(.testOptions(
target: target,
versionStrategy: .semVar(.init(requireExistingFile: false))
versionStrategy: .semvar(requireExistingFile: false)
))
#expect(output == "/baz/Sources/bar/Version.swift")
}
@@ -96,6 +102,8 @@ struct CliClientTests {
$0.fileClient.fileExists = { _ in false }
$0.gitClient = .mock(.tag("1.0.0"))
$0.cliClient = .liveValue
$0.configurationClient = .liveValue
$0.configurationClient.find = { _ in URL(filePath: "/") }
setupDependencies(&$0)
} operation: {
try await operation()
@@ -113,7 +121,7 @@ struct CliClientTests {
}
enum TestArguments {
static let testCases = ["bar", "Sources/bar", "/Sources/bar", "./Sources/bar"]
static let testCases = ["bar", "Sources/bar", "./Sources/bar"]
static let bumpCases = CliClient.BumpOption.allCases.reduce(into: [(CliClient.BumpOption, Bool)]()) {
$0.append(($1, true))
$0.append(($1, false))
@@ -129,15 +137,16 @@ extension CliClient.SharedOptions {
gitDirectory: String? = "/baz",
dryRun: Bool = false,
target: String = "bar",
logLevel: Logger.Level = .trace,
versionStrategy: CliClient.VersionStrategy = .semVar(.init())
versionStrategy: Configuration.VersionStrategy = .semvar(.init())
) -> Self {
.init(
return .init(
dryRun: dryRun,
gitDirectory: gitDirectory,
logLevel: logLevel,
target: target,
versionStrategy: versionStrategy
projectDirectory: gitDirectory,
loggingOptions: .init(command: "test", verbose: 2),
configurationToMerge: .init(
target: .init(module: .init(target)),
strategy: versionStrategy
)
)
}
}

View File

@@ -1,71 +0,0 @@
@_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.asyncShellClient = .liveValue
$0.gitClient = .liveValue
$0.fileClient = .liveValue
}, operation: {
super.invokeTest()
})
}
var gitDir: String {
URL(fileURLWithPath: #file)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.cleanFilePath
}
#if !os(Linux)
func test_live() async throws {
@Dependency(\.gitClient) var versionClient: GitClient
let version = try await versionClient.currentVersion(in: gitDir)
print("VERSION: \(version)")
// can't really have a predictable result for the live client.
XCTAssertNotEqual(version, "blob")
}
#endif
func test_file_client() async throws {
try await withTemporaryDirectory { tmpDir in
@Dependency(\.fileClient) var fileClient
let filePath = tmpDir.appendingPathComponent("blob.txt")
try await fileClient.write(string: "Blob", to: 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

@@ -1,7 +1,9 @@
import ConfigurationClient
@_spi(Internal) import ConfigurationClient
import CustomDump
import Dependencies
import Foundation
import Testing
import TestSupport
@Suite("ConfigurationClientTests")
struct ConfigurationClientTests {
@@ -17,31 +19,120 @@ struct ConfigurationClientTests {
let decoded = try coders.jsonDecoder().decode(Configuration.self, from: encoded)
#expect(decoded == configuration)
let tomlEncoded = try coders.tomlEncoder().encode(configuration)
let tomlDecoded = try coders.tomlDecoder().decode(
Configuration.self,
from: tomlEncoded
)
#expect(tomlDecoded == configuration)
}
}
@Test(arguments: ["foo", ".foo"])
func configurationFile(fileName: String) {
for ext in ["toml", "json", "bar"] {
let file = ConfigruationFile(url: URL(filePath: "\(fileName).\(ext)"))
switch ext {
case "toml":
#expect(file == .toml(URL(filePath: "\(fileName).toml")))
case "json":
#expect(file == .json(URL(filePath: "\(fileName).json")))
default:
#expect(file == nil)
@Test
func writeAndLoad() async throws {
try await withTemporaryDirectory { url in
try await run {
@Dependency(\.configurationClient) var configurationClient
for ext in ["json"] {
let fileUrl = url.appending(path: "test.\(ext)")
let configuration = Configuration.mock()
try await configurationClient.write(configuration, fileUrl)
let loaded = try await configurationClient.load(fileUrl)
#expect(loaded == configuration)
let findAndLoaded = try await configurationClient.findAndLoad(fileUrl)
#expect(findAndLoaded == configuration)
try FileManager.default.removeItem(at: fileUrl)
}
}
}
}
@Test
func findAndLoad() async throws {
try await withTemporaryDirectory { url in
try await run {
@Dependency(\.configurationClient) var configurationClient
let shouldBeNil = try await configurationClient.find(url)
#expect(shouldBeNil == nil)
do {
_ = try await configurationClient.findAndLoad(url)
#expect(Bool(false))
} catch {
#expect(Bool(true))
}
for ext in ["json"] {
let fileUrl = url.appending(path: ".bump-version.\(ext)")
let configuration = Configuration.mock()
try await configurationClient.write(configuration, fileUrl)
let loaded = try await configurationClient.findAndLoad(fileUrl)
#expect(loaded == configuration)
try FileManager.default.removeItem(at: fileUrl)
}
}
}
}
@Test
func mergingBranch() {
let branch = Configuration.Branch(includeCommitSha: false)
let branch2 = Configuration.Branch(includeCommitSha: true)
let merged = branch.merging(branch2)
#expect(merged == branch2)
let merged2 = branch.merging(nil)
#expect(merged2 == branch)
}
@Test
func mergingSemvar() {
let strategy1 = Configuration.VersionStrategy.semvar(.init())
let other = Configuration.VersionStrategy.semvar(.init(
allowPreRelease: true,
preRelease: .init(prefix: "foo", strategy: .gitTag),
requireExistingFile: false,
requireExistingSemVar: false,
strategy: .gitTag()
))
let merged = strategy1.merging(other)
#expect(merged == other)
let otherMerged = other.merging(strategy1)
#expect(otherMerged == other)
}
@Test
func mergingTarget() {
let config1 = Configuration(target: .init(path: "foo"))
let config2 = Configuration(target: .init(module: .init("bar")))
let merged = config1.merging(config2)
#expect(merged.target! == .init(module: .init("bar")))
let merged2 = merged.merging(config1)
#expect(merged2.target! == .init(path: "foo"))
let merged3 = merged2.merging(nil)
#expect(merged3 == merged2)
}
@Test
func mergingVersionStrategy() {
let version = Configuration.VersionStrategy.semvar(.init())
let version2 = Configuration.VersionStrategy.branch(.init())
let merged = version.merging(version2)
#expect(merged == version2)
let merged2 = merged.merging(.branch(includeCommitSha: false))
#expect(merged2.branch!.includeCommitSha == false)
let merged3 = version2.merging(version)
#expect(merged3 == version)
}
func run(
setupDependencies: @escaping (inout DependencyValues) -> Void = { _ in },
operation: () async throws -> Void

View File

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

1
gum Normal file
View File

@@ -0,0 +1 @@
foo,bar,baz table

View File

@@ -1,11 +1,25 @@
product := "cli-version"
docker_image := "cli-version"
product := "bump-version"
docker_image := "bump-version"
docker_tag := "test"
tap_url := "https://git.housh.dev/michael/homebrew-formula"
tap := "michael/formula"
formula := "bump-version"
release_base_url := "https://git.housh.dev/michael/swift-bump-version/archive"
version := "$(git describe --tags --exact-match)"
[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 \
@@ -13,7 +27,8 @@ build configuration="debug":
--configuration {{configuration}} \
--product {{product}}
alias b := build
# Bump our version of the command-line tool.
bump-version *ARGS: (run "bump" ARGS)
# Build a docker image.
build-docker configuration="debug":
@@ -30,7 +45,9 @@ clean:
# Clean and build.
clean-build configuration="debug": clean (build configuration)
alias cb := clean-build
# Remove bottles
remove-bottles:
rm -rf *.gz
# Test locally.
test *ARGS:
@@ -40,5 +57,36 @@ test *ARGS:
test-docker: build-docker
@docker run --rm {{docker_image}}:{{docker_tag}}
# Run tests in docker without building a new image.
test-docker-without-building:
@docker run --rm {{docker_image}}:{{docker_tag}} swift test
# Show the current git-tag version.
echo-version:
@echo "VERSION: {{version}}"
# Get the sha256 sum of the release and copy to clipboard.
get-release-sha prefix="": (build "release")
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"
# Preview the documentation locally.
preview-documentation target="BumpVersion":
swift package \
--disable-sandbox \
preview-documentation \
--target {{target}}
# Preview the documentation locally.
build-documentation dir="./docs" target="BumpVersion" basePath="bump-version":
swift package \
--allow-writing-to-directory {{dir}} \
generate-documentation \
--target {{target}} \
--disable-indexing \
--transform-for-static-hosting \
--hosting-base-path {{basePath}} \
--output-path {{dir}}