Skip to content

Bug: git-aware lookups silently return wrong-branch results when target branch lacks the symbol #49

Description

@jifaricher

Status: Confirmed bug, not yet reported upstream as of 2026-08-11.
Target: facebookexperimental/semcode GitHub issue tracker.
Reproduction commit: main at 301fff6 (also present on feat/mcp-http-transport).
Suggested issue title: Bug: git-aware lookups silently return wrong-branch results when target branch lacks the symbol


Summary

When a semcode database is indexed across multiple git branches (semcode-index --branches A,B), the git-aware lookup methods silently return symbol versions from a different branch when the requested symbol does not exist at the requested branch/SHA. Instead of returning None (which the caller's code path correctly handles), the implementations fall back to the non-git-aware find_function(name) / find_type(name) / find_typedef(name) queries, which ignore the requested SHA entirely and return any indexed version of the symbol.

This contradicts the project's own CLAUDE.md documentation and the doc comments on find_function() itself, and produces misleading user-facing output such as "No functions call function 'foo'" even when foo does not exist on the requested branch at all.

Affected versions / context

  • Introduced / exposed by: PR Multi branch indexing #8 "Multi branch indexing" (merged 2026-01-13), which added --branches for the indexer and a branch parameter for MCP queries.
  • Reproduced on main at commit 301fff6 (and on the feat/mcp-http-transport branch which does not modify src/database/connection.rs).
  • The fallback code paths predate PR Multi branch indexing #8 — they originate in bd7c08ff ("diffinfo: optimize performance with single-scan caller index", 2026-02-04) for find_function_git_aware and fcbbf9d ("Add a symbol mapping table") for find_function_with_manifest. PR Multi branch indexing #8 made the buggy behavior user-visible by giving callers a way to request a specific branch.

Reproduction

  1. Index two branches, where one contains a function and the other does not:

    # repo with branches:  main (no `foo`),  feature-x (has `foo`, called by `bar`)
    semcode-index --source /path/to/repo --branches main,feature-x
  2. Query find_callers with branch=main for foo (which does not exist on main):

    semcode --database /path/to/repo
    # then in REPL or via MCP:
    find_callers foo --branch main
  3. Actual output:

    Finding all functions that call: foo
    Info: No functions call function 'foo'
    

    — i.e. the function was "found" (the Some(entity) branch was taken in mcp_show_callers), but the callers list is empty.

  4. Expected output:

    Error: Function or macro 'foo' not found in database
    

    — i.e. find_function_git_aware should have returned None because foo does not exist at the main commit.

Root cause

src/database/connection.rsfind_function_git_aware (line 825) has two fallback points that bypass the requested git_sha:

pub async fn find_function_git_aware(
    &self,
    name: &str,
    git_sha: &str,
) -> Result<Option<FunctionInfo>> {
    if let Some(func) = self.workdir_find_function(name) {
        return Ok(Some(func));                // (a) workdir overlay — separate concern
    }
    let git_manifest = self.generate_git_manifest(git_sha).await?;
    if git_manifest.is_empty() {
        tracing::info!(
            "No files resolved for '{}' at commit '{}' - falling back to non-git lookup",
            name, git_sha
        );
        return self.find_function(name).await;   // (b) FALLBACK — wrong-branch result
    }
    self.find_function_with_manifest(name, &git_manifest).await
}

And find_function_with_manifest (line 847) has a second fallback at line 869-872:

if resolved_hashes.is_empty() {
    // Fallback: do a regular find to get any available functions
    return self.find_function(name).await;   // (c) FALLBACK — wrong-branch result
}

find_function(name) (line 810) is documented to only be used when git SHA cannot be determined or for administrative/debug operations:

/// # When to Use This Method
/// - Fallback when git SHA cannot be determined (not in a git repository)
/// - Administrative/debugging operations that need to see all versions
///
/// # Behavior
/// Returns the "best match" from all indexed versions without considering git history.

But the fallbacks at (b) and (c) call it with a known git_sha already in scope, contradicting the doc comment. The result is a FunctionInfo whose git_file_hash corresponds to a different commit than the one the caller asked about.

A similar two-stage interaction explains the user-facing "No callers" message:

  • mcp_show_callers (src/bin/semcode-mcp.rs:284) calls find_function_git_aware(name, git_sha). The fallback returns Some(entity) from a different branch.
  • It then calls get_function_callers_git_aware(name, git_sha) (line 2041), which does not have a wrong-branch fallback — it correctly returns an empty Vec because no callers reference the requested SHA's version of the function (the function doesn't exist there).
  • The MCP handler sees Some(entity) + empty callers, and prints "Info: No functions call function 'foo'" — implying the function exists on the requested branch with no callers, when in fact the function does not exist on the requested branch at all.

