The developer writes in the ticket: "fixed, it's in the fix-cart-total branch, please check". You open the environment and the behaviour is unchanged. Half a day of messages later it turns out the branch has not been merged: the environment runs a different version of the code, and the fix is simply not in it.
The second reason to dig in is automated tests: they live in the same repository as the code, next to test data and checklists. Even swapping a single selector means a branch, a commit and a merge request.
Top: your branch left main while main moved ahead with someone else's commit. Both edits touched the same lines, so Git stops and asks whose version is right. Then two outcomes: merge adds a merge commit with two parents and keeps both lines visible, while rebase replays the same edits on top of fresh main — the history straightens out, but the commits are new ones with different identifiers.
Repository, commit, branch
Folders named "tests_final", "tests_final_2", "tests_final_pete_fix" are history kept by hand; a month later nobody can say which one works. Git does it differently: a repository stores a chain of snapshots of the project, not copies of folders.
One snapshot is a commit: the state of every file plus author, time, message and an identifier (the long hexadecimal string is usually written as its first seven characters — a1b2c3d). A commit is exactly what people point at when they say "the fix went into the build".
A branch is a movable bookmark on that chain; each new commit pushes the bookmark forward. The shared branch is usually called main, and builds for the environments come from it. Your task lives in its own branch, and switching branches changes the files in your working folder.
A day in a branch: take, fix, send
To know where a colleague's fix currently sits you only need one flow — it is the same for a developer and for an automation engineer, and only the content of the commit differs.
git switch main && git pull # fresh shared branch
git switch -c fix-login-test # your own branch for the task
git add tests/login_test.py # pick what goes into the snapshot
git commit -m "test: wait for the button, not a fixed pause"
git push -u origin fix-login-test # send it to the server
Then you open a merge request on the server — a pull request in GitHub, a merge request in GitLab: description, discussion, automated checks and a merge button. Until that button is pressed, your change exists only in the branch.
Two consequences for testing follow. First, you check the build that actually contains the change, and the build number always goes into the bug report. Second, a branch is short-lived: merge it and delete it, because a long-living branch piles up divergence from main and turns the merge into a job of its own.
merge and rebase: two ways to join branches
"Rebase onto main and then we'll merge" is the sentence people usually trip over. What stands behind it is simple: while you were fixing the test, other commits landed in main and the branches diverged.
merge joins them as they are: Git creates a merge commit with two parents, and the history shows a diamond — split, then joined. Nothing is rewritten.
rebase replays your commits on top of the fresh tip of main: the history becomes a straight line, as if you had started this morning. The price of that beauty is that these are new commits — same content, different identifiers, the old ones replaced.
Merge is the default choice: it rewrites nothing, so there is little to get wrong. Rebase belongs in your own branch, the one nobody else has pulled — including the everyday git pull --rebase. The general rule sounds like this: history already pushed to a shared branch is never rewritten. Colleagues who pulled the old commits would end up with a history that disagrees with yours, which is also why push --force into main is banned almost everywhere.
A merge conflict is a question, not a crash
A conflict is not Git breaking; it is an honest "I don't know": both branches changed the same lines differently. git status lists the disputed files, and inside the file you find markers:
<<<<<<< HEAD
wait_for(button, timeout=5)
=======
wait_for(button, timeout=15)
>>>>>>> fix-login-test
The top part is the branch you are standing on — that is what HEAD marks — and the bottom one is the branch you are merging in. The order of work: open the file, decide by the meaning of the task, delete the markers, git add file, then git commit. Changed your mind halfway — git merge --abort puts everything back.
The big trap is deciding mechanically. "Keep mine" in a shared data file or checklist quietly wipes out someone else's checks, and the loss surfaces a week later. Prevention is cheaper than the cure: short branches and pulling main in regularly keep conflicts small and rare.
A quick switch: stash
Half a change sits in your file, nothing is committed, and you are asked to verify an urgent fix on another branch right now. Git will not always let you switch: if your edit touches a file that looks different in the other branch, it stops rather than overwrite your work. And when it does let you through, the unfinished change travels with you and may end up in someone else's commit.
git stash # put unfinished work in a pocket
git switch hotfix-payments # go check the urgent thing
git switch - # back to your branch
git stash pop # take the work out again
Stash is a pocket for half an hour, not an archive: back on the task — pop straight away. A dozen forgotten stashes is a reliable way to never recall what was in them.
revert against reset: only revert in a shared branch
A merged change broke the environment and has to be rolled back. There are two tools, and the choice between them comes down to "has anyone else seen this yet".
reset moves the branch bookmark backwards, that is, it rewrites history: fine while the commits live only on your machine (--hard also throws the changes away — the one place in Git where work is genuinely lost). revert rewrites nothing: it creates a new commit with the opposite changes. Not a line of history is lost, which is why anything merged into main is rolled back this way only.
For testing, revert has a consequence: the defect is not closed after a rollback. The code went back to its previous state, the cause did not go anywhere — the ticket returns to work, and the retest happens on a build that already includes the revert.
Which build the change appeared in: log and blame
"It used to work" helps nobody in a bug report. "Works on 4.18, fails on 4.19" narrows the search down to the commits between two builds — and that list is read straight from the repository.
git log --oneline v4.18..v4.19 # what landed between builds
git log --oneline -- tests/login_test.py # the history of one file
git blame tests/login_test.py # who changed each line
blame shows the commit, author and date for every line. The name misleads: this is archaeology, not a hunt for the guilty. You find a strange line, go to its commit, and the message explains "why" — and when it doesn't, you at least know whom to ask.
In short
- A commit is a snapshot of the project with author, time and identifier; a branch is a movable bookmark on the chain of commits.
- Until the merge request is accepted, the change exists only in the branch: test the build that really contains it.
- merge keeps both lines and adds a commit with two parents; rebase replays your commits on top of
mainas new ones. - What has been pushed to a shared branch is not rewritten: merge and revert there, reset, rebase and force-push only in your own branch.
- Resolve a conflict by meaning: markers →
add→commit, and--abortcancels the whole merge. logover a range of builds andblameon a line turn "it used to work" into a specific commit.
What to read next
- How automation works — the test code that lives in this repository.
- How to write a bug report — where the build number and the commit range belong.
- Linux commands and the terminal — the same command line, a neighbouring skill.
- Test plan and test suites — where the environment and the build are agreed before testing starts.