Skip to content

I pushed my fix, and my teammate’s work disappeared

  • Python · Foundations
  • 25 min read, about 1 h 15 min with practice
  • Concept
  • Asked often: medium
  • Reviewed 2026-11

You and Bo work on the same sales report. You fix the total and run git push. Git says no:

! [rejected] main -> main (fetch first)
hint: Updates were rejected because the remote contains work that you do not have locally.

You’re in a hurry, so you search the error and find git push --force. It works. The next morning Bo asks where his notes went. His commit is gone from main.

How do two people change one project without losing each other’s work?

  • Goal: a way of working where every change is saved with a reason, and nobody’s work is overwritten.
  • Rules: two people may change the project on the same day. Each change gets checked by someone before it reaches main. You can always see what changed, and when.
  • The hard part: your copy and the shared copy move on at the same time.

Example: you and Bo each finish a change. Both end up on main, one after the other, and git log shows both.

Hints open one at a time:

Hint 1 of 3

git fetch downloads the shared commits without changing your files.

Hint 2 of 3

You can put your commits on top of the newest main (rebase), or join the two lines of work (merge).

Hint 3 of 3

A branch per change, and a pull request to merge it, means nobody pushes to main directly.

A commit is a saved version of the whole project, with a message and an id like 3f2a9c1. Each commit points to the one before it, so the commits form a history.

git add report.py # choose what goes in the next commit (the "staging area")
git commit -m "Fix the total when a day has no sales"
git log --oneline # the history, newest first

A branch is a name that points to a commit. Commit on it, and the name moves forward. main is the shared branch. You make your own for each change:

main: A---B---C---F Bo's work, C and F, reached main
\
fix-total: D---E your two commits, made from B

Bringing them together. Two ways:

CommandWhat it doesThe history after
git merge mainAdds one merge commit that joins both linesBoth lines, plus a join
git rebase mainReplays D and E on top of F, as new commits D′ and E′One straight line

Remotes. origin is the shared copy, usually on GitHub. git fetch downloads its commits. git pull is fetch plus merge (or rebase). git push uploads your branch. A push is refused if the remote branch has commits you don’t have. That refusal protects Bo’s work. --force removes the protection.

A pull request (PR) asks to merge your branch into main. Teammates review it, tests run, and then it’s merged.

All three keep your fix. What changes is whether you keep its history, and whether Bo keeps his work.

Okay

Copies of the folder

The idea: Copy the whole folder before each change, and merge teammates’ files by hand.

You can always go back to report_monday. That’s already better than no copy at all.

Terminal window
# Keep versions as copies of the folder
cp -r report report_monday
cp -r report report_final
cp -r report report_final_v2_really
# Your teammate emails you their report.py; you copy their lines in by hand
Works for
One person, small changes
Weak spot
No history, merging by hand
Why
A copy says nothing about what changed or why.

Where it breaks: nobody knows what changed between final and final_v2_really, or why. To use Bo’s work, you copy his lines in by hand and hope you got them all.

Better

Git, everyone on main

The idea: Commit each change with a message, and push straight to main.

The waste in Okay: full copies with no record of what changed, when Git keeps every version with its reason.

Now each change has a message, and git log and git diff show what happened.

Highlighted lines are new in this rung.

Terminal window
# One Git history, everyone commits straight to main
git init
git add report.py
git commit -m "Add the sales report"
git push origin main
# ! [rejected] main -> main (fetch first) <- a teammate pushed first
git push --force origin main # <- wipes their commits from main
Works for
One person
Weak spot
--force wipes work
Why
Every push to main races everyone else’s.
What changed from the previous rung
Terminal window
# Keep versions as copies of the folder
cp -r report report_monday
cp -r report report_final
cp -r report report_final_v2_really
# Your teammate emails you their report.py; you copy their lines in by hand
# One Git history, everyone commits straight to main
git init
git add report.py
git commit -m "Add the sales report"
git push origin main
# ! [rejected] main -> main (fetch first) <- a teammate pushed first
git push --force origin main # <- wipes their commits from main

