Parsing git status porcelain output Jump to heading

Hooks, prompts, editor integrations and CI guards all need to know the state of a working tree: is it clean, what is staged, are there conflicts, is the branch ahead of its upstream. git status answers all of that for people, in a format that changes between versions and follows the user’s language. git status --porcelain=v2 answers it for scripts, in a documented format that will not change, with one line per entry and a type character at the start that says exactly what kind of entry it is. Combined with -z, every path arrives intact, whatever characters it contains. This page explains the format and builds a small reader for it, within scripting Git with plumbing commands.

When to use this approach Jump to heading

  • A script parses git status or git status --short output and breaks on some machines.
  • You need staged versus unstaged state, conflict detection or branch tracking information in a script.
  • File names in your repositories include spaces, non-ASCII characters or worse.
  • You are writing a hook that must refuse to run on a dirty or conflicted tree, as in handling partially staged files.

Step 1 β€” Request the right format Jump to heading

--porcelain=v2 gives the detailed, stable format. -z terminates entries with NUL instead of newline and turns off path quoting. --branch adds header lines about the current branch and its upstream.

git status --porcelain=v2 -z --branch | tr '\0' '\n'      # tr only to make it readable here
# # branch.oid 8b3e4f2c…
# # branch.head feature/export
# # branch.upstream origin/feature/export
# # branch.ab +2 -0
# 1 .M N... 100644 100644 100644 1d3f… 1d3f… src/export/schedule.py
# 1 M. N... 100644 100644 100644 9e2a… 4b77… src/export/api.py
# u UU N... 100644 100644 100644 100644 a1… b2… c3… src/billing/totals.py
# ? notes/todo.md
Entry types in porcelain v2Header lines start with a hash and describe the branch. Ordinary changed entries start with 1, renames and copies with 2, unmerged entries with u, untracked files with a question mark and ignored files with an exclamation mark. The first character always tells the script how to parse the rest of the line.#branch headers1changed entry2rename / copyuunmerged? / !untracked / ignoreddispatch on the first character, then split a fixed number of fields

Step 2 β€” Read the XY status of changed entries Jump to heading

Ordinary entries (1) and renames (2) carry a two-character XY field: X is the staged (index) status, Y the unstaged (working tree) status, with . meaning unchanged.

1 .M …   modified in the working tree, not staged
1 M. …   modified and staged
1 MM …   staged, then modified again (partially staged)
1 A. …   added to the index
1 .D …   deleted in the working tree, not staged
2 R. … R100 new\0old   renamed in the index (path, then original path)
What the X and Y characters meanThe first character describes the index compared with HEAD, which is what will be committed. The second describes the working tree compared with the index, which is what is not yet staged. A dot means no change on that side, so .M is unstaged only and M. is staged only.X (staged)Y (unstaged).Mno changemodifiedM.modifiedno changeMMmodifiedmodified againA.addedno change.Dno changedeleted'MM' is the partially staged case that formatters must handle carefully

Step 3 β€” Parse the stream safely in shell Jump to heading

Because entries are NUL-terminated and rename entries contain a second NUL-terminated path, a line-oriented while read is not enough in POSIX shell. A small awk program with the record separator set to NUL handles it, including the extra path after renames.

#!/bin/sh
# scripts/status-summary.sh β€” counts and lists from porcelain v2
git status --porcelain=v2 -z --branch | awk 'BEGIN { RS="\0" }
  skip { skip=0; next }                                   # original path after a rename
  /^# branch.head / { head=substr($0, 15) }
  /^# branch.ab /   { split($0, ab, " "); ahead=ab[3]; behind=ab[4] }
  /^1 / { xy=substr($0,3,2); path=$0; sub(/^([^ ]+ ){8}/, "", path)
          if (substr(xy,1,1)!=".") staged++; if (substr(xy,2,1)!=".") unstaged++ }
  /^2 / { xy=substr($0,3,2); if (substr(xy,1,1)!=".") staged++; if (substr(xy,2,1)!=".") unstaged++; skip=1 }
  /^u / { conflicted++ }
  /^\? / { untracked++ }
  END { printf "branch=%s ahead=%s behind=%s staged=%d unstaged=%d conflicted=%d untracked=%d\n",
        head, ahead, behind, staged, unstaged, conflicted, untracked }'

The sub(/^([^ ]+ ){8}/, "") removes the eight fixed fields before the path on a type-1 line, leaving the path intact even if it contains spaces. Type-2 lines have nine fields before the path, and type-u lines have ten.

# Verification: works with hostile names
printf 'x' > 'name with space.txt'; printf 'y' > 'tab	here.txt'
sh scripts/status-summary.sh       # untracked=2, no broken output

Step 4 β€” Turn the summary into decisions Jump to heading

Most scripts need a yes-or-no answer. Build small predicates on the parser, and use exit codes for the simplest cases.

# Fail a release script on any uncommitted change, conflicts or untracked files
summary=$(sh scripts/status-summary.sh)
case "$summary" in
  *conflicted=[1-9]*) echo "resolve conflicts first"; exit 1 ;;
  *staged=0\ unstaged=0\ conflicted=0\ untracked=0) : ;;
  *) echo "working tree not clean: $summary"; exit 1 ;;
esac

# Cheapest possible checks, no parsing at all
git diff --quiet               || echo "unstaged changes"
git diff --cached --quiet      || echo "staged changes"
test -z "$(git ls-files --others --exclude-standard -z | head -c1)" || echo "untracked files"
Choosing how much to parseIf the script only needs to know whether anything changed, use git diff --quiet exit codes. If it needs counts or kinds of change, parse porcelain v2. If it needs the branch's upstream state, add the branch headers. Each step up costs more code, so use the least that answers the question.What does the script need to know?anything changed?Exit codesdiff --quietwhat kind of changeParse v2XY fieldsahead / behindv2 --branchbranch.ab headerexit codes are the most stable interface Git has

Step 5 β€” Keep v1 for simple, legacy cases only Jump to heading

--porcelain without a version is v1, which matches git status --short without colour: two status characters, a space, the path. It is stable, but lacks branch tracking detail and submodule state. Use v2 for new scripts; v1 is fine where an existing script already depends on it and only needs paths and XY codes.

git status --porcelain=v1 -z | tr '\0' '\n' | head -3
#  M src/export/schedule.py
# M  src/export/api.py
# ?? notes/todo.md

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Is porcelain output affected by the user’s configuration? Jump to heading

Porcelain formats ignore colour and translation settings. Some options still matter β€” status.showUntrackedFiles=no hides untracked entries β€” so pass --untracked-files=all explicitly when your script needs them.

How do I detect submodule changes? Jump to heading

In v2, the third field of type-1 and 2 entries is a four-character submodule state: N... for a normal file, S followed by flags for submodules. Check it if your repository uses submodules.

Is git status slow on large repositories? Jump to heading

It can be, because it scans the working tree. For hooks on very large repositories, enable fsmonitor and the untracked cache, as described in using fsmonitor and the untracked cache.