diff --git a/Sources/DependencyCalculator/PackageMetadata.swift b/Sources/DependencyCalculator/PackageMetadata.swift index 1dca36a..0d29412 100644 --- a/Sources/DependencyCalculator/PackageMetadata.swift +++ b/Sources/DependencyCalculator/PackageMetadata.swift @@ -25,7 +25,7 @@ struct PackageTargetMetadata: Sendable { flags.append("--ignore-lock") } - let manifest = try Shell.execOrFail("(cd \(path) && swift package dump-package \(flags.joined(separator: " ")))") + let manifest = try Shell.execOrFail("(cd \(path.string.shellQuoted) && swift package dump-package \(flags.joined(separator: " ")))") .trimmingCharacters(in: .newlines) guard let manifestData = manifest.data(using: .utf8), let manifestJson = try JSONSerialization.jsonObject(with: manifestData, options: []) as? [String: Any], diff --git a/Sources/Git/Git+Changeset.swift b/Sources/Git/Git+Changeset.swift index cbd1b0c..590cf13 100644 --- a/Sources/Git/Git+Changeset.swift +++ b/Sources/Git/Git+Changeset.swift @@ -13,7 +13,7 @@ public extension Git { func changeset(baseBranch: String, verbose: Bool = false) throws -> Set { let gitRoot = try repoRoot() - var currentBranch = try Shell.execOrFail("(cd \(gitRoot) && git branch --show-current)").trimmingCharacters(in: .newlines) + var currentBranch = try Shell.execOrFail("(cd \(gitRoot.string.shellQuoted) && git branch --show-current)").trimmingCharacters(in: .newlines) if verbose { logger.info("Current branch: \(currentBranch)") logger.info("Base branch: \(baseBranch)") @@ -25,7 +25,7 @@ public extension Git { currentBranch = "HEAD" } - let changes = try Shell.execOrFail("(cd \(gitRoot) && git diff '\(baseBranch)'..'\(currentBranch)' --name-only)") + let changes = try Shell.execOrFail("(cd \(gitRoot.string.shellQuoted) && git diff \(baseBranch.shellQuoted)..\(currentBranch.shellQuoted) --name-only)") let changesTrimmed = changes.trimmingCharacters(in: .whitespacesAndNewlines) guard !changesTrimmed.isEmpty else { @@ -38,7 +38,7 @@ public extension Git { func localChangeset() throws -> Set { let gitRoot = try repoRoot() - let changes = try Shell.execOrFail("(cd \(gitRoot) && git diff HEAD --name-only)") + let changes = try Shell.execOrFail("(cd \(gitRoot.string.shellQuoted) && git diff HEAD --name-only)") let changesTrimmed = changes.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/Git/Git.swift b/Sources/Git/Git.swift index e8894c1..9dd6623 100644 --- a/Sources/Git/Git.swift +++ b/Sources/Git/Git.swift @@ -14,7 +14,7 @@ public struct Git { } public func repoRoot() throws -> Path { - let gitPath = try Shell.execOrFail("(cd \(path) && git rev-parse --show-toplevel)").trimmingCharacters(in: .newlines) + let gitPath = try Shell.execOrFail("(cd \(path.string.shellQuoted) && git rev-parse --show-toplevel)").trimmingCharacters(in: .newlines) return Path(gitPath).absolute() } @@ -22,7 +22,7 @@ public struct Git { public func find(pattern: String) throws -> Set { let gitRoot = try repoRoot() - let result = try Shell.exec("(cd \(gitRoot) && git ls-files | grep \(pattern))").0.trimmingCharacters(in: .newlines) + let result = try Shell.exec("(cd \(gitRoot.string.shellQuoted) && git ls-files | grep \(pattern.shellQuoted))").0.trimmingCharacters(in: .newlines) guard !result.isEmpty else { return Set() diff --git a/Sources/SelectiveTestShell/String+Shell.swift b/Sources/SelectiveTestShell/String+Shell.swift new file mode 100644 index 0000000..8087c5d --- /dev/null +++ b/Sources/SelectiveTestShell/String+Shell.swift @@ -0,0 +1,13 @@ +// +// Created by Mike Gerasymenko +// + +import Foundation + +public extension String { + /// Wrap string in single quotes for safe usage in shell commands. + var shellQuoted: String { + guard !isEmpty else { return "''" } + return "'" + self.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } +} diff --git a/Tests/SelectiveTestingTests/SelectiveTestingProjectTests.swift b/Tests/SelectiveTestingTests/SelectiveTestingProjectTests.swift index 9794d18..938f59f 100644 --- a/Tests/SelectiveTestingTests/SelectiveTestingProjectTests.swift +++ b/Tests/SelectiveTestingTests/SelectiveTestingProjectTests.swift @@ -2,6 +2,7 @@ // Created by Mike Gerasymenko // +import Foundation import PathKit @testable import SelectiveTestingCore import SelectiveTestShell @@ -33,6 +34,40 @@ struct SelectiveTestingProjectTests { ])) } + @Test + func projectBasePathWithSpaces() async throws { + // given + let testTool = try IntegrationTestTool() + defer { try? testTool.tearDown() } + + let projectRoot = testTool.projectPath.string.shellQuoted + let originalName = "ExampleProject.xcodeproj" + let spacedName = "Example Project.xcodeproj" + try Shell.execOrFail("(cd \(projectRoot) && git checkout main)") + try Shell.execOrFail("(cd \(projectRoot) && git mv \(originalName.shellQuoted) \(spacedName.shellQuoted))") + try Shell.execOrFail("(cd \(projectRoot) && git commit -m 'Rename project with spaces')") + try Shell.execOrFail("(cd \(projectRoot) && git checkout feature)") + try Shell.execOrFail("(cd \(projectRoot) && git merge main)") + + let renamedProject = testTool.projectPath + spacedName + let tool = try testTool.createSUT(config: nil, + basePath: renamedProject) + + // when + try testTool.changeFile(at: renamedProject + "project.pbxproj") + + // then + let result = try await tool.run() + let expectedTargets: Set = Set([ + TargetIdentity.project(path: renamedProject, targetName: "ExampleProject", testTarget: false), + TargetIdentity.project(path: renamedProject, targetName: "ExampleProjectTests", testTarget: true), + TargetIdentity.project(path: renamedProject, targetName: "ExampleProjectUITests", testTarget: true), + TargetIdentity.project(path: renamedProject, targetName: "ExmapleTargetLibrary", testTarget: false), + TargetIdentity.project(path: renamedProject, targetName: "ExmapleTargetLibraryTests", testTarget: true), + ]) + #expect(result == expectedTargets) + } + @Test func projectDeepGroupPathChange_turbo() async throws { // given