Where it breaks: two people push to main. The second push is refused, and git push --force “fixes” it by deleting the first person’s commits. And nobody checks a change before it reaches main.

Best

A branch and a pull request

The idea: Branch for each change, rebase it on the newest main, push the branch, and merge it with a PR.

The waste in Better: everyone races to push to one shared branch, and nothing gets checked.

Your branch is yours, so pushing it never touches Bo’s work. git rebase origin/main puts your commit after his. The PR shows the change, a teammate reviews it, and GitHub merges it.

Highlighted lines are new in this rung.

Terminal window
# A branch for each change, merged through a pull request
git switch main && git pull # start from the newest main
git switch -c fix-total # a branch for this one change
git add report.py
git commit -m "Fix the total when a day has no sales"
git fetch origin
git rebase origin/main # replay your commit on top of the newest main
git push -u origin fix-total # then open a pull request on GitHub
Works for
A whole team
Weak spot
A few more steps
Why
Nobody pushes to main; each change is reviewed, then merged.
What changed from the previous rung
Terminal window
# One Git history, everyone commits straight to main
git init
# A branch for each change, merged through a pull request
git switch main && git pull # start from the newest main
git switch -c fix-total # a branch for this one change
git add report.py
git commit -m "Add the sales report"
git push origin main
# ! [rejected] main -> main (fetch first) <- a teammate pushed first
git push --force origin main # <- wipes their commits from main
git commit -m "Fix the total when a day has no sales"
git fetch origin
git rebase origin/main # replay your commit on top of the newest main
git push -u origin fix-total # then open a pull request on GitHub

When Best isn’t best: rebase gives your commits new ids. Never rebase a branch someone else is also using; merge it instead. On a solo project with no reviewer, committing to main is fine.

git status tells you how your branch and the shared one differ: “Your branch is ahead of ‘origin/main’ by 2 commits”, or “have 2 and 1 different commits each”. Write the sum it does.

Each history is a list of commit ids, oldest first. Both start with the same commits, then may split. Return how many commits come after the split on each side: (ahead, behind). Histories can hold tens of thousands of commits.

Ahead and behind

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: ahead_behind(['a', 'b', 'c', 'd'], ['a', 'b', 'x']) → [2, 1]
  2. Not run yet: ahead_behind(['a', 'b'], ['a', 'b']) → [0, 0]
  3. Not run yet: ahead_behind(['a', 'b', 'c'], ['a']) → [2, 0]
  4. Not run yet: Extra test
  5. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayHistory with reasonsTeammates’ work safeChecked before mainUse it when
Okay: copiesNoMerged by handNoNever, once you know Git
Better: commit to mainYesNo, with --forceNoWorking alone
Best: branch and PRYesYesYesAny shared project
  • Committing secrets: a password in a commit stays in the history even after you delete the file. Use a .gitignore and environment variables, and change any secret that was pushed.
  • Huge commits: “Fix things” with 40 files is hard to review. Commit one change at a time, with a message that says why.
  • A merge conflict means both sides changed the same lines. Git marks them with <<<<<<<, ======= and >>>>>>>. Edit the file to the right result, git add it, and continue.
  • Undo the last commit, keep the changes: git reset --soft HEAD~1. Undo a pushed commit: git revert <id>, which adds a new commit that reverses it.
  • Lost work: git reflog lists where your branch pointed recently, even after a bad reset.

Questions to ask first

  • Does the team merge or rebase? Squash on merge?
  • Who reviews pull requests, and what has to pass first?

A 60-second walkthrough

“I make a branch from the latest main for each change and commit in small steps with clear messages. Before pushing, I fetch and rebase onto origin/main, so my commits sit on top of everyone else’s. Then I push the branch and open a pull request. CI runs and a teammate reviews. I never force-push a shared branch; on my own branch after a rebase I use --force-with-lease.”

