In the previous lesson we walked through the full cycle — edit, stage, commit — and saw how git status guides you at every step. But we used git add and git commit in their most elementary form: staging whole files and committing with a short message.

In practice that runs out of road very quickly. A real afternoon's work does not produce tidy changes: it produces one file with two fixes that have nothing to do with each other, a configuration file that should never have been versioned, another one that needs renaming, and a commit message with a typo in it. Git has a precise answer for every one of those situations, and mastering them is the difference between a history you can read and one you can only endure.

This is the densest lesson in the module. We are going to look at git add in depth — including the difference between -A, -u and ., which trips up almost everybody — the interactive hunk-by-hunk mode, how to unstage, how to move and delete files inside Git, and every useful variant of git commit, --amend included. We will close with the idea that gives all of it a purpose: the atomic commit.

Contents

  1. git add: ways of saying what to stage
  2. -A versus -u versus .: the table that settles it
  3. Staging hunk by hunk with git add -p
  4. Taking things out of the staging area: git restore --staged
  5. Discarding working tree changes: git restore
  6. Moving and renaming with git mv
  7. Deleting with git rm (and the special case --cached)
  8. git commit: every way of committing
  9. --amend: fixing the last commit
  10. The atomic commit

  1. git add: ways of saying what to stage

git add copies the current content of one or more files into the staging area. It accepts many ways of telling it what to act on:

# A single specific file
git add app.js

# Several files
git add app.js styles.css index.html

# A whole directory, recursively
git add src/

# Everything below the current directory
git add .

# The whole repository, wherever you happen to be inside it
git add -A

# A pattern (quotes are mandatory, see below)
git add "*.css"

An important detail about patterns

When you type git add *.css without quotes, it is your shell that expands the asterisk, not Git. The upshot is that only files in the current directory are expanded, and if there are none the command fails.

With quotes, the pattern reaches Git untouched, and Git applies it recursively across the whole repository:

git add "*.css"        # every .css in the repository, at any depth
git add *.css          # only the .css files in the current folder (the shell expands them)

It is a subtle difference with real consequences. The recommendation: use quotes when you want Git's behaviour, and be aware of which one you are asking for.

git add also stages deletions and new files

A common misunderstanding is to think that git add is only for "adding". Its actual job is to synchronise the staging area with the working tree for the paths you name. That covers:

  • New files → they start being tracked.
  • Modified files → their staged version is updated.
  • Files deleted from disk → the deletion is staged.

That last case takes people by surprise:

rm old.js
git add old.js       # stages the DELETION of old.js
git status -s
# → D  old.js

Yes: git add on a file that no longer exists records its disappearance. It makes sense once you read add as "take note of the current state of this path".

Checking without executing

Two very handy options before a broad add:

# Shows what it would do, without doing it
git add --dry-run .
git add -n .
add 'app.js'
add 'styles.css'
add 'src/utils.js'
# Shows the detail of what it is adding
git add --verbose .

In a large or unfamiliar repository, git add -n . costs a second and saves you grief.

  1. -A versus -u versus .: the table that settles it

This is, by a distance, the most widespread confusion around git add. The three forms look alike and do different things. The difference comes down to two axes: which kinds of change they include, and which part of the repository they act on.

Form New files Modified files Deleted files Scope
git add . Yes Yes Yes Only from the current directory downwards
git add -A (--all) Yes Yes Yes The whole repository, wherever you are
git add -u (--update) No Yes Yes The whole repository
git add <path> Yes Yes Yes That path only

Two conclusions read straight off the table:

  • -u is the only one that leaves new files out. It acts exclusively on files Git already tracks. It is the right choice when you want to record all your work on known files without risking adding a new file by accident.
  • . and -A do the same thing apart from the scope. The difference only shows up when you are not at the root of the repository.

A practical demonstration

Let us set the scene in Ana's repository:

cd ~/projects/task-manager
mkdir -p src
echo "// utils" > src/utils.js               # new, inside src/
echo "/* new */" >> styles.css               # modified, at the root
rm README.md                                 # deleted, at the root
echo "# temporary" > DRAFT.md                # new, at the root
git status -s
 M styles.css
 D README.md
?? DRAFT.md
?? src/

Case A — git add . from the root:

git add .
git status -s
A  DRAFT.md
D  README.md
M  styles.css
A  src/utils.js

Everything goes in: new, modified and deleted.

