From a12ec854be3609cb0005a988f03df450641ab4e8 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 21 Apr 2026 14:45:52 +0200 Subject: [PATCH] Keep overlapping agent integration off the protected base branch Guardex needs a managed merge lane when multiple agent branches touch the same files. This adds the agent-branch-merge workflow, wires gx merge into setup-managed files and package scripts, and makes overlap/conflict handling resumable without merging directly onto main. Constraint: Protected base branches must remain untouched while overlapping agent work is integrated Rejected: Merge helper branches directly onto main | breaks the sandboxed integration workflow Confidence: high Scope-risk: moderate Directive: Keep conflict stops resumable and do not auto-resolve overlapping merges beyond ordered git merge behavior Tested: node --test test/merge-workflow.test.js Tested: node --check bin/multiagent-safety.js Tested: openspec validate agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28 --type change --strict Tested: openspec validate --specs Tested: git diff --check Not-tested: npm test still hangs after early passing output in this environment --- bin/multiagent-safety.js | 140 +++++- .../.openspec.yaml | 2 + .../proposal.md | 19 + .../specs/overlapping-agent-merge/spec.md | 42 ++ .../tasks.md | 25 ++ package.json | 1 + scripts/agent-branch-merge.sh | 421 ++++++++++++++++++ templates/scripts/agent-branch-merge.sh | 421 ++++++++++++++++++ test/merge-workflow.test.js | 276 ++++++++++++ 9 files changed, 1340 insertions(+), 7 deletions(-) create mode 100644 openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/.openspec.yaml create mode 100644 openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/proposal.md create mode 100644 openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/specs/overlapping-agent-merge/spec.md create mode 100644 openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/tasks.md create mode 100755 scripts/agent-branch-merge.sh create mode 100755 templates/scripts/agent-branch-merge.sh create mode 100644 test/merge-workflow.test.js diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 8d8d52b..8a33b4d 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -89,6 +89,7 @@ const TEMPLATE_ROOT = path.resolve(__dirname, '..', 'templates'); const TEMPLATE_FILES = [ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', + 'scripts/agent-branch-merge.sh', 'scripts/codex-agent.sh', 'scripts/guardex-docker-loader.sh', 'scripts/review-bot-watch.sh', @@ -112,6 +113,7 @@ const TEMPLATE_FILES = [ const REQUIRED_WORKFLOW_FILES = [ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', + 'scripts/agent-branch-merge.sh', 'scripts/guardex-docker-loader.sh', 'scripts/agent-worktree-prune.sh', 'scripts/agent-file-locks.py', @@ -126,6 +128,7 @@ const REQUIRED_PACKAGE_SCRIPTS = { 'agent:codex': 'bash ./scripts/codex-agent.sh', 'agent:branch:start': 'bash ./scripts/agent-branch-start.sh', 'agent:branch:finish': 'bash ./scripts/agent-branch-finish.sh', + 'agent:branch:merge': 'bash ./scripts/agent-branch-merge.sh', 'agent:cleanup': 'gx cleanup', 'agent:hooks:install': 'bash ./scripts/install-agent-git-hooks.sh', 'agent:locks:claim': 'python3 ./scripts/agent-file-locks.py claim', @@ -149,6 +152,7 @@ const REQUIRED_PACKAGE_SCRIPTS = { const EXECUTABLE_RELATIVE_PATHS = new Set([ 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', + 'scripts/agent-branch-merge.sh', 'scripts/codex-agent.sh', 'scripts/guardex-docker-loader.sh', 'scripts/review-bot-watch.sh', @@ -171,6 +175,7 @@ const CRITICAL_GUARDRAIL_PATHS = new Set([ '.githooks/post-checkout', 'scripts/agent-branch-start.sh', 'scripts/agent-branch-finish.sh', + 'scripts/agent-branch-merge.sh', 'scripts/agent-worktree-prune.sh', 'scripts/codex-agent.sh', 'scripts/agent-file-locks.py', @@ -233,6 +238,7 @@ const SUGGESTIBLE_COMMANDS = [ 'setup', 'doctor', 'agents', + 'merge', 'finish', 'report', 'protect', @@ -257,6 +263,7 @@ const CLI_COMMAND_DESCRIPTIONS = [ ['setup', 'Install, repair, and verify guardrails (flags: --repair, --install-only, --target)'], ['doctor', 'Repair drift + verify (auto-sandboxes on protected main)'], ['protect', 'Manage protected branches (list/add/remove/set/reset)'], + ['merge', 'Create/reuse an integration lane and merge overlapping agent branches'], ['sync', 'Sync agent branches with origin/'], ['finish', 'Commit + PR + merge completed agent branches (--all, --branch)'], ['cleanup', 'Prune merged/stale agent branches and worktrees'], @@ -304,13 +311,14 @@ const AI_SETUP_PROMPT = `GitGuardex (gx) setup checklist for Codex/Claude in thi 3) Repair: gx doctor 4) Task loop: bash scripts/codex-agent.sh "" "" or branch-start -> python3 scripts/agent-file-locks.py claim -> branch-finish -5) Finish: gx finish --all -6) Cleanup: gx cleanup -7) OpenSpec: /opsx:propose -> /opsx:apply -> /opsx:archive -8) Optional: gx protect add release staging -9) Optional: gx sync --check && gx sync -10) Review bot: install https://github.com/apps/cr-gpt + set OPENAI_API_KEY -11) Fork sync: install https://github.com/apps/pull + cp .github/pull.yml.example .github/pull.yml +5) Integrate: gx merge --branch --branch +6) Finish: gx finish --all +7) Cleanup: gx cleanup +8) OpenSpec: /opsx:propose -> /opsx:apply -> /opsx:archive +9) Optional: gx protect add release staging +10) Optional: gx sync --check && gx sync +11) Review bot: install https://github.com/apps/cr-gpt + set OPENAI_API_KEY +12) Fork sync: install https://github.com/apps/pull + cp .github/pull.yml.example .github/pull.yml `; const AI_SETUP_COMMANDS = `npm i -g @imdeadpool/guardex @@ -319,6 +327,7 @@ gx setup gx doctor bash scripts/codex-agent.sh "" "" python3 scripts/agent-file-locks.py claim --branch "" +gx merge --branch "" --branch "" gx finish --all gx cleanup gx protect add release staging @@ -3543,6 +3552,82 @@ function parseCleanupArgs(rawArgs) { return options; } +function parseMergeArgs(rawArgs) { + const options = { + target: process.cwd(), + base: '', + into: '', + branches: [], + task: '', + agent: '', + }; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--target') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--target requires a path value'); + } + options.target = next; + index += 1; + continue; + } + if (arg === '--base') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--base requires a branch value'); + } + options.base = next; + index += 1; + continue; + } + if (arg === '--into') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--into requires an agent/* branch value'); + } + options.into = next; + index += 1; + continue; + } + if (arg === '--branch') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--branch requires an agent/* branch value'); + } + options.branches.push(next); + index += 1; + continue; + } + if (arg === '--task') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--task requires a task value'); + } + options.task = next; + index += 1; + continue; + } + if (arg === '--agent') { + const next = rawArgs[index + 1]; + if (!next) { + throw new Error('--agent requires an agent value'); + } + options.agent = next; + index += 1; + continue; + } + throw new Error(`Unknown option: ${arg}`); + } + + if (options.branches.length === 0) { + throw new Error('merge requires at least one --branch input'); + } + + return options; +} + function parseFinishArgs(rawArgs) { const options = { target: process.cwd(), @@ -6587,6 +6672,46 @@ function cleanup(rawArgs) { process.exitCode = 0; } +function merge(rawArgs) { + const options = parseMergeArgs(rawArgs); + const repoRoot = resolveRepoRoot(options.target); + const mergeScript = path.join(repoRoot, 'scripts', 'agent-branch-merge.sh'); + + if (!fs.existsSync(mergeScript)) { + throw new Error(`Missing merge script: ${mergeScript}. Run '${SHORT_TOOL_NAME} setup' first.`); + } + + const args = [mergeScript]; + if (options.base) { + args.push('--base', options.base); + } + if (options.into) { + args.push('--into', options.into); + } + if (options.task) { + args.push('--task', options.task); + } + if (options.agent) { + args.push('--agent', options.agent); + } + for (const branch of options.branches) { + args.push('--branch', branch); + } + + const mergeResult = run('bash', args, { cwd: repoRoot, stdio: 'pipe' }); + if (mergeResult.stdout) { + process.stdout.write(mergeResult.stdout); + } + if (mergeResult.stderr) { + process.stderr.write(mergeResult.stderr); + } + if (mergeResult.status !== 0) { + throw new Error(`merge command failed with status ${mergeResult.status}`); + } + + process.exitCode = 0; +} + function finish(rawArgs) { const options = parseFinishArgs(rawArgs); const repoRoot = resolveRepoRoot(options.target); @@ -7073,6 +7198,7 @@ function main() { if (command === 'prompt') return prompt(rest); if (command === 'doctor') return doctor(rest); if (command === 'agents') return agents(rest); + if (command === 'merge') return merge(rest); if (command === 'finish') return finish(rest); if (command === 'report') return report(rest); if (command === 'protect') return protect(rest); diff --git a/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/.openspec.yaml b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/.openspec.yaml new file mode 100644 index 0000000..4b8c565 --- /dev/null +++ b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-21 diff --git a/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/proposal.md b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/proposal.md new file mode 100644 index 0000000..aeca6ba --- /dev/null +++ b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/proposal.md @@ -0,0 +1,19 @@ +## Why + +- GitGuardex currently finishes one `agent/*` branch into the base branch at a time, but it does not provide an integration workflow for the common case where multiple agent worktrees touched the same implementation files. +- In that situation users end up staring at several parallel worktrees with overlapping edits and no first-class Guardex command that creates the right integration branch/worktree, reports overlap, and gives a safe place to resolve conflicts. +- OpenSpec already treats implementation as owner/helper lanes with durable artifacts, so the merge workflow should preserve that model instead of pushing users back to ad hoc manual git work on the protected base. + +## What Changes + +- Add a first-class `gx merge` command plus a managed `scripts/agent-branch-merge.sh` workflow. +- Let the workflow either create a fresh integration `agent/*` branch/worktree from the configured base branch or merge helper branches into an existing owner branch via `--into`. +- Report overlapping changed files across the requested source branches before merging so users can see where collisions are expected, especially inside shared implementation files and OpenSpec surfaces. +- Merge branches in explicit order, stop on conflicts without touching the protected base branch, and print resumable instructions that keep conflict resolution inside the integration worktree. +- Ship the new script through the setup/templates/package metadata path so downstream repos get the same capability. + +## Impact + +- Affected surfaces: `gx` CLI command catalog, managed workflow scripts/templates, setup/doctor script expectations, and regression tests. +- Risk: merge automation is sensitive to dirty worktrees and stale branches, so the implementation needs strict preflight checks and clear conflict-stop behavior. +- Rollout: local CLI/script addition only; no data migration. diff --git a/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/specs/overlapping-agent-merge/spec.md b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/specs/overlapping-agent-merge/spec.md new file mode 100644 index 0000000..2464106 --- /dev/null +++ b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/specs/overlapping-agent-merge/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: integration merge runs inside an agent worktree +GitGuardex SHALL merge overlapping `agent/*` branches inside an integration `agent/*` branch/worktree instead of merging directly on the protected base branch. + +#### Scenario: create a fresh integration lane +- **WHEN** a user runs `gx merge` with one or more `--branch agent/...` inputs and no `--into` +- **THEN** the system creates a new integration `agent/*` branch/worktree from the configured base branch +- **AND** all requested merges run inside that integration worktree +- **AND** the command prints the integration branch and worktree path. + +#### Scenario: reuse an existing owner lane +- **WHEN** a user runs `gx merge --into ` with additional source branches +- **THEN** the system reuses that owner branch as the merge target +- **AND** it refuses to proceed if the target worktree has uncommitted changes or an in-progress merge operation. + +### Requirement: overlapping file edits are reported before merge +GitGuardex SHALL detect and report files changed by more than one requested source branch before applying the merges. + +#### Scenario: overlapping implementation files exist +- **WHEN** two or more requested source branches changed the same file relative to the merge base +- **THEN** the command prints each overlapping file +- **AND** it identifies the source branches that changed that file +- **AND** it still allows the user to continue into the integration lane unless another hard preflight check fails. + +### Requirement: conflicts stop with resumable guidance +GitGuardex SHALL stop on merge conflicts inside the integration worktree and provide resumable next-step guidance without mutating the protected base branch. + +#### Scenario: sequential merge hits a conflict +- **WHEN** `gx merge` successfully merges earlier source branches and then encounters a conflict on a later source branch +- **THEN** the command exits non-zero +- **AND** it prints the target integration branch/worktree, the source branch that conflicted, and the conflicting files +- **AND** it tells the user how to resolve or abort the conflict inside the integration worktree +- **AND** it prints the remaining branches so the merge sequence can be resumed intentionally afterward. + +### Requirement: setup-managed repos receive the merge workflow +GitGuardex setup/doctor SHALL install the managed merge workflow files and package script entry needed to run `gx merge`. + +#### Scenario: setup bootstraps a repo +- **WHEN** `gx setup` or `gx doctor --repair` installs managed workflow files +- **THEN** the repo contains `scripts/agent-branch-merge.sh` +- **AND** the repo package scripts include a stable merge entry point for the managed workflow. diff --git a/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/tasks.md b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/tasks.md new file mode 100644 index 0000000..2468cf1 --- /dev/null +++ b/openspec/changes/agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28/tasks.md @@ -0,0 +1,25 @@ +## 1. Specification + +- [x] 1.1 Finalize acceptance criteria for the overlapping-agent merge workflow. +- [x] 1.2 Define normative requirements for integration-branch creation, overlap reporting, and conflict-stop behavior. + +## 2. Implementation + +- [x] 2.1 Add a managed `agent-branch-merge` script that can create or reuse an integration worktree and merge multiple `agent/*` branches in order. +- [x] 2.2 Add `gx merge` CLI wiring, package metadata, and template/setup propagation for the new workflow. +- [x] 2.3 Keep the protected base branch untouched while merging and print resumable instructions for conflict resolution. + +## 3. Verification + +- [x] 3.1 Add/update focused regression coverage for clean merges, overlap reporting, and conflict-stop behavior. +- [ ] 3.2 Run `npm test`. BLOCKED: full suite produced early passing output but then stayed silent/hung in this environment; focused `node --test test/merge-workflow.test.js` passed. +- [x] 3.3 Run `node --check bin/multiagent-safety.js`. +- [x] 3.4 Run `openspec validate agent-codex-merge-overlapping-agent-branches-2026-04-21-14-28 --type change --strict`. +- [x] 3.5 Run `openspec validate --specs`. +- [x] 3.6 Run `git diff --check`. + +## 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/package.json b/package.json index a8dfd04..20c1daf 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "agent:codex": "bash ./scripts/codex-agent.sh", "agent:branch:start": "bash ./scripts/agent-branch-start.sh", "agent:branch:finish": "bash ./scripts/agent-branch-finish.sh", + "agent:branch:merge": "bash ./scripts/agent-branch-merge.sh", "agent:cleanup": "gx cleanup", "agent:hooks:install": "bash ./scripts/install-agent-git-hooks.sh", "agent:locks:claim": "python3 ./scripts/agent-file-locks.py claim", diff --git a/scripts/agent-branch-merge.sh b/scripts/agent-branch-merge.sh new file mode 100755 index 0000000..c8ab622 --- /dev/null +++ b/scripts/agent-branch-merge.sh @@ -0,0 +1,421 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_BRANCH="" +BASE_BRANCH_EXPLICIT=0 +TARGET_BRANCH="" +TASK_NAME="" +AGENT_NAME="${GUARDEX_MERGE_AGENT_NAME:-codex}" +declare -a SOURCE_BRANCHES=() + +usage() { + cat <<'EOF' +Usage: scripts/agent-branch-merge.sh --branch [--branch ...] [--into ] [--task ] [--agent ] [--base ] + +Examples: + bash scripts/agent-branch-merge.sh --branch agent/codex/ui-a --branch agent/codex/ui-b + bash scripts/agent-branch-merge.sh --into agent/codex/owner-lane --branch agent/codex/helper-a --branch agent/codex/helper-b +EOF +} + +sanitize_slug() { + local raw="$1" + local fallback="${2:-merge-agent-branches}" + local slug + slug="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')" + if [[ -z "$slug" ]]; then + slug="$fallback" + fi + printf '%s' "$slug" +} + +resolve_base_branch() { + local repo="$1" + local explicit_target="$2" + local configured="" + local branch_base="" + + if [[ -n "$explicit_target" ]]; then + branch_base="$(git -C "$repo" config --get "branch.${explicit_target}.guardexBase" || true)" + if [[ -n "$branch_base" ]]; then + printf '%s' "$branch_base" + return 0 + fi + fi + + configured="$(git -C "$repo" config --get multiagent.baseBranch || true)" + if [[ -n "$configured" ]]; then + printf '%s' "$configured" + return 0 + fi + + for fallback in dev main master; do + if git -C "$repo" show-ref --verify --quiet "refs/heads/${fallback}" \ + || git -C "$repo" show-ref --verify --quiet "refs/remotes/origin/${fallback}"; then + printf '%s' "$fallback" + return 0 + fi + done + + printf '%s' "dev" +} + +get_worktree_for_branch() { + local repo="$1" + local branch="$2" + git -C "$repo" worktree list --porcelain | awk -v target="refs/heads/${branch}" ' + $1 == "worktree" { wt = $2 } + $1 == "branch" && $2 == target { print wt; exit } + ' +} + +is_clean_worktree() { + local wt="$1" + git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \ + && git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \ + && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]] +} + +has_in_progress_git_op() { + local wt="$1" + local git_dir="" + git_dir="$(git -C "$wt" rev-parse --git-dir 2>/dev/null || true)" + if [[ -z "$git_dir" ]]; then + return 1 + fi + if [[ "$git_dir" != /* ]]; then + git_dir="$(cd "$wt/$git_dir" 2>/dev/null && pwd -P || true)" + fi + if [[ -z "$git_dir" ]]; then + return 1 + fi + [[ -f "${git_dir}/MERGE_HEAD" || -d "${git_dir}/rebase-merge" || -d "${git_dir}/rebase-apply" ]] +} + +select_unique_worktree_path() { + local root="$1" + local name="$2" + local candidate="${root}/${name}" + local suffix=2 + while [[ -e "$candidate" ]]; do + candidate="${root}/${name}-${suffix}" + suffix=$((suffix + 1)) + done + printf '%s' "$candidate" +} + +branch_exists() { + local repo="$1" + local branch="$2" + git -C "$repo" show-ref --verify --quiet "refs/heads/${branch}" +} + +branch_is_agent_lane() { + local branch="$1" + [[ "$branch" == agent/* ]] +} + +array_contains() { + local needle="$1" + shift || true + local item + for item in "$@"; do + if [[ "$item" == "$needle" ]]; then + return 0 + fi + done + return 1 +} + +collect_branch_files() { + local repo="$1" + local base_ref="$2" + local branch="$3" + git -C "$repo" diff --name-only "${base_ref}...${branch}" -- . ":(exclude).omx/state/agent-file-locks.json" 2>/dev/null || true +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --base) + BASE_BRANCH="${2:-}" + BASE_BRANCH_EXPLICIT=1 + shift 2 + ;; + --into) + TARGET_BRANCH="${2:-}" + shift 2 + ;; + --branch) + SOURCE_BRANCHES+=("${2:-}") + shift 2 + ;; + --task) + TASK_NAME="${2:-}" + shift 2 + ;; + --agent) + AGENT_NAME="${2:-codex}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[agent-branch-merge] Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[agent-branch-merge] Not inside a git repository." >&2 + exit 1 +fi + +repo_root="$(git rev-parse --show-toplevel)" +common_git_dir_raw="$(git -C "$repo_root" rev-parse --git-common-dir)" +if [[ "$common_git_dir_raw" == /* ]]; then + common_git_dir="$common_git_dir_raw" +else + common_git_dir="$(cd "$repo_root/$common_git_dir_raw" && pwd -P)" +fi +repo_common_root="$(cd "$common_git_dir/.." && pwd -P)" +agent_worktree_root="${repo_common_root}/.omx/agent-worktrees" +mkdir -p "$agent_worktree_root" + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then + echo "[agent-branch-merge] --base requires a branch value." >&2 + exit 1 +fi + +if [[ -z "$TARGET_BRANCH" && "${#SOURCE_BRANCHES[@]}" -lt 1 ]]; then + echo "[agent-branch-merge] Provide at least one --branch source lane." >&2 + exit 1 +fi + +if [[ -n "$TARGET_BRANCH" ]] && ! branch_is_agent_lane "$TARGET_BRANCH"; then + echo "[agent-branch-merge] --into must reference an agent/* branch: ${TARGET_BRANCH}" >&2 + exit 1 +fi + +deduped_sources=() +for branch in "${SOURCE_BRANCHES[@]}"; do + if [[ -z "$branch" ]]; then + echo "[agent-branch-merge] --branch requires an agent/* branch value." >&2 + exit 1 + fi + if ! branch_is_agent_lane "$branch"; then + echo "[agent-branch-merge] Source branch must be agent/*: ${branch}" >&2 + exit 1 + fi + if ! branch_exists "$repo_root" "$branch"; then + echo "[agent-branch-merge] Local source branch not found: ${branch}" >&2 + exit 1 + fi + if ! array_contains "$branch" "${deduped_sources[@]}"; then + deduped_sources+=("$branch") + fi +done +SOURCE_BRANCHES=("${deduped_sources[@]}") + +if [[ "${#SOURCE_BRANCHES[@]}" -eq 0 ]]; then + echo "[agent-branch-merge] No unique source branches were provided." >&2 + exit 1 +fi + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then + BASE_BRANCH="$(resolve_base_branch "$repo_root" "$TARGET_BRANCH")" +fi + +if [[ -z "$BASE_BRANCH" ]]; then + echo "[agent-branch-merge] Unable to resolve a base branch." >&2 + exit 1 +fi + +start_ref="$BASE_BRANCH" +if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then + git -C "$repo_root" fetch origin "$BASE_BRANCH" --quiet + start_ref="origin/${BASE_BRANCH}" +elif ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then + echo "[agent-branch-merge] Base branch not found locally or on origin: ${BASE_BRANCH}" >&2 + exit 1 +fi + +target_worktree="" +target_created=0 + +if [[ -z "$TARGET_BRANCH" ]]; then + if [[ -z "$TASK_NAME" ]]; then + first_hint="$(printf '%s' "${SOURCE_BRANCHES[0]}" | sed -E 's#^agent/[^/]+/##; s#^agent/##')" + source_count="${#SOURCE_BRANCHES[@]}" + if [[ "$source_count" -gt 1 ]]; then + TASK_NAME="$(sanitize_slug "merge-${first_hint}-and-$((source_count - 1))-more" "merge-agent-branches")" + else + TASK_NAME="$(sanitize_slug "merge-${first_hint}" "merge-agent-branches")" + fi + else + TASK_NAME="$(sanitize_slug "$TASK_NAME" "merge-agent-branches")" + fi + + start_output="" + if ! start_output="$( + cd "$repo_root" + env GUARDEX_OPENSPEC_AUTO_INIT=1 bash "scripts/agent-branch-start.sh" "$TASK_NAME" "$AGENT_NAME" "$BASE_BRANCH" 2>&1 + )"; then + printf '%s\n' "$start_output" >&2 + exit 1 + fi + + printf '%s\n' "$start_output" + TARGET_BRANCH="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Created branch: //p' | head -n 1)" + target_worktree="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | head -n 1)" + if [[ -z "$TARGET_BRANCH" || -z "$target_worktree" ]]; then + echo "[agent-branch-merge] Unable to parse target branch/worktree from agent-branch-start output." >&2 + exit 1 + fi + target_created=1 +else + if ! branch_exists "$repo_root" "$TARGET_BRANCH"; then + echo "[agent-branch-merge] Target branch not found: ${TARGET_BRANCH}" >&2 + exit 1 + fi + + target_worktree="$(get_worktree_for_branch "$repo_root" "$TARGET_BRANCH")" + if [[ -z "$target_worktree" ]]; then + target_worktree="$(select_unique_worktree_path "$agent_worktree_root" "${TARGET_BRANCH//\//__}")" + git -C "$repo_root" worktree add "$target_worktree" "$TARGET_BRANCH" >/dev/null + target_created=1 + echo "[agent-branch-merge] Attached worktree for target branch '${TARGET_BRANCH}': ${target_worktree}" + fi +fi + +if [[ "$TARGET_BRANCH" == "$BASE_BRANCH" ]]; then + echo "[agent-branch-merge] Target branch must not equal the protected base branch '${BASE_BRANCH}'." >&2 + exit 1 +fi + +if ! is_clean_worktree "$target_worktree"; then + if [[ "$target_created" -eq 1 ]]; then + echo "[agent-branch-merge] Target worktree has freshly generated scaffold changes; continuing inside the new integration lane." + else + echo "[agent-branch-merge] Target worktree is not clean: ${target_worktree}" >&2 + echo "[agent-branch-merge] Commit, stash, or discard local changes before merging agent lanes." >&2 + exit 1 + fi +fi + +if has_in_progress_git_op "$target_worktree"; then + echo "[agent-branch-merge] Target worktree has an in-progress merge/rebase: ${target_worktree}" >&2 + echo "[agent-branch-merge] Resolve or abort that git operation before running the merge workflow." >&2 + exit 1 +fi + +for source_branch in "${SOURCE_BRANCHES[@]}"; do + if [[ "$source_branch" == "$TARGET_BRANCH" ]]; then + echo "[agent-branch-merge] Source branch list includes the target branch: ${source_branch}" >&2 + exit 1 + fi + source_worktree="$(get_worktree_for_branch "$repo_root" "$source_branch")" + if [[ -n "$source_worktree" ]] && ! is_clean_worktree "$source_worktree"; then + echo "[agent-branch-merge] Source worktree is not clean for '${source_branch}': ${source_worktree}" >&2 + echo "[agent-branch-merge] Commit or stash source-lane changes before integration." >&2 + exit 1 + fi +done + +pending_branches=() +for source_branch in "${SOURCE_BRANCHES[@]}"; do + if git -C "$repo_root" merge-base --is-ancestor "$source_branch" "$TARGET_BRANCH" >/dev/null 2>&1; then + echo "[agent-branch-merge] Skipping '${source_branch}' because it is already integrated into '${TARGET_BRANCH}'." + continue + fi + pending_branches+=("$source_branch") +done + +if [[ "${#pending_branches[@]}" -eq 0 ]]; then + echo "[agent-branch-merge] No pending source branches remain for target '${TARGET_BRANCH}'." + echo "[agent-branch-merge] Target worktree: ${target_worktree}" + exit 0 +fi + +declare -A file_to_branches=() +declare -a overlap_files=() +for source_branch in "${pending_branches[@]}"; do + while IFS= read -r changed_file; do + [[ -z "$changed_file" ]] && continue + existing="${file_to_branches[$changed_file]:-}" + if [[ -z "$existing" ]]; then + file_to_branches["$changed_file"]="$source_branch" + continue + fi + if [[ ",${existing}," == *",${source_branch},"* ]]; then + continue + fi + file_to_branches["$changed_file"]="${existing},${source_branch}" + if ! array_contains "$changed_file" "${overlap_files[@]}"; then + overlap_files+=("$changed_file") + fi + done < <(collect_branch_files "$repo_root" "$start_ref" "$source_branch") +done + +echo "[agent-branch-merge] Target branch: ${TARGET_BRANCH}" +echo "[agent-branch-merge] Target worktree: ${target_worktree}" +echo "[agent-branch-merge] Base branch: ${BASE_BRANCH} (${start_ref})" +echo "[agent-branch-merge] Merge order: ${pending_branches[*]}" + +if [[ "${#overlap_files[@]}" -gt 0 ]]; then + echo "[agent-branch-merge] Overlapping changed files detected across requested branches:" + for overlap_file in "${overlap_files[@]}"; do + branches_csv="${file_to_branches[$overlap_file]}" + branches_display="$(printf '%s' "$branches_csv" | sed 's/,/, /g')" + echo " - ${overlap_file} <- ${branches_display}" + done +else + echo "[agent-branch-merge] No overlapping changed files detected across requested branches." +fi + +for index in "${!pending_branches[@]}"; do + source_branch="${pending_branches[$index]}" + echo "[agent-branch-merge] Merging '${source_branch}' into '${TARGET_BRANCH}'..." + if git -C "$target_worktree" merge --no-ff --no-edit "$source_branch"; then + echo "[agent-branch-merge] Merged '${source_branch}'." + continue + fi + + conflict_files="$(git -C "$target_worktree" diff --name-only --diff-filter=U || true)" + echo "[agent-branch-merge] Merge conflict detected while merging '${source_branch}' into '${TARGET_BRANCH}'." >&2 + echo "[agent-branch-merge] Target worktree: ${target_worktree}" >&2 + if [[ -n "$conflict_files" ]]; then + echo "[agent-branch-merge] Conflicting files:" >&2 + while IFS= read -r conflict_file; do + [[ -n "$conflict_file" ]] && echo " - ${conflict_file}" >&2 + done <<< "$conflict_files" + fi + echo "[agent-branch-merge] Resolve or abort inside the integration worktree:" >&2 + echo " cd \"${target_worktree}\"" >&2 + echo " git status" >&2 + echo " git add && git commit" >&2 + echo " # or: git merge --abort" >&2 + + remaining_branches=("${pending_branches[@]:$((index + 1))}") + if [[ "${#remaining_branches[@]}" -gt 0 ]]; then + echo "[agent-branch-merge] Remaining branches:" >&2 + for remaining in "${remaining_branches[@]}"; do + echo " - ${remaining}" >&2 + done + resume_cmd="gx merge --into ${TARGET_BRANCH} --base ${BASE_BRANCH}" + for remaining in "${remaining_branches[@]}"; do + resume_cmd="${resume_cmd} --branch ${remaining}" + done + echo "[agent-branch-merge] Resume after resolving with: ${resume_cmd}" >&2 + fi + exit 1 +done + +echo "[agent-branch-merge] Merge sequence complete for '${TARGET_BRANCH}'." +if [[ "$target_created" -eq 1 ]]; then + echo "[agent-branch-merge] Review and verify in '${target_worktree}', then finish the integration branch when ready." +fi +echo "[agent-branch-merge] Next step: bash scripts/agent-branch-finish.sh --branch \"${TARGET_BRANCH}\" --base \"${BASE_BRANCH}\" --via-pr --wait-for-merge --cleanup" diff --git a/templates/scripts/agent-branch-merge.sh b/templates/scripts/agent-branch-merge.sh new file mode 100755 index 0000000..c8ab622 --- /dev/null +++ b/templates/scripts/agent-branch-merge.sh @@ -0,0 +1,421 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_BRANCH="" +BASE_BRANCH_EXPLICIT=0 +TARGET_BRANCH="" +TASK_NAME="" +AGENT_NAME="${GUARDEX_MERGE_AGENT_NAME:-codex}" +declare -a SOURCE_BRANCHES=() + +usage() { + cat <<'EOF' +Usage: scripts/agent-branch-merge.sh --branch [--branch ...] [--into ] [--task ] [--agent ] [--base ] + +Examples: + bash scripts/agent-branch-merge.sh --branch agent/codex/ui-a --branch agent/codex/ui-b + bash scripts/agent-branch-merge.sh --into agent/codex/owner-lane --branch agent/codex/helper-a --branch agent/codex/helper-b +EOF +} + +sanitize_slug() { + local raw="$1" + local fallback="${2:-merge-agent-branches}" + local slug + slug="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')" + if [[ -z "$slug" ]]; then + slug="$fallback" + fi + printf '%s' "$slug" +} + +resolve_base_branch() { + local repo="$1" + local explicit_target="$2" + local configured="" + local branch_base="" + + if [[ -n "$explicit_target" ]]; then + branch_base="$(git -C "$repo" config --get "branch.${explicit_target}.guardexBase" || true)" + if [[ -n "$branch_base" ]]; then + printf '%s' "$branch_base" + return 0 + fi + fi + + configured="$(git -C "$repo" config --get multiagent.baseBranch || true)" + if [[ -n "$configured" ]]; then + printf '%s' "$configured" + return 0 + fi + + for fallback in dev main master; do + if git -C "$repo" show-ref --verify --quiet "refs/heads/${fallback}" \ + || git -C "$repo" show-ref --verify --quiet "refs/remotes/origin/${fallback}"; then + printf '%s' "$fallback" + return 0 + fi + done + + printf '%s' "dev" +} + +get_worktree_for_branch() { + local repo="$1" + local branch="$2" + git -C "$repo" worktree list --porcelain | awk -v target="refs/heads/${branch}" ' + $1 == "worktree" { wt = $2 } + $1 == "branch" && $2 == target { print wt; exit } + ' +} + +is_clean_worktree() { + local wt="$1" + git -C "$wt" diff --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \ + && git -C "$wt" diff --cached --quiet -- . ":(exclude).omx/state/agent-file-locks.json" \ + && [[ -z "$(git -C "$wt" ls-files --others --exclude-standard)" ]] +} + +has_in_progress_git_op() { + local wt="$1" + local git_dir="" + git_dir="$(git -C "$wt" rev-parse --git-dir 2>/dev/null || true)" + if [[ -z "$git_dir" ]]; then + return 1 + fi + if [[ "$git_dir" != /* ]]; then + git_dir="$(cd "$wt/$git_dir" 2>/dev/null && pwd -P || true)" + fi + if [[ -z "$git_dir" ]]; then + return 1 + fi + [[ -f "${git_dir}/MERGE_HEAD" || -d "${git_dir}/rebase-merge" || -d "${git_dir}/rebase-apply" ]] +} + +select_unique_worktree_path() { + local root="$1" + local name="$2" + local candidate="${root}/${name}" + local suffix=2 + while [[ -e "$candidate" ]]; do + candidate="${root}/${name}-${suffix}" + suffix=$((suffix + 1)) + done + printf '%s' "$candidate" +} + +branch_exists() { + local repo="$1" + local branch="$2" + git -C "$repo" show-ref --verify --quiet "refs/heads/${branch}" +} + +branch_is_agent_lane() { + local branch="$1" + [[ "$branch" == agent/* ]] +} + +array_contains() { + local needle="$1" + shift || true + local item + for item in "$@"; do + if [[ "$item" == "$needle" ]]; then + return 0 + fi + done + return 1 +} + +collect_branch_files() { + local repo="$1" + local base_ref="$2" + local branch="$3" + git -C "$repo" diff --name-only "${base_ref}...${branch}" -- . ":(exclude).omx/state/agent-file-locks.json" 2>/dev/null || true +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --base) + BASE_BRANCH="${2:-}" + BASE_BRANCH_EXPLICIT=1 + shift 2 + ;; + --into) + TARGET_BRANCH="${2:-}" + shift 2 + ;; + --branch) + SOURCE_BRANCHES+=("${2:-}") + shift 2 + ;; + --task) + TASK_NAME="${2:-}" + shift 2 + ;; + --agent) + AGENT_NAME="${2:-codex}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[agent-branch-merge] Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[agent-branch-merge] Not inside a git repository." >&2 + exit 1 +fi + +repo_root="$(git rev-parse --show-toplevel)" +common_git_dir_raw="$(git -C "$repo_root" rev-parse --git-common-dir)" +if [[ "$common_git_dir_raw" == /* ]]; then + common_git_dir="$common_git_dir_raw" +else + common_git_dir="$(cd "$repo_root/$common_git_dir_raw" && pwd -P)" +fi +repo_common_root="$(cd "$common_git_dir/.." && pwd -P)" +agent_worktree_root="${repo_common_root}/.omx/agent-worktrees" +mkdir -p "$agent_worktree_root" + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -z "$BASE_BRANCH" ]]; then + echo "[agent-branch-merge] --base requires a branch value." >&2 + exit 1 +fi + +if [[ -z "$TARGET_BRANCH" && "${#SOURCE_BRANCHES[@]}" -lt 1 ]]; then + echo "[agent-branch-merge] Provide at least one --branch source lane." >&2 + exit 1 +fi + +if [[ -n "$TARGET_BRANCH" ]] && ! branch_is_agent_lane "$TARGET_BRANCH"; then + echo "[agent-branch-merge] --into must reference an agent/* branch: ${TARGET_BRANCH}" >&2 + exit 1 +fi + +deduped_sources=() +for branch in "${SOURCE_BRANCHES[@]}"; do + if [[ -z "$branch" ]]; then + echo "[agent-branch-merge] --branch requires an agent/* branch value." >&2 + exit 1 + fi + if ! branch_is_agent_lane "$branch"; then + echo "[agent-branch-merge] Source branch must be agent/*: ${branch}" >&2 + exit 1 + fi + if ! branch_exists "$repo_root" "$branch"; then + echo "[agent-branch-merge] Local source branch not found: ${branch}" >&2 + exit 1 + fi + if ! array_contains "$branch" "${deduped_sources[@]}"; then + deduped_sources+=("$branch") + fi +done +SOURCE_BRANCHES=("${deduped_sources[@]}") + +if [[ "${#SOURCE_BRANCHES[@]}" -eq 0 ]]; then + echo "[agent-branch-merge] No unique source branches were provided." >&2 + exit 1 +fi + +if [[ "$BASE_BRANCH_EXPLICIT" -eq 0 ]]; then + BASE_BRANCH="$(resolve_base_branch "$repo_root" "$TARGET_BRANCH")" +fi + +if [[ -z "$BASE_BRANCH" ]]; then + echo "[agent-branch-merge] Unable to resolve a base branch." >&2 + exit 1 +fi + +start_ref="$BASE_BRANCH" +if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then + git -C "$repo_root" fetch origin "$BASE_BRANCH" --quiet + start_ref="origin/${BASE_BRANCH}" +elif ! git -C "$repo_root" show-ref --verify --quiet "refs/heads/${BASE_BRANCH}"; then + echo "[agent-branch-merge] Base branch not found locally or on origin: ${BASE_BRANCH}" >&2 + exit 1 +fi + +target_worktree="" +target_created=0 + +if [[ -z "$TARGET_BRANCH" ]]; then + if [[ -z "$TASK_NAME" ]]; then + first_hint="$(printf '%s' "${SOURCE_BRANCHES[0]}" | sed -E 's#^agent/[^/]+/##; s#^agent/##')" + source_count="${#SOURCE_BRANCHES[@]}" + if [[ "$source_count" -gt 1 ]]; then + TASK_NAME="$(sanitize_slug "merge-${first_hint}-and-$((source_count - 1))-more" "merge-agent-branches")" + else + TASK_NAME="$(sanitize_slug "merge-${first_hint}" "merge-agent-branches")" + fi + else + TASK_NAME="$(sanitize_slug "$TASK_NAME" "merge-agent-branches")" + fi + + start_output="" + if ! start_output="$( + cd "$repo_root" + env GUARDEX_OPENSPEC_AUTO_INIT=1 bash "scripts/agent-branch-start.sh" "$TASK_NAME" "$AGENT_NAME" "$BASE_BRANCH" 2>&1 + )"; then + printf '%s\n' "$start_output" >&2 + exit 1 + fi + + printf '%s\n' "$start_output" + TARGET_BRANCH="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Created branch: //p' | head -n 1)" + target_worktree="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | head -n 1)" + if [[ -z "$TARGET_BRANCH" || -z "$target_worktree" ]]; then + echo "[agent-branch-merge] Unable to parse target branch/worktree from agent-branch-start output." >&2 + exit 1 + fi + target_created=1 +else + if ! branch_exists "$repo_root" "$TARGET_BRANCH"; then + echo "[agent-branch-merge] Target branch not found: ${TARGET_BRANCH}" >&2 + exit 1 + fi + + target_worktree="$(get_worktree_for_branch "$repo_root" "$TARGET_BRANCH")" + if [[ -z "$target_worktree" ]]; then + target_worktree="$(select_unique_worktree_path "$agent_worktree_root" "${TARGET_BRANCH//\//__}")" + git -C "$repo_root" worktree add "$target_worktree" "$TARGET_BRANCH" >/dev/null + target_created=1 + echo "[agent-branch-merge] Attached worktree for target branch '${TARGET_BRANCH}': ${target_worktree}" + fi +fi + +if [[ "$TARGET_BRANCH" == "$BASE_BRANCH" ]]; then + echo "[agent-branch-merge] Target branch must not equal the protected base branch '${BASE_BRANCH}'." >&2 + exit 1 +fi + +if ! is_clean_worktree "$target_worktree"; then + if [[ "$target_created" -eq 1 ]]; then + echo "[agent-branch-merge] Target worktree has freshly generated scaffold changes; continuing inside the new integration lane." + else + echo "[agent-branch-merge] Target worktree is not clean: ${target_worktree}" >&2 + echo "[agent-branch-merge] Commit, stash, or discard local changes before merging agent lanes." >&2 + exit 1 + fi +fi + +if has_in_progress_git_op "$target_worktree"; then + echo "[agent-branch-merge] Target worktree has an in-progress merge/rebase: ${target_worktree}" >&2 + echo "[agent-branch-merge] Resolve or abort that git operation before running the merge workflow." >&2 + exit 1 +fi + +for source_branch in "${SOURCE_BRANCHES[@]}"; do + if [[ "$source_branch" == "$TARGET_BRANCH" ]]; then + echo "[agent-branch-merge] Source branch list includes the target branch: ${source_branch}" >&2 + exit 1 + fi + source_worktree="$(get_worktree_for_branch "$repo_root" "$source_branch")" + if [[ -n "$source_worktree" ]] && ! is_clean_worktree "$source_worktree"; then + echo "[agent-branch-merge] Source worktree is not clean for '${source_branch}': ${source_worktree}" >&2 + echo "[agent-branch-merge] Commit or stash source-lane changes before integration." >&2 + exit 1 + fi +done + +pending_branches=() +for source_branch in "${SOURCE_BRANCHES[@]}"; do + if git -C "$repo_root" merge-base --is-ancestor "$source_branch" "$TARGET_BRANCH" >/dev/null 2>&1; then + echo "[agent-branch-merge] Skipping '${source_branch}' because it is already integrated into '${TARGET_BRANCH}'." + continue + fi + pending_branches+=("$source_branch") +done + +if [[ "${#pending_branches[@]}" -eq 0 ]]; then + echo "[agent-branch-merge] No pending source branches remain for target '${TARGET_BRANCH}'." + echo "[agent-branch-merge] Target worktree: ${target_worktree}" + exit 0 +fi + +declare -A file_to_branches=() +declare -a overlap_files=() +for source_branch in "${pending_branches[@]}"; do + while IFS= read -r changed_file; do + [[ -z "$changed_file" ]] && continue + existing="${file_to_branches[$changed_file]:-}" + if [[ -z "$existing" ]]; then + file_to_branches["$changed_file"]="$source_branch" + continue + fi + if [[ ",${existing}," == *",${source_branch},"* ]]; then + continue + fi + file_to_branches["$changed_file"]="${existing},${source_branch}" + if ! array_contains "$changed_file" "${overlap_files[@]}"; then + overlap_files+=("$changed_file") + fi + done < <(collect_branch_files "$repo_root" "$start_ref" "$source_branch") +done + +echo "[agent-branch-merge] Target branch: ${TARGET_BRANCH}" +echo "[agent-branch-merge] Target worktree: ${target_worktree}" +echo "[agent-branch-merge] Base branch: ${BASE_BRANCH} (${start_ref})" +echo "[agent-branch-merge] Merge order: ${pending_branches[*]}" + +if [[ "${#overlap_files[@]}" -gt 0 ]]; then + echo "[agent-branch-merge] Overlapping changed files detected across requested branches:" + for overlap_file in "${overlap_files[@]}"; do + branches_csv="${file_to_branches[$overlap_file]}" + branches_display="$(printf '%s' "$branches_csv" | sed 's/,/, /g')" + echo " - ${overlap_file} <- ${branches_display}" + done +else + echo "[agent-branch-merge] No overlapping changed files detected across requested branches." +fi + +for index in "${!pending_branches[@]}"; do + source_branch="${pending_branches[$index]}" + echo "[agent-branch-merge] Merging '${source_branch}' into '${TARGET_BRANCH}'..." + if git -C "$target_worktree" merge --no-ff --no-edit "$source_branch"; then + echo "[agent-branch-merge] Merged '${source_branch}'." + continue + fi + + conflict_files="$(git -C "$target_worktree" diff --name-only --diff-filter=U || true)" + echo "[agent-branch-merge] Merge conflict detected while merging '${source_branch}' into '${TARGET_BRANCH}'." >&2 + echo "[agent-branch-merge] Target worktree: ${target_worktree}" >&2 + if [[ -n "$conflict_files" ]]; then + echo "[agent-branch-merge] Conflicting files:" >&2 + while IFS= read -r conflict_file; do + [[ -n "$conflict_file" ]] && echo " - ${conflict_file}" >&2 + done <<< "$conflict_files" + fi + echo "[agent-branch-merge] Resolve or abort inside the integration worktree:" >&2 + echo " cd \"${target_worktree}\"" >&2 + echo " git status" >&2 + echo " git add && git commit" >&2 + echo " # or: git merge --abort" >&2 + + remaining_branches=("${pending_branches[@]:$((index + 1))}") + if [[ "${#remaining_branches[@]}" -gt 0 ]]; then + echo "[agent-branch-merge] Remaining branches:" >&2 + for remaining in "${remaining_branches[@]}"; do + echo " - ${remaining}" >&2 + done + resume_cmd="gx merge --into ${TARGET_BRANCH} --base ${BASE_BRANCH}" + for remaining in "${remaining_branches[@]}"; do + resume_cmd="${resume_cmd} --branch ${remaining}" + done + echo "[agent-branch-merge] Resume after resolving with: ${resume_cmd}" >&2 + fi + exit 1 +done + +echo "[agent-branch-merge] Merge sequence complete for '${TARGET_BRANCH}'." +if [[ "$target_created" -eq 1 ]]; then + echo "[agent-branch-merge] Review and verify in '${target_worktree}', then finish the integration branch when ready." +fi +echo "[agent-branch-merge] Next step: bash scripts/agent-branch-finish.sh --branch \"${TARGET_BRANCH}\" --base \"${BASE_BRANCH}\" --via-pr --wait-for-merge --cleanup" diff --git a/test/merge-workflow.test.js b/test/merge-workflow.test.js new file mode 100644 index 0000000..62e5216 --- /dev/null +++ b/test/merge-workflow.test.js @@ -0,0 +1,276 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const cp = require('node:child_process'); + +const cliPath = path.resolve(__dirname, '..', 'bin', 'multiagent-safety.js'); +const defaultGuardexHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-merge-home-')); + +function withGuardexHome(extraEnv = {}) { + return { + ...process.env, + GUARDEX_HOME_DIR: extraEnv.GUARDEX_HOME_DIR || defaultGuardexHomeDir, + ...extraEnv, + }; +} + +function runNode(args, cwd, extraEnv = {}) { + return cp.spawnSync('node', [cliPath, ...args], { + cwd, + encoding: 'utf8', + env: withGuardexHome(extraEnv), + }); +} + +function runCmd(cmd, args, cwd, extraEnv = {}) { + const sanitizedEnv = { ...process.env }; + delete sanitizedEnv.CODEX_THREAD_ID; + delete sanitizedEnv.OMX_SESSION_ID; + delete sanitizedEnv.CODEX_CI; + delete sanitizedEnv.CLAUDECODE; + delete sanitizedEnv.CLAUDE_CODE_SESSION_ID; + + const pushBypassEnv = + cmd === 'git' && Array.isArray(args) && args[0] === 'push' + ? { ALLOW_PUSH_ON_PROTECTED_BRANCH: '1' } + : {}; + + return cp.spawnSync(cmd, args, { + cwd, + encoding: 'utf8', + env: { ...sanitizedEnv, ...pushBypassEnv, ...extraEnv }, + }); +} + +function initRepo() { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-merge-')); + const repoDir = path.join(tempDir, 'repo'); + fs.mkdirSync(repoDir); + + let result = runCmd('git', ['init', '-b', 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['config', 'user.email', 'bot@example.com'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['config', 'user.name', 'Bot'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ name: path.basename(repoDir), private: true, scripts: {} }, null, 2) + '\n', + 'utf8', + ); + + return repoDir; +} + +function seedCommit(repoDir) { + let result = runCmd('git', ['add', '.'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['commit', '-m', 'seed'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); +} + +function commitSetup(repoDir) { + let 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); +} + +function commitFile(repoDir, relativePath, contents, message, options = {}) { + const filePath = path.join(repoDir, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents, 'utf8'); + + const currentBranch = runCmd('git', ['branch', '--show-current'], repoDir); + assert.equal(currentBranch.status, 0, currentBranch.stderr || currentBranch.stdout); + const branchName = currentBranch.stdout.trim(); + const lockScriptPath = path.join(repoDir, 'scripts', 'agent-file-locks.py'); + if (branchName.startsWith('agent/') && fs.existsSync(lockScriptPath)) { + const claim = runCmd( + 'python3', + ['scripts/agent-file-locks.py', 'claim', '--branch', branchName, relativePath], + repoDir, + ); + if (!options.allowLockConflict) { + assert.equal(claim.status, 0, claim.stderr || claim.stdout); + } + } + + let result = runCmd('git', ['add', relativePath], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + const commitEnv = ['dev', 'main', 'master'].includes(branchName) + ? { ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1' } + : {}; + const commitArgs = options.noVerify ? ['commit', '--no-verify', '-m', message] : ['commit', '-m', message]; + result = runCmd('git', commitArgs, repoDir, commitEnv); + assert.equal(result.status, 0, result.stderr || result.stdout); +} + +function combinedOutput(result) { + return `${result.stdout || ''}\n${result.stderr || ''}`; +} + +function extractMergeTargetBranch(output) { + const match = String(output || '').match(/\[agent-branch-merge\] Target branch: (.+)/); + assert.ok(match, `missing merge target branch in output: ${output}`); + return match[1].trim(); +} + +function extractMergeTargetWorktree(output) { + const match = String(output || '').match(/\[agent-branch-merge\] Target worktree: (.+)/); + assert.ok(match, `missing merge target worktree in output: ${output}`); + return match[1].trim(); +} + +test('setup installs the managed merge workflow script and package entry', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + + const result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const mergeScriptPath = path.join(repoDir, 'scripts', 'agent-branch-merge.sh'); + assert.equal(fs.existsSync(mergeScriptPath), true, 'merge script should be installed'); + fs.accessSync(mergeScriptPath, fs.constants.X_OK); + + const packageJson = JSON.parse(fs.readFileSync(path.join(repoDir, 'package.json'), 'utf8')); + assert.equal(packageJson.scripts['agent:branch:merge'], 'bash ./scripts/agent-branch-merge.sh'); +}); + +test('merge command creates an integration lane, reports overlaps, and merges cleanly', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + commitSetup(repoDir); + + commitFile(repoDir, 'shared.txt', 'alpha\nbeta\ngamma\n', 'add shared baseline'); + + result = runCmd('git', ['checkout', '-b', 'agent/test-merge-a'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + commitFile(repoDir, 'shared.txt', 'alpha-one\nbeta\ngamma\n', 'agent a update'); + + result = runCmd('git', ['checkout', 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['checkout', '-b', 'agent/test-merge-b'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + commitFile(repoDir, 'shared.txt', 'alpha\nbeta\ngamma-two\n', 'agent b update', { + allowLockConflict: true, + noVerify: true, + }); + + result = runCmd('git', ['checkout', 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runNode( + [ + 'merge', + '--target', + repoDir, + '--task', + 'merge-shared-smoke', + '--branch', + 'agent/test-merge-a', + '--branch', + 'agent/test-merge-b', + ], + repoDir, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const output = combinedOutput(result); + assert.match(output, /Overlapping changed files detected across requested branches/); + assert.match(output, /shared\.txt <- agent\/test-merge-a, agent\/test-merge-b/); + + const targetBranch = extractMergeTargetBranch(output); + const targetWorktree = extractMergeTargetWorktree(output); + assert.match(targetBranch, /^agent\/codex\/merge-shared-smoke-/); + + const mergedFile = fs.readFileSync(path.join(targetWorktree, 'shared.txt'), 'utf8'); + assert.equal(mergedFile, 'alpha-one\nbeta\ngamma-two\n'); + + let ancestry = runCmd('git', ['merge-base', '--is-ancestor', 'agent/test-merge-a', targetBranch], repoDir); + assert.equal(ancestry.status, 0, ancestry.stderr || ancestry.stdout); + ancestry = runCmd('git', ['merge-base', '--is-ancestor', 'agent/test-merge-b', targetBranch], repoDir); + assert.equal(ancestry.status, 0, ancestry.stderr || ancestry.stdout); + + assert.match(output, /OpenSpec change workspace: .+openspec\/changes\/agent-codex-merge-shared-smoke-/); + assert.match(output, /OpenSpec plan workspace: .+openspec\/plan\/agent-codex-merge-shared-smoke-/); +}); + +test('merge command reuses an owner lane and stops with resumable guidance on conflict', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + commitSetup(repoDir); + + commitFile(repoDir, 'shared.txt', 'base-line\n', 'add shared conflict baseline'); + + result = runCmd('git', ['checkout', '-b', 'agent/test-owner'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['checkout', 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runCmd('git', ['checkout', '-b', 'agent/test-helper-a'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + commitFile(repoDir, 'shared.txt', 'helper-a-line\n', 'helper a change'); + + result = runCmd('git', ['checkout', 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['checkout', '-b', 'agent/test-helper-b'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + commitFile(repoDir, 'shared.txt', 'helper-b-line\n', 'helper b conflicting change', { + allowLockConflict: true, + noVerify: true, + }); + + result = runCmd('git', ['checkout', 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['checkout', '-b', 'agent/test-helper-c'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + commitFile(repoDir, 'later.txt', 'later branch\n', 'helper c later branch'); + + result = runCmd('git', ['checkout', 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + result = runNode( + [ + 'merge', + '--target', + repoDir, + '--into', + 'agent/test-owner', + '--branch', + 'agent/test-helper-a', + '--branch', + 'agent/test-helper-b', + '--branch', + 'agent/test-helper-c', + ], + repoDir, + ); + assert.equal(result.status, 1, 'merge should stop on conflict'); + + const output = combinedOutput(result); + assert.match(output, /Merge conflict detected while merging 'agent\/test-helper-b' into 'agent\/test-owner'/); + assert.match(output, /Remaining branches:/); + assert.match(output, /agent\/test-helper-c/); + assert.match(output, /Resume after resolving with: gx merge --into agent\/test-owner --base dev --branch agent\/test-helper-c/); + + const targetWorktree = extractMergeTargetWorktree(output); + let mergeHead = runCmd('git', ['-C', targetWorktree, 'rev-parse', '-q', '--verify', 'MERGE_HEAD'], repoDir); + assert.equal(mergeHead.status, 0, mergeHead.stderr || mergeHead.stdout); + + let ancestry = runCmd('git', ['merge-base', '--is-ancestor', 'agent/test-helper-a', 'agent/test-owner'], repoDir); + assert.equal(ancestry.status, 0, ancestry.stderr || ancestry.stdout); + ancestry = runCmd('git', ['merge-base', '--is-ancestor', 'agent/test-helper-b', 'agent/test-owner'], repoDir); + assert.notEqual(ancestry.status, 0, 'conflicting branch should not be fully integrated yet'); +});