Git, CI/CD & GitOpsVII · Repository InspectionInspection
git status decoded — staged, unstaged, untracked, and the porcelain contract
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
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 addmoves 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 addmoves it back to staged;git restorediscards 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 requiresgit 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:
XYwhereXis the index status andYis 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 fromHEAD.A— added: path is staged and was not inHEAD.D— deleted: staged content removes the path fromHEAD.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:
--branchshows 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-stashshows 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
- Always parse
--porcelain. Any script that consumesgit statusshould consume--porcelainand never the default human-readable output. Pin the behaviour with a versioned contract; the human format is not a contract. - Check the index slot, not the working-tree slot. A
MMline 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. - Use
--branchand--show-stashin 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. - Treat
--porcelainas stable; the human format as cosmetic. The wording ofgit statuschanges 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 samegit status --porcelaincheck.
Quiz
Knowledge check · 4 questions
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?
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.
Q3. Name the two flags that make `git status` output production-relevant for CI scripts, and explain what each one reveals.
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.