diff --git a/Sources/Commands/PackageCommands/AddProduct.swift b/Sources/Commands/PackageCommands/AddProduct.swift index 2a0ab921b28..246cbe0740f 100644 --- a/Sources/Commands/PackageCommands/AddProduct.swift +++ b/Sources/Commands/PackageCommands/AddProduct.swift @@ -15,7 +15,6 @@ import Basics import CoreCommands import Foundation import PackageGraph -import SwiftParser @_spi(PackageRefactor) import SwiftRefactor import SwiftSyntax import TSCBasic @@ -55,30 +54,7 @@ extension SwiftPackageCommand { var targets: [String] = [] func run(_ swiftCommandState: SwiftCommandState) throws { - let workspace = try swiftCommandState.getActiveWorkspace() - - guard let packagePath = try swiftCommandState.getWorkspaceRoot().packages.first else { - throw StringError("unknown package") - } - - // Load the manifest file - let fileSystem = workspace.fileSystem - let manifestPath = packagePath.appending("Package.swift") - let manifestContents: ByteString - do { - manifestContents = try fileSystem.readFileContents(manifestPath) - } catch { - throw StringError("cannot find package manifest in \(manifestPath)") - } - - // Parse the manifest. - let manifestSyntax = manifestContents.withData { data in - data.withUnsafeBytes { buffer in - buffer.withMemoryRebound(to: UInt8.self) { buffer in - Parser.parse(source: buffer) - } - } - } + let (manifestSyntax, manifestPath) = try swiftCommandState.readPackageManifestAsSyntaxTree() // Map the product type. let type: ProductDescription.ProductType = switch self.type { @@ -101,7 +77,7 @@ extension SwiftPackageCommand { ) try editResult.applyEdits( - to: fileSystem, + to: swiftCommandState.getActiveWorkspace().fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: !globalOptions.logging.quiet @@ -109,4 +85,3 @@ extension SwiftPackageCommand { } } } - diff --git a/Sources/Commands/PackageCommands/AddTarget.swift b/Sources/Commands/PackageCommands/AddTarget.swift index 58cfb4dd996..454aa36f79c 100644 --- a/Sources/Commands/PackageCommands/AddTarget.swift +++ b/Sources/Commands/PackageCommands/AddTarget.swift @@ -16,7 +16,6 @@ import CoreCommands import Foundation import PackageGraph import PackageModel -import SwiftParser @_spi(PackageRefactor) import SwiftRefactor import SwiftSyntax import SwiftSyntaxBuilder @@ -76,35 +75,12 @@ extension SwiftPackageCommand { var testingLibrary: AddPackageTarget.TestHarness = .default func run(_ swiftCommandState: SwiftCommandState) async throws { - let workspace = try swiftCommandState.getActiveWorkspace() - - guard let packagePath = try swiftCommandState.getWorkspaceRoot().packages.first else { - throw StringError("unknown package") - } - - // Load the manifest file - let fileSystem = workspace.fileSystem - let manifestPath = packagePath.appending("Package.swift") - let manifestContents: ByteString - do { - manifestContents = try fileSystem.readFileContents(manifestPath) - } catch { - throw StringError("cannot find package manifest in \(manifestPath)") - } - - // Parse the manifest. - let manifestSyntax = manifestContents.withData { data in - data.withUnsafeBytes { buffer in - buffer.withMemoryRebound(to: UInt8.self) { buffer in - Parser.parse(source: buffer) - } - } - } + let (manifestSyntax, manifestPath) = try swiftCommandState.readPackageManifestAsSyntaxTree() // Move sources into their own folder if they're directly in `./Sources`. try await self.moveSingleTargetSources( - workspace: workspace, - packagePath: packagePath, + workspace: swiftCommandState.getActiveWorkspace(), + packagePath: manifestPath, verbose: !self.globalOptions.logging.quiet, observabilityScope: swiftCommandState.observabilityScope ) @@ -140,7 +116,7 @@ extension SwiftPackageCommand { ) try editResult.applyEdits( - to: fileSystem, + to: swiftCommandState.getActiveWorkspace().fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: !self.globalOptions.logging.quiet @@ -150,7 +126,7 @@ extension SwiftPackageCommand { try self.addAuxiliaryFiles( target: target, testHarness: self.testingLibrary, - fileSystem: fileSystem, + fileSystem: swiftCommandState.getActiveWorkspace().fileSystem, rootPath: manifestPath.parentDirectory ) } diff --git a/Sources/Commands/PackageCommands/AddTargetDependency.swift b/Sources/Commands/PackageCommands/AddTargetDependency.swift index 47cded60850..98e71895fd5 100644 --- a/Sources/Commands/PackageCommands/AddTargetDependency.swift +++ b/Sources/Commands/PackageCommands/AddTargetDependency.swift @@ -16,7 +16,6 @@ import CoreCommands import Foundation import PackageGraph import PackageModel -import SwiftParser @_spi(PackageRefactor) import SwiftRefactor import SwiftSyntax import TSCBasic @@ -43,30 +42,7 @@ extension SwiftPackageCommand { var package: String? func run(_ swiftCommandState: SwiftCommandState) throws { - let workspace = try swiftCommandState.getActiveWorkspace() - - guard let packagePath = try swiftCommandState.getWorkspaceRoot().packages.first else { - throw StringError("unknown package") - } - - // Load the manifest file - let fileSystem = workspace.fileSystem - let manifestPath = packagePath.appending("Package.swift") - let manifestContents: ByteString - do { - manifestContents = try fileSystem.readFileContents(manifestPath) - } catch { - throw StringError("cannot find package manifest in \(manifestPath)") - } - - // Parse the manifest. - let manifestSyntax = manifestContents.withData { data in - data.withUnsafeBytes { buffer in - buffer.withMemoryRebound(to: UInt8.self) { buffer in - Parser.parse(source: buffer) - } - } - } + let (manifestSyntax, manifestPath) = try swiftCommandState.readPackageManifestAsSyntaxTree() let dependency: PackageTarget.Dependency if let package { @@ -84,7 +60,7 @@ extension SwiftPackageCommand { ) try editResult.applyEdits( - to: fileSystem, + to: swiftCommandState.getActiveWorkspace().fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: !globalOptions.logging.quiet diff --git a/Sources/Commands/PackageCommands/AddTargetPlugin.swift b/Sources/Commands/PackageCommands/AddTargetPlugin.swift index 6b513e407b7..60c93e5d1fb 100644 --- a/Sources/Commands/PackageCommands/AddTargetPlugin.swift +++ b/Sources/Commands/PackageCommands/AddTargetPlugin.swift @@ -16,7 +16,6 @@ import CoreCommands import Foundation import PackageGraph import PackageModel -import SwiftParser @_spi(PackageRefactor) import SwiftRefactor import SwiftSyntax import TSCBasic @@ -42,30 +41,7 @@ extension SwiftPackageCommand { var package: String? func run(_ swiftCommandState: SwiftCommandState) throws { - let workspace = try swiftCommandState.getActiveWorkspace() - - guard let packagePath = try swiftCommandState.getWorkspaceRoot().packages.first else { - throw StringError("unknown package") - } - - // Load the manifest file - let fileSystem = workspace.fileSystem - let manifestPath = packagePath.appending("Package.swift") - let manifestContents: ByteString - do { - manifestContents = try fileSystem.readFileContents(manifestPath) - } catch { - throw StringError("cannot find package manifest in \(manifestPath)") - } - - // Parse the manifest. - let manifestSyntax = manifestContents.withData { data in - data.withUnsafeBytes { buffer in - buffer.withMemoryRebound(to: UInt8.self) { buffer in - Parser.parse(source: buffer) - } - } - } + let (manifestSyntax, manifestPath) = try swiftCommandState.readPackageManifestAsSyntaxTree() let pluginUsage: PackageTarget.PluginUsage = .plugin(name: pluginName, package: package) @@ -78,7 +54,7 @@ extension SwiftPackageCommand { ) try editResult.applyEdits( - to: fileSystem, + to: swiftCommandState.getActiveWorkspace().fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: !globalOptions.logging.quiet @@ -86,4 +62,3 @@ extension SwiftPackageCommand { } } } - diff --git a/Sources/Commands/PackageCommands/Migrate.swift b/Sources/Commands/PackageCommands/Migrate.swift index d7485164f6e..ae80678267f 100644 --- a/Sources/Commands/PackageCommands/Migrate.swift +++ b/Sources/Commands/PackageCommands/Migrate.swift @@ -286,6 +286,7 @@ extension SwiftPackageCommand { switch error { case .cannotFindPackage, .cannotAddSettingsToPluginTarget, + .cannotFindDependencies, .existingDependency, .malformedManifest: break diff --git a/Sources/Commands/PackageCommands/SwiftCommandState+readPackageManifestAsSyntaxTree.swift b/Sources/Commands/PackageCommands/SwiftCommandState+readPackageManifestAsSyntaxTree.swift new file mode 100644 index 00000000000..05cf209c426 --- /dev/null +++ b/Sources/Commands/PackageCommands/SwiftCommandState+readPackageManifestAsSyntaxTree.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Basics +import CoreCommands +import Foundation +import PackageGraph +import PackageModel +import SwiftParser +import SwiftSyntax +import TSCBasic +import Workspace + +extension SwiftCommandState { + /// Read the package manifest of the current package on disk and parse it as a syntax tree. + func readPackageManifestAsSyntaxTree() throws -> (syntax: SourceFileSyntax, path: Basics.AbsolutePath) { + guard let packagePath = try getWorkspaceRoot().packages.first else { + throw StringError("unknown package") + } + + let manifestPath = packagePath.appending(component: Manifest.filename) + let manifestContents: ByteString + do { + manifestContents = try getActiveWorkspace().fileSystem.readFileContents(manifestPath) + } catch { + throw StringError("cannot find package manifest in \(manifestPath)") + } + + let sourceFileSyntax = manifestContents.withData { data in + data.withUnsafeBytes { buffer in + buffer.withMemoryRebound(to: UInt8.self) { buffer in + Parser.parse(source: buffer) + } + } + } + return (sourceFileSyntax, manifestPath) + } +} diff --git a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift index 14f31ba83b4..bfea18aa404 100644 --- a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift +++ b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift @@ -40,6 +40,7 @@ public struct SwiftPackageCommand: AsyncParsableCommand { PurgeCache.self, Reset.self, Update.self, + UpgradeDependencies.self, Describe.self, Init.self, Format.self, diff --git a/Sources/Commands/PackageCommands/UpgradeDependencies.swift b/Sources/Commands/PackageCommands/UpgradeDependencies.swift new file mode 100644 index 00000000000..b8e1fa92858 --- /dev/null +++ b/Sources/Commands/PackageCommands/UpgradeDependencies.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import ArgumentParser +import Basics +import CoreCommands +import PackageGraph +import PackageModel +@_spi(PackageRefactor) import SwiftRefactor +import SwiftSyntax +import Workspace + +import struct TSCUtility.Version + +extension SwiftPackageCommand { + /// Rewrites every `from:` or `exact:` version requirement in the package + /// manifest to its latest released version, including new major versions. + struct UpgradeDependencies: AsyncSwiftCommand { + static let configuration = CommandConfiguration( + commandName: "upgrade-dependencies", + abstract: "Upgrade all package dependencies with `from:` or `exact:` version requirements to their latest released version, including new major versions.", + helpNames: [.short, .long, .customLong("help", withSingleDash: true)] + ) + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + func run(_ swiftCommandState: SwiftCommandState) async throws { + let (manifestSyntax, manifestPath) = try swiftCommandState.readPackageManifestAsSyntaxTree() + let workspace = try swiftCommandState.getActiveWorkspace() + + let observabilityScope = swiftCommandState.observabilityScope + let context = try await UpgradePackageDependencies.Context( + resolvingLatestVersionIn: manifestSyntax + ) { dependency, currentVersion in + await workspace.latestVersion( + of: dependency, + currentVersion: currentVersion, + observabilityScope: observabilityScope + ) + } + + let edits = try UpgradePackageDependencies.textRefactor(syntax: manifestSyntax, in: context) + + try edits.applyEdits( + to: workspace.fileSystem, + manifest: manifestSyntax, + manifestPath: manifestPath, + verbose: !self.globalOptions.logging.quiet + ) + } + } +} + +extension UpgradePackageDependencies.Dependency { + /// A human-readable identifier for diagnostics — the URL for a + /// source-control dep, the identity for a registry dep. + fileprivate var displayName: String { + switch self { + case .sourceControl(let url): return url + case .registry(let identity): return identity + } + } + + fileprivate var packageReference: PackageReference { + switch self { + case .sourceControl(let url): + let sourceControlURL = SourceControlURL(url) + return .remoteSourceControl( + identity: PackageIdentity(url: sourceControlURL), + url: sourceControlURL + ) + case .registry(let identity): + return .registry(identity: PackageIdentity.plain(identity)) + } + } +} + +extension Workspace { + /// Returns the latest released version of `dependency` that the caller + /// is willing to upgrade to, or `nil` if it cannot be resolved. + /// + /// Pre-release versions (those with a SemVer pre-release suffix such as + /// `-alpha.1`) are excluded when `currentVersion` is itself not a + /// pre-release: a manifest pinned to `4.115.0` should not silently + /// follow a freshly-published `5.0.0-alpha.1`. When `currentVersion` is + /// already a pre-release the latest available version is returned, even + /// if it is itself a pre-release. + func latestVersion( + of dependency: UpgradePackageDependencies.Dependency, + currentVersion: String, + observabilityScope: ObservabilityScope + ) async -> String? { + // If the current version literal does not parse as a SemVer, treat it + // as a release for the purposes of this filter: hiding pre-releases is + // the safer default for the kinds of unparseable values seen in the + // wild (e.g. partially-edited manifests). + let allowsPrereleases = Version(currentVersion).map { !$0.prereleaseIdentifiers.isEmpty } ?? false + do { + let container = try await self.getContainer( + for: dependency.packageReference, + updateStrategy: .always, + observabilityScope: observabilityScope + ) + return try await container.toolsVersionsAppropriateVersionsDescending() + .first(where: { allowsPrereleases || $0.prereleaseIdentifiers.isEmpty })?.description + } catch { + observabilityScope.emit( + warning: "could not resolve latest version of \(dependency.displayName): \(error.interpolationDescription)" + ) + return nil + } + } +} diff --git a/Sources/_InternalTestSupport/GitRepositoryExtensions.swift b/Sources/_InternalTestSupport/GitRepositoryExtensions.swift index 46a83a67aa1..c97938ce737 100644 --- a/Sources/_InternalTestSupport/GitRepositoryExtensions.swift +++ b/Sources/_InternalTestSupport/GitRepositoryExtensions.swift @@ -53,13 +53,17 @@ package extension GitRepository { } /// Commit the staged changes. If the message is not provided a dummy message will be used for the commit. - func commit(message: String? = nil) throws { + func commit(message: String? = nil, allowEmpty: Bool = false) throws { // FIXME: We don't need to set these every time but we usually only commit once or twice for a test repo. try Process.checkNonZeroExit(args: Git.tool, "-C", self.path.pathString, "config", "user.email", "example@example.com") try Process.checkNonZeroExit(args: Git.tool, "-C", self.path.pathString, "config", "user.name", "Example Example") try Process.checkNonZeroExit(args: Git.tool, "-C", self.path.pathString, "config", "commit.gpgsign", "false") try Process.checkNonZeroExit(args: Git.tool, "-C", self.path.pathString, "config", "tag.gpgsign", "false") - try Process.checkNonZeroExit(args: Git.tool, "-C", self.path.pathString, "commit", "-m", message ?? "Add some files.") + var arguments = [Git.tool, "-C", self.path.pathString, "commit", "-m", message ?? "Add some files."] + if allowEmpty { + arguments.append("--allow-empty") + } + try Process.checkNonZeroExit(arguments: arguments) } /// Tag the git repo. diff --git a/Sources/_InternalTestSupport/SwiftTesting+Tags.swift b/Sources/_InternalTestSupport/SwiftTesting+Tags.swift index 0535e3d5c52..e7b35b4c27f 100644 --- a/Sources/_InternalTestSupport/SwiftTesting+Tags.swift +++ b/Sources/_InternalTestSupport/SwiftTesting+Tags.swift @@ -166,6 +166,7 @@ extension Tag.Feature.Command.Package { @Tag public static var ToolsVersion: Tag @Tag public static var Unedit: Tag @Tag public static var Update: Tag + @Tag public static var UpgradeDependencies: Tag } extension Tag.Feature.Command.PackageRegistry { diff --git a/Tests/CommandsTests/UpgradeDependenciesCommandTests.swift b/Tests/CommandsTests/UpgradeDependenciesCommandTests.swift new file mode 100644 index 00000000000..8adf924ca35 --- /dev/null +++ b/Tests/CommandsTests/UpgradeDependenciesCommandTests.swift @@ -0,0 +1,418 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Basics +@testable import Commands +import Foundation +import PackageGraph +import PackageLoading +import PackageModel +import PackageRegistry +import SourceControl +@_spi(PackageRefactor) import SwiftRefactor +import Testing +import Workspace +import _InternalTestSupport + +import struct SPMBuildCore.BuildSystemProvider +import struct TSCUtility.Version + +@discardableResult +fileprivate func execute( + _ args: [String] = [], + packagePath: AbsolutePath? = nil, + manifest: String? = nil, + env: Environment? = nil, + configuration: BuildConfiguration, + buildSystem: BuildSystemProvider.Kind +) async throws -> (stdout: String, stderr: String) { + var environment = env ?? [:] + if let manifest, let packagePath { + try localFileSystem.writeFileContents(packagePath.appending("Package.swift"), string: manifest) + } + + // don't ignore local packages when caching + environment["SWIFTPM_TESTS_PACKAGECACHE"] = "1" + return try await executeSwiftPackage( + packagePath, + configuration: configuration, + extraArgs: args, + env: environment, + buildSystem: buildSystem, + ) +} + +// Helper function to arbitrarily assert on manifest content +private func expectManifest(_ packagePath: AbsolutePath, _ callback: (String) throws -> Void) throws { + let manifestPath = packagePath.appending("Package.swift") + expectFileExists(at: manifestPath) + let contents: String = try localFileSystem.readFileContents(manifestPath) + try callback(contents) +} + +@Suite( + .tags( + .Feature.Command.Package.UpgradeDependencies, + ) +) +struct UpgradeDependenciesCommandTests { + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func upgradesFromVersionToLatestTag( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + + try GitRepository(path: fixturePath.appending("Foo")).tag(name: "2.0.0") + + // Smoke check that the package doesn't contain a 2.0.0 dependency initially. + try expectManifest(packageRoot) { contents in + #expect(!contents.contains(#"from: "2.0.0""#)) + } + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#"from: "2.0.0""#)) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func upgradeTwoDependenciesToLatestTag( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Complex", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("deck-of-playing-cards") + + try GitRepository(path: fixturePath.appending("PlayingCard")).tag(name: "2.0.0") + try GitRepository(path: fixturePath.appending("FisherYates")).tag(name: "3.0.0") + + // Smoke check that the manifest is on the pre-upgrade versions. + try expectManifest(packageRoot) { contents in + #expect(!contents.contains(#"from: "2.0.0""#)) + #expect(!contents.contains(#"from: "3.0.0""#)) + } + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#".package(url: "../PlayingCard", from: "2.0.0")"#)) + #expect(contents.contains(#".package(url: "../FisherYates", from: "3.0.0")"#)) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func skipsPreReleasesWhenCurrentVersionIsRelease( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + let foo = GitRepository(path: fixturePath.appending("Foo")) + + // Publish a pre-release on top of the fixture's 1.2.3 tag. + try foo.commit(allowEmpty: true) + try foo.tag(name: "2.0.0-alpha.1") + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#"from: "1.2.3""#)) + #expect(!contents.contains("2.0.0-alpha.1")) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func picksUpPreReleasesWhenCurrentVersionIsPreRelease( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + let foo = GitRepository(path: fixturePath.appending("Foo")) + + // Rewrite the client manifest to depend on a pre-release. + let manifestPath = packageRoot.appending("Package.swift") + let original: String = try localFileSystem.readFileContents(manifestPath) + try localFileSystem.writeFileContents( + manifestPath, + string: original.replacingOccurrences( + of: #"from: "1.0.0""#, + with: #"from: "1.0.0-beta.1""# + ) + ) + + try foo.commit(allowEmpty: true) + try foo.tag(name: "2.0.0-alpha.1") + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#"from: "2.0.0-alpha.1""#)) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func picksUpNewlyPublishedTagAfterInitialResolve( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + + // Populate `.build/checkouts` with the initial tag. + _ = try await execute( + ["resolve"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + // Smoke check that the package doesn't contain a 2.0.0 dependency initially. + try expectManifest(packageRoot) { contents in + #expect(!contents.contains(#"from: "2.0.0""#)) + } + + // Publish a new major version upstream after bar has checked out 1.2.3 as the Foo dependency. + let foo = GitRepository(path: fixturePath.appending("Foo")) + try foo.commit(allowEmpty: true) + try foo.tag(name: "2.0.0") + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents.contains(#"from: "2.0.0""#)) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func zeroDependenciesWithNewerVersionLeavesManifestUnchanged( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await fixture(name: "DependencyResolution/External/Simple", createGitRepo: true) { fixturePath in + let packageRoot = fixturePath.appending("Bar") + let manifestPath = packageRoot.appending("Package.swift") + + // The fixture ships pinning Foo to `from: "1.0.0"` but the git repo created by `createGitRepo` + // publishes it as 1.2.3. Bump the pin to 1.2.3 so that there is no newer version to upgrade to. + let original: String = try localFileSystem.readFileContents(manifestPath) + let manifest = original.replacingOccurrences( + of: #"from: "1.0.0""#, + with: #"from: "1.2.3""# + ) + try localFileSystem.writeFileContents(manifestPath, string: manifest) + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: packageRoot, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(packageRoot) { contents in + #expect(contents == manifest) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func unresolvableDependencyLeavesManifestUnchanged( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await testWithTemporaryDirectory { tmpPath in + let path = tmpPath.appending("PackageB") + try localFileSystem.createDirectory(path) + + let manifest = """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package( + name: "client", + dependencies: [ + .package(path: "invalid", from: "1.0.0"), + ], + targets: [ .target(name: "client", dependencies: [ "library" ]) ] + ) + """ + try localFileSystem.writeFileContents(path.appending("Package.swift"), string: manifest) + + _ = try await execute( + ["upgrade-dependencies"], + packagePath: path, + configuration: .debug, + buildSystem: buildSystem, + ) + + try expectManifest(path) { contents in + #expect(contents == manifest) + } + } + } + + @Test( + arguments: SupportedBuildSystemOnAllPlatforms + ) + func missingPackageManifestReportsUnknownPackage( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await testWithTemporaryDirectory { tmpPath in + await expectThrowsCommandExecutionError( + try await execute( + ["upgrade-dependencies"], + packagePath: tmpPath, + configuration: .debug, + buildSystem: buildSystem, + ) + ) { error in + #expect(error.stderr.contains("Could not find Package.swift")) + } + } + } + + @Test( + .disabled(if: ProcessInfo.hostOperatingSystem == .windows, "POSIX file permissions don't apply on Windows"), + arguments: SupportedBuildSystemOnAllPlatforms + ) + func unreadablePackageManifestReportsCannotFindManifest( + buildSystem: BuildSystemProvider.Kind + ) async throws { + try await testWithTemporaryDirectory { tmpPath in + let manifestPath = tmpPath.appending("Package.swift") + try localFileSystem.writeFileContents( + manifestPath, + string: """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package(name: "client") + """ + ) + + // Strip every permission bit so the manifest is unreadable to the + // current user. + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0))], + ofItemAtPath: manifestPath.pathString + ) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o644))], + ofItemAtPath: manifestPath.pathString + ) + } + + // Running as root may bypass POSIX read permissions, so the manifest would remain readable and the + // expected error would not fire. Skip in that case. + guard !FileManager.default.isReadableFile(atPath: manifestPath.pathString) else { + return + } + + await expectThrowsCommandExecutionError( + try await execute( + ["upgrade-dependencies"], + packagePath: tmpPath, + configuration: .debug, + buildSystem: buildSystem, + ) + ) { error in + #expect(error.stderr.contains("cannot find package manifest")) + } + } + } + + @Test + func latestVersionPicksUpNewVersionFromRegistry() async throws { + // We cannot inject a `MockRegistry` into the setup that runs the upgrade-dependencies command. The above checks + // already check the whole Package manifest edit path, so just check that `workspace.latestVersion` is able to + // pick up new versions from a package registry here. + let fs = InMemoryFileSystem() + try fs.createMockToolchain() + + let identity = PackageIdentity.plain("scope.name") + let packagePath = AbsolutePath.root + + let registry = MockRegistry( + filesystem: fs, + identityResolver: DefaultIdentityResolver(), + checksumAlgorithm: MockHashAlgorithm(), + fingerprintStorage: MockPackageFingerprintStorage(), + signingEntityStorage: MockPackageSigningEntityStorage() + ) + + let packageSource = InMemoryRegistryPackageSource( + fileSystem: fs, + path: .root.appending(components: "registry", "server", identity.description) + ) + try packageSource.writePackageContent() + + registry.addPackage(identity: identity, versions: ["1.0.0", "2.0.0", "3.1.0"], source: packageSource) + + let workspace = try Workspace._init( + fileSystem: fs, + environment: .mockEnvironment, + location: .init(forRootPackage: packagePath, fileSystem: fs), + customHostToolchain: .mockHostToolchain(fs), + customManifestLoader: MockManifestLoader(manifests: [:]), + customRegistryClient: registry.registryClient + ) + let observability = ObservabilitySystem.makeForTesting() + + let latest = await workspace.latestVersion( + of: .registry(identity: "scope.name"), + currentVersion: "1.0.0", + observabilityScope: observability.topScope + ) + + #expect(latest == "3.1.0") + expectNoDiagnostics(observability.diagnostics) + } +}