Repository: colbymchenry/codegraph (2026-06-13T20:58:48Z)

Issue:
Git worktrees nested in the project (e.g. Claude Code's .claude/worktrees/) are indexed as N duplicate embedded repos, with no way to exclude them
## Summary

When a project contains git worktrees nested inside its own tree, codegraph indexes each worktree as a full embedded repo, producing N near-identical copies of every symbol. There is currently no way to exclude them: no `.codegraphignore`, no config key, no env var, and the root `.gitignore` is intentionally bypassed for embedded repos.

## Environment

- codegraph 1.0.0 (npm/Homebrew, linux-x64)
- A Go + TypeScript/Svelte repo, roughly 1,850 source files
- 13 git worktrees created by Claude Code under `.claude/worktrees/` (each made with `git worktree add`, so each has a `.git` *file* pointing into the parent's `.git/worktrees/<name>`)
- `.claude/worktrees/` is listed in the root `.gitignore`

## What happens

`codegraph status` after a normal index:

```
Files:   24,533   (expected ~1,850)
Nodes:   501,218
Edges:   2,007,176
DB Size: 1862 MB
```

Every symbol shows up ~14 times (the main checkout plus 13 worktrees). A `codegraph query <symbol>` fills the entire result window with `.claude/worktrees/<name>/...` duplicates, so symbol search and natural-language `explore` become unusable; NL `explore` frequently resolves to a stale worktree copy instead of the main tree.

## Root cause

This is the embedded-repo indexing behavior (#514, and the direction requested in #622): codegraph deliberately indexes nested git repos even when the parent `.gitignore` excludes them, on the assumption that an embedded repo is first-party code the parent merely hides for hygiene. The gate in `extraction/index` indexes any nested directory containing `.git` as long as it is not matched by the *defaults-only* ignore set (which excludes `node_modules`, `vendor`, and similar, but not `.claude`).

Git worktrees break that assumption. A worktree is not a distinct embedded project; it is a duplicate working view of the *same* repo that is already indexed. So instead of adding new code, each worktree multiplies the whole graph.

## Why this differs from #622

#622 (a 17-service monorepo of separate clones) genuinely wants gitignored embedded repos indexed, because each clone is distinct source. Worktrees are the opposite case: same repo, different branch, so indexing them is pure duplication. A fix needs to tell the two apart.

## Suggested fixes (any one helps)

1. Detect git worktrees and skip them. A worktree's `.git` is a *file* containing `gitdir: .../.git/worktrees/<name>` rather than a `.git` *directory*. Skipping entries whose `.git` resolves into the parent repo's own `.git/worktrees/` would exclude worktrees while still indexing genuine embedded clones (the #622 case).
2. Honor a user-provided ignore mechanism (a `.codegraphignore`, a config key, or an env var) so paths like `.claude/` can be excluded from the embedded-repo pass. Today the root `.gitignore` is intentionally bypassed for embedded repos and there is no other lever.
3. At minimum, add `.claude` (Claude Code's worktree and config directory) to the default ignore set, since `.claude/worktrees/` is becoming a very common nested-worktree layout.

## Workaround

Patching `DEFAULT_IGNORE_DIRS` in `extraction/index` to include `.claude`, then re-indexing, drops the graph from 24,533 to 1,850 files and removes every duplicate symbol. It has to be re-applied after each codegraph upgrade.



Discussion:
Fixed in #868 (merged to `main`) — and thank you for the unusually well-diagnosed report; your option 1 was exactly the fix.

CodeGraph now classifies each nested `.git` before indexing it:

- `.git` **directory** → a genuine embedded clone → indexed (unchanged, #514/#622)
- `.git` **file → `…/.git/worktrees/…`** → a git worktree (same repo, already indexed) → **skipped**
- `.git` **file → `…/.git/modules/…`** → a submodule → indexed (unchanged)

So worktrees fall back to your normal `.gitignore` and stop multiplying the graph, while real embedded repos and submodules are unaffected. Reproducing your layout (worktrees under a gitignored `.claude/worktrees/` plus a genuine embedded clone), the index drops from the duplicated count back to the main tree's files, every symbol appears once, and the embedded clone is still indexed. It applies to worktrees anywhere, not just `.claude/` — no `DEFAULT_IGNORE_DIRS` patching needed.

This ships in the next release; `codegraph index -f` after upgrading will rebuild without the duplicates. Thanks again @tphakala!

@colbymchenry this is still a blocker issue. I have a nested git repo (~2600 files) inside the repo I want to work with (~70 files), and I cannot find any way to exclude the nested repo. It's in `.gitignore` but codegraph ignores the `.gitignore` for this case and aggressively includes everything.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3113c059d..60ac01277 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- Git worktrees nested inside your project — like the `.claude/worktrees/` that Claude Code creates — are no longer indexed as duplicate copies of your whole codebase. CodeGraph deliberately indexes genuine embedded repos (a real second project checked out inside yours), but a worktree is just another working view of a repo it already indexed, so each one was multiplying every symbol — one report went from ~1,850 files to over 24,000, with search and `explore` flooded by stale duplicates. CodeGraph now recognizes worktrees and skips them, while still indexing real embedded repos and submodules. Thanks @tphakala. (#848)
 - Running `codegraph serve --mcp` by hand no longer just hangs in silence. That command is the MCP server your AI agent starts for itself — not a step you run directly — and in a terminal it used to sit there waiting for input that never comes, looking broken. It now recognizes when a person runs it and explains what to do instead (`codegraph status`, `codegraph daemon`), and it's been dropped from the command listing so it stops looking like something you need to launch.
 - Cross-file static method calls like `ClassName.staticMethod()` now resolve correctly. CodeGraph was linking the call to the *class* instead of the method (and recording it as a construction), so `callers` and `impact` for a static method came back empty — a real blind spot in TypeScript and JavaScript codebases that lean on static utility classes (Python and other languages with the same call shape benefit too). The call now links to the method itself. Thanks @contextFlow-lab. (#825)
 - `codegraph affected` now accepts `./`-prefixed and absolute file paths, not just bare project-relative ones. Passing `./src/x.ts` or an absolute path — common when the file list comes from another tool — used to silently match nothing and report no affected tests. Thanks @contextFlow-lab. (#825)
diff --git a/src/extraction/index.ts b/src/extraction/index.ts
index 9921c6fb6..643634d66 100644
--- a/src/extraction/index.ts
+++ b/src/extraction/index.ts
@@ -280,6 +280,39 @@ const EMBEDDED_REPO_SEARCH_DEPTH = 4;
 /** Max directories examined per search — a huge ignored data dir must never stall a scan/sync. */
 const EMBEDDED_REPO_SEARCH_ENTRIES = 2000;
 
+/**
+ * Classify a directory's `.git` entry for embedded-repo discovery.
+ *
+ * - A `.git` **directory** is an embedded clone — distinct first-party code a
+ *   super-repo merely hides from git; index it (#193, #514).
+ * - A `.git` **file** is a pointer (`gitdir: …`). A git **worktree** points into
+ *   the host repo's own `.git/worktrees/<name>`, so it is a second working view
+ *   of a repo CodeGraph already indexes — indexing it just duplicates the whole
+ *   graph N times; skip it (#848). A **submodule** points into `.git/modules/`
+ *   and is distinct code, so index it as before.
+ *
+ * Returns `'none'` when there is no `.git` entry here.
+ */
+function classifyGitDir(absDir: string): 'embedded' | 'worktree' | 'none' {
+  let st: fs.Stats;
+  try {
+    st = fs.statSync(path.join(absDir, '.git'));
+  } catch {
+    return 'none';
+  }
+  if (st.isDirectory()) return 'embedded';
+  if (!st.isFile()) return 'none';
+  try {
+    const gitdir = fs.readFileSync(path.join(absDir, '.git'), 'utf8').match(/^gitdir:\s*(.+)$/m)?.[1]?.trim();
+    // A linked worktree's gitdir lives under some repo's `.git/worktrees/`.
+    // Match both separators so a Windows-style pointer is recognized too.
+    if (gitdir && /(^|[\\/])\.git[\\/]worktrees[\\/]/.test(gitdir)) return 'worktree';
+  } catch {
+    // Unreadable `.git` pointer — fall back to the prior "index it" behavior.
+  }
+  return 'embedded';
+}
+
 /**
  * Find git repositories nested under `absDir` (inclusive), shallow bounded BFS.
  * Stops descending at each repo root found — contents belong to that repo's own
@@ -300,7 +333,11 @@ function findNestedGitRepos(absDir: string, relPrefix: string): string[] {
       logDebug('Embedded-repo search entry cap hit — deeper repos (if any) not discovered', { under: relPrefix });
       break;
     }
-    if (fs.existsSync(path.join(abs, '.git'))) {
+    const cls = classifyGitDir(abs);
+    if (cls === 'worktree') {
+      continue; // a git worktree duplicates an already-indexed repo (#848) — skip
+    }
+    if (cls === 'embedded') {
       found.push(rel);
       continue; // its own git handles everything below
     }
@@ -473,7 +510,9 @@ function collectGitFiles(repoDir: string, prefix: string, files: Set<string>, em
       // descend into default-ignored locations — an embedded repo inside
       // node_modules is an npm git-dependency, not project code.
       const childDir = path.join(repoDir, rel);
-      if (fs.existsSync(path.join(childDir, '.git')) && !defaultsOnlyIgnore().ignores(rel)) {
+      // A git worktree surfaces here as an opaque untracked dir too — skip it,
+      // it's a duplicate working view of an already-indexed repo (#848).
+      if (classifyGitDir(childDir) === 'embedded' && !defaultsOnlyIgnore().ignores(rel)) {
         embeddedRoots?.add(normalizePath(prefix + rel));
         collectGitFiles(childDir, prefix + rel, files, embeddedRoots);
       }


Test patch:
diff --git a/__tests__/multi-repo-workspace.test.ts b/__tests__/multi-repo-workspace.test.ts
index db1fba2a2..73e2330a0 100644
--- a/__tests__/multi-repo-workspace.test.ts
+++ b/__tests__/multi-repo-workspace.test.ts
@@ -108,6 +108,29 @@ describe('multi-repo workspaces (#514)', () => {
     expect(files).toContain('main.ts');
   });
 
+  it('skips nested git worktrees instead of indexing them as duplicate embedded repos (#848)', () => {
+    // Claude Code (and others) create worktrees under a gitignored path like
+    // `.claude/worktrees/<name>/`. A worktree's `.git` is a FILE pointing into
+    // the host repo's own `.git/worktrees/`, so it is the SAME repo already
+    // indexed — sweeping it in as an embedded repo multiplies the whole graph.
+    // A genuine embedded clone (a `.git` *directory*) must still be indexed.
+    write(path.join(ws, 'src/app.ts'), 'export function app() { return 1; }\n');
+    write(path.join(ws, '.gitignore'), '.claude/\nvendored/\n');
+    makeRepo(ws);
+    // A real linked worktree under the gitignored .claude/worktrees/.
+    git(ws, 'worktree', 'add', '-q', '.claude/worktrees/feature', '-b', 'feature');
+    // A genuine embedded clone, also gitignored — must STAY indexed (#514).
+    write(path.join(ws, 'vendored/lib.ts'), 'export function vendoredFn() { return 9; }\n');
+    makeRepo(path.join(ws, 'vendored'));
+
+    const files = scanDirectory(ws);
+    expect(files).toContain('src/app.ts');
+    // The worktree is a duplicate working view — never indexed.
+    expect(files.some((f) => f.includes('.claude/worktrees'))).toBe(false);
+    // The genuine embedded clone is still indexed (#514/#622 preserved).
+    expect(files).toContain('vendored/lib.ts');
+  });
+
   it('non-git workspace: walks children and respects each child own .gitignore', () => {
     write(path.join(ws, 'proj-a/src/auth.ts'), 'export function login() {}\n');
     write(path.join(ws, 'proj-a/build/out.ts'), 'export function generated() {}\n');