My .gitignore Has Been Ignoring My Lockfile This Whole Time
TL;DR: I asked opencode to audit this repo's config against best practices for a private solo repo. Secrets hygiene came back clean. The genuine surprise was line 7 of my own .gitignore, which said /package-lock.json, so my lockfile was never tracked, and Dependabot vulnerability alerts were switched off. Also ~40 stale branches, a loud git branch -d refusal, and the last command in the && chain silently never running because of it. Fixed in commit 42cf1f0, plus two git aliases that encode the right cleanup order, git pull && git sweep.
I asked my agent (opencode) to review this repo's config. The whole ask was to check ManningWorks/lukemanning-site against best practices for a private solo-dev repo and tell me if anything needs changing.
My honest expectation was a list of green ticks and maybe a nitpick about something I'd decided on purpose. The site builds, Vercel deploys it, nothing's on fire. What could be wrong?
Most of it was fine. Some of it really wasn't.
The parts that were fine
Secrets hygiene, the part I actually cared about, came back clean. .env* is gitignored and .env.local holds a real GITHUB_TOKEN and a DEV_TO_API_KEY, untracked. Better still, the audit ran this check
git log --all --oneline -- '.env*'
Empty output. No env file was ever committed, on any branch, ever. That's the check I'd never have thought to run.
Ignores for node_modules, .next, .velite, tsbuildinfo were all correct. No LICENSE file, also correct for a private repo. No license means all rights reserved by default.
My own .gitignore was the problem
Then it got to line 7 of my .gitignore.
/package-lock.json
My own lockfile. At some point I told git to ignore it, deliberately, and then apparently never thought about it again.
I pushed back on this one, because ignoring a lockfile is a real opinion. Plenty of library maintainers do exactly that, and I've shipped a library myself, so the instinct has history.
But this isn't a library, it's a deployed app. Vercel builds this site from the repo, and with no tracked lockfile every build resolves dependencies fresh. What package-lock.json actually does is pin those resolutions to exact versions, which is what makes deploys reproducible. Without it, a transitive dependency can publish a breaking version and break a deploy with zero code changes on my side. Dependabot also needs the lockfile to open version update PRs at all.
Extra mess in the same area was pnpm archaeology. A .pnpmfile.cjs in the repo root and a pnpm block in package.json, leftovers from a pnpm phase. Meanwhile .npmrc and package-lock.json say npm is the actual current tool, the lockfile sitting on disk, untracked. Two package managers' worth of config, one of them dead.
Dependabot was off. One API call fixed it
Dependabot alerts are GitHub watching my dependencies for known vulnerabilities and telling me when it finds one. They were off, so nothing would have told me when a dependency picked up a CVE. The audit caught it via the API.
gh api repos/ManningWorks/lukemanning-site/vulnerability-alerts
404. On this endpoint that's just how GitHub says off, which threw me because 404 usually means the repo name is wrong. The docs say 204 means enabled, and this endpoint never returns 200.
The fix was a PUT.
gh api -X PUT repos/ManningWorks/lukemanning-site/vulnerability-alerts
Enabling alerts is free even on private repos. One API call.
Branch protection needs GitHub Pro on private repos
Branch protection on private repos needs GitHub Pro. The API says so directly.
Upgrade to GitHub Pro or make this repository public
Secret scanning and push protection aren't available on free private repos either. My first thought was that both are moot for a solo dev, but that's only half true. Secret scanning would have real value here, because I'm the most likely person to leak my own secrets. That one's a genuine gap, and closing it costs GitHub Pro money. Push protection, fair enough, there's nobody else's push to protect against. At least now I know it's a plan limit and not a config miss.
Forty stale branches and one silent failure
The pile was ~40 stale branches, local and remote. Every PR merged on GitHub left its head branch behind, and I'd clearly never gone back to clean up. Mostly merged, long forgotten.
I set the bar before deleting anything. Only branches proven merged get deleted. Two checks. git branch --merged master and its -r twin for ancestry, plus a cross-check against gh pr list --state merged.
One branch failed the ancestry check and was still safe to delete, and that's the interesting case.
8ca8fd1 feat: auto-inject referral boxes... (#26)
That's the output of git log master..feat/referral-link-injection --oneline. One commit, and it wasn't an ancestor of master. But the (#26) suffix is a squash-merge fingerprint. My PRs land as squash merges, and squash doesn't carry a branch's commits into master. It forges one fresh commit and stamps "title (#PR)" on it.
Which leaves a question. That stamp belongs on the commit squash forges on master, not on anything sitting at a branch tip. How this one got there, I never fully reconstructed. My best guess is the branch picked up a squash-style commit somewhere along the way, a cherry-pick of one or a reset onto one.
The cross-check showed the branch actually went out through PR #53, same name, merged, feature live on master. It got reused across PRs at some point and the stale #26 fingerprint never washed off. An orphaned copy, in other words. Tip not in master's history anywhere, changes already live.
The branch was a husk.
git branch -d would refuse it forever, so it needed -D (force-delete, the shortcut for --delete --force), with the receipt above as justification.
Then the batch deletion failed halfway through.
The chain was a long run of git branch -d calls for the proven-merged branches, then git branch -D feat/referral-link-injection at the very end. One of the -d deletions refused for merge/review-publisher-published-posts-into-master because it apparently wasn't merged into master, but it was. The reason is a rule I didn't know existed. From the git man page on -d.
The branch must be fully merged in its upstream branch, or in HEAD if no upstream was set.
This branch tracked a differently named remote upstream, origin/review/publisher-published-posts. The refusal explained it clearly enough. Not yet merged to 'refs/remotes/origin/review/publisher-published-posts', even though it is merged to HEAD.
AI figured it out and helped me clean it up.
Sweep before pull doesn't work
First instinct for the cleanup was git sweep && git pull. Wrong order, and for a different reason than the -d refusal earlier. The sweep's filter, git branch --merged <base>, reads whatever base branch it points at. That's a separate check from the -d safety rule above. The filter decides what makes the delete list, and the -d check gives each branch its final veto. If I swept before pulling, my local master didn't yet contain the merge I'd just done on GitHub, so the freshly merged branch never even made the delete list. No error, just a sweep that did nothing useful, so I needed a second sweep anyway. Pull first, then sweep.
The aliases that landed in ~/.gitconfig
[alias]
sync = "!f() { git pull && git sweep; }; f"
sweep = "!f() { git fetch --prune; b=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null) || b=$(git rev-parse --verify -q main >/dev/null 2>&1 && echo main || echo master); git branch --merged \"$b\" | grep -vE \"^\\*\" | grep -Fvx \" $b\" | xargs -r git branch -d; }; f"
(-r is a GNU xargs thing, it makes xargs do nothing on empty input. macOS's BSD xargs doesn't have it.)
The real protection for the drafts is the filter, not the -d. git branch --merged <base> only lists branches that are ancestors of the base branch, so the unmerged feat/draft-* branches never make the delete list at all. The -d is the backstop. And as that merge/review-publisher... refusal showed, the backstop has its own opinions about upstreams and can refuse mid-sweep. It just refuses safely, which is why it's still -d and not -D. Every sweep run so far: drafts untouched.
The first cut of the sweep alias had a bug I noticed after: the grep skipped any branch name containing "master", so merge/review-publisher-published-posts-into-master would never make the sweep list. It would just sit there forever. That bugged me enough to rewrite it, and the alias above is the rewrite. It resolves the base branch now, origin/HEAD if set, then a main/master fallback, and the greps only skip the current branch and the base itself. The into-master branch gets swept like everything else.
Then one more API call to set delete_branch_on_merge=true, so merged PR head branches auto-delete on the remote from now on, and the pile can't quietly rebuild itself.