From 10d0834212fe4d3d3278d48c3328426d47c9f5d9 Mon Sep 17 00:00:00 2001 From: Andrew McKnight Date: Sun, 9 Aug 2026 23:56:57 -0400 Subject: [PATCH] Migrate from ShellKit to swift-subprocess; make the API async-only binarybirds/shell-kit was deleted from GitHub. `swift build` can no longer resolve it, so a fresh checkout of main fails for everyone. Rather than vendor a copy of Shell.swift, this moves GitKit onto swiftlang/swift-subprocess, the official async process API. Git no longer subclasses Shell; it owns its own execution primitive. This is a breaking change, so it warrants a major version: - run() is async throws. The synchronous variant and the completion-handler variant are both removed. - Shell.Error becomes Git.Error, with the same outputData and generic(Int, String) cases. - path, verbose, type and env move onto Git unchanged; maxOutputSize is new (16MB default). The macOS-only outputHandler/errorHandler streaming hooks are gone. - platforms is now macOS 13, swift-subprocess's floor. Execution semantics are preserved deliberately: commands still run through /bin/sh -c, so `cd "path" && git ...` composition, .raw() strings and the shell quoting in .commit all behave exactly as before. The Alias and Command enums are untouched, so the only change at a call site is the await. Tests are converted to async/await; all 23 still pass. LinuxMain and XCTestManifests are removed since they cannot express async tests. --- Package.swift | 8 +- README.md | 67 ++-- Sources/GitKit/Git.swift | 122 ++++++-- Tests/GitKitTests/GitKitTests.swift | 398 +++++++++++------------- Tests/GitKitTests/XCTestManifests.swift | 17 - Tests/LinuxMain.swift | 14 - 6 files changed, 335 insertions(+), 291 deletions(-) delete mode 100644 Tests/GitKitTests/XCTestManifests.swift delete mode 100644 Tests/LinuxMain.swift diff --git a/Package.swift b/Package.swift index 78139f6..9e43bc1 100644 --- a/Package.swift +++ b/Package.swift @@ -1,17 +1,19 @@ -// swift-tools-version:5.10 +// swift-tools-version: 6.1 import PackageDescription let package = Package( name: "git-kit", + // swift-subprocess declares a floor of macOS 13. + platforms: [.macOS(.v13)], products: [ .library(name: "GitKit", targets: ["GitKit"]), ], dependencies: [ - .package(url: "https://github.com/binarybirds/shell-kit", from: "1.0.0"), + .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"), ], targets: [ .target(name: "GitKit", dependencies: [ - .product(name: "ShellKit", package: "shell-kit"), + .product(name: "Subprocess", package: "swift-subprocess"), ]), .testTarget(name: "GitKitTests", dependencies: ["GitKit"]), ] diff --git a/README.md b/README.md index b9a8578..db231ed 100644 --- a/README.md +++ b/README.md @@ -4,41 +4,50 @@ GitKit is a Swift wrapper around the git command line interface. ## Usage -Some basic examples: +Every command is `async`. Some basic examples: ```swift import GitKit -try Git().run(.cmd(.config, "--global user.name")) +try await Git().run(.cmd(.config, "--global user.name")) let git = Git(path: "~/example/") -try git.run(.cmd(.initialize)) -try git.run(.cmd(.status)) -try git.run(.cmd(.branch, "-a")) -try git.run(.cmd(.pull)) - -try git.run(.clone(url: "https://gitlab.com/binarybirds/shell-kit.git")) -try git.run(.commit(message: "some nasty bug fixed")) -try git.run(.log(1)) -try git.run(.tag("1.0.0")) -try git.run(.pull(remote: "origin", branch: "master")) -try git.run(.push(remote: "origin", branch: "master")) -try git.run(.create(branch: "dev")) -try git.run(.checkout(branch: "master")) -try git.run(.merge(branch: "dev")) +try await git.run(.cmd(.initialize)) +try await git.run(.cmd(.status)) +try await git.run(.cmd(.branch, "-a")) +try await git.run(.cmd(.pull)) + +try await git.run(.clone(url: "https://github.com/armcknight/git-kit.git")) +try await git.run(.commit(message: "some nasty bug fixed")) +try await git.run(.log(numberOfCommits: 1)) +try await git.run(.tag("1.0.0")) +try await git.run(.pull(remote: "origin", branch: "main")) +try await git.run(.push(remote: "origin", branch: "main")) +try await git.run(.create(branch: "dev")) +try await git.run(.checkout(branch: "main")) +try await git.run(.merge(branch: "dev")) + +try await git.run(.raw("log -2")) +try await git.run(.raw("rebase -i ")) +``` -try git.run(.raw("log -2")) -try git.run(.raw("rebase -i ")) +Commands are run through a shell (`/bin/sh` by default), so a raw command may +chain with `&&` exactly as it would at a prompt: +```swift +try await git.run("cd /some/path && git status") ``` +Failures throw `Git.Error.generic(exitCode, stderr)`; output that cannot be +decoded throws `Git.Error.outputData`. + ## Install Just use the Swift Package Manager as usual: ```swift -.package(url: "https://github.com/binarybirds/git-kit", from: "1.0.0"), +.package(url: "https://github.com/armcknight/git-kit", from: "2.0.0"), ``` Don't forget to add "GitKit" to your target as a dependency: @@ -49,6 +58,26 @@ Don't forget to add "GitKit" to your target as a dependency: That's it. +## Migrating from 1.x + +2.0.0 is a breaking release. + +- **Async only.** `Git.run` is now `async throws`. The synchronous variant and + the completion-handler variant (`run(_:completion:)`) are both gone. Call + sites need `try await`. +- **No more ShellKit.** `Git` no longer subclasses `ShellKit.Shell`; commands + run on [swift-subprocess](https://github.com/swiftlang/swift-subprocess). + `binarybirds/shell-kit` was deleted from GitHub, so 1.x can no longer be + resolved on a fresh checkout at all. +- **Errors moved.** `Shell.Error` is now `Git.Error`, with the same + `outputData` and `generic(Int, String)` cases. +- **`Shell` members moved onto `Git`.** `path`, `verbose`, `type`, and `env` + are unchanged; `maxOutputSize` is new (16MB default). The macOS-only + `outputHandler` / `errorHandler` streaming hooks are gone. +- **Platform floor.** macOS 13, inherited from swift-subprocess. + +The `Alias` and `Command` enums are unchanged, so command construction is +identical apart from the `await`. ## License diff --git a/Sources/GitKit/Git.swift b/Sources/GitKit/Git.swift index 25f5e26..08ca543 100644 --- a/Sources/GitKit/Git.swift +++ b/Sources/GitKit/Git.swift @@ -6,10 +6,29 @@ Copyright Binary Birds. All rights reserved. */ -import ShellKit +import Foundation +import Subprocess +import System /// a Git wrapper class -public final class Git: Shell { +public final class Git { + + /// git errors + public enum Error: LocalizedError { + /// invalid shell output data error + case outputData + /// generic error, the first parameter is the error code, the second is the error message + case generic(Int, String) + + public var errorDescription: String? { + switch self { + case .outputData: + return "Invalid or empty shell output." + case .generic(let code, let message): + return message + " (code: \(code))" + } + } + } /// Git aliases to make the API more convenient public enum Alias { @@ -263,8 +282,9 @@ public final class Git: Shell { case lsRemote = "ls-remote" } + // MARK: - private helper methods - + /** This method helps to assemble a Git command string from an alias @@ -273,7 +293,6 @@ public final class Git: Shell { - Parameters: - alias: The git alias to be executed - - args: Additional arguments for the Git alias - Returns: The Git command */ @@ -292,7 +311,7 @@ public final class Git: Shell { cmd += ["cd", quotedPath, "&&"] } cmd += ["git", alias.rawValue] - + let command = cmd.joined(separator: " ") if self.verbose { @@ -300,7 +319,7 @@ public final class Git: Shell { } return command } - + // MARK: - public api /// work directory, if peresent a directory change will occur before running any Git commands @@ -308,10 +327,19 @@ public final class Git: Shell { /// NOTE: if the git init command is called with a non-existing path, directories /// presented in the path string will be created recursively public var path: String? - + // prints git commands constructed from the alias before execution public var verbose = false - + + /// the shell used to interpret commands, by default: /bin/sh + public var type: String + + /// custom env variables exposed to the shell + public var env: [String: String] + + /// maximum number of bytes collected from the shell's standard output and standard error + public var maxOutputSize: Int + /** Initializes a new Git object @@ -319,12 +347,19 @@ public final class Git: Shell { - path: The path of the Swift package (work directory) - type: The type of the shell, default: /bin/sh - env: Additional environment variables for the shell, default: empty + - maxOutputSize: Maximum bytes to collect from stdout and stderr, default: 16MB */ - public init(path: String? = nil, type: String = "/bin/sh", env: [String: String] = [:]) { + public init( + path: String? = nil, + type: String = "/bin/sh", + env: [String: String] = [:], + maxOutputSize: Int = 16 * 1024 * 1024 + ) { self.path = path - - super.init(type, env: env) + self.type = type + self.env = env + self.maxOutputSize = maxOutputSize } /** @@ -334,27 +369,70 @@ public final class Git: Shell { - alias: The git command alias to be executed - Throws: - `ShellError.outputData` if the command execution succeeded but the output is empty, - otherwise `ShellError.generic(Int, String)` where the first parameter is the exit code, - the second is the error message + `Git.Error.outputData` if the command execution succeeded but the output could not be + decoded, otherwise `Git.Error.generic(Int, String)` where the first parameter is the + exit code, the second is the error message - Returns: The output string of the command without trailing newlines */ @discardableResult - public func run(_ alias: Alias) throws -> String { - try self.run(self.rawCommand(alias)) + public func run(_ alias: Alias) async throws -> String { + try await self.run(self.rawCommand(alias)) } /** - Async version of the run function + Runs a raw command through the current shell. - Parameters: - - alias: The git command alias to be executed - - completion: The completion block with the output and error + - command: The command to be executed - The command will be executed on a concurrent dispatch queue. + - Throws: + `Git.Error.outputData` if the command execution succeeded but the output could not be + decoded, otherwise `Git.Error.generic(Int, String)` where the first parameter is the + exit code, the second is the error message + + - Returns: The output string of the command without trailing newlines */ - public func run(_ alias: Alias, completion: @escaping ((String?, Swift.Error?) -> Void)) { - self.run(self.rawCommand(alias), completion: completion) + @discardableResult + public func run(_ command: String) async throws -> String { + var overrides: [Environment.Key: String?] = [:] + for (key, value) in self.env { + guard let key = Environment.Key(rawValue: key) else { continue } + overrides[key] = value + } + let environment: Environment = overrides.isEmpty + ? .inherit + : .inherit.updating(overrides) + + let result = try await Subprocess.run( + .path(FilePath(self.type)), + arguments: Arguments(["-c", command]), + environment: environment, + output: .string(limit: self.maxOutputSize), + error: .string(limit: self.maxOutputSize) + ) + + guard result.terminationStatus.isSuccess else { + let code: Int + switch result.terminationStatus { + case .exited(let status): code = Int(status) + case .signaled(let signal): code = Int(signal) + } + let message = result.standardError? + .trimmingCharacters(in: .newlines) + .nilIfEmpty ?? "Unknown error" + throw Error.generic(code, message) + } + + guard let output = result.standardOutput else { + throw Error.outputData + } + return output.trimmingCharacters(in: .newlines) + } +} + +private extension String { + var nilIfEmpty: String? { + self.isEmpty ? nil : self } } diff --git a/Tests/GitKitTests/GitKitTests.swift b/Tests/GitKitTests/GitKitTests.swift index 88877e0..45676d0 100644 --- a/Tests/GitKitTests/GitKitTests.swift +++ b/Tests/GitKitTests/GitKitTests.swift @@ -19,28 +19,6 @@ extension String { } final class GitKitTests: XCTestCase { - static var allTests = [ - ("testInit", testInit), - ("testLog", testLog), - ("testCommandWithArgs", testCommandWithArgs), - ("testClone", testClone), - ("testRevParse", testRevParse), - ("testAddAll", testAddAll), - ("testStatusShort", testStatusShort), - ("testGitConfig", testGitConfig), - ("testPushPull", testPushPull), - ("testBranchOperations", testBranchOperations), - ("testTagOperations", testTagOperations), - ("testRemoteOperations", testRemoteOperations), - ("testSubmoduleOperations", testSubmoduleOperations), - ("testRevList", testRevList), - ("testLsRemote", testLsRemote), - ("testCommitVariations", testCommitVariations), - ("testLogVariations", testLogVariations), - ("testLogWithRevisions", testLogWithRevisions), - ("testPathsWithSpaces", testPathsWithSpaces), - ("testCloneWithSpacesInDirectoryName", testCloneWithSpacesInDirectoryName), - ] // MARK: - helpers @@ -59,48 +37,48 @@ final class GitKitTests: XCTestCase { } } - private func _test(_ alias: Git.Alias, path: String, expectation: String) throws { + private func _test(_ alias: Git.Alias, path: String, expectation: String) async throws { let path = path try self.clean(path: path) let expectedOutput = expectation let git = Git(path: path) - try git.run(.raw("init && git commit -m 'initial' --allow-empty --no-gpg-sign")) - let output = try git.run(alias) + try await git.run(.raw("init && git commit -m 'initial' --allow-empty --no-gpg-sign")) + let output = try await git.run(alias) self.assert(type: "output", result: output, expected: expectedOutput) try self.clean(path: path) } // MARK: - test functions - func testInit() throws { + func testInit() async throws { let path = self.currentPath() let expectation = "Initialized empty Git repository in" try self.clean(path: path) let git = Git(path: path) - let out = try git.run(.cmd(.initialize)) + let out = try await git.run(.cmd(.initialize)) try self.clean(path: path) XCTAssertTrue(out.hasPrefix(expectation), "Repository was not created.") } - func testLog() throws { + func testLog() async throws { let path = self.currentPath() let expectation = "Hello world!" try self.clean(path: path) let git = Git(path: path) - try git.run(.cmd(.initialize)) - try git.run(.commit(message: expectation, allowEmpty: true)) - let out = try git.run(.log(numberOfCommits: 1)) + try await git.run(.cmd(.initialize)) + try await git.run(.commit(message: expectation, allowEmpty: true)) + let out = try await git.run(.log(numberOfCommits: 1)) try self.clean(path: path) XCTAssertTrue(out.hasSuffix(expectation), "Commit was not created.") } - func testCommandWithArgs() throws { + func testCommandWithArgs() async throws { let path = self.currentPath() - try self._test(.cmd(.branch, "-a"), path: path, expectation: "* main") + try await self._test(.cmd(.branch, "-a"), path: path, expectation: "* main") } - func testClone() throws { + func testClone() async throws { let basePath = self.currentPath() let currentDirectory = FileManager.default.currentDirectoryPath let sourcePath = "\(currentDirectory)/\(basePath)-source" @@ -110,16 +88,16 @@ final class GitKitTests: XCTestCase { try self.clean(path: clonePath) let sourceGit = Git(path: sourcePath) - try sourceGit.run(.raw("init")) - try sourceGit.run(.raw("config user.name 'Test User'")) - try sourceGit.run(.raw("config user.email 'test@example.com'")) - try sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) + try await sourceGit.run(.raw("init")) + try await sourceGit.run(.raw("config user.name 'Test User'")) + try await sourceGit.run(.raw("config user.email 'test@example.com'")) + try await sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) let git = Git(path: clonePath) - try git.run(.clone(url: sourcePath)) + try await git.run(.clone(url: sourcePath)) let clonedRepoName = try XCTUnwrap(sourcePath.components(separatedBy: "/").last) - let statusOutput = try git.run("cd \(clonePath)/\(clonedRepoName) && git status") + let statusOutput = try await git.run("cd \(clonePath)/\(clonedRepoName) && git status") XCTAssertTrue(statusOutput.contains("On branch main"), "Should be on main branch") XCTAssertTrue(statusOutput.contains("nothing to commit"), "Should be clean working directory") @@ -127,7 +105,7 @@ final class GitKitTests: XCTestCase { try self.clean(path: clonePath) } - func testCloneWithDirectory() throws { + func testCloneWithDirectory() async throws { let basePath = self.currentPath() let currentDirectory = FileManager.default.currentDirectoryPath let sourcePath = "\(currentDirectory)/\(basePath)-source" @@ -137,15 +115,15 @@ final class GitKitTests: XCTestCase { try self.clean(path: clonePath) let sourceGit = Git(path: sourcePath) - try sourceGit.run(.raw("init")) - try sourceGit.run(.raw("config user.name 'Test User'")) - try sourceGit.run(.raw("config user.email 'test@example.com'")) - try sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) + try await sourceGit.run(.raw("init")) + try await sourceGit.run(.raw("config user.name 'Test User'")) + try await sourceGit.run(.raw("config user.email 'test@example.com'")) + try await sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) let git = Git(path: clonePath) - try git.run(.clone(url: sourcePath, dirName: "MyCustomDirectory")) + try await git.run(.clone(url: sourcePath, dirName: "MyCustomDirectory")) - let statusOutput = try git.run("cd \(clonePath)/MyCustomDirectory && git status") + let statusOutput = try await git.run("cd \(clonePath)/MyCustomDirectory && git status") XCTAssertTrue(statusOutput.contains("On branch main"), "Should be on main branch") XCTAssertTrue(statusOutput.contains("nothing to commit"), "Should be clean working directory") @@ -153,7 +131,7 @@ final class GitKitTests: XCTestCase { try self.clean(path: clonePath) } - func testCheckoutRemoteTracking() throws { + func testCheckoutRemoteTracking() async throws { let basePath = self.currentPath() let currentDirectory = FileManager.default.currentDirectoryPath let sourcePath = "\(currentDirectory)/\(basePath)-source" @@ -163,20 +141,20 @@ final class GitKitTests: XCTestCase { try self.clean(path: clonePath) let sourceGit = Git(path: sourcePath) - try sourceGit.run(.raw("init")) - try sourceGit.run(.raw("config user.name 'Test User'")) - try sourceGit.run(.raw("config user.email 'test@example.com'")) - try sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) + try await sourceGit.run(.raw("init")) + try await sourceGit.run(.raw("config user.name 'Test User'")) + try await sourceGit.run(.raw("config user.email 'test@example.com'")) + try await sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) let git = Git(path: clonePath) - try git.run(.clone(url: sourcePath)) + try await git.run(.clone(url: sourcePath)) let clonedRepoName = try XCTUnwrap(sourcePath.components(separatedBy: "/").last) let repoPath = "\(clonePath)/\(clonedRepoName)" let repoGit = Git(path: repoPath) - try repoGit.run(.checkout(branch: "feature-branch", create: true, tracking: "origin/main")) - let branchOutput = try repoGit.run(.raw("branch -vv")) + try await repoGit.run(.checkout(branch: "feature-branch", create: true, tracking: "origin/main")) + let branchOutput = try await repoGit.run(.raw("branch -vv")) XCTAssertTrue(branchOutput.contains("feature-branch"), "New branch should be created") XCTAssertTrue(branchOutput.contains("origin/main"), "Branch should track origin/main") @@ -185,99 +163,99 @@ final class GitKitTests: XCTestCase { try self.clean(path: clonePath) } - func testRevParse() throws { + func testRevParse() async throws { let path = self.currentPath() try self.clean(path: path) let git = Git(path: path) - try git.run(.raw("init")) - try git.run(.commit(message: "initial commit", allowEmpty: true)) + try await git.run(.raw("init")) + try await git.run(.commit(message: "initial commit", allowEmpty: true)) - let abbrevRef = try git.run(.revParse(abbrevRef: true, revision: "HEAD")) + let abbrevRef = try await git.run(.revParse(abbrevRef: true, revision: "HEAD")) XCTAssertEqual(abbrevRef, "main", "Should return abbreviated reference name") - let fullSHA = try git.run(.revParse(abbrevRef: false, revision: "HEAD")) + let fullSHA = try await git.run(.revParse(abbrevRef: false, revision: "HEAD")) XCTAssertTrue(fullSHA.count == 40, "Should return full 40-character SHA") XCTAssertTrue(fullSHA.allSatisfy { $0.isHexDigit }, "SHA should contain only hex characters") - let symbolicRef = try git.run(.revParse(abbrevRef: false, revision: "@")) + let symbolicRef = try await git.run(.revParse(abbrevRef: false, revision: "@")) XCTAssertEqual(symbolicRef, fullSHA, "Symbolic '@' should resolve to same SHA as HEAD") - let currentBranch = try git.run(.revParse(abbrevRef: true, revision: "@")) + let currentBranch = try await git.run(.revParse(abbrevRef: true, revision: "@")) XCTAssertEqual(currentBranch, "main", "Should return current branch name") try self.clean(path: path) } - func testAddAll() throws { + func testAddAll() async throws { let path = self.currentPath() try self.clean(path: path) let git = Git(path: path) - try git.run(.raw("init")) + try await git.run(.raw("init")) FileManager.default.createFile(atPath: "\(path)/test.txt", contents: "test content".data(using: .utf8)) - try git.run(.addAll) + try await git.run(.addAll) - let statusOutput = try git.run(.status()) + let statusOutput = try await git.run(.status()) XCTAssertTrue(statusOutput.contains("new file"), "File should be staged") try self.clean(path: path) } - func testStatusShort() throws { + func testStatusShort() async throws { let path = self.currentPath() try self.clean(path: path) let git = Git(path: path) - try git.run(.raw("init")) + try await git.run(.raw("init")) FileManager.default.createFile(atPath: "\(path)/file.txt", contents: "test".data(using: .utf8)) - try git.run(.addAll) + try await git.run(.addAll) - let shortStatus = try git.run(.status(short: true)) - let regularStatus = try git.run(.status(short: false)) + let shortStatus = try await git.run(.status(short: true)) + let regularStatus = try await git.run(.status(short: false)) XCTAssertTrue(shortStatus.count < regularStatus.count, "Short status should be more concise") try self.clean(path: path) } - func testGitConfig() throws { + func testGitConfig() async throws { let path = self.currentPath() try self.clean(path: path) let git = Git(path: path) - try git.run(.raw("init")) + try await git.run(.raw("init")) - try git.run(.writeConfig(name: "user.name", value: "\"Test User GitKit\"")) - try git.run(.writeConfig(name: "user.email", value: "test@gitkit.example.com")) - try git.run(.writeConfig(name: "core.editor", value: "vim")) + try await git.run(.writeConfig(name: "user.name", value: "\"Test User GitKit\"")) + try await git.run(.writeConfig(name: "user.email", value: "test@gitkit.example.com")) + try await git.run(.writeConfig(name: "core.editor", value: "vim")) - let userName = try git.run(.readConfig(name: "user.name")) - let userEmail = try git.run(.readConfig(name: "user.email")) - let coreEditor = try git.run(.readConfig(name: "core.editor")) + let userName = try await git.run(.readConfig(name: "user.name")) + let userEmail = try await git.run(.readConfig(name: "user.email")) + let coreEditor = try await git.run(.readConfig(name: "core.editor")) XCTAssertEqual(userName.trimmingCharacters(in: .whitespacesAndNewlines), "Test User GitKit", "User name should be set correctly") XCTAssertEqual(userEmail.trimmingCharacters(in: .whitespacesAndNewlines), "test@gitkit.example.com", "User email should be set correctly") XCTAssertEqual(coreEditor.trimmingCharacters(in: .whitespacesAndNewlines), "vim", "Core editor should be set correctly") - try git.run(.commit(message: "test commit", allowEmpty: true)) + try await git.run(.commit(message: "test commit", allowEmpty: true)) - let logOutput = try git.run(.raw("log --format='%an <%ae>' -1")) + let logOutput = try await git.run(.raw("log --format='%an <%ae>' -1")) XCTAssertTrue(logOutput.contains("Test User GitKit "), "Commit should use the configured user information") - try git.run(.writeConfig(name: "user.name", value: "\"Updated User\"")) - let updatedUserName = try git.run(.readConfig(name: "user.name")) + try await git.run(.writeConfig(name: "user.name", value: "\"Updated User\"")) + let updatedUserName = try await git.run(.readConfig(name: "user.name")) XCTAssertTrue(updatedUserName.contains("Updated User"), "Should be able to update existing config values") try self.clean(path: path) } - func testPushPull() throws { + func testPushPull() async throws { let basePath = self.currentPath() let currentDirectory = FileManager.default.currentDirectoryPath let sourcePath = "\(currentDirectory)/\(basePath)-source" @@ -287,26 +265,26 @@ final class GitKitTests: XCTestCase { try self.clean(path: clonePath) let sourceGit = Git(path: sourcePath) - try sourceGit.run(.raw("init")) - try sourceGit.run(.raw("config user.name 'Test User'")) - try sourceGit.run(.raw("config user.email 'test@example.com'")) - try sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) + try await sourceGit.run(.raw("init")) + try await sourceGit.run(.raw("config user.name 'Test User'")) + try await sourceGit.run(.raw("config user.email 'test@example.com'")) + try await sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) let git = Git(path: clonePath) - try git.run(.clone(url: sourcePath)) + try await git.run(.clone(url: sourcePath)) let clonedRepoName = try XCTUnwrap(sourcePath.components(separatedBy: "/").last) let repoPath = "\(clonePath)/\(clonedRepoName)" let repoGit = Git(path: repoPath) - try repoGit.run(.fetch()) - try repoGit.run(.fetch(remote: "origin")) - try repoGit.run(.fetch(remote: "origin", branch: "main")) + try await repoGit.run(.fetch()) + try await repoGit.run(.fetch(remote: "origin")) + try await repoGit.run(.fetch(remote: "origin", branch: "main")) - try repoGit.run(.pull()) - try repoGit.run(.pull(remote: "origin")) - try repoGit.run(.pull(remote: "origin", branch: "main")) - try repoGit.run(.pull(remote: "origin", branch: "main", rebase: true)) + try await repoGit.run(.pull()) + try await repoGit.run(.pull(remote: "origin")) + try await repoGit.run(.pull(remote: "origin", branch: "main")) + try await repoGit.run(.pull(remote: "origin", branch: "main", rebase: true)) let pushCommand = Git.Alias.push(remote: "origin", branch: "main") XCTAssertEqual(pushCommand.rawValue, "push origin main", "Push command should be properly formatted") @@ -315,64 +293,64 @@ final class GitKitTests: XCTestCase { try self.clean(path: clonePath) } - func testBranchOperations() throws { + func testBranchOperations() async throws { let path = self.currentPath() try self.clean(path: path) let git = Git(path: path) - try git.run(.raw("init")) - try git.run(.commit(message: "initial", allowEmpty: true)) + try await git.run(.raw("init")) + try await git.run(.commit(message: "initial", allowEmpty: true)) - try git.run(.create(branch: "feature-branch")) + try await git.run(.create(branch: "feature-branch")) - try git.run(.checkout(branch: "another-branch", create: true)) + try await git.run(.checkout(branch: "another-branch", create: true)) - try git.run(.checkout(branch: "main")) - try git.run(.merge(branch: "feature-branch")) + try await git.run(.checkout(branch: "main")) + try await git.run(.merge(branch: "feature-branch")) - try git.run(.delete(branch: "feature-branch")) + try await git.run(.delete(branch: "feature-branch")) - let branchOutput = try git.run(.raw("branch")) + let branchOutput = try await git.run(.raw("branch")) XCTAssertTrue(branchOutput.contains("another-branch"), "Branch should exist") XCTAssertFalse(branchOutput.contains("feature-branch"), "Deleted branch should not exist") try self.clean(path: path) } - func testTagOperations() throws { + func testTagOperations() async throws { let path = self.currentPath() try self.clean(path: path) let git = Git(path: path) - try git.run(.raw("init")) - try git.run(.commit(message: "initial", allowEmpty: true)) + try await git.run(.raw("init")) + try await git.run(.commit(message: "initial", allowEmpty: true)) - try git.run(.tag("v1.0.0")) - try git.run(.tag("v1.1.0")) + try await git.run(.tag("v1.0.0")) + try await git.run(.tag("v1.1.0")) - let tagOutput = try git.run(.raw("tag")) + let tagOutput = try await git.run(.raw("tag")) XCTAssertTrue(tagOutput.contains("v1.0.0"), "Tag v1.0.0 should exist") XCTAssertTrue(tagOutput.contains("v1.1.0"), "Tag v1.1.0 should exist") try self.clean(path: path) } - func testRemoteOperations() throws { + func testRemoteOperations() async throws { let path = self.currentPath() try self.clean(path: path) let git = Git(path: path) - try git.run(.raw("init")) + try await git.run(.raw("init")) - try git.run(.addRemote(name: "origin", url: "https://github.com/test/repo.git")) - try git.run(.addRemote(name: "upstream", url: "https://github.com/upstream/repo.git")) + try await git.run(.addRemote(name: "origin", url: "https://github.com/test/repo.git")) + try await git.run(.addRemote(name: "upstream", url: "https://github.com/upstream/repo.git")) - try git.run(.renameRemote(oldName: "upstream", newName: "upstream-new")) + try await git.run(.renameRemote(oldName: "upstream", newName: "upstream-new")) - let remoteOutput = try git.run(.raw("remote -v")) + let remoteOutput = try await git.run(.raw("remote -v")) XCTAssertTrue(remoteOutput.contains("origin"), "Origin remote should exist") XCTAssertTrue(remoteOutput.contains("upstream-new"), "Renamed remote should exist") XCTAssertFalse(remoteOutput.contains("upstream\t"), "Old remote name should not exist") @@ -380,7 +358,7 @@ final class GitKitTests: XCTestCase { try self.clean(path: path) } - func testSubmoduleOperations() throws { + func testSubmoduleOperations() async throws { let basePath = self.currentPath() let currentDirectory = FileManager.default.currentDirectoryPath let mainRepoPath = "\(currentDirectory)/\(basePath)-main" @@ -390,69 +368,69 @@ final class GitKitTests: XCTestCase { try self.clean(path: submoduleRepoPath) let submoduleGit = Git(path: submoduleRepoPath) - try submoduleGit.run(.raw("init")) - try submoduleGit.run(.raw("config user.name 'Test User'")) - try submoduleGit.run(.raw("config user.email 'test@example.com'")) - try submoduleGit.run(.raw("commit -m 'submodule initial commit' --allow-empty --no-gpg-sign")) + try await submoduleGit.run(.raw("init")) + try await submoduleGit.run(.raw("config user.name 'Test User'")) + try await submoduleGit.run(.raw("config user.email 'test@example.com'")) + try await submoduleGit.run(.raw("commit -m 'submodule initial commit' --allow-empty --no-gpg-sign")) // Save the current global config value (if any) and set it temporarily let git = Git(path: mainRepoPath) var originalConfigValue: String? do { - originalConfigValue = try git.run(.raw("config --global --get protocol.file.allow")) + originalConfigValue = try await git.run(.raw("config --global --get protocol.file.allow")) } catch { // Config doesn't exist, which is fine originalConfigValue = nil } - try git.run(.raw("init")) - try git.run(.raw("config user.name 'Test User'")) - try git.run(.raw("config user.email 'test@example.com'")) + try await git.run(.raw("init")) + try await git.run(.raw("config user.name 'Test User'")) + try await git.run(.raw("config user.email 'test@example.com'")) // Set the protocol.file.allow config temporarily - try git.run(.raw("config --global protocol.file.allow always")) + try await git.run(.raw("config --global protocol.file.allow always")) - try git.run(.commit(message: "initial", allowEmpty: true)) + try await git.run(.commit(message: "initial", allowEmpty: true)) - try git.run(.raw("submodule add \(submoduleRepoPath) submodules/test-submodule")) + try await git.run(.raw("submodule add \(submoduleRepoPath) submodules/test-submodule")) - try git.run(.submoduleUpdate()) - try git.run(.submoduleUpdate(init: true)) - try git.run(.submoduleUpdate(recursive: true)) - try git.run(.submoduleUpdate(init: true, recursive: true, rebase: true)) + try await git.run(.submoduleUpdate()) + try await git.run(.submoduleUpdate(init: true)) + try await git.run(.submoduleUpdate(recursive: true)) + try await git.run(.submoduleUpdate(init: true, recursive: true, rebase: true)) - try git.run(.submoduleForeach(recursive: false, command: "pwd")) - try git.run(.submoduleForeach(recursive: true, command: "git status")) + try await git.run(.submoduleForeach(recursive: false, command: "pwd")) + try await git.run(.submoduleForeach(recursive: true, command: "git status")) - let statusOutput = try git.run(.raw("submodule status")) + let statusOutput = try await git.run(.raw("submodule status")) XCTAssertTrue(statusOutput.contains("test-submodule"), "Submodule should be listed in status") // Restore the original global config value if let originalValue = originalConfigValue { - try git.run(.raw("config --global protocol.file.allow \(originalValue)")) + try await git.run(.raw("config --global protocol.file.allow \(originalValue)")) } else { // Config didn't exist before, so remove it - _ = try? git.run(.raw("config --global --unset protocol.file.allow")) + _ = try? await git.run(.raw("config --global --unset protocol.file.allow")) } try self.clean(path: mainRepoPath) try self.clean(path: submoduleRepoPath) } - func testRevList() throws { + func testRevList() async throws { let path = self.currentPath() try self.clean(path: path) let git = Git(path: path) git.verbose = true - try git.run(.raw("init")) - try git.run(.commit(message: "first", allowEmpty: true)) - try git.run(.commit(message: "second", allowEmpty: true)) + try await git.run(.raw("init")) + try await git.run(.commit(message: "first", allowEmpty: true)) + try await git.run(.commit(message: "second", allowEmpty: true)) - let commitCount = try git.run(.revList(count: true, revisions: "HEAD")) - let commitList = try git.run(.revList(revisions: "HEAD")) - let commitRange = try git.run(.revList(revisions: "HEAD HEAD~1")) + let commitCount = try await git.run(.revList(count: true, revisions: "HEAD")) + let commitList = try await git.run(.revList(revisions: "HEAD")) + let commitRange = try await git.run(.revList(revisions: "HEAD HEAD~1")) XCTAssertEqual(commitCount.trimmingCharacters(in: .whitespacesAndNewlines), "2", "Should have 2 commits") XCTAssertTrue(commitList.contains("\n"), "Should list multiple commits") @@ -461,33 +439,33 @@ final class GitKitTests: XCTestCase { try self.clean(path: path) } - func testLsRemote() throws { + func testLsRemote() async throws { let path = self.currentPath() try self.clean(path: path) let git = Git(path: path) - try git.run(.raw("init")) - try git.run(.raw("config user.name 'Test User'")) - try git.run(.raw("config user.email 'test@example.com'")) + try await git.run(.raw("init")) + try await git.run(.raw("config user.name 'Test User'")) + try await git.run(.raw("config user.email 'test@example.com'")) - try git.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) + try await git.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) - try git.run(.raw("checkout -b feature/test-feature")) - try git.run(.raw("commit -m 'feature commit' --allow-empty --no-gpg-sign")) + try await git.run(.raw("checkout -b feature/test-feature")) + try await git.run(.raw("commit -m 'feature commit' --allow-empty --no-gpg-sign")) - try git.run(.raw("checkout -b develop")) - try git.run(.raw("commit -m 'develop commit' --allow-empty --no-gpg-sign")) + try await git.run(.raw("checkout -b develop")) + try await git.run(.raw("commit -m 'develop commit' --allow-empty --no-gpg-sign")) - try git.run(.raw("checkout main")) - try git.run(.raw("tag v1.0.0")) - try git.run(.raw("tag v1.1.0")) + try await git.run(.raw("checkout main")) + try await git.run(.raw("tag v1.0.0")) + try await git.run(.raw("tag v1.1.0")) let currentDirectory = FileManager.default.currentDirectoryPath let absolutePath = "\(currentDirectory)/\(path)" - let remoteRefs = try git.run(.lsRemote(url: absolutePath)) - let headsOnly = try git.run(.lsRemote(url: absolutePath, limitToHeads: true)) + let remoteRefs = try await git.run(.lsRemote(url: absolutePath)) + let headsOnly = try await git.run(.lsRemote(url: absolutePath, limitToHeads: true)) XCTAssertTrue(remoteRefs.contains("refs/heads/main"), "Should contain main branch") XCTAssertTrue(remoteRefs.contains("refs/heads/feature/test-feature"), "Should contain feature branch") @@ -510,7 +488,7 @@ final class GitKitTests: XCTestCase { try self.clean(path: path) } - func testCommitVariations() throws { + func testCommitVariations() async throws { let signedCommitAlias = Git.Alias.commit(message: "test signed", allowEmpty: true, gpgSigned: true) XCTAssertTrue(signedCommitAlias.rawValue.contains("--gpg-sign"), "GPG signed commit should include --gpg-sign flag") XCTAssertFalse(signedCommitAlias.rawValue.contains("--no-gpg-sign"), "GPG signed commit should NOT include --no-gpg-sign flag") @@ -520,22 +498,22 @@ final class GitKitTests: XCTestCase { XCTAssertFalse(unsignedCommitAlias.rawValue.contains("--gpg-sign"), "Unsigned commit should NOT include --gpg-sign flag") } - func testLogVariations() throws { + func testLogVariations() async throws { let path = self.currentPath() try self.clean(path: path) let git = Git(path: path) - try git.run(.raw("init")) - try git.run(.commit(message: "first commit", allowEmpty: true)) - try git.run(.commit(message: "second commit", allowEmpty: true)) - try git.run(.commit(message: "third commit", allowEmpty: true)) + try await git.run(.raw("init")) + try await git.run(.commit(message: "first commit", allowEmpty: true)) + try await git.run(.commit(message: "second commit", allowEmpty: true)) + try await git.run(.commit(message: "third commit", allowEmpty: true)) - let limitedLog = try git.run(.log(numberOfCommits: 2)) - let fullLog = try git.run(.log()) - let onelineLog = try git.run(.log(options: ["--oneline"])) - let prettyLog = try git.run(.log(numberOfCommits: 1, options: ["--pretty=format:%s"])) - let singleCommitLog = try git.run(.log(numberOfCommits: 1)) + let limitedLog = try await git.run(.log(numberOfCommits: 2)) + let fullLog = try await git.run(.log()) + let onelineLog = try await git.run(.log(options: ["--oneline"])) + let prettyLog = try await git.run(.log(numberOfCommits: 1, options: ["--pretty=format:%s"])) + let singleCommitLog = try await git.run(.log(numberOfCommits: 1)) XCTAssertTrue(limitedLog.contains("third commit"), "Limited log should contain third commit") XCTAssertTrue(limitedLog.contains("second commit"), "Limited log should contain second commit") @@ -568,18 +546,18 @@ final class GitKitTests: XCTestCase { try self.clean(path: path) } - func testLogWithRevisions() throws { + func testLogWithRevisions() async throws { let path = self.currentPath() try self.clean(path: path) let git = Git(path: path) - try git.run(.raw("init")) - try git.run(.commit(message: "first commit", allowEmpty: true)) - try git.run(.commit(message: "second commit", allowEmpty: true)) - try git.run(.commit(message: "third commit", allowEmpty: true)) + try await git.run(.raw("init")) + try await git.run(.commit(message: "first commit", allowEmpty: true)) + try await git.run(.commit(message: "second commit", allowEmpty: true)) + try await git.run(.commit(message: "third commit", allowEmpty: true)) - let logWithRevisions = try git.run(.log(revisions: "@^^..@^")) + let logWithRevisions = try await git.run(.log(revisions: "@^^..@^")) XCTAssertTrue(logWithRevisions.contains("second commit"), "Log with @^^..@^ revision should contain second commit") XCTAssertFalse(logWithRevisions.contains("first commit"), "Log with @^^..@^ revision should NOT contain first commit") @@ -588,7 +566,7 @@ final class GitKitTests: XCTestCase { try self.clean(path: path) } - func testPathsWithSpaces() throws { + func testPathsWithSpaces() async throws { let basePath = self.currentPath() let pathWithSpaces = "\(basePath) with spaces" @@ -597,30 +575,30 @@ final class GitKitTests: XCTestCase { let git = Git(path: pathWithSpaces) // Test basic operations in a path with spaces - try git.run(.raw("init")) - try git.run(.raw("config user.name 'Test User'")) - try git.run(.raw("config user.email 'test@example.com'")) + try await git.run(.raw("init")) + try await git.run(.raw("config user.name 'Test User'")) + try await git.run(.raw("config user.email 'test@example.com'")) // Create a file and test git operations FileManager.default.createFile(atPath: "\(pathWithSpaces)/test file.txt", contents: "test content".data(using: .utf8)) - try git.run(.addAll) - let statusOutput = try git.run(.status(short: true)) + try await git.run(.addAll) + let statusOutput = try await git.run(.status(short: true)) XCTAssertTrue(statusOutput.contains("A"), "File should be staged") - try git.run(.commit(message: "test commit with spaces in path", allowEmpty: false)) + try await git.run(.commit(message: "test commit with spaces in path", allowEmpty: false)) - let logOutput = try git.run(.log(numberOfCommits: 1, options: ["--oneline"])) + let logOutput = try await git.run(.log(numberOfCommits: 1, options: ["--oneline"])) XCTAssertTrue(logOutput.contains("test commit with spaces in path"), "Commit should be successful") // Test status after commit - let cleanStatus = try git.run(.status(short: true)) + let cleanStatus = try await git.run(.status(short: true)) XCTAssertTrue(cleanStatus.isEmpty, "Working directory should be clean after commit") try self.clean(path: pathWithSpaces) } - func testCloneWithSpacesInDirectoryName() throws { + func testCloneWithSpacesInDirectoryName() async throws { let basePath = self.currentPath() let currentDirectory = FileManager.default.currentDirectoryPath let sourcePath = "\(currentDirectory)/\(basePath)-source" @@ -632,14 +610,14 @@ final class GitKitTests: XCTestCase { // Create source repository let sourceGit = Git(path: sourcePath) - try sourceGit.run(.raw("init")) - try sourceGit.run(.raw("config user.name 'Test User'")) - try sourceGit.run(.raw("config user.email 'test@example.com'")) - try sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) + try await sourceGit.run(.raw("init")) + try await sourceGit.run(.raw("config user.name 'Test User'")) + try await sourceGit.run(.raw("config user.email 'test@example.com'")) + try await sourceGit.run(.raw("commit -m 'initial commit' --allow-empty --no-gpg-sign")) // Test cloning into a path with spaces let git = Git(path: clonePath) - try git.run(.clone(url: sourcePath, dirName: targetDirName)) + try await git.run(.clone(url: sourcePath, dirName: targetDirName)) // Verify the clone was successful let clonedRepoPath = "\(clonePath)/\(targetDirName)" @@ -648,49 +626,37 @@ final class GitKitTests: XCTestCase { // Test git operations in the cloned repo with spaces in path let clonedGit = Git(path: clonedRepoPath) - let statusOutput = try clonedGit.run(.status()) + let statusOutput = try await clonedGit.run(.status()) XCTAssertTrue(statusOutput.contains("On branch main"), "Should be on main branch") XCTAssertTrue(statusOutput.contains("nothing to commit"), "Should be clean working directory") // Test creating and committing a file with spaces in the repo path FileManager.default.createFile(atPath: "\(clonedRepoPath)/file with spaces.txt", contents: "content".data(using: .utf8)) - try clonedGit.run(.addAll) - try clonedGit.run(.commit(message: "add file with spaces")) + try await clonedGit.run(.addAll) + try await clonedGit.run(.commit(message: "add file with spaces")) - let logOutput = try clonedGit.run(.log(numberOfCommits: 1, options: ["--oneline"])) + let logOutput = try await clonedGit.run(.log(numberOfCommits: 1, options: ["--oneline"])) XCTAssertTrue(logOutput.contains("add file with spaces"), "New commit should exist") try self.clean(path: sourcePath) try self.clean(path: clonePath) } - #if os(macOS) - func testAsyncRun() throws { + /// Replaces the former completion-handler `testAsyncRun`; the block-based + /// variant was removed in 2.0, so the same behaviour is covered via async. + func testAsyncRun() async throws { let path = self.currentPath() try self.clean(path: path) let expectedOutput = """ On branch main nothing to commit, working tree clean """ - + let git = Git(path: path) - try git.run(.raw("init && git commit -m 'initial' --allow-empty --no-gpg-sign")) - - let expectation = XCTestExpectation(description: "Shell command finished.") - git.run(.cmd(.status)) { result, error in - if let error = error { - try? self.clean(path: path) - return XCTFail("There should be no errors. (error: `\(error.localizedDescription)`)") - } - guard let output = result else { - try? self.clean(path: path) - return XCTFail("Empty result, expected `\(expectedOutput)`.") - } - self.assert(type: "output", result: output, expected: expectedOutput) - try? self.clean(path: path) - expectation.fulfill() - } - self.wait(for: [expectation], timeout: 5) + try await git.run(.raw("init && git commit -m 'initial' --allow-empty --no-gpg-sign")) + let output = try await git.run(.cmd(.status)) + self.assert(type: "output", result: output, expected: expectedOutput) + try self.clean(path: path) } - #endif + } diff --git a/Tests/GitKitTests/XCTestManifests.swift b/Tests/GitKitTests/XCTestManifests.swift deleted file mode 100644 index 39c6c84..0000000 --- a/Tests/GitKitTests/XCTestManifests.swift +++ /dev/null @@ -1,17 +0,0 @@ -/** - XCTestManifests.swift - GitKitTests - - Created by Tibor Bödecs on 2018.12.31. - Copyright Binary Birds. All rights reserved. -*/ - -import XCTest - -#if !canImport(ObjectiveC) -public func allTests() -> [XCTestCaseEntry] { - return [ - testCase(GitKitTests.allTests), - ] -} -#endif diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift deleted file mode 100644 index ec48547..0000000 --- a/Tests/LinuxMain.swift +++ /dev/null @@ -1,14 +0,0 @@ -/** - LinuxMain.swift - GitTests - - Created by Tibor Bödecs on 2019.01.02. - Copyright Binary Birds. All rights reserved. - */ - -import XCTest -@testable import GitTests - -XCTMain([ - testCase(GitTests.allTests), -])