Summary
File discovery is sourced entirely from git ls-files and git diff, so a file that
git has never been told about is invisible to the graph — not just until the next
build, but permanently, including through a full rebuild. For an AI assistant that
writes a new file and immediately asks the graph about it, this is the common case:
the assistant's own new code is the one thing the graph cannot see.
Where
code_review_graph/incremental.py, both discovery paths:
- Full build —
collect_all_files() takes its candidate list from
get_all_tracked_files(), which is git ls-files. Untracked files never become
candidates, so full_build() never parses them. Worse, full_build()'s stale
purge computes existing_files - current_abs from that same list, so an untracked
file that somehow reached the graph is actively deleted from it on the next
rebuild.
- Incremental update —
incremental_update() gets its change set from
get_changed_files(), which is git diff --name-status <base> --. git diff
does not report untracked paths, so the file never enters all_files and is never
parsed.
get_staged_and_unstaged() does see untracked files (git status --porcelain),
but it is only reached by detect-changes, update --brief and the review tools as
a fallback when get_changed_files() returns empty — never by the code that
actually writes nodes into the graph.
Reproduction
In any repo with at least one commit:
code-review-graph build # note the file count
printf 'def marker_fn():\n return 1\n' > new_module.py # do NOT git add
code-review-graph update # "No changes detected"
code-review-graph build # file count unchanged
# query_graph_tool(query_type="callers_of", target="marker_fn") -> not_found
git add -N new_module.py # intent-to-add, no content change
code-review-graph build # file count +1, marker_fn resolves
git add -N changes nothing about the file's content — only git's index — which
isolates the cause to discovery.
Impact
The MCP integration is wired to be live: a PostToolUse hook runs
code-review-graph update after every edit, and serve --auto-watch watches the
tree. Both are defeated for new files. The assistant is told the graph is current,
asks callers_of / references_to / file_summary about code it wrote seconds
ago, gets zero, and concludes the symbol does not exist.
This is the same failure #987 describes, approached from the other end: #987 makes
the empty result admit its uncertainty, which is a real improvement, but the result
is still empty. The proposal here is that it should not be empty.
Proposed fix
Make discovery tracked ∪ untracked-but-not-ignored, which is exactly
git ls-files ∪ git ls-files --others --exclude-standard. --exclude-standard
applies .gitignore, .git/info/exclude and core.excludesFile, so build output,
node_modules, virtualenvs and the tool's own .code-review-graph/ stay out — the
existing DEFAULT_IGNORE_PATTERNS pass then applies on top as it does today. Unlike
git status --porcelain, ls-files --others lists individual files rather than
collapsing a wholly-untracked directory to dir/, so no second walk is needed.
Two call sites:
collect_all_files() — union the untracked list into candidates (keep the
filesystem-walk fallback for when both are empty, i.e. not a git repo).
get_changed_files() — union the untracked list into the git branch's result, so
the incremental path sees new files. A file git has never seen is, by any useful
definition, a change.
--recurse-submodules is not valid with --others, so the submodule option applies
only to the tracked call.
Deletion caveat — also fixed in the local patch
Once untracked files are indexed, deleting one is invisible to the incremental path
in the same way: git diff never names it (never tracked) and --others no longer
names it (gone), so remove_file_data() is not called and the node lingers until the
next full build, whose stale purge does clear it. serve --auto-watch catches it via
filesystem events, so this only affects hook-only setups (the PostToolUse hook
calling update, or any other caller of incremental_update() with no watcher
running).
Update: this is no longer an open gap in my local patch. incremental_update() now
resolves the graph's recorded file list against disk before its early return: for
every file the graph already knows about that this update's own diff/untracked scan
didn't name, it stats the path and calls the existing remove_file_data() if the
file is gone — the same mechanism the watcher's on_deleted uses, just triggered by
a filesystem stat instead of a filesystem event. Verified round-trip on a scratch
repo: create an untracked file → update → node appears; delete it, no rebuild →
update → node is gone. No extra git calls, no watcher required.
Environment
code-review-graph 2.3.6 (installed as a uv tool), macOS 15, Python 3.13
- Verified still present on
main as of today: collect_all_files() and
get_changed_files() are unchanged in this respect in 2.3.8 and later.
Summary
File discovery is sourced entirely from
git ls-filesandgit diff, so a file thatgit has never been told about is invisible to the graph — not just until the next
build, but permanently, including through a full rebuild. For an AI assistant that
writes a new file and immediately asks the graph about it, this is the common case:
the assistant's own new code is the one thing the graph cannot see.
Where
code_review_graph/incremental.py, both discovery paths:collect_all_files()takes its candidate list fromget_all_tracked_files(), which isgit ls-files. Untracked files never becomecandidates, so
full_build()never parses them. Worse,full_build()'s stalepurge computes
existing_files - current_absfrom that same list, so an untrackedfile that somehow reached the graph is actively deleted from it on the next
rebuild.
incremental_update()gets its change set fromget_changed_files(), which isgit diff --name-status <base> --.git diffdoes not report untracked paths, so the file never enters
all_filesand is neverparsed.
get_staged_and_unstaged()does see untracked files (git status --porcelain),but it is only reached by
detect-changes,update --briefand the review tools asa fallback when
get_changed_files()returns empty — never by the code thatactually writes nodes into the graph.
Reproduction
In any repo with at least one commit:
git add -Nchanges nothing about the file's content — only git's index — whichisolates the cause to discovery.
Impact
The MCP integration is wired to be live: a
PostToolUsehook runscode-review-graph updateafter every edit, andserve --auto-watchwatches thetree. Both are defeated for new files. The assistant is told the graph is current,
asks
callers_of/references_to/file_summaryabout code it wrote secondsago, gets zero, and concludes the symbol does not exist.
This is the same failure #987 describes, approached from the other end: #987 makes
the empty result admit its uncertainty, which is a real improvement, but the result
is still empty. The proposal here is that it should not be empty.
Proposed fix
Make discovery tracked ∪ untracked-but-not-ignored, which is exactly
git ls-files∪git ls-files --others --exclude-standard.--exclude-standardapplies
.gitignore,.git/info/excludeandcore.excludesFile, so build output,node_modules, virtualenvs and the tool's own.code-review-graph/stay out — theexisting
DEFAULT_IGNORE_PATTERNSpass then applies on top as it does today. Unlikegit status --porcelain,ls-files --otherslists individual files rather thancollapsing a wholly-untracked directory to
dir/, so no second walk is needed.Two call sites:
collect_all_files()— union the untracked list intocandidates(keep thefilesystem-walk fallback for when both are empty, i.e. not a git repo).
get_changed_files()— union the untracked list into the git branch's result, sothe incremental path sees new files. A file git has never seen is, by any useful
definition, a change.
--recurse-submodulesis not valid with--others, so the submodule option appliesonly to the tracked call.
Deletion caveat — also fixed in the local patch
Once untracked files are indexed, deleting one is invisible to the incremental path
in the same way:
git diffnever names it (never tracked) and--othersno longernames it (gone), so
remove_file_data()is not called and the node lingers until thenext full build, whose stale purge does clear it.
serve --auto-watchcatches it viafilesystem events, so this only affects hook-only setups (the
PostToolUsehookcalling
update, or any other caller ofincremental_update()with no watcherrunning).
Update: this is no longer an open gap in my local patch.
incremental_update()nowresolves the graph's recorded file list against disk before its early return: for
every file the graph already knows about that this update's own diff/untracked scan
didn't name, it stats the path and calls the existing
remove_file_data()if thefile is gone — the same mechanism the watcher's
on_deleteduses, just triggered bya filesystem stat instead of a filesystem event. Verified round-trip on a scratch
repo: create an untracked file →
update→ node appears; delete it, no rebuild →update→ node is gone. No extra git calls, no watcher required.Environment
code-review-graph2.3.6 (installed as a uv tool), macOS 15, Python 3.13mainas of today:collect_all_files()andget_changed_files()are unchanged in this respect in 2.3.8 and later.