From fcc4342d96a55a14da1ab36eb9a749b065d8d932 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 13 Apr 2026 23:28:47 +0200 Subject: [PATCH] Allow VS Code commits on protected branches that exist only locally Users working in local-only feature branches inside VS Code Source Control should not be blocked by protected-branch hooks when no remote/upstream branch exists yet. The hook now keeps protection for shared/remote branches while permitting local-only VS Code commits, and codex-session protection remains unchanged. Constraint: Codex/OMX sessions must stay blocked on protected branches Rejected: Keep TERM_PROGRAM=vscode as VS Code context signal | over-matches terminal sessions and weakens branch guardrails Rejected: Allow all protected-branch VS Code commits | would permit unsafe writes on shared protected branches Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep local-only exception tied to both missing upstream and missing remote counterpart Tested: node --test test/install.test.js; npm test --- .githooks/pre-commit | 20 +++++++-- README.md | 3 +- templates/githooks/pre-commit | 20 +++++++-- test/install.test.js | 77 ++++++++++++++++++++++++++++++++++- 4 files changed, 112 insertions(+), 8 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 8ae9fe1..bacdcff 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -24,7 +24,7 @@ if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}" fi is_vscode_git_context=0 -if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n "${VSCODE_IPC_HOOK_CLI:-}" || "${TERM_PROGRAM:-}" == "vscode" ]]; then +if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n "${VSCODE_IPC_HOOK_CLI:-}" ]]; then is_vscode_git_context=1 fi @@ -55,6 +55,15 @@ for protected_branch in $protected_branches_raw; do fi done +is_local_only_branch=0 +if [[ "$is_protected_branch" == "1" ]]; then + upstream_ref="$(git for-each-ref --format='%(upstream:short)' "refs/heads/${branch}" | head -n 1)" + remote_branch_ref="$(git for-each-ref --format='%(refname:short)' "refs/remotes/*/${branch}" | head -n 1)" + if [[ -z "$upstream_ref" && -z "$remote_branch_ref" ]]; then + is_local_only_branch=1 + fi +fi + codex_require_agent_branch_raw="${MUSAFETY_CODEX_REQUIRE_AGENT_BRANCH:-$(git config --get multiagent.codexRequireAgentBranch || true)}" if [[ -z "$codex_require_agent_branch_raw" ]]; then codex_require_agent_branch_raw="true" @@ -124,8 +133,10 @@ MSG fi if [[ "$is_protected_branch" == "1" ]]; then - if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch_writes" == "1" ]]; then - exit 0 + if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" ]]; then + if [[ "$allow_vscode_protected_branch_writes" == "1" || "$is_local_only_branch" == "1" ]]; then + exit 0 + fi fi if [[ "$is_unborn_branch" == "1" && "$is_codex_session" != "1" ]]; then @@ -147,6 +158,9 @@ After finishing work: Optional repo override for manual VS Code protected-branch commits: git config multiagent.allowVscodeProtectedBranchWrites true +VS Code Source Control commits on protected local-only branches +(no upstream and no remote branch) are allowed automatically. + Temporary bypass (not recommended): ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ... MSG diff --git a/README.md b/README.md index 6f854d2..4152264 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,8 @@ Note: the monitor dispatches Codex through explicit `--task/--agent/--base` flag - `gx setup` checks GitHub CLI (`gh`) and prints install guidance if missing. - Interactive self-update prompt defaults to **No** (`[y/N]`). - In initialized repos, `setup`/`install`/`fix` block protected-base writes unless explicitly overridden. -- Direct commits/pushes to protected branches are blocked by default (including VS Code Source Control). +- Direct commits/pushes to protected branches are blocked by default. +- Exception: VS Code Source Control commits are allowed on protected branches that exist only locally (no upstream and no remote branch). - Optional repo override for manual VS Code protected-branch writes: `git config multiagent.allowVscodeProtectedBranchWrites true`. - Codex/agent sessions stay blocked on protected branches and must use `agent/*` branch + PR workflow. - On protected `main`, `gx doctor` auto-runs in a sandbox agent branch/worktree. diff --git a/templates/githooks/pre-commit b/templates/githooks/pre-commit index 8ae9fe1..bacdcff 100755 --- a/templates/githooks/pre-commit +++ b/templates/githooks/pre-commit @@ -24,7 +24,7 @@ if [[ -n "${CODEX_THREAD_ID:-}" || -n "${OMX_SESSION_ID:-}" || "${CODEX_CI:-0}" fi is_vscode_git_context=0 -if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n "${VSCODE_IPC_HOOK_CLI:-}" || "${TERM_PROGRAM:-}" == "vscode" ]]; then +if [[ -n "${VSCODE_GIT_IPC_HANDLE:-}" || -n "${VSCODE_GIT_ASKPASS_NODE:-}" || -n "${VSCODE_IPC_HOOK_CLI:-}" ]]; then is_vscode_git_context=1 fi @@ -55,6 +55,15 @@ for protected_branch in $protected_branches_raw; do fi done +is_local_only_branch=0 +if [[ "$is_protected_branch" == "1" ]]; then + upstream_ref="$(git for-each-ref --format='%(upstream:short)' "refs/heads/${branch}" | head -n 1)" + remote_branch_ref="$(git for-each-ref --format='%(refname:short)' "refs/remotes/*/${branch}" | head -n 1)" + if [[ -z "$upstream_ref" && -z "$remote_branch_ref" ]]; then + is_local_only_branch=1 + fi +fi + codex_require_agent_branch_raw="${MUSAFETY_CODEX_REQUIRE_AGENT_BRANCH:-$(git config --get multiagent.codexRequireAgentBranch || true)}" if [[ -z "$codex_require_agent_branch_raw" ]]; then codex_require_agent_branch_raw="true" @@ -124,8 +133,10 @@ MSG fi if [[ "$is_protected_branch" == "1" ]]; then - if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" && "$allow_vscode_protected_branch_writes" == "1" ]]; then - exit 0 + if [[ "$is_codex_session" != "1" && "$is_vscode_git_context" == "1" ]]; then + if [[ "$allow_vscode_protected_branch_writes" == "1" || "$is_local_only_branch" == "1" ]]; then + exit 0 + fi fi if [[ "$is_unborn_branch" == "1" && "$is_codex_session" != "1" ]]; then @@ -147,6 +158,9 @@ After finishing work: Optional repo override for manual VS Code protected-branch commits: git config multiagent.allowVscodeProtectedBranchWrites true +VS Code Source Control commits on protected local-only branches +(no upstream and no remote branch) are allowed automatically. + Temporary bypass (not recommended): ALLOW_COMMIT_ON_PROTECTED_BRANCH=1 git commit ... MSG diff --git a/test/install.test.js b/test/install.test.js index d9f69cf..4653eb8 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -1086,9 +1086,10 @@ test('protect command manages configured protected branches', () => { assert.match(result.stdout, /reset to defaults/); }); -test('pre-commit blocks non-codex VS Code commits on custom protected branches by default', () => { +test('pre-commit blocks non-codex VS Code commits on custom protected branches by default when branch has remote counterpart', () => { const repoDir = initRepoOnBranch('release'); seedCommit(repoDir); + attachOriginRemoteForBranch(repoDir, 'release'); let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); @@ -1107,6 +1108,7 @@ test('pre-commit blocks non-codex VS Code commits on custom protected branches b test('pre-commit blocks non-codex protected branch commits from VS Code Source Control env by default', () => { const repoDir = initRepo(); seedCommit(repoDir); + attachOriginRemote(repoDir); const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); @@ -1126,6 +1128,50 @@ test('pre-commit blocks non-codex protected branch commits from VS Code Source C assert.match(hookResult.stderr, /\[agent-branch-guard\] Direct commits on protected branches are blocked\./); }); +test('pre-commit allows non-codex VS Code commits on protected local-only 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 hookResult = runCmd( + 'bash', + ['.githooks/pre-commit'], + repoDir, + { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '0', + VSCODE_GIT_IPC_HANDLE: '1', + VSCODE_GIT_ASKPASS_NODE: '1', + VSCODE_IPC_HOOK_CLI: '1', + }, + ); + assert.equal(hookResult.status, 0, hookResult.stderr || hookResult.stdout); +}); + +test('pre-commit blocks codex commits on protected local-only branches even from VS Code Source Control env', () => { + 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 hookResult = runCmd( + 'bash', + ['.githooks/pre-commit'], + repoDir, + { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '0', + CODEX_THREAD_ID: 'test-thread', + VSCODE_GIT_IPC_HANDLE: '1', + VSCODE_GIT_ASKPASS_NODE: '1', + VSCODE_IPC_HOOK_CLI: '1', + }, + ); + assert.equal(hookResult.status, 1, hookResult.stderr || hookResult.stdout); + assert.match(hookResult.stderr, /\[guardex-preedit-guard\] Codex edit\/commit detected on a protected branch\./); +}); + test('pre-push blocks non-codex protected branch pushes from VS Code Source Control env by default', () => { const repoDir = initRepoOnBranch('main'); seedCommit(repoDir); @@ -1153,6 +1199,7 @@ test('pre-push blocks non-codex protected branch pushes from VS Code Source Cont test('pre-commit allows non-codex protected branch commits from VS Code Source Control env when explicitly enabled', () => { const repoDir = initRepo(); seedCommit(repoDir); + attachOriginRemote(repoDir); const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); @@ -1178,6 +1225,34 @@ test('pre-commit allows non-codex protected branch commits from VS Code Source C assert.equal(hookResult.status, 0, hookResult.stderr || hookResult.stdout); }); +test('pre-commit does not treat TERM_PROGRAM=vscode as VS Code Source Control context', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + attachOriginRemote(repoDir); + + const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + + let configResult = runCmd( + 'git', + ['config', 'multiagent.allowVscodeProtectedBranchWrites', 'true'], + repoDir, + ); + assert.equal(configResult.status, 0, configResult.stderr || configResult.stdout); + + const hookResult = runCmd( + 'bash', + ['.githooks/pre-commit'], + repoDir, + { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '0', + TERM_PROGRAM: 'vscode', + }, + ); + assert.equal(hookResult.status, 1, hookResult.stderr || hookResult.stdout); + assert.match(hookResult.stderr, /\[agent-branch-guard\] Direct commits on protected branches are blocked\./); +}); + test('pre-push allows non-codex protected branch pushes from VS Code Source Control env when explicitly enabled', () => { const repoDir = initRepoOnBranch('main'); seedCommit(repoDir);