CCLearn

Scoping a repo too big for the window

verified against Claude Code 2.1.261 · 2026-09-0920 min

By the end you can

  • Choose a starting directory deliberately, knowing what it decides about access, memory and settings
  • Layer per-directory CLAUDE.md files and exclude the ones you never work in
  • Write Read deny rules for checked-in generated code, and state what they do not cover
  • Configure sparse worktrees and cross-package access so a large tree costs less on disk and in context
  • Confirm what actually loaded instead of assuming the configuration took effect

The repository has nine packages and you work in one. Claude starts, and the window already holds conventions for the eight you have never opened. Your first question sends it reading through a vendored SDK somebody checked in four years ago. Nothing malfunctioned. The failure mode of this level is not danger — it is confident waste, and it begins before you type.

The default settings are tuned for a smaller repo

Claude Code works at any size, but as the codebase grows, the defaults tuned for smaller projects can fill the context window with instructions and file reads unrelated to the task, costing tokens and degrading Claude's performance. — Monorepos and large repos

Read that as a statement about configuration, not about the model. Lesson 3.1 counted what a window is spent on; this is the same accounting in a tree where the defaults stop holding. The settings below are independent — apply the ones your repository needs.

Where you start decides almost everything

One decision does most of the work, and you make it in the shell before any settings file exists:

Where you launch claude determines which files Claude can read and edit without an additional permission grant, which CLAUDE.md files load into context at startup, and which project settings apply. — Monorepos and large repos

Start fromFile accessCLAUDE.md loaded at launchUse when
Repository rootEvery fileRoot only; subdirectory files load on demand when Claude reads thereTasks span multiple packages or subsystems
A subdirectoryThat subtree only, until you grant moreThat directory's plus every ancestor'sWork is scoped to one package or subsystem

Start in packages/api/ and Claude loads that package's CLAUDE.md and the root one, with nothing from packages/web/ in context. Start at the root and you get the root file at launch, then each subdirectory's file on demand as Claude reads files there. On-demand loading happens either way — what differs is its reach. From the root it can pull in any package in the tree; from packages/api/ it cannot leave that subtree, because nothing outside it is readable without a further grant.

Neither is better. Root is right when the change genuinely spans packages, a subdirectory when it does not. What is wrong is choosing by habit and then being surprised by what loaded.

Layer CLAUDE.md by directory, then exclude the rest

Lesson 6.1 established the load order and Lesson 6.6 argued that a long instruction file makes results worse. At this scale the argument turns structural: one root file either grows to cover every subsystem or stays too generic to help. The documented split is two levels — repository-wide rules at the root, area conventions in each package or subsystem directory — committed so teammates inherit them, each maintained by whoever owns that directory. The size target is per file, not per repository:

target under 200 lines per CLAUDE.md file. Longer files consume more context and reduce adherence. — Memory

Refusing other people's files is claudeMdExcludes, covered in Lesson 6.3. Two of its properties matter more here. It is static:

The exclusion list is static, not a per-task switch. To focus on one package today and another tomorrow, start Claude from that package's directory instead of editing exclusions. — Monorepos and large repos

And managed policy CLAUDE.md files cannot be excluded, so organization-wide instructions always apply (Settings reference). If a line you keep fighting comes from managed policy, no exclusion list removes it.

Block reads you will never want

Instructions are one cost; file reads are the other. Most of the reading you want to prevent is already prevented:

Claude's content searches respect .gitignore by default, so paths already listed there, such as node_modules/, dist/, and build/, stay out of search results without additional configuration. — Monorepos and large repos

So deny rules earn most of their keep on what is checked in: a vendored SDK, committed generated code, a directory of golden snapshots. Be exact about what .gitignore bought you, though. It keeps a path out of search results; it does not keep Claude from opening the file. Grep skips gitignored files, and "to search a gitignored file, Claude passes its path directly" (Tools reference). That is why the documentation's own example still denies dist/ and build/ — directories it named one paragraph earlier as typically gitignored.

json
{
  "permissions": {
    "deny": [
      "Read(./**/dist/**)",
      "Read(./**/build/**)",
      "Read(./**/*.generated.*)",
      "Read(./vendor/**)"
    ]
  }
}

