One morning I checked the log of an overnight scheduled job. The agent reported that it had committed and pushed the latest data files. Exit code zero, not a single error anywhere. Then I opened the repository on GitHub — the newest commit was from the day before.
Nothing had been pushed. Nothing had even been committed. And no line of the log admitted it.
In several of my projects as an indie developer, scheduled AI agents handle routine data updates to git repositories. This was the first failure mode in that setup that genuinely cost me a full day, precisely because it looks like success from every angle the agent checks. A job that crashes loudly is a far kinder problem than a job that smiles and does nothing.
The cause turns out to be very basic git behavior. It just happens to hide perfectly when a few automation-specific conditions line up.
The symptom: clean logs, unchanged remote
Three facts, side by side:
- The agent's run finished with exit code 0
git push origin mainprintedEverything up-to-date- The remote main branch had no new commit
Everything up-to-date reads like a success message. What it actually means is "there was nothing to push" — and that distinction is where the whole problem hides.
Note that this is a different family of trouble from authentication failures. If your agent gets Permission denied, the credentials are the issue, and I have written up that diagnosis separately in Why Antigravity Agents Hit Permission denied on git push, and How to Fix It for Good. Here we are dealing with a push that fails while saying nothing at all.
How to reproduce it
The problem appears when three conditions line up:
- You work in a disposable VM or container right after
git clone, with no global.gitconfig - The script runs without
set -e, or the commit line is guarded by something like|| true - Nobody watches the log in real time
A freshly cloned environment has no user.name or user.email. When git commit runs there, git refuses with the familiar "Please tell me who you are" message.
In an interactive terminal you notice immediately. In an unattended run, the message scrolls past, the script keeps going, and there is now nothing to push. git push then treats the no-op as a perfectly normal outcome and exits with code 0.
If you have only ever automated commits through CI systems such as GitHub Actions, you may never have seen this: most CI templates and prebuilt actions configure an identity for you. A bare container driven by an agent has no such convenience layer. What you script is exactly what runs.
The cause: a silent commit failure meets a forgiving push
Two pieces of standard git behavior combine here.
First, git rejects commits without an identity. Reasonable on its own.
Second, git push does not treat "nothing to push" as an error. Everything up-to-date is a report that there was no work to do, and its exit code is 0.
So the chain is: the commit fails quietly, the push succeeds vacuously, and the agent reports success. Every command behaved exactly as documented. Nothing is broken anywhere — which is exactly why it is so hard to catch. If the agent's definition of success is "the commands returned zero," this chain looks flawless.
Fix 1: set the identity, then verify by SHA instead of exit code
The immediate fix is to set the identity right after cloning, locally to the repository.
cd /path/to/repo
git config user.email "bot@example.com"
git config user.name "Update Bot"For one-shot commands you can pass it inline with -c instead.
git -c user.email="bot@example.com" -c user.name="Update Bot" \
commit -m "Update data files"The deeper fix is changing what "success" means. I stopped trusting exit codes and started comparing commit hashes after every push.
#!/bin/bash
set -euo pipefail
cd /path/to/repo
git config user.email "bot@example.com"
git config user.name "Update Bot"
git add data/
git commit -m "Update data files"
git push origin main
# After pushing, compare the local and remote tips
LOCAL_SHA=$(git rev-parse HEAD)
REMOTE_SHA=$(git ls-remote origin refs/heads/main | cut -f1)
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
echo "ERROR: local=$LOCAL_SHA remote=$REMOTE_SHA - not reflected" >&2
exit 1
fi
echo "OK: $LOCAL_SHA is now on the remote"With set -euo pipefail at the top, the script dies the moment the commit fails. With the SHA comparison, even an unexpected no-op push gets caught.
Judge the run by observable state, not by whether commands went through. For agent-driven work, that mental shift mattered more to me than any single line of code.
Fix 2: when index.lock blocks you, commit through the REST API
Even with the identity configured, some sandboxed environments left a stale .git/index.lock behind, and parallel processes kept colliding with it. Retries did not help.
The lock file can be removed safely once you confirm no other git process is running.
# Remove the lock only after confirming no other git process exists
ps aux | grep -v grep | grep "git " || rm -f .git/index.lockPatching the lock every time felt like bailing water, though. I eventually moved these jobs to the GitHub REST API, which builds the commit server-side — blob, tree, commit, then a ref update — and never touches the local index. That sidesteps both the identity requirement and the lock. For a single-file update, the whole flow looks like this.
#!/bin/bash
set -euo pipefail
OWNER="your-name"; REPO="your-repo"; BRANCH="main"
TOKEN="YOUR_GITHUB_TOKEN"
API="https://api.github.com/repos/$OWNER/$REPO"
AUTH="Authorization: Bearer $TOKEN"
# 1) Get the current tip commit and its tree
HEAD_SHA=$(curl -s -H "$AUTH" "$API/git/ref/heads/$BRANCH" | jq -r '.object.sha')
TREE_SHA=$(curl -s -H "$AUTH" "$API/git/commits/$HEAD_SHA" | jq -r '.tree.sha')
# 2) Create a blob, a tree, and a commit with the new content
BLOB_SHA=$(jq -n --rawfile c data.json '{content:$c, encoding:"utf-8"}' \
| curl -s -X POST -H "$AUTH" -d @- "$API/git/blobs" | jq -r '.sha')
NEW_TREE=$(jq -n --arg base "$TREE_SHA" --arg b "$BLOB_SHA" \
'{base_tree:$base, tree:[{path:"data.json", mode:"100644", type:"blob", sha:$b}]}' \
| curl -s -X POST -H "$AUTH" -d @- "$API/git/trees" | jq -r '.sha')
NEW_COMMIT=$(jq -n --arg t "$NEW_TREE" --arg p "$HEAD_SHA" --arg m "Update data.json" \
'{message:$m, tree:$t, parents:[$p]}' \
| curl -s -X POST -H "$AUTH" -d @- "$API/git/commits" | jq -r '.sha')
# 3) Advance the branch tip to the new commit
curl -s -X PATCH -H "$AUTH" -d "{\"sha\":\"$NEW_COMMIT\"}" \
"$API/git/refs/heads/$BRANCH" | jq -r '.object.sha'The commit author is recorded from the token's account, so no user.name is needed. And because the "push" is just the final ref update, failures became far easier to isolate.
Prevention: build distrust into the workflow up front
Three habits now live in every automated job I run.
- Template the identity setup. The first lines of every runbook I hand to an agent configure
user.emailanduser.name, every single time. - Make SHA comparison the only success signal. Exit codes and self-reported "pushed successfully" messages do not count as evidence.
- Start every script with
set -euo pipefail. It turns silent drift into a loud, immediate stop.
An agent's report becomes trustworthy only once it is checked against observable facts. Keeping the convenience of automation while adding one layer of doubt turned out to be the real fix.
For the underlying behavior, the git commit documentation, the git push documentation, and the GitHub REST API Git database reference are worth a look.
If you automate git with agents, start by adding those few lines of SHA verification to one script today. I hope this saves someone else the day it cost me.