From e65b437bb6139232485b229de76ae71ab2f8f7fc Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 24 Jun 2026 14:50:20 +0200 Subject: [PATCH] Prevent dirty primary auto-revert Dirty primary checkout work belongs to the user or another lane, so the post-checkout guard must not stash it or switch branches again. Constraint: Keep clean-tree auto-revert behavior unchanged. Tested: bash -n templates/githooks/post-checkout Tested: node --check src/mcp/collect.js Tested: node --test --test-name-pattern "post-checkout guard skips auto-revert" test/branch.test.js Tested: openspec validate agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44 --type change --strict Tested: openspec validate --specs Scope-risk: Primary checkout stays on the accidental branch when dirty until manual recovery. --- .../.openspec.yaml | 2 ++ .../proposal.md | 24 +++++++++++++ .../primary-checkout-immutability/spec.md | 28 +++++++++++++++ .../tasks.md | 35 +++++++++++++++++++ src/mcp/collect.js | 2 +- templates/AGENTS.multiagent-safety.md | 4 +-- templates/githooks/post-checkout | 13 +++---- test/branch.test.js | 29 +++++++++++++++ 8 files changed, 124 insertions(+), 13 deletions(-) create mode 100644 openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/.openspec.yaml create mode 100644 openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/proposal.md create mode 100644 openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/specs/primary-checkout-immutability/spec.md create mode 100644 openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/tasks.md diff --git a/openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/.openspec.yaml b/openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/.openspec.yaml new file mode 100644 index 00000000..fab62b41 --- /dev/null +++ b/openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-24 diff --git a/openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/proposal.md b/openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/proposal.md new file mode 100644 index 00000000..dd944e95 --- /dev/null +++ b/openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/proposal.md @@ -0,0 +1,24 @@ +## Why + +- The primary-checkout post-checkout guard can currently auto-stash dirty work + as `guardex-auto-revert` and switch the visible checkout back to the protected + branch. In shared agent workflows that can move another agent's or the user's + uncommitted edits out from under them. + +## What Changes + +- Keep clean-primary auto-revert behavior for accidental branch switches during + agent sessions. +- For dirty primary checkouts, print the existing guard warning plus a manual + recovery hint, but do not run `git stash` and do not switch branches again. +- Update the agent-facing warning text and regression coverage for the dirty + primary checkout path. + +## Impact + +- Affected surfaces: `gx hook run post-checkout`, installed hook shims that + dispatch to it, MCP primary-checkout warnings, and the full AGENTS contract + template. +- Risk: a dirty primary checkout remains on the newly selected branch until the + user/agent recovers manually. This is intentional because preserving + uncommitted work is safer than moving it automatically. diff --git a/openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/specs/primary-checkout-immutability/spec.md b/openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/specs/primary-checkout-immutability/spec.md new file mode 100644 index 00000000..e2cb73bf --- /dev/null +++ b/openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/specs/primary-checkout-immutability/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Primary checkout cannot be silently switched during agent sessions +The `post-checkout` hook SHALL print a `[agent-primary-branch-guard]` warning +when the primary working tree (where `git-dir == git-common-dir`) is switched +AWAY from a protected branch (`main`, `dev`, `master`, or any branch listed +in `multiagent.protectedBranches`) during an agent session. Agent sessions +are detected via the presence of any of: `CLAUDECODE`, +`CLAUDE_CODE_SESSION_ID`, `CODEX_THREAD_ID`, `OMX_SESSION_ID`, or +`CODEX_CI=1`. If the working tree is clean, the hook SHALL auto-revert the +primary checkout to the previous protected branch. If the working tree is +dirty, the hook SHALL NOT stash changes and SHALL NOT switch branches again. + +#### Scenario: Agent session triggers auto-revert on clean tree +- **GIVEN** the primary checkout is on `main` and the tree is clean +- **AND** `CLAUDECODE=1` is exported +- **WHEN** the user or an agent runs `git checkout -b feature/x` +- **THEN** the `[agent-primary-branch-guard]` warning appears on stderr +- **AND** the primary checkout is returned to `main`. + +#### Scenario: Dirty tree skips auto-revert +- **GIVEN** the primary checkout is on `main` with uncommitted edits +- **AND** an agent session is detected +- **WHEN** `git checkout feature/x` runs +- **THEN** the hook prints a `Working tree dirty — auto-revert skipped` + message with a manual recovery hint +- **AND** the branch is NOT reverted so no uncommitted work is lost +- **AND** no `guardex-auto-revert` stash is created. diff --git a/openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/tasks.md b/openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/tasks.md new file mode 100644 index 00000000..a237181d --- /dev/null +++ b/openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/tasks.md @@ -0,0 +1,35 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +## Handoff + +- Handoff: change=`agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44`; branch=`agent/codex/prevent-dirty-primary-auto-revert-2026-06-24-14-44`; scope=`post-checkout dirty primary tree preservation`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`. +- Copy prompt: Continue `agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44` on branch `agent/codex/prevent-dirty-primary-auto-revert-2026-06-24-14-44`. Work inside the existing sandbox, review `openspec/changes/agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44/tasks.md`, continue from the current state instead of creating a new sandbox, and when the work is done run `gx branch finish --branch agent/codex/prevent-dirty-primary-auto-revert-2026-06-24-14-44 --base main --via-pr --wait-for-merge --cleanup`. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44`. +- [x] 1.2 Define normative requirements in `specs/primary-checkout-immutability/spec.md`. + +## 2. Implementation + +- [x] 2.1 Implement scoped behavior changes. +- [x] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. + Evidence: `bash -n templates/githooks/post-checkout`; `node --check src/mcp/collect.js`; `node --test --test-name-pattern "post-checkout guard skips auto-revert" test/branch.test.js`. +- [x] 3.2 Run `openspec validate agent-codex-prevent-dirty-primary-auto-revert-2026-06-24-14-44 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run the cleanup pipeline: `gx branch finish --branch agent/codex/prevent-dirty-primary-auto-revert-2026-06-24-14-44 --base main --via-pr --wait-for-merge --cleanup`. This handles commit -> push -> PR create -> merge wait -> worktree prune in one invocation. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/src/mcp/collect.js b/src/mcp/collect.js index 7ae50883..4db2336a 100644 --- a/src/mcp/collect.js +++ b/src/mcp/collect.js @@ -237,7 +237,7 @@ function buildAgentRecord(mainRoot, wt, locks, prInfo, nowMs) { && record.dirty.length === 0; if (wt.isPrimary) { record.warning = - 'on the PRIMARY checkout, not an isolated worktree — edits here risk auto-stash/revert when another lane switches branches. Use `gx branch start`.'; + 'on the PRIMARY checkout, not an isolated worktree — clean branch switches may be auto-reverted, while dirty work is left in place for manual recovery. Use `gx branch start`.'; } return record; } diff --git a/templates/AGENTS.multiagent-safety.md b/templates/AGENTS.multiagent-safety.md index 18cb8b26..8dd9dbf2 100644 --- a/templates/AGENTS.multiagent-safety.md +++ b/templates/AGENTS.multiagent-safety.md @@ -50,9 +50,7 @@ If you are about to type `git checkout agent/...` from the primary checkout, **s ### Dirty-tree rule -Finish or stash edits inside the worktree they belong to before any branch switch on primary. The post-checkout guard may auto-stash a dirty primary tree as `guardex-auto-revert ->` — that is a safety net, not a workflow. - -Recover: `git stash list | grep 'guardex-auto-revert'`. +Finish or stash edits inside the worktree they belong to before any branch switch on primary. The post-checkout guard auto-reverts only a clean primary tree. If the primary tree is dirty, it leaves the branch and files in place and prints a manual recovery hint instead of stashing or reverting someone else's work. ### Ownership diff --git a/templates/githooks/post-checkout b/templates/githooks/post-checkout index 7364efb9..3a334fea 100755 --- a/templates/githooks/post-checkout +++ b/templates/githooks/post-checkout @@ -67,20 +67,15 @@ echo "[agent-primary-branch-guard] The primary working tree must stay on its bas echo "[agent-primary-branch-guard] Use 'git worktree add' (or gx branch start) for feature work." >&2 if [[ "$is_agent" == "1" ]]; then - echo "[agent-primary-branch-guard] Agent session detected — reverting to '$prev_branch'." >&2 + echo "[agent-primary-branch-guard] Agent session detected." >&2 echo "[agent-primary-branch-guard] Bypass with GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1 if truly intentional." >&2 if git diff --quiet && git diff --cached --quiet; then + echo "[agent-primary-branch-guard] Clean tree detected — reverting to '$prev_branch'." >&2 GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1 git checkout "$prev_branch" >/dev/null 2>&1 || true echo "[agent-primary-branch-guard] Reverted to '$prev_branch'." >&2 else - stash_msg="guardex-auto-revert $(date +%s) ${prev_branch}->${new_branch}" - if git stash push --include-untracked -m "$stash_msg" >/dev/null 2>&1; then - GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1 git checkout "$prev_branch" >/dev/null 2>&1 || true - echo "[agent-primary-branch-guard] Dirty tree auto-stashed as '$stash_msg'; primary reverted to '$prev_branch'." >&2 - echo "[agent-primary-branch-guard] Restore later with: git stash list | grep 'guardex-auto-revert'" >&2 - else - echo "[agent-primary-branch-guard] Auto-stash failed; working tree left on '$new_branch'. Fix manually: git stash -u && git checkout $prev_branch" >&2 - fi + echo "[agent-primary-branch-guard] Working tree dirty — auto-revert skipped to avoid moving uncommitted work." >&2 + echo "[agent-primary-branch-guard] Primary checkout left on '$new_branch'. Commit or stash the changes in the correct worktree, then recover manually with: GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1 git checkout '$prev_branch'" >&2 fi else echo "[agent-primary-branch-guard] Bypass with GUARDEX_ALLOW_PRIMARY_BRANCH_SWITCH=1 if intentional." >&2 diff --git a/test/branch.test.js b/test/branch.test.js index 53a0befe..190b6eb2 100644 --- a/test/branch.test.js +++ b/test/branch.test.js @@ -954,6 +954,35 @@ test('repo .env GUARDEX_ON=false disables bootstrap scripts and git hook enforce }); +test('post-checkout guard skips auto-revert and auto-stash when primary tree is dirty', () => { + const { repoDir } = createBootstrappedRepo({ branch: 'main', committed: true }); + + const packageJsonPath = path.join(repoDir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + packageJson.description = 'dirty primary edit'; + fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, 'utf8'); + fs.writeFileSync(path.join(repoDir, 'untracked-primary.txt'), 'untracked primary work\n', 'utf8'); + + const checkoutResult = runCmd('git', ['checkout', '-b', 'feature/dirty-primary'], repoDir, { + CODEX_THREAD_ID: 'test-thread', + }); + assert.equal(checkoutResult.status, 0, checkoutResult.stderr || checkoutResult.stdout); + assert.match(checkoutResult.stderr, /Working tree dirty .* auto-revert skipped/); + + const currentBranch = runCmd('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repoDir); + assert.equal(currentBranch.status, 0, currentBranch.stderr || currentBranch.stdout); + assert.equal(currentBranch.stdout.trim(), 'feature/dirty-primary'); + + const currentPackageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + assert.equal(currentPackageJson.description, 'dirty primary edit'); + assert.equal(fs.existsSync(path.join(repoDir, 'untracked-primary.txt')), true); + + const stashList = runCmd('git', ['stash', 'list'], repoDir); + assert.equal(stashList.status, 0, stashList.stderr || stashList.stdout); + assert.doesNotMatch(stashList.stdout, /guardex-auto-revert/); +}); + + test('post-merge auto-runs cleanup on base branch and skips non-base branches', () => { const repoDir = initRepo(); seedCommit(repoDir);