Likely follow-ups

  • Merge or rebase? Merge keeps the history as it happened, with a join commit. Rebase rewrites your commits into a straight line. Never rebase commits others already have.
  • What is the staging area? What git add puts in the next commit, so you can commit some changes and not others.
  • git fetch or git pull? Fetch only downloads. Pull downloads and then merges or rebases into your branch.

Three problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.

git status --short prints one line per file: two code letters, a space, and the path. The first letter is the staged change, the second the change not yet staged. ?? means untracked. Write changed(status) to sort the paths into "staged", "unstaged" and "untracked". A file can be in both staged and unstaged.

Example: "MM a.py\n M b.py\n?? new.txt" gives {"staged": ["a.py"], "unstaged": ["a.py", "b.py"], "untracked": ["new.txt"]}.

Hint 1 of 3

line.split() throws away the spaces, and with them which column a letter was in.

Hint 2 of 3

line[0] is the staged letter, line[1] the unstaged one, and line[3:] the path.

Hint 3 of 3

A file with MM has both, so use two ifs, not if and elif.

Read git status

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: changed('A c.py\n?? new.txt') → {'staged': ['c.py'], 'unstaged': [], 'untracked': ['new.txt']}
  2. Not run yet: changed(' M b.py') → {'staged': [], 'unstaged': ['b.py'], 'untracked': []}
  3. Not run yet: changed('MM a.py\n M b.py\n?? new.txt') → {'staged': ['a.py'], 'unstaged': ['a.py', 'b.py'], 'untracked': ['new.txt']}
  4. Not run yet: Extra test
  5. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Split each line

The idea: Split the line into code and path; ?? is untracked, anything else staged.

"A c.py" and "?? new.txt" come out right.

def changed(status: str) -> dict[str, list[str]]:
out: dict[str, list[str]] = {"staged": [], "unstaged": [], "untracked": []}
for line in status.splitlines():
code, path = line.split()
if code == "??":
out["untracked"].append(path)
else:
out["staged"].append(path)
return out
Time
O(n)
Space
O(n)
Why
One pass over the n characters.

Where it breaks: split() loses which column the letter was in, so " M b.py" counts as staged. A path with a space gives three parts and a ValueError.

Better

Read the columns

The idea: Take line[0], line[1] and line[3:]; check untracked, then staged, then unstaged.

The waste in Okay: it throws away the column that says what kind of change it is.

Highlighted lines are new in this rung.

def changed(status: str) -> dict[str, list[str]]:
out: dict[str, list[str]] = {"staged": [], "unstaged": [], "untracked": []}
for line in status.splitlines():
x, y, path = line[0], line[1], line[3:]
if x == "?":
out["untracked"].append(path)
elif x != " ":
out["staged"].append(path)
elif y != " ":
out["unstaged"].append(path)
return out
Time
O(n)
Space
O(n)
Why
The same pass.
What changed from the previous rung
def changed(status: str) -> dict[str, list[str]]:
out: dict[str, list[str]] = {"staged": [], "unstaged": [], "untracked": []}
for line in status.splitlines():
code, path = line.split()
if code == "??":
x, y, path = line[0], line[1], line[3:]
if x == "?":
out["untracked"].append(path)
else:
elif x != " ":
out["staged"].append(path)
elif y != " ":
out["unstaged"].append(path)
return out

Where it breaks: elif puts "MM a.py" only in staged, though it also has unstaged changes.

Best

Two separate ifs

The idea: Check the staged and unstaged letters on their own, so a file can be in both.

The waste in Better: it lets one kind of change hide the other.

Highlighted lines are new in this rung.

