Skip to main content
RunBook Academy

Git, CI/CD & GitOpsVII · Repository InspectionInspection

git status decoded — staged, unstaged, untracked, and the porcelain contract

Intermediate⏱ ~18 mingit

What you'll learn

  • Decode the three states git status reports: staged, unstaged, and untracked
  • Map every two-letter porcelain code to a movement between the index, the working tree, and HEAD
  • Use --short or --porcelain as a stable machine-readable contract in CI scripts
  • Recognise --branch and --show-stash as production-relevant additions to the default output
  • Identify the failure modes of parsing human-readable status output

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.

git status is the most-run command in any Git workflow, and the most-misread. The human-readable format is a presentation: it is localised, it changes between Git versions, and it is not a contract. The porcelain format is a contract: every line has exactly two letters, a space, and a path. The difference matters because the first thing every CI pipeline, pre-commit hook, and deployment guard does is parse git status to decide whether the working tree is clean. Wrong format, wrong decision, wrong production outcome.

The three states status reports

git status compares three trees: the working tree, the index, and HEAD. For every path it has to answer, in order, which of the three trees the path is in and how. The three states are:

  • Staged. The path is in the index but its blob differs from the one in HEAD. git add moves a path into this state. The next commit will include the staged version, not the working-tree version.
  • Unstaged. The path is in the index and the working tree, but the working-tree version differs from the index. The change has been edited but not yet staged. git add moves it back to staged; git restore discards the working-tree change.
  • Untracked. The path is in the working tree but not in the index. Git has never seen it, or it has been excluded by .gitignore. Promoting it to tracked requires git add.
flowchart LR
    WT["Working tree"] -->|git add| IDX["Index"]
    IDX -->|git commit| HD["HEAD"]
    HD -->|git restore --staged| IDX
    IDX -->|git restore| WT
    UN["Untracked"] -->|git add| IDX
    UN -->|git clean| GONE["removed"]

The fourth state — deleted — is implicit. A path that is in the index but not in the working tree is shown as a deletion. A path that is in the working tree but not in the index is shown as untracked.

Reading the short output

git status --short (or --porcelain) is the format every script should consume. Every line is exactly two letters, a space, and a path. The two letters are the index status and the working-tree status:

  • XY where X is the index status and Y is the working-tree status.
  • ? means the slot is untracked or unchanged in a way Git does not surface.
  • ! means the path is ignored by .gitignore.
  • A space is “unchanged in this slot”.

The index codes (X):

  • M — modified: staged content differs from HEAD.
  • A — added: path is staged and was not in HEAD.
  • D — deleted: staged content removes the path from HEAD.
  • R — renamed: staged content renames the path.
  • C — copied: staged content copies the path.
  • U — updated but unmerged: a merge conflict to be resolved.

The working-tree codes (Y) cover the same set but describe the working tree relative to the index. Renames and copies add a second line with the source path.

git status --short
# M  deploy/prod/us-east-1.tf
# MM main.tf
# A  modules/redis/main.tf
# D  legacy/iam.json
# ?? scratch.txt
# UU merge.conflict
# R  old/path.tf -> new/path.tf

MM main.tf is the interesting case: the path is staged with one set of changes, but the working tree has further changes that have not been staged. The next commit will record the staged version; the working-tree changes will be discarded on the next checkout unless they are staged first.

Branch and stash flags

Two flags are production-relevant for git status and worth knowing explicitly:

  • --branch shows the current branch and the relationship between the local branch and its upstream. The output ends with a line like ## main...origin/main [ahead 2]. In CI this is the difference between “we are testing what we plan to deploy” and “we are testing something that has diverged from upstream”.
  • --show-stash shows the count of stash entries. A non-empty stash is a sign that the working state is not the only state. CI typically treats a non-empty stash as a hard failure; a stash in a build environment is a stash that has not been cleaned.
git status --short --branch --show-stash
## main...origin/main [ahead 2]
M  deploy/prod/us-east-1.tf
?? scratch.txt

Porcelain in scripts

The default human-readable output is for humans. Parsing it in a script is a bug: the wording is translated, the colour codes are injected, the format changes between releases. The --porcelain flag is the stable contract.

# Correct: count paths with any change
git status --porcelain | wc -l

# Wrong: grep for the word "modified" in human-readable output
git status | grep -c "modified"

The --porcelain format is specified to remain stable across Git versions and locales. Anything that depends on parsing git status output should consume --porcelain and nothing else. The second form breaks the moment Git is built with a different language pack or the wording changes in a release.

# Guard a CI step
if [ -n "$(git status --porcelain)" ]; then
  echo "working tree is dirty"
  exit 1
fi

# Build a list of untracked paths for review
git status --porcelain | awk '$1 == "??" { print $2 }'

Production discipline

  1. Always parse --porcelain. Any script that consumes git status should consume --porcelain and never the default human-readable output. Pin the behaviour with a versioned contract; the human format is not a contract.
  2. Check the index slot, not the working-tree slot. A MM line is a working-tree change that will be dropped on the next commit. CI pipelines that only check “is anything changed?” miss the difference between staged and unstaged changes.
  3. Use --branch and --show-stash in CI. A clean working tree on a branch that has diverged from upstream is not a clean state to deploy from. A non-empty stash is a state that will not be reproducible.
  4. Treat --porcelain as stable; the human format as cosmetic. The wording of git status changes between releases and languages. The two-letter format is documented and does not.

Cross-course references

  • Linux for Production Sysadmins - Part XXXIV (ConfigMgmt) discusses the same input-output-stability contract: parsers consume --porcelain, humans read the formatted output.
  • Ansible for Production Sysadmins - Part XXXVII (RepoArch) covers the working-tree-clean check as a precondition for an Ansible run; the same discipline applies to any CI pipeline.
  • Terraform for Production Sysadmins - Part IX (State) covers the pre-conditions for a terraform apply, which begin with the same git status --porcelain check.

Quiz

Knowledge check · 4 questions

  1. Q1. A CI guard script parses the default human-readable output of `git status` to decide whether the working tree is clean. What is the most likely failure mode?

  2. Q2. A line `MM main.tf` in the porcelain output means the file is staged and that the staged version will be the one recorded in the next commit.

  3. Q3. Name the two flags that make `git status` output production-relevant for CI scripts, and explain what each one reveals.

  4. Q4. A pre-commit hook rejects a commit because the working tree is dirty. Diagnose what the porcelain output actually shows and what the hook should have done.

    An engineer runs `git commit -m 'fix: bump redis'`. The hook reports 'working tree is dirty, refusing to commit'. The engineer runs `git status` and sees: 'Changes to be committed: modified: redis.conf'. The hook is parsing human-readable output and looking for the word 'modified'. After the engineer's `git add` the working tree is reported as 'Changes not staged for commit: none' and 'Untracked files: none'. The hook is re-run and passes. The commit is recorded with the version of redis.conf that was in the index, not the version the engineer expected.

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