Summary
Make Tuist’s SwiftPM graph loader tolerant of relative paths inside .build/workspace-state.json and .build/swifterpm/package-info/index.json. Today it treats every path as absolute; this change anchors them against scratchDirectory via AbsolutePath(validating:relativeTo:), which leaves absolute paths untouched but resolves relative ones correctly.
This is the Tuist-side half of a two-PR change. The companion swifterpm PR (tuist/swifterpm#37) flips both metadata files to emit paths relative to scratchDirectory so a cached .build/ is portable across hosts (and across the same host moved to a new checkout location). Landing this loader change first means today’s absolute-path output keeps working unchanged, and once the swifterpm pin is bumped, the new relative-path output Just Works without further loader edits.
Why this matters
Right after #11258 someone asked whether .build/ could be cached. Walking through it:
- On CI, swifterpm’s
replaceWithCachedDirectory already copies the global SwifterPM cache into .build/ (Sources/swifterpm/FileSystemSupport.swift:122), so symlinks pointing into ~/.cache/swifterpm/... are not a blocker on CI.
- But
workspace-state.json still embeds the absolute scratch-dir prefix in Artifact.path, fileSystem Dependency.packageRef.path/location, and pre-existing Prebuilt.path/checkoutPath. The same goes for the swifterpm package-info index. Today’s loader calls AbsolutePath(validating: path) (without relativeTo:) at four sites, which throws on any relative string.
- That throw blocks the migration path. Switching to
AbsolutePath(validating: path, relativeTo: scratchDirectory) is a no-op for absolute paths and unlocks the swifterpm-side change without forcing a coordinated cutover.
Changes
cli/Sources/TuistLoader/Loaders/SwiftPackageManagerGraphLoader.swift:
packageFolder resolution for local/fileSystem/localSourceControl deps (line 152): now anchored to scratchDirectory.
targetToArtifactPaths entries (line 180): same.
mapPackagePrebuilts (lines 470-471): prebuilt.path and prebuilt.checkoutPath resolved against scratchDirectory. The function now takes scratchDirectory: and the caller threads it through.
SwifterPMPackageInfoCache.load (lines 522+): accepts both schema_version: 1 (old, absolute) and schema_version: 2 (new, relative). Each entry’s packagePath/packageInfoPath is resolved against scratchDirectory at load time, so packagesByPath keys stay absolute strings and existing lookup callers don’t change. The CachedPackageInfo struct now stores the resolved AbsolutePath instead of the raw Entry.
Fields converted from absolute to scratch-relative
Each field below is relativized to scratchDir only when the target path resolves inside the consuming project (either under packageDir or under scratchDir). Paths that escape both — typically external fileSystem / localSourceControl deps living on another part of the disk — stay absolute, matching SwiftPM’s output for entries that are inherently not portable across hosts. The /private/var vs /var symlink layer on macOS is collapsed with a string substitution rather than realpath, since following every symlink would resolve <scratch>/swifterpm/artifacts/<id>/<target> out into the global cache and make a logically-in-scratch path look like it escapes.
For reference, every field this PR touches in those two metadata files, and what each one points at on disk:
.build/workspace-state.json
| Field |
What the path targets on disk |
object.artifacts[].path |
The xcframework or artifactbundle binary itself, at <scratch>/swifterpm/artifacts/<id>/<target>/<target>.xcframework for remote / local-zip sources, or at the in-repo location for local non-zip xcframeworks |
object.dependencies[].packageRef.location (kinds root, fileSystem, localSourceControl) |
A source package directory on disk: the consuming project for root, an external local package for fileSystem / localSourceControl |
object.dependencies[].state.path (kind fileSystem) |
Same target as the matching packageRef.location |
object.prebuilts[].path / checkoutPath / includePath |
Extracted prebuilt libs (SwiftSyntax-style). Forwarded as-is — these entries are written by SwiftPM’s prebuilt manager, not by swifterpm, so this PR doesn’t rewrite them. Downstream loaders can still anchor them via AbsolutePath(validating:relativeTo:) |
Remote URLs (packageRef.location for remoteSourceControl) and registry identities (location for registry) are not paths and stay verbatim.
.build/swifterpm/package-info/index.json
| Field |
What the path targets on disk |
root.package_path, packages[].package_path |
The source package directory: under <scratch>/checkouts/... for SCM, <scratch>/registry/downloads/... for registry, or an external local path for fileSystem |
root.package_info_path, packages[].package_info_path |
The dumped per-package manifest JSON inside the cache directory (default <scratch>/swifterpm/package-info/...) |
root.location, packages[].location (kinds root, localSourceControl, fileSystem) |
Same target as the matching package_path |
How binary artifacts are scoped
For both the cross-host caching story and reviewer sanity, this is where binaries actually live:
cache.binaryArtifactDirectory(identity:, targetName:, checksum:) (swifterpm/Sources/swifterpm/Restore.swift:130) returns a checksum-namespaced path under the global SwifterPM cache: ~/.cache/swifterpm/artifacts/<identity>/<target>-<checksum-prefix>/. Two checksums of the same identity / target cannot collide.
downloadBinaryArtifact (Restore.swift:209) downloads the archive into cache.binaryArtifactArchivePath (also checksum-keyed), verifies the SHA-256, and extracts into that cached directory. Re-download is gated by validCachedBinaryArtifactArchive so checksum-good archives skip re-hashing.
artifactDirectory(scratchDir:, packageIdentity:, targetName:) (Restore.swift:468) is the per-project landing spot: <scratch>/swifterpm/artifacts/<id>/<target>/.
replaceScratchArtifact (Restore.swift:197) materialises the cached dir at that scratch path through fileSystem.replaceWithSymlinkedDirectory. Inside that helper (FileSystemSupport.swift:114) the branch is Environment.isCI → copy on CI, symlink on dev.
workspaceArtifact (Restore.swift:972) calls binaryArtifact(in: directory) against that scratch-side path and writes its location — which after this PR is swifterpm/artifacts/<id>/<target>/<target>.xcframework relative to scratchDir.
So artifact.path always resolves to a file inside <scratch>/swifterpm/artifacts/<id>/<target>/. The dev-vs-CI distinction is what makes the relative-path encoding actually useful for cross-host caching:
- On CI: the scratch-side directory is a real copy of the xcframework / artifactbundle.
.build/ is self-contained: the relative artifact.path plus the on-disk file resolves end-to-end without referencing the host’s home directory at all.
- On dev: the scratch-side entry is a symlink into
~/.cache/swifterpm/artifacts/.... Restoring .build/ from another machine works at the metadata layer (the relative path still anchors against scratch), but the symlink target won’t exist on the new host. Solution is either to cache ~/.cache/swifterpm/ alongside .build/, or to run one tuist install to re-create the symlinks (cheap — checksums match, no re-download).
Considered alternatives
- Wipe
.build/workspace-state.json on tuist install when the prefix doesn’t match. Smaller surface and avoids any schema change, but it forces a re-resolve on every cross-host restore and the metadata files are derived state anyway, not the source of truth. Once swifterpm writes relative paths, no rewrite is needed at all.
- String-replace the absolute prefix on read. Tighter coupling to swifterpm’s serialization format and brittle when scratchDir lives under
/private/var vs /var. The Path-based relative(to:) already handles that lexically.
Test plan
-
xcodebuild test -workspace Tuist.xcworkspace -scheme Tuist-Workspace -only-testing TuistLoaderTests/SwiftPackageManagerGraphLoaderTests — 11/11 passing locally, including the new load_whenWorkspaceStatePathsAreRelativeToScratchDirectory_resolvesAgainstScratchDir regression that feeds a relative-form workspace-state.json and asserts the loader produces a fully-resolved artifact AbsolutePath rooted at <scratch>/swifterpm/artifacts/....
- CI green
- After swifterpm PR lands + is tagged: bump the pin in
cli/Package.swift and run a fresh tuist install end-to-end to confirm both the new relative output and a stale absolute .build/ from before the bump load cleanly.
🤖 Generated with Claude Code