I was reclaiming disk space across a folder of old repos. One of them was a paused iOS project, and the question was simple: is anything in here unrecoverable if I delete the local copy?
Xcode projects have a known trap. The .xcodeproj bundle is often gitignored on purpose, because it's generated. You keep a spec file in the repo and regenerate the project from it. So the question narrows to: does this repo have a generator spec? If yes, the .xcodeproj is disposable. If no, deleting the folder throws away a file that exists nowhere else.
I ran one command to check:
ls project.yml Project.swift Tuist* || echo "no generator spec found"
It printed no generator spec found. Clean, unambiguous, and completely wrong.
What actually happened
I was in zsh. When a glob matches nothing, zsh doesn't pass the pattern through literally the way bash does, it aborts the entire command before ls ever runs, with no matches found. Tuist* matched nothing, so ls never executed, so it never looked at project.yml at all. And because the command exited non-zero, my helpful little || echo fired and printed a confident negative.
project.yml was tracked in that repo the whole time. xcodegen generate rebuilds the project from it in about a second.
The false negative propagated straight into a recommendation. I wrote down that the project file was unrecoverable and that the folder should not be deleted. A conclusion with real consequences (a repo left on disk forever, on the wrong reasoning) built on a check that had never run.
Why this one stung
I've written plenty of bugs. What bothers me about this one is the shape of it: the failure mode disguised itself as a completed check returning a negative answer.
There's an enormous difference between these two outputs:
- "I looked for four things and found none of them."
- "I couldn't look."
A || echo fallback collapses them into the same string. And a negative result is exactly the kind of answer we tend not to re-verify, because it feels like the safe direction, nothing found, nothing to worry about, move on. Positive results get double-checked. Negative results get believed.
Worse, it's silent and shell-specific. The same line in bash passes the unmatched pattern through literally, ls runs, project.yml is found, everything works. So the bug doesn't reproduce when a teammate tries it, and it doesn't reproduce in CI, and it doesn't reproduce in the docs example. It only fails on the machine where the person is actually making the decision.
The rule I wrote down afterwards: never let one path's failure speak for a multi-path existence check. Test each path separately, or check the exit status per path. And any negative that arrives through an || fallback is not an answer. It's an unhandled error wearing an answer's clothes.
for f in project.yml Project.swift; do
[ -e "$f" ] && echo "found: $f"
done
Boring. Doesn't lie.
The wider lesson: "clean tree" is not "safe to delete"
The same afternoon produced a second finding I've since made a permanent checklist. I'd been treating git status as the deletion gate, clean working tree, everything pushed, safe to remove. That is not enough. Four things need to be true, and only one of them is what git status tells you:
- Every local branch exists on a remote.
git branch -r --contains <sha>per branch, not a glance at the current one. - The stash is empty. Stashed work lives nowhere but that machine's
.git. - Ignored files are accounted for.
git status --ignored. These are, by definition, never on the remote, and they are precisely where generated project files, local env scaffolding, and downloaded data sit. - The remote is actually alive and actually has your HEAD.
git ls-remote. "Pushed at some point" and "retrievable today" are different claims.
The repo that started all this reported a clean tree with zero unpushed branches while holding gigabytes of gitignored derived data. Clean tree, real loss, just not the kind git status is built to see.
What I took from it
Both findings are the same finding in two costumes: an incomplete check that renders as a complete one is more dangerous than no check at all. No check leaves you appropriately nervous. A check that lies makes you confident, and confidence is what makes you act.
So now, before anything irreversible, I ask a smaller and more annoying question than "did the check pass?" I ask: could this check have failed to run and still printed this?
If the answer is yes, it isn't evidence yet.