Skip to content

fix(sync): detect changes when the project lives in a subdirectory of its git repo - #1636

Closed
innoxxicide wants to merge 1 commit into
colbymchenry:mainfrom
innoxxicide:fix/git-status-subdir-project
Closed

innoxxicide wants to merge 1 commit into
colbymchenry:mainfrom
innoxxicide:fix/git-status-subdir-project

Conversation

@innoxxicide

Copy link
Copy Markdown

No matching issue — found while using CodeGraph on a repository whose project
lives one directory down. I couldn't find an existing report for it, so the
repro is written out in full below.

codegraph status printed Index is up to date no matter how many files had
changed, whenever the indexed project sat below its repository root — a
monorepo package, an app/ folder beside a server/ one. codegraph sync run
a second later found the same files and reindexed them, so the two commands
flatly contradicted each other, and anything trusting the status count (the
staleness reminder, a scripted check) read the index as clean forever.

Reproduced both ways on the released build before touching anything:

layout edit codegraph status
project is the repo root 1 file modified Modified: 1 files
project in app/ of the repo 1 file modified Index is up to date

What was wrong

getGitChangedFiles runs git status --porcelain with cwd set to the
project root. Porcelain is indifferent to cwd in both directions: it
prints repository-relative paths (the format deliberately ignores
status.relativePaths) and it reports the whole repository, not just cwd.
Those paths went straight into path.join(projectRoot, filePath), producing
<repo>/<sub>/<sub>/… — a path that cannot exist. fs.readFileSync threw, and
the entry was swallowed by the logDebug('Skipping unreadable file while detecting changes') guard, so added and modified came back empty for
every edit
. The failure was silent and total, not partial.

getGitVisibleFiles — the scan/index path — was never affected, because
git ls-files is both cwd-relative and cwd-scoped. That asymmetry between the
two git fast paths is exactly why status and sync disagreed, and it is now
pinned by a test so it stays deliberate.

What this does

Detection is fully automatic — no codegraph.json option added, nothing for a
user to opt into. Scoping git status to the subtree is also strictly cheaper in
a large monorepo than reporting the whole repository and discarding most of it.

Tests

New __tests__/git-changed-subdir-project.test.ts, 11 cases. 8 of them
fail on unmodified main
(expected [ 'app/src/index.js' ] to include 'src/index.js', and expected [] to include 'src/index.js' through the public
CodeGraph.getChangedFiles) and pass here; the other 3 are regression guards for
behaviour that already worked.

