Skip to content

Your First Git Repository: A Complete Hands-On Tutorial

Lesson 7 of 12Beginner12 min readGit Fundamentals · Getting StartedVerified: Git 2.43.0 on Ubuntu 24.04; every command and output in this lesson was run

This lesson builds a real Git repository from an empty directory, one command at a time. By the end you will have three commits, a readable history, and — more importantly — an accurate account of what each command did to the working tree, the index and the object database.

Every command and every output shown here was actually run. Your object IDs will differ from the ones printed below, because they are derived partly from your name, email and the current time. Everything else should match.

A tiny project called hello-git with two files and three commits. The point is not the project — it is watching state move through Git’s three areas and learning to read what Git tells you.

Terminal window
mkdir hello-git
cd hello-git

Nothing Git-related has happened yet. This is an ordinary empty folder.

Terminal window
git init

What it doesCreates a .git subdirectory containing an empty object database, an empty index, a config file, and a HEAD pointing at your initial branch.

Why we run itThis is the single step that turns an ordinary folder into a Git repository. Everything after it depends on .git existing.

Expected resultOne line confirming initialisation and the absolute path of the new .git directory.

Initialized empty Git repository in /home/you/hello-git/.git/

If you have not set init.defaultBranch, Git also prints a hint about the initial branch name being master. That hint is harmless, but setting the config removes it and matches modern convention:

Terminal window
git config --global init.defaultBranch main

Your project directory now looks like this:

  • Directoryhello-git/
    • Directory.git/ created by git init — this is the repository
      • HEAD
      • config
      • Directoryobjects/
      • Directoryrefs/
Terminal window
git status

What it doesCompares the working tree, the index and HEAD, then reports the differences in plain language.

Why we run itIt is the cheapest and most informative command in Git. Run it constantly — especially before add and before commit.

Expected resultYour branch name, a note that there are no commits yet, and confirmation there is nothing to commit.

On branch main
No commits yet
nothing to commit (create/copy files and use "git add" to track)

Read that carefully — it is telling you three separate things:

  • On branch main — HEAD points at a branch called main.
  • No commits yet — the branch does not point at a commit, because none exists. The branch ref will not exist as a file until the first commit is made.
  • nothing to commit — the working tree is empty, so there is nothing to record.
Terminal window
printf '# Hello Git\n\nA small project for learning Git from the ground up.\n' > README.md

You can equally create the file in a text editor. The content does not matter.

Terminal window
git status
On branch main
No commits yet
Untracked files:
(use "git add <file>..." to include in what will be committed)
README.md
nothing added to commit but untracked files present (use "git add" to track)

README.md is untracked: it exists in your working tree, but Git has no record of it in the index or in any commit. Git is reporting it, not managing it.

Terminal window
git add README.md

What it doesReads the current content of README.md, stores it in the object database as a blob, and points that file's index entry at the new blob.

Why we run itCommitting records the index, not the working tree. Staging is how you choose exactly what the next commit contains.

Expected resultNo output. Silence means success — a Unix convention Git follows throughout.

Confirm the effect:

Terminal window
git status
On branch main
No commits yet
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
new file: README.md

The file moved from Untracked files to Changes to be committed. It is now staged: its content sits in the index, ready to be committed.

Terminal window
git commit -m "Add project README"

What it doesBuilds tree objects from the index, writes a commit object recording that tree plus your identity, timestamp and message, and points the current branch at the new commit.

Why we run itThis is the operation that writes a permanent snapshot into history. Until you commit, nothing is recorded.

Expected resultA summary line with the branch name, the abbreviated commit ID, and your message, followed by a count of files and lines changed.

[main (root-commit) 695394d] Add project README
1 file changed, 3 insertions(+)
create mode 100644 README.md

That output is dense. Every part means something:

FragmentMeaning
[mainThe branch this commit was made on
(root-commit)This commit has no parent — it is the first in the repository
695394d]The abbreviated object ID of the new commit
Add project READMEYour commit message
1 file changed, 3 insertions(+)The change relative to the (empty) parent state
create mode 100644 README.mdA new file was added, with regular non-executable permissions

You will only ever see (root-commit) once per repository.

Terminal window
git log

What it doesWalks the commit graph backwards from HEAD, printing each commit's full ID, author, date and message.

Why we run itIt is how you read what has happened in a repository. Every commit's metadata is stored locally, so this needs no network.