def changed(status: str) -> dict[str, list[str]]:
out: dict[str, list[str]] = {"staged": [], "unstaged": [], "untracked": []}
for line in status.splitlines():
x, y, path = line[0], line[1], line[3:]
if x == "?":
out["untracked"].append(path)
continue
if x != " ":
out["staged"].append(path)
if y != " ": # not elif: a file can have both
out["unstaged"].append(path)
return out
Time
O(n)
Space
O(n)
Why
The same pass.
What changed from the previous rung
def changed(status: str) -> dict[str, list[str]]:
out: dict[str, list[str]] = {"staged": [], "unstaged": [], "untracked": []}
for line in status.splitlines():
x, y, path = line[0], line[1], line[3:]
if x == "?":
out["untracked"].append(path)
elif x != " ":
continue
if x != " ":
out["staged"].append(path)
elif y != " ":
if y != " ": # not elif: a file can have both
out["unstaged"].append(path)
return out

When Best isn’t best: for scripts, git status --porcelain=v2 -z is made for programs. It never changes between Git versions, and handles odd file names.

Git merges with three versions: the base both sides started from, ours and theirs. Write merge_line(base, ours, theirs) for one line. If only one side changed it, take that side. If both made the same change, take it. If both changed it differently, return a conflict:

<<<<<<< ours
(our line)
=======
(their line)
>>>>>>> theirs
Hint 1 of 3

Compare each side with the base to see who changed the line.

Hint 2 of 3

If ours is still the base, only theirs changed.

Hint 3 of 3

When both changed it, and not in the same way, neither can win.

Merge one line

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: merge_line('total = 0', 'total = 1', 'total = 0') → 'total = 1'
  2. Not run yet: merge_line('total = 0', 'total = 0', 'total = 2') → 'total = 2'
  3. Not run yet: merge_line('a', 'b', 'b') → 'b'
  4. Not run yet: merge_line('a', 'b', 'c') → '<<<<<<< ours\nb\n=======\nc\n>>>>>>> theirs'
  5. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Take theirs

The idea: Always use their line.

When only they changed the line, it’s right.

def merge_line(base: str, ours: str, theirs: str) -> str:
return theirs
Time
O(1)
Space
O(1)
Why
No comparing.

Where it breaks: your change is lost whenever they didn’t touch the line. That’s what --force does to a whole branch.

Better

Ours if we changed it

The idea: Use ours if it differs from the base, else theirs.

The waste in Okay: it never looks at the base, so it can’t tell who changed what.

Highlighted lines are new in this rung.

def merge_line(base: str, ours: str, theirs: str) -> str:
if ours != base:
return ours
return theirs
Time
O(1)
Space
O(n)
Why
Comparing lines of length n.
What changed from the previous rung
def merge_line(base: str, ours: str, theirs: str) -> str:
if ours != base:
return ours
return theirs

Where it breaks: when both sides changed the line, theirs is dropped without a word.

Best

A conflict when both changed it

The idea: Same change or only one side: take it; two different changes: mark a conflict.

The waste in Better: it decides a conflict on its own, when only a person knows which line is right.

Highlighted lines are new in this rung.

def merge_line(base: str, ours: str, theirs: str) -> str:
if ours == theirs or theirs == base:
return ours
if ours == base:
return theirs
return f"<<<<<<< ours\n{ours}\n=======\n{theirs}\n>>>>>>> theirs"
Time
O(n)
Space
O(n)
Why
Comparing lines of length n, and building the marker.
What changed from the previous rung
def merge_line(base: str, ours: str, theirs: str) -> str:
if ours != base:
if ours == theirs or theirs == base:
return ours
return theirs
if ours == base:
return theirs
return f"<<<<<<< ours\n{ours}\n=======\n{theirs}\n>>>>>>> theirs"

When Best isn’t best: real merges line up whole files first, to find which lines match. That part is a diff algorithm, like the one in Python’s difflib.

Write rebase(main, mine). Both are histories, oldest first, that start with the same commits. Return the history after git rebase main on your branch. First comes all of main, then your own commits in order. A replayed commit is a new commit, so add ' to its id: D becomes D'.

