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" 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 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 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-refwithout 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, thengit 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.
Related Jump to heading
- Scripting Git with Plumbing Commands — the parent topic.
- Iterating Refs with for-each-ref — reading refs before you update them.
- Post-Receive Hooks for Notifications and Deploys — server-side jobs that may write commits.
- Recovering Unreachable Objects with git fsck — if a ref update orphaned commits.