Repository navigation
feat(monorepo): widen dynamic rule walk to the git repository root - #31
Conversation
findProjectRoot stops at the innermost project marker. In monorepos with nested markers (Cargo workspaces: a Cargo.toml per member crate, pnpm workspaces: per-package package.json) a target file inside a member resolves its project root to the member directory, so the rule walk never reaches workspace- or repository-level .github/instructions/ directories and those rules silently never fire for member code. Widen the dynamic (per-target) project root to the innermost enclosing .git repository root instead: - engine.loadDynamicRules and fingerprintDynamicTargets wrap the per-target findProjectRoot result with widenToRepositoryRoot. - Globs stay keyed to the directory owning the rule file via the existing scopeRelative path base: a rule in backend/.github/instructions/ matches backend-relative patterns, a rule in the repository-root .github/instructions/ matches repo-relative patterns. - A .git in the user's home directory (dotfiles repositories) is ignored so rules never leak from ~ into marker-less projects. - Static (always-on) discovery is unchanged. - index.ts now dedups natively loaded context files in the tool_result path too: with the walk reaching repository level, single-file rules (AGENTS.md/CLAUDE.md) from levels pi already loaded natively must not be re-injected per matching read. Tests: unit tests for widenToRepositoryRoot (project-root.test.ts) and end-to-end engine tests with a real Cargo-workspace layout (engine-monorepo.test.ts) covering workspace-level rules, repository -level rules via repo-relative globs, negative scoping, and discovery of single-file rules on the walk.
Single-file rules (AGENTS.md/CLAUDE.md) match every dynamic target. With the walk now reaching repository level, a root-launched session that reads N backend files would re-inject backend/AGENTS.md N times (the per-target dynamic cache keys on the target path). Track realpaths of already-injected single-file rules at session level and clear the set on session start and compaction alongside the engine session state. Glob rules keep per-target re-injection semantics (a pipeline file and a photo module file each get their matching instructions).
|
Preempting a likely objection — "GitHub Copilot only loads instructions from the repository root, so widening breaks Copilot compatibility." I think that argument is worth addressing head-on, because the comparison is only partially apt:
That said, if the default-on widening feels too aggressive for the project's direction, I'm happy to rework this into an opt-in (env var / registered flag, e.g. Happy to implement whichever variant you prefer — thanks for the extension either way, it slots into the pi workflow really nicely. Co-authored-by: glm-5.3 (neuralwatt) |
code-yeongyu
left a comment
There was a problem hiding this comment.
The widening design is right: walk-up stops at the first .git, so it does not escape above the innermost repository root. The filesystem-root, dirname-stall, and home-directory guards keep the walk O(depth) and bounded on deep trees. Static discovery staying marker-based is a reasonable compatibility choice.
I cannot squash-merge as-is: biome format fails on src/index.ts, and four unit tests fail (macOS /var vs /private/var path canonicalization, plus engine-monorepo tests that compare non-canonical write() paths and do not isolate homeDir, so real user-home rules leak into the assertion).
Please address the inline comments. I will land this once check+unit tests are green.
| } | ||
|
|
||
| if (existsSync(join(currentDirectory, ".git"))) { | ||
| return currentDirectory; |
There was a problem hiding this comment.
Walk correctly returns the first .git found walking up, so it cannot escape above the innermost repo root. Please realpath the walk (same as findProjectRoot) before comparing/returning: on macOS resolve() stays on /var/folders while tests and findProjectRoot use /private/var, so widenToRepositoryRoot currently returns a non-canonical path and the nested-member test fails. Also add a test that a nested .git wins over a parent .git (must not keep walking).
| engine.commitDynamicTargetFingerprints(fingerprints); | ||
| for (const rule of loaded.rules) { | ||
| if (nativeContextPaths.has(rule.path) || nativeContextPaths.has(rule.realPath)) { | ||
| engine.markStaticInjected(rule); |
There was a problem hiding this comment.
Biome check fails here: extra indent on markStaticInjected, and the contextFiles loop on line 103 exceeds lineWidth 120. Please run biome check --write (do not disable the rule).
| - Native context dedup in the `tool_result` path: single-file rules (AGENTS.md/CLAUDE.md) | ||
| that pi already loaded natively into the system prompt are no longer re-injected per | ||
| matching file read when dynamic discovery walks to the repository root. | ||
| ### Added |
There was a problem hiding this comment.
Duplicate ### Added heading left over from the rebase. Drop this line so the Unreleased Added section is a single list.
|
|
||
| // then | ||
| const paths = result.rules.map((rule) => rule.path); | ||
| expect(paths).toContain(rustRule); |
There was a problem hiding this comment.
These assertions compare fs.write() paths (non-canonical tmpdir) to rule.path (often realpathed). On macOS that is /var/folders vs /private/var, so toContain fails even when the rule was loaded. Pass homeDir: fs.mkdir("home") on findCandidates so user-home rules cannot leak, and compare via realpath/canonical paths. The third test expecting length 0 currently sees 3 rules for the same reason.
| const result = widenToRepositoryRoot(memberRoot); | ||
|
|
||
| // then | ||
| expect(result).toBe(canonicalPath(repositoryRoot)); |
There was a problem hiding this comment.
canonicalPath(repositoryRoot) is /private/var/... while widenToRepositoryRoot returns resolve() (/var/folders/...). Either realpath inside widenToRepositoryRoot or assert against resolve(repositoryRoot) consistently. Please also cover: (1) home-directory .git is ignored, (2) walk stops at innermost .git and does not continue to a parent repo.
Canonicalize paths so the git-root walk and candidate discovery agree on macOS symlink roots, and stop asserting against non-canonical tmp paths.
code-yeongyu
left a comment
There was a problem hiding this comment.
Follow-up landed: realpath the git-root walk, canonicalize discovery targets so macOS symlink roots stay inside the repo, biome format on src/index.ts, changelog heading, isolated homeDir in engine-monorepo tests, and coverage that the walk stops at the innermost .git. Unit + integration tests pass locally. Squash-merging.
|
GitHub Actions did not start jobs for this PR: workflow run https://github.com/code-yeongyu/pi-rules/actions/runs/35965336806 concluded |
Summary
findProjectRootstops at the innermost project marker, so a target file inside a workspace member (Cargo workspace: aCargo.tomlper member crate; pnpm workspace: apackage.jsonper package) resolves its project root to the member directory — the walk-up ends there and workspace/repository-level rule directories are never discovered. Those rules silently never fire for member code. This PR widens the dynamic (per-target) project root to the innermost enclosing git repository root (.git), so rules placed at member, workspace and repository level all participate.scopeRelativepath base: a rule in<workspace>/.github/instructions/matches workspace-relative globs, a rule in the repository root's.github/instructions/matches repo-relative globs.tool_resultpath as well; (2) since single-file rules match every target, they are now injected once per session (set cleared on session start/compaction) instead of once per read file. Static (always-on) discovery is unchanged. A.gitin the user's home directory (dotfiles repositories) is ignored so rules never leak from~into marker-less projects.Motivating example
Reading
backend/crates/member/src/lib.rscurrently injects nothing (project root =backend/crates/member); with this change it injectsrust.instructions.mdand the walk's single-file rules, regardless of the session cwd.Verification
npm run check(typecheck + biome)npm test(unit tests — 275 passing, incl. 7 new)npm run test:integration(integration tests — 46 passing)npm pack --dry-run(release sanity)pi -e ./src/index.tssmoke-tested locally — verified end-to-end against a real Cargo-workspace monorepo (headless pi sessions reading member files, non-member workspace files, and repository-level files; per-read injection sets and scope isolation asserted, plus negative case: workspace rule scoped toscripts/**does not match member.rsreads)Rule-loading impact
[Unreleased]New tests: unit tests for
widenToRepositoryRootintest/project-root.test.tsand engine-level tests with a real marker-nested workspace layout (test/engine-monorepo.test.ts), following the existing fixture helpers and#given/#when/#thenconventions.Design notes / open questions for the maintainer:
.gitguard is a pragmatic safeguard against dotfiles repositories; an alternative would be making the widening opt-in. I kept it default-on because plain single-project repos are unaffected (their project root already contains.git, sowidenToRepositoryRootis a no-op) and the monorepo case silently losing rules is harder to debug than the widening.Summary by cubic
Widens dynamic rule discovery to the enclosing git repository root so rules placed at repository, workspace, and member levels all fire for files inside workspace members (Cargo/pnpm workspaces). Previously the walk stopped at the innermost project marker, so workspace-level rules silently never applied to member code.
Monorepo rule discovery
.git; static discovery is unchanged./varvs/private/var).scopeRelativepath base..gitin the user's home directory (dotfiles repositories) is ignored so rules don't leak from~.Single-file rule injection
AGENTS.md/CLAUDE.md) are now injected once per session instead of once per read file; the set clears on session start and compaction.tool_resultpath as well.Written for commit 1c7a86f. Summary will update on new commits.