diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a43997c..2825e9a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -14,6 +14,5 @@ } } } - }, - "forwardPorts": [8080] + } } \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0c62eec..bfb0969 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /Packages /*.xcodeproj .swiftpm +.vscode/ diff --git a/Package.resolved b/Package.resolved index 864b749..671fd93 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,16 +1,59 @@ { - "object": { - "pins": [ - { - "package": "ShellQuote", - "repositoryURL": "https://github.com/SwiftPackageIndex/ShellQuote", - "state": { - "branch": null, - "revision": "5f555550c30ef43d64b36b40c2c291a95d62580c", - "version": "1.0.2" - } + "pins" : [ + { + "identity" : "shellquote", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SwiftPackageIndex/ShellQuote", + "state" : { + "revision" : "5f555550c30ef43d64b36b40c2c291a95d62580c", + "version" : "1.0.2" } - ] - }, - "version": 1 + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", + "version" : "1.5.3" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", + "version" : "1.0.2" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "836bc4557b74fe6d2660218d56e3ce96aff76574", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-tools-support-core", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-tools-support-core.git", + "state" : { + "revision" : "93784c59434dbca8e8a9e4b700d0d6d94551da6a", + "version" : "0.5.2" + } + } + ], + "version" : 2 } diff --git a/Package.swift b/Package.swift index 869a44c..22b2edd 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:4.2 +// swift-tools-version:5.8 /** * ShellOut @@ -10,23 +10,31 @@ import PackageDescription let package = Package( name: "ShellOut", + platforms: [.macOS("10.15.4")], products: [ .library(name: "ShellOut", targets: ["ShellOut"]) ], dependencies: [ .package(url: "https://github.com/SwiftPackageIndex/ShellQuote", from: "1.0.2"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-tools-support-core.git", from: "0.5.2"), + .package(url: "https://github.com/apple/swift-algorithms.git", from: "1.0.0"), ], targets: [ .target( name: "ShellOut", dependencies: [ - .product(name: "ShellQuote", package: "ShellQuote") + .product(name: "ShellQuote", package: "ShellQuote"), + .product(name: "Logging", package: "swift-log"), + .product(name: "Algorithms", package: "swift-algorithms"), + .product(name: "TSCBasic", package: "swift-tools-support-core"), ], path: "Sources" ), .testTarget( name: "ShellOutTests", - dependencies: ["ShellOut"] + dependencies: ["ShellOut"], + exclude: ["Fixtures"] ) ] ) diff --git a/Sources/ShellOut.swift b/Sources/ShellOut.swift index f62c227..9566b85 100644 --- a/Sources/ShellOut.swift +++ b/Sources/ShellOut.swift @@ -6,6 +6,9 @@ import Foundation import Dispatch +import Logging +import TSCBasic +import Algorithms // MARK: - API @@ -32,18 +35,23 @@ import Dispatch to command: SafeString, arguments: [Argument] = [], at path: String = ".", - process: Process = .init(), + logger: Logger? = nil, outputHandle: FileHandle? = nil, errorHandle: FileHandle? = nil, environment: [String : String]? = nil -) throws -> String { - let command = "cd \(path.escapingSpaces) && \(command) \(arguments.map(\.string).joined(separator: " "))" +) async throws -> (stdout: String, stderr: String) { + let command = "\(command) \(arguments.map(\.string).joined(separator: " "))" - return try process.launchBash( + return try await TSCBasic.Process.launchBash( with: command, + logger: logger, outputHandle: outputHandle, errorHandle: errorHandle, - environment: environment + environment: environment, + at: path == "." ? nil : + (path == "~" ? TSCBasic.localFileSystem.homeDirectory.pathString : + (path.starts(with: "~/") ? "\(TSCBasic.localFileSystem.homeDirectory.pathString)/\(path.dropFirst(2))" : + path)) ) } @@ -68,16 +76,16 @@ import Dispatch @discardableResult public func shellOut( to command: ShellOutCommand, at path: String = ".", - process: Process = .init(), + logger: Logger? = nil, outputHandle: FileHandle? = nil, errorHandle: FileHandle? = nil, environment: [String : String]? = nil -) throws -> String { - try shellOut( +) async throws -> (stdout: String, stderr: String) { + try await shellOut( to: command.command, arguments: command.arguments, at: path, - process: process, + logger: logger, outputHandle: outputHandle, errorHandle: errorHandle, environment: environment @@ -390,69 +398,49 @@ extension ShellOutCommand { // MARK: - Private -private extension Process { - @discardableResult func launchBash(with command: String, outputHandle: FileHandle? = nil, errorHandle: FileHandle? = nil, environment: [String : String]? = nil) throws -> String { - executableURL = URL(fileURLWithPath: "/bin/bash") - arguments = ["-c", command] - - if let environment = environment { - self.environment = environment - } - - // Because FileHandle's readabilityHandler might be called from a - // different queue from the calling queue, avoid a data race by - // protecting reads and writes to outputData and errorData on - // a single dispatch queue. - let outputQueue = DispatchQueue(label: "bash-output-queue") - - var outputData = Data() - var errorData = Data() - - let outputPipe = Pipe() - standardOutput = outputPipe - - let errorPipe = Pipe() - standardError = errorPipe - - outputPipe.fileHandleForReading.readabilityHandler = { handler in - let data = handler.availableData - outputQueue.async { - outputData.append(data) - outputHandle?.write(data) - } - } - - errorPipe.fileHandleForReading.readabilityHandler = { handler in - let data = handler.availableData - outputQueue.async { - errorData.append(data) - errorHandle?.write(data) - } - } - - try run() - - waitUntilExit() - - outputHandle?.closeFile() - errorHandle?.closeFile() - - outputPipe.fileHandleForReading.readabilityHandler = nil - errorPipe.fileHandleForReading.readabilityHandler = nil - - // Block until all writes have occurred to outputData and errorData, - // and then read the data back out. - return try outputQueue.sync { - if terminationStatus != 0 { - throw ShellOutError( - terminationStatus: terminationStatus, - errorData: errorData, - outputData: outputData - ) +private extension TSCBasic.Process { + @discardableResult static func launchBash( + with command: String, + logger: Logger? = nil, + outputHandle: FileHandle? = nil, + errorHandle: FileHandle? = nil, + environment: [String : String]? = nil, + at: String? = nil + ) async throws -> (stdout: String, stderr: String) { + let process = try Self.init( + arguments: ["/bin/bash", "-c", command], + environment: environment ?? ProcessEnv.vars, + workingDirectory: at.map { try .init(validating: $0) } ?? TSCBasic.localFileSystem.currentWorkingDirectory ?? .root, + outputRedirection: .collect(redirectStderr: false), + startNewProcessGroup: false, + loggingHandler: nil + ) + + try process.launch() + + let result = try await process.waitUntilExit() + + try outputHandle?.write(contentsOf: (try? result.output.get()) ?? []) + try outputHandle?.close() + try errorHandle?.write(contentsOf: (try? result.stderrOutput.get()) ?? []) + try errorHandle?.close() + + guard case .terminated(code: let code) = result.exitStatus, code == 0 else { + let code: Int32 + switch result.exitStatus { + case .terminated(code: let termCode): code = termCode + case .signalled(signal: let sigNo): code = -sigNo } - - return outputData.shellOutput() + throw ShellOutError( + terminationStatus: code, + errorData: Data((try? result.stderrOutput.get()) ?? []), + outputData: Data((try? result.output.get()) ?? []) + ) } + return try ( + stdout: String(result.utf8Output().trimmingSuffix(while: \.isNewline)), + stderr: String(result.utf8stderrOutput().trimmingSuffix(while: \.isNewline)) + ) } } @@ -468,25 +456,3 @@ private extension Data { } } - -private extension String { - var escapingSpaces: String { - return replacingOccurrences(of: " ", with: "\\ ") - } - - func appending(argument: String) -> String { - return "\(self) \"\(argument)\"" - } - - func appending(arguments: [String]) -> String { - return appending(argument: arguments.joined(separator: "\" \"")) - } - - mutating func append(argument: String) { - self = appending(argument: argument) - } - - mutating func append(arguments: [String]) { - self = appending(arguments: arguments) - } -} diff --git a/Tests/ShellOutTests/Fixtures/ErrNo.zip b/Tests/ShellOutTests/Fixtures/ErrNo.zip new file mode 100644 index 0000000..104c6e9 Binary files /dev/null and b/Tests/ShellOutTests/Fixtures/ErrNo.zip differ diff --git a/Tests/ShellOutTests/ShellOutTests.swift b/Tests/ShellOutTests/ShellOutTests.swift index 7c86d58..d5fb448 100644 --- a/Tests/ShellOutTests/ShellOutTests.swift +++ b/Tests/ShellOutTests/ShellOutTests.swift @@ -7,6 +7,24 @@ import XCTest @testable import ShellOut +func XCTAssertEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line +) async where T: Equatable { + do { + let expr1 = try await expression1() + let expr2 = try await expression2() + + return XCTAssertEqual(expr1, expr2, message(), file: file, line: line) + } catch { + // Trick XCTest into behaving correctly for a thrown error. + return XCTAssertEqual(try { () -> Bool in throw error }(), false, message(), file: file, line: line) + } +} + class ShellOutTests: XCTestCase { func test_appendArguments() throws { var cmd = try ShellOutCommand(command: "foo") @@ -31,53 +49,53 @@ class ShellOutTests: XCTestCase { ) } - func testWithoutArguments() throws { - let uptime = try shellOut(to: "uptime".checked) + func testWithoutArguments() async throws { + let uptime = try await shellOut(to: "uptime".checked).stdout XCTAssertTrue(uptime.contains("load average")) } - func testWithArguments() throws { - let echo = try shellOut(to: "echo".checked, arguments: ["Hello world".quoted]) + func testWithArguments() async throws { + let echo = try await shellOut(to: "echo".checked, arguments: ["Hello world".quoted]).stdout XCTAssertEqual(echo, "Hello world") } - func testSingleCommandAtPath() throws { + func testSingleCommandAtPath() async throws { let tempDir = NSTemporaryDirectory() - try shellOut( + try await shellOut( to: "echo".checked, arguments: ["Hello", ">".verbatim, "\(tempDir)ShellOutTests-SingleCommand.txt".quoted] ) - let textFileContent = try shellOut( + let textFileContent = try await shellOut( to: "cat".checked, arguments: ["ShellOutTests-SingleCommand.txt".quoted], at: tempDir - ) + ).stdout XCTAssertEqual(textFileContent, "Hello") } - func testSingleCommandAtPathContainingSpace() throws { - try shellOut(to: "mkdir".checked, + func testSingleCommandAtPathContainingSpace() async throws { + try await shellOut(to: "mkdir".checked, arguments: ["-p".verbatim, "ShellOut Test Folder".quoted], at: NSTemporaryDirectory()) - try shellOut(to: "echo".checked, arguments: ["Hello", ">", "File"].verbatim, + try await shellOut(to: "echo".checked, arguments: ["Hello", ">", "File"].verbatim, at: NSTemporaryDirectory() + "ShellOut Test Folder") - let output = try shellOut( + let output = try await shellOut( to: "cat".checked, - arguments: ["\(NSTemporaryDirectory())ShellOut Test Folder/File".quoted]) + arguments: ["\(NSTemporaryDirectory())ShellOut Test Folder/File".quoted]).stdout XCTAssertEqual(output, "Hello") } - func testSingleCommandAtPathContainingTilde() throws { - let homeContents = try shellOut(to: "ls".checked, arguments: ["-a"], at: "~") + func testSingleCommandAtPathContainingTilde() async throws { + let homeContents = try await shellOut(to: "ls".checked, arguments: ["-a"], at: "~").stdout XCTAssertFalse(homeContents.isEmpty) } - func testThrowingError() { + func testThrowingError() async { do { - try shellOut(to: "cd".checked, arguments: ["notADirectory".verbatim]) + try await shellOut(to: "cd".checked, arguments: ["notADirectory".verbatim]) XCTFail("Expected expression to throw") } catch let error as ShellOutError { XCTAssertTrue(error.message.contains("notADirectory")) @@ -109,21 +127,21 @@ class ShellOutTests: XCTestCase { XCTAssertEqual(error.localizedDescription, expectedErrorDescription) } - func testCapturingOutputWithHandle() throws { + func testCapturingOutputWithHandle() async throws { let pipe = Pipe() - let output = try shellOut(to: "echo".checked, + let output = try await shellOut(to: "echo".checked, arguments: ["Hello".verbatim], - outputHandle: pipe.fileHandleForWriting) + outputHandle: pipe.fileHandleForWriting).stdout let capturedData = pipe.fileHandleForReading.readDataToEndOfFile() XCTAssertEqual(output, "Hello") XCTAssertEqual(output + "\n", String(data: capturedData, encoding: .utf8)) } - func testCapturingErrorWithHandle() throws { + func testCapturingErrorWithHandle() async throws { let pipe = Pipe() do { - try shellOut(to: "cd".checked, + try await shellOut(to: "cd".checked, arguments: ["notADirectory".verbatim], errorHandle: pipe.fileHandleForWriting) XCTFail("Expected expression to throw") @@ -139,54 +157,53 @@ class ShellOutTests: XCTestCase { } } - func test_createFile() throws { + func test_createFile() async throws { let tempFolderPath = NSTemporaryDirectory() - try shellOut(to: .createFile(named: "Test", contents: "Hello world"), - at: tempFolderPath) - XCTAssertEqual(try shellOut(to: .readFile(at: tempFolderPath + "Test")), - "Hello world") + try await shellOut(to: .createFile(named: "Test", contents: "Hello world"), + at: tempFolderPath, logger: .init(label: "test")) + await XCTAssertEqualAsync(try await shellOut(to: .readFile(at: tempFolderPath + "Test")).stdout, "Hello world") } - func testGitCommands() throws { + func testGitCommands() async throws { // Setup & clear state let tempFolderPath = NSTemporaryDirectory() - try shellOut(to: "rm".checked, + try await shellOut(to: "rm".checked, arguments: ["-rf", "GitTestOrigin"].verbatim, - at: tempFolderPath) - try shellOut(to: "rm".checked, + at: tempFolderPath, logger: .init(label: "test")) + try await shellOut(to: "rm".checked, arguments: ["-rf", "GitTestClone"].verbatim, - at: tempFolderPath) + at: tempFolderPath, logger: .init(label: "test")) // Create a origin repository and make a commit with a file let originPath = tempFolderPath + "/GitTestOrigin" - try shellOut(to: .createFolder(named: "GitTestOrigin"), at: tempFolderPath) - try shellOut(to: .gitInit(), at: originPath) - try shellOut(to: .createFile(named: "Test", contents: "Hello world"), at: originPath) - try shellOut(to: .gitCommit(message: "Commit"), at: originPath) + try await shellOut(to: .createFolder(named: "GitTestOrigin"), at: tempFolderPath, logger: .init(label: "test")) + try await shellOut(to: .gitInit(), at: originPath, logger: .init(label: "test")) + try await shellOut(to: .createFile(named: "Test", contents: "Hello world"), at: originPath, logger: .init(label: "test")) + try await shellOut(to: .gitCommit(message: "Commit"), at: originPath, logger: .init(label: "test")) // Clone to a new repository and read the file let clonePath = tempFolderPath + "/GitTestClone" let cloneURL = URL(fileURLWithPath: originPath) - try shellOut(to: .gitClone(url: cloneURL, to: "GitTestClone"), at: tempFolderPath) + try await shellOut(to: .gitClone(url: cloneURL, to: "GitTestClone"), at: tempFolderPath, logger: .init(label: "test")) let filePath = clonePath + "/Test" - XCTAssertEqual(try shellOut(to: .readFile(at: filePath)), "Hello world") + await XCTAssertEqualAsync(try await shellOut(to: .readFile(at: filePath), logger: .init(label: "test")).stdout, "Hello world") // Make a new commit in the origin repository - try shellOut(to: .createFile(named: "Test", contents: "Hello again"), at: originPath) - try shellOut(to: .gitCommit(message: "Commit"), at: originPath) + try await shellOut(to: .createFile(named: "Test", contents: "Hello again"), at: originPath, logger: .init(label: "test")) + try await shellOut(to: .gitCommit(message: "Commit"), at: originPath, logger: .init(label: "test")) // Pull the commit in the clone repository and read the file again - try shellOut(to: .gitPull(), at: clonePath) - XCTAssertEqual(try shellOut(to: .readFile(at: filePath)), "Hello again") + try await shellOut(to: .gitPull(), at: clonePath) + await XCTAssertEqualAsync(try await shellOut(to: .readFile(at: filePath), logger: .init(label: "test")).stdout, "Hello again") } - func testArgumentQuoting() throws { - XCTAssertEqual(try shellOut(to: "echo".checked, - arguments: ["foo ; echo bar".quoted]), + func testArgumentQuoting() async throws { + await XCTAssertEqualAsync(try await shellOut(to: "echo".checked, + arguments: ["foo ; echo bar".quoted]).stdout, "foo ; echo bar") - XCTAssertEqual(try shellOut(to: "echo".checked, - arguments: ["foo ; echo bar".verbatim]), + await XCTAssertEqualAsync(try await shellOut(to: "echo".checked, + arguments: ["foo ; echo bar".verbatim]).stdout, "foo\nbar") } @@ -199,4 +216,50 @@ class ShellOutTests: XCTestCase { XCTAssertEqual(Argument.url(.init(string: "https://example.com")!).string, "https://example.com") } + + func test_git_tags() async throws { + // setup + let tempDir = NSTemporaryDirectory().appending("test_stress_\(UUID())") + defer { + try? Foundation.FileManager.default.removeItem(atPath: tempDir) + } + let sampleGitRepoName = "ErrNo" + let sampleGitRepoZipFile = fixturesDirectory() + .appendingPathComponent("\(sampleGitRepoName).zip").path + let path = "\(tempDir)/\(sampleGitRepoName)" + try! Foundation.FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: false, attributes: nil) + try! await ShellOut.shellOut(to: .init(command: "unzip", arguments: [sampleGitRepoZipFile.quoted]), at: tempDir) + + // MUT + await XCTAssertEqualAsync(try await shellOut(to: try ShellOutCommand(command: "git", arguments: ["tag"]), + at: path).stdout, """ + 0.2.0 + 0.2.1 + 0.2.2 + 0.2.3 + 0.2.4 + 0.2.5 + 0.3.0 + 0.4.0 + 0.4.1 + 0.4.2 + 0.5.0 + 0.5.1 + 0.5.2 + v0.0.1 + v0.0.2 + v0.0.3 + v0.0.4 + v0.0.5 + v0.1.0 + """) + } +} + +extension ShellOutTests { + func fixturesDirectory(path: String = #file) -> URL { + let url = URL(fileURLWithPath: path) + let testsDir = url.deletingLastPathComponent() + return testsDir.appendingPathComponent("Fixtures") + } }