Case B — git add . from a subdirectory:

git reset            # undo the previous staging
cd src
git add .
git status -s
 M styles.css
 D README.md
?? DRAFT.md
A  src/utils.js

Here is the trap. Only src/utils.js has been staged, because . means "the current directory". The changes at the root are still unstaged. Somebody committing now, convinced they had added everything, would leave three changes out.

Case C — git add -A from that same subdirectory:

git reset
git add -A            # still inside src/
git status -s
A  DRAFT.md
D  README.md
M  styles.css
A  src/utils.js

-A ignores where you are and acts on the whole repository. It is the behaviour most people believe they are asking for when they type git add ..

Case D — git add -u:

git reset
git add -u
git status -s
D  README.md
M  styles.css
?? DRAFT.md
?? src/

Only changes to already tracked files have been staged: the modification to styles.css and the deletion of README.md. The two new files remain untracked. This is the behaviour you want when you are working in the middle of a folder full of generated files and you do not trust a sweeping add.

A historical note. Before Git 2.0, git add . did not stage deletions, and that asymmetry caused no end of trouble. From Git 2.0 onwards the behaviour is the one described in the table. If you read old documentation saying otherwise, it is out of date.

Which one to use

  • git add <path> when you know exactly what you want. It is the default choice of anyone who looks after their history.
  • git add -u to record all your work on known files, with no surprises.
  • git add -A when you genuinely want everything and you have a .gitignore you trust.
  • git add . only if you are at the root and you know you are at the root.

And in every case: git status before committing.

  1. Staging hunk by hunk with git add -p

The following Tuesday comes round. Ana wants to implement marking tasks as done, but while she is at it she spots an ugly colour in styles.css and fixes it in passing. By the time she stops, app.js contains two unrelated changes: the new feature, and a small fix to a console message that she also patched on the fly.

Staging app.js whole would mix the two things into the same commit. The answer is hunk mode:

git add -p app.js
git add --patch app.js     # long form

Git walks through the file showing the changes in chunks (hunks) and asking what to do with each one:

diff --git a/app.js b/app.js
index 7b2e8f1..3c9d4a2 100644
--- a/app.js
+++ b/app.js
@@ -12,4 +12,5 @@ function addTask(text) {
   tasks.push({ id: Date.now(), text: text, done: false });
   renderList();
+  updateCounter();
 }

(1/3) Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]?

There are a great many possible answers, but in practice five get used:

Key Action
y Yes, stage this hunk
n No, leave it unstaged
s Split the hunk into smaller ones
e Edit the hunk by hand, line by line
q Quit interactive mode
a Stage this hunk and all the remaining ones in the file
d Stage neither this one nor any of the remaining ones in the file
? Show the help with every option

Let us watch Ana's session in full. First hunk: the call to the counter, which is part of the new feature.

(1/3) Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? y

Second hunk: the marking function.

