Skip to content

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

Merged
stackbilt-admin merged 2 commits intomainfrom
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 intomainfrom
fix/stash-empty-objects

Conversation

@stackbilt-admin
Copy link
Copy Markdown
Member

Summary

  • Fixes fix: 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; fi
if ! git diff --cached --quiet 2>/dev/null; then has_tracked_dirty=true; fi

if $has_tracked_dirty; then
  if git stash push -m "cc-taskrunner:${task_id:0:8}" 2>/dev/null; then
    if ! 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"
    fi
  fi
fi

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 main Apr 9, 2026
@stackbilt-admin stackbilt-admin deleted the fix/stash-empty-objects branch April 9, 2026 12:37
Sign up for free to 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