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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-29
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## Why

- After a Guardex PR merge, local base checkouts can remain behind `origin/<base>` and VS Code shows pending sync/pull state even though the branch was already pushed and merged.
- Guardex already refreshes clean local base worktrees, but a harmless dirty edit currently prevents the automatic `git pull --ff-only` and leaves the operator stuck doing manual cleanup.

## What Changes

- Let `agent-branch-finish.sh` attempt the post-merge base refresh even when the local base worktree has dirty edits.
- Keep the refresh guarded: use `git pull --ff-only` with Git autostash disabled, and treat failures as warnings so Git never stashes, rebases, or overwrites local work.
- Update focused finish-flow regression coverage for dirty/non-conflicting and dirty/conflicting base worktrees.

## Impact

- Affects `gx branch finish` and `gx finish` only after a successful merge path reaches the base-worktree refresh step.
- Dirty base worktrees may now fast-forward automatically when Git can preserve local edits. If Git detects an overwrite/conflict risk, Guardex leaves the worktree untouched and reports the failed refresh.
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
## ADDED Requirements

### Requirement: Guarded Post-Merge Base Pull

After `gx branch finish` successfully merges an agent branch and local base worktree refresh is enabled, Guardex SHALL attempt to fast-forward the local base worktree from `origin/<base>` using `git pull --ff-only` even when the base worktree has local modifications.

#### Scenario: Dirty base worktree can be fast-forwarded
- **GIVEN** a local base worktree has dirty edits that do not conflict with the merged remote changes
- **WHEN** `gx branch finish` completes a merge and refreshes the local base worktree
- **THEN** Guardex SHALL run the guarded fast-forward pull
- **AND** the base worktree SHALL contain the merged remote change
- **AND** the dirty local edits SHALL remain intact.

#### Scenario: Dirty base worktree cannot be safely fast-forwarded
- **GIVEN** a local base worktree has dirty edits that Git would overwrite during the post-merge pull
- **WHEN** `gx branch finish` attempts the guarded fast-forward pull
- **THEN** Guardex SHALL leave the base worktree dirty edits untouched
- **AND** Guardex SHALL emit a warning instead of stashing, rebasing, or forcing the update.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## 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-auto-pull-base-after-pr-merge-2026-06-29-23-02`; branch=`agent/codex/auto-pull-base-after-pr-merge-2026-06-29-23-02`; scope=`guarded post-merge base pull for dirty worktrees`; action=`continue this sandbox or finish cleanup after a usage-limit/manual takeover`.
- Copy prompt: Continue `agent-codex-auto-pull-base-after-pr-merge-2026-06-29-23-02` on branch `agent/codex/auto-pull-base-after-pr-merge-2026-06-29-23-02`. Work inside the existing sandbox, review `openspec/changes/agent-codex-auto-pull-base-after-pr-merge-2026-06-29-23-02/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/auto-pull-base-after-pr-merge-2026-06-29-23-02 --base main --via-pr --wait-for-merge --cleanup`.

## 1. Specification

- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-auto-pull-base-after-pr-merge-2026-06-29-23-02`.
- [x] 1.2 Define normative requirements in `specs/auto-pull-base-after-pr-merge/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.
- `node --test --test-reporter=spec --test-name-pattern "dirty local base worktree" test/finish.test.js`
- `bash -n templates/scripts/agent-branch-finish.sh`
- [x] 3.2 Run `openspec validate agent-codex-auto-pull-base-after-pr-merge-2026-06-29-23-02 --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/auto-pull-base-after-pr-merge-2026-06-29-23-02 --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).
11 changes: 5 additions & 6 deletions templates/scripts/agent-branch-finish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -643,17 +643,16 @@ is_clean_worktree() {

refresh_clean_base_worktree() {
local wt="$1"
local pull_output=""
[[ -z "$wt" || "$PUSH_ENABLED" -ne 1 ]] && return 0

if ! is_clean_worktree "$wt"; then
echo "[agent-branch-finish] Warning: local ${BASE_BRANCH} worktree is dirty; skipping 'git pull --ff-only origin ${BASE_BRANCH}' for ${wt}." >&2
return 0
fi

if GUARDEX_DISABLE_POST_MERGE_CLEANUP=1 GUARDEX_PRUNE_ACTIVE_CWD="$finish_active_cwd" git -C "$wt" pull --ff-only origin "$BASE_BRANCH" >/dev/null; then
if pull_output="$(GUARDEX_DISABLE_POST_MERGE_CLEANUP=1 GUARDEX_PRUNE_ACTIVE_CWD="$finish_active_cwd" git -C "$wt" -c rebase.autoStash=false -c merge.autostash=false pull --ff-only origin "$BASE_BRANCH" 2>&1)"; then
echo "[agent-branch-finish] Refreshed local ${BASE_BRANCH} worktree with 'git pull --ff-only origin ${BASE_BRANCH}': ${wt}"
else
echo "[agent-branch-finish] Warning: failed to refresh local ${BASE_BRANCH} worktree with 'git pull --ff-only origin ${BASE_BRANCH}': ${wt}" >&2
if [[ -n "$pull_output" ]]; then
echo "$pull_output" >&2
fi
fi
}

Expand Down
57 changes: 52 additions & 5 deletions test/finish.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1180,7 +1180,7 @@ exit 1
});


test('agent-branch-finish warns instead of pulling dirty local base worktree', () => {
test('agent-branch-finish guarded-pulls dirty local base worktree when Git can preserve edits', () => {
const repoDir = initRepo();
seedCommit(repoDir);
attachOriginRemote(repoDir);
Expand Down Expand Up @@ -1211,13 +1211,60 @@ test('agent-branch-finish warns instead of pulling dirty local base worktree', (
);
assert.equal(finish.status, 0, finish.stderr || finish.stdout);
assert.match(
finish.stderr,
/Warning: local dev worktree is dirty; skipping 'git pull --ff-only origin dev' for /,
finish.stdout,
/Refreshed local dev worktree with 'git pull --ff-only origin dev': /,
);
assert.equal(
fs.existsSync(path.join(auxWorktree, 'agent-dirty-base-refresh.txt')),
false,
'dirty local dev worktree should not be pulled implicitly',
true,
'dirty local dev worktree should still fast-forward when Git can preserve local edits',
);
assert.equal(
fs.readFileSync(path.join(auxWorktree, 'package.json'), 'utf8'),
'{"dirty":true}\n',
'guarded pull must preserve unrelated dirty local edits',
);
});


test('agent-branch-finish leaves dirty local base worktree untouched when guarded pull would overwrite', () => {
const repoDir = initRepo();
seedCommit(repoDir);
attachOriginRemote(repoDir);

let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);
result = runCmd('git', ['add', '.'], repoDir);
assert.equal(result.status, 0, result.stderr);
result = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, {
ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1',
});
assert.equal(result.status, 0, result.stderr);
result = runCmd('git', ['push', 'origin', 'dev'], repoDir);
assert.equal(result.status, 0, result.stderr);

result = runCmd('git', ['checkout', '-b', 'agent/test-dirty-base-refresh-conflict'], repoDir);
assert.equal(result.status, 0, result.stderr);
commitFile(repoDir, 'package.json', '{"from":"agent"}\n', 'agent conflicting package change');

const auxWorktree = path.join(path.dirname(repoDir), 'aux-dirty-conflict-dev');
result = runCmd('git', ['worktree', 'add', auxWorktree, 'dev'], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);
fs.writeFileSync(path.join(auxWorktree, 'package.json'), '{"dirty":true}\n', 'utf8');

const finish = runBranchFinish(
['--branch', 'agent/test-dirty-base-refresh-conflict', '--base', 'dev', '--direct-only', '--no-cleanup'],
repoDir,
);
assert.equal(finish.status, 0, finish.stderr || finish.stdout);
assert.match(
finish.stderr,
/Warning: failed to refresh local dev worktree with 'git pull --ff-only origin dev': /,
);
assert.equal(
fs.readFileSync(path.join(auxWorktree, 'package.json'), 'utf8'),
'{"dirty":true}\n',
'guarded pull must leave conflicting dirty local edits untouched',
);
});

Expand Down
Loading