diff --git a/Package.swift b/Package.swift index 078fc6b..2ae6d84 100644 --- a/Package.swift +++ b/Package.swift @@ -41,7 +41,7 @@ let targets: [PackageDescription.Target] = [ dependencies: ["XcodeProj", "SelectiveTestLogger"], swiftSettings: sharedSettings), .target(name: "Git", - dependencies: ["SelectiveTestShell", "SelectiveTestLogger", "PathKit"], + dependencies: ["SelectiveTestShell", "SelectiveTestLogger", "PathKit", "Workspace"], swiftSettings: sharedSettings), .target(name: "SelectiveTestLogger", dependencies: ["Rainbow"], @@ -56,6 +56,9 @@ let targets: [PackageDescription.Target] = [ name: "DependencyCalculatorTests", dependencies: ["DependencyCalculator", "Workspace", "PathKit", "SelectiveTestingCore"], resources: [.copy("ExamplePackages")]), + .testTarget( + name: "WorkspaceTests", + dependencies: ["Workspace"]), .plugin( name: "SelectiveTestingPlugin", capability: .command( diff --git a/Sources/DependencyCalculator/DependencyCalculator.swift b/Sources/DependencyCalculator/DependencyCalculator.swift index f76db9d..e41edcc 100644 --- a/Sources/DependencyCalculator/DependencyCalculator.swift +++ b/Sources/DependencyCalculator/DependencyCalculator.swift @@ -6,25 +6,38 @@ import Foundation import Workspace import PathKit import SelectiveTestLogger +import Git + +public struct ChangedTarget: Hashable { + let target: TargetIdentity + enum ChangeType: Hashable { + case direct(lines: Int) + case indirect(by: TargetIdentity) + } + let changeType: ChangeType +} extension WorkspaceInfo { - public func affectedTargets(changedFiles: Set) -> Set { - var result = Set() + public func affectedTargets(changedFiles: Set) -> Set { + var result = Set() - changedFiles.forEach { path in + changedFiles.forEach { metadata in - if let targets = targetsForFiles[path] { - result = result.union(targets) + if let targets = targetsForFiles[metadata.path] { + result = result.union(targets.map({ targetIdentity in + ChangedTarget(target: targetIdentity, changeType: .direct(lines: metadata.changedLines)) + })) } - else if let targetFromFolder = targetForFolder(path) { - result.insert(targetFromFolder) + else if let targetFromFolder = targetForFolder(metadata.path) { + result.insert(ChangedTarget(target: targetFromFolder, changeType: .direct(lines: metadata.changedLines))) } else { - Logger.message("Changed file at \(path) appears not to belong to any target") + Logger.message("Changed file at \(metadata.path) appears not to belong to any target") } } let indirectlyAffected = indirectlyAffectedTargets(targets: result) + return result.union(indirectlyAffected) } @@ -34,11 +47,13 @@ extension WorkspaceInfo { }?.value } - public func indirectlyAffectedTargets(targets: Set) -> Set { + public func indirectlyAffectedTargets(targets: Set) -> Set { var result = Set() targets.forEach { targetAffected in - let affected = dependencyStructure.affected(by: targetAffected) + let affected = dependencyStructure.affected(by: targetAffected.target).map { targetIdentity in + ChangedTarget(target: targetIdentity, changeType: .indirect(by: targetAffected)) + } let nextLevelAffected = indirectlyAffectedTargets(targets: affected) result = result.union(affected).union(nextLevelAffected) } diff --git a/Sources/Git/Git+Changeset.swift b/Sources/Git/Git+Changeset.swift index cc26f5c..f6d7209 100644 --- a/Sources/Git/Git+Changeset.swift +++ b/Sources/Git/Git+Changeset.swift @@ -6,9 +6,10 @@ import Foundation import PathKit import SelectiveTestLogger import SelectiveTestShell +import Workspace -extension Git { - public func changeset(baseBranch: String, verbose: Bool = false) throws -> Set { +extension Git { + public 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) @@ -23,26 +24,30 @@ extension Git { currentBranch = "HEAD" } - let changes = try Shell.execOrFail("cd \(path) && git diff \(baseBranch)..\(currentBranch) --name-only") + let changes = try Shell.execOrFail("cd \(path) && git diff \(baseBranch)..\(currentBranch) --stat") let changesTrimmed = changes.trimmingCharacters(in: .whitespacesAndNewlines) guard !changesTrimmed.isEmpty else { return Set() } - return Set(changesTrimmed.components(separatedBy: .newlines).map { gitRoot + $0 } ) + return Set(changesTrimmed.components(separatedBy: .newlines).compactMap { line in + ChangesetMetadata(gitStatOutput: line) + }) } - public func localChangeset() throws -> Set { + public func localChangeset() throws -> Set { let gitRoot = try repoRoot() - let changes = try Shell.execOrFail("cd \(gitRoot) && git diff --name-only") + let changes = try Shell.execOrFail("cd \(gitRoot) && git diff --stat") let changesTrimmed = changes.trimmingCharacters(in: .whitespacesAndNewlines) guard !changesTrimmed.isEmpty else { return Set() } - return Set(changesTrimmed.components(separatedBy: .newlines).map { gitRoot + $0 } ) + return Set(changesTrimmed.components(separatedBy: .newlines).compactMap { line in + ChangesetMetadata(gitStatOutput: line) + }) } } diff --git a/Sources/SelectiveTestingCore/SelectiveTestingTool.swift b/Sources/SelectiveTestingCore/SelectiveTestingTool.swift index 0e1e1f2..330ac5d 100644 --- a/Sources/SelectiveTestingCore/SelectiveTestingTool.swift +++ b/Sources/SelectiveTestingCore/SelectiveTestingTool.swift @@ -54,7 +54,7 @@ public final class SelectiveTestingTool { public func run() async throws -> Set { // 1. Identify changed files - let changeset: Set + let changeset: Set Logger.message("Finding changeset for repository at \(basePath)") if let baseBranch { @@ -153,6 +153,9 @@ public final class SelectiveTestingTool { let type: TargetType let path: String let testTarget: Bool + let changedLinesCount: Int? + let indirectlyChangedByTarget: String? + let indirectlyChangedByTargetAtPath: String? } let array = Array(affectedTargets.map { target in diff --git a/Sources/Workspace/ChangesetMetadata.swift b/Sources/Workspace/ChangesetMetadata.swift new file mode 100644 index 0000000..d59710e --- /dev/null +++ b/Sources/Workspace/ChangesetMetadata.swift @@ -0,0 +1,45 @@ +// +// Created by Mike Gerasymenko +// + +import Foundation +import PathKit + +public struct ChangesetMetadata: Hashable { + public let path: Path + public let changedLines: Int + + static let regex = try! NSRegularExpression(pattern: "^(.*)\\|\\s*(\\d*)") + + init(path: Path, changedLines: Int) { + self.path = path + self.changedLines = changedLines + } + + public init?(gitStatOutput: String) { + // Output example + // Tests/SelectiveTestingTests/IntegrationTestTool.swift | 28 ++++++++++++++-------------- + + guard let result = ChangesetMetadata.regex.firstMatch(in: gitStatOutput, + range: NSRange(location: 0, length: gitStatOutput.count)) else { + return nil + } + + let filenameRange = result.range(at: 1) + let lineChangeRange = result.range(at: 2) + + guard filenameRange.location != NSNotFound, + lineChangeRange.location != NSNotFound, + let filenameSwiftRange = Range(filenameRange, in: gitStatOutput), + let lineSwiftRange = Range(lineChangeRange, in: gitStatOutput) + else { + return nil + } + + let filename = gitStatOutput[filenameSwiftRange] + let lineChange = gitStatOutput[lineSwiftRange] + + path = Path(filename.trimmingCharacters(in: .whitespacesAndNewlines)) + changedLines = Int(lineChange.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 + } +} diff --git a/Tests/DependencyCalculatorTests/DependencyCalculatorTests.swift b/Tests/DependencyCalculatorTests/DependencyCalculatorTests.swift index d9941d3..a446bf5 100644 --- a/Tests/DependencyCalculatorTests/DependencyCalculatorTests.swift +++ b/Tests/DependencyCalculatorTests/DependencyCalculatorTests.swift @@ -5,6 +5,7 @@ import Foundation import XCTest @testable import DependencyCalculator +@testable import Workspace import Workspace import PathKit import SelectiveTestingCore @@ -40,9 +41,10 @@ final class DependencyCalculatorTests: XCTestCase { // given let (depsGraph, mainApp, module, submodule, mainAppTests, moduleTests, submoduleTests) = depStructure() - let files = Set([Path("/folder/submodule/file.swift")]) + let files = Set([ChangesetMetadata(path: Path("/folder/submodule/file.swift"), + changedLines: 1)]) - let graph = WorkspaceInfo(files: [submodule: files], + let graph = WorkspaceInfo(files: [submodule: Set(files.map(\.path))], folders: [:], dependencyStructure: depsGraph, candidateTestPlan: nil) @@ -58,9 +60,10 @@ final class DependencyCalculatorTests: XCTestCase { // given let (depsGraph, mainApp, _, _, mainAppTests, _, _) = depStructure() - let files = Set([Path("/folder/submodule/file.swift")]) + let files = Set([ChangesetMetadata(path: Path("/folder/submodule/file.swift"), + changedLines: 1)]) - let graph = WorkspaceInfo(files: [mainApp: files], + let graph = WorkspaceInfo(files: [mainApp: Set(files.map(\.path))], folders: [:], dependencyStructure: depsGraph, candidateTestPlan: nil) @@ -76,9 +79,10 @@ final class DependencyCalculatorTests: XCTestCase { // given let (depsGraph, mainApp, module, _, mainAppTests, moduleTests, _) = depStructure() - let files = Set([Path("/folder/submodule/file.swift")]) + let files = Set([ChangesetMetadata(path: Path("/folder/submodule/file.swift"), + changedLines: 1)]) - let graph = WorkspaceInfo(files: [module: files], + let graph = WorkspaceInfo(files: [module: Set(files.map(\.path))], folders: [:], dependencyStructure: depsGraph, candidateTestPlan: nil) diff --git a/Tests/WorkspaceTests/GitTests.swift b/Tests/WorkspaceTests/GitTests.swift new file mode 100644 index 0000000..3597781 --- /dev/null +++ b/Tests/WorkspaceTests/GitTests.swift @@ -0,0 +1,47 @@ +// +// Created by Mike Gerasymenko +// + +import XCTest +@testable import Workspace + +final class ChangesetMetadataTests: XCTestCase { + func testOutputParsing() { + let sampleOutput = """ + Package.swift | 3 +++ + Sources/DependencyCalculator/DependencyCalculator.swift | 2 +- + Sources/DependencyCalculator/DependencyGraph.swift | 8 ++++---- + Sources/DependencyCalculator/PackageMetadata.swift | 8 ++++---- + Sources/Git/ChangesetMetadata.swift | 40 ++++++++++++++++++++++++++++++++++++++++ + Sources/Git/Git+Changeset.swift | 18 +++++++++++------- + Sources/SelectiveTestingCore/SelectiveTestingTool.swift | 22 +++++++++++----------- + Sources/TestConfigurator/TestConfigurator.swift | 16 ++++++++-------- + Sources/Workspace/Target.swift | 63 +++++++++++++++++---------------------------------------------- + Tests/DependencyCalculatorTests/DependencyCalculatorTests.swift | 12 ++++++------ + Tests/DependencyCalculatorTests/PackageMetadataTests.swift | 16 +++++++--------- + Tests/GitTests/GitTests.swift | 33 +++++++++++++++++++++++++++++++++ + Tests/SelectiveTestingTests/IntegrationTestTool.swift | 28 ++++++++++++++-------------- + 13 files changed, 159 insertions(+), 110 deletions(-) +""" + + let data = sampleOutput.components(separatedBy: .newlines).compactMap { line in + ChangesetMetadata(gitStatOutput: line) + } + + XCTAssertEqual(data.count, 13) + XCTAssertEqual(data, [ChangesetMetadata(path: "Package.swift", changedLines: 3), + ChangesetMetadata(path: "Sources/DependencyCalculator/DependencyCalculator.swift", changedLines: 2), + ChangesetMetadata(path: "Sources/DependencyCalculator/DependencyGraph.swift", changedLines: 8), + ChangesetMetadata(path: "Sources/DependencyCalculator/PackageMetadata.swift", changedLines: 8), + ChangesetMetadata(path: "Sources/Git/ChangesetMetadata.swift", changedLines: 40), + ChangesetMetadata(path: "Sources/Git/Git+Changeset.swift", changedLines: 18), + ChangesetMetadata(path: "Sources/SelectiveTestingCore/SelectiveTestingTool.swift", changedLines: 22), + ChangesetMetadata(path: "Sources/TestConfigurator/TestConfigurator.swift", changedLines: 16), + ChangesetMetadata(path: "Sources/Workspace/Target.swift", changedLines: 63), + ChangesetMetadata(path: "Tests/DependencyCalculatorTests/DependencyCalculatorTests.swift", changedLines: 12), + ChangesetMetadata(path: "Tests/DependencyCalculatorTests/PackageMetadataTests.swift", changedLines: 16), + ChangesetMetadata(path: "Tests/GitTests/GitTests.swift", changedLines: 33), + ChangesetMetadata(path: "Tests/SelectiveTestingTests/IntegrationTestTool.swift", changedLines: 28), + ]) + } +}