Skip to content

Collect licenses from a project generator's scratch directory - #36

Merged
zunda-pixel merged 4 commits into
mainfrom
feat/tuist-scratch-directory
Sep 17, 2026
Merged

zunda-pixel merged 4 commits into
mainfrom
feat/tuist-scratch-directory

Conversation

@zunda-pixel

Copy link
Copy Markdown
Owner

What changed

The plugin now also collects licenses from the scratch directory a project generator resolves its own packages into, so a Tuist project that keeps its dependencies in Tuist/Package.swift gets a complete license list.

Why

sourcePackagesPath walks up from the plugin work directory to SourcePackages — the directory Xcode checks packages out into. Tuist resolves the packages declared in Tuist/Package.swift itself, into its own scratch directory, and only packages declared natively in the Xcode project ever reach SourcePackages.

So on a Tuist project the plugin sees almost nothing. Measured on a project with two packages integrated through Tuist/Package.swift and one declared natively:

DerivedData/App/SourcePackages/checkouts/   → swift-log                                ← the plugin looked only here
Tuist/.build/checkouts/                     → swift-argument-parser, swift-collections  ← invisible

The generated LicenseProvider.swift listed one package.

That scratch directory has the same layout as SourcePackages — a workspace-state.json next to a checkouts/ directory — so it can be read exactly the same way.

How

  • Plugin: the Xcode entry point resolves the project directory through context.xcodeProject.directoryURL and passes Tuist/.build (or .build) to the executable alongside SourcePackages, but only when that directory holds a workspace-state.json. Projects not generated this way pass nothing extra and behave exactly as before. The SwiftPM entry point is unchanged.
  • Executable: LicenseProviderExec accepts one or more source package directories and merges the results. Existing callers that pass a single path keep working, including anyone invoking the executable directly from a script.

Second bug found along the way

Reading the scratch directory surfaced a separate defect. SwiftPM symlinks a checkout into the shared source cache when one is in use, and appendingPathComponent only marks a URL as a directory when the path already exists as one. For a symlink the resulting URL has no trailing slash, and contentsOfDirectory(at:) then fails:

Error Domain=NSCocoaErrorDomain Code=256 "The file "swift-argument-parser" couldn't be opened."
  NSUnderlyingError=Error Domain=NSPOSIXErrorDomain Code=20 "Not a directory"

Note the URLs — the symlinked checkout has no trailing slash, the real directory does:

.../checkouts/swift-argument-parser     ← symlink, fails with ENOTDIR
.../checkouts/swift-collections/        ← real directory, works

Checkout directories now have their symlinks resolved before being read. This affects the SwiftPM path too whenever the shared source cache is in use.

Impact

Tuist users can adopt the build tool plugin instead of scripting LicenseProviderExec by hand. Anyone using SwiftPM's shared source cache stops silently losing the licenses of symlinked checkouts.

Validation

  • swift test in Example passes and still generates all five packages — the SwiftPM path is unaffected.

  • On a Tuist 4.208.0 project with swift-argument-parser + swift-collections via Tuist/Package.swift and swift-log declared natively:

    generated packages
    before 1 (swift-log)
    after 3 (swift-argument-parser, swift-collections, swift-log)

    swift-argument-parser is a symlinked checkout there, so this also exercises the ENOTDIR fix. Build succeeds both ways.

  • swift format lint -r -p -s is clean.

🤖 Generated with Claude Code

The plugin located packages by walking up from its work directory to
`SourcePackages`, the directory Xcode checks packages out into. Tuist resolves
the packages declared in `Tuist/Package.swift` itself, into its own scratch
directory, and only the ones declared natively in the Xcode project reach
`SourcePackages`. A Tuist project that keeps its dependencies in
`Tuist/Package.swift` therefore got a license list with just those few packages
in it — measured on a project with two Tuist-integrated packages and one native
one, the generated file listed only the native package.

That scratch directory has the same layout as `SourcePackages`, so it can be
read the same way:

- The Xcode plugin now resolves the project directory through
  `context.xcodeProject.directoryURL` and passes `Tuist/.build`, or `.build`, to
  the executable alongside `SourcePackages` when it holds a
  `workspace-state.json`. Projects that are not generated this way pass nothing
  extra and behave exactly as before, and the SwiftPM plugin is unchanged.
- `LicenseProviderExec` accepts one or more source package directories and
  merges what it finds. Existing callers pass a single path and keep working.

Reading the scratch directory also surfaced a second problem. SwiftPM symlinks a
checkout into the shared source cache when one is in use, and
`appendingPathComponent` only marks a URL as a directory when the path already
exists as one. For a symlink the resulting URL has no trailing slash and
`contentsOfDirectory(at:)` fails with `ENOTDIR`, so such a package was skipped
with an error rather than having its license collected. Checkout directories now
have their symlinks resolved before being read.

Validation:
- `swift test` in `Example` passes and still generates all five packages, so the
  SwiftPM path is unaffected.
- On a Tuist 4.208.0 project with `swift-argument-parser` and `swift-collections`
  integrated through `Tuist/Package.swift` and `swift-log` declared natively, the
  generated file goes from one package to all three. `swift-argument-parser` is a
  symlinked checkout there, so it also covers the `ENOTDIR` fix.
- `swift format lint -r -p -s` is clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread Plugins/LicenseProviderPlugin/LicenseProviderPlugin.swift Outdated
Comment thread Sources/LicenseProviderExec/main.swift Outdated
Comment thread Sources/LicenseProviderExec/main.swift Outdated
@zunda-pixel
zunda-pixel merged commit 24e8e04 into main Sep 17, 2026
2 checks passed
@zunda-pixel
zunda-pixel deleted the feat/tuist-scratch-directory branch September 17, 2026 15:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant