fix(cli): tolerate read-only workspace in ensureGitNexusIgnored (#1549)#1550
Conversation
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.
|
@Avicennasis is attempting to deploy a commit to the NexusCore Team on Vercel. A member of the Team first needs to authorize it. |
CI Report✅ All checks passed Pipeline Status
Test Results
✅ All 9030 tests passed 1 test(s) skipped — expand for details
Code CoverageTests
📋 View full run · Generated by CI |
|
Claude finished @magyargergo's task in 5m 46s —— View job Adversarial Production-Readiness Review — PR #1550Todo list:
1. Review Bar for This PRReviewed against:
TESTING.md: Present and read. No platform-specific skip policy for chmod tests was found there. 2. Current PR State
3. Branch Hygiene AssessmentVerdict: merge-from-main commit present but harmless and merge-safe The PR contains two merge-from-main commits:
Running No whitespace errors ( 4. Understanding of the ChangeTwo complementary changes in
5. FindingsFinding 1 —
|
| Severity | High |
| File/line | gitnexus/src/storage/repo-manager.ts:347–358, gitnexus/test/unit/repo-manager.test.ts:155–191 |
| Risk | The three new tests (lines 155–191) never exercise the EROFS/EACCES catch in ensureGitInfoExclude. All new tests either omit a .git directory entirely (causing ensureGitInfoExclude to return early at the stat check, line 327–331) or use the fast path (file already correct). The new code at lines 347–358 is reachable only when .git is a real directory, .git/info/exclude needs to be created/updated, AND the write fails. No test covers that path. |
| Evidence | grep -n 'ensureGitInfoExclude' gitnexus/test → 0 matches. Tests at lines 168–190 chmod 0o555 the .gitnexus storage dir, which does not affect .git/info/exclude reachability. Lines 96–116 test the happy-path .git/info/exclude write only. |
| Recommended fix | Add a test that: (a) creates a real .git/info/exclude path; (b) makes .git/info or the target file unwritable (or mocks fs.writeFile to reject with { code: 'EROFS' }); (c) asserts not.toThrow() AND that logger.warn was called with the expected path and code. _captureLogger() from gitnexus/src/core/logger.ts is already available for assertion. |
| Blocks merge | Yes — DoD.md §2.7: "Tests cover the real changed path — they would fail if behavior, wiring, or contracts were broken." The EROFS/EACCES tolerance in ensureGitInfoExclude is a changed path with no test that would catch a regression. The trigger prompt's PR-specific DoD also explicitly requires: "Unit tests must cover ensureGitInfoExclude read-only or permission-denied write path." |
Finding 2 — EPERM not in the tolerance list
| Severity | Medium |
| File/line | repo-manager.ts:309, repo-manager.ts:351 |
| Risk | Both catch blocks gate on err?.code === 'EROFS' || err?.code === 'EACCES'. Node.js on Windows uses EPERM for many write-denied scenarios (e.g. writing to a file marked read-only via icacls, or a volume mounted with a read-only driver). The rest of this codebase already handles EPERM in analogous contexts: lbug-config.ts:140 has const HANDLE_RELEASE_LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']) and bridge-db.ts:261 has const RETRY_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']). A Windows user whose workspace is on a read-only share would receive an unhandled EPERM thrown from ensureGitNexusIgnored, causing gitnexus index to exit non-zero after a successful registerRepo — the exact "registered-but-error-exited" state this PR is meant to fix. |
| Evidence | grep -n 'EPERM' gitnexus/src/storage/repo-manager.ts → 0 matches. grep -n 'EPERM' gitnexus/src/core/lbug/lbug-config.ts → line 140. The Docker workflow is Linux-only, but gitnexus index is also documented as a host-side command. |
| Recommended fix | Add || err?.code === 'EPERM' to both catch conditions (lines 309 and 351). Add a unit test that injects { code: 'EPERM' } via vi.spyOn(fs, 'writeFile').mockRejectedValueOnce(...) to assert the path is tolerated. |
| Blocks merge | Maybe — directly blocks if Windows CLI support is a shipping requirement. The documented Docker use case is Linux-only, but the CLI is cross-platform (CI runs on Windows). Given the established pattern in this codebase of handling EPERM alongside EACCES, omitting it here looks like an accidental gap rather than a deliberate decision. |
Finding 3 — logger.warn call not asserted in tests
| Severity | Low |
| File/line | repo-manager.test.ts:180–190 |
| Risk | The test titled "warns and continues when the storage dir is read-only and the file does not yet exist" only asserts resolves.not.toThrow(). It does not assert that logger.warn was called, the call's payload shape, or the path/code fields. If the catch block were accidentally changed to silently return (dropping the warn), this test would still pass. The warning is part of the stated PR contract and is the user's only signal that a write was skipped. |
| Evidence | grep -n '_captureLogger|logger|warn' gitnexus/test/unit/repo-manager.test.ts → line 180 contains only the description string "warns"; no import of _captureLogger and no assertion on warn calls. |
| Recommended fix | Import _captureLogger from logger.ts; add a beforeEach/afterEach capture; in the read-only test assert cap.records().some(r => r.msg?.includes('.gitnexus/.gitignore') && r.level === 40) (pino level 40 = warn). The same pattern is used in logger.test.ts. |
| Blocks merge | No — but this weakens test fidelity and the PR's stated contract. Recommend as a follow-up or part of the same commit. |
Finding 4 — chmod 0o555 tests do not verify the catch branch on Windows or under root
| Severity | Low |
| File/line | repo-manager.test.ts:172, repo-manager.test.ts:183 |
| Risk | chmod 0o555 is a no-op on Windows (fs.chmod silently succeeds but the write is still allowed). This means the catch block in ensureGitNexusIgnored is never hit on Windows CI; both tests pass because the write succeeds, not because the tolerance works. Similarly, processes running as root on Linux ignore directory mode bits and writes succeed through chmod-denied paths. The Windows CI run in this PR's CI report ("all checks passed") confirms the tests pass, but doesn't confirm the catch branch fires on Windows. |
| Evidence | CI says tests pass on all 3 platforms. On Windows, chmod 0o555 does not actually deny writes; Node.js fs.writeFile succeeds regardless. |
| Recommended fix | Keep the chmod test as an integration-style sanity check on Linux/macOS (add skipIf(process.platform === 'win32') to avoid false confidence). Add a complementary mock-based test using vi.spyOn(fs, 'writeFile').mockRejectedValueOnce(Object.assign(new Error('read-only'), { code: 'EROFS' })) to force the catch branch in a platform-neutral way. This is the only reliable cross-platform method short of actual bind mounts. |
| Blocks merge | Maybe — the fix works on Linux (the Docker target), but the Windows catch branch is untested. If EPERM is also added (Finding 2), a mock-based test is required to verify it, making this blocking in conjunction with Finding 2. |
6. PR-Specific Assessment Sections
Storage ignore behavior (Lane A)
PASS with caveat (EPERM). ensureGitNexusIgnored correctly reads before writing, short-circuits on exact content match *\n, handles ENOENT by falling through to the write path, and calls ensureGitInfoExclude on both code paths. The mkdir call is inside the same try block as writeFile, so an EROFS on mkdir (cold :ro mount) is also tolerated. Non-ENOENT read errors correctly re-throw. EROFS/EACCES are narrowly scoped to the two best-effort ignore files only. EPERM is absent (see Finding 2). No parent directory creation occurs before the fast-path read.
Git info exclude behavior (Lane B)
PASS for logic, FAIL for test coverage. ensureGitInfoExclude correctly returns early when .git does not exist or is a file (worktree). Preserves existing content, avoids duplicates (checks both .gitnexus and .gitnexus/), handles correct newline termination. Read failures on non-ENOENT re-throw (intentional). EROFS/EACCES write tolerance is implemented correctly. Missing test for the write-failure path (see Finding 1).
CLI/index transaction semantics (Lane C)
PASS. registerRepo is called at index-repo.ts:117 before ensureGitNexusIgnored at line 118. The fix ensures that a EROFS/EACCES failure in ensureGitNexusIgnored no longer propagates — the command exits 0 and the registry entry is coherent. Real errors (missing .gitnexus/, missing lbug, meta load failure, registerRepo throws) are not downgraded. GITNEXUS_HOME=/data/gitnexus in Dockerfile.cli:73 correctly routes global registry writes to the container's writable volume, not the :ro workspace. The documented workflow is coherent after #1551 (PATH fix) is included.
Cross-platform filesystem semantics (Lane D)
CONDITIONAL PASS. Linux :ro bind mounts produce EROFS — handled. Normal permission denial produces EACCES — handled. EPERM is not handled — Windows CLI users with read-only workspaces would still get a thrown error (Finding 2). chmod 0o555 tests work on Linux/macOS but are no-ops on Windows (Finding 4). The Docker use case is Linux-only and works correctly with the current fix.
Tests and CI fidelity (Lane F)
NOT FULLY SATISFACTORY.
- ✅ Idempotent fast-path: mtime preservation test (lines 155–166) — good.
- ✅ Cold write on read-only dir (lines 180–190) — exercises the EACCES catch in practice on Linux/macOS.
- ❌
ensureGitInfoExcludewrite-failure: not tested (Finding 1). - ❌
logger.warnpayload: not asserted (Finding 3). ⚠️ chmod-based tests don't fire the catch on Windows (Finding 4).- ✅ CI passed on 3 platforms (8,846 tests).
- Docker end-to-end validation is manual-only (PR description); no automated E2E test. Acceptable given the nature of the fix.
- Vercel authorization is unrelated to this CLI/storage change.
Logging/observability (Lane E)
PASS with minor gap. No circular dependency between repo-manager.ts and logger.ts. Warning messages are structured with { path, code } and actionable text describing the impact. Local paths only — no secrets disclosed. Warnings fire at most once per cold invocation (subsequent calls hit the fast path). The _captureLogger() helper is available but not used in the new tests to assert warn payloads (Finding 3).
Hidden Unicode and hygiene (Lane G)
CLEAN. No hidden or bidirectional Unicode control characters found in either changed file. Non-ASCII characters present (─, —, →) are box-drawing and typographic characters used consistently throughout the existing codebase as section dividers and comment punctuation. git diff --check produced no output. DoD.md, CONTRIBUTING.md, and GUARDRAILS.md are unchanged. No unrelated files in the effective diff.
7. Final Verdict
not production-ready
The implementation logic is correct and the primary documented Docker workflow (host analyze then container index on :ro mount) works as intended. However, two issues prevent this from meeting the repo's DoD and the PR-specific bar:
-
The EROFS/EACCES tolerance added to
ensureGitInfoExclude(lines 347–358) has zero test coverage — no test exercises this code path. DoD.md §2.7 requires that changed paths have tests that would fail on regression, and the PR-specific DoD explicitly lists this test as required. This is the primary blocking finding. -
EPERMis absent from both catch conditions, breaking the read-only-tolerance promise for Windows CLI users. The rest of the codebase (lbug-config.ts,bridge-db.ts) already handlesEPERMalongsideEACCESfor analogous filesystem scenarios, making this an accidental omission rather than a deliberate choice.
Both gaps are small and mechanical to address (add a test with a mock/chmod for ensureGitInfoExclude; add EPERM to both catch lists; optionally assert logger.warn). The fix itself is sound, well-scoped, and architecturally correct — these are test coverage and error-code completeness issues only.
Actionable fixes needed before merge:
- Add
ensureGitInfoExcludewrite-failure test → — cover theensureGitInfoExcludeEROFS/EACCES path directly. - Add EPERM to both catch conditions → — match the EPERM handling already used in
lbug-config.tsandbridge-db.ts. - Assert logger.warn is called in the read-only test → — verify the warning contract, not just absence of throw.
- 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>
✨ PR AutofixFound fixable formatting / unused-import issues across 89 changed lines. Comment |
|
/autofix |
|
✅ Applied autofix and pushed a commit. (apply run) |
…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
Fixes the documented Docker workflow described in issue #1549:
gitnexus index /workspace/<repo>against a:robind mount (per.env.example"Optional read-only mount" + the README example) currently fails withEROFS: read-only file system, open '/workspace/.gitnexus/.gitignore'.Root cause is a regression introduced by PR #1248 ("fix(cli): keep GitNexus ignores inside .gitnexus"). That PR — intentionally and correctly — moved the gitignore write out of the user's root
.gitignoreand into<repo>/.gitnexus/.gitignore. The new code unconditionally writes that path inside the workspace, which fails on a read-only bind mount even when the host has already created an identical file during a prioranalyze.What this PR does
Two complementary changes inside
ensureGitNexusIgnoredandensureGitInfoExclude:Idempotent fast path — read the existing
.gitnexus/.gitignorecontent first; if it already matches the desired value (*\n), skip the write entirely. This covers the common Docker workflow case (host didanalyze, container doesindex).EROFS/EACCEStolerance — when a write is genuinely needed but the filesystem refuses it, log a structuredlogger.warn(...)via the existingpinologger and continue.registerReporuns beforeensureGitNexusIgnoredinindexCommand, so the global-registry write is already committed when we get here; letting the gitignore failure propagate leaves the user with a registered-but-error-exited command (visible in/api/repos, butgitnexus indexexits non-zero — confusing).Both code paths still write on a normal writable filesystem and still produce the same content as before. Behaviour only changes for the read-only / permission-denied case.
Test plan
cd gitnexus && npx tsc --noEmit— clean.cd gitnexus && npx vitest run test/unit/repo-manager.test.ts—PASS (61) FAIL (0). Three new tests cover idempotent fast-path and:roparent dir (simulated viachmod 0o555).cd gitnexus && npx vitest run test/unit/index-repo-command.test.ts—PASS (11) FAIL (0).cd gitnexus && npx vitest run test/unit/run-analyze.test.ts—PASS (18) FAIL (0).npx prettier --check gitnexus/src/storage/repo-manager.ts gitnexus/test/unit/repo-manager.test.ts— clean.Reproducer for the Docker workflow itself (verified before/after the fix):
Notes
gitnexusPATH issue from Documented Docker workflow (gitnexus index /workspace/<repo>on:romount) is broken in two places #1549 is the subject of a separate, smaller PR — I kept the two changes apart so each can be reviewed/reverted on its own.analyzeworkflow) is byte-identical to before.Closes #1549 (in conjunction with the Dockerfile.cli
PATHPR).