Skip to content

fix: auto-stash creates empty stash objects from untracked noise (#19) - #21

Merged
stackbilt-admin merged 2 commits into
mainfrom
fix/stash-empty-objects
Apr 9, 2026
Merged

fix: auto-stash creates empty stash objects from untracked noise (#19)#21
stackbilt-admin merged 2 commits into
mainfrom
fix/stash-empty-objects

Conversation

@stackbilt-admin

Copy link
Copy Markdown
Member

Summary

  • Fixesfix: auto-stash creates empty stash objects from untracked noise files, no cleanup #19 — the auto-stash preflight was creating empty stash objects from untracked-only dirty state, with no cleanup, causing stashes to accumulate indefinitely across autonomous runs.
  • Only stashes on real tracked changes now, detected via git diff --quiet and git diff --cached --quiet.
  • Dropped --include-untracked from the stash push — untracked files are left alone (the worktree cleanup path handles them separately).
  • Added an empty-stash guard: post-push, verify the stash tree differs from its parent, drop immediately if not.
  • Applied symmetrically to taskrunner.sh and plugin/taskrunner.sh.

Root cause

# Old:if [[ -n"$(git status --porcelain 2>/dev/null)" ]];then
git stash push -m "cc-taskrunner:${task_id:0:8}" --include-untracked 2>/dev/null && stashed=true
fi

status --porcelain includes untracked files. Running in a repo that has charter CLI writing telemetry to .charter/telemetry/events.ndjson (or any equivalent untracked noise) triggers a stash on every task run. Combined with --include-untracked, the stash object is created with a tree that ends up matching HEAD — empty diff, but still a real stash entry.

Without a cleanup path, stashes pile up forever.

Real-world evidence

From a production fork after routine operation:

$ git stash list
stash@{0}: On main: cc-taskrunner: auto-stash before task 16c2374a-...
stash@{1}: On main: cc-taskrunner: auto-stash before task 3a6c2503-...
stash@{2}: On main: cc-taskrunner: auto-stash before task 45149a3f-...
... (10 entries total)
$ git stash show stash@{0} --include-untracked --stat
web/.charter/telemetry/events.ndjson | 36 ++++++++++++++++++++++++++++++++++++
1 file changed, 36 insertions(+)
$ git stash show stash@{1} --include-untracked --stat
(empty)
$ # ...same for stash@{2} through stash@{9}

The fix

# New:local has_tracked_dirty=false
if! git diff --quiet 2>/dev/null;then has_tracked_dirty=true;fiif! git diff --cached --quiet 2>/dev/null;then has_tracked_dirty=true;fiif$has_tracked_dirty;thenif git stash push -m "cc-taskrunner:${task_id:0:8}"2>/dev/null;thenif! git diff stash@{0}^1 stash@{0} --quiet 2>/dev/null;then
stashed=true
log "│ Stashed uncommitted tracked changes"else
git stash drop stash@{0} 2>/dev/null ||true
log "│ Stash was empty after push — dropped"fififi

Test plan

Verified with a 5-scenario synthetic test in a throwaway repo:

  • Clean tree → skipped
  • Untracked noise only → skipped (previously: empty stash)
  • Tracked file modified → stashed, real content
  • Stash pop → recovers content correctly
  • Staged changes only → stashed, real content

All scenarios pass. Bash syntax verified via bash -n on both files.

Related

  • Fork downstream has already landed this fix and a separate .gitignore cleanup for **/.charter/telemetry/ (which is what exposed the bug — .charter/telemetry/ at the root .gitignore doesn't match nested paths in monorepos).
  • Follow-up feat: blast radius preflight gate via charter blast #20 proposes a blast-radius preflight gate that builds on top of this cleaned-up preflight pipeline.

🤖 Generated with Claude Code

The dirty-state preflight used:
if [[ -n "$(git status --porcelain)" ]]; then
git stash push --include-untracked ...
fi
Two problems compounded:
1. `status --porcelain` includes untracked files. Autonomous runners
often operate in repos with untracked noise (charter telemetry at
`.charter/telemetry/events.ndjson`, build artifacts, IDE lockfiles).
The condition treated ANY of that as grounds to stash.
2. `git stash push --include-untracked` with only noise files produced
empty-diff stash objects. There was no post-push verification, so
empty stashes accumulated silently across every task run.
Downstream evidence from a production fork: 10 auto-stashes piled up,
9 with empty diffs and the 10th containing only 36 lines of telemetry.
None contained real work.
Fix:
- Check for real tracked changes via `git diff --quiet` and
`git diff --cached --quiet` — these only detect staged/unstaged
modifications to tracked files, skipping untracked entirely.
- Dropped `--include-untracked` from the stash push. If untracked
files need to survive, the worktree cleanup handles them separately.
- Added an empty-stash guard: after push, verify the stash tree
differs from its parent. If not, drop the stash immediately.
Applied to both taskrunner.sh and plugin/taskrunner.sh.
Verified via synthetic 5-scenario test in the AEGIS fork:
clean tree → skipped ✓
untracked noise only → skipped ✓ (previously: empty stash)
tracked file modified → stashed ✓
stash pop recovery → works ✓
staged changes only → stashed ✓
Closes#19.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Updates the Branch Isolation section to accurately reflect the new
stash logic:
- Only stashes on real tracked changes (staged or unstaged)
- Leaves untracked files alone (no more --include-untracked)
- Verifies each stash captured content; drops empty stashes immediately
The old description implied "any uncommitted work" got stashed, which
was both inaccurate (the bug stashed untracked noise as empty objects)
and misleading (readers couldn't tell why `git stash list` was piling up).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
stackbilt-admin pushed a commit to Stackbilt-dev/charter that referenced this pull request Apr 9, 2026
Both packages are now powering downstream integrations in cc-taskrunner
(Stackbilt-dev/cc-taskrunner#21, #22). Added "Downstream integrations"
sections to both READMEs pointing at the taskrunner as a real-world
example of wiring these primitives into a governance workflow.
blast:
- Documents cc-taskrunner 1.5.0's 4-level severity ladder with gate
behavior table
- Explains the auto_safe downgrade on critical severity
- Points at compute_blast_radius() in taskrunner.sh
surface:
- Documents cc-taskrunner 1.4.0's mission brief fingerprint injection
- Mentions the 80-line cap and graceful no-op behavior
- Points at build_fingerprint() in taskrunner.sh
Both sections help readers understand how these zero-dep analysis
packages compose with real autonomous-agent workflows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@stackbilt-admin
stackbilt-admin merged commit 87c02f0 into mainApr 9, 2026
@stackbilt-admin
stackbilt-admin deleted the fix/stash-empty-objects branch April 9, 2026 12:37
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: auto-stash creates empty stash objects from untracked noise files, no cleanup

1 participant

@stackbilt-admin