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/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. 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