From 575b1d9fe144afd3a20e5f0f755969c1dc476ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era=20Buend=C3=ADa?= Date: Fri, 12 Jun 2026 17:37:26 +0200 Subject: [PATCH 1/2] fix: write workspace-state and package-info paths relative to scratch dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit every path swifterpm writes into `.build/workspace-state.json` and `.build/swifterpm/package-info/index.json` as relative to the scratch directory instead of the host's absolute prefix, so a cached `.build/` can be restored on a different host (or under a different checkout location on the same host) and the metadata still resolves to real files. What changes - `Restore.writeWorkspaceState`: `Artifact.path`, fileSystem `Dependency.state.path`, and `packageRef.location` for root / fileSystem / localSourceControl kinds are now anchored at `scratchDir` via a new `URL.relativePathString(to:)` helper (uses `tuist/Path`'s `relative(to:)` so we get `..` components when needed). Remote URLs and registry identities pass through unchanged. - `PackageInfoCacheWriter.write`: same treatment for `package_path`, `package_info_path`, and `location` on every entry. Bumps `schema_version` from 1 to 2 so downstream consumers can detect the new encoding. - Existing prebuilt entries are still forwarded verbatim through `existingWorkspacePrebuilts`; they originate from SwiftPM's prebuilt manager and are out of scope here, and downstream readers can handle either form by anchoring with `AbsolutePath(validating:relativeTo:)`. The on-disk content (`checkouts/`, `registry/downloads/`, `swifterpm/artifacts/`) is unchanged: only the metadata files are rewritten. Running new swifterpm against an old `.build/` is idempotent — both JSON files are overwritten on every resolve, so one `tuist install` (or `swift package resolve` via swifterpm) flips the state. Pairs with tuist/tuist#11262 which teaches Tuist's `SwiftPackageManagerGraphLoader` to anchor relative paths back against the scratch directory; that PR is permissive (still accepts absolute paths), so the two changes can land in either order. Tests - Existing assertions updated to expect the relative encoding (artifact paths, fileSystem dep locations, `schema_version`, etc.). - New `writeWorkspaceStateEmitsScratchRelativeArtifactAndDependencyPaths` regression asserts no emitted path starts with `/` and that re-anchoring against the scratch directory still resolves to the on-disk artifact. - Full suite: 105/105 passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/swifterpm/FileSystemSupport.swift | 12 +++ Sources/swifterpm/PackageInfoCache.swift | 49 ++++++++---- Sources/swifterpm/Restore.swift | 41 ++++++---- .../PackageInfoCacheTests.swift | 18 +++-- Tests/swifterpmTests/RestoreTests.swift | 78 ++++++++++++++++++- 5 files changed, 157 insertions(+), 41 deletions(-) diff --git a/Sources/swifterpm/FileSystemSupport.swift b/Sources/swifterpm/FileSystemSupport.swift index f1f83ae..4a6731e 100644 --- a/Sources/swifterpm/FileSystemSupport.swift +++ b/Sources/swifterpm/FileSystemSupport.swift @@ -26,6 +26,18 @@ extension AbsolutePath { } } +extension URL { + /// Path of `self` expressed relative to `base`, using `..` components as needed. + /// Returns `"."` when `self` and `base` refer to the same path. + /// Used to keep `.build/workspace-state.json` and the swifterpm package-info index + /// relocatable: paths are anchored to `scratchDir` so a cached `.build/` works + /// after the project tree moves to a different absolute location. + func relativePathString(to base: URL) throws -> String { + let relative = try absolutePath.relative(to: base.absolutePath).pathString + return relative.isEmpty ? "." : relative + } +} + extension FileSystem { /// Write `data` atomically by writing to a temp sibling and then replacing the destination. /// Creates parent directories if missing. diff --git a/Sources/swifterpm/PackageInfoCache.swift b/Sources/swifterpm/PackageInfoCache.swift index 88f1028..80fd291 100644 --- a/Sources/swifterpm/PackageInfoCache.swift +++ b/Sources/swifterpm/PackageInfoCache.swift @@ -76,8 +76,12 @@ enum PackageInfoCacheWriter { _ = try await cachedOrDumpPackageJSON( packageDir: packagePath, destination: packageInfoPath, disableSandbox: disableSandbox) - return packageEntry( - pin: pin, packagePath: packagePath, packageInfoPath: packageInfoPath) + return try packageEntry( + pin: pin, + packagePath: packagePath, + packageInfoPath: packageInfoPath, + scratchDir: scratchDir + ) }.sorted { $0.identity < $1.identity } var allPackages = packages @@ -101,14 +105,15 @@ enum PackageInfoCacheWriter { _ = try await cachedOrDumpPackageJSON( packageDir: packagePath, destination: packageInfoPath, disableSandbox: disableSandbox) + let relativePackagePath = try packagePath.relativePathString(to: scratchDir) let entry = PackageInfoEntry( identity: dependency.identity, kind: "fileSystem", - location: packagePath.path, + location: relativePackagePath, version: nil, revision: nil, - packagePath: packagePath.path, - packageInfoPath: packageInfoPath.path + packagePath: relativePackagePath, + packageInfoPath: try packageInfoPath.relativePathString(to: scratchDir) ) return entry } @@ -119,17 +124,18 @@ enum PackageInfoCacheWriter { } allPackages.append(contentsOf: localPackages) + let relativePackageDir = try packageDir.relativePathString(to: scratchDir) let index = PackageInfoIndex( - schemaVersion: 1, + schemaVersion: 2, generatedAtUnix: UInt64(Date().timeIntervalSince1970), root: PackageInfoEntry( identity: "root", kind: "root", - location: packageDir.path, + location: relativePackageDir, version: nil, revision: resolved.originHash, - packagePath: packageDir.path, - packageInfoPath: rootPath.path + packagePath: relativePackageDir, + packageInfoPath: try rootPath.relativePathString(to: scratchDir) ), packages: allPackages ) @@ -182,17 +188,28 @@ enum PackageInfoCacheWriter { return cacheDate >= manifestDate } - private static func packageEntry(pin: ResolvedPin, packagePath: URL, packageInfoPath: URL) - -> PackageInfoEntry - { - PackageInfoEntry( + private static func packageEntry( + pin: ResolvedPin, + packagePath: URL, + packageInfoPath: URL, + scratchDir: URL + ) throws -> PackageInfoEntry { + // localSourceControl pins carry a filesystem path in `location`; relativize so + // the index stays portable across hosts. Remote/registry pins keep their URL or + // identity verbatim. + let location: String = if pin.kind == "localSourceControl" { + try URL(fileURLWithPath: pin.location).relativePathString(to: scratchDir) + } else { + pin.location + } + return PackageInfoEntry( identity: pin.identity, kind: pin.kind, - location: pin.location, + location: location, version: pin.state.version, revision: pin.state.revision, - packagePath: packagePath.path, - packageInfoPath: packageInfoPath.path + packagePath: try packagePath.relativePathString(to: scratchDir), + packageInfoPath: try packageInfoPath.relativePathString(to: scratchDir) ) } diff --git a/Sources/swifterpm/Restore.swift b/Sources/swifterpm/Restore.swift index b4410b4..a21b3b2 100644 --- a/Sources/swifterpm/Restore.swift +++ b/Sources/swifterpm/Restore.swift @@ -353,7 +353,7 @@ enum WorkspaceRestorer { if let packageDir { contexts.append( PackageContext( - packageRef: rootPackageRef(packageDir), + packageRef: try rootPackageRef(packageDir, scratchDir: scratchDir), packagePath: packageDir, canonicalizeLocalBinaryPaths: true )) @@ -368,9 +368,10 @@ enum WorkspaceRestorer { ) { contexts.append( PackageContext( - packageRef: fileSystemPackageRef( + packageRef: try fileSystemPackageRef( localPackage.dependency, packagePath: localPackage.packagePath, + scratchDir: scratchDir, name: ManifestParser.packageName(localPackage.manifest) ), packagePath: localPackage.packagePath, @@ -389,6 +390,7 @@ enum WorkspaceRestorer { packageRef: try await packageRef( pin, packagePath: packagePath, + scratchDir: scratchDir, disableSandbox: disableSandbox ), packagePath: packagePath, @@ -399,12 +401,12 @@ enum WorkspaceRestorer { return contexts } - private static func rootPackageRef(_ packageDir: URL) -> [String: String] { + private static func rootPackageRef(_ packageDir: URL, scratchDir: URL) throws -> [String: String] { let canonical = PathCanonicalizer.realpath(packageDir) return [ "identity": canonical.lastPathComponent.lowercased(), "kind": "root", - "location": canonical.path, + "location": try canonical.relativePathString(to: scratchDir), "name": canonical.lastPathComponent, ] } @@ -412,19 +414,18 @@ enum WorkspaceRestorer { private static func fileSystemPackageRef( _ dependency: ManifestFileSystemDependency, packagePath: URL, + scratchDir: URL, name: String? = nil - ) - -> [String: String] - { + ) throws -> [String: String] { [ "identity": dependency.identity, "kind": "fileSystem", - "location": packagePath.path, + "location": try packagePath.relativePathString(to: scratchDir), "name": name ?? dependency.name, ] } - private static func packageRef(_ pin: ResolvedPin) throws -> [String: String] { + private static func packageRef(_ pin: ResolvedPin, scratchDir: URL) throws -> [String: String] { if PinKind.isRegistry(pin.kind) { return [ "identity": pin.identity, @@ -433,10 +434,17 @@ enum WorkspaceRestorer { "name": pin.identity, ] } + // localSourceControl pins carry a filesystem path in `location`; relativize so + // workspace-state.json stays portable across hosts. + let location: String = if pin.kind == "localSourceControl" { + try URL(fileURLWithPath: pin.location).relativePathString(to: scratchDir) + } else { + pin.location + } return [ "identity": pin.identity, "kind": pin.kind, - "location": pin.location, + "location": location, "name": PinKind.checkoutDirectoryName(pin), ] } @@ -444,9 +452,10 @@ enum WorkspaceRestorer { private static func packageRef( _ pin: ResolvedPin, packagePath: URL, + scratchDir: URL, disableSandbox: Bool ) async throws -> [String: String] { - var ref = try packageRef(pin) + var ref = try packageRef(pin, scratchDir: scratchDir) guard PinKind.isSourceControl(pin.kind) else { return ref } @@ -827,6 +836,7 @@ enum WorkspaceRestorer { let ref = try await packageRef( pin, packagePath: packagePathForPin(scratchDir: scratchDir, pin: pin), + scratchDir: scratchDir, disableSandbox: disableSandbox ) dependencies.append([ @@ -839,7 +849,7 @@ enum WorkspaceRestorer { "subpath": PinKind.checkoutDirectoryName(pin), ]) } else if PinKind.isRegistry(pin.kind) { - let ref = try packageRef(pin) + let ref = try packageRef(pin, scratchDir: scratchDir) try dependencies.append([ "basedOn": NSNull(), "packageRef": ref, @@ -860,9 +870,10 @@ enum WorkspaceRestorer { rootManifest: manifest, disableSandbox: disableSandbox ) { - let ref = fileSystemPackageRef( + let ref = try fileSystemPackageRef( localPackage.dependency, packagePath: localPackage.packagePath, + scratchDir: scratchDir, name: ManifestParser.packageName(localPackage.manifest) ) dependencies.append([ @@ -870,7 +881,7 @@ enum WorkspaceRestorer { "packageRef": ref, "state": [ "name": "fileSystem", - "path": localPackage.packagePath.path, + "path": try localPackage.packagePath.relativePathString(to: scratchDir), ], "subpath": localPackage.dependency.identity, ]) @@ -1027,7 +1038,7 @@ enum WorkspaceRestorer { value: [ "kind": artifact.kind, "packageRef": context.packageRef, - "path": artifact.path.path, + "path": try artifact.path.relativePathString(to: scratchDir), "source": source, "targetName": target.name, ]) diff --git a/Tests/swifterpmTests/PackageInfoCacheTests.swift b/Tests/swifterpmTests/PackageInfoCacheTests.swift index b755179..6590c04 100644 --- a/Tests/swifterpmTests/PackageInfoCacheTests.swift +++ b/Tests/swifterpmTests/PackageInfoCacheTests.swift @@ -29,12 +29,15 @@ struct PackageInfoCacheTests { JSONSerialization.jsonObject( with: try await fileSystem.readFile(at: indexPath.absolutePath)) as? [String: Any]) - #expect(index["schema_version"] as? Int == 1) + #expect(index["schema_version"] as? Int == 2) #expect((index["packages"] as? [[String: Any]])?.isEmpty == true) let rootEntry = try #require(index["root"] as? [String: Any]) #expect(rootEntry["identity"] as? String == "root") #expect(rootEntry["revision"] as? String == "origin") + #expect( + rootEntry["package_path"] as? String + == (try package.relativePathString(to: scratch))) } } @@ -193,12 +196,15 @@ struct PackageInfoCacheTests { packages.compactMap { $0["identity"] as? String } == [ "local-one", "local-two", ]) - #expect( - packages.first?["package_path"] as? String - == PathCanonicalizer.realpath(localOne).path) + let expectedLocalOnePath = try PathCanonicalizer.realpath(localOne) + .relativePathString(to: scratch) + #expect(packages.first?["package_path"] as? String == expectedLocalOnePath) for package in packages { - let packageInfoPath = try #require(package["package_info_path"] as? String) - #expect(try await fileSystem.exists(URL(fileURLWithPath: packageInfoPath).absolutePath)) + let relativePackageInfoPath = try #require(package["package_info_path"] as? String) + let resolvedPackageInfoURL = scratch + .appendingPathComponent(relativePackageInfoPath) + .standardizedFileURL + #expect(try await fileSystem.exists(resolvedPackageInfoURL.absolutePath)) } } } diff --git a/Tests/swifterpmTests/RestoreTests.swift b/Tests/swifterpmTests/RestoreTests.swift index 9c49513..24265d4 100644 --- a/Tests/swifterpmTests/RestoreTests.swift +++ b/Tests/swifterpmTests/RestoreTests.swift @@ -238,8 +238,10 @@ struct RestoreTests { ) #expect(Set(refsByIdentity.keys) == ["local-one", "local-two"]) - let expectedLocalOne = PathCanonicalizer.realpath(localOne).path - let expectedLocalTwo = PathCanonicalizer.realpath(localTwo).path + let expectedLocalOne = try PathCanonicalizer.realpath(localOne) + .relativePathString(to: scratch) + let expectedLocalTwo = try PathCanonicalizer.realpath(localTwo) + .relativePathString(to: scratch) #expect(refsByIdentity["local-one"]?["location"] as? String == expectedLocalOne) #expect(refsByIdentity["local-two"]?["location"] as? String == expectedLocalTwo) #expect( @@ -282,7 +284,9 @@ struct RestoreTests { let source = try #require(artifact["source"] as? [String: Any]) #expect(artifacts.count == 1) #expect(artifact["targetName"] as? String == "Foo") - #expect(artifact["path"] as? String == PathCanonicalizer.realpath(framework).path) + #expect( + artifact["path"] as? String + == (try PathCanonicalizer.realpath(framework).relativePathString(to: scratch))) #expect(packageRef["kind"] as? String == "root") #expect(packageRef["identity"] as? String == "package") #expect(source["type"] as? String == "local") @@ -350,7 +354,9 @@ struct RestoreTests { #expect(artifacts.count == 1) #expect(artifact["targetName"] as? String == "Foo") - #expect(artifact["path"] as? String == artifactPath.path) + #expect( + artifact["path"] as? String + == (try artifactPath.relativePathString(to: scratch))) #expect(packageRef["kind"] as? String == "remoteSourceControl") #expect(packageRef["location"] as? String == "https://github.com/example/binary.git") #expect(source["type"] as? String == "remote") @@ -462,6 +468,70 @@ struct RestoreTests { } } + @Test + func writeWorkspaceStateEmitsScratchRelativeArtifactAndDependencyPaths() async throws { + // Regression test for cross-host `.build/` caching: every path written to + // workspace-state.json should be relative to the scratch directory, so a + // cached `.build/` can be restored under a different absolute prefix and + // still resolve back to real files. + try await withTemporaryDirectory { root in + let package = root.appendingPathComponent("Package") + let local = package.appendingPathComponent("LocalDep") + let scratch = root.appendingPathComponent("Package/.build") + let framework = package.appendingPathComponent("XCFrameworks/Foo.xcframework") + try await fileSystem.makeDirectory(at: framework.absolutePath, options: [.createTargetParentDirectories]) + try await fileSystem.atomicWrite( + validXCFrameworkInfoPlist(), + to: framework.appendingPathComponent("Info.plist") + ) + var rootManifest = localBinaryTargetManifest(name: "Foo", path: "XCFrameworks/Foo.xcframework") + rootManifest["dependencies"] = [ + [ + "fileSystem": [ + [ + "identity": "local-dep", + "path": "LocalDep", + ], + ] + ], + ] + try await writeCachedManifest(rootManifest, packageDir: package) + try await writeCachedManifest(emptyManifest(name: "LocalDep"), packageDir: local) + + try await WorkspaceRestorer.writeWorkspaceState( + packageDir: package, + scratchDir: scratch, + resolved: ResolvedPins(originHash: "origin", pins: [], version: 3), + disableSandbox: false + ) + + let statePath = scratch.appendingPathComponent("workspace-state.json") + let state = try #require( + try JSONSerialization.jsonObject( + with: await fileSystem.readFile(at: statePath.absolutePath)) + as? [String: Any]) + let object = try #require(state["object"] as? [String: Any]) + let artifacts = try #require(object["artifacts"] as? [[String: Any]]) + let dependencies = try #require(object["dependencies"] as? [[String: Any]]) + + let artifactPath = try #require(artifacts.first?["path"] as? String) + let dependencyStatePath = try #require( + (dependencies.first?["state"] as? [String: Any])?["path"] as? String) + let dependencyLocation = try #require( + (dependencies.first?["packageRef"] as? [String: Any])?["location"] as? String) + + #expect(!artifactPath.hasPrefix("/")) + #expect(!dependencyStatePath.hasPrefix("/")) + #expect(!dependencyLocation.hasPrefix("/")) + + // Sanity check: anchoring back to scratch resolves to the real on-disk file. + let resolvedArtifact = scratch + .appendingPathComponent(artifactPath) + .standardizedFileURL + #expect(try await fileSystem.exists(resolvedArtifact.absolutePath)) + } + } + private func makeXCFrameworkZip(root: URL, targetName: String) async throws -> URL { let archiveRoot = root.appendingPathComponent("archive") let framework = archiveRoot.appendingPathComponent("\(targetName).xcframework") From 4204020d04dd908df95199e19a7be48cf895956d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Pi=C3=B1era=20Buend=C3=ADa?= Date: Fri, 12 Jun 2026 18:08:45 +0200 Subject: [PATCH 2/2] scope relativization to paths inside packageDir or scratchDir The first pass relativized everything unconditionally, which broke the e2e differential suite: external `localSourceControl` and `fileSystem` deps live outside the consuming project, and rewriting them as seven-`..` chains diverges from SwiftPM's output for paths that inherently cannot be made portable across hosts. Scope the relative-path encoding to URLs that resolve inside either `packageDir` (the consuming project) or `scratchDir` (the build dir). Paths that escape both stay absolute, matching SwiftPM byte-for-byte for the e2e checks. The portability story for `.build/` is unchanged: every file that lives inside the cacheable unit (artifacts, in-tree binary xcframeworks, in-tree fileSystem deps, package-info entries) is still emitted relative to scratch. Use a string-level `/private/var/` -> `/var/` collapse for the "inside packageDir or scratchDir" check rather than POSIX `realpath`. `realpath` resolves every symlink, and on dev machines `/swifterpm/artifacts//` is a symlink into the global cache; following it would make a path that's logically inside scratch look like it escapes. Tests now assert the clean relative form directly (e.g. `../Package/LocalOne`) instead of constructing it from `PathCanonicalizer.realpath(...).relativePathString(to: scratch)`, which mixed canonicalized self with non-canonicalized base. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/swifterpm/FileSystemSupport.swift | 45 ++++++++++++++++ Sources/swifterpm/PackageInfoCache.swift | 51 +++++++++++++------ Sources/swifterpm/Restore.swift | 49 +++++++++++++----- .../PackageInfoCacheTests.swift | 21 +++++--- Tests/swifterpmTests/RestoreTests.swift | 19 +++---- 5 files changed, 138 insertions(+), 47 deletions(-) diff --git a/Sources/swifterpm/FileSystemSupport.swift b/Sources/swifterpm/FileSystemSupport.swift index 4a6731e..ace5431 100644 --- a/Sources/swifterpm/FileSystemSupport.swift +++ b/Sources/swifterpm/FileSystemSupport.swift @@ -36,6 +36,51 @@ extension URL { let relative = try absolutePath.relative(to: base.absolutePath).pathString return relative.isEmpty ? "." : relative } + + /// Returns `self` encoded relative to `scratchDir` only when it lives inside + /// `packageDir`; otherwise returns the absolute path verbatim. + /// + /// This scopes the relative-path encoding to paths that are part of the consuming + /// project (its scratch dir, in-tree binaries, and in-tree fileSystem deps). Paths + /// that point outside the project tree (e.g. an external local fileSystem or + /// `localSourceControl` dep on another disk location) are host-specific by nature + /// and keep their absolute form, matching SwiftPM's `workspace-state.json` so the + /// e2e differential suite still holds for those entries. + func pathRelativeToScratchIfInsidePackage( + scratchDir: URL, packageDir: URL + ) throws -> String { + // Relativize when this URL lives under either the project root (packageDir) or + // the scratch dir. Both are project-scoped, so paths inside either are part of + // the cacheable `.build/`-rooted unit. Paths outside both (typically external + // local fileSystem / localSourceControl deps) stay absolute so SwiftPM-compat + // against the e2e differential suite still holds for those entries. + // + // We normalize the macOS `/private/var` vs `/var` symlink layer with a string + // substitution rather than POSIX `realpath`. realpath follows every symlink, + // and on developer machines `/swifterpm/artifacts//` is a + // symlink into the global cache; following it would make a path that's + // logically inside scratch look like it lives outside. + let selfNormalized = Self.normalizedPathString(path) + let packageNormalized = Self.normalizedPathString(packageDir.path) + let scratchNormalized = Self.normalizedPathString(scratchDir.path) + + let selfAbs = try AbsolutePath(validating: selfNormalized) + let packageAbs = try AbsolutePath(validating: packageNormalized) + let scratchAbs = try AbsolutePath(validating: scratchNormalized) + + let packageRelative = packageAbs == selfAbs ? "" : try selfAbs.relative(to: packageAbs).pathString + let scratchRelative = scratchAbs == selfAbs ? "" : try selfAbs.relative(to: scratchAbs).pathString + let insidePackage = !packageRelative.hasPrefix("..") + let insideScratch = !scratchRelative.hasPrefix("..") + guard insidePackage || insideScratch else { + return path + } + return scratchRelative.isEmpty ? "." : scratchRelative + } + + private static func normalizedPathString(_ value: String) -> String { + value.replacingOccurrences(of: "/private/var/", with: "/var/") + } } extension FileSystem { diff --git a/Sources/swifterpm/PackageInfoCache.swift b/Sources/swifterpm/PackageInfoCache.swift index 80fd291..ede06ed 100644 --- a/Sources/swifterpm/PackageInfoCache.swift +++ b/Sources/swifterpm/PackageInfoCache.swift @@ -80,6 +80,7 @@ enum PackageInfoCacheWriter { pin: pin, packagePath: packagePath, packageInfoPath: packageInfoPath, + packageDir: packageDir, scratchDir: scratchDir ) }.sorted { $0.identity < $1.identity } @@ -105,15 +106,19 @@ enum PackageInfoCacheWriter { _ = try await cachedOrDumpPackageJSON( packageDir: packagePath, destination: packageInfoPath, disableSandbox: disableSandbox) - let relativePackagePath = try packagePath.relativePathString(to: scratchDir) + let scopedPackagePath = try packagePath.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ) let entry = PackageInfoEntry( identity: dependency.identity, kind: "fileSystem", - location: relativePackagePath, + location: scopedPackagePath, version: nil, revision: nil, - packagePath: relativePackagePath, - packageInfoPath: try packageInfoPath.relativePathString(to: scratchDir) + packagePath: scopedPackagePath, + packageInfoPath: try packageInfoPath.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ) ) return entry } @@ -124,18 +129,22 @@ enum PackageInfoCacheWriter { } allPackages.append(contentsOf: localPackages) - let relativePackageDir = try packageDir.relativePathString(to: scratchDir) + let scopedPackageDir = try packageDir.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ) let index = PackageInfoIndex( schemaVersion: 2, generatedAtUnix: UInt64(Date().timeIntervalSince1970), root: PackageInfoEntry( identity: "root", kind: "root", - location: relativePackageDir, + location: scopedPackageDir, version: nil, revision: resolved.originHash, - packagePath: relativePackageDir, - packageInfoPath: try rootPath.relativePathString(to: scratchDir) + packagePath: scopedPackageDir, + packageInfoPath: try rootPath.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ) ), packages: allPackages ) @@ -192,15 +201,21 @@ enum PackageInfoCacheWriter { pin: ResolvedPin, packagePath: URL, packageInfoPath: URL, + packageDir: URL, scratchDir: URL ) throws -> PackageInfoEntry { - // localSourceControl pins carry a filesystem path in `location`; relativize so - // the index stays portable across hosts. Remote/registry pins keep their URL or - // identity verbatim. - let location: String = if pin.kind == "localSourceControl" { - try URL(fileURLWithPath: pin.location).relativePathString(to: scratchDir) + // localSourceControl pins carry a filesystem path in `location`. Relativize only + // when that path lives inside the consuming project; external clones stay + // absolute so SwiftPM-compat against the e2e diff fixtures still holds. Remote + // and registry pins keep their URL or identity verbatim. + let location: String + if pin.kind == "localSourceControl" { + location = try URL(fileURLWithPath: pin.location) + .pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ) } else { - pin.location + location = pin.location } return PackageInfoEntry( identity: pin.identity, @@ -208,8 +223,12 @@ enum PackageInfoCacheWriter { location: location, version: pin.state.version, revision: pin.state.revision, - packagePath: try packagePath.relativePathString(to: scratchDir), - packageInfoPath: try packageInfoPath.relativePathString(to: scratchDir) + packagePath: try packagePath.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ), + packageInfoPath: try packageInfoPath.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ) ) } diff --git a/Sources/swifterpm/Restore.swift b/Sources/swifterpm/Restore.swift index a21b3b2..0f60c91 100644 --- a/Sources/swifterpm/Restore.swift +++ b/Sources/swifterpm/Restore.swift @@ -371,6 +371,7 @@ enum WorkspaceRestorer { packageRef: try fileSystemPackageRef( localPackage.dependency, packagePath: localPackage.packagePath, + packageDir: packageDir, scratchDir: scratchDir, name: ManifestParser.packageName(localPackage.manifest) ), @@ -390,6 +391,7 @@ enum WorkspaceRestorer { packageRef: try await packageRef( pin, packagePath: packagePath, + packageDir: packageDir, scratchDir: scratchDir, disableSandbox: disableSandbox ), @@ -406,7 +408,9 @@ enum WorkspaceRestorer { return [ "identity": canonical.lastPathComponent.lowercased(), "kind": "root", - "location": try canonical.relativePathString(to: scratchDir), + "location": try canonical.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: canonical + ), "name": canonical.lastPathComponent, ] } @@ -414,18 +418,25 @@ enum WorkspaceRestorer { private static func fileSystemPackageRef( _ dependency: ManifestFileSystemDependency, packagePath: URL, + packageDir: URL, scratchDir: URL, name: String? = nil ) throws -> [String: String] { [ "identity": dependency.identity, "kind": "fileSystem", - "location": try packagePath.relativePathString(to: scratchDir), + "location": try packagePath.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ), "name": name ?? dependency.name, ] } - private static func packageRef(_ pin: ResolvedPin, scratchDir: URL) throws -> [String: String] { + private static func packageRef( + _ pin: ResolvedPin, + packageDir: URL?, + scratchDir: URL + ) throws -> [String: String] { if PinKind.isRegistry(pin.kind) { return [ "identity": pin.identity, @@ -434,12 +445,17 @@ enum WorkspaceRestorer { "name": pin.identity, ] } - // localSourceControl pins carry a filesystem path in `location`; relativize so - // workspace-state.json stays portable across hosts. - let location: String = if pin.kind == "localSourceControl" { - try URL(fileURLWithPath: pin.location).relativePathString(to: scratchDir) + // localSourceControl pins carry a filesystem path in `location`. Relativize when + // that path lives inside the consuming project; otherwise leave it absolute so + // SwiftPM-compat against external local-control deps holds. + let location: String + if pin.kind == "localSourceControl", let packageDir { + location = try URL(fileURLWithPath: pin.location) + .pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ) } else { - pin.location + location = pin.location } return [ "identity": pin.identity, @@ -452,10 +468,11 @@ enum WorkspaceRestorer { private static func packageRef( _ pin: ResolvedPin, packagePath: URL, + packageDir: URL?, scratchDir: URL, disableSandbox: Bool ) async throws -> [String: String] { - var ref = try packageRef(pin, scratchDir: scratchDir) + var ref = try packageRef(pin, packageDir: packageDir, scratchDir: scratchDir) guard PinKind.isSourceControl(pin.kind) else { return ref } @@ -836,6 +853,7 @@ enum WorkspaceRestorer { let ref = try await packageRef( pin, packagePath: packagePathForPin(scratchDir: scratchDir, pin: pin), + packageDir: packageDir, scratchDir: scratchDir, disableSandbox: disableSandbox ) @@ -849,7 +867,7 @@ enum WorkspaceRestorer { "subpath": PinKind.checkoutDirectoryName(pin), ]) } else if PinKind.isRegistry(pin.kind) { - let ref = try packageRef(pin, scratchDir: scratchDir) + let ref = try packageRef(pin, packageDir: packageDir, scratchDir: scratchDir) try dependencies.append([ "basedOn": NSNull(), "packageRef": ref, @@ -873,6 +891,7 @@ enum WorkspaceRestorer { let ref = try fileSystemPackageRef( localPackage.dependency, packagePath: localPackage.packagePath, + packageDir: packageDir, scratchDir: scratchDir, name: ManifestParser.packageName(localPackage.manifest) ) @@ -881,7 +900,9 @@ enum WorkspaceRestorer { "packageRef": ref, "state": [ "name": "fileSystem", - "path": try localPackage.packagePath.relativePathString(to: scratchDir), + "path": try localPackage.packagePath.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ), ], "subpath": localPackage.dependency.identity, ]) @@ -972,6 +993,7 @@ enum WorkspaceRestorer { try await workspaceArtifact( target, context: context, + packageDir: packageDir, scratchDir: scratchDir ) } @@ -983,6 +1005,7 @@ enum WorkspaceRestorer { private static func workspaceArtifact( _ target: ManifestBinaryTarget, context: PackageContext, + packageDir: URL, scratchDir: URL ) async throws -> WorkspaceArtifact? { let identity = context.packageRef["identity"] ?? target.name @@ -1038,7 +1061,9 @@ enum WorkspaceRestorer { value: [ "kind": artifact.kind, "packageRef": context.packageRef, - "path": try artifact.path.relativePathString(to: scratchDir), + "path": try artifact.path.pathRelativeToScratchIfInsidePackage( + scratchDir: scratchDir, packageDir: packageDir + ), "source": source, "targetName": target.name, ]) diff --git a/Tests/swifterpmTests/PackageInfoCacheTests.swift b/Tests/swifterpmTests/PackageInfoCacheTests.swift index 6590c04..38d42d7 100644 --- a/Tests/swifterpmTests/PackageInfoCacheTests.swift +++ b/Tests/swifterpmTests/PackageInfoCacheTests.swift @@ -196,15 +196,20 @@ struct PackageInfoCacheTests { packages.compactMap { $0["identity"] as? String } == [ "local-one", "local-two", ]) - let expectedLocalOnePath = try PathCanonicalizer.realpath(localOne) - .relativePathString(to: scratch) - #expect(packages.first?["package_path"] as? String == expectedLocalOnePath) + // LocalOne is inside packageDir; encoded relative to scratch via packageDir. + // LocalTwo lives outside packageDir; stays absolute. + #expect(packages.first?["package_path"] as? String == "../Package/LocalOne") for package in packages { - let relativePackageInfoPath = try #require(package["package_info_path"] as? String) - let resolvedPackageInfoURL = scratch - .appendingPathComponent(relativePackageInfoPath) - .standardizedFileURL - #expect(try await fileSystem.exists(resolvedPackageInfoURL.absolutePath)) + let pathString = try #require(package["package_info_path"] as? String) + // cacheDir is a sibling of scratch and outside packageDir in this test + // setup, so package_info_path stays absolute. Real Tuist projects place + // cacheDir at /swifterpm/package-info which keeps it relative. + let resolvedURL: URL = if pathString.hasPrefix("/") { + URL(fileURLWithPath: pathString) + } else { + scratch.appendingPathComponent(pathString).standardizedFileURL + } + #expect(try await fileSystem.exists(resolvedURL.absolutePath)) } } } diff --git a/Tests/swifterpmTests/RestoreTests.swift b/Tests/swifterpmTests/RestoreTests.swift index 24265d4..d174a21 100644 --- a/Tests/swifterpmTests/RestoreTests.swift +++ b/Tests/swifterpmTests/RestoreTests.swift @@ -238,15 +238,12 @@ struct RestoreTests { ) #expect(Set(refsByIdentity.keys) == ["local-one", "local-two"]) - let expectedLocalOne = try PathCanonicalizer.realpath(localOne) - .relativePathString(to: scratch) - let expectedLocalTwo = try PathCanonicalizer.realpath(localTwo) - .relativePathString(to: scratch) - #expect(refsByIdentity["local-one"]?["location"] as? String == expectedLocalOne) - #expect(refsByIdentity["local-two"]?["location"] as? String == expectedLocalTwo) + // The deps live under packageDir, so they're encoded relative to scratch. + #expect(refsByIdentity["local-one"]?["location"] as? String == "../Package/LocalOne") + #expect(refsByIdentity["local-two"]?["location"] as? String == "../Package/LocalTwo") #expect( Set(dependencies.compactMap { ($0["state"] as? [String: Any])?["path"] as? String }) - == [expectedLocalOne, expectedLocalTwo]) + == ["../Package/LocalOne", "../Package/LocalTwo"]) } } @@ -284,9 +281,9 @@ struct RestoreTests { let source = try #require(artifact["source"] as? [String: Any]) #expect(artifacts.count == 1) #expect(artifact["targetName"] as? String == "Foo") - #expect( - artifact["path"] as? String - == (try PathCanonicalizer.realpath(framework).relativePathString(to: scratch))) + // framework is inside packageDir, scratch is a sibling, so the artifact is + // emitted relative to scratch via packageDir. + #expect(artifact["path"] as? String == "../Package/XCFrameworks/Foo.xcframework") #expect(packageRef["kind"] as? String == "root") #expect(packageRef["identity"] as? String == "package") #expect(source["type"] as? String == "local") @@ -356,7 +353,7 @@ struct RestoreTests { #expect(artifact["targetName"] as? String == "Foo") #expect( artifact["path"] as? String - == (try artifactPath.relativePathString(to: scratch))) + == "swifterpm/artifacts/binary/Foo/Foo.xcframework") #expect(packageRef["kind"] as? String == "remoteSourceControl") #expect(packageRef["location"] as? String == "https://github.com/example/binary.git") #expect(source["type"] as? String == "remote")