Same-pattern bugs elsewhere

The identical fallback shape appears in several other git-aware methods, all with the same defect:

Method File:line of fallback Fallback condition
find_function_git_aware src/database/connection.rs:841 git_manifest.is_empty()
find_function_with_manifest (internal) src/database/connection.rs:871 resolved_hashes.is_empty()
find_types_git_aware src/database/connection.rs:1393-1403 resolved_hashes.is_empty()find_type(name)
find_typedef_git_aware (first fallback) src/database/connection.rs:1586-1593 resolved_hashes.is_empty()find_typedef(name)
find_typedef_git_aware (second fallback) src/database/connection.rs:1603-1610 hash matched but no typedef found → find_typedef(name)
find_all_functions_git_aware src/database/connection.rs:1033-1041 resolved_hashes.is_empty()find_all_by_name_unfiltered(name)

Methods that do not have this bug (correctly return empty on miss):

  • get_function_callees_git_aware (connection.rs:2319) — returns Ok(Vec::new()) when manifest is empty.
  • grep_function_bodies_git_aware (connection.rs:3387) — filters by manifest; misses become empty results, no fallback. This is the correct pattern.
  • get_distinct_reference_counts_git_aware (connection.rs:1784) — filters by manifest, no fallback.

The fact that grep_function_bodies_git_aware implements the "manifest miss → empty result" pattern correctly in the same file is strong evidence that the fallbacks in find_function* / find_type* / find_typedef* are an oversight rather than an intentional design choice.

Affected MCP tools

Five MCP tools see the wrong-branch behavior:

Tool Affected via
find_function find_function_git_aware + find_all_functions_git_aware
find_type find_type_git_aware, find_types_git_aware, find_typedef_git_aware
find_callers find_function_git_aware (function body lookup) — get_function_callers_git_aware itself is correct, but its caller already saw the wrong entity
find_calls same shape — find_function_git_aware + correct get_function_callees_git_aware
find_callchain recurses through find_function_git_aware at each hop — bug compounds across the chain

Documentation conflict

The project's CLAUDE.md ("Git-Aware Operations" section) explicitly warns:

Without git-aware lookups:

  • Users may jump to outdated function definitions
  • Call chains may include deleted or renamed functions
  • Type information may not match current code structure
  • Results are confusing and incorrect for active development
    Remember: When in doubt, use git-aware functions!

The fallbacks make git-aware lookups behave like non-git-aware lookups exactly when the difference matters most (the requested branch doesn't contain the symbol).

Suggested fix

Two viable approaches:

Option A — Pass "explicit SHA" through the call chain. Add an explicit: bool (or enum GitShaSource { Explicit, DefaultHead }) parameter to find_function_git_aware and friends. The MCP handler already has this information in resolve_git_sha_or_branch (src/bin/semcode-mcp.rs:2668) — currently that information is lost at the DB boundary. When explicit is true, the fallbacks return Ok(None) / Ok(Vec::new()) instead of calling find_function/find_type/find_typedef. When explicit is false (non-git repo or default HEAD), keep the current fallback behavior for back-compat.

Option B — Drop the fallbacks entirely. find_function_git_aware and friends always return None / empty when the manifest doesn't contain the symbol's file at the requested SHA. The non-git-aware find_function(name) continues to exist for the "non-git repository" and "administrative/debug" use cases documented on find_function itself; the caller would call it directly when they genuinely want SHA-agnostic lookup.

Option B is simpler and more consistent with grep_function_bodies_git_aware's existing correct behavior. The risk is identifying any callers that rely on the fallback for legitimate reasons (e.g., a non-git repo path where git_sha is "" or some default — generate_git_manifest returns empty there, triggering the fallback).

A secondary, smaller issue: resolve_git_sha_or_branch (src/bin/semcode-mcp.rs:2668-2686) silently falls through to the default HEAD when git::resolve_branch fails, with only an eprintln! warning. A user who explicitly requests branch=feature-x and types the branch name wrong gets results from the wrong commit (HEAD) with no error. Worth fixing alongside the main bug: when the caller explicitly requested a branch, resolution failure should produce a clear error, not a silent fallback.

Minimal regression test (suggested)

  1. Create a small git repo with two branches where branch A contains a function foo and branch B does not.
  2. semcode-index --source /path --branches A,B
  3. Assert: find_function(name="foo", branch="B") returns None (or an MCP "not found" error).
  4. Assert: find_function(name="foo", branch="A") returns the A version of foo.
  5. Same for find_type, find_typedef, find_callers, find_calls, find_callchain.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions