DEV Community

Amayo Clinton
Amayo Clinton

Posted on

Git Mastery

If you think you know Git because you can add, commit, and push, try this: build a single hello.sh script through every core Git concept — commits, history rewriting, tagging, branching, conflicts, rebasing, bare repos — and document every command as you go.

That's exactly what this Git project puts you through. It's deceptively simple on paper ("just print Hello World") and brutally thorough underneath. Here's the walkthrough, built from my actual terminal output and commit history.

The Setup

git config --global user.name "devuser"
git config --global user.email "devuser@example.com"

mkdir -p work/hello && cd work/hello
git init
Enter fullscreen mode Exit fullscreen mode

Nothing fancy — just a repo and a script:

echo "Hello, World" > hello.sh
git add hello.sh
git commit -m "feat: add initial hello world script"
Enter fullscreen mode Exit fullscreen mode

That first commit (d03dea2) is the root of everything that follows.

Building History On Purpose

The project pushes you to think about commits as units of meaning, not just checkpoints. When I added a shebang and comments to hello.sh, I didn't just commit the whole diff — I split it:

git add -p hello.sh
git commit -m "docs: add default value comment"
# commit: 7fd0362

git add hello.sh
git commit -m "refactor: use variable with default fallback"
# commit: a53609a
Enter fullscreen mode Exit fullscreen mode

git add -p is the star here — it lets you stage parts of a file's changes, so a single edit to hello.sh becomes two logically separate commits instead of one messy one.

Reading history the useful way

Plain git log is fine, but for real work you want condensed, filtered, custom views:

git log --oneline          # condensed
git log -2                 # last 2 entries
git log --since="5 minutes ago"   # time-scoped
Enter fullscreen mode Exit fullscreen mode

And a genuinely useful personalized format:

git log --pretty=format:"* %h %ad | %s%d [%an]" --date=short
Enter fullscreen mode Exit fullscreen mode
* d0dd395 2026-04-23 | docs: restore README and move documentation to README_REPORT.md (HEAD -> main, origin/main, origin/HEAD) [devuser]
* 7d6d6db 2026-04-22 | docs: add complete project documentation with hashes [devuser]
* 0a72da5 2026-04-22 | docs: update README for shared repo [devuser]
* 237cb11 2026-04-22 | docs: add comment to Makefile (origin/greet) [devuser]
* a53609a 2026-04-21 | refactor: use variable with default fallback (tag: v1) [devuser]
* 7fd0362 2026-04-21 | docs: add default value comment (tag: v1-beta) [devuser]
* d03dea2 2026-04-21 | feat: add initial hello world script [devuser]
Enter fullscreen mode Exit fullscreen mode

One line, all the context you need: hash, date, message, refs, author.

Time Travel: Checkout, Tags, and Not Losing Your Mind

Restoring old snapshots is where git checkout <hash> earns its keep:

git checkout d03dea2
cat hello.sh
# echo "Hello, World"

git checkout 7fd0362
cat hello.sh
# #!/bin/bash
# # Default is "World"
# echo "Hello, $1"

git checkout master   # back to the tip, no hash memorized
Enter fullscreen mode Exit fullscreen mode

Tags turn "that one commit" into a name you'll actually remember:

git tag v1                  # tags a53609a — current version
git tag v1-beta HEAD~1       # tags 7fd0362 — one commit back, no hash needed

git tag
# v1
# v1-beta
Enter fullscreen mode Exit fullscreen mode

Jumping between v1 and v1-beta is just git checkout <tagname> — tags behave like read-only bookmarks into history.

Changing Your Mind (Repeatedly)

This section of the project is really about the difference between unstaged, staged, and committed — and the different tool for undoing each:

git restore hello.sh              # discard unstaged edits
git restore --staged hello.sh     # unstage (keep the edits)
git revert HEAD --no-edit         # undo a commit by creating a new one
Enter fullscreen mode Exit fullscreen mode

Then it gets fun: tag a commit, reset past it, and prove the "deleted" commit still exists until Git actually garbage-collects it.

git tag oops              # tags 1cff52e
git reset --hard v1       # HEAD now back at a53609a

git log --all --oneline
# 1cff52e is STILL visible — reset doesn't delete objects, it just moves refs
Enter fullscreen mode Exit fullscreen mode

To actually make it disappear:

git reflog expire --expire=now --all
git gc --prune=now
Enter fullscreen mode Exit fullscreen mode

