Centralized Workflow
The centralized workflow is the simplest way a team can collaborate with Git: everyone clones a single repository hosted on GitHub, works on the same branch — almost always main — and pushes commits straight back to that shared remote. It looks a lot like older systems such as Subversion, except every developer actually holds a full copy of the project’s history, not just the latest snapshot. It’s the natural starting point for small teams, personal projects with a collaborator or two, and any situation where a full pull-request review process would be overkill.
Overview: How the Centralized Workflow Works
Git is a distributed version control system, which means every git clone copies the entire project history — every commit, every tree, every blob — onto your machine. The centralized workflow doesn’t fight that; it simply chooses, by convention, to treat one remote repository (almost always named origin on GitHub) as the single source of truth. Everyone’s local main branch is expected to track and stay in sync with the remote’s main branch.
To understand why pushes sometimes get rejected in this workflow, it helps to remember what a commit actually is. Every commit is an object identified by a SHA hash of its content. It stores a pointer to a tree object (a snapshot of the whole project’s directory structure at that moment), a pointer to its parent commit (or commits, for a merge), the author, and the commit message. A tree, in turn, points to blob objects, which hold the raw contents of individual files, and to other trees for subdirectories. A branch like main is nothing more than a small text file containing the SHA of its latest commit — a movable pointer. When you commit, Git writes new blob/tree/commit objects and moves the branch pointer forward to the new commit. HEAD normally points at the branch (not directly at a commit), so it moves along automatically as you commit.
When you clone a repository, Git also creates a remote-tracking branch called origin/main, which records where the remote’s main was as of your last fetch. Your local main and origin/main start out pointing at the same commit. As you and your teammates each commit locally and push, the remote’s main moves forward. Your local origin/main only updates when you run git fetch or git pull — it is a cached record, not a live view of the remote.
By default, Git only allows a push to succeed if it is a fast-forward: the commit you’re pushing must have the remote’s current tip as one of its ancestors. If a teammate pushed a commit to main after your last pull, your local history and the remote’s history have diverged, and Git refuses the push rather than silently overwriting their work. You then have to integrate their commits into your local branch — usually with git pull (fetch + merge) or git pull --rebase (fetch + rebase) — before you can push again. This constant “pull, integrate, push” rhythm is the defining characteristic of the centralized workflow.
Because everyone commits straight to main, the centralized workflow works best with frequent, small pulls and pushes, good communication about who’s touching which files, and a team small enough that a formal code-review gate (feature branches plus pull requests, covered elsewhere in this course) isn’t yet necessary.
Syntax
The centralized workflow doesn’t introduce new Git subcommands — it’s a pattern built from commands you already need for any repository. The core loop looks like this:
git clone "<repository-url>"
git pull origin main
git add "<file>"
git commit -m "<message>"
git push origin main
| Command | What it does |
|---|---|
git clone <url> |
Copies the entire remote repository, including full history, to your machine and sets up origin as the default remote. |
git pull origin main |
Fetches the latest commits from the remote’s main and merges them into your local main (fast-forwards if possible). |
git pull --rebase origin main |
Fetches, then replays your local commits on top of the remote’s latest commit instead of creating a merge commit. |
git add <file> |
Stages a file’s current contents into the index, so the next commit will include it. |
git commit -m "<message>" |
Creates a new commit object from what’s staged in the index and moves main to point at it. |
git push origin main |
Sends your local commits to the remote and advances the remote’s main pointer, but only if it’s a fast-forward. |
git status |
Shows staged/unstaged changes and whether your branch is ahead of or behind origin/main. |
git log --oneline |
Shows a compact commit history, useful for confirming what you’re about to push. |
Examples
Example 1: Clone, change, and push
You start by cloning the shared repository, making a small change, and pushing it straight to main:
git clone git@github.com:acme/inventory-app.git
cd inventory-app
echo "## v1.1 - improved search" >> CHANGELOG.md
git add CHANGELOG.md
git commit -m "docs: note v1.1 search improvements in changelog"
git push origin main
Output:
Cloning into 'inventory-app'...
remote: Enumerating objects: 342, done.
remote: Counting objects: 100% (342/342), done.
remote: Compressing objects: 100% (210/210), done.
remote: Total 342 (delta 98), reused 320 (delta 90), pack-reused 0
Receiving objects: 100% (342/342), 88.10 KiB | 2.10 MiB/s, done.
Resolving deltas: 100% (98/98), done.
[main 7c2e9a1] docs: note v1.1 search improvements in changelog
1 file changed, 1 insertion(+)
To github.com:acme/inventory-app.git
4f1a9d0..7c2e9a1 main -> main
Cloning downloaded the entire history, and both your local main and your local copy of origin/main started out pointing at commit 4f1a9d0. Staging and committing the changelog created a brand-new commit object, 7c2e9a1, whose parent is 4f1a9d0, and moved main to point at it. Because 7c2e9a1‘s history includes the remote’s current tip, the push was a clean fast-forward, and the remote’s main now also points at 7c2e9a1.
Example 2: Pulling before you start work
Good habit: before making any new changes, sync with whatever teammates have already pushed.
cd inventory-app
git pull origin main
Output:
From github.com:acme/inventory-app
* branch main -> FETCH_HEAD
Updating 7c2e9a1..9b3f2d4
Fast-forward
src/search.py | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
Since you had no local commits of your own yet, Git simply moved your local main forward from 7c2e9a1 to the remote’s new tip, 9b3f2d4. No merge commit was needed — this is exactly what a fast-forward looks like.
Example 3: A rejected push, and how to fix it
Now imagine you make your own commit, but a teammate pushes to main before you do:
echo "def search(query):" >> src/search.py
echo " pass" >> src/search.py
git add src/search.py
git commit -m "feat: stub out search function"
git push origin main
Output:
To github.com:acme/inventory-app.git
! [rejected] main -> main (fetch first)
error: failed to push some refs to 'github.com:acme/inventory-app.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. You may want to first integrate the remote changes
hint: (e.g., 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
Your local main is based on 9b3f2d4, but while you were editing, a teammate’s push moved the remote’s main further ahead. Your commit’s history doesn’t contain that newer tip as an ancestor, so this isn’t a fast-forward and Git refuses to overwrite it. The fix is to integrate the remote’s changes first, then push again:
git pull --rebase origin main
git push origin main
Output:
From github.com:acme/inventory-app
* branch main -> FETCH_HEAD
Successfully rebased and updated refs/heads/main.
To github.com:acme/inventory-app.git
9b3f2d4..a15c7e2 main -> main
git pull --rebase fetched the teammate’s commit, then replayed your “feat: stub out search function” commit on top of it, giving your commit a brand-new SHA and a linear history with no merge commit. Because your branch now contains the remote’s tip as an ancestor, the follow-up push was a fast-forward and succeeded.
How It Works Step by Step
Here is what actually happens, object by object, during one edit-commit-push cycle in the centralized workflow:
- You edit a tracked file. Git compares it against what’s recorded in the index (the staging area) and reports it as “modified” in
git status. git addhashes the new file content and writes it as a blob object under.git/objectsif an identical blob doesn’t already exist, then updates the index to point that file’s path at the new blob.git commitbuilds tree objects for every directory from what’s in the index (reusing any tree or blob that didn’t change), creates a new commit object pointing at the root tree and at your currentmaintip as its parent, and moves themainref to the new commit’s SHA.HEAD, which points atmain, follows along automatically.git pushuploads any objects the remote doesn’t already have, then asks the remote to move itsmainref to your new commit — but the remote only agrees if your commit’s ancestry contains its currentmaintip (a fast-forward).- If a teammate’s push already moved the remote’s
maintip past what your local copy oforigin/mainknows about, the fast-forward check fails and the remote rejects your push, leaving both histories untouched. git pull --rebase(or plaingit pull) fetches the missing objects, updates your localorigin/main, and then either replays your commit on top of theirs with a new SHA (rebase) or ties both histories together with a merge commit (merge). Either way, your localmainnow contains the remote’s tip as an ancestor.- The next
git pushis now a fast-forward from the remote’s point of view, so it succeeds and advances the sharedmain.
Common Mistakes
Mistake 1: Pushing without pulling first
git push origin main
If you commit and immediately push without checking for upstream changes, you risk the non-fast-forward rejection shown in Example 3. That rejection itself is harmless — nothing is lost — but it’s a sign you skipped a sync step. The real danger is reaching for --force to make the rejection go away instead of integrating properly.
Fix: run git pull (ideally git pull --rebase) before every push, or set git config --global pull.rebase true so a plain git pull always rebases.
Mistake 2: Force-pushing over a shared main
git push --force origin main
--force unconditionally overwrites whatever is on the remote with your local history, even if a teammate pushed commits you’ve never fetched. Their commits become unreachable from main and can effectively be lost. This is especially dangerous in a centralized workflow, since everyone pushes directly to the one shared branch.
git push --force-with-lease origin main
Fix: if you genuinely need to overwrite the remote (for example, after amending a mistaken commit only you have made), use --force-with-lease instead of bare --force — it refuses to push if the remote has commits you haven’t seen — and coordinate with your team before doing it at all. Never rewrite history that others may have already pulled; that’s Git’s golden rule of rebasing.
Mistake 3: Committing without checking what’s staged
git add .
git commit -m "wip"
git add . stages every modified and new file in the current directory, including debug prints, local config, or half-finished experiments you never meant to share. Since everything you push in this workflow lands straight on main with no pull-request review to catch it, an careless git add . can ship unfinished or sensitive files immediately.
Fix: run git status and git diff --staged before committing, and keep a proper .gitignore for files that should never be tracked (build output, .env, editor files).
Best Practices
git config --global pull.rebase true
- Pull — ideally with
--rebase— at the start of every work session and again right before you push, so conflicts surface early and in small pieces. - Commit small, focused changes with Conventional Commits-style messages (
feat:,fix:,docs:,chore:) so history stays readable for the whole team. - Agree as a team on
pull.rebase: a linear history from rebasing is easier to read, but a plain merge preserves exactly when branches diverged. - Communicate about which files or features you’re touching, since there’s no branch isolation to keep work apart.
- Even without pull requests, consider a GitHub branch protection rule on
main(for example, requiring passing status checks) to catch broken commits before they block everyone else. - Never run a bare
git push --forceon a shared branch; reach for--force-with-leaseand only after checking with the team. - Keep a
.gitignorein the repository from day one, and add a.gitattributeswith* text=autoif the team works across operating systems.
Practice Exercises
- Create a new repository on GitHub, clone it locally, add a
README.md, commit it, and push it tomain. Then edit the README again, commit, and push a second time. Rungit log --onelineand confirm both commits sit on one straight line. - Simulate two collaborators: clone the same repository into two separate local folders (for example
project-aandproject-b). Fromproject-a, commit and push a change. Fromproject-b, without pulling first, make a different change and try to push — you should see a rejected push like the one in Example 3. Resolve it withgit pull --rebase origin mainand push again successfully. - In
project-bfrom the previous exercise, rungit config pull.rebase trueso future pulls rebase automatically. Make one more local commit, thengit pullandgit push, and check the output to confirm no merge commit was created.
Summary
- The centralized workflow has every collaborator clone the same repository and push commits directly to one shared branch, almost always
main. - Each clone holds the full commit history;
origin/mainis only a cached snapshot of the remote, updated byfetchorpull. - Git only allows fast-forward pushes by default, which is exactly what forces you to pull and integrate before pushing whenever someone else has pushed first.
git pull --rebasekeeps history linear by replaying your commits; a plaingit pullmerges and can add merge commits.- Never bare-force-push to a shared
main; use--force-with-leaseand coordinate with the team if shared history truly must be rewritten. - The centralized workflow suits small teams and simple projects; larger teams typically graduate to feature branches and pull requests for code review.
