49 Commits
0.1.1 ... dev

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

7
.editorconfig Normal file
View File

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

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

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

View File

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

View File

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

1
.gitignore vendored
View File

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

1
.prettierignore Normal file
View File

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

View File

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

11
.swiftformat Normal file
View File

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

11
.swiftlint.yml Normal file
View File

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

View File

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

8
Dockerfile Normal file
View File

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

View File

@@ -1,42 +0,0 @@
PLATFORM_MACOS = macOS
CONFIG := debug
DOCC_TARGET ?= CliVersion
DOCC_BASEPATH = $(shell basename "$(PWD)")
DOCC_DIR ?= ./docs
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:5.7-focal \
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,12 +1,13 @@
{ {
"originHash" : "9fe004cf869b34d1fe07e8b58a90b044281e8e94805df6723d4604ba5a6400d9",
"pins" : [ "pins" : [
{ {
"identity" : "combine-schedulers", "identity" : "combine-schedulers",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/combine-schedulers", "location" : "https://github.com/pointfreeco/combine-schedulers",
"state" : { "state" : {
"revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", "revision" : "9fa31f4403da54855f1e2aeaeff478f4f0e40b13",
"version" : "1.0.0" "version" : "1.0.2"
} }
}, },
{ {
@@ -23,8 +24,17 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser.git", "location" : "https://github.com/apple/swift-argument-parser.git",
"state" : { "state" : {
"revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", "revision" : "41982a3656a71c768319979febd796c6fd111d5c",
"version" : "1.2.3" "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"
} }
}, },
{ {
@@ -32,8 +42,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-clocks", "location" : "https://github.com/pointfreeco/swift-clocks",
"state" : { "state" : {
"revision" : "d1fd837326aa719bee979bdde1f53cd5797443eb", "revision" : "b9b24b69e2adda099a1fa381cda1eeec272d5b53",
"version" : "1.0.0" "version" : "1.0.5"
} }
}, },
{ {
@@ -41,8 +51,17 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras", "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : { "state" : {
"revision" : "ea631ce892687f5432a833312292b80db238186a", "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8",
"version" : "1.0.0" "version" : "1.3.1"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump.git",
"state" : {
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
"version" : "1.3.3"
} }
}, },
{ {
@@ -50,8 +69,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-dependencies.git", "location" : "https://github.com/pointfreeco/swift-dependencies.git",
"state" : { "state" : {
"revision" : "4e1eb6e28afe723286d8cc60611237ffbddba7c5", "revision" : "5526c8a27675dc7b18d6fa643abfb64bcb200b77",
"version" : "1.0.0" "version" : "1.6.2"
} }
}, },
{ {
@@ -59,14 +78,14 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-docc-plugin.git", "location" : "https://github.com/apple/swift-docc-plugin.git",
"state" : { "state" : {
"revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64",
"version" : "1.3.0" "version" : "1.4.3"
} }
}, },
{ {
"identity" : "swift-docc-symbolkit", "identity" : "swift-docc-symbolkit",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-docc-symbolkit", "location" : "https://github.com/swiftlang/swift-docc-symbolkit",
"state" : { "state" : {
"revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
"version" : "1.0.0" "version" : "1.0.0"
@@ -77,8 +96,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git", "location" : "https://github.com/apple/swift-log.git",
"state" : { "state" : {
"revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91",
"version" : "1.5.3" "version" : "1.6.2"
} }
}, },
{ {
@@ -95,8 +114,17 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/m-housh/swift-shell-client.git", "location" : "https://github.com/m-housh/swift-shell-client.git",
"state" : { "state" : {
"revision" : "9c0c4757d0a2e313d1a4a28e60418a753d3e4230", "revision" : "d67f693f92428ef8adecb48c2b26ccac0d2f98cb",
"version" : "0.1.4" "version" : "0.2.2"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "0687f71944021d616d34d922343dcef086855920",
"version" : "600.0.1"
} }
}, },
{ {
@@ -104,10 +132,10 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : { "state" : {
"revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", "revision" : "a3f634d1a409c7979cabc0a71b3f26ffa9fc8af1",
"version" : "1.0.2" "version" : "1.4.3"
} }
} }
], ],
"version" : 2 "version" : 3
} }

View File

@@ -1,76 +1,119 @@
// swift-tools-version: 5.6 // swift-tools-version: 6.0
import PackageDescription import PackageDescription
let package = Package( let package = Package(
name: "swift-cli-version", name: "swift-cli-version",
platforms: [ platforms: [
.macOS(.v10_15) .macOS(.v13)
], ],
products: [ products: [
.library(name: "CliVersion", targets: ["CliVersion"]), .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: "BuildWithVersionPlugin", targets: ["BuildWithVersionPlugin"]),
.plugin(name: "GenerateVersionPlugin", targets: ["GenerateVersionPlugin"]), .plugin(name: "BumpVersionPlugin", targets: ["BumpVersionPlugin"])
.plugin(name: "UpdateVersionPlugin", targets: ["UpdateVersionPlugin"])
], ],
dependencies: [ dependencies: [
.package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.1.3"), .package(url: "https://github.com/pointfreeco/swift-dependencies.git", from: "1.6.2"),
.package(url: "https://github.com/m-housh/swift-shell-client.git", from: "0.2.2"),
.package(url: "https://github.com/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-docc-plugin.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.2") .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.6.2"),
.package(url: "https://github.com/pointfreeco/swift-custom-dump.git", from: "1.3.3")
], ],
targets: [ targets: [
.executableTarget( .executableTarget(
name: "cli-version", name: "BumpVersion",
dependencies: [ dependencies: [
"CliVersion", "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( .target(
name: "CliVersion", name: "CliClient",
dependencies: [ dependencies: [
.product(name: "ShellClient", package: "swift-shell-client") "ConfigurationClient",
"FileClient",
"GitClient",
"LoggingExtensions",
.product(name: "Logging", package: "swift-log"),
.product(name: "CustomDump", package: "swift-custom-dump")
] ]
), ),
.testTarget( .testTarget(
name: "CliVersionTests", name: "CliVersionTests",
dependencies: ["CliVersion"] dependencies: ["CliClient", "TestSupport"]
), ),
.target(
name: "ConfigurationClient",
dependencies: [
"FileClient",
"LoggingExtensions",
.product(name: "CustomDump", package: "swift-custom-dump"),
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies")
]
),
.testTarget(
name: "ConfigurationClientTests",
dependencies: ["ConfigurationClient", "TestSupport"]
),
.target(
name: "FileClient",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies")
]
),
.target(
name: "GitClient",
dependencies: [
"FileClient",
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "ShellClient", package: "swift-shell-client")
]
),
.testTarget(
name: "GitClientTests",
dependencies: ["GitClient"]
),
.target(
name: "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( .plugin(
name: "BuildWithVersionPlugin", name: "BuildWithVersionPlugin",
capability: .buildTool(), capability: .buildTool(),
dependencies: [ dependencies: [
"cli-version" "BumpVersion"
] ]
), ),
.plugin( .plugin(
name: "GenerateVersionPlugin", name: "BumpVersionPlugin",
capability: .command( capability: .command(
intent: .custom( intent: .custom(
verb: "generate-version", verb: "bump-version",
description: "Generates a version file in the given target." description: "Bumps 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."
), ),
permissions: [ permissions: [
.writeToPackageDirectory(reason: "Update a version file in the target's directory.") .writeToPackageDirectory(reason: "Update a version file in the target's directory.")
] ]
), ),
dependencies: [ dependencies: [
"cli-version" "BumpVersion"
] ]
) )
] ]

View File

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

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,30 +0,0 @@
import PackagePlugin
import Foundation
@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 = URL(fileURLWithPath: gitVersion.path.string)
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 PackagePlugin
import Foundation
@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 = URL(fileURLWithPath: gitVersion.path.string)
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. // Do not set this variable, it is set during the build process.
let VERSION: String? = "0.1.0" let VERSION: String? = "0.2.0-rc-5-ga26d869"

View File

@@ -0,0 +1,104 @@
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 }
}
}
/// Handles the command-line commands.
@DependencyClient
public struct CliClient: Sendable {
/// Build and update the version based on the git tag, or branch + sha.
public var build: @Sendable (SharedOptions) async throws -> String
/// Bump the existing version.
public var bump: @Sendable (BumpOption, SharedOptions) async throws -> String
/// Generate a version file with an optional version that can be set manually.
public var generate: @Sendable (SharedOptions) async throws -> String
/// Parse the configuration options.
public var parsedConfiguration: @Sendable (SharedOptions) async throws -> Configuration
public enum BumpOption: Sendable, CaseIterable {
case major, minor, patch, preRelease
}
/// 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
/// 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,
projectDirectory: String? = nil,
loggingOptions: LoggingOptions,
configurationToMerge: Configuration? = nil,
configurationFile: String? = nil,
requireConfigurationFile: Bool = false
) {
self.allowPreReleaseTag = allowPreReleaseTag
self.dryRun = dryRun
self.projectDirectory = projectDirectory
self.loggingOptions = loggingOptions
self.configurationFile = configurationFile
self.configurationToMerge = configurationToMerge
self.requireConfigurationFile = requireConfigurationFile
}
}
}
extension CliClient: DependencyKey {
public static let testValue: CliClient = Self()
public static func live(environment: [String: String]) -> Self {
.init(
build: { try await $0.build(environment) },
bump: { try await $1.bump($0) },
generate: { try await $0.generate() },
parsedConfiguration: { options in
try await options.loggingOptions.withLogger {
try await options.withMergedConfiguration { $0 }
}
}
)
}
public static var liveValue: CliClient {
.live(environment: ProcessInfo.processInfo.environment)
}
}