This is the moment the project really teaches you something most tutorials skip: git reset doesn't delete data, it just moves pointers. Nothing is gone until the reflog expires and garbage collection runs.

One more subtlety — fixing a mistake without creating a new commit:

# forgot the author email — fix the file, then fold it into the last commit
git add hello.sh
git commit --amend --no-edit
Enter fullscreen mode Exit fullscreen mode

--amend rewrites the last commit in place instead of adding a new one. Great for local fixes, dangerous on anything already pushed and shared.

Reorganizing the Project

mkdir lib
git mv hello.sh lib/hello.sh
git commit -m "chore: move hello.sh into lib/ directory"
Enter fullscreen mode Exit fullscreen mode

git mv does the mv and the git add in one step — Git tracks it as a rename, not a delete+add, as long as the content is similar enough.

Going Under the Hood: Blobs, Trees, Commits

This is the part that actually explains why Git is fast and safe: everything is content-addressed.

git cat-file -t 0a72da543f
# commit

git cat-file -p 0a72da543f
# tree   74c47aeda48e094725bd8ae1d62adbbc88997e1b
# parent 980a0b51b84a5305bffdfbacd1f266bd8e9fa3a3
# author devuser <devuser@example.com>
# docs: update README for shared repo
Enter fullscreen mode Exit fullscreen mode

A commit object is just a pointer to a tree (a snapshot of the directory) plus a parent (the previous commit) plus metadata. Walk the tree:

git ls-tree HEAD
# 100644 blob 12d4ffb...  Makefile
# 100644 blob 0791b4b...  README.md
# 040000 tree b47b7e2...  lib

git ls-tree HEAD lib/
# 100644 blob 2d14ca5...  lib/greeter.sh
# 100644 blob c282d10...  lib/hello.sh

git cat-file -p c282d107dea0aa9909c27c291de8a83182847034
# (raw content of lib/hello.sh)
Enter fullscreen mode Exit fullscreen mode

Every version of every file ever committed lives in .git/objects/ as a blob, addressed by the SHA-1 hash of its content. HEAD just points at a branch ref, which points at a commit, which points at a tree, which points at blobs. That's the whole model.

Branching Without Fear

git switch -c greet
Enter fullscreen mode Exit fullscreen mode

I built out a Greeter function on its own branch, isolated from master:

git add lib/greeter.sh
git commit -m "feat: add Greeter function in lib/greeter.sh"
# 0f958cb

git add lib/hello.sh
git commit -m "refactor: use Greeter function in hello.sh"

git add Makefile
git commit -m "docs: add comment to Makefile"
# 237cb11
Enter fullscreen mode Exit fullscreen mode

And diffed the two branches directly, file by file:

git diff master greet -- Makefile
git diff master greet -- lib/hello.sh
git diff master greet -- lib/greeter.sh
Enter fullscreen mode Exit fullscreen mode

The resulting graph, right before merging things back together:

* 237cb11 (origin/greet) docs: add comment to Makefile
* 0f958cb feat: add Greeter function in lib/greeter.sh
* ef6052e feat: add interactive name prompt in hello.sh
* 7ee3abd docs: add README
* 45087c6 chore: add Makefile with run target
* 90ca44f chore: move hello.sh into lib/ directory
* 1a2643c docs: add author comment
| * 1cff52e (tag: oops) chore unwanted change to be removed
| * 7fd9565 Revert "chore: add unwanted committed change"
| * fb95a77 chore: add unwanted committed change
|/
* a53609a (tag: v1) refactor: use variable with default fallback
* 7fd0362 (tag: v1-beta) docs: add default value comment
* 9562a00 feat: add shebang and accept name argument
* d03dea2 feat: add initial hello world script
Enter fullscreen mode Exit fullscreen mode

You can actually see the oops line branching off and rejoining — a nice visual proof that reset didn't destroy anything, it just left that commit orphaned until GC swept it.

The Main Event: Conflicts and Rebasing

This is where the project stops being polite. First, a clean merge:

git switch greet
git merge master
Enter fullscreen mode Exit fullscreen mode

Then I changed hello.sh on master itself:

git switch master
git add lib/hello.sh
git commit -m "feat: add interactive name prompt in hello.sh"
# ef6052e
Enter fullscreen mode Exit fullscreen mode

Now merge master into greet again — except this time both branches touched the same lines:

git switch greet
git merge master
# CONFLICT (content): Merge conflict in lib/hello.sh
Enter fullscreen mode Exit fullscreen mode

Resolving it by hand:

