DEV Community

Rulestack
Rulestack

Posted on

A Claude Code subagent wrote 25 posts into our working tree. The parent's git rebase --skip erased all 25

On 2026-09-15 a Claude Code subagent appended 25 post drafts to a tracked file in our working tree while the parent session was resolving a rebase conflict in the same tree. The parent ran git rebase --skip. All 25 rows were gone; only the 26 untracked media files the subagent had rendered survived. We got the rows back from a copy the subagent had left outside the repo, and the restore commit shows +37 -12 on that one file.

This is a failure story about two things sharing one working tree: a Claude Code subagent and its parent. Neither did anything unusual. The subagent edited a file and reported success. The parent finished a rebase the way the git hint suggests. The combination deleted work that had never been committed, and git could not bring it back, because the content had never been turned into an object.

Below is the timeline reconstructed from git history and our run log, a five-minute reproduction you can run in a throwaway repository, what the git manual says and does not say about --skip, and a guard script you can run instead of a bare git pull --rebase. Claude Code v2.1.278 and git 2.50.1 (Apple Git-155) were used for everything measured here.

Five minutes to see it yourself

You do not need Claude Code for this part. You need a conflict in progress and a second party that edits a tracked file while the conflict is open. The "subagent" below is three echo lines.

export LC_ALL=C
ROOT=$(mktemp -d); cd "$ROOT"
git init -q --bare origin.git
git clone -q origin.git local && cd local && git checkout -q -b main
printf 'line1\n' > README.md
echo '{"id":"post-1","text":"existing row"}' > stock.jsonl
git add . && git commit -q -m base && git push -q -u origin main

# a remote session changes README.md
cd "$ROOT" && git clone -q origin.git other && cd other
printf 'line1 changed by remote\n' > README.md
git commit -q -am "remote: change README" && git push -q origin main

# the parent commits a conflicting change, then syncs
cd "$ROOT/local"
printf 'line1 changed locally\n' > README.md
git commit -q -am "local: change README"
git pull --rebase origin main        # -> CONFLICT (content): Merge conflict in README.md

# "the subagent" works while the conflict is open
for i in 2 3 4; do echo "{\"id\":\"post-$i\",\"text\":\"written by subagent\"}" >> stock.jsonl; done
echo png-bytes > media-2026-09-15.png

git status --short; wc -l stock.jsonl
git rebase --skip
git status --short; wc -l stock.jsonl; git stash list
Enter fullscreen mode Exit fullscreen mode

The two status listings are the whole story:

UU README.md
 M stock.jsonl
?? media-2026-09-15.png
       4 stock.jsonl
Successfully rebased and updated refs/heads/main.
?? media-2026-09-15.png
       1 stock.jsonl
Enter fullscreen mode Exit fullscreen mode

git status --short before and after git rebase --skip: the modified tracked file disappears, the untracked file stays

The UU conflict is resolved by dropping the local commit, which is what --skip is for. The M stock.jsonl line, the subagent's work, is gone. The ?? file is still there. git stash list is empty, so nothing was parked anywhere. git fsck --lost-found afterwards lists one dangling commit and one dangling tree; both belong to the skipped local commit. The three appended rows never reached the index, so no blob was ever written for them. There is nothing in the object database to recover.

git rebase --abort behaves the same way in this respect. I ran it on an identical setup and the file went back to one line. The only exit that left the tree alone was git rebase --quit, which the manual documents as leaving "the index and working tree ... unchanged". After --quit the four rows and the conflict markers were both still there, but HEAD was detached on the upstream commit, so it is an emergency exit, not a fix.

What the manual says about --skip

From git rebase --help on git 2.50.1:

--skip
Restart the rebasing process by skipping the current patch.

That is the complete entry. It does not say the working tree is reset. The behaviour above is what "restart the rebasing process" means in practice: the merge backend checks out the state it wants to continue from, and anything in a tracked file that is not part of that state is overwritten. Compare the entry for --quit, which does spell out what happens to the tree:

--quit
Abort the rebase operation but HEAD is not reset back to the original branch. The index and working tree are also left unchanged as a result.