Expected resultOne entry per commit, newest first. With one commit, one entry.

commit 695394d1a4e0b6f0c9b0f8e0d1c2b3a4e5f60718 (HEAD -> main)
Author: Ada Lovelace <ada@example.com>
Date: Sat Aug 22 13:20:41 2026 +0000
Add project README

(HEAD -> main) tells you that HEAD points at the branch main, and main points at this commit. That is the reference chain from Lesson 3 displayed in one line.

For a compact view:

Terminal window
git log --oneline
695394d Add project README
Terminal window
git status
On branch main
nothing to commit, working tree clean

“Working tree clean” means the working tree, the index and the HEAD commit all agree. All three copies of your project are identical.

After a commit, all three areas agree

Three stacked boxes labelled working tree, index and repository. All three contain the same file, README.md, with the same content hash, showing that after committing there are no differences between them. A label reads: git status reports nothing to commit, working tree clean.

Working treeIndexRepository (HEAD)README.md · 42e11dbREADME.md · 42e11dbREADME.md · 42e11dbidentical“working treeclean”

Step 10 — Add a second file and commit it

Section titled “Step 10 — Add a second file and commit it”

Create another file:

Terminal window
printf 'Sun\nMon\nTue\n' > days.txt

Stage and commit it in one go:

Terminal window
git add days.txt
git commit -m "Add list of days"
[main c4dfb0b] Add list of days
1 file changed, 3 insertions(+)
create mode 100644 days.txt

Note what is missing this time: no (root-commit). This commit has a parent — the first commit — and history is now a chain of two.

Step 11 — Modify a tracked file and inspect the change

Section titled “Step 11 — Modify a tracked file and inspect the change”

Replace the README’s content with a longer version:

Terminal window
printf '# Hello Git\n\nA small project for learning Git from the ground up.\n\n## What this is\n\nA scratch repository used to practise the Git basics.\n' > README.md

Now check the status:

Terminal window
git status
On branch main
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: README.md
no changes added to commit (use "git add" and/or "git commit -a")

The file is modified, not untracked — Git already knows about it and can compare versions.

Terminal window
git diff

What it doesShows the difference between your working tree and the index — that is, changes you have made but not yet staged.

Why we run itIt answers “what exactly did I change?” before you commit. Reviewing the diff is the most reliable way to avoid committing something unintended.

Expected resultA unified diff. Lines prefixed with + were added, lines prefixed with - were removed, and unprefixed lines are unchanged context.

diff --git a/README.md b/README.md
index 42e11db..4d4fdc0 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,7 @@
# Hello Git
A small project for learning Git from the ground up.
+
+## What this is
+
+A scratch repository used to practise the Git basics.

Reading the header pays off:

  • a/README.md is the “before” version (the index); b/README.md is the “after” (the working tree).
  • index 42e11db..4d4fdc0 gives the abbreviated blob IDs of the two versions. Those are real objects — the first is stored in the repository, the second exists only in your working tree so far.
  • @@ -1,3 +1,7 @@ is the hunk header: 3 lines starting at line 1 in the old version correspond to 7 lines starting at line 1 in the new.

For a summary rather than the full text:

Terminal window
git diff --stat
README.md | 4 ++++
1 file changed, 4 insertions(+)

Before staging, run the experiment promised in Step 6. Stage the file, then check status:

Terminal window
git add README.md
git status
On branch main
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: README.md

Now the two diff commands answer different questions:

Terminal window
git diff --staged

What it doesShows the difference between the index and HEAD — exactly what the next commit will contain.

Why we run itBare git diff shows only unstaged changes, so after staging it prints nothing. This is the command that reviews a commit before you make it.

Expected resultThe same diff you saw before staging, because the change moved from the working tree into the index.

Running plain git diff now produces no output, because the working tree and index agree. That is correct behaviour, and it surprises nearly everyone the first time.

Commit:

Terminal window
git commit -m "Describe the project in the README"
[main 2b1e0f8] Describe the project in the README
1 file changed, 4 insertions(+)

No create mode line this time — no new file was added, an existing one was modified.

Terminal window
git log --oneline --graph
* 2b1e0f8 Describe the project in the README
* c4dfb0b Add list of days
* 695394d Add project README

Three commits, newest first. --graph draws the branch structure; with a single linear branch it is just a column of asterisks, but it becomes essential once branches diverge.

