Skip to main content
RunBook Academy

Git, CI/CD & GitOpsVI · Index / Staging AreaIndex

Staging versus skipping — git add, .gitignore, and the untracked bucket

Intermediate⏱ ~19 mingit

What you'll learn

  • Distinguish "not staged" from "ignored" in git status output
  • Read .gitignore patterns and explain glob, anchored, and negation forms
  • Decide when a file should be staged once, ignored permanently, or tracked but gitignored
  • Use git rm --cached to stop tracking a file without deleting it from the working tree
  • Recognise the security implication of accidentally committing secrets

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

Two distinct ideas hide under the word “skipped” in a Git workflow. A file that the engineer has not yet added is untracked and git status lists it under “Untracked files”. A file that .gitignore matches is ignored and git status does not mention it at all, unless explicit flags force it to. The difference matters: an untracked file is a candidate for git add; an ignored file is not in the repository’s domain unless the engineer forces it. Misreading the two is how secrets end up in commits and how build artifacts end up inflating the repository by gigabytes.

The three buckets of git status

git status collapses everything it sees into named buckets, and the difference between them is what the engineer has decided:

git status --short
# M  terraform/main.tf          (modified, staged)
#  M ansible/hosts.yml          (modified, not staged)
# A  playbooks/rotate-creds.yml (added/staged)
# ?? secrets/dev-keys.pem       (untracked)
# !! secrets/prod-keys.pem      (ignored, would be tracked with --ignored)

The X/Y column in --short output is the staging state on the left and the working-tree state on the right. The conceptual buckets:

  • Staged — the index differs from HEAD. The file is in the next commit.
  • Not staged — the working tree differs from the index. The file has been edited since the last git add.
  • Untracked — the working tree contains a path that is not in the index, and .gitignore does not match it. The path exists on disk; Git does not have an opinion about it yet.
  • Ignored.gitignore matches the path; Git does not list it under untracked files unless --ignored is passed. The path is outside the repository’s domain.
flowchart LR
    A["Working tree path"] --> B{"in index?"}
    B -->|yes| C["tracked file\nstaged or not staged"]
    B -->|no| D{".gitignore\nmatches?"}
    D -->|yes| E["ignored\nhidden from git status"]
    D -->|no| F["untracked\nlisted by git status"]

How .gitignore patterns work

A .gitignore file contains one pattern per line. Patterns are matched against the path relative to the .gitignore file’s location (or against any path if the pattern has no slash). Three forms cover most needs:

# Plain name — matches the file or directory anywhere
*.pem

# Anchored — matches only at the same level as the .gitignore
/secrets/

# Negation — re-include a path that an earlier pattern excluded
!/scripts/deploy.sh