And the entry for --autostash:

--autostash, --no-autostash
Automatically create a temporary stash entry before the operation begins, and apply it after the operation ends. This means that you can run rebase on a dirty worktree. However, use with care: the final stash application after a successful rebase might result in non-trivial conflicts.

I confirmed the official page at https://git-scm.com/docs/git-rebase (fetched 2026-09-22) carries the same three sentences. The important word in the autostash entry is "before". I tested both orderings. When the subagent's edit existed before git pull --rebase started and rebase.autoStash=true was set, git printed Created autostash: d1d81e3, the conflict opened with stock.jsonl clean, and after --skip it printed Applied autostash. and the four rows were back. When the edit was made after the conflict was already open, autostash had nothing to hold, and --skip removed the rows exactly as without it. Our incident was the second case: the subagent wrote during the conflict window.

A guard to run instead of a bare pull --rebase

Twenty lines. It treats every uncommitted change to a tracked file that you did not name on the command line as someone else's work, stashes only those paths, rebases, and restores them on success. On a conflict it stops and tells you the one thing that matters: the stash is the only copy, so --skip and reset --hard are off the table.

#!/usr/bin/env bash
# pre-rebase-guard.sh [path-you-own ...] — run INSTEAD of a bare `git pull --rebase`.
# Any uncommitted change to a tracked file that you did not name is treated as someone
# else's work: it is stashed before the rebase and restored after a clean rebase.
set -eu
others=()
for p in $(git status --short --untracked-files=no | awk '{print $NF}'); do
  keep=0; for m in "${@:-}"; do [ "$p" = "$m" ] && keep=1; done
  [ $keep -eq 0 ] && others+=("$p")