Example: rebase(["A", "B", "C", "F"], ["A", "B", "D", "E"]) gives ["A", "B", "C", "F", "D'", "E'"].

Hint 1 of 3

Your own commits are the ones not on main.

Hint 2 of 3

Rebase puts main first, then yours, unlike adding main’s new commits after yours.

Hint 3 of 3

Checking c not in main walks the list each time; a set is faster for long histories.

Replay my commits

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: rebase(['A', 'B', 'C', 'F'], ['A', 'B', 'D', 'E']) → ['A', 'B', 'C', 'F', "D'", "E'"]
  2. Not run yet: rebase(['A', 'B'], ['A']) → ['A', 'B']
  3. Not run yet: Extra test
  4. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Add main’s new commits after mine

The idea: Keep my history, then add the commits only main has.

Every commit is there.

def rebase(main: list[str], mine: list[str]) -> list[str]:
new_on_main = [c for c in main if c not in mine]
return mine + new_on_main
Time
O(m × n)
Space
O(m + n)
Why
Each commit of main is looked for in all of mine.

Where it breaks: the order is wrong. Your work now comes before Bo’s, though his reached main first.

Better

main first, then mine

The idea: Take all of main, then add my commits that aren’t on it.

The waste in Okay: it puts your commits under Bo’s, when rebase puts them on top.

Highlighted lines are new in this rung.

def rebase(main: list[str], mine: list[str]) -> list[str]:
my_commits = [c for c in mine if c not in main]
return main + my_commits
Time
O(m × n)
Space
O(m + n)
Why
Each of my commits is looked for in all of main.
What changed from the previous rung
def rebase(main: list[str], mine: list[str]) -> list[str]:
new_on_main = [c for c in main if c not in mine]
return mine + new_on_main
my_commits = [c for c in mine if c not in main]
return main + my_commits

Where it breaks: it keeps the old ids. A replayed commit has a new parent, so Git gives it a new id. That’s why rebasing a shared branch confuses everyone who has the old ids.

Best

New ids, and a set

The idea: Put main’s commits in a set, keep mine that aren’t in it, and give each a new id.

The waste in Better: it pretends replayed commits are the same commits, and searches a list for each one.

Highlighted lines are new in this rung.

def rebase(main: list[str], mine: list[str]) -> list[str]:
on_main = set(main)
my_commits = [c for c in mine if c not in on_main]
return main + [c + "'" for c in my_commits] # replayed commits are new commits
Time
O(m + n)was O(m × n)
Space
O(m + n)
Why
One pass to make the set, one over my commits.
What changed from the previous rung
def rebase(main: list[str], mine: list[str]) -> list[str]:
my_commits = [c for c in mine if c not in main]
return main + my_commits
on_main = set(main)
my_commits = [c for c in mine if c not in on_main]
return main + [c + "'" for c in my_commits] # replayed commits are new commits

When Best isn’t best: real rebase replays the changes, not the ids, and can stop on a conflict. Then you fix it and run git rebase --continue.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Multiple choicegit push says "rejected (fetch first)". What is the safe next step?
  2. Multiple choiceWhat does git rebase main do to your branch’s commits?
  3. Spot the bugYou committed a password, then deleted the file in the next commit and pushed. What is still wrong?
  4. Predict the outputgit status --short prints this. Which file has changes that are not staged yet?
    A  new.py
    M  app.py
     M notes.md

Add these to your review deck. Each card comes back a little later every time you get it right.

  • git add chooses what goes in the next commit; git commit -m "why" saves it.
  • git switch -c name makes a branch for one change; commit small steps on it.
  • git fetch, then git rebase origin/main (or merge it), before you push.
  • git push -u origin name, then open a pull request. Never --force a shared branch.
  • Lost something? git reflog. Undo a pushed commit? git revert <id>.

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started