home / writing / git-internals-and-workflow

Git Internals and Workflow

Git is easier to use once you know it is a content-addressed store of snapshots. This guide covers the object model, everyday branching and history editing, remotes and the pull request workflow.

Objects and hashes

Inside the hidden .git directory, Git is a key-value database. The key is a hash of the content (SHA-1 traditionally, with SHA-256 available in newer setups), so identical content always gets the same ID and any change produces a new one. There are four object types:

ObjectHolds
blobThe contents of one file, with no name or permissions
treeA directory listing: names, modes and the hashes of blobs or sub-trees
commitA pointer to a tree, the parent commit(s), author, committer, timestamp and message
tagAn annotated, named pointer to another object

You can poke at them directly:

echo "hello" | git hash-object -w --stdin   # store a blob, print its hash
git cat-file -t <hash>                       # type of an object
git cat-file -p <hash>                       # pretty-print its content
git ls-tree HEAD                             # tree of the current commit

Because a commit stores its parent's hash, history is a chain that cannot be altered without changing every later ID. Unchanged files are simply reused by reference between commits, so snapshots stay cheap.

Commits, branches and HEAD

Changes move through three areas: the working directory, the staging area (index) and the repository. The basic cycle:

git config --global user.name "Your Name"
git config --global user.email "user@example.com"
git status
git add src/app.py        # stage one file (git add -p stages hunks)
git commit -m "Add request validation"
git commit --amend        # rewrite the latest commit (only if not yet shared)
git diff                  # unstaged changes; --staged for staged ones
git log --oneline --graph --decorate

List patterns you never want tracked in .gitignore. It does not affect files that are already tracked; remove those with git rm --cached.

A branch is nothing more than a movable file containing a commit hash, which is why branching is instant. HEAD is the pointer to where you are, normally the current branch. Checking out a raw commit gives a detached HEAD: commits made there are not on any branch and can be lost unless you create one with git switch -c rescue.

git branch                    # list
git switch -c feature/login   # create and switch
git switch main
git branch -d feature/login   # delete if merged (-D forces)
git switch --detach <hash>    # inspect an old commit

Merge versus rebase

Both bring one line of work into another, in different ways.

  • Merge joins histories. If the target has not moved, Git just fast-forwards the pointer. Otherwise it creates a merge commit with two parents. History stays truthful, at the price of more branching noise.
  • Rebase replays your commits on top of another base, creating new commits with new hashes. The result is a straight, readable line, but it rewrites history.
git switch main
git merge feature/login          # merge
git switch feature/login
git rebase main                  # replay your commits onto main
git rebase -i HEAD~4             # squash, reorder, reword recent commits

When two sides edit the same lines, Git stops and marks conflicts with <<<<<<<, ======= and >>>>>>>. Edit the file to the intended result, git add it, then git merge --continue or git rebase --continue. Use --abort to back out.

Golden rule: rebase only commits that nobody else has pulled. Rewriting shared history forces collaborators to untangle diverged copies. If you must push a rebased branch of your own, use git push --force-with-lease rather than a bare --force.

Stash, restore and reset

Stash shelves uncommitted work so you can switch context:

git stash push -m "wip: parser"   # add -u to include untracked files
git stash list
git stash pop                     # re-apply and drop (apply keeps it)

Restore handles files, leaving history alone:

git restore file.txt                  # discard working-tree edits (irreversible)
git restore --staged file.txt         # unstage, keep the edits
git restore --source=HEAD~2 file.txt  # bring back an older version

Reset moves the current branch pointer, with three strengths:

ModeBranch pointerIndexWorking files
--softmovedkeptkept
--mixed (default)movedresetkept
--hardmovedresetoverwritten

To undo a commit that is already shared, prefer git revert <hash>, which adds a new commit that cancels the old one. If you reset too far, git reflog lists recent positions of HEAD so you can usually recover.

Remotes

A remote is a named URL for another copy of the repository; the conventional name for the original is origin. After git clone, Git keeps remote-tracking branches such as origin/main, which record where the remote was the last time you talked to it.

  • git fetch downloads new objects and updates tracking branches without touching your work.
  • git pull is fetch plus an integration step: merge by default, or rebase with --rebase.
  • git push -u origin feature/login publishes a branch and sets its upstream so later push and pull need no arguments.
  • git push origin --delete feature/login removes a remote branch; git fetch --prune cleans stale tracking refs.
  • git remote -v shows configured remotes, and git remote add upstream <url> adds another, handy for forks.

Fetching first and inspecting with git log main..origin/main is a safe habit before integrating.

Pull requests

A pull request (PR) is a hosting-platform feature, not a Git one. It proposes merging one branch into another and wraps the diff in discussion, review, status checks and approval. To open one, push your branch, choose the base and compare branches, describe the change and what you tested, and request reviewers. Collaborators need appropriate repository access, or you contribute from a fork.

Repositories usually protect the main branch: require a PR, a minimum number of approvals, passing CI checks, up-to-date branches and no force pushes. The merge button typically offers three strategies: a merge commit, a squash into one commit, or a rebase and fast-forward. Squash keeps the main history tidy; merge commits preserve detail.

Feature-branch workflow

  1. Update: git switch main && git pull.
  2. Branch with a descriptive name: git switch -c feature/add-search.
  3. Commit small, focused changes with messages that explain why.
  4. Periodically sync with main via git rebase main (or merge main into the branch) to resolve conflicts early.
  5. Push and open a PR; automated checks run, reviewers comment, you push fixes.
  6. Merge once approved and green, then delete the branch locally and remotely.

Keep branches short-lived, never commit directly to the protected branch, never commit secrets (removing them later does not erase them from history), and use annotated tags such as git tag -a v1.2.0 to mark releases.