From edf2d98408035c4ddca1e8cffb84a7cf69302388 Mon Sep 17 00:00:00 2001 From: Mike Gerasimenko Date: Sat, 15 Nov 2025 23:56:27 +0100 Subject: [PATCH] Prune targets not connected to workspace --- .../DependencyGraph.swift | 9 ++- Sources/Workspace/DependencyGraph.swift | 34 ++++++++++ Sources/Workspace/WorkspaceInfo.swift | 29 +++++++++ .../DependencyCalculatorTests.swift | 63 +++++++++++++++++++ 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/Sources/DependencyCalculator/DependencyGraph.swift b/Sources/DependencyCalculator/DependencyGraph.swift index fadadcc..c515078 100644 --- a/Sources/DependencyCalculator/DependencyGraph.swift +++ b/Sources/DependencyCalculator/DependencyGraph.swift @@ -153,6 +153,7 @@ extension WorkspaceInfo { folders: folders, dependencyStructure: resultDependencies, candidateTestPlans: candidateTestPlans) + let finalWorkspaceInfo: WorkspaceInfo if let config { let additionalBasePath: Path if path.extension == "xcworkspace" || path.extension == "xcodeproj" { @@ -161,10 +162,14 @@ extension WorkspaceInfo { additionalBasePath = path } // Process additional config - return processAdditional(config: config, workspaceInfo: workspaceInfo, basePath: additionalBasePath) + finalWorkspaceInfo = processAdditional(config: config, + workspaceInfo: workspaceInfo, + basePath: additionalBasePath) } else { - return workspaceInfo + finalWorkspaceInfo = workspaceInfo } + + return finalWorkspaceInfo.pruningDisconnectedTargets() } static func processAdditional(config: WorkspaceInfo.AdditionalConfig, diff --git a/Sources/Workspace/DependencyGraph.swift b/Sources/Workspace/DependencyGraph.swift index 8ea9722..07de3c0 100644 --- a/Sources/Workspace/DependencyGraph.swift +++ b/Sources/Workspace/DependencyGraph.swift @@ -54,4 +54,38 @@ public struct DependencyGraph { return DependencyGraph(dependsOn: map) } + + public func reachableTargets(startingFrom roots: Set) -> Set { + guard !roots.isEmpty else { return [] } + + var visited = Set() + var stack = Array(roots) + + while let current = stack.popLast() { + if visited.contains(current) { + continue + } + visited.insert(current) + + let dependencies = dependsOn[current] ?? Set() + for dependency in dependencies where !visited.contains(dependency) { + stack.append(dependency) + } + } + + return visited + } + + public func filteringTargets(_ allowed: Set) -> DependencyGraph { + guard !allowed.isEmpty else { return DependencyGraph(dependsOn: [:]) } + + var filtered: [TargetIdentity: Set] = [:] + + for target in allowed { + let dependencies = (dependsOn[target] ?? Set()).intersection(allowed) + filtered[target] = dependencies + } + + return DependencyGraph(dependsOn: filtered) + } } diff --git a/Sources/Workspace/WorkspaceInfo.swift b/Sources/Workspace/WorkspaceInfo.swift index aaef492..e3cf3b0 100644 --- a/Sources/Workspace/WorkspaceInfo.swift +++ b/Sources/Workspace/WorkspaceInfo.swift @@ -97,3 +97,32 @@ public extension WorkspaceInfo { public let dependencies: [String: [String]] } } + +public extension WorkspaceInfo { + func pruningDisconnectedTargets() -> WorkspaceInfo { + let projectTargets = Set(files.keys.filter { $0.type == .project }) + guard !projectTargets.isEmpty else { return self } + + var reachable = dependencyStructure.reachableTargets(startingFrom: projectTargets).union(projectTargets) + guard !reachable.isEmpty else { return self } + + let reachablePackageRoots = Set(reachable + .filter { $0.type == .package } + .map { $0.path }) + + if !reachablePackageRoots.isEmpty { + for target in files.keys where target.type == .package && reachablePackageRoots.contains(target.path) { + reachable.insert(target) + } + } + + let filteredFiles = files.filter { reachable.contains($0.key) } + let filteredFolders = folders.filter { reachable.contains($0.value) } + let filteredDependencies = dependencyStructure.filteringTargets(reachable) + + return WorkspaceInfo(files: filteredFiles, + folders: filteredFolders, + dependencyStructure: filteredDependencies, + candidateTestPlans: candidateTestPlans) + } +} diff --git a/Tests/DependencyCalculatorTests/DependencyCalculatorTests.swift b/Tests/DependencyCalculatorTests/DependencyCalculatorTests.swift index f3a9109..e74e34b 100644 --- a/Tests/DependencyCalculatorTests/DependencyCalculatorTests.swift +++ b/Tests/DependencyCalculatorTests/DependencyCalculatorTests.swift @@ -92,4 +92,67 @@ struct DependencyCalculatorTests { // then #expect(affected == Set([module, moduleTests, mainApp, mainAppTests])) } + + @Test + func filtersUnreferencedPackagesWhenWorkspaceHasProjects() throws { + let project = TargetIdentity.project(path: Path("/workspace/App.xcodeproj"), + targetName: "App", + testTarget: false) + let usedPackage = TargetIdentity.package(path: Path("/workspace/Packages/Used"), + targetName: "UsedTarget", + testTarget: false) + let unusedPackage = TargetIdentity.package(path: Path("/workspace/Packages/Unused"), + targetName: "UnusedTarget", + testTarget: false) + + let files: [TargetIdentity: Set] = [ + project: [Path("/workspace/App/App.swift")], + usedPackage: [Path("/workspace/Packages/Used/Source.swift")], + unusedPackage: [Path("/workspace/Packages/Unused/Source.swift")] + ] + + let dependencies = DependencyGraph(dependsOn: [ + project: Set([usedPackage]), + usedPackage: Set() + ]) + + let info = WorkspaceInfo(files: files, + folders: [:], + dependencyStructure: dependencies, + candidateTestPlan: nil) + + let pruned = info.pruningDisconnectedTargets() + + #expect(pruned.files.keys.contains(project)) + #expect(pruned.files.keys.contains(usedPackage)) + #expect(!pruned.files.keys.contains(unusedPackage)) + } + + @Test + func keepsPackagesWhenNoProjectsPresent() throws { + let packageA = TargetIdentity.package(path: Path("/workspace/Packages/A"), + targetName: "ATarget", + testTarget: false) + let packageB = TargetIdentity.package(path: Path("/workspace/Packages/B"), + targetName: "BTarget", + testTarget: false) + + let files: [TargetIdentity: Set] = [ + packageA: [Path("/workspace/Packages/A/file.swift")], + packageB: [Path("/workspace/Packages/B/file.swift")] + ] + + let dependencies = DependencyGraph(dependsOn: [ + packageA: Set([packageB]) + ]) + + let info = WorkspaceInfo(files: files, + folders: [:], + dependencyStructure: dependencies, + candidateTestPlan: nil) + + let pruned = info.pruningDisconnectedTargets() + + #expect(pruned.files.keys == files.keys) + } }