In the previous lesson we used git push several times without explaining it. That was fair enough: the project had to get onto the server before we could study the receiving side. Now we turn the channel around and take the sending command apart.
git push is the command with consequences for other people. Everything you have done so far — committing, branching, merging, even getting things wrong — happened inside your own .git/ and could be undone without anyone noticing. A push changes the repository the whole team treats as the reference. What you send, others download; what you delete from the server, others stop having.
That is why this lesson devotes so much space to rejections and to the force options. Pushing is easy; knowing why the server says no, and what is safe to do about it, is what separates the people who do not destroy their colleagues' work from the people who do.
Ana finally pushes main and her branches to the team server, and Bruno and Carla receive them.
Contents
- What
git pushactually sends - The first push:
git push -u origin main - What
-uactually does - The push refspec
- The short forms and
push.default - When the server says no: non-fast-forward
--force-with-leaseversus--force- Deleting a branch on the server
- Pushing tags
- Ana publishes her branches; Bruno and Carla receive them
- What
git push actually sends
git push actually sendsThe precise definition, which is narrower than most people imagine:
git pushdoes two things: it uploads to the remote the objects it is missing, and it asks it to update a reference so that it points at a specific commit.
Objects and a pointer. Nothing else.
What it does NOT send:
- Your working tree. Modified but uncommitted files stay on your disk. If you have edited
app.jsand not runcommit, that change does not leave your machine however much you push. - Your index. The staging area is yours and yours alone.
- Your configuration.
.git/configdoes not travel: each person's remotes, aliases and settings are their own. - Your other branches, unless you ask for them explicitly.
- Your reflog. The record of your movements is local.
- Ignored files. Whatever is not inside a commit does not exist as far as
pushis concerned.
That first one is the most important and the source of a classic bewilderment: "I pushed and my colleague cannot see the change". Almost always it means the change was never committed. Only what sits inside a commit travels.
The internal steps
sequenceDiagram
participant L as Local repository
participant R as Server
L->>R: Which references do you have and where do they point?
R-->>L: refs/heads/main → c2a8f1e
Note over L: Works out which objects<br/>the server has and which it lacks
L->>R: Sends the packfile with the missing objects
Note over R: Stores the objects<br/>and checks that the update<br/>is a fast-forward
L->>R: Update refs/heads/main → b4d7e93
R-->>L: Accepted
That second-to-last step — the fast-forward check — is the heart of section 6 and the reason behind almost every rejection.
And an interesting consequence: pushing is incremental. Git works out which objects the server is missing and sends only those, compressed into a packfile. Sending a project's thousandth commit costs no more than sending its second.
- The first push:
git push -u origin main
git push -u origin mainLet us go back to the moment Ana published. Her repository had the remote registered (lesson 04-02) and authentication sorted out (lesson 04-03), but she had never pushed anything:
Enumerating objects: 34, done. Counting objects: 100% (34/34), done. Delta compression using up to 8 threads Compressing objects: 100% (22/22), done. Writing objects: 100% (34/34), 4.87 KiB | 4.87 MiB/s, done. Total 34 (delta 9), reused 0 (delta 0), pack-reused 0 remote: Resolving deltas: 100% (9/9), done. To https://git.example.com/team/task-manager.git * [new branch] main -> main branch 'main' set up to track 'origin/main'.
That output is worth reading in full, because it tells the whole story:
| Line | What it means |
|---|---|
Enumerating objects: 34 |
Git has counted the objects that need sending |
Delta compression using up to 8 threads |
It is compressing, storing differences between similar objects |
Writing objects: 100% (34/34), 4.87 KiB |
The actual upload: 34 objects in under 5 KB |
remote: Resolving deltas |
This one comes from the server: it is rebuilding the objects. Anything prefixed with remote: originates there |
* [new branch] main -> main |
The main branch has been created on the server |
branch 'main' set up to track 'origin/main' |
The effect of -u: tracking has been configured |
Look at the efficiency: the project's entire history — four base commits, two merges, a squash, a resolved conflict — fits in 4.87 KB. That is the delta compression of the data model we studied in lesson 01-04.
And a check from the server's side:
c2a8f1e4b6d9f3a7e2c5b8d1f4a6c9e3b7d2f5a8 HEAD c2a8f1e4b6d9f3a7e2c5b8d1f4a6c9e3b7d2f5a8 refs/heads/main
The repository that was empty now has a branch and a history.
- What
-u actually does
-u actually does-u is short for --set-upstream, and it turns up in absolutely every Git tutorial without hardly any of them explaining what it does.
What it does is write two lines into .git/config:
[remote "origin"]
url = https://git.example.com/team/task-manager.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
remote = origin
merge = refs/heads/mainThat [branch "main"] section is the tracking relationship: the association between your local main and the main branch on origin. And from that moment on, several things change:
| Without tracking | With tracking (-u) |
|---|---|
git push origin main every time |
plain git push |
git pull origin main every time |
plain git pull |
git status says nothing about the server |
git status says "2 commits ahead of 'origin/main'" |
git branch -vv shows no pairing |
It shows [origin/main] and the gap |
The check is immediate:
That up to date with 'origin/main' line appears only if tracking is configured. Without it, git status would have nothing to compare against.
You only need -u the first time you push a branch. After that the configuration is written and plain git push works.
And remember that in lesson 01-06 we configured this:
With that setting (available since Git 2.37), -u becomes unnecessary: when you push a new branch, Git configures the tracking automatically. Ana could simply have typed git push and the result would have been identical. We used it explicitly so that you could see what happens underneath.
Everything to do with tracking branches — how to inspect them, how to change them, what that "2 commits ahead" really means — is the subject of lesson 04-06, the one that closes the module.
- The push refspec
In lesson 04-02 we took the fetch refspec apart. The push one uses exactly the same syntax, and seeing it completes the circle.
The full form of a push is:
Read aloud: "send my main branch and use it to update the server's main branch."
Compared with the fetch refspec, the logic is identical and only the direction flips:
| Refspec | Source (left) | Destination (right) | |
|---|---|---|---|
fetch |
+refs/heads/*:refs/remotes/origin/* |
The server | Your disk |
push |
main:main |
Your disk | The server |
In both cases, left = where it comes from, right = where it goes. Once you see it that way, it stops being arbitrary syntax.
Cases only the full form can express
# Send my local branch to a server branch with a DIFFERENT name
git push origin feature/alphabetical-order:experimental/order
# Send one specific commit (not the branch tip) to a remote branch
git push origin 9d1e4b7:refs/heads/partial-review
# Send HEAD to a branch with a different name
git push origin HEAD:main
# Update a server branch with the contents of ANOTHER of my branches
git push origin main:productionThe third one, HEAD:main, is useful when you are on a detached HEAD, or on a branch whose local name differs, and you want to send what you have to main.
Short names and full names
These two lines are equivalent:
Git expands short names. But there is one case where the full form is mandatory: when the destination branch does not exist yet and the name is ambiguous. If the server had a tag called review and you wanted to create a branch with that name, Git would not know which you meant:
The solution is to be explicit:
The empty side of the colon: deleting
If you leave the left-hand side empty, you are sending nothing to that reference, which deletes it:
This is the old deletion syntax, and we come back to it in section 8.
- The short forms and
push.default
push.defaultDay to day, hardly anyone writes the full refspec. These are the short forms and what each one means:
# 1. Everything explicit
git push origin main:main
# 2. A single name: it goes to the branch of the same name
git push origin main
# 3. Only the remote: depends on push.default
git push origin
# 4. Nothing: uses the tracked remote and push.default
git pushForm 4 is the one you will use 95% of the time, and its behaviour depends on a setting we already configured back in lesson 01-06:
The possible values and what they do:
| Value | Behaviour |
|---|---|
simple |
(The default since Git 2.0) Pushes only the current branch, and only if the remote one has the same name. With no tracking it refuses and tells you what to type |
current |
Pushes the current branch to a branch of the same name, creating it if need be. Does not require tracking |
upstream |
Pushes the current branch to its tracking branch, even if the name differs |
matching |
(The old, dangerous behaviour) Pushes every local branch that has a namesake on the server |
nothing |
Refuses to do anything without an explicit refspec |
matching deserves a historical warning. It was the default until Git 2.0, and it produced an unpleasant surprise: you typed git push intending to send the branch you were on, and Git sent all eight local branches with namesakes on the server in one go, half-finished experiments you never meant to publish included. Changing it to simple was one of Git's best decisions. Do not set it to matching.
And one option that genuinely is useful when you really do want to send several:
Explicit and deliberate, which is how it should be.
- When the server says no: non-fast-forward
This is Git's most frequent rejection and the one you really must understand.
Bruno starts work in the morning, makes two commits and pushes:
To https://git.example.com/team/task-manager.git ! [rejected] main -> main (fetch first) error: failed to push some refs to 'https://git.example.com/team/task-manager.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.
Why it happens
The server has commits you do not have. While Bruno was working, Ana pushed hers. The graph looks like this:
flowchart LR
C1["c2a8f1e"] --> C2["9e2f4a7"]
C1 --> C3["7d1f4b8"]
C2 --> C4["b4d7e93<br/><b>the server's main</b>"]
C3 --> C5["3e9c2a5<br/><b>Bruno's main</b>"]
style C4 fill:#e8f0ff
style C5 fill:#fff0e8
Both started from c2a8f1e and each moved on separately. They have diverged, exactly the concept from lesson 03-01.
If Git accepted Bruno's push, the server's main reference would jump from b4d7e93 to 3e9c2a5. And since b4d7e93 is not an ancestor of 3e9c2a5, Ana's two commits would stop being reachable from any branch: they would vanish from the project without anyone noticing, and anybody cloning afterwards would see no trace of them.
That is why Git rejects the push: so as not to destroy someone else's work. The rule is that a server reference may only advance fast-forward, that is, towards a descendant of where it was. It is the same notion of fast-forward from module 3, now applied as a safety measure.
And here it is worth connecting two things. Remember from lesson 04-02 that the fetch refspec carries a leading +:
That + allows non-fast-forward updates on your remote references, because their job is to mirror the server whatever state it is in. On the push side there is no + by default, and that is deliberate: permissive inwards, protective outwards.
The messages you will see
| Message | Situation |
|---|---|
! [rejected] main -> main (fetch first) |
The server has commits you do not have |
! [rejected] main -> main (non-fast-forward) |
The same, with the reference already fetched |
! [rejected] main -> main (stale info) |
Your information about the server is out of date (typical with --force-with-lease) |
The basic way out
The procedure is always the same, and it is exactly what you learned in the previous lesson:
# 1. Fetch what is on the server
git fetch origin
# 2. See what it is
git log --oneline main..origin/main
git log --oneline origin/main..main
# 3. Integrate
git merge origin/main
# 4. Push again
git pushWith pull.ff only configured, step 3 in the form of git pull will stop with Not possible to fast-forward, because there is genuine divergence and Git will not decide for you. At that point you have to integrate explicitly.
What you must never do is what instinct — and the odd bit of ill-advised internet wisdom — suggests:
That overwrites the server's reference with your version, and Ana's commits are left orphaned. She will recover them from her own disk, true, but anyone who clones in the meantime will not see them, and confusion is guaranteed.
This section covers the cause and the basic way out. The full treatment of divergence — how to choose between merging, reapplying or rethinking; what to do if there are conflicts as well; how to get out of the tangled cases — is the subject of lesson 09-03: Resolving Divergence with the Remote. Take three ideas away from here:
- The rejection is a protection, not a failure.
- The cause is always the same: the remote has commits you do not have.
- The default way out is to integrate first and push afterwards.
--force-with-lease versus --force
--force-with-lease versus --forceThere are legitimate situations in which you need to overwrite a branch on the server: you have rewritten your own working branch (with the techniques of module 5) and the result is no longer a descendant of what is published. In those cases a normal push will quite rightly be rejected, and you have to force.
But there are two ways to force, and the difference between them can be a colleague's whole afternoon of work.
--force: "write this, whatever happens"
Git overwrites the server's reference without checking anything. If somebody had pushed something in the meantime, their commits are left orphaned.
--force-with-lease: "write this, if the server is still where I think it is"
Git compares the reference's current value on the server against your origin/feature/alphabetical-order, that is, against the latest photograph you hold. If they match, it overwrites. If they do not — because somebody has pushed since your last fetch — it rejects the push:
! [rejected] feature/alphabetical-order -> feature/alphabetical-order (stale info) error: failed to push some refs
The word lease is well chosen: you were holding the reference in a particular state, and if that state has changed the contract is broken and nothing gets written.
flowchart TB
P["git push --force-with-lease"] --> Q{"Is the server where my<br/>origin/branch says it is?"}
Q -->|Yes| A["Overwrites:<br/>nobody has touched anything<br/>since my last fetch"]
Q -->|No| R["REJECTS:<br/>somebody has pushed something<br/>I have not seen"]
style A fill:#e8f4e8
style R fill:#f9e8e8
Side by side
--force (-f) |
--force-with-lease |
|
|---|---|---|
| Checks the server's state | No | Yes |
| If a colleague pushed in the meantime | Destroys it silently | Rejects the push |
| Protects against rewriting others' work | No | Yes |
Needs a recent fetch to be useful |
— | Yes (see the warning below) |
| When to use it | Practically never | When forcing really is necessary |
The important warning about --force-with-lease
The protection rests on your local remote reference. And that opens a hole:
If you fetch immediately beforehand, your origin/branch is updated with whatever your colleague has just pushed, the comparison matches and --force-with-lease happily accepts… overwriting precisely what the option was supposed to protect you from overwriting.
There are two ways to do it properly:
First: look before you force. A fetch followed by a deliberate inspection:
git fetch origin
git log --oneline origin/feature/alphabetical-order
# Is it all mine? Do I recognise every commit?
git push --force-with-leaseSecond, and better: state explicitly which value you expect.
Here you depend on no photograph at all: you tell Git "only write if the server is exactly at 9d1e4b7". It is the safest form, and the one to use in scripts.
Since Git 2.30 there is also a complementary option:
It additionally checks that the commits on the server are included in what you are about to send — that is, that you have genuinely integrated them and not merely downloaded them.
The golden rules of forcing
- Never force
main,developor any shared branch. If the team works on it, rewriting it forces everyone to sort out a mess. And that is exactly what the platforms' protected branches prevent. - Force only your own branches, working branches nobody else is using.
- Always use
--force-with-lease. Turn--forceinto a word you never type. - Tell the team if you force a branch somebody might have cloned.
- If in doubt, do not force. A history with one merge commit too many has never killed anyone; a
--forceonmainon a Friday very nearly has.
One alias helps make the good habit the most convenient one:
And a reassuring note, because there is no need to live in fear: if somebody forces and destroys commits, they can almost always be recovered. Whoever had them on their disk still has them; and on the server, the reflog or the platform's own tools will usually let you rescue them. It is a nuisance, not a catastrophe. Recovery is studied in lesson 09-04.
- Deleting a branch on the server
When a branch has been integrated, it has to be deleted in both places: in your repository (with git branch -d, lesson 03-06) and on the server.
The modern form
Readable and hard to confuse. This is the one to use.
The old form
It does exactly the same thing, and now you can see why: it is the <source>:<destination> refspec with an empty source. You are sending "nothing" to that reference on the server, which removes it.
You will meet it in documentation and in old scripts, so it is worth recognising. But it is also worth knowing the risk: one space too many and you are deleting something you did not mean to. Compare:
git push origin :main # DELETES main from the server
git push origin main # Pushes main to the serverOne character between publishing and destroying. That is why --delete is better.
What has to be done afterwards
Deleting a branch on the server does not delete the references your colleagues hold. Each of them will have to clean up, as we saw in the previous lesson:
And if they also have the local branch, that is deleted separately:
Three different things again: the branch on the server, each person's remote reference and each person's local branch. The next lesson hammers that distinction home.
A warning: deleting a branch on the server can leave its commits with no reference reaching them. If that branch was not merged, those commits are at the mercy of the server's garbage collector. Check beforehand with git log --oneline main..origin/the-branch that there is nothing to lose.
- Pushing tags
A detail that surprises almost everyone the first time: git push does not send tags.
The tag has been created in your repository and there it stays. The default refspec covers branches only (refs/heads/*), not tags (refs/tags/*).
The ways to send them:
# One specific tag
git push origin v1.0.0
# ALL local tags
git push origin --tags
# Only the ANNOTATED tags pointing at commits that are being pushed
git push origin --follow-tagsThe difference between the last two matters:
| Option | What it sends |
|---|---|
--tags |
All your local tags, test ones and ones pointing at unpushed commits included |
--follow-tags |
Only the annotated tags pointing at commits reachable from what you are sending |
--follow-tags is almost always the right option for daily use, and you can leave it configured:
And to delete a tag from the server, the same syntax as always:
What tags are, the difference between lightweight and annotated ones, how they are used to mark versions and how they fit into the release flow is the subject of lesson 05-05: Tagging Commits. Here we care only about the transport: that they do not travel by themselves, and which option sends them.
- Ana publishes her branches; Bruno and Carla receive them
We close with the full team scenario.
Ana has main published already and two local branches still to share: docs/update-notes and fix/focus-after-delete.
docs/update-notes 7f3c9a2 Update the internal notes with the new flow fix/focus-after-delete 8a3f7c1 Fix the focus after deleting a task * main b4d7e93 [origin/main] Adjust the footer style
Only main has a tracking branch (the square brackets). The other two do not exist on the server yet.
Enumerating objects: 5, done. Writing objects: 100% (3/3), 412 bytes | 412.00 KiB/s, done. To https://git.example.com/team/task-manager.git * [new branch] docs/update-notes -> docs/update-notes branch 'docs/update-notes' set up to track 'origin/docs/update-notes'.
Note the last line: tracking has configured itself, with no -u. That is push.autoSetupRemote true from lesson 01-06 doing its job.
* [new branch] fix/focus-after-delete -> fix/focus-after-delete branch 'fix/focus-after-delete' set up to track 'origin/fix/focus-after-delete'.
She did not even have to name the remote or the branch. The final state:
b4d7e935f1c8a2e6d4b7f9a3c1e5b8d2f4a6c9e7 HEAD 7f3c9a28e4b1d6f9a2c5e8b3d7f1a4c6e9b2d5f8 refs/heads/docs/update-notes 8a3f7c1e4b9d2f6a5c8e3b7d1f4a9c2e6b5d8f3a refs/heads/fix/focus-after-delete b4d7e935f1c8a2e6d4b7f9a3c1e5b8d2f4a6c9e7 refs/heads/main
Bruno receives
From https://git.example.com/team/task-manager * [new branch] docs/update-notes -> origin/docs/update-notes * [new branch] fix/focus-after-delete -> origin/fix/focus-after-delete
* main remotes/origin/HEAD -> origin/main remotes/origin/docs/update-notes remotes/origin/fix/focus-after-delete remotes/origin/main
Ana's branches are on his disk as remote references, not as local branches. He can examine them without creating anything:
Carla receives and gets to work
Carla does the same and decides to carry on with Ana's fix:
branch 'fix/focus-after-delete' set up to track 'origin/fix/focus-after-delete'. Switched to a new branch 'fix/focus-after-delete'
She works, commits and pushes:
echo "// Return focus to the field after deleting" >> app.js
git commit -am "Return focus to the field after deleting"
git pushTo git@git.example.com:team/task-manager.git 8a3f7c1..d5e9b2f fix/focus-after-delete -> fix/focus-after-delete
This is the full cycle closed. Note the shape of that last line, different from the one on the first push:
* [new branch] X -> X→ a branch has been created on the server.8a3f7c1..d5e9b2f X -> X→ an existing branch has advanced from one commit to another, fast-forward.
The project that spent three modules shut inside a single laptop now circulates between three machines and three different operating systems.
Common Mistakes and Tips
Mistake 1: "I pushed and they cannot see my change". Ninety per cent of the time, the change was never committed. git push sends commits, not modified files. Check with git status and git log --oneline -3.
Mistake 2: answering a rejection with --force. A non-fast-forward rejection means the server has work you do not have. Forcing destroys it. The right answer is fetch + integrate + push. See 09-03.
Mistake 3: using --force instead of --force-with-lease. The first checks nothing; the second refuses if somebody has pushed since your last fetch. One costs no more to type than the other; make an alias.
Mistake 4: running fetch right before --force-with-lease. It cancels out the protection, because it updates the very reference Git compares against. Either look deliberately at what has arrived, or use the --force-with-lease=branch:hash form.
Mistake 5: forcing a shared branch. Rewriting main forces the whole team to sort out a mess they did not cause. Force only your own working branches.
Mistake 6: believing git push sends tags. It does not: the default refspec covers refs/heads/* only. Use --follow-tags or set push.followTags true.
Mistake 7: typing git push origin :branch without paying attention. One extra space between the : and the name changes the meaning entirely. Use --delete, which says what it does.
Mistake 8: deleting a branch from the server and expecting it to vanish from everyone else's repository. Each person has to run git fetch --prune. Set fetch.prune true across the team.
Tip 1: look before you push. git log --oneline origin/main..main shows you exactly which commits are about to go out. Ten seconds that stop you publishing a wip or a forgotten console.log.
Tip 2: run git fetch before you start, not before you push. That way you spot divergence while it is still cheap to resolve, instead of discovering it with the work already finished.
Tip 3: an alias for safe forcing.
Tip 4: check what push.default is set to. The value simple is the right one. If you work on a team with old configurations, make sure nobody is on matching.
Tip 5: --dry-run for a rehearsal.
It shows what the push would do without doing it. Useful with awkward refspecs or before a deletion.
Exercises
Exercise 1: the anatomy of a push
Set up a bare server and a working repository. Then:
- Make three commits and push with
-u. - Show
.git/configbefore and after, and point out which lines-uadded. - Explain the full push output, line by line.
- Make another commit and push without
-u. What changes in the output, and why? - Check from the server that the references are where they should be, without cloning.
Exercise 2: causing and resolving a rejection
With one server and two clones (Ana and Bruno):
- Both start from the same commit.
- Ana commits and pushes.
- Bruno commits (without fetching) and tries to push.
- Capture the rejection message and explain exactly why it happened, drawing the graph.
- Resolve it properly without forcing, and demonstrate that both sets of commits are still on the server.
- Repeat the experiment resolving it with
--force, and demonstrate that Ana's commit has vanished from the branch.
Exercise 3: push refspecs
With a repository connected to a bare, achieve the following and explain each command:
- Push your local branch
feature/testto a server branch calledexperimental/test. - Push the state of a commit earlier than your branch tip to a server branch called
review. - Push
HEADtomainwhile on a detached HEAD. - Delete
experimental/testfrom the server both possible ways. - Check the result with
git ls-remoteafter each step.
Solutions
Solution 1:
# 1. Three commits
echo "<h1>Task manager</h1>" > index.html
git add . && git commit -m "Add initial task manager structure"
echo "body { font-family: sans-serif; }" > styles.css
git add . && git commit -m "Add base styles for the list"
echo "console.log('tasks');" > app.js
git add . && git commit -m "Add task deletion to the list"
# 2a. Configuration BEFORE
git remote add origin /tmp/ex-push/server.git
cat .git/config[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
[remote "origin"]
url = /tmp/ex-push/server.git
fetch = +refs/heads/*:refs/remotes/origin/*Enumerating objects: 9, done. Counting objects: 100% (9/9), done. Delta compression using up to 8 threads Compressing objects: 100% (5/5), done. Writing objects: 100% (9/9), 782 bytes | 782.00 KiB/s, done. Total 9 (delta 1), reused 0 (delta 0), pack-reused 0 To /tmp/ex-push/server.git * [new branch] main -> main branch 'main' set up to track 'origin/main'.
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
[remote "origin"]
url = /tmp/ex-push/server.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "main"]
remote = origin
merge = refs/heads/mainThe new lines are the [branch "main"] section with remote and merge. That is all -u does: establish the tracking branch.
3. The output, line by line:
Enumerating objects: 9— Git has identified 9 objects to send: 3 commits + 3 trees + 3 blobs.Delta compression using up to 8 threads— it compresses in parallel, storing differences between similar objects.Writing objects: 100% (9/9), 782 bytes— the actual upload: 782 bytes.Total 9 (delta 1), reused 0— of the 9 objects, 1 has been stored as a difference against another; none has been reused because the server was empty.To /tmp/ex-push/server.git— the destination.* [new branch] main -> main— themainbranch has been created on the server. The asterisk and[new branch]mark the creation.branch 'main' set up to track 'origin/main'— the effect of-u.
# 4. Another commit, pushed without -u
echo "# Task manager" > README.md
git add . && git commit -m "Document installation in the README"
git pushEnumerating objects: 4, done. Writing objects: 100% (3/3), 298 bytes | 298.00 KiB/s, done. To /tmp/ex-push/server.git 7d2f9a1..4c8e3b6 main -> main
Two differences, and both have an explanation:
- Plain
git pushwas enough: tracking was already configured by the earlier-u, so Git knows which remote and which branch to send to. - The result line no longer says
[new branch]but7d2f9a1..4c8e3b6: the branch already existed and moved from one commit to another. That two-dot range signals a fast-forward, exactly the notation we saw withfetch.
On top of that, only 3 new objects were sent instead of the initial 9: pushing is incremental.
4c8e3b6f9a2d5c8e1b7f4a3d6c9e2b5f8a1d4c7e HEAD 4c8e3b6f9a2d5c8e1b7f4a3d6c9e2b5f8a1d4c7e refs/heads/main
4c8e3b6 Document installation in the README 7d2f9a1 Add task deletion to the list 9f4c2a8 Add base styles for the list 5a1d8f3 Add initial task manager structure
Solution 2:
mkdir -p /tmp/ex-reject && cd /tmp/ex-reject
git init --bare server.git
git clone server.git ana
cd ana
echo "base" > f.txt
git add . && git commit -m "Add initial task manager structure"
git push -u origin main
cd ..
git clone server.git bruno# 2. Ana commits and pushes
cd /tmp/ex-reject/ana
echo "counter" >> f.txt
git commit -am "Add the pending task counter"
git push
git log --oneline -2# 3. Bruno commits without fetching and tries to push
cd /tmp/ex-reject/bruno
echo "filter" >> f.txt
git commit -am "Add the pending task filter"
git pushTo /tmp/ex-reject/server.git ! [rejected] main -> main (fetch first) error: failed to push some refs to '/tmp/ex-reject/server.git' hint: Updates were rejected because the remote contains work that you do hint: not have locally.
4. Why: both started from 5a1d8f3. Ana created 9e2f4a7 on top and published it; Bruno created 7d1f4b8 on top of the same 5a1d8f3. The graph:
For Bruno's push to be valid, 9e2f4a7 would have to be an ancestor of 7d1f4b8. It is not: they are sibling branches. If the server accepted the update, refs/heads/main would move to 7d1f4b8 and 9e2f4a7 would stop being reachable: Ana's commit would disappear from the project. Git prevents that.
You can check it formally:
git fetch origin
git merge-base --is-ancestor origin/main main && echo "It would be a fast-forward" || echo "NOT a fast-forward: hence the rejection"If it is the same file, there will be a conflict (both added a line at the end of f.txt). It is resolved with what you learned in 03-05:
# Resolve keeping both contributions
printf "base\ncounter\nfilter\n" > f.txt
git add f.txt
git commit -m "Merge the task counter and the filter"
git push# BOTH sets of commits are on the server
git --git-dir=/tmp/ex-reject/server.git log --oneline --graph main* c3b8f5d Merge the task counter and the filter |\ | * 9e2f4a7 Add the pending task counter * | 7d1f4b8 Add the pending task filter |/ * 5a1d8f3 Add initial task manager structure
Nothing has been lost. Both pieces of work coexist, joined by a merge commit with two parents, exactly as in module 3.
# 6. The destructive version, so you can see it with your own eyes
cd /tmp/ex-reject
rm -rf server.git ana bruno
git init --bare server.git
git clone server.git ana
cd ana && echo "base" > f.txt && git add . && git commit -m "Base" && git push -u origin main && cd ..
git clone server.git bruno
cd ana
echo "counter" >> f.txt && git commit -am "Add the pending task counter" && git push
cd ../bruno
echo "filter" >> f.txt && git commit -am "Add the pending task filter"
git push --forceThat + and the (forced update) are the mark of the disaster:
Ana's commit is no longer on the branch. Anyone cloning now will not see it. It still exists as a loose object on the server — and certainly in Ana's repository — but no reference reaches it. Now compare with what would have happened using the safe option:
Rejected. The server was not where Bruno's photograph said it was, so the protection kicked in. That, in one line, is the reason never to type --force again.
Solution 3:
mkdir -p /tmp/ex-refspec && cd /tmp/ex-refspec
git init --bare server.git
git init -b main work
cd work
echo "base" > f.txt && git add . && git commit -m "Base"
git remote add origin /tmp/ex-refspec/server.git
git push -u origin main
git switch -c feature/test
echo "one" >> f.txt && git commit -am "First step"
echo "two" >> f.txt && git commit -am "Second step"
echo "three" >> f.txt && git commit -am "Third step"# 1. Local branch -> remote branch with a DIFFERENT name
git push origin feature/test:experimental/testThe <source>:<destination> refspec decouples the two names: on the left what I send, on the right what it is called over there.
Here the full form refs/heads/review is necessary: since the destination branch does not exist yet, Git cannot work out whether you want to create a branch or a tag. With the full name there is no ambiguity. And notice that the left-hand side is a hash, not a branch name: any expression that resolves to a commit will do.
# 3. Detached HEAD -> main
git switch --detach 8f1a3d5
git push origin HEAD:refs/heads/from-detachedHEAD resolves to the commit you are on, even with no branch pointing at it. It is the way to publish work from a detached HEAD without having to create a local branch first.
Both do the same thing. The second is the refspec with an empty source: sending "nothing" to that reference removes it. It is exactly the same syntax, taken to its limit.
main and from-detached remain; experimental/test and review have gone. And one important detail: this repository's local branches were untouched by either deletion.
Conclusion
With this lesson, the team's work flows in both directions. The essentials:
git pushsends objects and asks for a reference to be updated. It does not send your working tree, your index, your configuration or your other branches. Only what sits inside a commit travels.git push -u origin <branch>:-uwrites the[branch "…"]section into.git/configand establishes the tracking branch. From then on, plaingit pushandgit pullwork andgit statuscan report the gap. Withpush.autoSetupRemote true(lesson 01-06) you do not even need it.- The push refspec uses the same syntax as the fetch one:
source:destination, left where it comes from and right where it goes. It lets you push to a branch with a different name, push a specific commit or pushHEAD. push.default simplepushes only the current branch. The oldmatchingpushed every namesake and was a source of surprises: do not use it.- A non-fast-forward rejection happens because the remote has commits you do not have. If Git accepted the push, those commits would stop being reachable and would disappear from the project. It is a protection, not a failure. The basic way out is
fetch→ integrate → push; the full treatment is in lesson 09-03. --force-with-leaseversus--force: the first checks that the server is still where your remote reference says and rejects if somebody has pushed; the second overwrites without looking and can leave someone else's work orphaned. Always use the first, and do notfetchimmediately beforehand (or use the--force-with-lease=branch:hashform). Never force shared branches.- Deleting a branch from the server:
git push origin --delete <branch>, or the old formgit push origin :<branch>(a refspec with an empty source). Everyone else will have to rungit fetch --prunefor it to disappear from their references. - Tags do not travel on their own:
--tagssends them all,--follow-tagsonly the annotated ones reachable from what you are sending, which is usually what you want. Tags are covered in lesson 05-05.
What comes next
You now know how to push and how to fetch. But along the way these lessons have thrown up messages we have not yet fully explained: "Your branch is ahead of 'origin/main' by 2 commits", "branch 'main' set up to track 'origin/main'", those square brackets in git branch -vv, and that git switch which creates a local branch from a remote one without being asked.
All of it points to the same concept: tracking branches. In lesson 04-06: Tracking Branches, which closes the module, we will see exactly what a branch's upstream is and where it lives in .git/config, we will settle once and for all the difference between main, origin/main and the server's main, and we will take apart how Git works out that "2 commits ahead". It is the lesson that ties every thread of the module together.
Mastering Git: From Beginner to Advanced
Module 1: Introduction to Git
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
