We stopped committing the .xcodeproj and the merge conflicts stopped
On Endeo we stopped committing the .xcodeproj. XcodeGen builds it from a YAML manifest at setup time, sources are directory-based, and CI regenerates from scratch every run. The merge-conflict category disappears, and project configuration becomes something a reviewer can actually read.
The conflict nobody can review
project.pbxproj is a generated file that Xcode treats as a database and git treats as text. It's full of 24-character hex identifiers, it reorders sections on a whim, and adding a single Swift file touches four or five places in it. It is the worst possible input to a three-way merge.
The part that bothers me more than the conflicts is the review. When that file appears in a pull request, nobody reads it. Everyone scrolls past a hundred lines of identifier churn and approves on faith. So the file that determines which sources compile, which resources ship, and what your build settings are is the one file in the repo that receives no scrutiny at all.
It's also a place where mistakes hide well. A file that exists on disk but was never added to the target compiles fine on the machine of whoever wrote it and breaks nowhere obvious. A stale build setting from three years ago survives because nobody can see it in a diff.
The manifest is the project
XcodeGen reads a YAML manifest and writes the .xcodeproj. The manifest declares targets, dependencies, build settings, capabilities, and source groups, and it's usually short enough to read in one sitting. That file is committed. The generated project is in .gitignore.
Sources are declared by directory rather than file by file, which is the change that does the actual work. Adding a Swift file means putting it in the right folder. There is no membership checkbox, no chance of forgetting it, and no diff. Files on disk and files in the build are the same set by construction rather than by discipline.
What lands in a pull request now is a YAML change a reviewer can actually read. Turning on a capability, changing a deployment target, or adding a target is four lines with a name attached, and someone can push back on it. That's a real review of something that used to be invisible.
- Merge conflicts in generated XML
- Reviewers can't read the diff
- UI changes sneak in unnoticed
- 'Works on my machine' has a home
- The manifest is the reviewable diff
- No conflict surface at all
- CI rebuilds from scratch every run
- An incomplete manifest fails immediately
The conflict avoidance is nice. The regeneration guarantee is what actually matters.
What it changes day to day
Onboarding is clone, run the generate step, open the project. Setup instructions stop drifting because the manifest is the only source of project truth and it can't get out of sync with itself.
Branch switching gets a caveat: if the manifest changed, regenerate. We handle it with a small script that everyone runs after pulling, and it's fast enough that running it unnecessarily costs nothing, which is most of why it becomes habitual.
The other benefit is that the same manifest generates the project for our Android-adjacent tooling story and for release builds identically. There is one description of what this app is, and every environment reads it.
Regeneration from scratch is the real check
The habit that turned out to matter most isn't the conflict avoidance, it's that CI throws the project away and rebuilds it from the manifest on every run. Nothing is cached and nothing is carried over from a developer's machine. If the manifest alone can't produce a project that compiles, the build fails immediately and in front of everyone.
That's a stronger guarantee than it sounds. It means the manifest can't quietly become incomplete, which is the failure mode that eventually kills every generated-project setup. Someone adds a build phase through the Xcode UI, it works locally for a month because nobody regenerated, and by the time it breaks the person who added it has forgotten doing it. Regenerating on every CI run collapses that window to a single build.
It also means the project file stops being something anyone has an opinion about. It's output. You don't review it, you don't merge it, and you don't debug it, because the only thing that can be wrong with it is the manifest that produced it.
What it costs
There's a generate step, and new people will forget it. The symptom is confusing, since your project looks fine but doesn't contain a file you can see in Finder. Putting the generate into a documented bootstrap script helps, and having CI fail loudly on a stale manifest helps more.
The larger cost is that Xcode's UI writes things into the project that the manifest doesn't know about, and you find them one at a time. Drag in a framework, flip a signing setting, add a capability through the checkbox UI, and it works right up until the next regeneration silently discards it. The first month involves a few rounds of figuring out which pane you touched and expressing the same thing in YAML.
That's genuinely annoying, and it's also the point. Anything that can't survive a regeneration was an undocumented local change that would have drifted between machines anyway. The regeneration just makes it fail immediately rather than in three weeks on somebody else's laptop.
When we wouldn't bother
A solo project with one branch has nothing to gain. The conflicts this solves are a function of concurrent work, and if there is none, you're buying a build step for no return.
A project with unusual build phases, heavy run-script customization, or a long tail of per-file compiler flags will spend more time translating existing configuration into the manifest than it saves in the first year. It's still probably right eventually, but going in with the expectation that it's a quick afternoon is how it gets abandoned halfway.
For anything with two or more people adding files regularly, we'd do it again immediately. The measure isn't hours saved, it's that a whole class of merge argument stopped happening and the project's configuration became something a person can read.
Questions
- What problem does generating the Xcode project solve?
- project.pbxproj is a generated database that git treats as text — hex IDs, reordered sections, multi-site diffs for one added file. Nobody reviews it, so build membership and settings change without scrutiny. A short YAML manifest is reviewable.
- Why regenerate the project in CI from scratch?
- It proves the committed manifest alone can produce a compiling project. Local-only Xcode UI changes that never made it into YAML fail immediately instead of working on one machine for a month and breaking later.
- When would you skip XcodeGen?
- Solo projects with no concurrent file adds gain little. Unusual run-script phases or heavy per-file flags make the first translation expensive. For two or more people adding files regularly, the conflict class is worth deleting immediately.
Sources
- XcodeGen documentation
- Apple — Xcode project format overview — Project files remain Xcode's database; generation keeps that database out of git.
- Endeo case study