I pushed my fix, and my teammate’s work disappeared
See the problem
Section titled “See the problem”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?
Define the task
Section titled “Define the task”- 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.
Pause and think
Section titled “Pause and think”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.
Understand the idea
Section titled “Understand the idea”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 firstA 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 BBringing them together. Two ways:
| Command | What it does | The history after |
|---|---|---|
git merge main | Adds one merge commit that joins both lines | Both lines, plus a join |
git rebase main | Replays 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.
Climb the ladder
Section titled “Climb the ladder”All three keep your fix. What changes is whether you keep its history, and whether Bo keeps his work.
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.
# Keep versions as copies of the foldercp -r report report_mondaycp -r report report_finalcp -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.
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.
# One Git history, everyone commits straight to maingit initgit add report.pygit commit -m "Add the sales report"git push origin main# ! [rejected] main -> main (fetch first) <- a teammate pushed firstgit 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
# Keep versions as copies of the foldercp -r report report_mondaycp -r report report_finalcp -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 maingit initgit add report.pygit commit -m "Add the sales report"git push origin main# ! [rejected] main -> main (fetch first) <- a teammate pushed firstgit push --force origin main # <- wipes their commits from mainWhere 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.
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.
# A branch for each change, merged through a pull requestgit switch main && git pull # start from the newest maingit switch -c fix-total # a branch for this one changegit add report.pygit commit -m "Fix the total when a day has no sales"git fetch origingit rebase origin/main # replay your commit on top of the newest maingit 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
# One Git history, everyone commits straight to maingit init# A branch for each change, merged through a pull requestgit switch main && git pull # start from the newest maingit switch -c fix-total # a branch for this one changegit add report.pygit commit -m "Add the sales report"git push origin main# ! [rejected] main -> main (fetch first) <- a teammate pushed firstgit push --force origin main # <- wipes their commits from maingit commit -m "Fix the total when a day has no sales"git fetch origingit rebase origin/main # replay your commit on top of the newest maingit push -u origin fix-total # then open a pull request on GitHubWhen 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.
Write it yourself
Section titled “Write it yourself”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- Not run yet:
ahead_behind(['a', 'b', 'c', 'd'], ['a', 'b', 'x'])→[2, 1] - Not run yet:
ahead_behind(['a', 'b'], ['a', 'b'])→[0, 0] - Not run yet:
ahead_behind(['a', 'b', 'c'], ['a'])→[2, 0] - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | History with reasons | Teammates’ work safe | Checked before main | Use it when |
|---|---|---|---|---|
| Okay: copies | No | Merged by hand | No | Never, once you know Git |
Better: commit to main | Yes | No, with --force | No | Working alone |
| Best: branch and PR | Yes | Yes | Yes | Any shared project |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- Committing secrets: a password in a commit stays in the history even after you delete the file. Use a
.gitignoreand 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 addit, 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 refloglists where your branch pointed recently, even after a bad reset.
Say it in the interview
Section titled “Say it in the interview”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 addputs in the next commit, so you can commit some changes and not others. git fetchorgit pull? Fetch only downloads. Pull downloads and then merges or rebases into your branch.
Practice
Section titled “Practice”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.
1. Read git status (easy)
Section titled “1. Read git status (easy)”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- Not run yet:
changed('A c.py\n?? new.txt')→{'staged': ['c.py'], 'unstaged': [], 'untracked': ['new.txt']} - Not run yet:
changed(' M b.py')→{'staged': [], 'unstaged': ['b.py'], 'untracked': []} - 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']} - Not run yet: Extra test
- 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
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.
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 outWhere it breaks: elif puts "MM a.py" only in staged, though it also has unstaged changes.
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 outWhen 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.
2. Merge one line (medium)
Section titled “2. Merge one line (medium)”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)>>>>>>> theirsHint 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- Not run yet:
merge_line('total = 0', 'total = 1', 'total = 0')→'total = 1' - Not run yet:
merge_line('total = 0', 'total = 0', 'total = 2')→'total = 2' - Not run yet:
merge_line('a', 'b', 'b')→'b' - Not run yet:
merge_line('a', 'b', 'c')→'<<<<<<< ours\nb\n=======\nc\n>>>>>>> theirs' - 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
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.
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 theirsWhere it breaks: when both sides changed the line, theirs is dropped without a word.
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.
3. Replay my commits (hard)
Section titled “3. Replay my commits (hard)”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- Not run yet:
rebase(['A', 'B', 'C', 'F'], ['A', 'B', 'D', 'E'])→['A', 'B', 'C', 'F', "D'", "E'"] - Not run yet:
rebase(['A', 'B'], ['A'])→['A', 'B'] - Not run yet: Extra test
- 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
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.
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_commitsWhere 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.
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 commitsWhen 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.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”git addchooses what goes in the next commit;git commit -m "why"saves it.git switch -c namemakes a branch for one change; commit small steps on it.git fetch, thengit rebase origin/main(or merge it), before you push.git push -u origin name, then open a pull request. Never--forcea shared branch.- Lost something?
git reflog. Undo a pushed commit?git revert <id>.
Go deeper
Section titled “Go deeper”- Pro Git, free online: chapters 2 and 3 cover everything here.
- About pull requests, in the GitHub docs.
git push --force-with-lease, in the Git docs.- Next level of the Python roadmap: Classes: inheritance, MRO,
super()and dunder methods.
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