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
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"
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
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
And a genuinely useful personalized format:
git log --pretty=format:"* %h %ad | %s%d [%an]" --date=short
* 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]
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
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
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
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
To actually make it disappear:
git reflog expire --expire=now --all
git gc --prune=now
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
--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"
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
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)
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
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
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
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
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
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
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
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"
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
Finally, merging greet back into master was a clean fast-forward — no merge commit at all:
git switch master
git merge greet
# Fast-forward
Fast-forward vs. merge vs. rebase, the short version:
-
Fast-forward:
masterhad no new commits sincegreetbranched off, so Git just slides themasterpointer forward to matchgreet. 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
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
Tracking a remote-only branch locally:
git branch --track greet origin/greet
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
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
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
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)