@@ -25,4 +26,12 @@ function renderList() {
     const li = document.createElement('li');
     li.textContent = task.text;
+    li.addEventListener('click', function () {
+      task.done = !task.done;
+      renderList();
+      updateCounter();
+    });
+    if (task.done) {
+      li.classList.add('done');
+    }
     list.appendChild(li);

(2/3) Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? y

Third hunk: the console message fix, which does not belong in this commit.

@@ -48,4 +57,4 @@ document.querySelector('#new-task').addEventListener('submit', function (event) {
   if (field.value.trim() !== '') {
     addTask(field.value.trim());
-    console.log('task addded');
+    console.log('Task added successfully');
     field.value = '';

(3/3) Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? n

The result:

git status -s
MM app.js
 M styles.css

app.js comes out as MM: one part staged (the feature) and another outstanding (the message). Ana can now commit just the first:

git commit -m "Mark tasks as done on click"
[main 9d1e4b7] Mark tasks as done on click
 1 file changed, 9 insertions(+)

And afterwards, in a separate commit, the rest:

git add app.js styles.css
git commit -m "Fix console message and secondary text colour"

Two clean commits where there was a jumble.

s: splitting a hunk

If two independent changes fall inside the same hunk because they sit close together, s breaks it up:

(1/2) Stage this hunk [y,n,q,a,d,s,e,?]? s
Split into 2 hunks.

Git can only split when there is at least one unchanged context line between the two blocks. If the changes are literally adjacent, s will not be offered and you will have to use e.

e: editing the hunk by hand

It is the most powerful option and the most intimidating. Git opens the hunk in your editor (the one you set up in Initial Configuration) with instructions at the bottom:

# To remove '-' lines, make them ' ' lines (context).
# To remove '+' lines, delete them.

The two rules you need:

  • To not stage an added line (+), delete it from the hunk.
  • To not stage a removed line (-), change its - to a space.

Never delete a line starting with -, and never add brand new lines: the patch would stop applying and Git will complain (though it will give you the chance to try again).

Related commands. -p works with other commands too: git restore -p, git stash -p, git checkout -p. And git add -i opens a full interactive menu, of which -p is only one option. In practice, -p covers 95% of cases.

  1. Taking things out of the staging area: git restore --staged

You staged something you did not mean to. The inverse operation is:

git restore --staged <file>

An example. Ana stages everything out of habit and realises that DRAFT.md should not have gone in:

git add -A
git status -s
A  DRAFT.md
M  app.js
M  styles.css
git restore --staged DRAFT.md
git status -s
M  app.js
M  styles.css
?? DRAFT.md

DRAFT.md has gone back to being an untracked file. Its content on disk has not been touched: --staged acts on the staging area alone.

The old form: git reset HEAD

You will see a great deal of documentation — and plenty of colleagues — using this:

git reset HEAD <file>
git reset <file>          # equivalent, HEAD is the default
git reset                 # takes EVERYTHING out of the staging area

It does exactly the same thing. The difference is one of design: git reset is a command with three modes and several meanings, and git checkout was overloaded too. To sort that out, Git 2.23 (2019) introduced two commands with one clear purpose each:

Task Modern form Old form
Take out of the staging area git restore --staged <f> git reset HEAD <f>
Discard changes on disk git restore <f> git checkout -- <f>
Switch branch git switch <branch> git checkout <branch>

The old forms still work and you will see them everywhere. Use the modern ones in your own work and recognise the old ones when you read them. Using git reset to move HEAD and rewrite history is another matter altogether, covered in Undoing Changes.

  1. Discarding working tree changes: git restore

Without --staged, git restore does something a good deal more serious: it overwrites the file on disk with the reference version, throwing your changes away.

git restore <file>

Ana experiments with a design in styles.css, hates the result and wants to go back:

git status -s
# →  M styles.css

git restore styles.css

git status -s
# → (empty)

The file is back as it was. The changes are gone for good: they were in no commit and in no staging area, so Git holds no copy of them. This is one of the few Git commands that destroys work with no safety net.

A serious warning. Before a git restore, ask yourself whether you really want to throw that work away. If you are unsure, there are alternatives that keep it: git stash (lesson 05-04) or simply committing it and deciding later.

The combination matters

When a file is in the MM state — staged and then modified — you need to be clear about what each variant discards:

Command Staging area Working tree
git restore --staged f Restored from HEAD Untouched
git restore f Untouched Restored from the staging area
git restore --staged --worktree f Restored from HEAD Restored from HEAD
git restore --source=HEAD~2 f The version from 2 commits ago is put in place

Look closely at the second row: a plain git restore f restores from the staging area, not from the last commit. If you had staged an intermediate version, that is the one you go back to.

Useful options:

# Discard changes to EVERY file in the repository (dangerous)
git restore :/

# Discard hunk by hunk, reviewing each one
git restore -p styles.css

# Bring an old version of a file back to disk
git restore --source=HEAD~3 app.js

That last one is not an "undo": it changes your working tree to hold old content, which you can then stage and commit as normal. It is a simple way of recovering the state of one particular file without disturbing the rest of the project.

  1. Moving and renaming with git mv

Ana has created a file called release-notes.md and wants it to be called CHANGES.md. She could do it from the file browser, but then Git would see one deleted file and one new one. The right way is:

git mv release-notes.md CHANGES.md
git status -s
R  release-notes.md -> CHANGES.md

The R code marks a rename, and the change is already staged: git mv stages the result automatically.

git mv is a shortcut

The command is not magic. It is exactly equivalent to three instructions:

mv release-notes.md CHANGES.md
git rm --cached release-notes.md
git add CHANGES.md

In fact, had you used a plain mv, Git would have detected the rename anyway once both changes were staged:

mv release-notes.md CHANGES.md
git add -A
git status -s
# → R  release-notes.md -> CHANGES.md

This connects with something fundamental about the data model we saw in 01-04: Git does not store renames. A blob holds content, and the name lives in the tree. What Git does is detect renames after the fact, by comparing the content of the deleted and added files. If the content is identical, detection is infallible; if you changed the file a great deal while moving it, Git may miss it and show a deletion plus a new file.

Common uses

# Rename
git mv styles.css main-styles.css

# Move into a subdirectory (which must exist)
mkdir css
git mv styles.css css/styles.css

# Move several files into a directory
git mv app.js utils.js src/

# Force the destination to be overwritten
git mv -f draft.md CHANGES.md

And the awkward case: changing only upper and lower case on macOS or Windows, where the filesystem does not distinguish between guide.md and GUIDE.md. Since Bruno works on macOS, he will run into it:

git mv guide.md GUIDE.md
# → fatal: destination exists

The answer is an intermediate step:

git mv guide.md temp.md
git mv temp.md GUIDE.md

Or use -f, which in recent versions of Git handles this particular case.

  1. Deleting with git rm (and the special case --cached)

To remove a file from the project:

git rm old.js
rm 'old.js'
git status -s
# → D  old.js

It does two things at once: it deletes the file from disk and stages the deletion. It is the equivalent of rm old.js && git add old.js.

If Git spots that the file has uncommitted changes, it refuses:

git rm app.js
error: the following file has local modifications:
    app.js
(use --cached to keep the file, or -f to force removal)

The refusal is deliberate: it warns you that you are about to lose work. If you are sure, -f.

git rm --cached: the case that really matters

This variant removes the file from version control but leaves it on disk. It is the answer to a very frequent problem: a file that should never have been versioned and that is already tracked.

Remember section 6 of the previous lesson: .gitignore only acts on untracked files. If config.local.json is already in the history, adding it to .gitignore achieves nothing. The correct sequence is:

# 1. Stop tracking it, without deleting it from disk
git rm --cached config.local.json
rm 'config.local.json'
# 2. Make sure it does not come back
echo "config.local.json" >> .gitignore

# 3. Commit both things
git add .gitignore
git commit -m "Stop versioning the local environment config"
ls config.local.json
# → config.local.json     ← still there, untouched
git status -s
# → (empty: it is ignored now)

For a whole directory you need -r (recursive):

git rm -r --cached node_modules/

A very handy trick once you have fixed the .gitignore and want it applied to everything already tracked:

git rm -r --cached .        # stop tracking everything (deleting nothing from disk)
git add .                   # add it all back, now honouring the .gitignore
git status -s               # check what got left out
git commit -m "Apply .gitignore to already versioned files"

An important warning. git rm --cached stops tracking a file from now on, but does not remove it from the history. Every earlier commit still contains it, and anyone with access to the repository can recover it. If the file held credentials, this does not protect them: you have to rotate them and, if there is no way round it, rewrite the history. We cover that in Security Best Practices.

Deletion at a glance

Command File on disk Tracked by Git
rm f (the system one) Deleted Still tracked; deletion unstaged
git rm f Deleted Deleted, staged
git rm --cached f Kept Stops being tracked
git rm -f f Deleted even with changes Deleted, staged
git rm -r --cached dir/ Kept The whole directory stops being tracked

  1. git commit: every way of committing

With the staging area ready, it is time to record. The form you already know:

git commit -m "Mark tasks as done on click"

And these are the variants worth knowing.

Without -m: the editor

git commit

Git opens your editor with a template:

# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# On branch main
# Changes to be committed:
#	modified:   app.js
#	modified:   styles.css
#

You type at the top, save and close. If you save without typing anything, the commit is cancelled. Lines starting with # are dropped.

This form has two advantages over -m: it shows you what you are about to commit while you write the message (one last chance to catch a mistake), and it makes multi-line messages easy to write.

Multi-line messages

A commit message has two parts: a first summary line and, optionally, a body separated by a blank line.

From the editor it comes naturally. From the command line there are two ways:

# With several -m: each one is a paragraph, separated by a blank line
git commit -m "Mark tasks as done on click" \
           -m "The state is kept in the in-memory array; reloading the page loses it. Persistence will be tackled once local storage exists."
# With a real line break (Bash and Zsh)
git commit -m "Mark tasks as done on click

The state is kept in the in-memory array; reloading the
page loses it."

The result is identical. Many commands — git log --oneline, the commit lists on web platforms — show only the first line, so that line has to work on its own.

-a: staging and committing in one go

git commit -a -m "Adjust the secondary text colour"
git commit -am "Adjust the secondary text colour"           # short form

It automatically stages every tracked file that is modified or deleted, and commits.

Its risk is that it skips the staging area — which is to say, it skips the decision about what goes in. With -a you commit everything you have touched, including:

  • The debugging console.log you left in app.js.
  • The three-line tweak in a file that has nothing to do with what you are committing.
  • The change you were going to look at more carefully before recording it.

On top of that, it does not include untracked files, which produces the opposite mistake: you think you have committed everything, the new file stays out, the project does not build on your colleague's machine and nobody can work out why.

git add + git commit git commit -a
New files Included if you add them Never included
Modified files The ones you pick All of them
Deleted files The ones you pick All of them
Control over the content Total None
Risk of a jumbled commit Low High

Use it when you are certain that everything you modified belongs to the same commit, and even then look at git status first. As a default habit, I do not recommend it.

Other useful options

# See the diff of what you are committing while you write the message
git commit -v

# Commit even with nothing staged (useful in scripts and for marking milestones)
git commit --allow-empty -m "Trigger the deployment"

# Set a specific authoring date
git commit --date="2026-07-15 10:00:00" -m "..."

# Commit on someone else's behalf (pair work)
git commit --author="Bruno Salas <bruno.salas@example.com>" -m "..."

# Sign the commit cryptographically
git commit -S -m "..."

git commit -v deserves a comment of its own: it appends the full diff of what is staged to the foot of the template. It is the best way of reviewing your own work just before recording it, and many developers turn it on permanently:

git config --global commit.verbose true

  1. --amend: fixing the last commit

Ana commits and, a second later, spots the typo in the message:

git log --oneline -1
# → 9d1e4b7 Mark tasks as done on clik

The fix:

git commit --amend -m "Mark tasks as done on click"
git log --oneline -1
# → 5c2e8b4 Mark tasks as done on click

Sorted. Notice that the hash has changed: it was 9d1e4b7 and it is now 5c2e8b4.

--amend is good for three things:

# 1. Change the message only
git commit --amend -m "Corrected message"

# 2. Add a forgotten file to the last commit
git add file-i-forgot.js
git commit --amend --no-edit          # --no-edit keeps the current message

# 3. Fix the authorship
git commit --amend --author="Ana Ferrer <ana.ferrer@example.com>" --no-edit

The second case is the most useful day to day: you have just committed and discover a file was missing. Instead of creating a "Add the missing file" commit, you fold it into the previous one and the history stays clean.

--amend rewrites history

This is the part to understand properly, and it ties directly back to The Git Data Model.

--amend does not modify the existing commit. It cannot: Git's objects are immutable, and their identifier is the hash of their content. What it does is:

  1. Create a new commit with the same parent as the old one and the corrected content and message.
  2. Move the current branch to that new commit.
  3. Leave the old commit orphaned, with nothing pointing at it.
graph LR
    subgraph "Before"
        A1["c5d9b1e"] --> B1["2f6a3c8"] --> C1["9d1e4b7<br/>(message with a typo)"]
        M1["main"] -.-> C1
    end
    subgraph "After --amend"
        A2["c5d9b1e"] --> B2["2f6a3c8"] --> C2["9d1e4b7<br/>(orphaned)"]
        B2 --> D2["5c2e8b4<br/>(corrected message)"]
        M2["main"] -.-> D2
    end

Hence the golden rule:

Never --amend a commit you have already shared.

If commit 9d1e4b7 was already on the server and Bruno had fetched it, rewriting it makes your history and his diverge: he has a commit you no longer have, and your new commit is unknown to him. Fixing that takes work and coordination. As long as the commit lives only on your machine, --amend is safe and highly recommended.

The general rule — rewrite local work freely, never shared work — applies just as much to rebase (module 5) and to every history-cleaning technique (08-02).

  1. The atomic commit

Everything above exists for one purpose: to let you create atomic commits.

An atomic commit is one that contains one complete change and only one. Two conditions at once:

  • Complete: it includes everything needed for that change to work. If you add a function and its styling, both go together; a commit that leaves the project broken is not atomic.
  • Single: it includes nothing that does not belong to that change. Not the spelling fix you made in passing, and not the debugging console.log.

The minimum rule

Here is the only rule you need for now, and it doubles as an atomicity test:

If describing your commit requires the word "and", it should probably be two commits.

Applied to Ana's case: "Mark tasks as done and fix the console message and adjust the colour". Three implicit "and"s, three commits. That is why she reached for git add -p.

Why it is worth the effort

With atomic commits With jumbled commits
git log tells the real story of the project The history is a list of "various changes"
Undoing one particular change is trivial Undoing drags in things you did not want
git bisect finds the exact guilty commit The guilty commit contains ten changes
Code reviews are quick Nobody wants to review 40 mixed-up files
git blame genuinely explains each line git blame points at "Friday's changes"

The benefits are collected months later, when something breaks and you have to work out why. Tools such as Git Bisect and Git Blame depend entirely on this discipline.

About the messages

An atomic commit needs a message that describes it. For now, three rules will do:

  1. A short first line (around 50 characters), descriptive on its own.
  2. In the imperative, describing what the commit does: "Add the task counter", not "Added the counter" and certainly not "changes".
  3. The what on the first line, the why in the body if it needs explaining.

The full conventions — Conventional Commits, lengths, body, trailers, issue references — are covered in Writing Good Commit Messages. With these three rules you will already write better messages than average.

Common Mistakes and Tips

  • Using git add . from a subdirectory thinking it adds everything. It only adds from there downwards. If you want the whole repository, it is git add -A.
  • Expecting -u to include new files. It does not, by design. That is its advantage, not its flaw.
  • Trusting git commit -a. It commits everything modified with no filter and leaves the new files out. It is the recipe for jumbled commits and forgotten files.
  • Reaching for git restore <file> lightly. It destroys your changes with no backup. It is one of the very few Git commands with no safety net. If you are unsure, git stash.
  • Confusing git restore --staged with git restore. The first takes things out of the staging area and does not touch the disk; the second overwrites the disk. The difference is between an undone change and a lost one.
  • Believing git rm --cached removes the file from the history. It does not: it remains in every earlier commit. For secrets, this is not enough.
  • Running --amend on a commit you have already shared. It rewrites history and causes divergence with your colleagues. Only on work still sitting on your machine.
  • Committing three days' work in a single commit. Nobody can review it, undo it or understand it. Commit early and often.
  • Tip: turn on git config --global commit.verbose true. Seeing the diff while you write the message catches a huge number of mistakes before they reach the history.
  • Tip: if git add -p feels slow at first, stick with it for a week. It is the habit that improves the quality of a history most, and it ends up being automatic.
  • Tip: when you are unsure what you are about to commit, git diff --staged shows you exactly. That is the subject of the next lesson.

Exercises

Exercise 1: Mastering -A, -u and .

Set up this scenario:

mkdir -p ~/practice/add-test/components && cd ~/practice/add-test
git init
echo "initial" > root.txt
echo "initial" > components/button.js
git add -A && git commit -m "Initial state"

echo "change" >> root.txt
echo "change" >> components/button.js
echo "new" > root-new.txt
echo "new" > components/menu.js
rm root.txt

Put yourself inside components/ and, running git reset between attempts to get back to the starting point, work out exactly what each of these commands stages. Write down the output of git status -s in each case and explain why:

  1. git add .
  2. git add -A
  3. git add -u
  4. git add ..

Exercise 2: Separating two changes in the same file

In the task-manager repository (or in a test one with a similar app.js), make these two changes at the same time in app.js:

  • Change A (feature): add a deleteTask(id) function that removes a task from the array and redraws the list.
  • Change B (maintenance): fix a misspelt comment in the file's header.

Then:

  1. Use git add -p to stage only change A.
  2. Show with git status -s that the file is in the MM state.
  3. Commit change A with the message "Add task deletion to the list".
  4. Commit change B as a second commit.
  5. Check with git log --oneline that there are two commits, and with git show --stat that each one touches what it should.

Exercise 3: Rescuing a badly staged repository

A colleague hands you a repository in this state, just after committing:

git log --oneline -1
# → 7f3c9a2 various fixes

git show --stat HEAD
 .env                  |  3 +
 app.js                | 24 ++++++++---
 styles.css            |  6 ++--
 node_modules/lib/a.js | 99 +++++++++++++++++
 README.md             |  2 +-
 5 files changed, 128 insertions(+), 6 deletions(-)

The commit has not been shared with anybody yet. Sort it out:

  1. What are the three problems with this commit?
  2. Write the sequence of commands that stops versioning .env and node_modules/, adds them to the .gitignore and keeps both files on disk.
  3. Replace the commit message with a descriptive one, without creating an extra commit.
  4. What would have changed in your answer if the commit had already been shared? And what extra step would be needed because .env was versioned at all?

Solutions

Solution to Exercise 1

The starting state (seen from the root):

 M components/button.js
 D root.txt
?? components/menu.js
?? root-new.txt

1. git add . from components/:

cd ~/practice/add-test/components
git add .
git status -s
M  components/button.js
A  components/menu.js
 D root.txt
?? root-new.txt

It stages everything (new, modified and deleted) but only from the current directory downwards. The changes at the root stay out.

2. git add -A:

git reset
git add -A
git status -s
M  components/button.js
A  components/menu.js
D  root.txt
A  root-new.txt

It stages the whole repository, completely ignoring where you are. All four entries have their first column filled in.

3. git add -u:

git reset
git add -u
git status -s
M  components/button.js
D  root.txt
?? components/menu.js
?? root-new.txt

It acts on the whole repository, but only on tracked files: the modification to button.js and the deletion of root.txt. The two new files remain untracked.

4. git add ..:

git reset
git add ..
git status -s
M  components/button.js
A  components/menu.js
D  root.txt
A  root-new.txt

The same result as -A, but for a different reason: .. is the root of the repository, so "from there downwards" is everything. It confirms that the difference between . and -A is purely the starting point of the walk.

In summary:

Command (from components/) button.js menu.js root.txt root-new.txt
git add . Yes Yes No No
git add -A Yes Yes Yes Yes
git add -u Yes No Yes No
git add .. Yes Yes Yes Yes

Solution to Exercise 2

The two changes in app.js. The header (change B):

// task-manager — mian logic              ← before
// task-manager — main logic              ← after

And the new function (change A):

function deleteTask(id) {
  const index = tasks.findIndex(function (t) { return t.id === id; });
  if (index !== -1) {
    tasks.splice(index, 1);
    renderList();
    updateCounter();
  }
}

1. Staging change A only:

git add -p app.js

The first hunk Git shows is the header one (it sits higher up the file):

@@ -1,3 +1,3 @@
-// task-manager — mian logic
+// task-manager — main logic
 const tasks = [];

(1/2) Stage this hunk [y,n,q,a,d,j,J,g,/,e,?]? n

We answer n: it does not belong in this commit. The second one is the function:

@@ -30,6 +30,15 @@ function renderList() {
+function deleteTask(id) {
+  const index = tasks.findIndex(function (t) { return t.id === id; });
...

(2/2) Stage this hunk [y,n,q,a,d,K,g,/,e,?]? y

2. Checking the MM state:

git status -s
# → MM app.js

First column M: there is a staged version (with the new function, without the comment fix). Second column M: the disk differs from it.

You can verify it more precisely:

git diff --staged --stat
# → app.js | 9 +++++++++
git diff --stat
# → app.js | 2 +-

Nine staged lines (the function) and one outstanding line (the comment). Exactly the separation we were after.

3 and 4. The two commits:

git commit -m "Add task deletion to the list"
# → [main 4e7f2a9] Add task deletion to the list
# →  1 file changed, 9 insertions(+)

git add app.js
git commit -m "Fix a typo in the header comment"
# → [main 8a1d5c3] Fix a typo in the header comment
# →  1 file changed, 1 insertion(+), 1 deletion(-)

5. Verification:

git log --oneline -2
8a1d5c3 (HEAD -> main) Fix a typo in the header comment
4e7f2a9 Add task deletion to the list
git show --stat 4e7f2a9 | tail -2
# →  app.js | 9 +++++++++
# →  1 file changed, 9 insertions(+)

git show --stat 8a1d5c3 | tail -2
# →  app.js | 2 +-
# →  1 file changed, 1 insertion(+), 1 deletion(-)

Two atomic commits on the same file. Without git add -p the only way to get there would be to undo one change by hand, commit, and type it back in.

Solution to Exercise 3

1. The three problems:

  1. .env is versioned. It holds credentials; they are now in the history and visible to anyone with access to the repository.
  2. node_modules/ is versioned. Those are reinstallable dependencies: they bloat the repository, clutter the diffs and add nothing.
  3. The message "various fixes" says nothing. And underneath it, the commit is not atomic: it mixes logic (app.js), styling (styles.css) and documentation (README.md) with files that should not be there at all.

2. Stop versioning .env and node_modules/:

git rm --cached .env
git rm -r --cached node_modules/
rm '.env'
rm 'node_modules/lib/a.js'

--cached is the key: the files stay on disk, they merely stop being tracked.

cat >> .gitignore <<'EOF'
.env
node_modules/
EOF

git add .gitignore
git status -s
A  .gitignore
D  .env
D  node_modules/lib/a.js

Checking that the files are still there:

ls -la .env && ls node_modules/lib/
# → -rw------- 1 ana ana 87 Jul 28 10:12 .env
# → a.js

3. Fixing the message without creating a new commit.

Since the changes from step 2 are staged and the commit has not been shared, they can be folded into that same commit with --amend, fixing the message along the way:

git commit --amend -m "Add task deletion and adjust the list styles"
[main 3b9e7d1] Add task deletion and adjust the list styles
 4 files changed, 29 insertions(+), 6 deletions(-)
git show --stat HEAD
 .gitignore   | 2 ++
 app.js       | 24 ++++++++---
 styles.css   | 6 ++--
 README.md    | 2 +-
 4 files changed, 29 insertions(+), 6 deletions(-)

.env and node_modules/ have vanished from the commit, and the message now describes something. The hash has changed (7f3c9a23b9e7d1) because, as we know, --amend creates a new commit.

A critical note: this --amend works cleanly because 7f3c9a2 was the only commit containing .env. Had the file been in twenty commits, --amend would only pull it out of the last one and it would still sit in the other nineteen.

About the message: it still has an "and" in it, so the truly correct move would have been to split the commit in two (feature and styling). A git reset and a couple of git add -p runs would do it, but that is already the territory of Keeping a Clean History.

4. If the commit had already been shared.

Two things would change:

  • No --amend. Rewriting a commit others already hold causes histories to diverge. The correct answer would be a new commit on top:
git rm --cached .env
git rm -r --cached node_modules/
git add .gitignore
git commit -m "Stop versioning credentials and dependencies"

The bad message on the earlier commit stays where it is. That is the price of having published: shared history is immutable in practice.

  • And, above all, the credentials have to be rotated. This is the point you cannot skip past: .env was in the history and, if that history was shared, the keys count as compromised. Untracking the file does not protect them; anyone who cloned the repository has them on disk, and git show 7f3c9a2:.env prints them out. The only valid response is to invalidate those credentials and generate new ones. Cleaning the history (with git filter-repo or similar) is a complementary step, never a substitute. We will look at it in Security Best Practices.

Conclusion

This lesson has given you fine control over what enters the history and how. To recap:

  • git add synchronises paths with the staging area, and that covers new, modified and deleted files.
  • -A, -u and . differ along two axes: -u is the only one that excludes new files, and . is the only one limited to the current directory. -A is "everything, everywhere".
  • git add -p stages hunk by hunk and is the tool that lets you separate two changes tangled together in the same file. It is the habit that improves a history most.
  • git restore --staged takes things out of the staging area without touching the disk; git restore overwrites the disk and destroys your changes with no safety net. Their old equivalents are git reset HEAD and git checkout --.
  • git mv and git rm perform the filesystem operation and stage it in one step. git rm --cached stops tracking while keeping the file, and it is the missing piece whenever a .gitignore "does not work".
  • git commit takes a message inline or in the editor, supports multi-line messages, and offers -a, which is convenient and risky: it commits everything modified and leaves everything new out.
  • --amend fixes the last commit by creating a new one and discarding the old. It is safe and very useful as long as that commit has not been shared.
  • The atomic commit — one complete change and only one — is the goal of everything above. The minimum rule: if you need "and" to describe it, split it.

Ana can now choose precisely what she commits. But there is a piece we have used without explaining it: in -p mode, Git showed her the changes in a format with + and - lines, @@ headers and hunks. That format turns up everywhere in Git, and being able to read it is essential.

In the next lesson, Inspecting Changes with git diff, we will learn to see exactly what has changed before committing: the difference between git diff, git diff --staged and git diff HEAD in terms of which areas they compare, how to read the unified diff format line by line, how to compare two specific commits or a single file, and the options that make the output readable in the difficult cases.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved