A commit is a snapshot of the whole project plus its parent commits, author and message, named by a hash of all of that. A branch is a movable name pointing at one commit, and HEAD marks what you have checked out, normally a branch.
A commit stores the full tree, not a diff, and its hash covers the parents too, so changing an old commit changes every hash after it: that is why rewriting pushed history causes trouble.
A branch is just a pointer: a ref in .git/refs/heads/ holding one hash that each new commit moves forward, so creating a branch is instant.
**HEAD** normally contains ref: refs/heads/main, so a new commit moves that branch.
Detached HEAD: HEAD holds a commit hash directly, after git checkout v1.4.0 or during a rebase or bisect. Commits made there belong to no branch: run git switch -c fix/login before switching away, or find them later in git reflog.
GoJunior
Git & Team Workflow
Merge vs rebase, conflicts, reset and revert, cherry-pick, stash, reflog and bisect, branching strategies, pull requests and code review, commit conventions, secrets in history, go.mod and go.sum
Questions closed
0 / 18
- 0Review
Questions
git fetch downloads new commits and moves remote-tracking branches such as origin/main, but never touches your branch or your files. git pull is a fetch followed by a merge (or a rebase) of the upstream branch into the one you are on.
Fetch is always safe: run it, then git log main..origin/main to see what arrived before you integrate it.
A plain pull merges: when both sides have new commits, you get a "Merge branch 'main' of …" commit that says nothing about the work.
**git pull --rebase** replays your unpushed commits on top of the fetched ones and keeps history straight; git config --global pull.rebase true makes it the default.
Diverged, with no preference set: current Git stops with fatal: Need to specify how to reconcile divergent branches. until you choose pull.rebase or pull.ff only.
Merge joins two histories with a merge commit and leaves every existing commit as it was. Rebase replays your commits on top of another branch as new commits with new hashes, giving a straight history, which is why rebasing commits others already have breaks their copies.
Fast-forward: if main has not moved since you branched, a merge just moves the main label to your last commit; --no-ff forces a merge commit so the feature stays visible as a unit.
Merge keeps the true history and is safe on shared branches; the price is a graph full of merge commits.
Rebase gives a linear log that is easier to read and to bisect; conflicts come one commit at a time.
The golden rule: rebase only commits nobody else has built on. Rebase and force-push a branch a teammate has pulled, and their old commits and your new ones become two copies of the same work.
Open each conflicted file, work out what both sides meant, write the version that keeps both intentions and delete the markers. Then build, run the tests, git add the files and finish with git commit or git rebase --continue; git merge --abort puts everything back if you get lost.
See both sides and the base: git status marks conflicted files UU, and git config --global merge.conflictStyle zdiff3 adds the original text between the two sides, so you see what each side changed.
Resolve by intent: if one side renamed a method and the other added a call to it, the result needs both.
Check before you commit: run go build ./... and go test ./..., because code that compiles can still be resolved wrong; git diff --check reports any leftover conflict marker.
Never commit markers, keep both blocks blindly (two copies of one method or import), take a whole side with --ours or --theirs just to end the conflict, or hand-merge generated code such as *.pb.go or sqlc output: regenerate it.
git stash shelves your uncommitted changes and leaves a clean working tree; git stash pop brings them back. git cherry-pick <sha> copies the change made by one commit onto the current branch as a new commit with a new hash.
git stash
New files stay behind: plain git stash saves tracked changes only; git stash -u includes untracked files.
Local and easy to forget: stashes are never pushed; name them (git stash push -m "wip: cart badge") and check git stash list.
**pop can conflict**: Git then keeps the entry, and you git stash drop it after resolving; for a long detour, a WIP commit is safer.
git cherry-pick
Typical use: backporting a fix from main to release/1.4; -x records "(cherry picked from commit …)" in the message.
The same change twice: the copy has another hash, so a later merge sees two commits, and it conflicts if the code around them changed.
.gitignore only stops untracked files from being added; a file Git already tracks stays tracked whatever the patterns say. Remove it from the index with git rm --cached and commit that: the file stays on your disk but leaves the repository.
Patterns are relative to the .gitignore that holds them: /build/ matches at that level only, build/ at any depth, and ! re-includes a path; git check-ignore -v <path> names the line that matched.
A Go project ignores built binaries (/bin/, /shop), test binaries (*.test), coverage profiles (coverage.out), local .env files, and go.work with go.work.sum when the modules are released separately.
It commits go.mod and go.sum, the output of go generate, and vendor/ only if the team vendors.
**The trap after git rm --cached**: teammates who pull that commit lose their copy of the file, which hurts for a config they filled in by hand, so warn them first.
Merge the require lines by hand, keeping every module either side needs and the higher version where both changed one, then run go mod tidy and build. Taking one side and letting tidy re-add the rest gives the other side's imports the newest releases, not the versions it tested.
Why the higher version: require lines are minimums and MVS builds the highest one anyway; keeping the lower one either changes nothing or downgrades code the other side tested with.
Tidy cannot read markers: it stops with malformed module path "<<<<<<<": invalid char '<', so the file must be valid before any go command runs.
The trap: keep main's go.mod, and tidy finds the branch's golang.org/x/sync import with no requirement and picks the latest release, which here also demanded a newer Go.
**go.sum is a set of hashes: keeping both sides' lines is safe, and tidy drops the ones nothing needs.
Look for replace**: a replace … => ../lib someone used locally works only on their machine; go.work is the local tool for that.
Check before committing: go build ./..., go test ./... and go mod verify; git diff main -- go.mod should show only your modules, and go mod tidy -diff in CI fails on anything left untidy.
A, B and C all move main back to b2 and differ only in where the change from c3 ends up: staged, unstaged or gone. D adds a new commit that undoes c3 and rewrites nothing, so on a pushed branch only git revert is safe.
**A, --soft**: the log ends at b2, and git status lists modified: client/client.go under "Changes to be committed". Handy for squashing recent commits.
**B, --mixed**: the log ends at b2, and the same change sits under "Changes not staged for commit". Handy for splitting a commit.
**C, --hard**: the log ends at b2 and the tree is clean; only git reflog still knows c3, and uncommitted edits it wipes are lost for good.
**D, revert**: the log reads Revert "Add retry", c3, b2, a1; the files match b2, and teammates just pull it.
A, B or C on a pushed branch: git status says "Your branch is behind 'origin/main' by 1 commit" and git push is rejected as non-fast-forward; forcing it pulls c3 out from under everyone who has it.
**git restore is for files, not history**: git restore <file> drops uncommitted edits, --staged unstages, and --source=b2 <file> brings back one file's old version without moving a branch.
git rebase -i origin/main lists your branch's commits; changing pick to squash, fixup, reword or drop, or reordering lines, rewrites them into a history worth reading. The rewritten branch needs a force push, and --force-with-lease refuses if someone else pushed meanwhile.
The verbs: reword edits a message, edit stops so you can amend or split, squash melds into the previous commit and keeps both messages, fixup melds and drops the message, drop deletes the commit.
Fixups as you go: git commit --fixup=<sha>, then --autosquash moves each fixup under its target.
**--force overwrites blindly: a commit a teammate pushed to the branch an hour ago disappears.
--force-with-lease** pushes only if the remote branch is where your last fetch saw it; otherwise ! [rejected] … (stale info).
Its gap: an IDE's background fetch updates origin/feature and the lease passes anyway; --force-if-includes (Git 2.30+) also requires the remote tip to be part of your history.
Only your own branch: rewriting main or a shared branch breaks the golden rule whatever the flag.
A reset or a rebase does not delete commits, it only stops pointing at them. git reflog lists every position HEAD has had, so you find the hash from before the accident and put a branch back on it with git reset --hard <sha> or git branch rescue <sha>.
Read it top down: HEAD@{0} is now; the state you want is the entry just below reset: moving to HEAD~2, or just below the rebase (start) line.
Each branch has its own reflog: git reflog show feature/search, where feature/search@{1} is the branch's previous tip.
Right after a rebase or a reset: git reset --hard ORIG_HEAD undoes it.
Not sure yet? git branch rescue <sha> parks the commit under a name before you look around.
What it cannot bring back: edits that were never committed or stashed, because reset --hard overwrites them; staged files survive only as loose objects that git fsck --lost-found may find.
Local, and it expires: the reflog is not on the server or in a fresh clone; unreachable entries expire after 30 days by default (the rest after 90), and then git gc may delete the commits.
You mark one commit bad and an older one good, and Git binary-searches the commits between them: it checks out the middle one, you test it and answer good or bad, and the range halves until one commit is left. A thousand commits take about ten steps.
Start and finish: git bisect start, git bisect bad, git bisect good v1.4.0; git bisect reset returns you to where you began.
**git bisect run <cmd> answers for you: exit code 0 is good, 1 to 127 is bad, and 125 means "cannot test this one, skip it".
Write the check first: a test that fails on the bug, left untracked, because checkouts leave untracked files alone.
Skip what does not build with exit 125, or bisect blames a commit that merely failed to compile.
Old commits, old dependencies**: the go command downloads whatever each commit's go.mod asks for, so nothing extra is needed.
**git bisect start --first-parent** steps only through main's own commits and names the merged pull request that broke it.
Flaky tests ruin it: one wrong answer sends the search into the wrong half.
A good message states in a short imperative subject what the commit does and explains in the body why. Conventional Commits add a machine-readable prefix (feat:, fix:, feat!:), so a tool can choose the next semantic version and write the changelog straight from the history.
Subject: about 50 characters, imperative ("Add retry to upload", not "Added"), no full stop. After a blank line, a body wrapped at 72 that gives the reason; the diff already shows what changed.
One logical change per commit, so it can be reverted or cherry-picked on its own.
The format: type(scope): description, with types such as feat, fix, refactor, perf, test, docs, ci and chore.
Mapping to semver: fix bumps the patch, feat the minor, and a ! after the type or a BREAKING CHANGE: footer the major.
Tools: release-please (release type go) or semantic-release write the changelog and the tag, and GoReleaser builds the binaries from that tag. For a module the tag is the version, so a breaking feat! from v1 on also needs the /v2 module path.
Squash merges turn the pull request title into the commit on main, so lint PR titles in CI; a commit-msg hook with commitlint catches the rest locally.
A good pull request does one thing, is small enough to read in one sitting, and says why it exists and how it was tested. A good review checks behaviour and design first, leaves formatting to the tools, and marks how much each comment matters.
As the author
Small and single-purpose: a few hundred changed lines; a refactor, a rename and a feature go in separate pull requests, so each can be reviewed and reverted alone.
The description: what and why, how you tested it, what you are unsure about, a sample request and response for an API change, and the ticket link.
Review your own diff first: stray logs and commented-out code are yours to catch.
As the reviewer
In this order: does it do what the ticket asks, what breaks (edge cases, errors, concurrency), does it fit the architecture, would a test fail without it; naming and style come last, or from a linter.
Label the weight: blocking:, suggestion:, nit:, and approve with comments when only nits remain.
Ask rather than order: "What happens here if the request times out?" teaches more than a rewritten snippet.
When you disagree
Reply to every comment, fixed or with a reason; after two rounds, talk for five minutes or ask the tech lead, and write the decision in the thread.
GitFlow keeps long-lived develop and main branches plus release and hotfix branches, built for scheduled, versioned releases. GitHub flow is short branches off main, merged through a PR and deployed. Trunk-based goes further: branches live a day or two, and unfinished work hides behind flags.
GitFlow: feature/* from develop, release/* frozen for testing, hotfix/* from main and merged back into both. Fine for a few big releases a year, heavy otherwise, and develop drifts away from what users run.
GitHub flow: every change is a reviewed pull request merged into main and released from there; it needs CI good enough that main is always shippable.
Trunk-based: the same with branches measured in hours to days and feature flags for anything not ready; the most merges and the smallest conflicts.
For a backend service: GitHub flow or trunk-based fits continuous deployment, where every merge to main is a deploy and a bad one is rolled back by redeploying the previous image. Release branches make sense only for a library that maintains an older major version.
How to choose: by release cadence and by what a broken release costs; most teams end up with trunk-based plus short-lived release branches.
A pre-commit hook should run fast, self-fixing checks on the staged files only, such as the formatter and a quick lint. CI stays the real gate, because hooks are not cloned with the repository and git commit --no-verify skips them by design.
Why a hook enforces nothing: .git/hooks/ is local and never pushed, so a fresh clone has no hooks until someone installs them; a check that must hold lives in CI and in branch protection.
Share the setup: commit a lefthook.yml (or a .pre-commit-config.yaml, or a .githooks/ folder plus git config core.hooksPath .githooks) and install it from the project's setup script.
What goes in: gofmt or goimports on the staged files and go vet; seconds, not minutes, or people start reaching for --no-verify.
Re-stage what the hook fixed: a formatter that rewrites files after git add leaves the commit unformatted; lefthook's stage_fixed: true adds them back.
What stays out: golangci-lint over the whole module, the race tests, and a check that no replace pointing at a local directory reached go.mod, which belong to CI.
Other hooks: commit-msg can check the Conventional Commits format, and pre-push can run the fast unit tests.
Rotate the secret first: revoke it and issue a new one, because it is already in the history and in every clone and fork, and a new commit only hides it from the latest version. Then, if the old value must disappear too, rewrite history with git filter-repo and force-push.
The first hour
Rotate, then investigate: revoke the database password, ship the replacement, and check the access logs for use since the push.
Why a new commit is not enough: every old commit still contains the value, bots scan public pushes within minutes, and in a private repository every person, CI runner and integration with read access has a copy.
Cleaning the history
Rewrite: in a fresh clone, git filter-repo --sensitive-data-removal --invert-paths --path .env drops the file from every commit (--replace-text replaces a string instead), and git push --force --mirror origin overwrites the server; branch protection has to allow it for the moment.
What it costs: every later hash changes, open pull requests break, and teammates must re-clone, since one push from an old clone brings the secret back.
On GitHub: pull request refs and cached views keep the old commits until GitHub Support removes them, and forks keep their own copies.
Next time
Secrets reach the process through its environment or mounted files, filled from a secret manager (Vault, AWS Secrets Manager, Kubernetes Secrets); a committed example file lists the names without values.
A secret scanner (gitleaks, trufflehog) in the pre-commit hook and in CI, plus the host's push protection, stops the next one before it lands.
A revert undoes the changes but not the history: main still contains PR #42's two commits, so the second merge brings in only what is new on the branch, the one fix. To get the feature back, revert the revert (git revert <revert-sha>) and then merge the fix.
What Git sees: the merge base of main and the branch is now the branch's old tip, already part of main. From there the branch changed one file, so the merge applies one change on top of a main where the revert removed everything else.
The output gives it away: Merge made by the 'ort' strategy. followed by cart/cart.go | 1 + for a feature of two commits.
The fix: revert the revert commit, which Git names Reapply "Merge PR #42: discounts", then merge the branch; on GitHub, the Revert button on the revert's pull request does the same.
If the fix touches code the feature added, the merge stops with a conflict (CONFLICT (modify/delete) when the file was new), which is at least loud.
**-m 1**: a merge commit has two parents, and -m 1 tells revert to keep main's side, almost always what you mean.
Revert is still the right tool: on a shared main it is the only undo that rewrites nothing. The trap is forgetting that the undo is now part of the history, so write the plan in the revert's pull request.
A semver tag such as v1.4.0 on a commit is the release: go get example.com/lib@v1.4.0 resolves it, and the module proxy and checksum database record that version's content for good. Moving the tag changes code everyone has already verified, so users get the old code or a checksum error.
The tag format: vMAJOR.MINOR.PATCH with the v; a module in a subdirectory takes a prefixed tag (api/v1.4.0); from v2 on the module path itself ends in /v2.
Untagged commits: go get example.com/lib@main records a pseudo-version such as v1.4.1-0.20261024101530-3f9c2e1a7b4d; fine for trying a fix, not something to release against.
Immutable once fetched: proxy.golang.org keeps the first content it saw for a version, sum.golang.org logs its hash, and every consumer's go.sum holds it too.
Moving a tag: the proxy keeps serving the old code, so your fix reaches nobody through it, while anyone fetching directly gets checksum mismatch and SECURITY ERROR.
Deleting a tag unpublishes nothing: the proxy still serves the version.
The fix is a new version: tag v1.4.1 with the correction and add retract v1.4.0 with a reason to its go.mod; go get then skips the bad version, and go list -m -u reports it as retracted.
Protect release tags: a tag ruleset on the repository, so nobody can force-push or delete v*.