git push

git push is the command that uploads your local commits to a remote repository such as GitHub, updating a branch there so teammates (and your other machines) can see your work. Until you run git push, every commit you make lives only on your machine — the remote repository has no idea it exists. Understanding exactly what push does, and does not do, is essential for working safely on a shared branch.

Overview: How git push Works

Git repositories are decentralized: your local repository and the copy on GitHub are two full, independent repositories that happen to share history. A remote is just a named URL pointing at another repository — origin is the conventional name Git gives the remote you cloned from, but you can add others with git remote add. Your local repo also keeps remote-tracking branches like origin/main, which are read-only bookmarks recording where the remote’s main branch was the last time you talked to it. They only move when you fetch, pull, or push — never by committing locally.

Recall Git’s object model: every commit is an object containing a pointer to a tree (a snapshot of your project’s files and directories), a pointer to its parent commit(s), and metadata (author, message, timestamp). A branch such as main is nothing more than a small file holding the 40-character SHA-1 (or SHA-256, on newer repos) hash of its latest commit — a lightweight, movable pointer. When you run git push origin main, Git does three things: it walks the commit graph from your local main tip backward and figures out which commit, tree, and blob objects the remote doesn’t already have; it transfers only those missing objects over the network as a compressed \”packfile\”; and then it asks the remote to move its own main ref to point at your new commit. That last step only succeeds if it’s a fast-forward — meaning the remote’s current commit is a direct ancestor of the commit you’re pushing. If someone else has pushed commits to main that you don’t have locally, the remote’s tip is no longer an ancestor of yours, and Git rejects the push rather than silently discard those commits.

The first time you push a new local branch, Git doesn’t know it should be linked to a same-named branch on the remote. The -u (--set-upstream) flag establishes that link, called an upstream or tracking relationship. Once set, a bare git push and git pull on that branch automatically know which remote branch to talk to, and commands like git status can tell you \”your branch is 2 commits ahead of origin/main\”.

Syntax

The general form is git push [options] [remote] [refspec], where the refspec describes which local branch maps to which remote branch. Common flags:

Flag Meaning
-u, --set-upstream Link the local branch to the remote branch so future plain push/pull know the target
--force Overwrite the remote branch even if it isn’t a fast-forward — dangerous, can discard others’ commits
--force-with-lease Like --force, but fails if the remote has commits you haven’t fetched yet — the safer default for rewriting shared-but-yours history
--tags Push all local tags that aren’t already on the remote
--delete (or a leading : before the branch name) Delete a branch on the remote
--all Push all local branches
--dry-run Show what would be pushed without actually pushing
-n Shorthand for --dry-run

You can also push a local branch to a differently-named remote branch with a full refspec, local-branch:remote-branch, or delete a remote branch by pushing an empty local side, :remote-branch.

Examples

Example 1: A basic push to an existing branch

git push origin main

Output:

Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Delta compression using up to 8 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 312 bytes | 312.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (1/1), completed with 1 local object.
To github.com:yourname/recipe-box.git
   a1b2c3d..e4f5g6h  main -> main

Git enumerated the objects reachable from your local main, figured out the remote was missing 3 of them, uploaded a small packfile, and then fast-forwarded the remote’s main ref from commit a1b2c3d to e4f5g6h. Because this was a fast-forward, no merge or rewrite happened on the remote.

Example 2: Pushing a brand-new branch and setting upstream

git switch -c feature/login-page
git add src/login.js
git commit -m "feat: add login page skeleton"
git push -u origin feature/login-page

Output:

Enumerating objects: 6, done.
Counting objects: 100% (6/6), done.
Delta compression using up to 8 threads
Compressing objects: 100% (4/4), done.
Writing objects: 100% (4/4), 612 bytes | 612.00 KiB/s, done.
Total 4 (delta 1), reused 0 (delta 0), pack-reused 0
remote:
remote: Create a pull request for 'feature/login-page' on GitHub by visiting:
remote:      https://github.com/yourname/recipe-box/pull/new/feature/login-page
remote:
To github.com:yourname/recipe-box.git
 * [new branch]      feature/login-page -> feature/login-page
branch 'feature/login-page' set up to track 'origin/feature/login-page'.

feature/login-page didn’t exist on GitHub before this push, so Git creates it there and points it at your new commit. GitHub also prints a handy link to open a pull request straight away. The final line confirms the upstream link was set — from now on, git push and git pull on this branch need no extra arguments.

Example 3: A rejected push, and recovering safely

git push origin main

Output:

To github.com:yourname/recipe-box.git
 ! [rejected]        main -> main (fetch first)
error: failed to push some refs to 'github.com:yourname/recipe-box.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually caused by another repository pushing
hint: to the same ref. If you want to integrate the remote changes, use
hint: 'git pull' before pushing again.

