diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 331f550..d53cc1f 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -895,6 +895,33 @@ function isSpawnFailure(result) { return Boolean(result?.error) && typeof result?.status !== 'number'; } +function ensureRepoBranch(repoRoot, branch) { + const current = currentBranchName(repoRoot); + if (current === branch) { + return { ok: true, changed: false }; + } + + const checkoutResult = run('git', ['-C', repoRoot, 'checkout', branch], { timeout: 20_000 }); + if (isSpawnFailure(checkoutResult)) { + return { + ok: false, + changed: false, + stdout: checkoutResult.stdout || '', + stderr: checkoutResult.stderr || '', + }; + } + if (checkoutResult.status !== 0) { + return { + ok: false, + changed: false, + stdout: checkoutResult.stdout || '', + stderr: checkoutResult.stderr || '', + }; + } + + return { ok: true, changed: true }; +} + function doctorSandboxBranchPrefix() { const now = new Date(); const stamp = [ @@ -996,12 +1023,26 @@ function startDoctorSandbox(blocked) { throw startResult.error; } if (startResult.status !== 0) { - throw new Error((startResult.stderr || startResult.stdout || 'failed to start doctor sandbox').trim()); + return startDoctorSandboxFallback(blocked); } const metadata = extractAgentBranchStartMetadata(startResult.stdout); - if (!metadata.worktreePath) { - throw new Error(`Failed to parse sandbox worktree from agent-branch-start output:\n${startResult.stdout}`); + const currentBranch = currentBranchName(blocked.repoRoot); + const worktreePath = metadata.worktreePath ? path.resolve(metadata.worktreePath) : ''; + const repoRootPath = path.resolve(blocked.repoRoot); + const hasSafeWorktree = Boolean(worktreePath) && worktreePath !== repoRootPath; + const branchChanged = Boolean(currentBranch) && currentBranch !== blocked.branch; + + if (!hasSafeWorktree || branchChanged) { + const restoreResult = ensureRepoBranch(blocked.repoRoot, blocked.branch); + if (!restoreResult.ok) { + const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim(); + throw new Error( + `doctor sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` + + (detail ? `\n${detail}` : ''), + ); + } + return startDoctorSandboxFallback(blocked); } return { diff --git a/test/install.test.js b/test/install.test.js index 3e6e05d..409b861 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -434,6 +434,54 @@ test('doctor on protected main auto-runs in a sandbox branch/worktree', () => { assert.equal(currentBranch.stdout.trim(), 'main'); }); +test('doctor keeps protected base checkout on main even if local starter script switches branches in-place', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + attachOriginRemoteForBranch(repoDir, 'main'); + + 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.stdout); + result = runCmd('git', ['push', 'origin', 'main'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const legacyStartScript = path.join(repoDir, 'scripts', 'agent-branch-start.sh'); + fs.writeFileSync( + legacyStartScript, + '#!/usr/bin/env bash\n' + + 'set -euo pipefail\n' + + 'branch_name="agent/legacy/doctor-in-place"\n' + + 'git checkout -B "$branch_name"\n' + + 'echo "[agent-branch-start] Created in-place branch: ${branch_name}"\n', + 'utf8', + ); + fs.chmodSync(legacyStartScript, 0o755); + + result = runCmd('git', ['add', '-f', 'scripts/agent-branch-start.sh'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['commit', '-m', 'simulate legacy in-place starter'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['push', 'origin', 'main'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runNode(['doctor', '--target', repoDir], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /doctor detected protected branch 'main'/); + assert.match(extractCreatedBranch(result.stdout), /^agent\/gx\/.+-gx-doctor$/); + + const currentBranch = runCmd('git', ['branch', '--show-current'], repoDir); + assert.equal(currentBranch.status, 0, currentBranch.stderr || currentBranch.stdout); + assert.equal(currentBranch.stdout.trim(), 'main'); +}); + test('doctor on protected main syncs repaired stale lock state back to base workspace', () => { const repoDir = initRepoOnBranch('main'); seedCommit(repoDir);