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.
FlutterJunior
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, pubspec.lock and generated files
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 flutter analyze and flutter 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 pubspec.lock or a generated .g.dart file: 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 Flutter project ignores .dart_tool/, build/, ios/Pods/, android/local.properties, android/key.properties, keystores (*.jks) and .fvm/; the flutter create template lists most of them.
It commits pubspec.lock (for an app), ios/Podfile.lock, .metadata, .fvmrc and the Gradle wrapper properties, and generated .g.dart files only if the team chose to commit them.
**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.
Pin the exact version in a file in the repository: FVM's .fvmrc, which fvm use 3.38.0 writes, or an exact flutter: version under environment in pubspec.yaml. Laptops and CI read the same file, and the SDK itself never goes into git.
**Commit .fvmrc**, which names the version; after a clone, fvm install fetches that SDK.
**Ignore .fvm/**: it holds a flutter_sdk symlink into this machine's cache, and FVM adds it to .gitignore itself.
CI reads the same file: subosito/flutter-action accepts flutter-version-file: .fvmrc, or a pubspec.yaml with an exact version; a version typed into the workflow drifts.
A pubspec pin enforces itself: with flutter: 3.37.0 and SDK 3.38.0 installed, pub get stops with "Because shop requires Flutter SDK version 3.37.0, version solving failed."
Resolve pubspec.yaml by hand, keeping both sides' dependencies, then take main's pubspec.lock and run flutter pub get: pub keeps every version main locked and solves only your additions. Check the lock file's diff against main before committing.
Why not hand-merge the lock: it is solver output with content hashes, and a hand-made mix can describe a combination pub never solved.
Which side is which: during git merge main, --theirs is main; during git rebase main the sides swap, so the same git checkout --theirs pubspec.lock would take your old lock.
Both sides bumped one package: keep the higher constraint unless one side lowered it on purpose.
Check what moved: git diff main -- pubspec.lock should list only your packages and their transitive dependencies.
**ios/Podfile.lock too**: take main's, run pod install after pub get, and commit the result.
CI as the safety net: flutter pub get --enforce-lockfile fails when the committed lock does not match pubspec.yaml.
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: lib/data/api_client.dart 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**: flutter test runs pub get itself when pubspec.yaml changed, but a commit whose dependencies no longer resolve should exit 125.
**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 dart) keeps a release pull request open that updates CHANGELOG.md and version: in pubspec.yaml, and merging it tags the release; for an app the version is still a product decision, but the notes feed the store release notes.
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.
Keep it small and single-purpose, explain why it exists and how you tested it, and review your own diff before anyone else does. When you disagree, answer with reasons instead of silence or silent compliance; if two rounds do not settle it, talk it through and record the decision.
Cheap to review: a few hundred changed lines with the refactor split from the feature that needs it, a description with what, why and how you tested it (device, platform, flavor), screenshots of UI changes on iOS and Android, and a look at your own diff first for the stray print or unrelated pubspec.yaml change.
Long work as a stack: a chain of small pull requests, each based on the previous one, beats one 2,000-line PR; unfinished parts stay behind a flag.
Every comment gets an answer: "done", or a reason. Changing code you believe is wrong just to get the approval hides the disagreement until it turns into a bug.
Disagree with facts: separate taste from correctness, bring a benchmark, the docs or a failing case, and offer a follow-up ticket for a fair point outside this PR.
Deadlock: after two rounds, a short call or the tech lead, with the outcome written in the thread.
Bring main into the branch now in one deliberate merge, resolve and test, and land the result as several reviewable pull requests if you can. Next time, integrate daily and merge small slices of the feature into main behind a flag instead of keeping them on a branch.
Getting out
**Merge main in rather than rebase: a rebase replays each of 40 commits and can stop at every one; one merge resolves each conflict once.
Resolve with the people who changed the other side: ten minutes with them beats guessing why the code moved.
Test the result fully**: flutter analyze, flutter test and a run on both iOS and Android; the conflicts that compile cleanly are the dangerous ones.
Split before review: send refactors and shared pieces to main in their own pull requests first, so what is left is smaller.
Not again
Integrate daily: git fetch and git merge origin/main keep each conflict small and fresh.
Merge unfinished work behind a flag: a compile-time const bool.fromEnvironment('NEW_CHECKOUT') or a Remote Config value; the code lives on main while the feature stays off.
Branch by abstraction for big rewrites: an interface in front of the old code, the new implementation beside it, the switch at the end.
Stacked pull requests: a chain of small PRs, each on top of the previous one, gives reviewers a few hundred lines at a time.
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 payment API key in the provider's dashboard, 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 config/prod.json 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
Build-time values come from CI secrets or a gitignored file passed to --dart-define-from-file, and real secrets stay on your server, because anything compiled into the app can be extracted from it; 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 lib/cart/cart.dart | 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.
Choose a monorepo when the code changes together: apps sharing a design system, an API client and models, where one pull request should update a library and every caller at once. Separate repositories suit parts with their own owners and release cycles, at the cost of versioning between them.
What a monorepo gives
Atomic changes: a breaking change in a shared package and its fix in every app land in one reviewed pull request; no matrix of "app 2 still uses core 1.4".
One toolchain, one set of rules: one Flutter version, one analysis_options.yaml, and a pub workspace with one pubspec.lock for every package.
What it costs
CI must build only what changed, or every pull request runs everything: Melos can select the packages changed since origin/main together with everything that depends on them.
Ownership: CODEOWNERS per folder, so the payments team reviews payments code.
Scale and access: the clone keeps growing (git clone --filter=blob:none and sparse checkout help), and access is per repository, so a contractor sees everything.
Independent releases get harder: each package needs its own tags (core-v1.4.0) and publishing tooling.
What separate repositories cost
Every shared change is a publish, a version bump and a pull request in each consumer, and consumers drift versions apart.
They win for open-source packages, for code with a different access level, and for teams that really ship on their own schedule.