Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions templates/scripts/agent-branch-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -861,20 +861,44 @@ 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 <ref>`. 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
return 0
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
}

Expand Down
32 changes: 32 additions & 0 deletions test/branch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading