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.
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/ 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' 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.
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.
- Hooks — client-side hooks in local hook configuration with Husky and server-side ones in server-side hook enforcement should use plumbing for every question they ask of the repository.
- Pre-push validation — computing the pushed commits, in finding the new commits in a pre-push hook, is a pure plumbing task.
- Forge APIs — plumbing answers questions about the repository; the forge API answers questions about pull requests, users and settings. Scripts that need both are covered in forge API and webhook automation.
- Merge previews —
git merge-tree --write-treeis plumbing for merges; see previewing merges with git merge-tree.
Configuration Reference Jump to heading
| Command or option | What it gives a script | Use instead of |
|---|---|---|
git rev-parse --verify --quiet <ref> | Object ID or non-zero exit | Parsing git log -1 or git show |
git symbolic-ref --short HEAD | Current branch name | Parsing git branch |
git for-each-ref --format=… | Refs with chosen fields | Parsing git branch -a or git tag |
git status --porcelain=v2 -z | Stable, NUL-safe status | Parsing git status |
git diff-tree -r -z --name-status | Changed paths with status | Parsing git show --stat |
git cat-file --batch-check | Many object types and sizes | One git cat-file per object |
git update-ref <ref> <new> <old> | Atomic ref move with a check | git reset / git checkout in scripts |
-c color.ui=false, LC_ALL=C | Predictable text when parsing is unavoidable | Hoping the user’s settings are default |
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Script works locally, fails on CI | Parsing porcelain affected by locale or version | Switch to plumbing or --porcelain formats |
| File names with spaces or accents break | Newline-separated, quoted output | Use -z and xargs -0 |
| Script slow on large repos | One Git process per object | Use cat-file --batch and rev-list --objects |
| Ref update lost a concurrent change | update-ref without old value | Always pass the expected old value |
| Branch name empty in CI | Detached HEAD checkout | Use GITHUB_HEAD_REF/CI_COMMIT_REF_NAME or symbolic-ref with a fallback |
| Output contains colour codes | color.ui=always in user config | Pass -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.
Related Jump to heading
- Parsing git status Porcelain Output — reading working-tree state reliably with the v2 format.
- Iterating Refs with for-each-ref — listing and filtering branches and tags for cleanup and reports.
- Creating Commits Without a Working Tree — building commits from objects for bots and servers.
- Forge API & Webhook Automation — the forge-side counterpart to repository-side scripting.
- Server-Side Hook Enforcement — the hooks that benefit most from plumbing.