From b4dd8eb2832bd45591472fae6b5978edd09b4568 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 10 Jun 2026 14:32:33 +0200 Subject: [PATCH] fix(branch-start): archive un-appliable auto-transfer stash instead of leaking it When protected-branch changes are auto-transferred to a new agent worktree and both the worktree apply AND the fallback restore-apply fail, the stash was left in the stash list forever. Over many agent runs this accumulated (one repo hit 994 guardex-auto-transfer stashes), slowing every git status / prompt. Now a failed restore archives the stash as a permanent refs/stash-archive/ ref and drops the stash entry. Work stays fully recoverable (git stash apply ) but no longer piles up in the stash list. - add archive_auto_transfer_stash helper (ref points at the stash commit) - archive on the failed-restore path; message tells the user the recover cmd - add GUARDEX_TEST_FAIL_RESTORE_APPLY test hook to drive the path - test: RED before / GREEN after; failing set unchanged (baseline-red repo) Co-Authored-By: Claude Opus 4.8 (1M context) --- templates/scripts/agent-branch-start.sh | 30 ++++++++++++++++++++--- test/branch.test.js | 32 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/templates/scripts/agent-branch-start.sh b/templates/scripts/agent-branch-start.sh index b0e7198..02d1f01 100755 --- a/templates/scripts/agent-branch-start.sh +++ b/templates/scripts/agent-branch-start.sh @@ -861,6 +861,20 @@ auto_transfer_message="" auto_transfer_source_branch="" auto_transfer_completed=0 +# Preserve an un-appliable auto-transfer stash as a permanent ref instead of +# leaving it to pile up in the stash list forever. Stash entries are already +# commits, so we just point a ref at the same object and drop the stash entry; +# the work stays fully recoverable via `git stash apply `. Echoes the ref. +archive_auto_transfer_stash() { + local ref="$1" + local sha archive_ref + sha="$(git -C "$repo_root" rev-parse --verify "$ref" 2>/dev/null)" || return 1 + archive_ref="refs/stash-archive/${auto_transfer_message:-auto-transfer}-${sha:0:8}" + git -C "$repo_root" update-ref "$archive_ref" "$sha" 2>/dev/null || return 1 + git -C "$repo_root" stash drop "$ref" >/dev/null 2>&1 || true + printf '%s' "$archive_ref" +} + restore_auto_transfer_stash_on_failure() { local exit_code="${1:-0}" if [[ "$exit_code" -eq 0 ]] || [[ -z "$auto_transfer_stash_ref" ]] || [[ "$auto_transfer_completed" -eq 1 ]]; then @@ -868,13 +882,23 @@ restore_auto_transfer_stash_on_failure() { fi local transfer_label="${auto_transfer_source_branch:-$BASE_BRANCH}" - if git -C "$repo_root" stash apply "$auto_transfer_stash_ref" >/dev/null 2>&1; then + # GUARDEX_TEST_FAIL_RESTORE_APPLY forces the restore-apply to fail so the + # archive path can be exercised deterministically in tests. + if ! env_flag_truthy "${GUARDEX_TEST_FAIL_RESTORE_APPLY:-}" \ + && git -C "$repo_root" stash apply "$auto_transfer_stash_ref" >/dev/null 2>&1; then git -C "$repo_root" stash drop "$auto_transfer_stash_ref" >/dev/null 2>&1 || true auto_transfer_stash_ref="" echo "[agent-branch-start] Restored moved changes back to '${transfer_label}' after startup failure." >&2 else - echo "[agent-branch-start] Startup failed and auto-restore also failed." >&2 - echo "[agent-branch-start] Changes are preserved in ${auto_transfer_stash_ref} on ${transfer_label}." >&2 + local archived_ref + if archived_ref="$(archive_auto_transfer_stash "$auto_transfer_stash_ref")"; then + auto_transfer_stash_ref="" + echo "[agent-branch-start] Startup failed and auto-restore also failed." >&2 + echo "[agent-branch-start] Changes archived at ${archived_ref} on ${transfer_label} (recover with: git stash apply ${archived_ref})." >&2 + else + echo "[agent-branch-start] Startup failed and auto-restore also failed." >&2 + echo "[agent-branch-start] Changes are preserved in ${auto_transfer_stash_ref} on ${transfer_label}." >&2 + fi fi } diff --git a/test/branch.test.js b/test/branch.test.js index 72602b6..53a0bef 100644 --- a/test/branch.test.js +++ b/test/branch.test.js @@ -324,6 +324,38 @@ test('agent-branch-start restores protected-branch changes when startup fails af assert.doesNotMatch(stashList.stdout, /guardex-auto-transfer-/); }); +test('agent-branch-start archives the auto-transfer stash instead of leaking it when restore also fails', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + + // A dirty, tracked, non-excluded file so protected-branch auto-transfer fires. + fs.writeFileSync(path.join(repoDir, 'work.txt'), 'original\n', 'utf8'); + let result = runCmd('git', ['add', 'work.txt'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['commit', '-m', 'add work.txt'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + fs.writeFileSync(path.join(repoDir, 'work.txt'), 'local edit\n', 'utf8'); + + // Force capture-then-fail AND a failed restore-apply so the archive path runs. + const scriptPath = path.resolve(__dirname, '..', 'scripts', 'agent-branch-start.sh'); + result = runCmd('bash', [scriptPath, 'archive-leak-fix', 'bot'], repoDir, { + GUARDEX_TEST_FAIL_AFTER_AUTO_TRANSFER_STASH: '1', + GUARDEX_TEST_FAIL_RESTORE_APPLY: '1', + }); + assert.notEqual(result.status, 0, 'branch start should fail after the simulated error'); + + // The un-appliable stash must NOT pile up in the stash list... + const stashList = runCmd('git', ['stash', 'list'], repoDir); + assert.doesNotMatch(stashList.stdout, /guardex-auto-transfer-/, 'stash must not leak'); + + // ...it must be preserved as a permanent archive ref instead. + const archived = runCmd('git', ['for-each-ref', '--format=%(refname)', 'refs/stash-archive'], repoDir); + assert.match(archived.stdout, /refs\/stash-archive\/.*guardex-auto-transfer-/, 'work must be archived to a ref'); + assert.match(result.stderr, /Changes archived at refs\/stash-archive\//); +}); + test('installed agent-branch-start script survives auto-transfer stash lookup under pipefail', () => { const repoDir = initRepoOnBranch('main'); seedCommit(repoDir);