• Terms of Service

Learn Internet Grow

Tech Skills

  • Learn
  • Books
  • Search
  • Create
  • Measure
  • Secure
  • Share
  • Terms of Service

You Can Build git commit By Hand: A Tour of Git Plumbing Commands

Filed Under: Learn

git commit looks like one operation. It isn’t. Underneath the porcelain verb sits a short, fixed sequence of lower-level Git plumbing commands — hash-object, update-index, write-tree, commit-tree, update-ref — and every one of them is a real, independently documented subcommand you can run yourself, by hand, against a real repository. If you already know Git’s object model — four immutable object types, content-addressed by their own hash, sitting under a thin layer of mutable pointer files called refs — this is the payoff: watching that model actually assemble a commit, primitive by primitive, with nothing hidden inside a single opaque verb. Build it once by hand and git commit stops being a black box; it becomes five calls you could have written yourself, wired together in a specific order for a specific reason.

Git plumbing commands — five small sealed mechanisms assembled by hand into one completed, glowing commit object

What “Plumbing” Actually Means

Git, in its own maintainers’ words, “was initially a toolkit for a version control system rather than a full user-friendly VCS” — which is why it still ships two layers of command surface rather than one. The gitglossary states the split plainly: porcelain gives “high level access to core Git,” and plumbing simply is “core Git.” The roughly thirty commands most engineers run daily — add, commit, branch, merge, log — are porcelain: friendly, forgiving, opinionated about workflow. Underneath them sits plumbing: lower-level subcommands designed, in Pro Git’s own framing, “to be chained together UNIX-style or called from scripts.”

That’s not a documentation nicety. It means the layer porcelain is built from is itself a public, versioned, individually man-paged API — every plumbing command below has its own stability contract, independent of the friendly verb wrapped around it. The rest of this article uses exactly five of them, in the exact order git commit uses internally, against a real repository, so every hash and every byte count below is something you can reproduce and check yourself.

Setting the Scene: An Empty Object Store

Start with nothing — an empty repository, no commits, no branches. The very first plumbing command doesn’t even need a repository state to act on; it needs only bytes:

$ git init$ echo 'test content' | git hash-object -w --stdind670460b4b4aece5915caf5c68d12f560a9fe3e4

git hash-object computes the SHA-1 of a blob <size>\0 header concatenated with the piped bytes, and the -w flag writes the zlib-compressed result straight into .git/objects/. There is no repository-wide state to update yet — no index entry, no tree, no branch, nothing tracking that this blob has anything to do with a file named anything. It’s just one immutable, addressable object sitting in the store, findable at .git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 because the loose-object format spec derives the storage path directly from the hash — first two hex characters as a subdirectory, the rest as the filename. Nothing about this step required a working tree, a staged file, or even a repository that has ever had a commit.

git hash-object writing the first object into an otherwise empty Git object store

Registering Content: git update-index --add --cacheinfo

A lone blob isn’t yet part of anything Git will snapshot. The next step registers it into the index — the real, binary, on-disk structure gitformat-index describes as a DIRC-signed header plus sorted entries, not a metaphor for “things I plan to commit”:

$ echo 'version 1' > test.txt$ git hash-object -w test.txt83baae61804e65cc73a7201a7252750c76066a30 $ git update-index --add --cacheinfo 100644 \    83baae61804e65cc73a7201a7252750c76066a30 test.txt

git update-index takes a mode (100644, an ordinary non-executable file), a blob hash, and a path, and writes an entry recording all three — plus the stat(2) metadata gitformat-index specifies (size, timestamps, inode) that makes git status fast without rehashing every file on every check. This is the operation git add exists to make friendlier: add also runs hash-object for you and defaults to reading the file instead of taking a hash directly, but the entry it writes into .git/index is identical to the one update-index --add --cacheinfo writes by hand. Nothing has been snapshotted yet — the index is a staging structure, one step removed from a tree object, and it’s entirely possible to register five files this way and never run write-tree at all.

git update-index staging a blob into the index, a real ledger structure with per-entry metadata

Freezing the Index: git write-tree

The index becomes a real, immutable object only when something asks for it:

$ git write-treed8329fc1cc938780ffdd9f94e0d364e0ea74f579 $ git cat-file -p d8329fc1cc938780ffdd9f94e0d364e0ea74f579100644 blob 83baae61804e65cc73a7201a7252750c76066a30    test.txt

git write-tree serializes the current index into a tree object and returns its hash — a pure function of the index’s current content, so running it twice in a row without changing anything returns the identical hash both times. git cat-file -p pretty-prints the tree as the sorted <mode> <type> <sha> <name> listing gitformat-loose describes, but the object on disk is tighter than that: a single-entry tree here is exactly 36 bytes — 100644 test.txt as ASCII text, one NUL byte, then twenty raw binary bytes encoding the blob’s hash, not forty hex characters. cat-file -p is choosing to render those twenty bytes back out as readable hex for you; the object itself never stores hex at all.

git write-tree freezing the current index into a single sealed tree object

Wrapping History: git commit-tree

A tree is a snapshot with no history attached to it — no author, no message, no parent. git commit-tree is the single command that turns a bare snapshot into a point in history:

$ echo "first commit" | git commit-tree d8329fc1cc938780ffdd9f94e0d364e0ea74f57939ac35b390830d6c693bbff29e42cbaeaaf8d7c6 $ git cat-file -p 39ac35b390830d6c693bbff29e42cbaeaaf8d7c6tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579author Demo <demo@example.com> 1786418880 -0500committer Demo <demo@example.com> 1786418880 -0500 first commit

git commit-tree‘s own manual page is unusually candid about its place in the layer stack: “this is usually not what an end user wants to run directly.” It takes a tree hash, zero or more -p <parent> flags, and a message, and produces exactly the object format the gitglossary defines for a commit — nothing in it is a diff; it references one complete tree and, when there’s a parent, one complete prior commit hash. Here’s a detail worth sitting with: if you run every command up to this point yourself, your test.txt blob hash and your tree hash will come out identical to the ones printed above, because both are pure functions of content you typed verbatim. Your commit hash won’t match. A commit object also hashes an author/committer line carrying a wall-clock timestamp, so the same tree, the same message, and even the same words produce a different commit object depending on when you ran the command — a small, precise illustration of exactly which parts of Git’s object model are content-addressed and which aren’t.

git commit-tree wrapping a tree object into a commit object, linked backward to its parent by a single embedded hash

Making It Real: git update-ref

Every command so far has only ever created new, immutable objects. Nothing in the repository points at any of them yet — 39ac35b... exists in .git/objects/, reachable by nothing. The final step is the only one in the whole sequence that mutates anything:

$ git update-ref refs/heads/master 39ac35b390830d6c693bbff29e42cbaeaaf8d7c6 $ cat .git/refs/heads/master39ac35b390830d6c693bbff29e42cbaeaaf8d7c6 $ cat .git/HEADref: refs/heads/master

git update-ref writes a hash into a plain text file — atomically, and optionally conditionally, to guard against a concurrent update racing it — rather than creating a new kind of object. refs/heads/master is exactly 41 bytes on disk: 40 hex characters plus a trailing newline, per gitrepository-layout. HEAD itself, in the common case, is a symbolic ref — a file holding ref: refs/heads/master rather than a hash directly — which is why “which branch am I on” is itself just one more layer of indirection through a mutable pointer. Run git log --oneline right now and it shows exactly one commit, first commit. There is nothing about the porcelain view of this repository that reveals it was built one plumbing call at a time — which is the point this whole exercise is building toward.

git update-ref moving a lightweight branch pointer onto the newly created commit

A Second Commit: Building the DAG By Hand

One commit proves the object shapes are right. A second, parented commit proves the graph is real — that history is a chain of hashes embedded inside other hashes, not a separately maintained list:

$ echo 'version 2' > test.txt$ git hash-object -w test.txt1f7a7a472abf3dd9643fd615f6da379c4acb3e3a $ echo 'new file' > new.txt$ git hash-object -w new.txtfa49b077972391ad58037050f2a75f74e3671e92 $ git update-index --cacheinfo 100644 1f7a7a472abf3dd9643fd615f6da379c4acb3e3a test.txt$ git update-index --add --cacheinfo 100644 fa49b077972391ad58037050f2a75f74e3671e92 new.txt $ git write-tree0155eb4229851634a0f03eb265b69f5a2d56f341 $ echo "second commit" | git commit-tree 0155eb4229851634a0f03eb265b69f5a2d56f341 \    -p 39ac35b390830d6c693bbff29e42cbaeaaf8d7c658b4255086c61aaebbf0e6186fed91a0edc7712a $ git update-ref refs/heads/master 58b4255086c61aaebbf0e6186fed91a0edc7712a $ git log --oneline58b4255 second commit39ac35b first commit

Nothing about this round introduced a new kind of operation — it’s the same five commands, run again, with one addition: -p on commit-tree, embedding the first commit’s hash directly inside the second commit’s own object content. That single embedded hash is the edge in the graph. There is no separate adjacency list, no parent-child table maintained anywhere else — ancestry is encoded entirely inside the immutable objects themselves, which is exactly why a hash can never point at something not yet written, and why Git’s history is necessarily a directed acyclic graph rather than something that could loop back on itself. git log, git show, gitk, a GitHub commit graph, and every GUI client all read this same embedded-parent structure; none of them are consulting any state this walkthrough didn’t just create by hand with five plumbing calls.

Plumbing → Porcelain: What Composes What

Porcelain commandWhat it composes underneath
git add <file>hash-object -w the file’s content, then update-index --add --cacheinfo to stage it
git commitwrite-tree the current index, commit-tree that tree with HEAD as parent, then update-ref to move HEAD‘s branch to the new commit
git branch <name>update-ref refs/heads/<name> <current commit> — one ref write, nothing else
git tag <name>update-ref refs/tags/<name> <object> (lightweight); a new tag object plus that same update-ref for an annotated tag
git reset --soft <commit>update-ref alone — moves the branch pointer; index and working tree are left untouched
git merge --ff-onlyupdate-ref alone — no new commit object, because history hasn’t diverged
git show <commit>cat-file -p <commit>, plus a diff computed on the fly against the parent’s tree
git logrev-list, walking embedded parent hashes from a starting commit — no separate history index exists to query instead

Two rows are worth lingering on, because they’re easy to miss the first time: reset --soft and a fast-forward merge are both nothing but update-ref. Neither creates a single new object. “Rewriting history,” “fast-forwarding,” and “creating a branch” are three different-sounding porcelain verbs that all bottom out in the identical plumbing operation — move one 41-byte pointer.

Object Types and How You Inspect Each One

Object typePlumbing command that creates itHow you inspect itWhat cat-file -p renders
blobgit hash-object -wgit cat-file -p <sha>raw file bytes, verbatim
treegit write-treegit cat-file -p <sha>sorted <mode> <type> <sha> <name> listing
commitgit commit-treegit cat-file -p <sha>tree, parent (if any), author/committer, message
tag (annotated)git tag -a (or git mktag)git cat-file -p <sha>pointer to another object, message, optional PGP signature

git cat-file -t <sha> reports just the type — blob, tree, commit, or tag — for any hash in the store, and -p dispatches its pretty-print format based on that same type. One inspection command handles all four object shapes because, at the storage layer, they’re the same envelope: a header, a NUL byte, and content, zlib-compressed. The difference between a blob and a commit is only what’s written inside that envelope, never how it’s wrapped.

Why the Split Is Deliberate: Git as a Platform, Not Just a Tool

The payoff of decomposing git commit into five orthogonal calls isn’t that engineers run them by hand day to day — almost nobody does. It’s that other software can build on the storage layer directly, without forking Git or reverse-engineering an opaque binary format. Five independent from-scratch reimplementations exist specifically because that layer is public and documented: libgit2 states its own scope outright — “libgit2 does not aim to replace the git tool or its user-facing commands” — and exposes roughly 175 lower-level API calls instead. JGit is Gerrit Code Review’s actual Git engine. go-git names its own gap in its README: it “covers the majority of the plumbing read operations and some of the main write operations, but lacks the main porcelain operations such as merges.” gitoxide ships two separate binaries, gix for plumbing and ein for porcelain, as a direct acknowledgment that the two are different products built from the same core. isomorphic-git runs entirely inside a browser tab, operating on a real .git directory with no native Git process anywhere near it. None of the five claims full parity with canonical Git — and none of them needed to, because they were never reimplementing a CLI. They were reimplementing an object format.

Independent tools and reimplementations built directly on Git's shared plumbing primitives, not on its command-line output

The extension surface runs the same direction. A custom subcommand needs no plugin API at all — Git’s wrapper looks for an executable literally named git-<verb> on PATH or in its exec-path and runs it with the remaining arguments. That’s the entire mechanism behind git-lfs, git-annex, git-flow, and git subtree: third-party tools become first-class git <verb> commands without Git’s maintainers ever publishing, versioning, or breaking a plugin interface. Git LFS itself is fully explainable as a clean/smudge filter — a documented gitattributes extension point — plus an external HTTP object store keyed by a content hash; no Git-core code exists that knows LFS’s name. Compare that to the more common shape of a CLI-only tool: no separately documented, independently invokable primitives underneath the porcelain, so any third-party integration is left to shell out to that same human-facing binary and parse formatted text output, or reverse-engineer an on-disk format nobody committed to keeping stable. Git avoided that failure mode by publishing the primitive layer as its own product, with its own man pages and its own stability expectations, deliberately separate from the porcelain wrapped around it.

What Building It By Hand Actually Teaches You

Look at the five calls again, sorted by what they’re allowed to do. hash-object, write-tree, and commit-tree only ever create — every one of them produces a brand-new, immutable object and never touches an existing one. update-index mutates a real structure, but a disposable one: the index isn’t part of history, and blowing it away and rebuilding it from HEAD costs nothing durable. Exactly one command in the entire sequence, update-ref, is allowed to mutate something that matters — and what it mutates is a 41-byte text file, not a single byte of the history it names.

The transferable lesson from Git's plumbing layer — vast immutable content beside one small, freely movable mutable pointer

That’s the transferable lesson underneath the whole exercise, and it generalizes well past version control: separate immutable content, addressed by its own hash, from mutable naming, addressed by convention — and keep the mutable surface as small and as boring as you can make it. A branch pointer, a latest alias in an object store, a HEAD cursor in an event log, a “current config version” row in a database — every one of these is the same pattern Git’s refs/heads/master file is: a small, cheap, freely rewritable pointer sitting on top of data that never changes once written. The moment a system’s central operation feels monolithic, it’s worth asking whether it actually decomposes into “create some immutable state, then move one pointer” — and whether publishing that decomposition as its own documented layer is worth doing, even knowing most callers will never touch it directly. Git’s bet, made in its earliest design and never walked back since, is that it was.

The commands and byte counts in this walkthrough were run and verified against Git 2.51; every hash shown is real output from an actual .git/objects/ directory, not a transcription from a book. If you’re the kind of engineer this level of detail appeals to, the discipline is the same one that makes reading code a superpower worth practicing deliberately rather than an occasional chore — and the same instinct that makes a DAG worth understanding as graph theory applies directly to the parent-hash chain this article just built by hand. It’s also worth noticing how much this mirrors other systems built the same way: MCP exposes a small, documented protocol underneath the AI tools people actually use, for the identical reason Git exposes plumbing — so an entire ecosystem can build on primitives instead of screen-scraping a CLI. Test frameworks reach for the same trick internally, too: Playwright’s Page Object pattern is a porcelain layer over lower-level browser primitives, for the same composability reason. And if walking parent pointers by hand felt satisfying, working through a graph-cloning problem in code is the same DAG-traversal muscle, applied outside Git entirely.

📚 Further Reading

  • git-hash-object, git-update-index, git-write-tree, git-commit-tree, git-update-ref — the five command references this walkthrough runs directly.
  • gitformat-loose and gitformat-index — the on-disk object envelope and the index’s binary layout.
  • gitrepository-layout — refs as plain files, HEAD as a symbolic ref, the packed-refs fallback at scale.
  • gitglossary — formal definitions for plumbing, porcelain, object, and directed acyclic graph.
  • Pro Git — “Git Internals: Plumbing and Porcelain” — the project’s own account of why the split exists, free under CC BY-NC-SA 3.0.
  • gitattributes — the clean/smudge filter mechanism Git LFS is built entirely from.
  • Reading Code Is the New Superpower Skill for Engineers — the companion discipline to building things by hand.
  • Learning the Brilliance of Graph Theory — the DAG intuition this article’s second commit makes concrete.

❓ Frequently Asked Questions

What are Git’s plumbing commands?

The low-level subcommands Git’s own porcelain is built from — hash-object, update-index, write-tree, commit-tree, update-ref, cat-file, and others — each independently documented, each designed to be scripted or chained rather than run interactively. The gitglossary defines plumbing simply as “core Git,” with porcelain as the friendlier layer on top of it.

Can I really build a commit without running git commit?

Yes, and this article just did it twice. git hash-object -w writes a blob, git update-index --add --cacheinfo stages it, git write-tree snapshots the index into a tree object, git commit-tree wraps that tree (plus an optional parent) into a commit object, and git update-ref moves a branch to point at it. That five-call sequence is exactly what git commit executes internally — nothing about the resulting commit is distinguishable from one made the ordinary way.

What’s the difference between git add and git update-index?

git update-index is the plumbing primitive; git add is the porcelain wrapper around it. add computes the blob hash for you (calling hash-object internally) and reads the file directly, while update-index --add --cacheinfo takes a mode, hash, and path explicitly — useful when the blob already exists in the object store, or when a script is staging content that was never written to the working tree at all.

Is it safe to use plumbing commands like commit-tree in scripts?

Yes — that’s their intended purpose, and it’s why their own documentation frames them as script-facing rather than interactive. git commit-tree‘s manual page is explicit that “this is usually not what an end user wants to run directly,” which is a statement about ergonomics for a human at a terminal, not about safety. Tools like Git LFS, custom merge drivers, and every one of the independent Git reimplementations rely on exactly this plumbing layer being stable and scriptable.

Why does Git expose plumbing publicly instead of hiding it?

Because publishing the primitive layer, with its own documentation and stability expectations, is what let independent software be built directly on Git’s object model instead of on its CLI’s text output. Five separate from-scratch reimplementations (libgit2, JGit, go-git, gitoxide, isomorphic-git), the entire git-<verb>-on-PATH extension convention, and Git LFS’s clean/smudge filter mechanism all depend on plumbing being a real, documented product rather than an internal implementation detail.

Do the hashes in this walkthrough change if I run the commands myself?

Partially. Blob and tree hashes are pure functions of content — type the identical bytes into hash-object and write-tree and you’ll get the identical hashes shown in this article, because content addressing has no room for anything else to vary. Commit hashes won’t match, because a commit object also hashes an author/committer timestamp, and your wall-clock time isn’t the one used here.


📚 Recommended Resources

Disclosure: some links below are Amazon affiliate links. If you buy through them, I earn a small commission — at no extra cost to you — which helps keep this site running.

If watching a commit assemble itself from five plumbing calls made you want to go deeper — into the plumbing layer itself, into the reference-manual command coverage, or into the discipline of building small composable primitives generally — these are the books for it.

  1. Pro Git (2nd ed.) — Scott Chacon & Ben Straub · Book. Also free in full at git-scm.com/book under CC BY-NC-SA 3.0 — read it there first. Chapter 10, “Git Internals,” is the source of the classic worked plumbing example this article’s walkthrough is built in the same spirit as; the print edition is a convenient reference if you’d rather have it off-screen.
  1. Version Control with Git (3rd ed.) — Prem Kumar Ponuthorai & Jon Loeliger · Book. The deepest print, reference-manual-style treatment of Git’s full command surface, plumbing included — the book to reach for when you need systematic coverage rather than a single worked example.
  1. Software Engineering at Google — Titus Winters, Tom Manshreck & Hyrum Wright · Book. Also free in full as HTML at abseil.io/resources/swe-book under CC BY-NC-ND 4.0 — read it there first. Not about Git specifically, but the clearest published case for exactly the architectural instinct this article closes on: build small, well-documented, independently callable primitives, and let higher-level tooling compose them rather than reimplementing them.

For the plumbing layer taken to its logical extreme — actually rebuilding Git’s internals from scratch, not just calling them — James Coglan’s Building Git walks through the object model, the index, diffing, and merging in Ruby, chapter by chapter. No Amazon listing exists for it; it’s sold ebook-only, direct from the author. If that’s the angle you want, buy it directly from shop.jcoglan.com/building-git — a non-Amazon purchase, no affiliate link here.

Newsletter

Tags

Agentic AI Agentic Development AI AI Agents AI Coding Agents Artificial intelligence Backlinks Blockchain Bounded Context Browser ChatGPT Plugins Claude Code CLPR Context Engineering Cross-Chain Cryptocurrency Developer Tools Distributed Systems DLT Domain-Driven Design Email Google Search HBAR Hedera Hedera Hashgraph Internet LLM Machine Learning MCP Microservices Model Context Protocol News NLP Orchestration Photoshop Productivity Saga Pattern SEO Smart Contracts Software Architecture software engineering Twitter Typescript Web3 Writing

Recent Articles

  • QUIC and HTTP/3: The Protocol That Won Is Being Routed Around October 11, 2026
  • The Mark I Perceptron: The Machine That Learned to See October 11, 2026
  • GraphRAG vs Vector RAG: Knowledge Graphs for AI, Explained October 11, 2026
  • Cardano Tokenomics: How ADA’s Supply Actually Works October 10, 2026
  • Transformer Encoder vs Decoder: How to Read the Diagram October 10, 2026
  • Nobody Checks the Verified Badge: The Real State of Git Commit Signing October 10, 2026

Copyright © 2026 · News Pro Theme On Genesis Framework · WordPress · Log in

Content on this site is produced with the assistance of Large Language Models and edited by a human. It is intended for educational purposes — facts may occasionally be incorrect. If you spot an error, please email Larionovdmitri@learninternetgrow.com.

Affiliate Disclosure: This site contains affiliate links to Amazon and other partners. If you purchase through these links, LearnInternetGrow.com may earn a commission at no additional cost to you. We only recommend products we genuinely believe in.

Privacy Policy · Terms of Service