Skip to content
Goatfied

devex

Branch-aware context retrieval pipelines

Context retrieval systems that ignore Git branches produce code suggestions from outdated or incorrect versions of your codebase.

2026-07-0211 min readBy Goatfied
Branch-aware context retrieval pipelines

When your AI coding assistant suggests editing user_controller.rb but grabs context from main while you're working in a feature branch that refactored it to users/profile_controller.rb, you've hit the branch awareness problem. The assistant confidently references code that no longer exists in your working tree, generates imports for functions that were deleted two commits ago, or suggests API calls with signatures that changed when your branch diverged three days back.

Branch-aware context retrieval means your embedding index, semantic search, and code graph reflect the exact state of the working branch—not a stale snapshot of main. This isn't exotic infrastructure; any team running parallel feature work needs it. The implementation involves real tradeoffs between indexing cost, query latency, and correctness guarantees that directly shape whether your AI tooling produces code that compiles on the first try or sends you into a debug loop chasing phantom references.

Why naive retrieval breaks in branching workflows

The simplest context retrieval pipeline indexes main once: build embeddings for every function and class, store them in a vector database, retrieve top-k matches when the LLM needs context. This works until a developer creates feature/payment-v2, renames three modules, and asks the AI to "add validation to the payment flow." The retriever surfaces code that doesn't exist in their branch.

You could re-index on every branch checkout, but that burns 30–90 seconds per switch for a medium codebase—enough to break flow. You could index every branch, but with 200 active feature branches you're maintaining 200× the storage and background reindexing cost. The worst outcome is a stale index that claims to be branch-aware but is 50 commits behind, creating mismatches where the AI sees main while you're three features deep in a branch where critical interfaces have changed.

Standard RAG pipelines treat the repository as a single, static knowledge base. This breaks in three high-stakes moments:

Branch divergence: Your feature branch deleted services/billing.ts but the embedding store still returns it because the index hasn't caught up. The assistant generates syntactically valid code that imports things that don't exist or calls methods with the wrong signatures.

Merge conflicts: During a merge or rebase, Git inserts conflict markers into files. These markers are text, so they get embedded. Retrieval treats them as normal code, and the assistant sees both versions—calculateTax(amount: number) and computeTax(amount: number, region: string)—without knowing which is real. Generated code might call the wrong function or reference the conflict markers themselves.

