A private GitHub repository for an app in ten minutes, done properly

★ ★ ★ ★ ★ Not rated

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 --version
Goal

A private repository, main holding only tested releases, dev for everything else, and nothing in the history that should have stayed on the laptop.

Not covered

Pull-request review policies, CI, and monorepos. Those come later in the series.

Order matters. Audit first, ignore second, commit third, create the remote last. The one step that is hard to undo is the push, so everything that could embarrass you is handled before it.

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 | tail

Things 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"
Secrets scan before anything else. Search for API keys, private keys and service-account files in everything that is not 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 contain

Write 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 -sb

If 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 main
Expected result gh 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
BranchWho pushesWhen
devAnyone on the team, directlyContinuously. Force pushes are tolerated while a feature is being shaped.
mainFast-forward merge from dev onlyAfter 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 dev

Keep 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
Only before anyone else has the commit. --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-push

Test 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=$?"      # 0
Escape hatch. A deliberate rewrite of main 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.
  • .gitignore covers 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.
  • dev branch created and pushed; working copy stays on dev; main is the default branch.
  • Any regrettable content removed by amend and --force-with-lease before anyone cloned.
  • pre-push hook 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.

0.0
★ ★ ★ ★ ★
0 reviews
5 ★
0 %
4 ★
0 %
3 ★
0 %
2 ★
0 %
1 ★
0 %

Add a comment

There's nothing here.

No comments have been posted here yet.