Scripting Git with Plumbing Commands Jump to heading

Every hook, CI step and release script in this site ends up asking Git questions: which files changed, which branches exist, what a commit contains, whether a ref moved. The tempting way to answer is to run the command a human would type — git status, git branch, git log — and parse what it prints. That output is designed for people. It changes between Git versions, it is translated into the user’s language, it adds colour when a terminal is detected, it quotes unusual file names, and it reflows when the window is narrow. Scripts built on it work on the author’s machine and break somewhere else. Git provides a second layer for exactly this purpose: plumbing commands and porcelain formats with documented, stable, machine-readable output. This part of Git Automation & CI/CD Hook Engineering covers how to use that layer so automation keeps working across machines, versions and file names.

Prerequisites Jump to heading

Porcelain and Plumbing Jump to heading

Git’s commands fall into two groups. Porcelain commands — status, branch, log, checkout — are the interface for people. Plumbing commands — rev-parse, for-each-ref, cat-file, diff-tree, ls-files, update-ref — are the interface for programs, and their output formats are promised to stay stable. Several porcelain commands also offer a machine-readable mode, such as git status --porcelain=v2, which carries the same promise.

Porcelain output against plumbing outputPorcelain output is meant for people: it can change between versions, follows the user's language and colour settings, and quotes or truncates paths. Plumbing commands and machine formats are meant for scripts: stable across versions, unaffected by locale and colour, and able to emit NUL-separated paths that survive any file name.Porcelain (for people)Plumbing / --porcelain (for scripts)stability across versionsnot promisedpromisedlanguage and colourfollow user settingsfixedunusual pathsquoted or escaped-z: raw, NUL-separatedexamplesstatus, branch, logfor-each-ref, cat-file, --porcelain=v2if a script parses text meant for people, it has an expiry date

The confusing naming deserves a note: --porcelain on git status means “porcelain-stable output for scripts”, the opposite of what the word suggests elsewhere. Read it as “the format you can rely on”.

Step 1 — Ask for Exactly the Value You Need Jump to heading

Many scripts parse a whole command’s output to extract one value. Plumbing usually offers a direct question instead.

# Current branch name — not by parsing `git branch`
git symbolic-ref --quiet --short HEAD || echo "(detached)"

# Commit a ref points to, verified to exist
git rev-parse --verify --quiet "refs/heads/main^{commit}"

# Repository root, git directory and common directory (for worktrees)
git rev-parse --show-toplevel --git-dir --git-common-dir

# Is the working tree clean? Exit status, no parsing
git diff --quiet && git diff --cached --quiet && echo clean
# Verification: the same commands work in a worktree, a detached HEAD and a bare repository
git -C /path/to/bare.git rev-parse --is-bare-repository

rev-parse --verify --quiet is the safe way to test whether a ref exists: it prints nothing and exits non-zero when it does not, rather than printing an error message a script might mistake for output.

Step 2 — Use Machine Formats When You Must Read Lists Jump to heading

When a script needs a list — changed files, branches, objects — use the command that emits a defined format, with NUL separators where file names are involved.

# Changed files between two commits, NUL-separated, with status letters
git diff-tree -r -z --no-commit-id --name-status "$old" "$new"

# Working tree status in the stable v2 format
git status --porcelain=v2 -z --branch

# Every branch with its commit and upstream, in a format you choose
git for-each-ref --format='%(refname:short)%09%(objectname)%09%(upstream:short)' refs/heads/
Plumbing commands for common scripting questionsrev-parse answers questions about refs and the repository layout. for-each-ref lists refs with chosen fields. diff-tree and ls-files list paths. cat-file reads objects in batches. update-ref and commit-tree change history without touching the working tree.rev-parserefs, paths, layoutfor-each-reflist refs + fieldsdiff-tree / ls-filespaths, -z safecat-file --batchread many objectsupdate-ref / commit-treewrite without checkoutmost automation needs only these five

Parsing these formats is covered in detail in parsing git status porcelain output and iterating refs with for-each-ref.

Step 3 — Handle Every File Name Safely Jump to heading

File names can contain spaces, quotes, tabs, newlines and non-ASCII characters. By default, Git quotes unusual names in its output ("caf\303\251.txt"), which breaks naive scripts in a different way. The robust approach is -z on the Git side and NUL-aware reading on the shell side.

# Safe: NUL-separated from Git, NUL-separated to xargs
git ls-files -z -- '*.md' | xargs -0 -r markdownlint

# Safe in a loop (bash/zsh: read -d ''; POSIX sh: use xargs -0 or a helper)
git diff --name-only -z "$base" HEAD | xargs -0 -n1 sh -c 'echo "changed: $1"' _
# Verification: create a hostile name and confirm the script handles it
printf 'x' > 'a file
with newline.md' && git add -A
git ls-files -z -- '*.md' | xargs -0 -n1 printf '[%s]\n'
How a file name reaches your script intactIf output is newline-separated and unquoted, names containing newlines split into two entries. If output is quoted, the script must unquote C-style escapes. If output is NUL-separated with -z and read with xargs -0 or read -d, every name arrives exactly as stored.How does the script read paths?lines, unquotedBreaks on newlinesand on core.quotePathlines, quotedMust unescapefragile-z + xargs -0Exact namesalways correcta file named with a newline is rare — until a script deletes the wrong file because of one

Step 4 — Read Objects in Bulk Jump to heading

Scripts that run git show or git cat-file once per object start a process per object, which is slow on large histories. cat-file --batch and --batch-check read many objects through one process.

# Size and type of every object in a range, through one process
git rev-list --objects "$old..$new" |
  git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)'

# Contents of several blobs by path at a commit
printf '%s\n' "HEAD:package.json" "HEAD:go.mod" | git cat-file --batch

