From 6ae698c95babda33f90eee40cee8ce17de0bac5a Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Sat, 11 Apr 2026 14:16:36 +0200 Subject: [PATCH] Make branch start/finish follow the active repo base by default Hardcoded dev defaults caused agent workflows to mis-target repos whose protected base is main/master or another branch. This change makes start/finish infer base from repo context and branch metadata so agent branches launch and merge back correctly without extra flags. Constraint: Must preserve explicit --base and multiagent.baseBranch behavior Rejected: Force setup to auto-write multiagent.baseBranch | intrusive repo config mutation Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep template scripts and runtime scripts in lockstep whenever branch-resolution logic changes Tested: bash -n scripts/agent-branch-start.sh scripts/agent-branch-finish.sh templates/scripts/agent-branch-start.sh templates/scripts/agent-branch-finish.sh templates/scripts/codex-agent.sh Tested: npm test (46/46 pass) Not-tested: Real GitHub protected-branch auto-merge policy paths in live repos --- README.md | 13 +++- bin/multiagent-safety.js | 2 +- scripts/agent-branch-finish.sh | 37 +++++++++- scripts/agent-branch-start.sh | 31 +++++++- templates/scripts/agent-branch-finish.sh | 37 +++++++++- templates/scripts/agent-branch-start.sh | 28 +++++++- templates/scripts/codex-agent.sh | 21 +++++- test/install.test.js | 92 +++++++++++++++++++++++- 8 files changed, 244 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 3d7fa3a..bfe480b 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ Use this exact checklist to setup multi-agent safety in this repository for Code 6) Optional: protect extra branches: musafety protect add release staging -7) Optional: sync your current agent branch with latest dev: +7) Optional: sync your current agent branch with latest base branch: musafety sync --check musafety sync ``` @@ -212,7 +212,7 @@ musafety scan [--target ] [--json] musafety report help ``` -## Keep agent branches synced with dev +## Keep agent branches synced with your base branch Use sync checks before finishing agent branches: @@ -223,9 +223,16 @@ musafety sync Defaults: -- base branch: `dev` (or `multiagent.baseBranch`) +- `musafety sync` base branch: `dev` (or `multiagent.baseBranch`) - strategy: `rebase` (or `multiagent.sync.strategy`) +`agent-branch-start.sh` and `agent-branch-finish.sh` resolve base branch in this order: + +1. explicit `--base` +2. `multiagent.baseBranch` +3. branch-linked base metadata / source upstream / current checked-out branch (context-dependent) +4. fallback `dev` + Useful variants: ```sh diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 0a5fb61..0c91c76 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -147,7 +147,7 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup multi-agent safety in 6) Optional: protect extra branches: musafety protect add release staging -7) Optional: sync your current agent branch with latest dev: +7) Optional: sync your current agent branch with latest base branch: musafety sync --check musafety sync `; diff --git a/scripts/agent-branch-finish.sh b/scripts/agent-branch-finish.sh index 1f33189..7a84f8d 100755 --- a/scripts/agent-branch-finish.sh +++ b/scripts/agent-branch-finish.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -BASE_BRANCH="dev" +BASE_BRANCH="" BASE_BRANCH_EXPLICIT=0 SOURCE_BRANCH="" PUSH_ENABLED=1 @@ -64,6 +64,15 @@ fi repo_root="$(git rev-parse --show-toplevel)" current_worktree="$(pwd -P)" +if [[ -z "$SOURCE_BRANCH" ]]; then + SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +fi + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then + echo "[agent-branch-finish] --base requires a non-empty branch name." >&2 + exit 1 +fi + if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" if [[ -n "$configured_base" ]]; then @@ -71,8 +80,30 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then fi fi -if [[ -z "$SOURCE_BRANCH" ]]; then - SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +if [[ -z "$BASE_BRANCH" ]]; then + branch_stored_base="$(git -C "$repo_root" config --get "branch.${SOURCE_BRANCH}.musafetyBase" || true)" + if [[ -n "$branch_stored_base" ]]; then + BASE_BRANCH="$branch_stored_base" + fi +fi + +if [[ -z "$BASE_BRANCH" ]]; then + source_upstream="$(git -C "$repo_root" for-each-ref --format='%(upstream:short)' "refs/heads/${SOURCE_BRANCH}" | head -n 1)" + source_upstream="${source_upstream:-}" + if [[ "$source_upstream" == */* ]]; then + BASE_BRANCH="${source_upstream#*/}" + fi +fi + +if [[ -z "$BASE_BRANCH" ]]; then + current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -n "$current_branch" && "$current_branch" != "HEAD" && "$current_branch" != "$SOURCE_BRANCH" ]]; then + BASE_BRANCH="$current_branch" + fi +fi + +if [[ -z "$BASE_BRANCH" ]]; then + BASE_BRANCH="dev" fi if [[ "$SOURCE_BRANCH" == "$BASE_BRANCH" ]]; then diff --git a/scripts/agent-branch-start.sh b/scripts/agent-branch-start.sh index 24331fb..eaf5c4f 100755 --- a/scripts/agent-branch-start.sh +++ b/scripts/agent-branch-start.sh @@ -3,10 +3,15 @@ set -euo pipefail TASK_NAME="${1:-task}" AGENT_NAME="${2:-agent}" -BASE_BRANCH="${3:-dev}" +BASE_BRANCH="${3:-}" +BASE_BRANCH_EXPLICIT=0 WORKTREE_MODE=1 WORKTREE_ROOT_REL=".omx/agent-worktrees" +if [[ -n "${3:-}" ]]; then + BASE_BRANCH_EXPLICIT=1 +fi + while [[ $# -gt 0 ]]; do case "$1" in --task) @@ -18,7 +23,8 @@ while [[ $# -gt 0 ]]; do shift 2 ;; --base) - BASE_BRANCH="${2:-dev}" + BASE_BRANCH="${2:-}" + BASE_BRANCH_EXPLICIT=1 shift 2 ;; --in-place) @@ -80,6 +86,25 @@ fi repo_root="$(git rev-parse --show-toplevel)" +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then + echo "[agent-branch-start] --base requires a non-empty branch name." >&2 + exit 1 +fi + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then + configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" + if [[ -n "$configured_base" ]]; then + BASE_BRANCH="$configured_base" + else + current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]]; then + BASE_BRANCH="$current_branch" + else + BASE_BRANCH="dev" + fi + fi +fi + if git show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then git fetch origin "${BASE_BRANCH}" --quiet start_ref="origin/${BASE_BRANCH}" @@ -123,6 +148,7 @@ if [[ "$WORKTREE_MODE" -eq 0 ]]; then fi git checkout -b "$branch_name" + git -C "$repo_root" config "branch.${branch_name}.musafetyBase" "$BASE_BRANCH" >/dev/null 2>&1 || true echo "[agent-branch-start] Created in-place branch: ${branch_name}" echo "$branch_name" exit 0 @@ -138,6 +164,7 @@ if [[ -e "$worktree_path" ]]; then fi git -C "$repo_root" worktree add -b "$branch_name" "$worktree_path" "$start_ref" +git -C "$repo_root" config "branch.${branch_name}.musafetyBase" "$BASE_BRANCH" >/dev/null 2>&1 || true if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then git -C "$worktree_path" branch --set-upstream-to="origin/${BASE_BRANCH}" "$branch_name" >/dev/null 2>&1 || true diff --git a/templates/scripts/agent-branch-finish.sh b/templates/scripts/agent-branch-finish.sh index 1f33189..7a84f8d 100755 --- a/templates/scripts/agent-branch-finish.sh +++ b/templates/scripts/agent-branch-finish.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -BASE_BRANCH="dev" +BASE_BRANCH="" BASE_BRANCH_EXPLICIT=0 SOURCE_BRANCH="" PUSH_ENABLED=1 @@ -64,6 +64,15 @@ fi repo_root="$(git rev-parse --show-toplevel)" current_worktree="$(pwd -P)" +if [[ -z "$SOURCE_BRANCH" ]]; then + SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +fi + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then + echo "[agent-branch-finish] --base requires a non-empty branch name." >&2 + exit 1 +fi + if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" if [[ -n "$configured_base" ]]; then @@ -71,8 +80,30 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then fi fi -if [[ -z "$SOURCE_BRANCH" ]]; then - SOURCE_BRANCH="$(git rev-parse --abbrev-ref HEAD)" +if [[ -z "$BASE_BRANCH" ]]; then + branch_stored_base="$(git -C "$repo_root" config --get "branch.${SOURCE_BRANCH}.musafetyBase" || true)" + if [[ -n "$branch_stored_base" ]]; then + BASE_BRANCH="$branch_stored_base" + fi +fi + +if [[ -z "$BASE_BRANCH" ]]; then + source_upstream="$(git -C "$repo_root" for-each-ref --format='%(upstream:short)' "refs/heads/${SOURCE_BRANCH}" | head -n 1)" + source_upstream="${source_upstream:-}" + if [[ "$source_upstream" == */* ]]; then + BASE_BRANCH="${source_upstream#*/}" + fi +fi + +if [[ -z "$BASE_BRANCH" ]]; then + current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -n "$current_branch" && "$current_branch" != "HEAD" && "$current_branch" != "$SOURCE_BRANCH" ]]; then + BASE_BRANCH="$current_branch" + fi +fi + +if [[ -z "$BASE_BRANCH" ]]; then + BASE_BRANCH="dev" fi if [[ "$SOURCE_BRANCH" == "$BASE_BRANCH" ]]; then diff --git a/templates/scripts/agent-branch-start.sh b/templates/scripts/agent-branch-start.sh index 2cb277e..ab8589e 100755 --- a/templates/scripts/agent-branch-start.sh +++ b/templates/scripts/agent-branch-start.sh @@ -3,7 +3,8 @@ set -euo pipefail TASK_NAME="task" AGENT_NAME="agent" -BASE_BRANCH="dev" +BASE_BRANCH="" +BASE_BRANCH_EXPLICIT=0 WORKTREE_MODE=1 ALLOW_IN_PLACE=0 WORKTREE_ROOT_REL=".omx/agent-worktrees" @@ -20,7 +21,8 @@ while [[ $# -gt 0 ]]; do shift 2 ;; --base) - BASE_BRANCH="${2:-dev}" + BASE_BRANCH="${2:-}" + BASE_BRANCH_EXPLICIT=1 shift 2 ;; --in-place) @@ -71,6 +73,7 @@ fi if [[ "${#POSITIONAL_ARGS[@]}" -ge 3 ]]; then BASE_BRANCH="${POSITIONAL_ARGS[2]}" + BASE_BRANCH_EXPLICIT=1 fi sanitize_slug() { @@ -109,6 +112,25 @@ fi repo_root="$(git rev-parse --show-toplevel)" +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then + echo "[agent-branch-start] --base requires a non-empty branch name." >&2 + exit 1 +fi + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then + configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" + if [[ -n "$configured_base" ]]; then + BASE_BRANCH="$configured_base" + else + current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]]; then + BASE_BRANCH="$current_branch" + else + BASE_BRANCH="dev" + fi + fi +fi + if git show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then git fetch origin "${BASE_BRANCH}" --quiet start_ref="origin/${BASE_BRANCH}" @@ -158,6 +180,7 @@ if [[ "$WORKTREE_MODE" -eq 0 ]]; then fi git checkout -b "$branch_name" + git -C "$repo_root" config "branch.${branch_name}.musafetyBase" "$BASE_BRANCH" >/dev/null 2>&1 || true echo "[agent-branch-start] Created in-place branch: ${branch_name}" echo "$branch_name" exit 0 @@ -173,6 +196,7 @@ if [[ -e "$worktree_path" ]]; then fi git -C "$repo_root" worktree add -b "$branch_name" "$worktree_path" "$start_ref" +git -C "$repo_root" config "branch.${branch_name}.musafetyBase" "$BASE_BRANCH" >/dev/null 2>&1 || true if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then git -C "$worktree_path" branch --set-upstream-to="origin/${BASE_BRANCH}" "$branch_name" >/dev/null 2>&1 || true diff --git a/templates/scripts/codex-agent.sh b/templates/scripts/codex-agent.sh index 9c81e5e..2c560cc 100755 --- a/templates/scripts/codex-agent.sh +++ b/templates/scripts/codex-agent.sh @@ -3,9 +3,14 @@ set -euo pipefail TASK_NAME="${MUSAFETY_TASK_NAME:-task}" AGENT_NAME="${MUSAFETY_AGENT_NAME:-agent}" -BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-dev}" +BASE_BRANCH="${MUSAFETY_BASE_BRANCH:-}" +BASE_BRANCH_EXPLICIT=0 CODEX_BIN="${MUSAFETY_CODEX_BIN:-codex}" +if [[ -n "$BASE_BRANCH" ]]; then + BASE_BRANCH_EXPLICIT=1 +fi + while [[ $# -gt 0 ]]; do case "$1" in --task) @@ -18,6 +23,7 @@ while [[ $# -gt 0 ]]; do ;; --base) BASE_BRANCH="${2:-$BASE_BRANCH}" + BASE_BRANCH_EXPLICIT=1 shift 2 ;; --codex-bin) @@ -40,6 +46,7 @@ while [[ $# -gt 0 ]]; do fi if [[ $# -gt 0 && "${1:-}" != -* ]]; then BASE_BRANCH="$1" + BASE_BRANCH_EXPLICIT=1 shift fi break @@ -47,6 +54,11 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then + echo "[codex-agent] --base requires a non-empty branch name." >&2 + exit 1 +fi + if ! command -v "$CODEX_BIN" >/dev/null 2>&1; then echo "[codex-agent] Missing Codex CLI command: $CODEX_BIN" >&2 echo "[codex-agent] Install Codex first, then retry." >&2 @@ -58,7 +70,12 @@ if [[ ! -x "scripts/agent-branch-start.sh" ]]; then exit 1 fi -start_output="$(bash scripts/agent-branch-start.sh "$TASK_NAME" "$AGENT_NAME" "$BASE_BRANCH")" +start_args=("$TASK_NAME" "$AGENT_NAME") +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then + start_args+=("$BASE_BRANCH") +fi + +start_output="$(bash scripts/agent-branch-start.sh "${start_args[@]}")" printf '%s\n' "$start_output" worktree_path="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | tail -n1)" diff --git a/test/install.test.js b/test/install.test.js index fcfc638..953c371 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -110,6 +110,10 @@ function seedCommit(repoDir) { } function attachOriginRemote(repoDir) { + return attachOriginRemoteForBranch(repoDir, 'dev'); +} + +function attachOriginRemoteForBranch(repoDir, branchName) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-origin-')); const originPath = path.join(tempDir, 'origin.git'); @@ -119,7 +123,7 @@ function attachOriginRemote(repoDir) { result = runCmd('git', ['remote', 'add', 'origin', originPath], repoDir); assert.equal(result.status, 0, result.stderr); - result = runCmd('git', ['push', '-u', 'origin', 'dev'], repoDir); + result = runCmd('git', ['push', '-u', 'origin', branchName], repoDir); assert.equal(result.status, 0, result.stderr); return originPath; @@ -166,6 +170,18 @@ function escapeRegexLiteral(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +function extractCreatedBranch(output) { + const match = String(output || '').match(/\[agent-branch-start\] Created branch: (.+)/); + assert.ok(match, `missing created branch in output: ${output}`); + return match[1].trim(); +} + +function extractCreatedWorktree(output) { + const match = String(output || '').match(/\[agent-branch-start\] Worktree: (.+)/); + assert.ok(match, `missing worktree path in output: ${output}`); + return match[1].trim(); +} + test('setup provisions workflow files and repo config', () => { const repoDir = initRepo(); @@ -301,6 +317,80 @@ test('setup agent-branch-start supports explicit snapshot override without codex assert.match(result.stdout, /Created branch: agent\/bot\/\d{8}-\d{6}-prod-snapshot-one-ship-fix/); }); +test('setup agent-branch-start defaults base to current branch and stores per-branch base metadata', () => { + 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 musafety 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); + + result = runCmd('bash', ['scripts/agent-branch-start.sh', 'auto-base', 'bot'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + const agentBranch = extractCreatedBranch(result.stdout); + const agentWorktree = extractCreatedWorktree(result.stdout); + + const upstream = runCmd('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], agentWorktree); + assert.equal(upstream.status, 0, upstream.stderr || upstream.stdout); + assert.equal(upstream.stdout.trim(), 'origin/main'); + + const storedBase = runCmd('git', ['config', '--get', `branch.${agentBranch}.musafetyBase`], repoDir); + assert.equal(storedBase.status, 0, storedBase.stderr || storedBase.stdout); + assert.equal(storedBase.stdout.trim(), 'main'); +}); + +test('agent-branch-finish infers base from source branch metadata and updates main worktree', () => { + 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 musafety 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); + + result = runCmd('bash', ['scripts/agent-branch-start.sh', 'finish-from-dev', 'bot'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + const agentBranch = extractCreatedBranch(result.stdout); + const agentWorktree = extractCreatedWorktree(result.stdout); + + commitFile(agentWorktree, 'agent-finish-main.txt', 'merged via inferred main base\n', 'agent change for main'); + + result = runCmd('git', ['checkout', '-b', 'helper-finish'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + const auxWorktree = path.join(path.dirname(repoDir), 'aux-main-worktree'); + result = runCmd('git', ['worktree', 'add', auxWorktree, 'main'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const finish = runCmd('bash', ['scripts/agent-branch-finish.sh', '--branch', agentBranch], repoDir); + assert.equal(finish.status, 0, finish.stderr || finish.stdout); + assert.match(finish.stdout, new RegExp(`Merged '${escapeRegexLiteral(agentBranch)}' into 'main'`)); + + assert.equal( + fs.existsSync(path.join(auxWorktree, 'agent-finish-main.txt')), + true, + 'main worktree should be fast-forwarded after finish', + ); + + const localBranchExists = runCmd('git', ['show-ref', '--verify', '--quiet', `refs/heads/${agentBranch}`], repoDir); + assert.equal(localBranchExists.status, 1, localBranchExists.stderr || localBranchExists.stdout); +}); + test('default invocation runs non-mutating status output', () => { const repoDir = initRepo();