Cross-branch reference leakage: Your feature branch depends on a utility function you wrote locally. The assistant retrieves context from main (where your utility doesn't exist) and suggests rewriting your code to not use it, or imports it from a different module where a similarly named function lives.

Layered indexes: main plus branch deltas

One practical pattern layers a stable main index with branch-specific overlay data. When a developer works on feature/payment-v2, the retrieval pipeline queries the shared main index (updated every few hours), applies a diff overlay capturing files changed in the branch, then re-ranks or filters results based on which files exist in the current HEAD.

The overlay can be lightweight—just changed file paths and their new embeddings, not the entire repo. If your branch touches 40 files out of 3,000, you index 40, not 3,000. A simple heuristic: if a file hash differs between branch HEAD and main HEAD, it's in the overlay.


def retrieve_context(query: str, branch: str, main_index: Index):

    changed_files = get_changed_files(branch, base="main")

    

    # Fast path: query main index

    main_results = main_index.search(query, top_k=50)

    

    # Overlay: re-embed changed files and prepend matches

    branch_embeddings = embed_files(changed_files)

    branch_results = search_embeddings(query, branch_embeddings, top_k=10)

    

    # Merge and dedupe, prioritizing branch state

    return merge_with_priority(branch_results, main_results, limit=20)

This cuts indexing work dramatically. The tradeoff: queries now do two lookups and a merge. In practice, the overlay search is small enough (tens of files) that latency stays under 100ms. The real cost is complexity—you're managing index versioning and merge logic.

Delta-based indexing cuts storage dramatically but adds retrieval complexity. Merging overlays during retrieval and handling rename/move operations requires careful bookkeeping. The Goatfied approach combines both: we index main fully and delta-index feature branches, but rebuild any branch index from scratch if the diff grows beyond a threshold (typically 15–20% of the codebase).

Git-native retrieval: treat the DAG as ground truth

A more principled approach uses Git's object model directly. Instead of maintaining separate indexes, store embeddings keyed by blob SHA. When you need context for a file in feature/payment-v2, you resolve the file path to its blob SHA in that branch's tree, look up embeddings by SHA, and fall back to re-embedding if the SHA is new.

This naturally deduplicates: if utils.rb hasn't changed across 15 branches, all 15 share the same embedding. The index grows only with unique content, not branch count. Storage scales with distinct blobs, not branch × file combinations.


# Resolve file to blob SHA

$ git rev-parse feature/payment-v2:app/models/payment.rb

a3f8d9c... 



# Lookup embedding for that blob

$ vector_db.get("blob:a3f8d9c...")

The downside: you need to walk Git trees on every query. For small changes this is negligible (modern Git is fast), but checking out an old commit or comparing against a stale base branch requires more tree traversal. You're trading index duplication for query-time Git operations.

Handling renames and moves without hallucination

Renames break naive retrieval. The LLM asks about "PaymentService" but it's been moved from lib/payment_service.rb to app/services/billing/payment_service.rb in your branch. Path-based lookups fail; semantic search might find it, but only if the content changed enough to warrant re-indexing.

One technique: maintain a rename map per branch, built from git diff --name-status. When a file shows as renamed (status R), store the old path → new path mapping. Query-time, check the rename map before looking up blobs:


def resolve_file_path(branch: str, path: str) -> str:

    rename_map = get_rename_map(branch)

    return rename_map.get(path, path)

This keeps retrieval deterministic. The cost is building and caching rename maps—trivial for recent branches, slower if you're comparing against a base from 200 commits ago. In Goatfied's agent loop, we cache these maps at plan time so subsequent validation and retry steps don't recompute the DAG walk.

Deciding what to index and when

You can't index every intermediate commit. Real systems pick anchor points based on tradeoffs between freshness and cost:

Main/master only: Simplest, but breaks on active branches where developers spend most of their time.

Main + active feature branches: Works if "active" is well-defined (last commit within 7 days, open PR). Requires clear retention policies—drop indexes for branches merged or deleted more than N days ago.

Main + HEAD of every branch a user has checked out recently: Scales per developer, not per branch. Goatfied's managed offering uses this approach—index what developers actually work with, expire unused branches after a week.

On-demand indexing: Index a branch the first time a developer requests AI help in it, cache for 24 hours. Cuts cost but introduces cold-start latency.

The tradeoff is staleness. If you index only on push, a developer working locally for two hours has stale context. If you index on every file save, you're burning CPU on half-finished edits. A middle ground: index on commit, which signals "this is coherent enough to share."

Set up a webhook from your Git server (GitHub, GitLab, self-hosted) that fires on push events. A background worker picks up the event, checks out the branch in a clean workspace, and runs your embedding pipeline. For large repos, incremental indexing is essential—only re-process files changed since the last indexed commit. Use Git's tree-diff to identify modified paths, then re-embed those files and update your vector store in place. A reasonable target is completing a delta index in under 60 seconds for branches with <100 changed files.

Incremental updates and cache invalidation

Reindexing the entire branch on every commit is expensive. Instead, track file-level changes. Hook into Git's post-commit or post-merge events, diff the commit to extract added, modified, deleted files, re-chunk and re-embed only the changed files, then upsert new vectors and delete vectors for removed code.


# In .git/hooks/post-commit

git diff --name-only HEAD~1 HEAD | while read file; do

  if [[ "$file" =~ \.(ts|py|go)$ ]]; then

    curl -X POST http://indexer:8080/update \

      -d "{\"repo\":\"myapp\",\"branch\":\"$(git branch --show-current)\",\"file\":\"$file\"}"

  fi

done

When main merges a breaking change, every feature branch's cached context becomes suspect. Aggressive cache invalidation (bust all branch overlays on main update) is correct but wasteful—most branches don't touch the affected code. Selective invalidation (track which files changed in main, invalidate only branches that also modified those paths) saves work but adds bookkeeping.

One pattern: tag embeddings with the main commit SHA they were derived from. On retrieval, if the developer's branch base is newer than the embedding's base, re-fetch. This bounds staleness to the merge-base lag, which for active branches is usually a day or two.

Query-time branch resolution and conflict handling

When the agent processes a user request, it needs to determine which branch context to use. Common patterns: explicit branch parameter from the IDE extension, implicit from Git state (git rev-parse --abbrev-ref HEAD), or multi-branch queries for PR reviews where you retrieve from both head and base branch, then diff results.

Goatfied's approach: when the agent enters the plan phase, it reads the branch from the workspace metadata and retrieves from that branch's index. If you're comparing two branches (generating a migration script, reviewing a PR), the agent runs two separate retrievals and passes both contexts to the LLM:


base_ctx = retrieve(query, branch="main")

head_ctx = retrieve(query, branch="feat/refactor")

prompt = f"Base branch has: {base_ctx}\nFeature branch has: {head_ctx}\nGenerate migration..."

For merge conflicts, treat the conflict explicitly. When a query targets a branch with unresolved conflicts, tag the conflicting files in the retrieval results so the agent knows these are ambiguous. In Goatfied's agent loop, we constrain the plan step: if the context includes conflicting versions, the agent must either resolve the conflict first or explicitly work around the ambiguous files. This prevents hallucinated merges where the AI invents a compromise between two conflicting implementations.

A robust mitigation for conflict markers in retrieved context: chunk-level filtering. Only exclude chunks that contain <<<<<<< markers. If a query would return zero results after filtering, return the conflict-marked chunks anyway but prepend a warning in the response.

Validation gates that catch retrieval failures

Instead of aiming for perfect branch-aware retrieval (which requires per-branch indexing and real-time invalidation), build a pipeline that retrieves optimistically, validates aggressively, and retries with refinement.

When the agent suggests code, run language-specific checks (compile, lint, test) against the actual filesystem and compiler before committing a response. When the retrieval pipeline returns stale context from the wrong branch, the validation step catches the import errors and forces a retry with corrected references. When validation fails, extract the error ("cannot find name 'UserService'"), feed it back into the retrieval query ("find the current authentication service implementation"), and regenerate.

This three-step loop—retrieve, validate, retry—handles branch divergence, conflict markers, and cross-branch leakage without requiring perfect up-front indexing. It's slower than a single-shot retrieval, but it produces code that actually works on the branch you're on. Goatfied's compile/lint/test gates run on every generated diff. If the assistant leaked a reference from the wrong branch, the compiler rejects it and the agent retries with corrected context.

The key insight: branch-aware context retrieval is a mitigation strategy, not a complete solution. The real solution is validation gates that catch retrieval failures before they reach the user.

Storage and cost management at scale

Per-branch indexing can spiral into serious storage costs. A 10k-file monorepo might produce 3 GB of embeddings per branch. With 50 active branches, that's 150 GB just for feature work. Set aggressive retention policies: drop indexes for branches that haven't been touched in 7 days, and purge immediately on branch deletion.

Use a tiered storage model—recent branches on fast SSD-backed vector stores, older branches on cheaper object storage with lazy rehydration. Delta indexes help, but each delta layer adds lookup latency as you need to check the overlay before falling back to the base. Profile your retrieval hot path: if you're spending >100ms resolving deltas, consider materializing popular branches into full indexes.

A 100-contributor team with 50 active branches and 10 commits/day/branch generates 500 embed operations daily. At typical embedding costs, and assuming 500 tokens/commit average, storage and compute are negligible per repo—but multiply by 100 repos and it's worth batching or rate-limiting.

Production deployment checklist

Before deploying branch-aware retrieval, validate these elements:

Indexing pipeline: Webhook handlers for push events, incremental diff detection, worker queue that doesn't block the main API, and monitoring for indexing lag. Measure time-to-index-refresh and alert if it exceeds your SLA (ideally <60 seconds for typical branches).

Storage hygiene: Automated pruning of stale branch indexes, cost dashboards tracking storage per branch, and emergency purge workflows for when a bad commit balloons your embeddings.

Retrieval semantics: Clear rules for conflict resolution, branch precedence in overlays, and fallback behavior when a branch index isn't ready yet. Test edge cases like rapid branch switching and force-pushes that rewrite history.

Developer feedback: Show indexing status in the UI—"Context updated 30s ago" is actionable; silence breeds mistrust. Let developers manually trigger re-indexing if they know the cache is stale.

Self-hosted vs. managed: If you're running Goatfied self-hosted, you control the entire pipeline—deploy an indexer service that hooks into your Git server, subscribes to webhook events for pushes and PR updates, and maintains a vector DB (Qdrant or pgvector) scoped by branch. If you're using a managed platform, check whether it supports branch namespacing; some services index only the default branch.

Branch-aware retrieval shows up in three critical moments: merge conflict assistance (the LLM needs to see both sides as they exist in their respective branches), cross-file refactors (when a developer renames an interface, the AI must suggest updates using the current branch's import paths), and validation gates where Goatfied's compile/lint/test loop runs against the branch HEAD—context for retry steps must match that exact state or the LLM chases phantoms.

Related posts

Branch-aware context retrieval pipelines | Goatfied Blog