From 5664930874e979aeab142de0a894f8892df6acf9 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Thu, 9 Apr 2026 07:14:48 -0500 Subject: [PATCH 1/2] fix: auto-stash creates empty stash objects from untracked noise (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 7 +++++++ plugin/taskrunner.sh | 23 +++++++++++++++++++---- taskrunner.sh | 31 +++++++++++++++++++++++++++---- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a65a741..f6216d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to cc-taskrunner will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/). +## [1.4.1] — 2026-04-09 + +### Fixed +- **Empty stash accumulation** (#19) — The auto-stash preflight in `taskrunner.sh` and `plugin/taskrunner.sh` used `[[ -n "$(git status --porcelain)" ]]` as the dirty-state check combined with `git stash push --include-untracked`. That triggered on any dirty state including untracked noise (charter telemetry, build artifacts), which created empty stash objects that piled up indefinitely. Downstream evidence: one operator found 10 accumulated stashes, 9 of which were empty and the 10th contained only 36 lines of telemetry noise. + + New logic only stashes when `git diff --quiet` or `git diff --cached --quiet` detect real tracked changes. The `--include-untracked` flag was removed. After the `stash push`, the stash is verified against its parent; empty stashes are dropped immediately as a belt-and-suspenders guard against races. + ## [1.4.0] — 2026-04-09 ### Added diff --git a/plugin/taskrunner.sh b/plugin/taskrunner.sh index 1c2ad2a..c3aa4da 100644 --- a/plugin/taskrunner.sh +++ b/plugin/taskrunner.sh @@ -315,10 +315,25 @@ execute_task() { use_branch=true branch="auto/${task_id:0:8}" - # Stash uncommitted changes to protect live work - 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 - log "│ Stashed uncommitted changes" + # Stash uncommitted TRACKED changes to protect live work. + # Only stashes real tracked diffs — untracked files alone are usually + # noise (telemetry, build artifacts) and produce empty stashes that + # accumulate indefinitely. See #19 for full bug report. + 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 + # Verify the stash captured content (drop if empty, race guard) + 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 # Start from main diff --git a/taskrunner.sh b/taskrunner.sh index 18fb9f5..5169b6d 100644 --- a/taskrunner.sh +++ b/taskrunner.sh @@ -386,10 +386,33 @@ execute_task() { use_branch=true branch="auto/${task_id:0:8}" - # Stash uncommitted changes to protect live work - 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 - log "│ Stashed uncommitted changes" + # Stash uncommitted TRACKED changes to protect live work. + # + # Only stash on real tracked diffs — untracked files alone are + # usually noise (charter telemetry, build artifacts, IDE lockfiles) + # and wrapping them in --include-untracked produces empty stash + # objects that pile up indefinitely. See #19 for the full bug + # report and accumulation evidence. + # + # Uses `git diff --quiet` and `git diff --cached --quiet` which + # only detect tracked changes. After the push, we verify the stash + # actually captured content by diffing against its parent; empty + # stashes are dropped immediately as a belt-and-suspenders guard. + 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 + # Verify the stash actually captured content (protect against races) + 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 # Start from main From b6a432a96ce0b00a3e3834c9e906d4953a4b4109 Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Thu, 9 Apr 2026 07:28:40 -0500 Subject: [PATCH 2/2] docs(README): clarify Branch Isolation stash behavior after #19 fix 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) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1640a99..5fb6d9a 100644 --- a/README.md +++ b/README.md @@ -205,7 +205,9 @@ main ───────────────────────── └── auto/i9j0k1l2 ── commit ── commit ── PR → (task 3) ``` -Before creating a branch, cc-taskrunner stashes any uncommitted work in the repo. After the task completes (or fails), it returns to main and restores the stash. Your in-progress work is never clobbered. +Before creating a branch, cc-taskrunner checks for **real tracked changes** via `git diff --quiet` and `git diff --cached --quiet`. If either shows uncommitted work, it stashes those tracked changes (staged or unstaged) and proceeds. Untracked files are left alone — they're usually build artifacts, telemetry, or IDE lockfiles that would create empty stash objects if captured. After the task completes (or fails), the runner returns to main and restores the stash. Your in-progress work is never clobbered. + +The runner also verifies each stash actually captured content (by diffing against its parent) and drops any empty stash immediately. This prevents `git stash list` from accumulating noise over hundreds of autonomous runs. If a task produces no commits (e.g., a research/analysis task), the empty branch is cleaned up automatically.