From aca78c042e47ce694eefb387e4918ee1dab4e432 Mon Sep 17 00:00:00 2001 From: ivkond Date: Sun, 5 Apr 2026 00:36:04 +0300 Subject: [PATCH] fix(ignore): respect negation patterns in .gitnexusignore childrenIgnored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit childrenIgnored checked `ig.ignores(rel) || ig.ignores(rel + '/')` which short-circuited on the bare path — directory-only negation patterns like `!iOS/` were missed because `ig.ignores('iOS')` treats the path as a file. Now only checks with trailing slash since childrenIgnored is only called for directories. Bare-name patterns (e.g. `local`) still match per gitignore spec. Fixes #596 Co-Authored-By: Claude Opus 4.6 (1M context) --- gitnexus/src/config/ignore-service.ts | 9 +++-- gitnexus/test/unit/ignore-service.test.ts | 44 +++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index 65e0b34e0..29dda57d0 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -398,11 +398,14 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio // defense-in-depth — do not remove `dot: false` assuming this covers it. if (DEFAULT_IGNORE_LIST.has(p.name)) return true; // Check against .gitignore / .gitnexusignore patterns. - // Test both bare path and path with trailing slash to handle - // bare-name patterns (e.g. `local`) and dir-only patterns (e.g. `local/`). + // Since childrenIgnored is only called for directories, always test with + // a trailing slash. This ensures directory-only negation patterns (e.g. + // `!iOS/`) are applied correctly — without the slash, `ig.ignores('iOS')` + // treats the path as a file and misses the negation. + // Bare-name patterns (e.g. `local`) still match `local/` per gitignore spec. if (ig) { const rel = p.relative(); - if (rel && (ig.ignores(rel) || ig.ignores(rel + '/'))) return true; + if (rel && ig.ignores(rel + '/')) return true; } return false; }, diff --git a/gitnexus/test/unit/ignore-service.test.ts b/gitnexus/test/unit/ignore-service.test.ts index 7d50b951e..999e34f02 100644 --- a/gitnexus/test/unit/ignore-service.test.ts +++ b/gitnexus/test/unit/ignore-service.test.ts @@ -333,6 +333,50 @@ describe('createIgnoreFilter', () => { await fs.unlink(path.join(tmpDir, '.gitignore')); }); + it('childrenIgnored respects negation patterns (exclude-all + whitelist)', async () => { + // Reproduces https://github.com/abhigyanpatwari/GitNexus/issues/596 + // Pattern: `*` (exclude all) + `!iOS/` + `!iOS/**` (whitelist iOS) + await fs.writeFile( + path.join(tmpDir, '.gitnexusignore'), + '*\n!iOS/\n!iOS/**\n!backend/\n!backend/living_plan/\n!backend/living_plan/**\n', + ); + const filter = await createIgnoreFilter(tmpDir); + + // Whitelisted directories must NOT be pruned + const iosPath = { name: 'iOS', relative: () => 'iOS' } as any; + expect(filter.childrenIgnored(iosPath)).toBe(false); + + const backendPath = { name: 'backend', relative: () => 'backend' } as any; + expect(filter.childrenIgnored(backendPath)).toBe(false); + + const livingPlanPath = { name: 'living_plan', relative: () => 'backend/living_plan' } as any; + expect(filter.childrenIgnored(livingPlanPath)).toBe(false); + + // Non-whitelisted directories must still be pruned + const srcPath = { name: 'src', relative: () => 'src' } as any; + expect(filter.childrenIgnored(srcPath)).toBe(true); + + const libPath = { name: 'lib', relative: () => 'lib' } as any; + expect(filter.childrenIgnored(libPath)).toBe(true); + + await fs.unlink(path.join(tmpDir, '.gitnexusignore')); + }); + + it('ignored respects negation patterns for files under whitelisted directories', async () => { + await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '*\n!iOS/\n!iOS/**\n'); + const filter = await createIgnoreFilter(tmpDir); + + // Files under whitelisted directory should NOT be ignored + const swiftFile = { name: 'App.swift', relative: () => 'iOS/App.swift' } as any; + expect(filter.ignored(swiftFile)).toBe(false); + + // Files outside whitelisted directory should be ignored + const pyFile = { name: 'main.py', relative: () => 'scripts/main.py' } as any; + expect(filter.ignored(pyFile)).toBe(true); + + await fs.unlink(path.join(tmpDir, '.gitnexusignore')); + }); + it('ignored returns true for file-glob patterns like *.log', async () => { await fs.writeFile(path.join(tmpDir, '.gitignore'), '*.log\n'); const filter = await createIgnoreFilter(tmpDir);