This is the technique behind the fast large-file check in rejecting large files before push.

Step 5 — Write History Without a Working Tree Jump to heading

Plumbing can create blobs, trees and commits and move refs directly, with no checkout. That is how server-side tools, bots and CI jobs make commits safely and quickly, and how they avoid touching a developer’s working tree.

blob=$(printf '1.4.1\n' | git hash-object -w --stdin)
tree=$(git ls-tree HEAD | awk -F'\t' -v b="$blob" '$2=="VERSION" { sub(/[0-9a-f]+$/, b, $1) } { print $1 "\t" $2 }' | git mktree)
commit=$(git commit-tree "$tree" -p HEAD -m "chore: bump VERSION to 1.4.1")
git update-ref refs/heads/main "$commit" "$(git rev-parse HEAD)"   # moves only if main is unchanged

The full technique, including updating nested paths, is in creating commits without a working tree.

Step 6 — Make Unavoidable Text Parsing Predictable Jump to heading

Sometimes a porcelain command has information no plumbing command offers in a convenient form — a human-readable log for a report, or git shortlog grouping. When you must parse text meant for people, pin every setting that can change it: language, colour, pager, date format and path quoting.

# A predictable environment for parsing porcelain output
export LC_ALL=C LANG=C GIT_PAGER=cat
git -c color.ui=false -c core.quotePath=false -c log.date=iso-strict \
    log --no-decorate --format='%H%x09%ad%x09%an%x09%s' "$range"

LC_ALL=C stops messages being translated; color.ui=false and GIT_PAGER=cat remove escape codes and pagers; core.quotePath=false stops non-ASCII paths being escaped; an explicit --format with tab separators turns log output into a table a script can split. With those pinned, even porcelain output becomes stable enough to rely on, though plumbing remains the better choice whenever it exists.

Settings that change porcelain outputThe user's language changes message text. Colour settings add escape codes. Pager settings can block a script waiting for input. Path quoting escapes non-ASCII names. Date settings change timestamp formats. Pinning all five makes porcelain output predictable when parsing it cannot be avoided.pin all of these before parsing text meant for peopleLC_ALL=Cno translated messagescolor.ui=falseno escape codesGIT_PAGER=catno pager waiting on inputcore.quotePath=falseraw non-ASCII pathsexplicit --format / --datefixed layout and timestampsa script that sets none of these works only on machines configured like its author's

Team Rollout Jump to heading

Moving existing automation onto plumbing is best done incrementally, starting with the scripts that run most often and fail most mysteriously.

Integration with Adjacent Workflows Jump to heading

Plumbing is the layer underneath most of the automation elsewhere on this site, so its boundaries are worth naming.

Configuration Reference Jump to heading

Command or optionWhat it gives a scriptUse instead of
git rev-parse --verify --quiet <ref>Object ID or non-zero exitParsing git log -1 or git show
git symbolic-ref --short HEADCurrent branch nameParsing git branch
git for-each-ref --format=…Refs with chosen fieldsParsing git branch -a or git tag
git status --porcelain=v2 -zStable, NUL-safe statusParsing git status
git diff-tree -r -z --name-statusChanged paths with statusParsing git show --stat
git cat-file --batch-checkMany object types and sizesOne git cat-file per object
git update-ref <ref> <new> <old>Atomic ref move with a checkgit reset / git checkout in scripts
-c color.ui=false, LC_ALL=CPredictable text when parsing is unavoidableHoping the user’s settings are default

Troubleshooting Jump to heading

SymptomLikely causeFix
Script works locally, fails on CIParsing porcelain affected by locale or versionSwitch to plumbing or --porcelain formats
File names with spaces or accents breakNewline-separated, quoted outputUse -z and xargs -0
Script slow on large reposOne Git process per objectUse cat-file --batch and rev-list --objects
Ref update lost a concurrent changeupdate-ref without old valueAlways pass the expected old value
Branch name empty in CIDetached HEAD checkoutUse GITHUB_HEAD_REF/CI_COMMIT_REF_NAME or symbolic-ref with a fallback
Output contains colour codescolor.ui=always in user configPass -c color.ui=false or use plumbing

Frequently Asked Questions Jump to heading

Is git log --format plumbing? Jump to heading

git log is porcelain, but its --format placeholders are documented and stable, which makes git log --format=… with explicit placeholders safe for scripts. Avoid parsing its default output, and pass --no-color and a fixed date format.

Do I need to worry about SHA-256 repositories? Jump to heading

Increasingly, yes. Never hard-code forty-character IDs or forty zeros. Derive the null ID with git hash-object --stdin </dev/null | tr '0-9a-f' '0' and match object IDs with [0-9a-f]{40,64}.

Should scripts use a library instead of the CLI? Jump to heading

Libraries such as libgit2 bindings or language-native Git implementations avoid process overhead and parsing entirely. For hooks and CI glue, the CLI with plumbing is usually simpler and always matches the installed Git’s behaviour exactly.

How do I test scripts against awkward repositories? Jump to heading

Keep a small fixture repository with hostile names, a detached HEAD, a worktree, submodules and an empty commit, and run every script against it in CI, as described in testing Git hooks before sharing them.

Which plumbing commands are worth learning first? Jump to heading

Five cover most automation: rev-parse for resolving refs and repository paths, for-each-ref for listing refs, diff-tree and ls-files for paths, cat-file --batch for reading objects, and update-ref for moving refs safely. Learn their --format placeholders and -z options and most parsing problems disappear.

What about Windows? Jump to heading

Plumbing output is the same on every platform, which is another reason to prefer it. Line-ending conversion and path separators still apply to working-tree files, so scripts that read file contents should use git cat-file or git show on blobs rather than reading checked-out files when exact bytes matter.