Modified / added / deleted reported with project-relative paths · sibling-package
edits ignored · status agreeing with sync end to end through the public
API
· the embedded-repo recursion below a subdirectory project (#1213) · the
project's own .gitignore (#766) · the gitignored-project fallback · project ==
repository root · a project not in git at all.

npx vitest run __tests__/git-changed-subdir-project.test.ts
     Tests  11 passed (11)

npx vitest run __tests__/git-changed-untracked-dir.test.ts __tests__/git-hooks.test.ts \
              __tests__/include-ignored-config.test.ts __tests__/sync.test.ts \
              __tests__/sync-rebuild-convergence.test.ts
     Tests  69 passed (69)

Full suite (Windows): 3004 passed / 27 failed / 219 skipped. Those 27 failures
reproduce test-for-test on unmodified HEAD — 26 are the documented Windows
EPERM temp-dir-cleanup quirk, the rest the explore-* budget gates — so none is
introduced here. tsc --noEmit clean.

Also

A user-facing CHANGELOG.md entry under [Unreleased] → Fixes, per the
CHANGELOG rules in CLAUDE.md. No version bump.

🤖 Generated with Claude Code

… its git repo

## Problem

`codegraph status` printed `Index is up to date` no matter how many files had
changed, whenever the indexed project sat below its repository root (a monorepo
package, an `app/` folder beside a `server/` one). `codegraph sync` run
immediately after found the same files and reindexed them, so the two commands
flatly contradicted each other, and anything trusting the status count — the
staleness reminder, a scripted check — read the index as clean forever.

`getGitChangedFiles` runs `git status --porcelain` with `cwd` set to the PROJECT
root. Porcelain prints REPOSITORY-relative paths from any cwd (the format
deliberately ignores `status.relativePaths`) and reports the whole repository
rather than just `cwd`. `getChangedFiles` then did
`path.join(projectRoot, filePath)`, producing `<repo>/<sub>/<sub>/...`. No such
file exists, `fs.readFileSync` threw, and the entry was dropped by
`logDebug('Skipping unreadable file while detecting changes')` — so `added` and
`modified` came back empty for every edit. The failure was silent and total, not
partial.

`getGitVisibleFiles` (the scan/index path) was never affected: `git ls-files` is
both cwd-relative and cwd-scoped. That asymmetry is exactly why `status` and
`sync` disagreed, and it is now pinned by a test.

Reproduced both ways before the fix: project in a subdirectory → "up to date"
with a modified file; project at the repository root → "Modified: 1 files".

## Fix

- `collectGitStatus` resolves where its directory sits inside the repository
  (`git rev-parse --show-prefix`), scopes `git status` to that subtree with a
  `-- .` pathspec, strips the prefix from every reported path, and drops
  anything outside — a sibling package's edits are no longer counted as this
  project's. A project that IS the repository root runs the byte-identical
  command it ran before and skips the prefix logic entirely.
- The prefix is resolved per recursion level rather than threaded from the
  caller, so the embedded-repo recursion (colbymchenry#1213, colbymchenry#970, colbymchenry#976) keeps working
  unchanged: those calls land on real repo roots and resolve an empty prefix.
- The ignore matchers (colbymchenry#766) now compare against a path that is genuinely
  relative to the directory whose `.gitignore` built them — before the fix a
  subdirectory project matched its own rules against repo-relative paths.
- A project inside a directory its parent repository GITIGNORES now declines the
  git fast path instead of reading git's silence as "nothing changed".
  `getGitVisibleFiles` already made that call; the shared `gitScopeIsIgnored`
  helper makes both paths make it identically, which is the invariant this bug
  violated.

Detection is fully automatic — no `codegraph.json` option was added. Scoping
`git status` to the subtree also makes it cheaper in a large monorepo than
reporting the whole repository and discarding most of it.

## Tests

New `__tests__/git-changed-subdir-project.test.ts`, 11 cases: modified / added /
deleted with project-relative paths, sibling-package edits ignored, `status`
agreeing with `sync` end to end through the public API, the embedded-repo
recursion below a subdirectory project, the project's own `.gitignore`, the
gitignored-project fallback, project == repository root, and a non-git project.
8 of the 11 fail on the unmodified code (`expected [ 'app/src/index.js' ] to
include 'src/index.js'`, and `expected [] to include 'src/index.js'` through
`CodeGraph.getChangedFiles`); all 11 pass after.

Full suite on Windows: 3004 passed / 27 failed / 219 skipped. Those 27 failures
reproduce test-for-test on unmodified HEAD — 26 are the documented Windows
`EPERM` temp-dir-cleanup quirk, the rest the `explore-*` budget gates — so none
is introduced here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
colbymchenry added a commit that referenced this pull request Oct 27, 2026
… changes

When the indexed project is a folder of its git repository (a monorepo
package, an app/ folder beside a server/ one), `codegraph status`,
`status --json` and the freshness check behind codegraph_status reported
the index up to date while `codegraph sync` found the very same changes.
`git status --porcelain` prints paths from the repository root whatever
folder it runs in, and reports the whole repository. Change detection
joined `app/src/index.js` under the project, found nothing at
`app/app/src/index.js`, and dropped it. The dirty paths a sync records
for the restore check (from PR #1878) were stored the same way, so a
file put back with git after a sync was missed too. And a project in a
folder its repository gitignores got a permanently clean answer from
git, which says nothing about an ignored folder.

Change detection now asks where the project sits in its repository
(`git rev-parse --show-prefix`, kept per folder, and skipped when the
folder has its own .git). Below the root it runs git status with a
`-- .` pathspec, takes the prefix off every path and leaves out anything
outside the folder. The project's ignore rules match those
project-relative paths, the recorded dirty paths are project-relative,
and a project its repository ignores declines the git path as the scan
already did, so callers take the full scan. A project at its repository
root runs exactly the git command it ran before.

Validation:
- git-status-subdir-project.test.ts (new, 7 cases): edited, added and
  deleted files in a subdir project, matching sync; edits outside the
  project left out, with the pathspec asserted; the restore case, across
  a reopen; the project's own anchored .gitignore rule; an embedded repo
  inside the project; the gitignored-project fallback; and a repo-root
  project running exactly today's status command with no prefix call.
  6 fail on main's source; the repo-root control passes on both. Four
  mutations (no pathspec, ignore rules on repo-relative paths, no
  fallback, no .git shortcut) each fail the test.
- CLI repros: after an edit and an added file in app/, status --json
  reads 0/0/0 on main and 1 added + 1 modified with the fix; the same in
  a gitignored project; a subdir restore reads 0 on main and 1 modified
  with the fix (a repo-root project reads 1 on both).
- The MCP freshness worker on a subdir project: 0/0/0 on main, 1 added
  + 1 modified with the fix.

Reported in PR #1636 by @innoxxicide. This reimplements the fix
independently, on top of the restore check that landed after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry added a commit that referenced this pull request Oct 27, 2026
… changes

When the indexed project is a folder of its git repository (a monorepo
package, an app/ folder beside a server/ one), `codegraph status`,
`status --json` and the freshness check behind codegraph_status reported
the index up to date while `codegraph sync` found the very same changes.
`git status --porcelain` prints paths from the repository root whatever
folder it runs in, and reports the whole repository. Change detection
joined `app/src/index.js` under the project, found nothing at
`app/app/src/index.js`, and dropped it. The dirty paths a sync records
for the restore check (from PR #1878) were stored the same way, so a
file put back with git after a sync was missed too. And a project in a
folder its repository gitignores got a permanently clean answer from
git, which says nothing about an ignored folder.

Change detection now asks where the project sits in its repository
(`git rev-parse --show-prefix`, kept per folder, and skipped when the
folder has its own .git). Below the root it runs git status with a
`-- .` pathspec, takes the prefix off every path and leaves out anything
outside the folder. The project's ignore rules match those
project-relative paths, the recorded dirty paths are project-relative,
and a project its repository ignores declines the git path as the scan
already did, so callers take the full scan. A project at its repository
root runs exactly the git command it ran before.

Validation:
- git-status-subdir-project.test.ts (new, 7 cases): edited, added and
  deleted files in a subdir project, matching sync; edits outside the
  project left out, with the pathspec asserted; the restore case, across
  a reopen; the project's own anchored .gitignore rule; an embedded repo
  inside the project; the gitignored-project fallback; and a repo-root
  project running exactly today's status command with no prefix call.
  6 fail on main's source; the repo-root control passes on both. Four
  mutations (no pathspec, ignore rules on repo-relative paths, no
  fallback, no .git shortcut) each fail the test.
- CLI repros: after an edit and an added file in app/, status --json
  reads 0/0/0 on main and 1 added + 1 modified with the fix; the same in
  a gitignored project; a subdir restore reads 0 on main and 1 modified
  with the fix (a repo-root project reads 1 on both).
- The MCP freshness worker on a subdir project: 0/0/0 on main, 1 added
  + 1 modified with the fix.

Reported in PR #1636 by @innoxxicide. This reimplements the fix
independently, on top of the restore check that landed after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry added a commit that referenced this pull request Oct 27, 2026
… changes (#2932)

When the indexed project is a folder of its git repository (a monorepo
package, an app/ folder beside a server/ one), `codegraph status`,
`status --json` and the freshness check behind codegraph_status reported
the index up to date while `codegraph sync` found the very same changes.
`git status --porcelain` prints paths from the repository root whatever
folder it runs in, and reports the whole repository. Change detection
joined `app/src/index.js` under the project, found nothing at
`app/app/src/index.js`, and dropped it. The dirty paths a sync records
for the restore check (from PR #1878) were stored the same way, so a
file put back with git after a sync was missed too. And a project in a
folder its repository gitignores got a permanently clean answer from
git, which says nothing about an ignored folder.

Change detection now asks where the project sits in its repository
(`git rev-parse --show-prefix`, kept per folder, and skipped when the
folder has its own .git). Below the root it runs git status with a
`-- .` pathspec, takes the prefix off every path and leaves out anything
outside the folder. The project's ignore rules match those
project-relative paths, the recorded dirty paths are project-relative,
and a project its repository ignores declines the git path as the scan
already did, so callers take the full scan. A project at its repository
root runs exactly the git command it ran before.

Validation:
- git-status-subdir-project.test.ts (new, 7 cases): edited, added and
  deleted files in a subdir project, matching sync; edits outside the
  project left out, with the pathspec asserted; the restore case, across
  a reopen; the project's own anchored .gitignore rule; an embedded repo
  inside the project; the gitignored-project fallback; and a repo-root
  project running exactly today's status command with no prefix call.
  6 fail on main's source; the repo-root control passes on both. Four
  mutations (no pathspec, ignore rules on repo-relative paths, no
  fallback, no .git shortcut) each fail the test.
- CLI repros: after an edit and an added file in app/, status --json
  reads 0/0/0 on main and 1 added + 1 modified with the fix; the same in
  a gitignored project; a subdir restore reads 0 on main and 1 modified
  with the fix (a repo-root project reads 1 on both).
- The MCP freshness worker on a subdir project: 0/0/0 on main, 1 added
  + 1 modified with the fix.

Reported in PR #1636 by @innoxxicide. This reimplements the fix
independently, on top of the restore check that landed after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry

Copy link
Copy Markdown
Owner

Thank you, @innoxxicide, for tracking this down so carefully: both root causes you found were right, the repository-relative porcelain paths and the gitignored project folder. We've landed the fix in #2932, reimplemented on top of the newer restore check (#1878), with credit to you in the changelog. A project at its repository root still runs exactly the same git command, and a project in a subfolder now asks git about its own folder only. Closing this in favor of it; it ships in the next release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants