Skip to content

fix(mcp): delete remote cr/* branches on merge, harden merged detection, add prune#60

Merged
ABB65 merged 3 commits into
mainfrom
fix/remote-branch-cleanup
Jul 9, 2026
Merged

fix(mcp): delete remote cr/* branches on merge, harden merged detection, add prune#60
ABB65 merged 3 commits into
mainfrom
fix/remote-branch-cleanup

Conversation

@ABB65

@ABB65 ABB65 commented Jul 9, 2026

Copy link
Copy Markdown
Member

Problem

In workflow: "review" with a git remote, every content save+merge cycle leaked exactly one remote cr/* branch. contentrain_content_save pushed cr/{op}/{model}/{ts} to origin; contentrain_merge merged it and deleted only the local branch. Nothing in the toolchain ever ran git push --delete.

From one field project: ~121 stale remote branches in a month, 13 new ones in a single working day. Studio's Pending Changes panel lists origin/cr/* by existence, so every leftover rendered as a phantom pending review. The branch-pressure limits and contentrain doctor only counted local branches, so the remote pile stayed invisible to the tooling meant to catch it.

Fix

Cut the leak at the sourcemergeBranch now deletes the merged branch's remote copy. Because every local merge path funnels through this one helper, the behavior is inherited for free by contentrain_merge, the CLI merge/diff commands, the Serve UI approve endpoints, and LocalProvider.mergeBranch (the Studio local path). Best-effort and config-gated: a failure surfaces as remote.warning, never as a failed merge. Opt out with remoteBranchCleanup: false.

Cover the sibling delete surfacescontentrain_branch_delete, LocalProvider.deleteBranch, the Serve reject endpoint, and interactive diff delete all remove the remote copy too. branch_delete also does a remote-only delete when the local ref is already gone (for branches leaked before this shipped).

Remote providers — GitHub/GitLab delete the source branch after a successful API merge (opt out per call with removeSourceBranch: false).

Drain the existing backlog — new contentrain prune CLI command (--dry-run / --yes / --json) batch-deletes merged remote leftovers alongside the local retention cleanup; contentrain_submit lazily prunes up to 20 merged remote branches per run so review workflows self-heal.

Make the remote visiblecontentrain doctor gains an offline-safe "Remote branches" check (authoritative ls-remote, 5s cap); contentrain_branch_list accepts remote: true.

Harden merged-branch detection — the reporter hit a case where, after a base-history rewrite (rebase/squash/force-push), merged branches flip to "unmerged" by ancestry. Detection now falls back to patch-id equivalence (git cherry) when ancestry breaks. The fallback only runs when it could actually change the health verdict, with bounded concurrency and monotonic verdict caching, so the per-write branch-health gate still costs one git branch --merged in the normal case.

Latent bug found along the way

git merge-base --is-ancestor signals via exit code with empty stderr, and simple-git reports exit-code-only failures as success. Every --is-ancestor guard in the codebase silently always-passed — including the fast-forward safety checks in the transaction layer. Replaced with rev-list --count (and cat-file -e-t in the prune path).

Config

One new key, remoteBranchCleanup?: boolean (default true), gating all remote mutations. Read-only remote visibility (doctor, branch listing) is never gated. Also documented the previously-undocumented branchWarnLimit / branchBlockLimit and fixed the branchRetention description.

⚠️ Deleting a pushed review branch closes any open PR/MR on it — intended (same phantom-review class), documented, opt-out via remoteBranchCleanup: false.

Tests

  • Net-new bare-remote fixture (git init --bare + file:// remote — push/ls-remote/push-delete work fully offline)
  • remote-branches.test.ts (14): deleteRemoteBranch / listRemoteCrBranches / pruneMergedRemoteBranches — happy, not-found, no-remote, disabled, offline, dry-run, max-cap
  • workflow-remote.test.ts (6): the three branch tools through the real MCP InMemoryTransport harness (single beforeAll fixture — these tools had zero prior coverage)
  • mergeBranch transaction suite (4): local+remote delete, gate off, offline warning, no-remote
  • branch-lifecycle.test.ts classification suite (12): ancestry, rewrite scenario, cap, retention, health thresholds — cleanupMergedBranches/checkBranchHealth had zero prior coverage
  • GitHub branch-ops suite (6, net-new) + GitLab merge-cleanup cases + doctor remote-check cases

Full suite: 636/637 (the one miss was a load-flake on a real-git test; timeout raised, passes 14/14 standalone). CLI: 154/154. Plus an 18/18 end-to-end check driving the real MCP server + real CLI against a real bare remote (save→merge→delete, gate off, remote-only delete, doctor, prune, offline).

Out of scope

Studio-side "Pending Changes" filtering lives in the separate Studio repo. This PR cuts the root cause; the Studio ancestry/tree-diff filter is a follow-up there.

🤖 Generated with Claude Code

ABB65 added 3 commits July 9, 2026 00:12
…ntracts

- ContentrainConfig.remoteBranchCleanup?: boolean (default true) gates all
  remote cr/* branch mutations (post-merge delete, branch delete, pruning)
- MergeResult.remote reports the post-merge source-branch cleanup outcome
  (deleted / skipped / warning) without ever failing the merge itself
- RepoProvider.mergeBranch gains an optional { removeSourceBranch } opt so
  drivers that own cleanup separately (Studio) can keep the old behavior
…ction

Review-mode saves push their cr/* branch to origin, but nothing ever
deleted the remote copy after a merge — one branch leaked per save+merge
cycle (121 stale branches in a month in the field), rendering as phantom
pending reviews in Studio.

- mergeBranch now deletes the merged branch's remote copy, best-effort:
  failures surface as remote.warning, never as a failed merge. Config-gated
  by remoteBranchCleanup (default on). Inherited by contentrain_merge, the
  CLI merge/diff commands, the serve approve endpoints and
  LocalProvider.mergeBranch for free.
- contentrain_branch_delete and LocalProvider.deleteBranch remove the
  remote copy too; branch_delete falls back to a remote-only delete when
  the local ref is already gone.
- GitHub/GitLab providers delete the source branch after a successful API
  merge (opt out per call with removeSourceBranch: false).
- Merged detection (isMerged/cleanupMergedBranches/checkBranchHealth) now
  falls back to patch-id equivalence (git cherry) so a base-history rewrite
  no longer flips merged branches to unmerged. The fallback only runs when
  it can change the health verdict, with bounded concurrency and monotonic
  verdict caching, so the hot pre-write gate stays at one subprocess.
- Fixes a latent no-op: merge-base --is-ancestor signals via exit code with
  empty stderr, which simple-git reports as success — the fast-forward
  guards in the transaction layer never fired. Replaced with rev-list
  --count (and cat-file -e with -t in the prune path).
- contentrain_doctor gains an offline-safe 'Remote branches' check
  (ls-remote, 5s cap); contentrain_branch_list accepts remote: true;
  contentrain_submit lazily prunes up to 20 merged remote leftovers.
- New branch-lifecycle exports: deleteRemoteBranch, listRemoteCrBranches,
  pruneMergedRemoteBranches, isRefMerged, classifyMergedBranches.

Tests: net-new bare-remote fixture; remote-branches suite (14),
workflow-remote MCP-harness suite (6, single beforeAll fixture),
mergeBranch transaction suite (4), branch-lifecycle classification suite
(12 — cleanupMergedBranches/checkBranchHealth had zero coverage before),
GitHub branch-ops suite (6, net-new), GitLab merge cleanup cases, doctor
remote check cases. Full suite: 636/637 (1 pre-bump load-flake, timeout
raised) + 18/18 end-to-end checks against a real bare remote.
- new 'contentrain prune' command: previews merged cr/* leftovers on the
  remote (--dry-run), confirms, then drains them in batch alongside the
  local retention cleanup; --json mutates only with --yes; non-zero exit
  when remote deletions fail
- merge/diff commands and the serve approve/reject endpoints surface the
  remote cleanup outcome (deleted / warning) from the MCP layer; reject
  and interactive delete now remove the pushed copy of a discarded draft
- doctor renders the new Remote branches check with zero changes (generic
  checks loop)

Tests: prune command suite (6, mocked MCP layer per merge.test.ts
pattern); diff/serve integration mocks extended for the new
branch-lifecycle surface. CLI suite: 154/154.
@netlify

netlify Bot commented Jul 9, 2026

Copy link
Copy Markdown

Deploy Preview for contentrain-ai ready!

Name Link
🔨 Latest commit b98e1c4
🔍 Latest deploy log https://app.netlify.com/projects/contentrain-ai/deploys/6a4f440f6b6b410008896e59
😎 Deploy Preview https://deploy-preview-60--contentrain-ai.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@ABB65
ABB65 merged commit 0b84743 into main Jul 9, 2026
6 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant