Creating commits without a working tree Jump to heading

Most automation that commits — a version bump, a regenerated file, a configuration change pushed by a bot — follows the human recipe: check out the branch, edit files, git add, git commit, push. That needs a working tree, takes time on large repositories, collides with anything else using the same checkout, and fails on bare repositories entirely. Git’s plumbing can create the same commit directly from objects: write the new file content as a blob, build a tree using a temporary index, create a commit pointing at that tree, and move the branch with a compare-and-swap so a concurrent update is never overwritten. Nothing is checked out at any point. This page builds that sequence, within scripting Git with plumbing commands.

When to use this approach Jump to heading

  • A bot or CI job changes a few files and commits, and checking out the repository is slow or impossible.
  • Automation runs on a bare repository, such as a server-side job or a mirror.
  • Several jobs may update the same branch, and you need updates to be atomic.
  • You want to understand what tools such as the forge’s commit API do underneath, as used in verified bot commits with a GitHub App identity.

Step 1 — Write the new file content as a blob Jump to heading

git hash-object -w stores content in the object database and prints its ID. The content can come from a file or from standard input.

git -C repo.git rev-parse --verify main        # works on bare repositories too
blob=$(printf '1.4.1\n' | git -C repo.git hash-object -w --stdin)
echo "$blob"
Five plumbing steps to a commitNew content is written as a blob. A temporary index is loaded from the parent commit's tree and the changed path is pointed at the new blob. write-tree turns the index into a tree, commit-tree creates a commit with that tree and the parent, and update-ref moves the branch only if it has not changed.hash-object -wblobtemp indexread-tree parentupdate-indexpath → blobwrite-treetreecommit-treecommitupdate-refnew + expected oldno checkout, no shared index, no race with other writers

Step 2 — Build the new tree with a temporary index Jump to heading

Editing trees by hand with mktree works for files at the top level but becomes fiddly for nested paths. A temporary index file handles any path: load the parent’s tree into it, point the changed paths at their new blobs, and write the result out as a tree. Using a separate index file keeps the repository’s real index untouched.

export GIT_DIR=repo.git
parent=$(git rev-parse --verify main)
tmp_index=$(mktemp); trap 'rm -f "$tmp_index"' EXIT

GIT_INDEX_FILE=$tmp_index git read-tree "$parent"
GIT_INDEX_FILE=$tmp_index git update-index --add --cacheinfo 100644,"$blob",VERSION
GIT_INDEX_FILE=$tmp_index git update-index --add --cacheinfo 100644,"$(git hash-object -w config/app.yml)",config/app.yml
tree=$(GIT_INDEX_FILE=$tmp_index git write-tree)

Removing a file is update-index --force-remove <path>; changing a file’s mode is the same --cacheinfo with 100755.

# Verification: the new tree differs from the parent's in exactly the intended paths
git diff-tree -r --name-status "$parent" "$tree"

Step 3 — Create the commit Jump to heading

git commit-tree creates a commit object from a tree, parents and a message. Author and committer come from environment variables or configuration, which matters for bots that need a recognisable identity.

commit=$(GIT_AUTHOR_NAME=release-bot GIT_AUTHOR_EMAIL=[email protected] \
         GIT_COMMITTER_NAME=release-bot GIT_COMMITTER_EMAIL=[email protected] \
         git commit-tree "$tree" -p "$parent" -m "chore(release): bump VERSION to 1.4.1")
git log -1 --format='%h %an %s' "$commit"

commit-tree accepts -S to sign the commit with the configured signing key, exactly like git commit -S. Signing bot commits is covered in signing bot commits made by CI workflows.

Step 4 — Move the branch atomically Jump to heading

git update-ref with three arguments — ref, new value, expected old value — updates the ref only if it still has the expected value. If another job moved the branch since you read it, the update fails instead of silently discarding that job’s commit.

if git update-ref -m "release-bot: bump VERSION" refs/heads/main "$commit" "$parent"; then
  echo "main updated to $(git rev-parse --short main)"
else
  echo "main moved since $parent — rebuild on the new tip and retry"
fi
Two writers, one branch, no lost updateJob A and job B both read main at P. Job A builds commit A on P and updates main from P to A, which succeeds. Job B builds commit B on P and tries to update main from P to B, which fails because main is now A. Job B rebuilds on A and retries successfully.job Ajob Brefs/heads/mainP → A (expect P)P → B (expect P)refused: main is AA → B' (expect A)without the expected old value, job B would have erased job A's commit

For multiple refs at once — a branch and a tag together — git update-ref --stdin with start, update and commit lines applies all updates or none.

git update-ref --stdin <<EOF
start
update refs/heads/main $commit $parent
create refs/tags/v1.4.1 $commit
commit
EOF
Checkout-based commits against plumbing commitsA checkout-based bot needs a working tree, touches the shared index, can collide with other jobs using the same checkout, and fails on bare repositories. A plumbing-based bot needs only the object database, uses a private temporary index, and moves the branch with a compare-and-swap.checkout + git commitplumbingneeds a working treeyesnoworks on bare reposnoyesconcurrent writersrace on pushupdate-ref refusesruns commit hooksyesno — run checks yourselfthe trade-off is hooks: plumbing skips them, so the job must run its own checks

Step 5 — Push, or let the server see it directly Jump to heading

On a clone, push the result as usual; on the server’s own bare repository, the ref update is already the change. Pushing from a clone keeps the same safety with --force-with-lease-style expectations built in: the push fails if the remote moved.

git push origin "$commit:refs/heads/main"      # fast-forward only; rejected if the remote moved

Note that ref updates made directly in a server’s bare repository bypass pre-receive hooks, which only run for pushes. Use that only for trusted server-side jobs, and push through the normal path for anything that should be checked.

⚠️ SAFETY WARNING: update-ref without the expected old value, or with a wrong one supplied carelessly, can move a branch backwards and orphan commits. Always pass the value you built on. If a branch was moved incorrectly, its reflog (when enabled — core.logAllRefUpdates) records the previous value: git reflog show main, then git update-ref refs/heads/main <previous> <current>.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Is this faster than checking out? Jump to heading

For small changes on large repositories, dramatically: no working tree is written. On a small repository the difference is negligible, and the main benefit is safety with concurrent writers and bare repositories.

Can I build a merge commit this way? Jump to heading

Yes. Compute the merged tree with git merge-tree --write-tree, then pass both parents to commit-tree with two -p options. That is how a preview merge is built in previewing merges with git merge-tree.

What about hooks like pre-commit? Jump to heading

Plumbing commands do not run commit hooks. If the change must pass the same checks as human commits, run the checks explicitly in the job before creating the commit, or push it through a pull request.