A private GitHub repository for an app in ten minutes, done properly
Before you begin
This is the procedure we use when a mobile or web application has been developed locally with a single "Initial commit" and now needs a backup, a history and a stable branch. It assumes a Mac or Linux shell, Git, and the GitHub CLI signed in to the account that will own the repository.
gh auth status
git --versionA private repository, main holding only tested releases, dev for everything else, and nothing in the history that should have stayed on the laptop.
Pull-request review policies, CI, and monorepos. Those come later in the series.
Audit the working tree
List what Git would commit and look at every line you do not recognise. Large binaries, generated folders and stray files hide in long lists.
git status --short
git status --short | wc -l
du -sh */ | sort -h | tailThings we found this way in real projects: a reference JPEG dropped into the project root months ago, generated ios/ and android/ folders from an Expo prebuild, a release/ folder with store screenshots, and a notes folder written for the developer, not for the public.
# confirm what a file is before deciding
file 0.jpg
git check-ignore -v ios android || echo "not ignored"node_modules. If one turns up, rotate it; a key that was ever in a commit is compromised even if the commit is rewritten later.grep -rln 'AIza\|BEGIN PRIVATE\|"private_key"' --exclude-dir=node_modules --exclude-dir=ios --exclude-dir=android . || echo "clean"
Decide what never leaves the machine
Put every exclusion in .gitignore now, so future contributors and future you cannot add it by accident with git add -A. Our Expo baseline looks like this:
node_modules/
.expo/
/ios
/android
.test-build/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
.env*.local
google-play-service-account.json
marketplace/out/
# internal notes, audit findings and handoff files — local only
/improvements/The last rule is a policy decision, not a technical one. Internal audit notes describe weaknesses in detail and are written in a tone meant for the team. They belong in a backup, not in a repository that may one day be shared with a client, a contractor or made public.
Files that are simply not part of the product, like a reference image used for a logo sketch, are not added at all. Do not delete them, do not ignore them; just leave them untracked and let git status remind you.
The first real commit
Stage everything, un-stage the exceptions, and read the list one more time before committing.
git add -A
git reset -q 0.jpg # untracked by choice
git status --short | grep -v '^??' # what the commit will containWrite the message for the person who opens the repository in a year. Say what the project is and what state it is in, not "wip".
git commit -F - <<'MSG'
Auto Hlídač: audit findings applied, simulator verification, Xcode 27 support
Complete Expo SDK 57 application (expo-router, SQLite, local notifications,
parking location) with store assets. Verified: typecheck, 74 tests, Maestro
flows on iPhone 17 (iOS 27) and Pixel 3a (API 34). Open items: Google Maps
key, App Store Connect app ID.
MSG
Create the repository and push
One command creates the private repository under your account, adds it as origin and pushes the current branch:
gh repo create coolsoft-example/AutoHlidac --private --source=. --remote=origin --push
git remote -v
git status -sbIf the repository already exists, add the remote by hand instead:
git remote add origin https://github.com/coolsoft-example/AutoHlidac.git
git push -u origin maingh repo view --json visibility,defaultBranchRef reports PRIVATE and main, and git status -sb shows ## main...origin/main.Two branches: dev for work, main for releases
For a solo developer or a small team, two long-lived branches are enough and cheaper than a full Git Flow. The rule is simple: main only ever receives tested code, dev receives everything else, and the working copy stays on dev.
git checkout -b dev
git push -u origin dev| Branch | Who pushes | When |
|---|---|---|
| dev | Anyone on the team, directly | Continuously. Force pushes are tolerated while a feature is being shaped. |
| main | Fast-forward merge from dev only | After testing is finished. Never rewritten, never deleted. |
# release: fast-forward main to the tested dev commit
git checkout main
git merge --ff-only dev
git push origin main
git checkout devKeep main as the default branch on GitHub so clones and links point at the stable code.
Undo a push you regret, while it is still cheap
Minutes after our first push we noticed the internal notes folder had gone up with it. This is the one moment when rewriting main is the right call: the repository is seconds old, nobody has cloned it, and the alternative is a history that carries the folder forever.
git rm -r -q --cached improvements
printf '\n# internal notes — local only\n/improvements/\n' >> .gitignore
git add .gitignore
git commit --amend --no-edit
git push --force-with-lease origin main
git ls-files improvements | wc -l # must print 0--force-with-lease refuses to overwrite a remote that moved since you last fetched, which is the safety net for the "nobody else has it" assumption. Once a branch has been shared, remove the files in a new commit and accept that history keeps them, or rotate whatever was exposed.Protect main without GitHub Pro
GitHub's branch protection rules are not available on private repositories under the free plan:
$ gh api -X PUT repos/OWNER/REPO/branches/main/protection --input rules.json
Upgrade to GitHub Pro or make this repository public to enable this feature. (HTTP 403)The same two guarantees, no force push and no deletion of main, can be enforced locally with a pre-push hook. It is not versioned (.git/hooks is per clone), so copy it into every clone that is allowed to push.
cat > .git/hooks/pre-push <<'HOOK'
#!/bin/sh
# Local stand-in for branch protection: refuse force pushes and deletion of main.
zero=0000000000000000000000000000000000000000
while read local_ref local_sha remote_ref remote_sha; do
[ "$remote_ref" = "refs/heads/main" ] || continue
if [ "$local_sha" = "$zero" ]; then
echo "pre-push: deleting main is not allowed." >&2
exit 1
fi
if [ "$remote_sha" != "$zero" ] && ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then
echo "pre-push: force push to main is not allowed; push fast-forward only, develop on dev." >&2
exit 1
fi
done
exit 0
HOOK
chmod +x .git/hooks/pre-pushTest it without touching the remote by feeding the hook the same lines Git would:
H=$(git rev-parse HEAD); P=$(git rev-parse HEAD~1); Z=0000000000000000000000000000000000000000
printf 'refs/heads/main %s refs/heads/main %s\n' $H $P | .git/hooks/pre-push; echo "fast-forward rc=$?" # 0
printf 'refs/heads/main %s refs/heads/main %s\n' $P $H | .git/hooks/pre-push; echo "force rc=$?" # 1
printf '(delete) %s refs/heads/main %s\n' $Z $H | .git/hooks/pre-push; echo "delete rc=$?" # 1
printf 'refs/heads/dev %s refs/heads/dev %s\n' $P $H | .git/hooks/pre-push; echo "dev force rc=$?" # 0main is still possible with git push --no-verify. That is the point: the dangerous action requires typing the intent, so it cannot happen by reflex.Checklist
- Working tree audited; stray files identified; secrets scan clean.
.gitignorecovers generated native folders, keys, local env files and internal notes.- First commit message describes the project and its state.
- Private repository created with
gh repo create --private --source=. --remote=origin --push. devbranch created and pushed; working copy stays ondev;mainis the default branch.- Any regrettable content removed by amend and
--force-with-leasebefore anyone cloned. pre-pushhook installed and tested in every clone that pushes.
Next in the series: a release checklist for fast-forwarding dev into main with tags, and what to do when two people work on dev at once.
There's nothing here.
No comments have been posted here yet.