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.
AndroidJunior
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, Gradle files and the keystore
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 ./gradlew assembleDebug testDebugUnitTest, 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 a gradle.lockfile or a generated source 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.
An Android project ignores .gradle/, build/, local.properties (the SDK path, often API keys too), *.iml and most of .idea/, .cxx/, and release keystores (*.jks, *.keystore) with the keystore.properties that holds their passwords.
It commits gradlew, gradlew.bat and gradle/wrapper/ (jar and properties, or the build cannot start), libs.versions.toml, a secret-free gradle.properties, and usually google-services.json, which identifies a Firebase project but is not a secret.
**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.
Resolve it by intent, line by line: keep both sides' new entries, and where both changed one version keep the newer one unless the other side pinned it back on purpose. Then build, and regenerate any lockfile or verification metadata the build uses instead of merging those by hand.
Here a hand merge is right: unlike a lockfile, every line of the catalog is a decision someone made, so the commit messages tell you why a version moved.
Never take one side for the whole block: main moved AGP and Kotlin, your branch added Room, and the result needs all three.
Versions travel in groups: Kotlin with KSP and the Compose compiler plugin, a BOM with the libraries that take their version from it; check that the merged set works together.
Fewer conflicts next time: keep each block sorted with one entry per line, so two additions rarely touch the same lines.
Regenerate, do not merge: with dependency locking, rerun the lock task (./gradlew dependencies --write-locks or the project's own lock-all task); with verification metadata, ./gradlew --write-verification-metadata sha256 help.
Then prove it: ./gradlew assembleDebug testDebugUnitTest, and ./gradlew :app:dependencies if a transitive version jumped unexpectedly.
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: app/src/main/kotlin/ApiClient.kt 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 Gradle wrapper and the version catalog come with each commit, so ./gradlew builds it with its own Gradle and dependency versions; only the JDK is yours, and a commit that needs a different one is a skip.
**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 or semantic-release turn the history into a changelog and a tag. For an app versionName is still a product decision, but the generated notes become the Play Console 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.
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, screenshots or a screen recording for UI changes, in light and dark theme, 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.
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**: ./gradlew lint testDebugUnitTest plus the instrumented tests of the features you touched; 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 BuildConfig field per build type 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.
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: ktlint (or Spotless) on the staged Kotlin files, and detekt only if it stays fast; 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: Gradle tasks that configure the whole project, Android Lint and the tests, 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 Play service-account key in Google Cloud, 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 app/play-service-account.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
Secrets live in the CI secret store and in ~/.gradle/gradle.properties or environment variables, never in the project's gradle.properties; 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 app/src/main/kotlin/Cart.kt | 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 Gradle build, convention plugins and one version catalog for every module.
What it costs
CI must build only what changed, or every pull request runs everything: the Gradle build cache plus a changed-modules filter such as Dropbox's Affected Module Detector.
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.