fix(cli): keep GitNexus ignores inside .gitnexus#1248
Conversation
Avoid mutating analyzed repositories' root .gitignore while keeping generated GitNexus state untracked via .gitnexus/.gitignore. Made-with: Cursor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Claude finished @magyargergo's task in 4m 6s —— View job Deep Production-Readiness Review: PR #1248 — fix(cli): keep GitNexus ignores inside .gitnexusReview bar for this PR
Issue and PR context considered#1233 symptom: GitNexus was appending Intended fix: Stop writing to the root Changed files: Current CI status: Core risk areas: (a) non-git and Understanding of the changeWhat #1233 reported: Every Why that's undesirable: It creates unwanted dirty state in every analyzed repo, forcing developers to either commit GitNexus-specific lines into their project's How Paths changed: Full analyze (post-register, guarded by What must remain unchanged: Registry, meta schema, CI status: In progress — cannot approve. Findings[critical] CI still in progress
[high] Full
|
| DoD item | Status |
|---|---|
No GitNexus analyze/index path modifies the analyzed repository's root .gitignore |
satisfied — zero addToGitignore references remain; helper writes only to .gitnexus/.gitignore |
.gitnexus/.gitignore is created with exactly *\n whenever GitNexus creates or uses .gitnexus in a git repo |
partially satisfied — index command: satisfied unconditionally; analyze full path: only when hasGitDir(repoPath) is true (misses --skip-git + parent-git-subdir case) |
Existing root .gitignore files remain byte-for-byte unchanged |
satisfied — confirmed by code inspection and test at repo-manager.test.ts:87-93 |
| Re-running analyze/index is idempotent and does not create noisy diffs | satisfied — helper overwrites *\n with *\n on every call; no diff created |
Already-up-to-date analyze still ensures .gitnexus/.gitignore exists |
satisfied — fast path at run-analyze.ts:169 calls helper inside if (currentCommit !== '') |
index command and full analyze command both use the same helper |
partially satisfied — both use ensureGitNexusInternalGitignore, but analyze guards with hasGitDir; index does not |
The change does not break registry, metadata, assertAnalysisFinalized, or detect-changes |
satisfied — no registry or meta schema changes; assertAnalysisFinalized logic untouched |
Tests prove root .gitignore is untouched and git status --short stays clean |
partially satisfied — git status --short is proven clean by helper unit test; E2E test asserts .gitignore content but not git status --short on full analyze path |
| CI, typecheck, focused tests, and full relevant test suite are green before approval | not satisfied — CI is in_progress |
Storage / gitignore behavior assessment
Root .gitignore mutation: Fully eliminated. addToGitignore has zero remaining references in the codebase. ignore-service.ts references are reads-only (file discovery), not writes. No production path writes to the root .gitignore.
.gitnexus/.gitignore creation: ensureGitNexusInternalGitignore correctly computes path.join(getStoragePath(repoPath), '.gitignore') = <repoPath>/.gitnexus/.gitignore. Uses fs.mkdir(..., { recursive: true }) to create the .gitnexus dir. Writes exactly '*\n'. Does not read or mutate the root .gitignore. Path is scoped to the local repo, not global ~/.gitnexus (which uses getGlobalDir() → os.homedir()/.gitnexus — a completely separate code path).
Idempotency: Writing *\n over *\n is a no-op from a behavioral standpoint. The file is never appended to, only overwritten. Git sees no diff.
Git status cleanliness: * in a .gitignore inside a directory causes Git to ignore all files in that directory, including the .gitignore itself. Proven by the repo-manager.test.ts test that runs git status --short after creating the gitignore and a dummy meta.json, asserting empty output.
Existing root .gitignore preservation: The helper never opens the root .gitignore path. Confirmed by test at repo-manager.test.ts:87-93 which writes node_modules/\n to root .gitignore, calls the helper, and asserts content is unchanged.
Old root .gitignore cleanup policy: Not auto-removed (correct). Release note should mention manual cleanup option.
Analyze/index path assessment
Full analyze: ensureGitNexusInternalGitignore(repoPath) is called after saveMeta and registerRepo, inside the try block, before AI context files. It is gated by if (hasGitDir(repoPath)) — this misses non-git paths, including --skip-git subfolders inside parent git repos.
Already-up-to-date fast path: Helper called inside if (currentCommit !== ''), which is correct — non-git repos have empty commit and fall through to full rebuild. Git repos on the fast path get the gitignore created/refreshed. ✓
Index command: Helper called unconditionally after registerRepo. No hasGitDir guard. Works for git repos, --allow-non-git paths, and cwd-discovered repos. ✓
Non-git / --skip-git behavior (full analyze): hasGitDir checks for .git directly in repoPath only (statSync(path.join(dirPath, '.git'))). A subdirectory without its own .git returns false. The helper is skipped. The parent git repo sees .gitnexus as untracked. This is the open issue.
Parent-git-subdir behavior: Described above. Not covered; index handles it, analyze does not.
Registry/meta/finalization impact: Zero. registerRepo, saveMeta, assertAnalysisFinalized, and assertSafeStoragePath are all untouched. The new helper runs after registration — same ordering risk as the old addToGitignore, not a regression.
Test assessment
Tests added/updated:
repo-manager.test.ts: 3 new tests forensureGitNexusInternalGitignore— creates.gitnexus/.gitignorewith*\n, preserves existing root.gitignore, provesgit status --shortis empty after helper + written storage files. Uses realgit initand realexecSync. ✓run-analyze.test.ts: 1 new test for already-up-to-date fast path — real git repo, saves meta with current commit, runsrunFullAnalysis(), assertsalreadyUpToDate: trueand.gitnexus/.gitignorecontent. ✓index-repo-command.test.ts: Updated to mockensureGitNexusInternalGitignore; asserts it is called on successful register and not called on validation failures. ✓cli-e2e.test.ts: 2 new assertions in theanalyze command runs pipelinetest — root.gitignoreabsent,.gitnexus/.gitignorecontent is*\n. ✓
Helper tests: Real-filesystem, real-git tests. Not mock-only. ✓
CLI E2E tests: Assertions cover existence and content. Do not assert git status --short explicitly. Gap noted above.
Git status test: Present in repo-manager.test.ts for the helper in isolation. Not present for the full runFullAnalysis() path.
Missing cases:
- Full analyze via
runFullAnalysis()+git status --shortassertion --skip-giton a path without.gitthat is inside a parent git repo (helper is not called;.gitnexus/.gitignoreis not created)
Would old behavior fail? Yes — repo-manager.test.ts:79-84 asserts .gitnexus/.gitignore content is *\n; the old addToGitignore approach would write to root .gitignore instead, which would fail the git status test and the content assertion.
Validation assessment
Latest CI status: CI — in_progress; Docker Build & Push — in_progress. Three informational workflows (PR Conventional Labeler ×2, PR Description Check) passed.
Typecheck (tsc --noEmit): Cannot confirm until CI completes. Import/export changes (ensureGitNexusInternalGitignore added to exports, addToGitignore removed) are correctly reflected across all importing files based on code inspection.
Focused tests: Correct selection (ensureGitNexusInternalGitignore|already-up-to-date|analyze command runs pipeline|registers successfully) covers the new and changed paths.
Full test suite: PR body claims 254 files, 7505 tests passing locally. Cannot verify until CI reports.
Detect-changes smoke: Listed in PR body as npx tsx src/cli/index.ts detect-changes --repo GitNexus. This is smoke, not proof.
Pending CI blocks approval: Yes.
Compatibility and rollout assessment
Existing repos with old root .gitignore entry: Unaffected. The new code never reads or writes the root .gitignore. Old entries remain until the user removes them manually.
Existing .gitnexus storage: Fully compatible. No schema change. No meta format change. On next analyze (including the fast path), .gitnexus/.gitignore is created. On next index, same.
User cleanup expectations: Users may optionally remove the .gitnexus entry from their root .gitignore. No action required for correctness — the internal gitignore takes over. If both exist, git applies both (redundant but harmless).
Release note recommendation:
GitNexus no longer writes to your repository's root
.gitignore. Starting with this release, generated index files are kept untracked via.gitnexus/.gitignore. If you have a.gitnexusline in your root.gitignorefrom a prior run, you may safely remove it manually.
Final verdict
not production-ready
CI is still in progress — this alone is a hard block per the DoD. Beyond CI, the analyze full path contains a hasGitDir(repoPath) guard that prevents .gitnexus/.gitignore from being created when --skip-git is used on a subdirectory inside a parent git repo. The index command has no such guard and handles this case correctly, but the inconsistency means the most common scenario for --skip-git usage (analyzing a monorepo subfolder with no .git) leaves .gitnexus visible to the parent repo's git status — which is precisely what #1233 was filed to prevent. The fix is a one-line removal of the if (hasGitDir(repoPath)) condition on the full analyze path.
The remaining DoD items (root mutation elimination, idempotency, already-up-to-date path, registry/meta integrity, root .gitignore preservation) are all satisfied. Once CI is green and the hasGitDir guard is removed from the full analyze path (with a matching test for the --skip-git + parent-git-subdir case), this PR is otherwise in good shape to merge.
CI Report✅ All checks passed Pipeline Status
Test Results
✅ All 7739 tests passed 1 test(s) skipped — expand for details
Code CoverageTests
📋 View full run · Generated by CI |
When an analyzed repo has a real .git directory, add .gitnexus/ to .git/info/exclude so local Git metadata ignores generated storage without touching root .gitignore. Made-with: Cursor
|
Claude finished @magyargergo's task in 3m 44s —— View job Production-Readiness Review: PR #1248 — fix(cli): keep GitNexus ignores inside .gitnexusReview bar for this PR
Issue and PR context considered#1233 symptom: GitNexus appended Intended fix: Stop writing to the root Changed files: Current CI status: CI workflow FAILED (run Core risk areas: (a) Understanding of the changeWhat #1233 reported: Every Why undesirable: Modifying a project's How Why Paths changed: Full analyze (guarded by What must remain unchanged: Registry, meta schema, CI status: Failing on current HEAD. Findings[critical] CI is failing on the current HEAD commit
[high] Full
|
| DoD item | Status |
|---|---|
No analyze/index path modifies the root .gitignore |
satisfied — zero addToGitignore references remain in production code; ignore-service.ts references are reads-only |
.gitnexus/.gitignore created with *\n on every path that creates .gitnexus |
partially satisfied — index: unconditional ✓; fast-path analyze: unconditional (guarded by currentCommit !== '', correct) ✓; full analyze: only when hasGitDir — misses --skip-git/parent-git-subdir ✗ |
Existing root .gitignore files remain byte-for-byte unchanged |
satisfied — confirmed by code inspection and repo-manager.test.ts:87–93 |
| Re-running is idempotent | satisfied — always-overwrite of *\n produces no diff; .git/info/exclude append is guarded with duplicate-entry check |
Already-up-to-date analyze ensures .gitnexus/.gitignore exists |
satisfied — fast path calls ensureGitNexusIgnored unconditionally inside if (currentCommit !== '') |
Full analyze and index command use consistent ignore behavior |
partially satisfied — both use ensureGitNexusIgnored; index is unconditional, full analyze guards with hasGitDir — inconsistent |
Parent-git/subdir --skip-git keeps parent working tree clean |
not satisfied — hasGitDir guard blocks helper call; no test covers this |
git status --short stays clean after analyze |
partially satisfied — proven for helper in isolation (repo-manager.test.ts:126–141) and implied by E2E content assertion; not proven for full runFullAnalysis() or parent-git/subdir path |
| CI, typecheck, focused tests, full suite green | not satisfied — CI is failing on current HEAD |
Storage / ignore behavior assessment
Root .gitignore mutation: Fully eliminated. addToGitignore has zero references in production code. ignore-service.ts references are read-only (file discovery). No production path writes to the root .gitignore. ✓
.gitnexus/.gitignore creation: ensureGitNexusIgnored correctly computes path.join(getStoragePath(repoPath), '.gitignore') = <repoPath>/.gitnexus/.gitignore. Uses fs.mkdir(..., { recursive: true }). Writes exactly '*\n'. Scoped to the local repo, not global ~/.gitnexus (separate path via getGlobalDir()). ✓
.git/info/exclude: ensureGitInfoExclude only runs when stat('.git').isDirectory() — correctly skips worktrees and non-git paths. Preserves existing content. Appends .gitnexus/ at most once (duplicate check on lines and trailing-slash forms). Idempotent. The mutation is local-only git metadata, not source-controlled. Acceptable. ✓
Idempotency: Writing *\n over *\n produces no observable diff. .git/info/exclude append is guarded. ✓
Git status cleanliness: Proven at the helper level (repo-manager.test.ts:126–141, real git init + execSync). Not proven for the full runFullAnalysis() path or the parent-git/subdir path. Partial.
Old root .gitignore cleanup policy: Not auto-removed (correct). No release note present yet.
Analyze/index path assessment
Full analyze (main path): ensureGitNexusIgnored(repoPath) is called at line 452–454, after saveMeta and registerRepo, inside the main try block, before AI context generation. Gated by if (hasGitDir(repoPath)) — this is the open issue. For direct git repos, works. For --skip-git / non-git paths / subfolders of parent repos, skipped.
Already-up-to-date fast path: ensureGitNexusIgnored(repoPath) called at line 169, unconditionally inside if (currentCommit !== ''). Non-git repos have currentCommit = '' and fall through to the full rebuild path. This is correct — the fast path only fires for git repos with a matching commit, and calling the helper there is right. ✓
Index command: ensureGitNexusIgnored(repoPath) called unconditionally at line 118 after registerRepo. No hasGitDir guard. Handles git repos, --allow-non-git paths, and cwd-discovered repos. ✓ This correctness is what makes the full-analyze inconsistency stand out.
Non-git / --skip-git behavior (full analyze): hasGitDir checks .git in repoPath only. Returns false for subfolders without own .git. Helper is skipped. Parent git repo sees .gitnexus/ as untracked. Open blocker.
Registry/meta/finalization impact: Zero. registerRepo, saveMeta, assertAnalysisFinalized, and assertSafeStoragePath are all untouched. ✓
Test assessment
repo-manager.test.ts (5 new tests): Creates .gitnexus/.gitignore with *\n ✓; preserves root .gitignore ✓; appends to .git/info/exclude ✓; .git/info/exclude idempotency ✓; .git-as-file (worktree) does not create exclude ✓; git status --short clean ✓. Strong real-git, real-fs coverage for the helper.
run-analyze.test.ts (1 new test): Already-up-to-date fast path → asserts alreadyUpToDate: true and .gitnexus/.gitignore content *\n. Real git repo. ✓ Missing: full analyze path and --skip-git/parent-git-subdir.
index-repo-command.test.ts (updated): Mocked; asserts ensureGitNexusIgnored called on success, not called on validation failures. ✓
cli-e2e.test.ts (2 new assertions): Root .gitignore absent, .gitnexus/.gitignore content *\n. Implies cleanliness but does not assert git status --short. Missing explicit status assertion and parent-git/subdir coverage.
Missing tests:
- Full
runFullAnalysis()path:git status --shortclean after.gitnexus/created --skip-giton a subdirectory inside a parent git repo:git status --shortfrom parent is empty
Would old behavior fail new tests? Yes — repo-manager.test.ts:79–84 asserts content *\n in .gitnexus/.gitignore; old addToGitignore wrote to root instead. The git status --short test would also fail on old code. ✓
Validation assessment
CI: Failing on current HEAD (5971d07). Run 25212892995 conclusion: failure. Blocks approval.
Typecheck: Cannot confirm for current HEAD until CI is fixed.
Focused tests: Correct selection in PR body. Cannot confirm for current HEAD.
Full test suite: PR body claims 254 files, 7505 tests locally — but that was for the first commit. The failing CI run covers the second commit which added ensureGitInfoExclude.
Docker/build: success. Not blocking.
CI blocks approval: Yes.
Compatibility and rollout assessment
Existing repos with old root .gitignore entry: Unaffected — the helper never reads or writes the root .gitignore. Old .gitnexus entries are left in place (correct policy). On next analyze/index, .gitnexus/.gitignore is created independently.
Existing .gitnexus storage: Fully compatible. No schema change. No meta format change. Storage created before this fix continues to work; next analyze adds .gitnexus/.gitignore.
User cleanup: Optional. The old root .gitignore entry is redundant once the internal gitignore exists — both applying is harmless. Users should be advised they may remove it.
Release note recommendation:
GitNexus no longer writes to your repository's root
.gitignore. Generated index files are now kept untracked via.gitnexus/.gitignore. If you have a.gitnexusline in your root.gitignorefrom a prior run, you may safely remove it manually.
Final verdict
not production-ready
Two hard blockers prevent merge: (1) CI is failing on the current HEAD (5971d07), which introduced ensureGitInfoExclude — the exact step that is failing cannot be confirmed without job log access, but the failure conclusion is certain and the DoD requires CI green. (2) The full analyze path guards ensureGitNexusIgnored with if (hasGitDir(repoPath)), which means any use of gitnexus analyze --skip-git on a subdirectory inside a parent git repo creates .gitnexus/ without .gitnexus/.gitignore, leaving the parent repo's git status dirty — precisely the problem #1233 was filed to prevent. The index command has no such guard and handles this case correctly, making the inconsistency a behavioral regression on the most common --skip-git use case. The fix is a one-line removal of the if (hasGitDir) condition on the full analyze path (the ensureGitInfoExclude sub-helper already short-circuits safely for non-git paths). Beyond these two blockers, the root mutation elimination, idempotency, already-up-to-date path, .git/info/exclude behavior, and registry/meta integrity are all well-implemented and the helper-level test coverage is genuinely strong.
Ensure full analyze always writes the internal GitNexus ignore file so parent Git repositories stay clean for --skip-git subdirectory indexes. Made-with: Cursor
|
Claude finished @magyargergo's task in 3m 37s —— View job Production-Readiness Review: PR #1248 — fix(cli): keep GitNexus ignores inside .gitnexusTasks:
Review bar for this PR
Issue and PR context considered#1233 symptom: Every Intended fix: Stop writing to root Changed files: CI/Docker status: Both workflows completed Earlier blockers:
Understanding of the changeWhat #1233 reported: Why undesirable: Modifying a project's How Why Paths changed: Full analyze (unconditional after What remains unchanged: Registry, meta schema, FindingsNo production-readiness issues found against the current DoD bar. Three low-severity observations follow, none of which block merge. [low]
|
| DoD item | Status |
|---|---|
No analyze/index path modifies root .gitignore |
satisfied — zero addToGitignore references remain in production code; ignore-service.ts references are read-only (file discovery, not writes) |
.gitnexus/.gitignore created with *\n on every path that creates .gitnexus storage |
satisfied — full analyze: unconditional at run-analyze.ts:452; fast path: unconditional inside if (currentCommit !== ''); index: unconditional at index-repo.ts:118 |
Existing root .gitignore files remain byte-for-byte unchanged |
satisfied — confirmed by code inspection and repo-manager.test.ts:87–94 (writes node_modules/\n to root, calls helper, asserts content unchanged) |
| Re-running analyze/index is idempotent | satisfied — *\n overwrites *\n (no diff); .git/info/exclude append is guarded by duplicate-entry check |
Already-up-to-date analyze ensures .gitnexus/.gitignore exists |
satisfied — fast path at run-analyze.ts:169 calls helper before returning alreadyUpToDate: true |
Full analyze and index command use consistent behavior |
satisfied — both call ensureGitNexusIgnored unconditionally after registerRepo; no hasGitDir guard on either path |
Parent-git/subdir --skip-git keeps parent working tree clean |
satisfied — skip-git-cli.test.ts:163 runs real CLI from COOLIO/ inside a parent git repo, asserts COOLIO/.gitnexus/.gitignore === '*\n' and git status --short === '' from parent |
git status --short stays clean after generated .gitnexus files are written |
satisfied — proven at helper level (repo-manager.test.ts:126–141, real git init + real storage files + execSync('git status --short') asserts '') and at CLI/integration level (skip-git-cli.test.ts:163) |
| CI, Docker, typecheck, focused tests, full suite green | satisfied — CI run 25214920613: success (7739/7739); Docker run 25214920552: success |
Storage / ignore behavior assessment
Root .gitignore mutation: Fully eliminated. addToGitignore has zero matches in the entire repository (confirmed via codebase search). ignore-service.ts references are reads-only, used for file discovery during pipeline ingestion. No production path writes to the root .gitignore.
.gitnexus/.gitignore creation: ensureGitNexusIgnored computes path.join(getStoragePath(repoPath), '.gitignore') = <repoPath>/.gitnexus/.gitignore. Creates the .gitnexus directory recursively before writing. Writes exactly '*\n'. Uses path.resolve internally via getStoragePath — handles relative paths and Windows paths correctly. Scoped to the local repo, not ~/.gitnexus (which uses getGlobalDir() → process.env.GITNEXUS_HOME || os.homedir()/.gitnexus — entirely separate code path).
.git/info/exclude: ensureGitInfoExclude (private, called only from ensureGitNexusIgnored) stats .git before any write. If stat throws (no .git exists) or .git is not a directory (linked worktree case), it returns early — no mutation. For real git repos: reads existing content, splits by newline, strips comments, checks for both .gitnexus and .gitnexus/ forms before appending. Separator logic handles missing trailing newlines. This is acceptable local-only git metadata — it is per-clone and non-source-controlled, consistent with tools like git update-index --skip-worktree.
Idempotency: Writing *\n over *\n is a no-op from git's perspective. .git/info/exclude append is guarded by duplicate-entry check on both the bare ('.gitnexus') and trailing-slash ('.gitnexus/') forms. Both paths are safe to call on every analyze/index run.
Git status cleanliness: Git traverses into untracked directories to read .gitignore files. * in .gitnexus/.gitignore causes Git to ignore all contents of .gitnexus/, including the .gitignore itself. Belt-and-suspenders: .git/info/exclude prevents even the .gitnexus/ directory name from appearing in git status output before Git descends into it. Proven empirically by repo-manager.test.ts:126–141 and skip-git-cli.test.ts:163.
Old root .gitignore cleanup policy: Not auto-removed (correct). The helper never opens the root .gitignore path. Redundant old entries (# .gitnexus) are harmless — both the root entry and the internal gitignore applying simultaneously is a no-op. Manual cleanup guidance should be in release notes.
Analyze/index path assessment
Full analyze (run-analyze.ts): ensureGitNexusIgnored(repoPath) is called at line 452, after saveMeta and registerRepo, inside the main try block (before AI context generation, before closeLbug). No hasGitDir guard — the previous blocker is resolved. Works for: direct git repos, --skip-git on non-git paths, and --skip-git on subdirs inside parent git repos (the core #1233 scenario). The ensureGitInfoExclude sub-helper short-circuits safely when no .git directory is present, so unconditional calling is safe for all path types.
Already-up-to-date fast path: ensureGitNexusIgnored(repoPath) called at line 169, inside if (currentCommit !== ''). Non-git repos always have currentCommit = '' (because repoHasGit is false → currentCommit = '') and fall through to the full rebuild — so the fast path is only reachable for git repos, where calling the helper is exactly right.
Index command (index-repo.ts): ensureGitNexusIgnored(repoPath) called unconditionally at line 118, after registerRepo. Not called on early-exit paths (non-git without --allow-non-git, missing .gitnexus/, missing lbug, missing meta.json without --force). Consistent with full analyze behavior.
Non-git / --skip-git (full analyze): Works correctly. hasGitDir(repoPath) is still consulted at lines 161–162 only to determine repoHasGit / currentCommit for the fast-path check — not to gate the ignore helper. The ignore helper is called unconditionally regardless of that determination.
Parent-git/subdir behavior: The scenario parent/.git/ + parent/app/ with gitnexus analyze --skip-git on app/:
hasGitDir('app/')→false;currentCommit = ''- No fast path; full pipeline runs
app/.gitnexus/is createdawait ensureGitNexusIgnored('app/')— createsapp/.gitnexus/.gitignorewith*\nensureGitInfoExclude('app/')—stat('app/.git')throws; returns early (no mutation toparent/.git/info/exclude)- Parent
git status --shortis clean becauseapp/.gitnexus/.gitignorecovers all generated content
Registry/meta/finalization impact: Zero. registerRepo, saveMeta, assertAnalysisFinalized, and assertSafeStoragePath are all untouched. The new helper runs after registration — same ordering risk as the old addToGitignore, not a regression. If ensureGitNexusIgnored fails after registerRepo, the repo is in the registry but lacks .gitnexus/.gitignore; the next analyze run will recreate it.
Test assessment
repo-manager.test.ts (5 new tests, ensureGitNexusIgnored block): Real-filesystem, real-git coverage. Creates .gitnexus/.gitignore with *\n ✓; preserves root .gitignore ✓; appends to .git/info/exclude ✓; .git/info/exclude idempotency (two calls, single append) ✓; .git as a file (linked worktree) does not create exclude ✓; git status --short is '' after helper + written meta.json ✓. The createTempDir helper creates a plain temp dir (not a git repo), and individual tests opt into git init as needed — correct isolation.
run-analyze.test.ts (1 new test): Already-up-to-date fast path with real git repo and real saveMeta. Asserts alreadyUpToDate: true and .gitnexus/.gitignore === '*\n'. ✓
index-repo-command.test.ts (updated): Mocked; asserts mockEnsureGitNexusIgnored is called exactly once on successful register and not called on any early-exit path. Mocking is appropriate here since the helper has its own deep real-fs tests. ✓
cli-e2e.test.ts (2 new assertions): Asserts MINI_REPO/.gitignore does not exist and MINI_REPO/.gitnexus/.gitignore === '*\n' after full analyze. Covers the real CLI output path on the fixture repo. ✓ (minor brittleness if fixture gains a .gitignore — noted under low findings, non-blocking)
skip-git-cli.test.ts (new test at line 163): The critical regression test for the previously-open blocker. Uses real parent git init, commits baseline fixtures (including a root .gitignore), runs the built CLI from COOLIO/ with --skip-git and isolated GITNEXUS_HOME, then asserts:
COOLIO/.gitnexus/.gitignore === '*\n'✓git status --shortfrom parent is''✓- Cleanup in
finallyblock ✓ - Cannot pass if
.gitnexus/.gitignoreis missing (the assertion would fail) ✓ - The parent repo has a tracked root
.gitignorein the commit, so any modification would surface ingit status --short— the assertion is adversarially strong.
Would old behavior fail new tests? Yes. repo-manager.test.ts:79–84 asserts content *\n in .gitnexus/.gitignore; old addToGitignore wrote to root .gitignore instead — the file would be absent and the test would fail. The git status --short test would also fail: old behavior would write to root .gitignore, making it show up as modified.
Validation assessment
CI: Run 25214920613 — success. 7739 tests passed, 0 failed, 1 skipped (documented Ruby block parameter limitation). Typecheck (tsc --noEmit) passed as part of the CI pipeline. This run covers the current HEAD.
Docker Build & Push: Run 25214920552 — success.
Typecheck: Passed in CI. Import/export changes (ensureGitNexusIgnored added to exports, addToGitignore removed) are reflected across all importing files (run-analyze.ts, index-repo.ts, test files).
Focused tests: PR body correctly selects ensureGitNexusInternalGitignore|already-up-to-date|analyze command runs pipeline|registers successfully. All run in the green full suite.
Detect-changes smoke: Listed in PR body as npx tsx src/cli/index.ts detect-changes --repo GitNexus (low risk, 0 affected processes). Smoke only, not verified here — but detect-changes touches no storage or ignore logic.
Compatibility and rollout assessment
Existing repos with old root .gitignore entry: Unaffected. The new code never reads or writes the root .gitignore. Old .gitnexus entries remain until the user removes them manually. Both the root entry and the internal gitignore coexisting is harmless — git applies both (redundant, no conflict).
Existing .gitnexus storage: Fully compatible. No schema change. No meta format change. Next analyze (including fast path) or index run creates .gitnexus/.gitignore. No re-index required.
User cleanup: Optional. Users may safely remove any # .gitnexus line from their root .gitignore. No action required for correctness.
Recommended release note:
GitNexus no longer writes to your repository's root
.gitignore. Generated index files are now kept untracked via.gitnexus/.gitignore. If you have a.gitnexusline in your root.gitignorefrom a prior run, you may safely remove it manually.
Final verdict
production-ready with minor follow-ups
All previously identified blockers are resolved: the hasGitDir guard on the full analyze path is removed (line 452 calls ensureGitNexusIgnored unconditionally), the parent-git/subdir regression test is solid and adversarially strong, CI and Docker both completed successfully on the current HEAD, and zero addToGitignore references remain anywhere in the codebase. The core correctness invariant — no root .gitignore mutation, .gitnexus/.gitignore created on every storage-creating path including --skip-git subdirs — is fully satisfied and covered by real-git, real-filesystem tests. The three remaining observations (always-overwrite intent comment, brittle E2E fixture assertion, release note) are polish items that do not affect correctness, safety, or test strength, and can be addressed in follow-up issues or as part of the next release.
* fix(cli): keep GitNexus ignores inside .gitnexus Avoid mutating analyzed repositories' root .gitignore while keeping generated GitNexus state untracked via .gitnexus/.gitignore. Made-with: Cursor * fix(cli): also use git info exclude for GitNexus storage When an analyzed repo has a real .git directory, add .gitnexus/ to .git/info/exclude so local Git metadata ignores generated storage without touching root .gitignore. Made-with: Cursor * fix(cli): keep skip-git subdir indexes ignored Ensure full analyze always writes the internal GitNexus ignore file so parent Git repositories stay clean for --skip-git subdirectory indexes. Made-with: Cursor
) (#1550) * fix(cli): tolerate read-only workspace in ensureGitNexusIgnored The documented Docker workflow mounts the host workspace at /workspace:ro and runs `gitnexus index /workspace/<repo>` against an index produced by a prior host-side `analyze`. Since PR #1248 ("keep GitNexus ignores inside .gitnexus") the index command has called `ensureGitNexusIgnored`, which unconditionally writes `<repo>/.gitnexus/.gitignore` and `<repo>/.git/info/exclude` — both fail with EROFS on the :ro bind mount even though the host already wrote the correct file during `analyze`. Two complementary changes: 1. Idempotent fast path. Read the existing .gitnexus/.gitignore content first; if it already matches the desired value (`*\n`), skip the write entirely. This is the common case for the Docker workflow and avoids touching the FS at all. 2. EROFS/EACCES tolerance. When a write is genuinely needed but the FS refuses it, log a structured warning via the existing pino logger and continue. `registerRepo` runs before `ensureGitNexusIgnored` in `indexCommand`, so the global-registry write is already committed when we get here — letting the gitignore-write failure propagate leaves the user with a registered-but-error-exited command. Three new unit tests pin the behaviour: - idempotent re-call leaves mtime untouched - ENOENT-then-correct path on a writable parent succeeds - :ro parent (simulated via chmod 0o555) does not throw, on the already-correct fast path and on the cold-create path Existing tests (61) still pass. Closes #1549. * test(storage): cover read-only ignore paths and tolerate EPERM (#1550) - Add isReadOnlyFilesystemError helper including EPERM alongside EROFS/EACCES for ensureGitNexusIgnored and ensureGitInfoExclude (Windows parity with lbug-config / bridge-db patterns). - Skip chmod-based read-only tests on win32 and uid 0; assert logger.warn on POSIX chmod denial for missing .gitignore. - Add repo-manager-ensure-ignore-readonly.test.ts with vi.mock fs/promises delegating writeFile so EROFS/EACCES/EPERM rejections are asserted with structured log path and message for both .gitignore and .git/info/exclude. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…higyanpatwari#1549) (abhigyanpatwari#1550) * fix(cli): tolerate read-only workspace in ensureGitNexusIgnored The documented Docker workflow mounts the host workspace at /workspace:ro and runs `gitnexus index /workspace/<repo>` against an index produced by a prior host-side `analyze`. Since PR abhigyanpatwari#1248 ("keep GitNexus ignores inside .gitnexus") the index command has called `ensureGitNexusIgnored`, which unconditionally writes `<repo>/.gitnexus/.gitignore` and `<repo>/.git/info/exclude` — both fail with EROFS on the :ro bind mount even though the host already wrote the correct file during `analyze`. Two complementary changes: 1. Idempotent fast path. Read the existing .gitnexus/.gitignore content first; if it already matches the desired value (`*\n`), skip the write entirely. This is the common case for the Docker workflow and avoids touching the FS at all. 2. EROFS/EACCES tolerance. When a write is genuinely needed but the FS refuses it, log a structured warning via the existing pino logger and continue. `registerRepo` runs before `ensureGitNexusIgnored` in `indexCommand`, so the global-registry write is already committed when we get here — letting the gitignore-write failure propagate leaves the user with a registered-but-error-exited command. Three new unit tests pin the behaviour: - idempotent re-call leaves mtime untouched - ENOENT-then-correct path on a writable parent succeeds - :ro parent (simulated via chmod 0o555) does not throw, on the already-correct fast path and on the cold-create path Existing tests (61) still pass. Closes abhigyanpatwari#1549. * test(storage): cover read-only ignore paths and tolerate EPERM (abhigyanpatwari#1550) - Add isReadOnlyFilesystemError helper including EPERM alongside EROFS/EACCES for ensureGitNexusIgnored and ensureGitInfoExclude (Windows parity with lbug-config / bridge-db patterns). - Skip chmod-based read-only tests on win32 and uid 0; assert logger.warn on POSIX chmod denial for missing .gitignore. - Add repo-manager-ensure-ignore-readonly.test.ts with vi.mock fs/promises delegating writeFile so EROFS/EACCES/EPERM rejections are asserted with structured log path and message for both .gitignore and .git/info/exclude. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(autofix): apply prettier + eslint fixes via /autofix command --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Summary
.gitignoreduringanalyzeandindex..gitnexus/.gitignorewith*so generated GitNexus storage stays untracked from inside the storage directory.Closes #1233
Test plan
cd gitnexus && npx prettier --check src/storage/repo-manager.ts src/core/run-analyze.ts src/cli/index-repo.ts test/unit/repo-manager.test.ts test/unit/index-repo-command.test.ts test/unit/run-analyze.test.ts test/integration/cli-e2e.test.tscd gitnexus && npx vitest run test/unit/repo-manager.test.ts test/unit/index-repo-command.test.ts test/unit/run-analyze.test.ts test/integration/cli-e2e.test.ts -t "ensureGitNexusInternalGitignore|already-up-to-date|analyze command runs pipeline|registers successfully"cd gitnexus && npx tsc --noEmitcd gitnexus && npm test(final rerun passed: 254 files, 7505 tests)cd gitnexus && npx tsx src/cli/index.ts detect-changes --repo GitNexus(low risk, 0 affected processes)