Now the part that decides whether you use these honestly. How far a Read deny rule reaches — which Bash file commands it covers, which redirect targets, what it does to Edit and Write on the same path, and the version each of those arrived in — is Lesson 10.5's table, and its limit is that lesson's subject: the rule is a check Claude Code runs before a tool call, so it never sees the open that happens inside a subprocess a permitted command started. One limit belongs here, because it is the one a large tree hits daily:

A Bash search such as grep -r or find over a directory that contains denied files still includes them in its output. — Monorepos and large repos

A deny rule is a read-cost control, not a boundary. Needing an actual boundary — secrets rather than noise — is Level 10's problem, and a deny rule alone is not its answer.

One asymmetry worth keeping, because it surprises people in a tree with a large gitignore. Grep skips gitignored files; Glob does not:

Glob doesn't respect .gitignore by default, so it finds gitignored files alongside tracked ones. — Tools reference

A file search can surface what a content search cannot.

Sparse worktrees and cross-package access

--worktree starts a session in a new git worktree and by default checks out the whole repository. worktree.sparsePaths uses git sparse-checkout to write only the listed directories plus root-level files, so worktrees start faster and cost less disk. Paths are relative to the repository root regardless of where you launched.

json
{
  "worktree": {
    "sparsePaths": [".claude", "packages/api", "packages/shared"],
    "symlinkDirectories": ["node_modules"]
  }
}

Root-level files such as package.json and lock files come along automatically. Root-level directories do not, which makes the first entry the one people leave out:

Root-level directories are not, so include .claude in the list if you want the repository root's .claude/settings.json, .claude/rules/, or .claude/skills/ available inside the worktree. — Monorepos and large repos

Omit it and the worktree has no repository-root .claude/ at all — no root settings.json, no rules/, no root-level skills/ — and that root file is exactly where a worktree session's project settings come from, as the next section shows. symlinkDirectories points each worktree's node_modules/ at the main copy rather than duplicating it. Worktree lifecycle — base branch, what a fresh checkout is missing, what cleanup deletes — is Lesson 11.4; here they are a disk-and-context setting only.

Sparse checkout leaves a mark on the repository itself. Git needs extensions.worktreeConfig in the shared .git/config while a sparse worktree exists, and Claude Code removes that entry once the last worktree goes — but only the entry it added itself. A value you set by hand it never removes, so on a repository where you enabled worktree config yourself the cleanup is still yours. It did not always clean up at all: "Before v2.1.207, the entry remained after the last worktree was removed, and go-git-based tools such as tea failed to open the repository until you ran git config --unset extensions.worktreeConfig" (Monorepos and large repos). When a tool that worked yesterday stops being able to open the repository after you finish with sparse worktrees, that unset is the fix — reach for it before looking for the change you made to the tool.

Reaching outside your starting directory has two doors, and they are not equivalent:

Added withFile accessLoads CLAUDE.md and rulesLoads skills
permissions.additionalDirectoriesYesNeverNever
--add-dir flag or /add-dirYesOnly with CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD setYes

That table reads as a feature in both directions. additionalDirectories edits packages/shared from packages/api without inheriting the shared package's instructions; --add-dir picks up a sibling's skills when you want them. The environment variable has no effect on directories listed in the setting.

The trap: settings do not layer the way memory does

CLAUDE.md layers down the tree, so it is a short step to assume .claude/settings.json does too. It does not:

Project settings in .claude/settings.json aren't inherited from parent directories the way CLAUDE.md files are. — Monorepos and large repos

Each subdirectory's settings file must stand alone. Deny rules written once at the root do not reach a session started in packages/api/.

The second half is worse, because it moves the ground under a configuration that was working:

The sparsePaths and symlinkDirectories settings are read from your starting directory before the worktree is created. After creation, the session's working directory is the worktree root, not the subdirectory you launched from. — Monorepos and large repos

So deny rules living only in packages/api/.claude/settings.json stop applying the moment that session works from a worktree, where project settings load from the worktree root's copy of the repository root's file. The documented fix is a second copy: permission rules and hooks you need inside worktrees go in the repository root's .claude/settings.json, duplicated rather than inherited.