View File

@@ -0,0 +1,12 @@
import ConfigurationClient
enum CliClientError: Error {
case gitDirectoryNotFound
case fileExists(path: String)
case fileDoesNotExist(path: String)
case failedToParseVersionFile
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 Use the plugins by including as a package to your project and declaring in the `plugins` section of
your target. your target.
> Note: You must use swift-tools version 5.6 or greater for package plugins and > Note: You must use swift-tools version 5.6 or greater for package plugins and target `macOS(.v13)`
> target `macOS(.v10_15)` or greater. > or greater.
```swift ```swift
// swift-tools-version: 5.7 // swift-tools-version: 5.10
import PackageDescription import PackageDescription
let package = Package( let package = Package(
platforms:[.macOS(.v10_15)], platforms:[.macOS(.v13)],
dependencies: [ 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: [ targets: [
.executableTarget( .executableTarget(
name: "<target name>", name: "<target name>",
dependencies: [...], dependencies: [...],
plugins: [ plugins: [
.plugin(name: "BuildWithVersionPlugin", package: "swift-cli-version") .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 The above example uses the build tool plugin. The `BuildWithVersionPlugin` will give you access to a
to a `VERSION` variable in your project that you can use to supply the version of the tool. `VERSION` variable in your project that you can use to supply the version of the tool.
### Example ### Example
@@ -42,27 +42,27 @@ to a `VERSION` variable in your project that you can use to supply the version o
import ArgumentParser import ArgumentParser
@main @main
struct MyCliTool: ParsableCommand { struct MyCliTool: ParsableCommand {
static let configuration = CommandConfiguration( static let configuration = CommandConfiguration(
abstract: "My awesome cli tool", abstract: "My awesome cli tool",
version: VERSION version: VERSION
) )
func run() throws { func run() throws {
print("Version: \(VERSION)") 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. not declared in your source files.
![Trust & Enable](trust) ![Trust & Enable](trust)
> Note: If your `DerivedData` folder lives in a directory that is a mounted volume / or somewhere > 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 > that is not under your home folder then you may get build failures using the build tool plugin, it
> plugin, it will work if you build from the command line and pass the `--disable-sandbox` flag to the > will work if you build from the command line and pass the `--disable-sandbox` flag to the build
> build command or use one of the manual methods. > command or use one of the manual methods.
## See Also ## See Also

View File

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

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 206 KiB

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

@@ -0,0 +1,72 @@
import Dependencies
import FileClient
import Foundation
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,
projectDirectory: String?
) async throws -> (version: String, usesOptionalType: Bool) {
@Dependency(\.gitClient) var gitClient
@Dependency(\.logger) var logger
let targetUrl = fileUrl
guard fileExists(targetUrl) else {
throw CliClientError.fileDoesNotExist(path: fileUrl.cleanFilePath)
}
let contents = try await read(targetUrl)
let versionLine = contents.split(separator: "\n")
.first { $0.hasPrefix("let VERSION:") }
guard let versionLine else {
throw CliClientError.failedToParseVersionFile
}
logger.debug("Version line: \(versionLine)")
let isOptional = versionLine.contains("String?")
logger.trace("Uses optional: \(isOptional)")
let versionString = versionLine.split(separator: "let VERSION: \(isOptional ? "String?" : "String") = ").last
guard let versionString else {
throw CliClientError.failedToParseVersionFile
}
logger.trace("Parsed version string: \(versionString)")
return (String(versionString), isOptional)
}
}

View File

@@ -0,0 +1,137 @@
import Dependencies
import FileClient
import Foundation
import GitClient
// Container for sem-var version.
@_spi(Internal)
public struct SemVar: CustomStringConvertible, Equatable, Sendable {
/// The major version.
public let major: Int
/// The minor version.
public let minor: Int
/// The patch version.
public let patch: Int
/// Extra pre-release tag.
public let preRelease: String?
public init(
major: Int,
minor: Int,
patch: Int,
preRelease: String? = nil
) {
self.major = major
self.minor = minor
self.patch = patch
self.preRelease = preRelease
}
public init(preRelease: String? = nil) {
self.init(
major: 0,
minor: 0,
patch: 0,
preRelease: preRelease
)
}
public init?(string: String) {
@Dependency(\.logger) var logger
logger.trace("Parsing semvar from: \(string)")
let parts = string.split(separator: ".")
logger.trace("parts: \(parts)")
guard parts.count >= 3 else {
return nil
}
let major = Int(String(parts[0].replacingOccurrences(of: "\"", with: "")))
logger.trace("major: \(String(describing: major))")
let minor = Int(String(parts[1]))
logger.trace("minor: \(String(describing: minor))")
let patchParts = parts[2].replacingOccurrences(of: "\"", with: "").split(separator: "-")
logger.trace("patchParts: \(String(describing: patchParts))")
let patch = Int(patchParts.first ?? "0")
logger.trace("patch: \(String(describing: patch))")
let preRelease = String(patchParts.dropFirst().joined(separator: "-"))
logger.trace("preRelease: \(String(describing: preRelease))")
self.init(
major: major ?? 0,
minor: minor ?? 0,
patch: patch ?? 0,
preRelease: preRelease
)
}
public var description: String { versionString() }
// Create a version string, optionally appending a suffix.
public func versionString(withPreReleaseTag: Bool = true) -> String {
let string = "\(major).\(minor).\(patch)"
guard withPreReleaseTag else { return string }
guard let suffix = preRelease, suffix.count > 0 else {
return string
}
if !suffix.hasPrefix("-") {
return "\(string)-\(suffix)"
}
return "\(string)\(suffix)"
}
// Bumps the sem-var by the given option (major, minor, patch)
public func bump(_ option: CliClient.BumpOption, preRelease: String? = nil) -> Self {
switch option {
case .major:
return .init(
major: major + 1,
minor: 0,
patch: 0,
preRelease: preRelease
)
case .minor:
return .init(
major: major,
minor: minor + 1,
patch: 0,
preRelease: preRelease
)
case .patch:
return .init(
major: major,
minor: minor,
patch: patch + 1,
preRelease: preRelease
)
case .preRelease:
guard let preRelease else { return self }
return applyingPreRelease(preRelease)
}
}
public func applyingPreRelease(_ preRelease: String) -> Self {
.init(
major: major,
minor: minor,
patch: patch,
preRelease: preRelease
)
}
}
@_spi(Internal)
public extension GitClient.Version {
var semVar: SemVar? {
.init(string: description)
}
}

View File

@@ -0,0 +1,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

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

View File

@@ -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,23 +0,0 @@
# ``CliVersion``
Derive a version for a command line tool from git tags or a git sha.
## Additional Resources
[Github Repo](https://github.com/m-housh/swift-cli-version)
## Overview
This tool exposes several plugins that can be used to derive a version for a command line program at
build time or by manually running the plugin. The version is derived from git tags and falling back to
the branch and git sha if a tag is not set for the current worktree state.
## Articles
- <doc:GettingStarted>
- <doc:ManualPlugins>
## Api
- ``FileClient``
- ``GitVersionClient``

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -0,0 +1,329 @@
import CustomDump
import Foundation
// 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.
///
///
public struct Configuration: Codable, Equatable, Sendable {
/// The target for the version.
public let target: Target?
/// The strategy used for deriving the version.
public let strategy: VersionStrategy?
public init(
target: Target? = nil,
strategy: VersionStrategy? = .semvar(.init())
) {
self.target = target
self.strategy = strategy
}
public static var `default`: Self { .mock(module: "<my-tool>") }
public static func mock(module: String = "cli-version") -> Self {
.init(
target: .init(module: .init(module)),
strategy: .semvar(.init(strategy: .gitTag(exactMatch: false)))
)
}
public static var customPreRelease: Self {
.init(
target: .init(module: .init("cli-version")),
strategy: .semvar(.init(
preRelease: .init(prefix: "rc", strategy: .branch())
))
)
}
}
public extension Configuration {
/// Represents the target where we will bump the version in.
///
/// This can either be a path to a version file or a module used to
/// locate the version file.
struct Target: Codable, Equatable, Sendable, CustomDumpReflectable {
/// The path to a version file.
public let path: String?
/// A module to find the version file in.
public let module: Module?
/// Create a target for the given path.
///
/// - Parameters:
/// - path: The path to the version file.
public init(path: String) {
self.path = path
self.module = nil
}
/// Create a target for the given module.
///
/// - Parameters:
/// - module: The module for the version file.
public init(module: Module) {
self.path = nil
self.module = module
}
/// Represents a module target for a version file.
///
public struct Module: Codable, Equatable, Sendable, CustomDumpReflectable {
/// The module directory name.
public let name: String
/// The version file name located in the module directory.
public let fileName: String?
/// Create a new module target.
///
/// - Parameters:
/// - name: The module directory name.
/// - fileName: The file name located in the module directory.
public init(
_ name: String,
fileName: String? = nil
) {
self.name = name
self.fileName = fileName
}
public var fileNameOrDefault: String {
fileName ?? "Version.swift"
}
public var customDumpMirror: Mirror {
.init(
self,
children: [
"name": name,
"fileName": fileNameOrDefault
],
displayStyle: .struct
)
}
}
public var customDumpMirror: Mirror {
guard let module else {
guard let path else { return .init(reflecting: self) }
return .init(
self,
children: [
"path": path
],
displayStyle: .struct
)
}
return .init(
self,
children: [
"module": module
],
displayStyle: .struct
)
}
}
/// Strategy used to generate a version.
///
/// Typically a `SemVar` strategy or `Branch`.
///
///
enum VersionStrategy: Codable, Equatable, Sendable, CustomDumpReflectable {
case branch(includeCommitSha: Bool = true)
case semvar(
allowPreRelease: Bool? = nil,
precedence: SemVar.Precedence? = nil,
preRelease: PreRelease? = nil,
requireExistingFile: Bool? = nil,
requireExistingSemVar: Bool? = nil,
strategy: SemVar.Strategy? = nil
)
public var branch: Branch? {
guard case let .branch(includeCommitSha) = self
else { return nil }
return .init(includeCommitSha: includeCommitSha)
}
public var semvar: SemVar? {
guard case let .semvar(
allowPreRelease,
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:
/// - 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

@@ -0,0 +1,133 @@
import Dependencies
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.
var configurationClient: ConfigurationClient {
get { self[ConfigurationClient.self] }
set { self[ConfigurationClient.self] = newValue }
}
}
/// Handles interactions with configuration files.
@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 -> URL?
/// Load a configuration file.
public var load: @Sendable (URL) async throws -> Configuration
/// Write a configuration file.
public var write: @Sendable (Configuration, URL) async throws -> Void
/// Find a configuration file and load it if found.
///
/// - 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
}
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))
}
}
extension ConfigurationClient: DependencyKey {
public static let testValue: ConfigurationClient = Self()
public static var liveValue: ConfigurationClient {
.init(
defaultFileName: { "\(Constants.defaultFileNameWithoutExtension).json" },
find: { try await findConfiguration($0) },
load: { try await loadConfiguration($0) },
write: { try await writeConfiguration($0, to: $1) }
)
}
}
private func findConfiguration(_ url: URL?) async throws -> URL? {
@Dependency(\.fileClient) var fileClient
var url: URL! = url
if url == nil {
url = try await URL(filePath: fileClient.currentDirectory())
}
if try await fileClient.isDirectory(url.cleanFilePath) {
url = url.appending(path: ConfigurationClient.Constants.defaultFileName)
}
if fileClient.fileExists(url) {
return url
}
return nil
}
private func loadConfiguration(_ url: URL) async throws -> Configuration {
@Dependency(\.coders.jsonDecoder) var jsonDecoder
@Dependency(\.fileClient) var fileClient
let string = try await fileClient.read(url.cleanFilePath)
return try jsonDecoder().decode(Configuration.self, from: Data(string.utf8))
}
enum ConfigurationClientError: Error {
case configurationNotFound
case invalidConfigurationDirectory(path: String)
}
private func writeConfiguration(_ configuration: Configuration, to url: URL) async throws {
@Dependency(\.fileClient) var fileClient
@Dependency(\.coders.jsonEncoder) var jsonEncoder
let data = try jsonEncoder().encode(configuration)
try await fileClient.write(data, url)
}

View File

@@ -0,0 +1,4 @@
import Foundation
extension ConfigurationClient {
}

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -0,0 +1,13 @@
import Logging
@_spi(Internal)
public extension Logger.Level {
init(verbose: Int) {
switch verbose {
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

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

View File

@@ -1,50 +0,0 @@
import ArgumentParser
import Foundation
import CliVersion
import ShellClient
extension CliVersionCommand {
struct Build: ParsableCommand {
static var configuration: CommandConfiguration = .init(
abstract: "Used for the build with version plugin.",
discussion: "This should generally not be interacted with directly, outside of the build plugin."
)
@OptionGroup var shared: SharedOptions
@Option(
name: .customLong("git-directory"),
help: "The git directory for the version."
)
var gitDirectory: String
func run() throws {
try withDependencies {
$0.logger.logLevel = .debug
$0.fileClient = .liveValue
$0.gitVersionClient = .liveValue
} operation: {
@Dependency(\.gitVersionClient) var gitVersion
@Dependency(\.fileClient) var fileClient
@Dependency(\.logger) var logger: Logger
logger.info("Building with git-directory: \(gitDirectory)")
let fileUrl = URL(fileURLWithPath: shared.target)
.appendingPathComponent(shared.fileName)
let fileString = fileUrl.fileString()
logger.info("File Url: \(fileString)")
let currentVersion = try gitVersion.currentVersion(in: gitDirectory)
let fileContents = buildTemplate
.replacingOccurrences(of: "nil", with: "\"\(currentVersion)\"")
try fileClient.write(string: fileContents, to: fileUrl)
logger.info("Updated version file: \(fileString)")
}
}
}
}

View File

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

View File

@@ -1,46 +0,0 @@
import ArgumentParser
import Dependencies
import Foundation
import CliVersion
import ShellClient
extension CliVersionCommand {
struct Generate: ParsableCommand {
static var configuration: CommandConfiguration = .init(
abstract: "Generates a version file in a command line tool that can be set via the git tag or git sha.",
discussion: "This command can be interacted with directly, outside of the plugin usage context.",
version: VERSION ?? "0.0.0"
)
@OptionGroup var shared: SharedOptions
func run() throws {
@Dependency(\.logger) var logger: Logger
@Dependency(\.fileClient) var fileClient
let targetUrl = parseTarget(shared.target)
let fileUrl = targetUrl.appendingPathComponent(shared.fileName)
let fileString = fileUrl.fileString()
guard !FileManager.default.fileExists(atPath: fileUrl.absoluteString) else {
logger.info("File already exists at path.")
throw GenerationError.fileExists(path: fileString)
}
if !shared.dryRun {
try fileClient.write(string: optionalTemplate, to: fileUrl)
logger.info("Generated file at: \(fileString)")
} else {
logger.info("Would generate file at: \(fileString)")
}
}
}
}
fileprivate enum GenerationError: Error {
case fileExists(path: String)
}

View File

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

View File

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

View File

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

View File

@@ -1,102 +0,0 @@
import XCTest
import CliVersion
import ShellClient
final class GitVersionTests: XCTestCase {
override func invokeTest() {
withDependencies({
$0.logger.logLevel = .debug
$0.logger = .liveValue
$0.shellClient = .liveValue
$0.gitVersionClient = .liveValue
$0.fileClient = .liveValue
}, operation: {
super.invokeTest()
})
}
var gitDir: String {
URL(fileURLWithPath: #file)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
.absoluteString
.replacingOccurrences(of: "file://", with: "")
}
func test_overrides_work() throws {
try withDependencies {
$0.gitVersionClient.override(with: "blob")
} operation: {
@Dependency(\.gitVersionClient) var versionClient
let version = try versionClient.currentVersion()
XCTAssertEqual(version, "blob")
}
}
func test_live() throws {
@Dependency(\.gitVersionClient) var versionClient: GitVersionClient
let version = try versionClient.currentVersion(in: gitDir)
print("VERSION: \(version)")
// can't really have a predictable result for the live client.
XCTAssertNotEqual(version, "blob")
}
func test_commands() throws {
@Dependency(\.shellClient) var shellClient: ShellClient
XCTAssertNoThrow(
try shellClient.background(
.gitCurrentBranch(gitDirectory: gitDir),
trimmingCharactersIn: .whitespacesAndNewlines
)
)
XCTAssertNoThrow(
try shellClient.background(
.gitCurrentSha(gitDirectory: gitDir),
trimmingCharactersIn: .whitespacesAndNewlines
)
)
}
func test_file_client() throws {
@Dependency(\.fileClient) var fileClient
let tmpDir = FileManager.default.temporaryDirectory
.appendingPathComponent("file-client-test")
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
defer { try! FileManager.default.removeItem(at: tmpDir) }
let filePath = tmpDir.appendingPathComponent("blob.txt")
try fileClient.write(string: "Blob", to: filePath)
let contents = try String(contentsOf: filePath)
.trimmingCharacters(in: .whitespacesAndNewlines)
XCTAssertEqual(contents, "Blob")
}
func test_file_client_with_string_path() throws {
@Dependency(\.fileClient) var fileClient
let tmpDir = FileManager.default.temporaryDirectory
.appendingPathComponent("file-client-string-test")
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
defer { try! FileManager.default.removeItem(at: tmpDir) }
let filePath = tmpDir.appendingPathComponent("blob.txt")
let fileString = filePath.absoluteString.replacingOccurrences(of: "file://", with: "")
try fileClient.write(string: "Blob", to: fileString)
let contents = try String(contentsOf: filePath)
.trimmingCharacters(in: .whitespacesAndNewlines)
XCTAssertEqual(contents, "Blob")
}
}

View File

@@ -0,0 +1,149 @@
@_spi(Internal) import ConfigurationClient
import CustomDump
import Dependencies
import Foundation
import Testing
import TestSupport
@Suite("ConfigurationClientTests")
struct ConfigurationClientTests {
@Test
func codable() async throws {
try await run {
@Dependency(\.configurationClient) var configurationClient
@Dependency(\.coders) var coders
let configuration = Configuration.customPreRelease
let encoded = try coders.jsonEncoder().encode(configuration)
let decoded = try coders.jsonDecoder().decode(Configuration.self, from: encoded)
#expect(decoded == configuration)
}
}
@Test
func writeAndLoad() async throws {
try await withTemporaryDirectory { url in
try await run {
@Dependency(\.configurationClient) var configurationClient
for ext in ["json"] {
let fileUrl = url.appending(path: "test.\(ext)")
let configuration = Configuration.mock()
try await configurationClient.write(configuration, fileUrl)
let loaded = try await configurationClient.load(fileUrl)
#expect(loaded == configuration)
let findAndLoaded = try await configurationClient.findAndLoad(fileUrl)
#expect(findAndLoaded == configuration)
try FileManager.default.removeItem(at: fileUrl)
}
}
}
}
@Test
func findAndLoad() async throws {
try await withTemporaryDirectory { url in
try await run {
@Dependency(\.configurationClient) var configurationClient
let shouldBeNil = try await configurationClient.find(url)
#expect(shouldBeNil == nil)
do {
_ = try await configurationClient.findAndLoad(url)
#expect(Bool(false))
} catch {
#expect(Bool(true))
}
for ext in ["json"] {
let fileUrl = url.appending(path: ".bump-version.\(ext)")
let configuration = Configuration.mock()
try await configurationClient.write(configuration, fileUrl)
let loaded = try await configurationClient.findAndLoad(fileUrl)
#expect(loaded == configuration)
try FileManager.default.removeItem(at: fileUrl)
}
}
}
}
@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
) async throws {
try await withDependencies {
$0.coders = .liveValue
$0.fileClient = .liveValue
$0.configurationClient = .liveValue
setupDependencies(&$0)
} operation: {
try await operation()
}
}
}

View File

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

1
gum Normal file
View File

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

92
justfile Normal file
View File

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