diff --git a/.githooks/post-merge b/.githooks/post-merge new file mode 100755 index 0000000..5c690b5 --- /dev/null +++ b/.githooks/post-merge @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${MUSAFETY_DISABLE_POST_MERGE_CLEANUP:-0}" == "1" ]]; then + exit 0 +fi + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "$repo_root" ]]; then + exit 0 +fi + +branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +if [[ -z "$branch" || "$branch" == "HEAD" ]]; then + exit 0 +fi + +base_branch="${MUSAFETY_BASE_BRANCH:-$(git -C "$repo_root" config --get multiagent.baseBranch || true)}" +if [[ -z "$base_branch" ]]; then + base_branch="dev" +fi + +if [[ "$branch" != "$base_branch" ]]; then + exit 0 +fi + +cli_path="$repo_root/bin/multiagent-safety.js" +if [[ ! -f "$cli_path" ]]; then + exit 0 +fi + +node_bin="${MUSAFETY_NODE_BIN:-node}" +if ! command -v "$node_bin" >/dev/null 2>&1; then + exit 0 +fi + +"$node_bin" "$cli_path" cleanup \ + --target "$repo_root" \ + --base "$base_branch" \ + --keep-clean-worktrees >/dev/null 2>&1 || true + +exit 0 diff --git a/.gitignore b/.gitignore index ed52393..5ad58bc 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ scripts/install-agent-git-hooks.sh scripts/openspec/init-plan-workspace.sh .githooks/pre-commit .githooks/pre-push +.githooks/post-merge oh-my-codex/ .codex/skills/guardex/SKILL.md .claude/commands/guardex.md diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 5acfaac..01615ac 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -49,6 +49,7 @@ const TEMPLATE_FILES = [ 'scripts/openspec/init-plan-workspace.sh', 'githooks/pre-commit', 'githooks/pre-push', + 'githooks/post-merge', 'codex/skills/guardex/SKILL.md', 'codex/skills/guardex-merge-skills-to-dev/SKILL.md', 'claude/commands/guardex.md', @@ -63,6 +64,7 @@ const REQUIRED_WORKFLOW_FILES = [ 'scripts/agent-file-locks.py', 'scripts/install-agent-git-hooks.sh', '.githooks/pre-commit', + '.githooks/post-merge', '.omx/state/agent-file-locks.json', ]; @@ -87,12 +89,14 @@ const EXECUTABLE_RELATIVE_PATHS = new Set([ 'scripts/openspec/init-plan-workspace.sh', '.githooks/pre-commit', '.githooks/pre-push', + '.githooks/post-merge', ]); const CRITICAL_GUARDRAIL_PATHS = new Set([ 'AGENTS.md', '.githooks/pre-commit', '.githooks/pre-push', + '.githooks/post-merge', 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', 'scripts/agent-worktree-prune.sh', @@ -118,6 +122,7 @@ const MANAGED_GITIGNORE_PATHS = [ 'scripts/openspec/init-plan-workspace.sh', '.githooks/pre-commit', '.githooks/pre-push', + '.githooks/post-merge', 'oh-my-codex/', '.codex/skills/guardex/SKILL.md', '.codex/skills/guardex-merge-skills-to-dev/SKILL.md', diff --git a/scripts/agent-file-locks.py b/scripts/agent-file-locks.py index 06cdd7a..53c2d2f 100755 --- a/scripts/agent-file-locks.py +++ b/scripts/agent-file-locks.py @@ -27,6 +27,7 @@ 'AGENTS.md', '.githooks/pre-commit', '.githooks/pre-push', + '.githooks/post-merge', 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', 'scripts/agent-file-locks.py', diff --git a/templates/githooks/post-merge b/templates/githooks/post-merge new file mode 100755 index 0000000..5c690b5 --- /dev/null +++ b/templates/githooks/post-merge @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${MUSAFETY_DISABLE_POST_MERGE_CLEANUP:-0}" == "1" ]]; then + exit 0 +fi + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "$repo_root" ]]; then + exit 0 +fi + +branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +if [[ -z "$branch" || "$branch" == "HEAD" ]]; then + exit 0 +fi + +base_branch="${MUSAFETY_BASE_BRANCH:-$(git -C "$repo_root" config --get multiagent.baseBranch || true)}" +if [[ -z "$base_branch" ]]; then + base_branch="dev" +fi + +if [[ "$branch" != "$base_branch" ]]; then + exit 0 +fi + +cli_path="$repo_root/bin/multiagent-safety.js" +if [[ ! -f "$cli_path" ]]; then + exit 0 +fi + +node_bin="${MUSAFETY_NODE_BIN:-node}" +if ! command -v "$node_bin" >/dev/null 2>&1; then + exit 0 +fi + +"$node_bin" "$cli_path" cleanup \ + --target "$repo_root" \ + --base "$base_branch" \ + --keep-clean-worktrees >/dev/null 2>&1 || true + +exit 0 diff --git a/test/install.test.js b/test/install.test.js index fa6b2ce..46eec84 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -273,6 +273,7 @@ test('setup provisions workflow files and repo config', () => { 'scripts/openspec/init-plan-workspace.sh', '.githooks/pre-commit', '.githooks/pre-push', + '.githooks/post-merge', '.codex/skills/guardex/SKILL.md', '.codex/skills/guardex-merge-skills-to-dev/SKILL.md', '.claude/commands/guardex.md', @@ -320,6 +321,7 @@ test('setup provisions workflow files and repo config', () => { assert.match(gitignoreContent, /scripts\/agent-file-locks\.py/); assert.match(gitignoreContent, /\.githooks\/pre-commit/); assert.match(gitignoreContent, /\.githooks\/pre-push/); + assert.match(gitignoreContent, /\.githooks\/post-merge/); assert.match(gitignoreContent, /\.omx\//); assert.match(gitignoreContent, /oh-my-codex\//); assert.match(gitignoreContent, /\.codex\/skills\/guardex\/SKILL\.md/); @@ -1824,6 +1826,55 @@ test('pre-push blocks codex protected branch pushes even from VS Code Source Con assert.match(hookResult.stderr, /\[guardex-preedit-guard\] Codex push detected toward protected branch\./); }); +test('post-merge auto-runs cleanup on base branch and skips non-base branches', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + + const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + + const markerPath = path.join(repoDir, '.post-merge-cleanup-args'); + fs.writeFileSync( + path.join(repoDir, 'bin', 'multiagent-safety.js'), + '#!/usr/bin/env node\n' + + "const fs = require('node:fs');\n" + + "const marker = process.env.MUSAFETY_POST_MERGE_MARKER;\n" + + "if (marker) fs.appendFileSync(marker, process.argv.slice(2).join(' ') + '\\n', 'utf8');\n", + 'utf8', + ); + + let result = runCmd('bash', ['.githooks/post-merge', '0'], repoDir, { + MUSAFETY_POST_MERGE_MARKER: markerPath, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + + let invocations = fs + .readFileSync(markerPath, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + assert.equal(invocations.length, 1); + assert.match(invocations[0], /^cleanup /); + assert.match(invocations[0], new RegExp(`--target ${escapeRegexLiteral(repoDir)}`)); + assert.match(invocations[0], /--base dev/); + assert.match(invocations[0], /--keep-clean-worktrees/); + + result = runCmd('git', ['checkout', '-b', 'feature/post-merge-skip'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runCmd('bash', ['.githooks/post-merge', '0'], repoDir, { + MUSAFETY_POST_MERGE_MARKER: markerPath, + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + + invocations = fs + .readFileSync(markerPath, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + assert.equal(invocations.length, 1, 'post-merge should skip cleanup on non-base branch'); +}); + test('codex-agent launches codex inside a fresh sandbox worktree and keeps branch/worktree by default', () => { const repoDir = initRepo(); seedCommit(repoDir);