A related edge, since deny patterns are paths: a relative pattern such as Read(./vendor/**) anchors at the session's current working directory, not at the repository root (Permissions). If you launch from several different subdirectories, write those rules with //-absolute paths.

Prove it loaded

Every setting here is silent when it works and silent when it does not. The check is one command:

To confirm which files loaded, run /context and check the list under Memory files. — Monorepos and large repos

That list is the difference between "I excluded the other team's package" and "the exclusion pattern matched". Exclusion patterns are matched against absolute file paths, so a relative-style pattern needs a leading **/ to match anywhere in the tree (Settings reference); one written without it matches nothing and says nothing. Presence or absence in the Memory files list is the only signal you get. Lesson 3.2 made /context the instrument panel for the window. Here it is the acceptance test for your configuration, run once after every edit to one of these files.

Checkpoint

  1. You work almost entirely in packages/api/, but today's change updates a type in packages/shared/ and every call site in api and web. Where do you start Claude, and what does that decision cost compared with the other option?

    Show answer

    Start at the repository root. The change genuinely spans packages, which is the case the root is for: from there every file is readable and editable without a further grant. Starting in packages/api/ would give you that subtree only, and packages/shared/ and packages/web/ would each need permissions.additionalDirectories or --add-dir before Claude could touch a call site.

    The cost is context and settings. Only the root CLAUDE.md loads at launch, but on-demand loading can then reach any package in the tree as Claude reads there, so conventions for the eight packages you never open can arrive uninvited — from packages/api/ that on-demand reach cannot leave the subtree. And where you launch determines which project settings apply; settings do not layer down the tree the way CLAUDE.md does, so deny rules, sparse paths and anything else living in packages/api/.claude/settings.json are not what governs a root session.

  2. A teammate adds Read(./**/*.generated.*) to the repository root's settings and reports that Claude can no longer open generated files. You then watch it print the contents of one. Name two ways that happens without either of you being wrong.

    Show answer

    Both of you are describing a rule that works — you watched the content come out of a door the rule does not guard. First: it was not a Read call. A deny rule is a check Claude Code runs before a tool call, so a Bash search such as grep -r or find over a directory that contains denied files still includes them in its output, and the check never sees the open that happens inside a subprocess a permitted command started. Read was not bypassed; Read was not what ran.

    Second: the rule was not in force in that session. Project settings in .claude/settings.json are not inherited from parent directories, so a rule written once at the repository root does not reach a session started in packages/api/. And the pattern is relative: Read(./**/*.generated.*) anchors at the session's current working directory, not at the repository root, so from another starting directory it can match nothing at all. Write such rules with //-absolute paths if you launch from several directories.

  3. Your packages/api/.claude/settings.json carries sparse paths and deny rules, and both work — until you run the same session with --worktree and the deny rules stop applying. What changed, and which file has to hold them instead?

    Show answer

    The working directory changed underneath the configuration. sparsePaths and symlinkDirectories are read from your starting directory before the worktree is created, which is exactly why the sparse checkout still came out right and misled you. After creation, the session's working directory is the worktree root, not packages/api/ — so project settings load from the worktree root's copy of the repository root's .claude/settings.json, and your deny rules only ever existed in the package's file.

    They have to be in the repository root's .claude/settings.json, duplicated rather than inherited — that is the documented fix for permission rules and hooks you need inside worktrees. Two follow-ons: include .claude in sparsePaths, or the worktree has no repository-root .claude/ at all and the copy you just wrote is not there either; and if the patterns are relative, they now anchor at the worktree root, so use //-absolute paths.

  4. Which single command would have told you, before any of the above, that the file you edited had no effect at all?

    Show answer

    /context, reading the list under Memory files. It is the documented way to confirm which files loaded, and it is the only signal you get: every setting in this lesson is silent when it works and silent when it does not. That list is the difference between "I excluded the other team's package" and "the exclusion pattern matched".

    The silent no-op it is built to catch: exclusion patterns are matched against absolute file paths, so a relative-style pattern needs a leading **/ to match anywhere in the tree — written without it, it matches nothing and says nothing. Run /context once after every edit to one of these files rather than assuming the edit took.

Sources