A teammate pushed to main after your last fetch, so your local main is no longer an ancestor of the remote’s main — a fast-forward is impossible. The fix is to integrate their work first, then push again:

git pull --rebase origin main
git push origin main

git pull --rebase fetches the remote commits and replays your local commits on top of them, keeping history linear. Only after your branch is caught up does the second push succeed as a fast-forward.

How It Works Step by Step

When you run git push origin main, Git performs roughly these steps internally:

  • It looks up the URL associated with the remote name origin (stored in .git/config).
  • It contacts the remote and asks for the current SHA of its main ref.
  • It compares that SHA to your local remote-tracking ref origin/main. If they differ, someone else has moved the remote branch since your last fetch, and Git will reject a non-forced push.
  • Assuming the check passes, Git walks backward from your local main tip through parent commits, collecting every commit, tree, and blob object the remote doesn’t already have.
  • It compresses those objects into a single packfile and transfers it over the network (SSH or HTTPS).
  • The remote unpacks the objects into its own object database and, if the update is a fast-forward (or you passed --force/--force-with-lease), moves its main ref to your new commit SHA.
  • Your local repository updates its own origin/main remote-tracking ref to match, so git status and git log origin/main reflect reality without another fetch.

Common Mistakes

Mistake 1: Force-pushing over a shared branch with bare –force

git push --force origin main

Bare --force overwrites whatever is on the remote unconditionally, even if a teammate pushed commits you’ve never seen — those commits simply vanish from main (they’re still recoverable from the reflog on the teammate’s machine for a while, but that’s a bad day for everyone). Prefer --force-with-lease, which refuses to push if the remote has moved since your last fetch:

git fetch origin
git push --force-with-lease origin main

This is only ever appropriate on a branch that’s genuinely yours (like your own feature branch after an interactive rebase) — never on a shared branch like main that others are actively committing to.

Mistake 2: Forgetting to set the upstream on a new branch

git switch -c feature/search-filters
git commit -am "feat: add search filter UI"
git push

Output:

fatal: The current branch feature/search-filters has no upstream branch.
To push the current branch and set the remote as upstream, use

    git push --set-upstream origin feature/search-filters

Git has no way to know which remote branch a brand-new local branch should map to, so it refuses to guess. Push with -u the first time (Git even prints the exact command to copy):

git push -u origin feature/search-filters

Mistake 3: Reacting to a rejected push by force-pushing instead of integrating

After seeing a [rejected] message, it’s tempting to \”make the error go away\” with git push --force. On a shared branch this is the rebase/force-push golden-rule violation: it discards a colleague’s history without warning. Always git fetch and either merge or rebase first, resolve any conflicts, and only then push normally (or with --force-with-lease if you rebased your own already-pushed feature branch).

Best Practices

  • Never rebase or force-push a branch that others have already pulled or built work on top of — the golden rule of rebasing. Force-pushing is only safe on branches you alone own.
  • Default to --force-with-lease instead of bare --force whenever you must rewrite a branch’s history that’s already on the remote.
  • Use -u the first time you push a new branch so subsequent git push / git pull calls need no arguments.
  • Run git push --dry-run when you’re unsure exactly what a push will do, especially before a force-push.
  • Write Conventional Commits style messages (feat: ..., fix: ..., chore: ...) so pushed history stays readable for reviewers.
  • Push small, frequent commits on feature branches rather than one giant commit — it makes code review and any future git bisect far easier.
  • Authenticate over SSH or with a Personal Access Token — GitHub no longer accepts plain password auth over HTTPS.
  • Delete remote feature branches after their pull request merges (git push origin --delete feature/login-page) to keep the branch list clean.

Practice Exercises

  • Create a new local branch named feature/dark-mode, make a commit on it, and push it to origin so that a plain git push would work on your next commit without extra flags. Check GitHub to confirm the branch appears.
  • On that same branch, make a second commit, then use git rebase -i to squash your two commits into one. Push the rewritten branch up — plain git push will be rejected; figure out which flag lets you update the remote branch safely, and explain why it’s safer than the alternative.
  • Create an annotated tag v1.0.0 on your latest commit on main, then push only that tag to origin (not with --tags). Verify it shows up under your repository’s Tags/Releases page on GitHub.

Summary

  • git push uploads local commits (and the objects they reference) to a remote branch, moving that branch’s ref forward on the remote.
  • A push only succeeds without extra flags when it’s a fast-forward; otherwise Git rejects it to avoid silently discarding commits.
  • -u/--set-upstream links a local branch to a remote one so future pushes and pulls need no arguments.
  • --force-with-lease is the safe way to overwrite remote history you rewrote yourself; bare --force can destroy teammates’ work.
  • Never rewrite (rebase or force-push) a branch other people have already pulled from.
  • Use git push origin --delete <branch> to remove a remote branch, and git push --tags or git push origin <tag> to publish tags.