A build that had been fine the day before started throwing about 161,000 duplicate-symbol errors at link time, one afternoon, out of nowhere. I did the usual flailing first: cleaned the build, deleted DerivedData, cleared the package caches. When none of that worked I updated Xcode, on the theory that the toolchain had rotted somehow, which is the kind of thing you reach for once you’ve stopped thinking. It didn’t help. Between the flailing and the Xcode download, it ate the rest of that afternoon and a good chunk of the next morning.
What I should have looked at much sooner: I keep a few git worktrees of this repo on the same machine, and two of them built the exact same commit without complaint. Same machine, same Xcode, same everything I’d been blaming. If two worktrees of one commit disagree, the difference isn’t in the commit. It’s in something each worktree has that git isn’t tracking. Once that sank in, finding it took about twenty minutes.
Why the same code links two ways
The app links a dozen local SwiftPM packages. A small static framework of debug helpers links the same packages, so two targets end up pulling in the same code. While those packages build as dynamic frameworks that’s harmless: one copy sits under PackageFrameworks, and both targets just reference it. When they build static, each target bakes in its own copy, and ld won’t accept the same symbol defined twice. Across a dozen packages and everything beneath them it piles up fast — the debug framework had quietly grown from a couple of megabytes to over a gigabyte, which was the real tell once I bothered to look at it.
I’d never actually chosen static or dynamic. A SwiftPM library product is .automatic unless you say otherwise, and Xcode picks per build from the shape of the dependency graph. It had always picked dynamic, so this was never something I’d had to think about, which is part of why it took so long to suspect.
What changed
One package was declared from: "2.0.0". A fresh resolve pulled a newer minor version, and that version had added a dependency of its own. That was apparently enough to push the graph past whatever line Xcode uses, and it built everything static instead. The package hadn’t done anything wrong; it was an ordinary minor release. The only thing that differed between my worktrees was which version each had landed on.
That version was recorded in Package.resolved, which wasn’t in git. The project is generated by XcodeGen, Package.resolved lives inside the generated .xcodeproj, and the whole generated folder is gitignored. Worktrees share .git, but they don’t share an ignored file sitting in a generated directory, so each one resolves on its own the first time it builds. The two that worked had resolved weeks earlier and never looked again. The broken one resolved that afternoon and picked up the bump.
What I changed
Pinning the package back to the version that worked got me building, but it’s a patch — the next dependency that reshuffles the graph does the same thing to me. The two things actually worth fixing were both mine. A version range left free to float, with nothing committed to hold it still, means every fresh worktree can resolve something a little different. And a decision as load-bearing as static-versus-dynamic linking was being left to a heuristic I’d never read, on inputs I wasn’t watching.
So Package.resolved goes into git now, ignored project folder or not, so a new worktree can’t quietly resolve its own versions. And the packages that need to be dynamic say so in their manifest, instead of leaving Xcode to keep guessing right.
The change came to a few lines. The day and a half before it went to cleaning, clearing caches, and reinstalling Xcode — everything except checking why one worktree disagreed with the other two.