To see what changed in a single commit:

Terminal window
git show --stat HEAD
2b1e0f8 Describe the project in the README
README.md | 4 ++++
1 file changed, 4 insertions(+)

Drop --stat to see the full diff for that commit.

You built a three-commit history, but the durable part is the mechanism you watched:

  1. git init created a repository. An empty object database, an empty index, and a HEAD pointing at main.

  2. Creating a file changed only the working tree. Git noticed and reported it as untracked.

  3. git add wrote a blob and updated the index. The content — not the name — was recorded.

  4. git commit built trees from the index and wrote a commit object, then moved main forward to point at it.

  5. git status reported the differences between the three areas at every step, and every message it printed maps to one of those differences.

  6. git diff and git diff --staged compared different pairs of those areas — which is why they showed different things at different moments.

Each of the three commits is now a permanent, content-addressed snapshot in .git/objects, connected to its parent, reachable from main, and reachable from HEAD through main.

Real projects contain files that should never be committed: build output, dependency directories, editor settings, and — critically — anything holding a secret. A .gitignore file tells Git to stop reporting them.

Create one at the top of the repository:

# Build output
build/
# Local environment configuration
.env

Watch the effect. Before the .gitignore exists, git status --short reports both:

?? .env
?? build/

Afterwards, they disappear from the listing entirely, and the only untracked file is .gitignore itself:

?? .gitignore

To confirm Git is deliberately ignoring them rather than failing to see them, ask explicitly:

Terminal window
git status --short --ignored
?? .gitignore
!! .env
!! build/

The !! prefix marks ignored paths.

Commit the .gitignore itself — it is project configuration that every contributor should share:

Terminal window
git add .gitignore
git commit -m "Ignore build output and local environment file"

Two situations come up constantly in the first week. Both have safe answers.

You staged something you did not mean to. Unstage it without touching the file on disk:

Terminal window
git restore --staged days.txt

What it doesCopies the file's content from HEAD back into the index, leaving the working tree untouched.

Why we run itIt reverses a git add. Your edits are preserved — only the staging decision is undone.

Expected resultNo output. git status --short then shows the file as modified but no longer staged: the status code moves from the first column to the second.

You want to throw away an uncommitted edit. This one destroys work, so read the warning:

Terminal window
git restore days.txt

Running git commit without staging. If nothing is staged, Git tells you so and makes no commit. Stage first, or use git commit -a to automatically stage tracked, modified files. -a never stages untracked files.

Using git add . reflexively. It stages everything under the current directory, including files you did not mean to include — build output, local configuration, or credentials. Run git status first. A .gitignore file is the durable fix.

Committing without reading the diff. git diff --staged takes two seconds and catches debugging statements, stray edits and accidental secrets before they enter history.

Expecting git diff to show everything. Bare git diff shows unstaged changes only. Use git diff HEAD for everything since the last commit.

Vague commit messages. “update”, “fix”, “changes” tell a future reader nothing. The message is the only place the why can live.

Assuming a commit is shared. Everything in this tutorial was local. Nothing left your machine, and this repository has no remote at all.

Do this in the same disposable repository. Predict each answer before running the command.

  1. Create two new files, notes.txt and scratch.txt.
  2. Stage only notes.txt.
  3. Predict what git status will report about each file, then run it.
  4. Now edit notes.txt again, without staging it.
  5. Predict what git status will show for notes.txt, then run it.
  6. Run git diff and git diff --staged and explain why they differ.
  7. Commit, then run git log --oneline to see your fourth commit.

Step 5 is the payoff: notes.txt appears under both “Changes to be committed” and “Changes not staged for commit”, because the index and the working tree hold two different versions of it. If that makes sense, you have understood the index.

Delete the practice directory whenever you like:

Terminal window
cd ..
rm -rf hello-git
  • git init turns a directory into a repository by creating .git.
  • Files are untracked until staged, then staged, then committed.
  • git add records file content into the index; git commit turns the index into a permanent snapshot.
  • git status reports the differences between the working tree, the index and HEAD.
  • git diff compares working tree to index; git diff --staged compares index to HEAD.
  • Commit output encodes real information: branch, root-commit status, object ID and file mode.
  • git log, git log --oneline and git show read history — all locally, with no network.

You have now used all three areas. The next four lessons take them one at a time and go deeper: the working tree, the index, HEAD, and finally the object database that stores it all.