nano lib/hello.sh   # remove conflict markers, keep master's version
git add lib/hello.sh
git commit -m "fix: resolve merge conflict accepting master version"
Enter fullscreen mode Exit fullscreen mode

Then: undo the merge and rebase instead

The project wants you to compare merging vs. rebasing on the same divergent history, so I rewound using the reflog and rebased instead:

git reset --hard ee0c23f   # pre-merge state, found via git reflog
git switch greet
git rebase master
# conflict again — resolve, then:
git add lib/hello.sh
git rebase --continue
Enter fullscreen mode Exit fullscreen mode

Finally, merging greet back into master was a clean fast-forward — no merge commit at all:

git switch master
git merge greet
# Fast-forward
Enter fullscreen mode Exit fullscreen mode

Fast-forward vs. merge vs. rebase, the short version:

  • Fast-forward: master had no new commits since greet branched off, so Git just slides the master pointer forward to match greet. No new commit, history stays linear.
  • Merge: both branches have unique commits, so Git creates a new commit with two parents, preserving exactly what happened and when. Honest history, but the graph gets messy.
  • Rebase: replays your branch's commits one by one on top of the new base, producing a straight line as if you'd branched off now instead of earlier. Clean history, but it rewrites commit hashes — never do this on a branch someone else is also working from.

Local, Remote, and Bare Repositories

Cloning is just copying the whole object database plus setting up a remote automatically:

git clone hello cloned_hello
cd cloned_hello

git remote -v
# origin  /home/devuser/Desktop/work/hello (fetch)
# origin  /home/devuser/Desktop/work/hello (push)

git branch -a
# * master
#   remotes/origin/HEAD -> origin/master
#   remotes/origin/greet
#   remotes/origin/master
Enter fullscreen mode Exit fullscreen mode

Fetch pulls down remote history without touching your working files; merge (or pull, which is both at once) actually integrates it:

git fetch
git merge origin/master
# equivalent to:
git pull
Enter fullscreen mode Exit fullscreen mode

Tracking a remote-only branch locally:

git branch --track greet origin/greet
Enter fullscreen mode Exit fullscreen mode

And pushing to an actual server:

git remote add origin https://git.example.com/devuser/hello.git
git push origin master
git push origin greet
Enter fullscreen mode Exit fullscreen mode

Bare repositories

A bare repo has no working directory — just the raw .git internals sitting at the top level. You never edit files in it directly; it exists purely as a shared exchange point that other repos push to and pull from. This is literally what GitHub, GitLab, and Gitea run on the server side.

git clone --bare hello hello.git
ls hello.git
# branches config description HEAD hooks info objects packed-refs refs

git remote add shared ../hello.git
git add README.md
git commit -m "docs: update README for shared repo"
git push shared master
Enter fullscreen mode Exit fullscreen mode

And pulling those shared changes into the other clone:

cd ~/Desktop/work/cloned_hello
git remote add shared ../hello.git
git pull shared master

git log --oneline
# 0a72da5 docs: update README for shared repo   <- now present here too
Enter fullscreen mode Exit fullscreen mode

What Actually Stuck

The exercise that reframed the most for me was the oops tag / reset / reflog sequence. Before this project, git reset --hard felt like deleting things. Watching a "deleted" commit still show up in git log --all — and only really disappearing after reflog expire + gc --prune=now — made it click that Git almost never destroys data on its own. It just stops pointing at it.

Second was rebase vs. merge, done back to back on the identical conflict. Reading about the difference is one thing; hitting the same conflict twice, once resolved with a merge commit and once resolved by replaying commits, made the tradeoff concrete instead of theoretical.

If you're doing this project (or something like it): don't skip the .git/objects/ exploration section. It's the one part that explains why everything else in Git works the way it does.

Closing Thoughts
Git has a reputation for being something you memorize commands for rather than actually understand — git add ., git commit -m "fix", git push, repeat. This project forced the opposite approach: build small, break things on purpose, and dig into why each command does what it does before moving to the next one.
If there's one takeaway to carry forward, it's this — Git rarely deletes anything without you explicitly telling it to (and meaning it). Once that clicks, branching, resetting, and rebasing stop feeling dangerous and start feeling like tools you can actually reach for with confidence.
If you're working through something similar, don't rush the sections that feel "boring" (looking at you, blobs and trees) — that's usually where the real understanding is hiding.
Thanks for reading — feel free to drop questions or your own Git war stories in the comments. 🐙

Top comments (0)