The rules in plain language:

  • A pattern without a slash matches the basename at any depth. *.pem matches secrets/dev.pem and tls/root.pem.
  • A pattern with a leading slash is anchored to the directory containing the .gitignore file. /secrets/ matches the secrets/ directory at the same level but not terraform/secrets/.
  • A pattern with a slash anywhere but at the start matches the pattern relative to the .gitignore file. build/** matches anything under a build/ directory at the same level.
  • A negation pattern (!) re-includes a path that an earlier pattern excluded. It does not re-include a path that an earlier pattern excluded because the file is not present; Git cannot track a file that does not exist.

Patterns apply cumulatively. A repository can have multiple .gitignore files: one at the root, plus per-directory files for finer-grained control. git status reports “nothing to commit, working tree clean” if every path is either tracked or ignored.

Staged once, ignored forever

The most common production scenario is a file that was previously committed but should no longer be in the repository. Examples: a .env file with development credentials committed by accident, a node_modules/ directory checked in before the team adopted a .gitignore, or a terraform.tfstate file checked in before the team moved state to S3.

The fix is two-step: stop tracking the file, and add it to .gitignore. Stopping tracking is the operation that removes the file from the index without deleting it from the working tree:

git rm --cached secrets/dev-keys.pem
git commit -m 'stop tracking dev keys (rotate first!)'

The --cached flag tells git rm to remove the path from the index but leave the working tree file intact. The file remains on disk; it is simply no longer part of the repository. Adding the path to .gitignore in the same commit prevents the next git add -A from re-tracking it.

sequenceDiagram
    participant WT as Working tree
    participant IDX as Index
    participant REPO as Repository

    Note over IDX: file currently tracked
    WT->>IDX: git rm --cached PATH
    Note over IDX: path removed from index
    Note over WT: file remains on disk
    WT->>REPO: git commit
    Note over REPO: path no longer in any commit's tree

The untracked bucket and the build artifact trap

A common source of accidental commits is the untracked bucket. A Terraform run produces a .terraform/ directory of provider plugins; an Ansible run produces .retry files; an editor produces .swp and *~ backups. None belong in the repository, but they appear in git status as untracked and lurk in the working tree until a broad git add -A sweeps them up.

The fix is a .gitignore at the repository root:

# Terraform
.terraform/
*.tfstate
*.tfstate.backup
.terraformrc
terraform.tfvars

# Ansible
*.retry
.ansible/

# Editor and OS
*.swp
*~
.DS_Store

An infrastructure repository without a .gitignore is an accident waiting to commit. The first commit in any new infrastructure repository should add a .gitignore; the discipline is to commit it before any other file.

The git add —force escape hatch

git add -f <path> (or git add --force <path>) stages an ignored file. The flag exists for legitimate use: a debug build configuration that should be temporarily committed, a machine-specific override that needs to ship once, an audit artifact that must be attached to a specific commit. It is not a normal workflow.

In an infrastructure repository, the right policy is to treat git add -f as a deliberate event that must be justified in the commit message. A pre-commit hook that refuses commits containing -f justifications in git log is overkill; a post-commit CI check that scans every commit for ignored files that were force-added is the right place for the guard rail.

Production discipline

  1. Commit a .gitignore before any other file. A repository without a .gitignore is one git add -A away from a gigabyte of node_modules/.
  2. Treat git rm --cached as a remediation, not a cleanup. Removing a file from tracking is the right move when the file should never have been tracked. For secrets, the remediation is incomplete until the secret is rotated.
  3. Treat git add -f as an audit event. Force-adding an ignored file should appear in the commit message and in the post-commit review. The flag exists; the discipline is to use it rarely and visibly.

Cross-course references

  • Linux for Production Sysadmins - Part XII (RepoSecurity) covers the package-manager analogue: ignored paths in Git are the same idea as files excluded from a Debian package or a Red Hat RPM, except the Git version is editable per clone.
  • Ansible for Production Sysadmins - Part XXXVII (RepoArch) describes the .gitignore patterns that an Ansible repository must include to keep vault files, retry files, and dynamic inventory out of the commit graph.
  • Terraform for Production Sysadmins - Part IX (State) treats .gitignore as the boundary that keeps the state file out of the repository even when the configuration directory is shared.

Quiz

Knowledge check · 4 questions

  1. Q1. An engineer notices that `git status` shows no output for a file they know is on disk. They check `.gitignore` and confirm the path is matched. What is the right next action if the file holds a development credential that was committed accidentally a year ago?

  2. Q2. Adding a path to `.gitignore` removes the file from every prior commit in the repository's history.

  3. Q3. Explain the difference between a path that `git status` lists under 'Untracked files' and a path that `git status` does not mention at all but exists on disk.

  4. Q4. Stop tracking a sensitive file that was committed by accident, harden the repository to prevent recurrence, and address the leaked secret.

    A team discovers that `secrets/dev-keys.pem` was committed to the repository nine months ago. The file holds a long-lived AWS access key for a development environment. The file is on disk in every clone. The team wants to stop tracking the file, prevent future accidental commits of secrets, and address the leaked credential.

Passing score: 75%. Answers are checked in this browser.