diff --git a/.changeset/quick-rams-wave.md b/.changeset/quick-rams-wave.md new file mode 100644 index 000000000..d528cda0f --- /dev/null +++ b/.changeset/quick-rams-wave.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 265 +--- +Workflow guard now respects its opt-in config before blocking forced git-add commands on worktree-agent branches. diff --git a/hooks/gsd-workflow-guard.js b/hooks/gsd-workflow-guard.js index 55743a46d..e5f3fe6bb 100644 --- a/hooks/gsd-workflow-guard.js +++ b/hooks/gsd-workflow-guard.js @@ -65,6 +65,17 @@ function currentBranch(cwd) { return result.stdout.trim(); } +function workflowGuardEnabled(cwd) { + const configPath = path.join(cwd, '.planning', 'config.json'); + if (!fs.existsSync(configPath)) return false; + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + return Boolean(config.hooks?.workflow_guard); + } catch (e) { + return false; + } +} + let input = ''; const stdinTimeout = setTimeout(() => process.exit(0), 3000); process.stdin.setEncoding('utf8'); @@ -75,8 +86,12 @@ process.stdin.on('end', () => { const data = JSON.parse(input); const toolName = data.tool_name; const cwd = data.cwd || process.cwd(); + const isWorkflowGuardEnabled = workflowGuardEnabled(cwd); if (toolName === 'Bash') { + if (!isWorkflowGuardEnabled) { + process.exit(0); + } const command = data.tool_input?.command || ''; for (const gitCwd of forceGitAddCwds(command, cwd)) { const branch = currentBranch(gitCwd); @@ -92,8 +107,8 @@ process.stdin.on('end', () => { process.exit(0); } - // Only guard Write and Edit tool calls - if (toolName !== 'Write' && toolName !== 'Edit') { + // Only guard Write, Edit, and MultiEdit tool calls + if (!['Write', 'Edit', 'MultiEdit'].includes(toolName)) { process.exit(0); } @@ -125,19 +140,8 @@ process.stdin.on('end', () => { process.exit(0); } - // Check if workflow guard is enabled - const configPath = path.join(cwd, '.planning', 'config.json'); - if (fs.existsSync(configPath)) { - try { - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - if (!config.hooks?.workflow_guard) { - process.exit(0); // Guard disabled (default) - } - } catch (e) { - process.exit(0); - } - } else { - process.exit(0); // No GSD project — don't guard + if (!isWorkflowGuardEnabled) { + process.exit(0); // Guard disabled (default) or no GSD project } // If we get here: GSD project, guard enabled, file edit outside .planning/, diff --git a/tests/bug-261-worktree-force-add-guard.test.cjs b/tests/bug-261-worktree-force-add-guard.test.cjs index d40f0585e..273b0d966 100644 --- a/tests/bug-261-worktree-force-add-guard.test.cjs +++ b/tests/bug-261-worktree-force-add-guard.test.cjs @@ -26,23 +26,36 @@ function makeRepo(branch) { return dir; } -function runHook(cwd, command) { +function setWorkflowGuard(dir, enabled) { + const planningDir = path.join(dir, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + fs.writeFileSync( + path.join(planningDir, 'config.json'), + JSON.stringify({ hooks: { workflow_guard: enabled } }, null, 2) + ); +} + +function runHookInput(cwd, input) { return spawnSync(process.execPath, [HOOK_PATH], { cwd, encoding: 'utf8', - input: JSON.stringify({ - cwd, - tool_name: 'Bash', - tool_input: { command }, - }), + input: JSON.stringify({ cwd, ...input }), + }); +} + +function runBashHook(cwd, command) { + return runHookInput(cwd, { + tool_name: 'Bash', + tool_input: { command }, }); } describe('bug #261: workflow guard blocks forced git add on worktree-agent branches', () => { - test('blocks git add -f on worktree-agent branch before it can stage gitignored files', () => { + test('blocks git add -f on worktree-agent branch when workflow guard is enabled', () => { const dir = makeRepo('worktree-agent-a1'); try { - const result = runHook(dir, 'git add -f .planning/phases/01/01-01-SUMMARY.md'); + setWorkflowGuard(dir, true); + const result = runBashHook(dir, 'git add -f .planning/phases/01/01-01-SUMMARY.md'); assert.strictEqual(result.status, 2); const envelope = JSON.parse(result.stdout); assert.strictEqual(envelope.decision, 'block'); @@ -55,7 +68,8 @@ describe('bug #261: workflow guard blocks forced git add on worktree-agent branc test('blocks git add --force with git global options on worktree-agent branch', () => { const dir = makeRepo('worktree-agent-b2'); try { - const result = runHook(path.dirname(dir), `git -C "${dir}" add --force .planning/SUMMARY.md`); + setWorkflowGuard(dir, true); + const result = runBashHook(dir, `git -C "${dir}" add --force .planning/SUMMARY.md`); assert.strictEqual(result.status, 2); assert.strictEqual(JSON.parse(result.stdout).code, 'WORKTREE_AGENT_FORCE_ADD_FORBIDDEN'); } finally { @@ -66,7 +80,8 @@ describe('bug #261: workflow guard blocks forced git add on worktree-agent branc test('allows ordinary git add on worktree-agent branch', () => { const dir = makeRepo('worktree-agent-c3'); try { - const result = runHook(dir, 'git add .planning/SUMMARY.md'); + setWorkflowGuard(dir, true); + const result = runBashHook(dir, 'git add .planning/SUMMARY.md'); assert.strictEqual(result.status, 0); assert.strictEqual(result.stdout, ''); } finally { @@ -77,11 +92,57 @@ describe('bug #261: workflow guard blocks forced git add on worktree-agent branc test('allows git add -f outside worktree-agent branches', () => { const dir = makeRepo('feature-docs'); try { - const result = runHook(dir, 'git add -f .planning/SUMMARY.md'); + setWorkflowGuard(dir, true); + const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md'); assert.strictEqual(result.status, 0); assert.strictEqual(result.stdout, ''); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); + + test('allows git add -f on worktree-agent branch when workflow guard is disabled', () => { + const dir = makeRepo('worktree-agent-d4'); + try { + setWorkflowGuard(dir, false); + const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md'); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, ''); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('allows git add -f on worktree-agent branch when no GSD config exists', () => { + const dir = makeRepo('worktree-agent-e5'); + try { + const result = runBashHook(dir, 'git add -f .planning/SUMMARY.md'); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, ''); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('applies the advisory path to MultiEdit when workflow guard is enabled', () => { + const dir = makeRepo('feature-multiedit'); + try { + setWorkflowGuard(dir, true); + const result = runHookInput(dir, { + tool_name: 'MultiEdit', + tool_input: { + file_path: path.join(dir, 'src.js'), + edits: [], + }, + }); + assert.strictEqual(result.status, 0); + const envelope = JSON.parse(result.stdout); + assert.match( + envelope.hookSpecificOutput.additionalContext, + /WORKFLOW ADVISORY/ + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); });