diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index bf1c671..8d8d52b 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -280,6 +280,9 @@ const DEPRECATED_COMMAND_ALIASES = new Map([ const AGENT_BOT_DESCRIPTIONS = [ ['agents', 'Start/stop review + cleanup bots for this repo'], ]; +const DOCTOR_AUTO_FINISH_DETAIL_LIMIT = 6; +const DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX = 72; +const DOCTOR_AUTO_FINISH_MESSAGE_MAX = 160; function envFlagIsTruthy(raw) { const lowered = String(raw || '').trim().toLowerCase(); @@ -504,6 +507,113 @@ function run(cmd, args, options = {}) { }); } +function formatElapsedDuration(ms) { + const durationMs = Number.isFinite(ms) ? Math.max(0, ms) : 0; + if (durationMs < 1000) { + return `${Math.round(durationMs)}ms`; + } + if (durationMs < 10_000) { + return `${(durationMs / 1000).toFixed(1)}s`; + } + return `${Math.round(durationMs / 1000)}s`; +} + +function truncateMiddle(value, maxLength) { + const text = String(value || ''); + const limit = Number.isFinite(maxLength) ? Math.max(4, maxLength) : 0; + if (!limit || text.length <= limit) { + return text; + } + + const visible = limit - 1; + const headLength = Math.ceil(visible / 2); + const tailLength = Math.floor(visible / 2); + return `${text.slice(0, headLength)}…${text.slice(text.length - tailLength)}`; +} + +function truncateTail(value, maxLength) { + const text = String(value || ''); + const limit = Number.isFinite(maxLength) ? Math.max(4, maxLength) : 0; + if (!limit || text.length <= limit) { + return text; + } + return `${text.slice(0, limit - 1)}…`; +} + +function compactAutoFinishPathSegments(message) { + return String(message || '').replace(/\((\/[^)]+)\)/g, (_, rawPath) => { + if ( + rawPath.includes(`${path.sep}.omx${path.sep}agent-worktrees${path.sep}`) || + rawPath.includes(`${path.sep}.omc${path.sep}agent-worktrees${path.sep}`) + ) { + return `(${path.basename(rawPath)})`; + } + return `(${truncateMiddle(rawPath, 72)})`; + }); +} + +function summarizeAutoFinishDetail(detail) { + const trimmed = String(detail || '').trim(); + const match = trimmed.match(/^\[(\w+)\]\s+([^:]+):\s*(.*)$/); + if (!match) { + return truncateTail(compactAutoFinishPathSegments(trimmed), DOCTOR_AUTO_FINISH_MESSAGE_MAX); + } + + const [, status, rawBranch, rawMessage] = match; + const branch = truncateMiddle(rawBranch, DOCTOR_AUTO_FINISH_BRANCH_LABEL_MAX); + let message = String(rawMessage || '').trim(); + + if (status === 'fail') { + message = message.replace(/^auto-finish failed\.?\s*/i, ''); + if (/\[agent-sync-guard\]/.test(message) && /Resolve conflicts/i.test(message)) { + message = 'rebase conflict in finish flow; run rebase --continue or rebase --abort in the source-probe worktree'; + } else if (/unable to compute ahead\/behind/i.test(message)) { + const aheadBehindMatch = message.match(/unable to compute ahead\/behind(?: \([^)]+\))?/i); + if (aheadBehindMatch) { + message = aheadBehindMatch[0]; + } + } else if (/remote ref does not exist/i.test(message)) { + message = 'branch merged, but the remote ref was already removed during cleanup'; + } + } + + message = compactAutoFinishPathSegments(message) + .replace(/\s+\|\s+/g, '; ') + .trim(); + + return `[${status}] ${branch}: ${truncateTail(message, DOCTOR_AUTO_FINISH_MESSAGE_MAX)}`; +} + +function printAutoFinishSummary(summary, options = {}) { + const enabled = Boolean(summary && summary.enabled); + const details = Array.isArray(summary && summary.details) ? summary.details : []; + const baseBranch = String(options.baseBranch || summary?.baseBranch || '').trim(); + const verbose = Boolean(options.verbose); + const detailLimit = Number.isFinite(options.detailLimit) + ? Math.max(0, options.detailLimit) + : DOCTOR_AUTO_FINISH_DETAIL_LIMIT; + + if (enabled) { + console.log( + `[${TOOL_NAME}] Auto-finish sweep (base=${baseBranch}): attempted=${summary.attempted}, completed=${summary.completed}, skipped=${summary.skipped}, failed=${summary.failed}`, + ); + const visibleDetails = verbose ? details : details.slice(0, detailLimit).map(summarizeAutoFinishDetail); + for (const detail of visibleDetails) { + console.log(`[${TOOL_NAME}] ${detail}`); + } + if (!verbose && details.length > detailLimit) { + console.log( + `[${TOOL_NAME}] … ${details.length - detailLimit} more branch result(s). Re-run with --verbose-auto-finish for full details.`, + ); + } + return; + } + + if (details.length > 0) { + console.log(`[${TOOL_NAME}] ${verbose ? details[0] : summarizeAutoFinishDetail(details[0])}`); + } +} + function gitRun(repoRoot, args, { allowFailure = false } = {}) { const result = run('git', ['-C', repoRoot, ...args]); if (!allowFailure && result.status !== 0) { @@ -1121,7 +1231,7 @@ function parseSetupArgs(rawArgs, defaults) { } function parseDoctorArgs(rawArgs) { - return parseRepoTraversalArgs(rawArgs, { + const doctorDefaults = { target: process.cwd(), dropStaleLocks: true, skipAgents: false, @@ -1131,7 +1241,24 @@ function parseDoctorArgs(rawArgs) { json: false, allowProtectedBaseWrite: false, waitForMerge: true, - }); + verboseAutoFinish: false, + }; + const forwardedArgs = []; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--verbose-auto-finish') { + doctorDefaults.verboseAutoFinish = true; + continue; + } + if (arg === '--compact-auto-finish') { + doctorDefaults.verboseAutoFinish = false; + continue; + } + forwardedArgs.push(arg); + } + + return parseRepoTraversalArgs(forwardedArgs, doctorDefaults); } function normalizeWorkspacePath(relativePath) { @@ -1309,6 +1436,7 @@ function buildSandboxDoctorArgs(options, sandboxTarget) { if (options.skipGitignore) args.push('--no-gitignore'); if (!options.dropStaleLocks) args.push('--keep-stale-locks'); args.push(options.waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge'); + if (options.verboseAutoFinish) args.push('--verbose-auto-finish'); if (options.json) args.push('--json'); return args; } @@ -2207,6 +2335,7 @@ function runDoctorInSandbox(options, blocked) { postSandboxAutoFinishSummary = autoFinishReadyAgentBranches(blocked.repoRoot, { baseBranch: blocked.branch, dryRun: options.dryRun, + waitForMerge: options.waitForMerge, excludeBranches: [metadata.branch], }); } @@ -2307,16 +2436,10 @@ function runDoctorInSandbox(options, blocked) { console.log(`[${TOOL_NAME}] Auto-finish skipped: ${finishResult.note}.`); } - if (postSandboxAutoFinishSummary.enabled) { - console.log( - `[${TOOL_NAME}] Auto-finish sweep (base=${blocked.branch}): attempted=${postSandboxAutoFinishSummary.attempted}, completed=${postSandboxAutoFinishSummary.completed}, skipped=${postSandboxAutoFinishSummary.skipped}, failed=${postSandboxAutoFinishSummary.failed}`, - ); - for (const detail of postSandboxAutoFinishSummary.details) { - console.log(`[${TOOL_NAME}] ${detail}`); - } - } else if (postSandboxAutoFinishSummary.details.length > 0) { - console.log(`[${TOOL_NAME}] ${postSandboxAutoFinishSummary.details[0]}`); - } + printAutoFinishSummary(postSandboxAutoFinishSummary, { + baseBranch: blocked.branch, + verbose: options.verboseAutoFinish, + }); if (omxScaffoldSyncResult.status === 'synced') { console.log(`[${TOOL_NAME}] Synced .omx scaffold back to protected branch workspace.`); } else if (omxScaffoldSyncResult.status === 'unchanged') { @@ -2871,6 +2994,7 @@ function hasSignificantWorkingTreeChanges(worktreePath) { function autoFinishReadyAgentBranches(repoRoot, options = {}) { const baseBranch = String(options.baseBranch || '').trim(); const dryRun = Boolean(options.dryRun); + const waitForMerge = options.waitForMerge !== false; const excludedBranches = new Set( Array.isArray(options.excludeBranches) ? options.excludeBranches.map((branch) => String(branch || '').trim()).filter(Boolean) @@ -2989,7 +3113,7 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) { '--base', baseBranch, '--via-pr', - '--wait-for-merge', + waitForMerge ? '--wait-for-merge' : '--no-wait-for-merge', '--cleanup', ]; const finishResult = run('bash', finishArgs, { cwd: repoRoot }); @@ -5127,31 +5251,38 @@ function doctor(rawArgs) { const repoResults = []; let aggregateExitCode = 0; - for (const repoPath of discoveredRepos) { + for (let repoIndex = 0; repoIndex < discoveredRepos.length; repoIndex += 1) { + const repoPath = discoveredRepos[repoIndex]; + const progressLabel = `${repoIndex + 1}/${discoveredRepos.length}`; if (!options.json) { - console.log(`[${TOOL_NAME}] ── Doctor target: ${repoPath} ──`); + console.log(`[${TOOL_NAME}] ── Doctor target: ${repoPath} [${progressLabel}] ──`); } - const nestedResult = run( - process.execPath, - [ - path.resolve(__filename), - 'doctor', - '--single-repo', - '--target', - repoPath, - ...(options.dropStaleLocks ? [] : ['--keep-stale-locks']), - ...(options.skipAgents ? ['--skip-agents'] : []), - ...(options.skipPackageJson ? ['--skip-package-json'] : []), - ...(options.skipGitignore ? ['--no-gitignore'] : []), - ...(options.dryRun ? ['--dry-run'] : []), - // Recursive child doctor runs should report pending PR state immediately instead of blocking the parent loop. - '--no-wait-for-merge', - ...(options.json ? ['--json'] : []), - ...(options.allowProtectedBaseWrite ? ['--allow-protected-base-write'] : []), - ], - { cwd: topRepoRoot }, - ); + const childArgs = [ + path.resolve(__filename), + 'doctor', + '--single-repo', + '--target', + repoPath, + ...(options.dropStaleLocks ? [] : ['--keep-stale-locks']), + ...(options.skipAgents ? ['--skip-agents'] : []), + ...(options.skipPackageJson ? ['--skip-package-json'] : []), + ...(options.skipGitignore ? ['--no-gitignore'] : []), + ...(options.dryRun ? ['--dry-run'] : []), + // Recursive child doctor runs should report pending PR state immediately instead of blocking the parent loop. + '--no-wait-for-merge', + ...(options.verboseAutoFinish ? ['--verbose-auto-finish'] : []), + ...(options.json ? ['--json'] : []), + ...(options.allowProtectedBaseWrite ? ['--allow-protected-base-write'] : []), + ]; + const startedAt = Date.now(); + const nestedResult = options.json + ? run(process.execPath, childArgs, { cwd: topRepoRoot }) + : cp.spawnSync(process.execPath, childArgs, { + cwd: topRepoRoot, + encoding: 'utf8', + stdio: 'inherit', + }); if (isSpawnFailure(nestedResult)) { throw nestedResult.error; } @@ -5181,9 +5312,12 @@ function doctor(rawArgs) { }, ); } else { - if (nestedResult.stdout) process.stdout.write(nestedResult.stdout); - if (nestedResult.stderr) process.stderr.write(nestedResult.stderr); - process.stdout.write('\n'); + console.log( + `[${TOOL_NAME}] Doctor target complete: ${repoPath} [${progressLabel}] in ${formatElapsedDuration(Date.now() - startedAt)}.`, + ); + if (repoIndex < discoveredRepos.length - 1) { + process.stdout.write('\n'); + } } } @@ -5232,6 +5366,7 @@ function doctor(rawArgs) { : autoFinishReadyAgentBranches(scanResult.repoRoot, { baseBranch: currentBaseBranch, dryRun: singleRepoOptions.dryRun, + waitForMerge: singleRepoOptions.waitForMerge, }); const safe = scanResult.guardexEnabled === false || (scanResult.errors === 0 && scanResult.warnings === 0); const musafe = safe; @@ -5273,16 +5408,10 @@ function doctor(rawArgs) { setExitCodeFromScan(scanResult); return; } - if (autoFinishSummary.enabled) { - console.log( - `[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`, - ); - for (const detail of autoFinishSummary.details) { - console.log(`[${TOOL_NAME}] ${detail}`); - } - } else if (autoFinishSummary.details.length > 0) { - console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`); - } + printAutoFinishSummary(autoFinishSummary, { + baseBranch: currentBaseBranch, + verbose: singleRepoOptions.verboseAutoFinish, + }); if (safe) { console.log(`[${TOOL_NAME}] ✅ Repo is fully safe.`); } else { diff --git a/openspec/changes/agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15/.openspec.yaml b/openspec/changes/agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15/.openspec.yaml new file mode 100644 index 0000000..4b8c565 --- /dev/null +++ b/openspec/changes/agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-21 diff --git a/openspec/changes/agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15/proposal.md b/openspec/changes/agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15/proposal.md new file mode 100644 index 0000000..f3aa015 --- /dev/null +++ b/openspec/changes/agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15/proposal.md @@ -0,0 +1,17 @@ +## Why + +- `gx doctor` currently buffers nested child runs and then dumps a long wall of wrapped auto-finish output, which makes the command look frozen in large workspaces. +- Recursive doctor already forwards `--no-wait-for-merge` to child doctor runs, but the single-repo auto-finish sweep ignores that flag and can still block on merge waits. +- The default failure lines include full rebase commands and long worktree paths, which hide the actual branch state the user needs to act on. + +## What Changes + +- Stream recursive child doctor output live and annotate nested targets with lightweight progress and completion timing so long runs keep moving visibly. +- Thread the doctor `--no-wait-for-merge` flag into the auto-finish sweep so ready-branch cleanup does not stall recursive doctor runs. +- Compact auto-finish sweep detail lines by default while keeping `--verbose-auto-finish` as an opt-in escape hatch for the raw failure text. + +## Impact + +- Affects the maintainer/operator `gx doctor` UX, especially in repos with many nested git repos or many candidate agent branches. +- Keeps JSON output unchanged; only the human-readable doctor output and wait behavior change. +- Main risk: compacting failure text too aggressively could hide useful context, so verbose mode remains available and the default summary must keep the actionable reason. diff --git a/openspec/changes/agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15/specs/doctor-workflow/spec.md b/openspec/changes/agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15/specs/doctor-workflow/spec.md new file mode 100644 index 0000000..4d9a057 --- /dev/null +++ b/openspec/changes/agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15/specs/doctor-workflow/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: `gx doctor` keeps recursive progress visible +The human-readable `gx doctor` workflow SHALL keep progress visible while recursive child doctor runs execute, so large nested workspaces do not appear frozen. + +#### Scenario: nested doctor targets stream visible progress +- **GIVEN** `gx doctor` is running recursively across multiple git repos +- **WHEN** a nested repo doctor run starts and then completes +- **THEN** the CLI SHALL print a target line for that repo before the child run +- **AND** it SHALL print a completion line with the same target plus elapsed time after that repo finishes + +### Requirement: doctor sweep respects `--no-wait-for-merge` +The doctor auto-finish sweep SHALL honor the doctor wait mode when it delegates to `scripts/agent-branch-finish.sh`. + +#### Scenario: no-wait mode is forwarded into ready-branch cleanup +- **GIVEN** a ready local `agent/*` branch exists during `gx doctor --no-wait-for-merge` +- **WHEN** doctor invokes the auto-finish sweep for that branch +- **THEN** it SHALL call the finish script with `--no-wait-for-merge` +- **AND** it SHALL not silently fall back to `--wait-for-merge` + +### Requirement: doctor sweep output stays compact by default +The human-readable auto-finish sweep SHALL show concise actionable branch results by default and SHALL preserve the raw failure text behind an explicit verbose flag. + +#### Scenario: default doctor output summarizes a long finish failure +- **GIVEN** an auto-finish failure emits a long rebase-conflict command trace +- **WHEN** `gx doctor` runs without `--verbose-auto-finish` +- **THEN** the default branch detail line SHALL summarize the actionable reason instead of dumping the full `git -C ... rebase --continue` command + +#### Scenario: verbose doctor output keeps the raw finish failure text +- **GIVEN** the same auto-finish failure +- **WHEN** `gx doctor --verbose-auto-finish` runs +- **THEN** the printed branch detail SHALL include the original failure text diff --git a/openspec/changes/agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15/tasks.md b/openspec/changes/agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15/tasks.md new file mode 100644 index 0000000..514ad93 --- /dev/null +++ b/openspec/changes/agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15/tasks.md @@ -0,0 +1,24 @@ +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `improve-gx-doctor-output-and-speed`. +- [x] 1.2 Define normative requirements in `specs/doctor-workflow/spec.md`. + +## 2. Implementation + +- [x] 2.1 Make recursive `gx doctor` show visible per-target progress instead of buffering nested output until each repo finishes. +- [x] 2.2 Respect doctor `--no-wait-for-merge` inside the auto-finish sweep and keep the default sweep output compact. +- [x] 2.3 Add focused install-test coverage for progress lines, no-wait forwarding, and compact-versus-verbose auto-finish rendering. + +## 3. Verification + +- [x] 3.1 Run focused doctor/install verification (`node --test --test-name-pattern "doctor" test/install.test.js`, `node --check bin/multiagent-safety.js`). +- [x] 3.2 Run `openspec validate agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +Verification note: `node --check bin/multiagent-safety.js` passed. `node --test --test-name-pattern "doctor" test/install.test.js` passed with 17 doctor-focused tests, including the new no-wait forwarding and compact-versus-verbose output regressions. `openspec validate agent-codex-improve-gx-doctor-output-and-speed-2026-04-21-14-15 --type change --strict` passed, and `openspec validate --specs` returned `No items found to validate.` + +## 4. Completion + +- [ ] 4.1 Finish the agent branch via PR merge + cleanup (`gx finish --via-pr --wait-for-merge --cleanup` or `bash scripts/agent-branch-finish.sh --branch --base --via-pr --wait-for-merge --cleanup`). +- [ ] 4.2 Record PR URL + final `MERGED` state in the completion handoff. +- [ ] 4.3 Confirm sandbox cleanup (`git worktree list`, `git branch -a`) or capture a `BLOCKED:` handoff if merge/cleanup is pending. diff --git a/test/install.test.js b/test/install.test.js index a4eaa03..9fb8e05 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -197,6 +197,45 @@ function seedReleasePackageManifest(repoDir, overrides = {}) { fs.writeFileSync(packageJsonPath, `${JSON.stringify(mergedPackageJson, null, 2)}\n`, 'utf8'); } +function prepareDoctorAutoFinishReadyBranch(repoDir, options = {}) { + const baseBranch = options.baseBranch || 'main'; + const taskName = options.taskName || 'doctor-ready-finish'; + const agentName = options.agentName || 'planner'; + const fileName = options.fileName || `${taskName}.txt`; + + 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.stdout); + 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', baseBranch], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runCmd( + 'bash', + ['scripts/agent-branch-start.sh', taskName, agentName, baseBranch], + repoDir, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + const readyBranch = extractCreatedBranch(result.stdout); + const readyWorktree = extractCreatedWorktree(result.stdout); + + fs.writeFileSync(path.join(readyWorktree, fileName), 'ready for finish\n', 'utf8'); + result = runCmd('git', ['add', fileName], readyWorktree); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['commit', '--no-verify', '-m', 'doctor ready branch change'], readyWorktree); + assert.equal(result.status, 0, result.stderr || result.stdout); + + return { + readyBranch, + readyWorktree, + fileName, + }; +} + function attachOriginRemote(repoDir) { return attachOriginRemoteForBranch(repoDir, 'dev'); } @@ -1295,32 +1334,10 @@ test('doctor auto-finishes clean pending agent branches against the current loca 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.stdout); - result = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, { - ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + const { readyBranch } = prepareDoctorAutoFinishReadyBranch(repoDir, { + taskName: 'doctor-ready-finish', + fileName: 'doctor-ready-finish.txt', }); - 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 = runCmd( - 'bash', - ['scripts/agent-branch-start.sh', 'doctor-ready-finish', 'planner', 'main'], - repoDir, - ); - assert.equal(result.status, 0, result.stderr || result.stdout); - const readyBranch = extractCreatedBranch(result.stdout); - const readyWorktree = extractCreatedWorktree(result.stdout); - - fs.writeFileSync(path.join(readyWorktree, 'doctor-ready-finish.txt'), 'ready for finish\n', 'utf8'); - result = runCmd('git', ['add', 'doctor-ready-finish.txt'], readyWorktree); - assert.equal(result.status, 0, result.stderr || result.stdout); - result = runCmd('git', ['commit', '--no-verify', '-m', 'doctor ready branch change'], readyWorktree); - assert.equal(result.status, 0, result.stderr || result.stdout); const ghLogPath = path.join(repoDir, '.doctor-auto-finish-gh.log'); const { fakePath: fakeGhPath } = createFakeGhScript(` @@ -1374,6 +1391,128 @@ exit 1 assert.equal(result.stdout.trim(), '', 'doctor auto-finish should remove remote ready branch'); }); +test('doctor forwards --no-wait-for-merge into the auto-finish sweep', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + attachOriginRemoteForBranch(repoDir, 'main'); + const { readyBranch } = prepareDoctorAutoFinishReadyBranch(repoDir, { + taskName: 'doctor-no-wait-sweep', + fileName: 'doctor-no-wait-sweep.txt', + }); + + const ghLogPath = path.join(repoDir, '.doctor-no-wait-gh.log'); + const ghMergeStatePath = path.join(repoDir, '.doctor-no-wait-gh-state'); + const { fakePath: fakeGhPath } = createFakeGhScript(` +LOG_PATH="${ghLogPath}" +STATE_PATH="${ghMergeStatePath}" +echo "$*" >> "$LOG_PATH" +if [[ "$1" == "--version" ]]; then + echo "gh version 2.0.0" + exit 0 +fi +if [[ "$1" == "pr" && "$2" == "create" ]]; then + exit 0 +fi +if [[ "$1" == "pr" && "$2" == "view" ]]; then + if [[ " $* " == *" --json url "* ]]; then + echo "https://example.test/pr/doctor-no-wait" + exit 0 + fi + if [[ " $* " == *" --json state,mergedAt,url "* ]]; then + printf "OPEN\\x1f\\x1f%s\\n" "https://example.test/pr/doctor-no-wait" + exit 0 + fi +fi +if [[ "$1" == "pr" && "$2" == "merge" ]]; then + if [[ " $* " == *" --auto "* ]]; then + exit 0 + fi + count=$(cat "$STATE_PATH" 2>/dev/null || echo 0) + count=$((count + 1)) + printf '%s' "$count" > "$STATE_PATH" + if [[ "$count" -eq 1 ]]; then + echo "simulated pending merge" >&2 + exit 1 + fi + exit 0 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +`); + + const result = runNodeWithEnv( + ['doctor', '--target', repoDir, '--allow-protected-base-write', '--no-wait-for-merge'], + repoDir, + { + GUARDEX_GH_BIN: fakeGhPath, + }, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const ghCalls = fs.readFileSync(ghLogPath, 'utf8'); + assert.match(ghCalls, /pr create/); + assert.match(ghCalls, new RegExp(`pr merge ${escapeRegexLiteral(readyBranch)} --squash --delete-branch --auto`)); + + const combinedOutput = `${result.stdout}\n${result.stderr}`; + assert.match(combinedOutput, /Auto-finish sweep \(base=main\): attempted=1, completed=1, skipped=\d+, failed=0/); +}); + +test('doctor compacts auto-finish failures by default and expands them with --verbose-auto-finish', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + attachOriginRemoteForBranch(repoDir, 'main'); + const { readyBranch, readyWorktree, fileName } = prepareDoctorAutoFinishReadyBranch(repoDir, { + taskName: 'doctor-compact-failure', + fileName: 'doctor-compact-failure.txt', + }); + let result = runCmd('git', ['worktree', 'remove', readyWorktree, '--force'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + fs.writeFileSync(path.join(repoDir, fileName), 'main branch conflicting change\n', 'utf8'); + result = runCmd('git', ['add', fileName], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['commit', '-m', 'main branch conflicting change'], 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 { fakePath: fakeGhPath } = createFakeGhScript(` +if [[ "$1" == "--version" ]]; then + echo "gh version 2.0.0" + exit 0 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +`); + + result = runNodeWithEnv( + ['doctor', '--target', repoDir, '--allow-protected-base-write'], + repoDir, + { GUARDEX_GH_BIN: fakeGhPath }, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + const compactOutput = `${result.stdout}\n${result.stderr}`; + assert.match( + compactOutput, + new RegExp( + `\\[fail\\] ${escapeRegexLiteral(readyBranch)}: rebase conflict in finish flow; run rebase --continue or rebase --abort in the source-probe worktree`, + ), + ); + assert.doesNotMatch(compactOutput, /git -C "\/tmp\/very\/long\/path\/for\/source-probe-agent-worktree/); + + result = runNodeWithEnv( + ['doctor', '--target', repoDir, '--allow-protected-base-write', '--verbose-auto-finish'], + repoDir, + { GUARDEX_GH_BIN: fakeGhPath }, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + const verboseOutput = `${result.stdout}\n${result.stderr}`; + assert.match(verboseOutput, new RegExp(`\\[fail\\] ${escapeRegexLiteral(readyBranch)}: auto-finish failed\\.`)); + assert.match(verboseOutput, /git -C ".+rebase --continue/); +}); + test('setup pre-commit blocks codex session commits on non-agent branches by default', () => { const repoDir = initRepo(); @@ -4115,6 +4254,7 @@ test('doctor recurses into nested frontend repos and repairs protected-main drif assert.equal(result.status, 0, result.stderr || result.stdout); assert.match(result.stdout, /Detected 2 git repos under/); assert.match(result.stdout, new RegExp(`Doctor target: ${escapeRegexLiteral(frontendDir)}`)); + assert.match(result.stdout, new RegExp(`Doctor target complete: ${escapeRegexLiteral(frontendDir)} \\[2/2\\] in `)); assert.match(result.stdout, /doctor detected protected branch 'main'/); assert.equal(fs.existsSync(path.join(frontendDir, 'AGENTS.md')), true, 'nested frontend AGENTS.md should be restored'); @@ -4216,6 +4356,7 @@ exit 1 const durationMs = Date.now() - startedAt; assert.equal(result.status, 1, result.stderr || result.stdout); assert.match(result.stdout, new RegExp(`Doctor target: ${escapeRegexLiteral(frontendDir)}`)); + assert.match(result.stdout, new RegExp(`Doctor target complete: ${escapeRegexLiteral(frontendDir)} \\[2/2\\] in `)); assert.match(result.stdout, /Auto-finish pending for sandbox branch/); assert.match(result.stdout, /PR: https:\/\/example\.test\/pr\/nested-doctor-pending/); assert.ok(