done
if [ ${#others[@]} -gt 0 ]; then
  echo "guard: stashing changes that are not yours -> ${others[*]}"
  git stash push -q -- "${others[@]}"
fi
if git pull --rebase origin main; then
  [ ${#others[@]} -gt 0 ] && git stash pop -q && echo "guard: restored ${others[*]}"
else
  echo "guard: conflict. Resolve, 'git add <path>', 'git rebase --continue', then 'git stash pop'."
  echo "guard: do NOT run 'git rebase --skip' or 'git reset --hard' — the stash is your only copy."; exit 1
fi
Enter fullscreen mode Exit fullscreen mode

Two runs in throwaway repositories. On a rebase that applies cleanly (the remote changed a different line), the output was guard: stashing changes that are not yours -> stock.jsonl, Successfully rebased and updated refs/heads/main., guard: restored stock.jsonl, and git status --short afterwards showed M stock.jsonl with four lines in the file and an empty stash list. On a conflicting rebase the guard exited 1 with stash@{0}: WIP on main holding the file; I resolved README.md, ran git add README.md, git rebase --continue, git stash pop, and the file had its four lines back with all three commits in the log.

One thing I learned while testing that our own written rule had wrong: you cannot stash the other party's path while a conflict is unmerged. git stash push -- stock.jsonl during the open conflict fails with error: could not write index and README.md: needs merge. It works once the resolution is staged with git add README.md, and it works before the rebase starts, which is why the guard stashes first. If you discover the foreign change only after the conflict is open and do not want to resolve yet, git diff -- <path> > backup.patch before --skip and git apply backup.patch after also worked in my test, and the patch was nine lines.

What actually happened on 2026-09-15

Times are JST. The reconstruction uses git log --stat, git show of the restore commit, and our run log for that day, which records each command's timestamp and one-line summary.

At 09:24 our content-horizon check reported the post queue was 25 slots short of the horizon it is required to cover. The parent session started a remote sync, hit a rebase conflict in a documentation file, and spent 09:26 to 10:02 resolving it by hand. In parallel it had launched a subagent to refill the queue. The subagent worked in the same checkout, because that is where a subagent starts. The official subagent page says so directly (fetched 2026-09-22): "A subagent starts in the main conversation's current working directory." At 09:37 the run log shows the subagent rendering its media (ten PNG images and two MP4 clips with captions), at 09:38:23 it re-sequenced the queue to 38 rows, and at 09:38:24 the horizon check went to "ok, 0 short". The subagent reported success and exited. Its 25 rows were sitting in a tracked JSONL file, uncommitted, and its 26 media files were sitting next to them, untracked.

At 10:02 the parent ran git rebase --skip to drop the local commit that had caused the conflict. The run log has no entry for it, because it was a raw git command, not one of our instrumented commands. At 10:35 the next horizon check reported "25 slots short" again. That is the first evidence anyone had. Nothing had errored. The subagent's final message still said the queue was full.

The recovery worked for one reason: the subagent had also written its 25 rows as a JSON input file into a scratch directory outside the repository, because that is the shape our append command takes. The parent re-ran the same append from that copy, re-sequenced the queue (now 37 rows, since an automated post had consumed one in between), and committed at 10:39. The restore commit's numstat for the queue file is 37 insertions, 12 deletions: 25 rows added back, and 12 existing rows rewritten because their scheduled slots were renumbered. The 26 media files went into the same commit as brand-new binaries, which is the tell that they had never been at risk: git had no record of them to reset to.

diffstat of the queue file in the restore commit: 37 added, 12 removed

Total damage: about 33 minutes of not knowing, and zero lost content. Had nobody noticed, the 10 rows left in the queue would have run dry after two days at five automated posts a day. If the subagent had written directly into the queue file without leaving the scratch copy, the 25 rows would have had to be written again.

The rule that came out of it

The rule lives in a rules file that Claude Code loads whenever a session touches source, tests, scripts or workflows. In substance:

While a subagent or another session is working in the same working tree, do not run git rebase --skip, git reset --hard, or git checkout -- <path>. When resolving a conflict by hand, first run git status --short and confirm there is no uncommitted diff other than your own. If there is, stash the other party's paths with git stash push -- <path>, resolve, and git stash pop afterwards. Instruct subagents that when they work in the main tree they must also leave their output in a scratch directory as the input file for the command that produced it, so that there is always one recovery path.

The rule text records the incident inline: the date, the 25 rows, the fact that only untracked media survived, and that the restore came from the scratch copy. That is deliberate. A rule that says "never use --skip" reads as superstition six months later; a rule that says why reads as a decision.

Given what I found while testing, we added the timing to the rule: the stash has to happen before the rebase starts or after the resolution is staged, not while the conflict is open. The guard script above is one mechanised form of that corrected rule. We have not adopted the script ourselves: our sync command runs git pull --rebase --autostash and aborts the rebase on a conflict, and the written rule covers the manual resolution that follows.

Why a subagent makes this worse than two humans

Two humans in one checkout is rare and both of them know it. A subagent in one checkout is the default, and the parent that launched it is the same process that is about to run git. The parent's attention is on the conflict. The subagent's completion message arrives as a tool result, easy to read as "done and safe" when it means "done and uncommitted in your tree".

The documented alternative is worktree isolation. The same page says: "To give the subagent an isolated copy of the repository instead, set isolation: worktree." and "A subagent with isolation: worktree runs its Bash and PowerShell commands inside its worktree." We had not set it for this subagent because its job was to append to a file that the parent's own commit gate then validates; a separate worktree would have meant a merge step for a 25-line append. That trade-off still holds for us. What changed is a written rule: the parent may not run destructive git while anyone else's edit is in the tree, and the check for "anyone else's edit" is a status listing, not memory.

If you run subagents that write into your checkout, the three questions worth answering before your next git pull --rebase are: does git status --short --untracked-files=no show a path you did not touch; does the party that wrote it have a second copy anywhere; and does your resolution habit end in --continue or in --skip. The reproduction above takes about five minutes and settles the third one for good.


Rulestack sells git-safety rules, hooks and subagent conventions for Claude Code at rulestack.gumroad.com. The rule quoted in this article is in the rules file our own sessions load, where it has been in force since the day we lost the 25 rows; the 20-line guard is a mechanised version we tested for this article and have not adopted.

Questions about running subagents against a shared working tree, or a rebase story of your own, are welcome on @ai-shop.bsky.social.

Top comments (0)