From 5cb2d5dec973aa4df7d2401b25768b7ab9e5a736 Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:22:35 -0700 Subject: [PATCH 1/5] fix(web): match .gitattributes patterns in subdirectories resolveLanguageFromGitAttributes passed each .gitattributes pattern straight to micromatch against the full repo path. A pattern with no slash, such as `*.h linguist-language=C`, only matched files at the repo root, so the override never applied to `src/foo.h`. Root-anchored patterns like `/config.in` never matched at all. Follow the gitattributes rules: a pattern with no slash matches the file name at any depth; any other pattern is matched from the repository root with a leading slash removed. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/web/src/lib/gitattributes.test.ts | 32 ++++++++++++++++++++++ packages/web/src/lib/gitattributes.ts | 14 +++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/lib/gitattributes.test.ts diff --git a/packages/web/src/lib/gitattributes.test.ts b/packages/web/src/lib/gitattributes.test.ts new file mode 100644 index 000000000..8cd2910e9 --- /dev/null +++ b/packages/web/src/lib/gitattributes.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'vitest'; +import { parseGitAttributes, resolveLanguageFromGitAttributes } from './gitattributes'; + +describe('resolveLanguageFromGitAttributes', () => { + test('a pattern without a slash matches files in any directory', () => { + const attrs = parseGitAttributes('*.h linguist-language=C\n'); + + expect(resolveLanguageFromGitAttributes('foo.h', attrs)).toBe('C'); + expect(resolveLanguageFromGitAttributes('src/include/foo.h', attrs)).toBe('C'); + }); + + test('a pattern with a leading slash is anchored to the repository root', () => { + const attrs = parseGitAttributes('/config.in linguist-language=Makefile\n'); + + expect(resolveLanguageFromGitAttributes('config.in', attrs)).toBe('Makefile'); + expect(resolveLanguageFromGitAttributes('sub/config.in', attrs)).toBeUndefined(); + }); + + test('a pattern with an inner slash is matched relative to the repository root', () => { + const attrs = parseGitAttributes('docs/*.txt linguist-language=Markdown\n'); + + expect(resolveLanguageFromGitAttributes('docs/intro.txt', attrs)).toBe('Markdown'); + expect(resolveLanguageFromGitAttributes('other/docs/intro.txt', attrs)).toBeUndefined(); + }); + + test('the last matching rule wins', () => { + const attrs = parseGitAttributes('*.inc linguist-language=PHP\nlegacy/*.inc linguist-language=Pascal\n'); + + expect(resolveLanguageFromGitAttributes('src/a.inc', attrs)).toBe('PHP'); + expect(resolveLanguageFromGitAttributes('legacy/a.inc', attrs)).toBe('Pascal'); + }); +}); diff --git a/packages/web/src/lib/gitattributes.ts b/packages/web/src/lib/gitattributes.ts index b729ea296..29a71cd04 100644 --- a/packages/web/src/lib/gitattributes.ts +++ b/packages/web/src/lib/gitattributes.ts @@ -58,13 +58,25 @@ export function parseGitAttributes(content: string): GitAttributes { return { rules }; } +// matchesGitAttributesPattern applies gitattributes pattern rules: a pattern +// without a slash matches the file name at any depth, and any other pattern +// is matched against the full path from the repository root. +// @see https://git-scm.com/docs/gitattributes#_description +function matchesGitAttributesPattern(filePath: string, pattern: string): boolean { + if (!pattern.includes('/')) { + const fileName = filePath.slice(filePath.lastIndexOf('/') + 1); + return micromatch.isMatch(fileName, pattern, { dot: true }); + } + return micromatch.isMatch(filePath, pattern.replace(/^\//, ''), { dot: true }); +} + // resolveLanguageFromGitAttributes returns the linguist-language override for // the given file path based on the parsed .gitattributes rules, or undefined // if no rule matches. Last matching rule wins, consistent with gitattributes semantics. export function resolveLanguageFromGitAttributes(filePath: string, gitAttributes: GitAttributes): string | undefined { let language: string | undefined; for (const rule of gitAttributes.rules) { - if (micromatch.isMatch(filePath, rule.pattern) && rule.attrs['linguist-language']) { + if (matchesGitAttributesPattern(filePath, rule.pattern) && rule.attrs['linguist-language']) { language = rule.attrs['linguist-language']; } } From 0dfba19d7c84b7d43726385d4ee321d387ceef8d Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:58:41 -0700 Subject: [PATCH 2/5] docs: add changelog entry for #1692 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f4d08c7..f8f93d248 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Made the default home page configurable with `DEFAULT_HOME_VIEW_PAGE`, defaulting to Code Search and supporting Ask. [#1677](https://github.com/sourcebot-dev/sourcebot/pull/1677) - Require authentication for the streaming and blocking Ask APIs in Public SaaS deployments. [#1679](https://github.com/sourcebot-dev/sourcebot/pull/1679) +- Fixed `.gitattributes` `linguist-language` overrides being ignored for files in subdirectories. [#1692](https://github.com/sourcebot-dev/sourcebot/pull/1692) ## [5.1.14] - 2026-09-17 From 5e9fcf2c77eda8f3a178fe132f1f54a607cd300d Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:47:14 -0700 Subject: [PATCH 3/5] fix(web): clear linguist language overrides --- packages/web/src/lib/gitattributes.test.ts | 20 ++++++++++++++++++++ packages/web/src/lib/gitattributes.ts | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/web/src/lib/gitattributes.test.ts b/packages/web/src/lib/gitattributes.test.ts index 8cd2910e9..b5ec9503c 100644 --- a/packages/web/src/lib/gitattributes.test.ts +++ b/packages/web/src/lib/gitattributes.test.ts @@ -29,4 +29,24 @@ describe('resolveLanguageFromGitAttributes', () => { expect(resolveLanguageFromGitAttributes('src/a.inc', attrs)).toBe('PHP'); expect(resolveLanguageFromGitAttributes('legacy/a.inc', attrs)).toBe('Pascal'); }); + + test('dotfiles match slashless patterns', () => { + const attrs = parseGitAttributes('*.conf linguist-language=TOML\n'); + + expect(resolveLanguageFromGitAttributes('src/.config.conf', attrs)).toBe('TOML'); + }); + + test.each(['-linguist-language', '!linguist-language'])('%s clears the previous language override', (reset) => { + const attrs = parseGitAttributes(`*.inc linguist-language=PHP\nlegacy/*.inc ${reset}\n`); + + expect(resolveLanguageFromGitAttributes('legacy/a.inc', attrs)).toBeUndefined(); + }); + + test('a later language value replaces a reset', () => { + const attrs = parseGitAttributes( + '*.inc linguist-language=PHP\nlegacy/*.inc -linguist-language\nlegacy/special.inc linguist-language=Pascal\n', + ); + + expect(resolveLanguageFromGitAttributes('legacy/special.inc', attrs)).toBe('Pascal'); + }); }); diff --git a/packages/web/src/lib/gitattributes.ts b/packages/web/src/lib/gitattributes.ts index 29a71cd04..07267b215 100644 --- a/packages/web/src/lib/gitattributes.ts +++ b/packages/web/src/lib/gitattributes.ts @@ -76,8 +76,9 @@ function matchesGitAttributesPattern(filePath: string, pattern: string): boolean export function resolveLanguageFromGitAttributes(filePath: string, gitAttributes: GitAttributes): string | undefined { let language: string | undefined; for (const rule of gitAttributes.rules) { - if (matchesGitAttributesPattern(filePath, rule.pattern) && rule.attrs['linguist-language']) { - language = rule.attrs['linguist-language']; + const languageAttribute = rule.attrs['linguist-language']; + if (matchesGitAttributesPattern(filePath, rule.pattern) && languageAttribute !== undefined) { + language = languageAttribute === 'false' || languageAttribute === 'unspecified' ? undefined : languageAttribute; } } return language; From 321a14c7b78bf16b4b8f460730f4546085d14f89 Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:19:58 -0700 Subject: [PATCH 4/5] fix(web): normalize relative gitattributes paths --- packages/web/src/lib/gitattributes.test.ts | 1 + packages/web/src/lib/gitattributes.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/web/src/lib/gitattributes.test.ts b/packages/web/src/lib/gitattributes.test.ts index b5ec9503c..806b47260 100644 --- a/packages/web/src/lib/gitattributes.test.ts +++ b/packages/web/src/lib/gitattributes.test.ts @@ -20,6 +20,7 @@ describe('resolveLanguageFromGitAttributes', () => { const attrs = parseGitAttributes('docs/*.txt linguist-language=Markdown\n'); expect(resolveLanguageFromGitAttributes('docs/intro.txt', attrs)).toBe('Markdown'); + expect(resolveLanguageFromGitAttributes('./docs/intro.txt', attrs)).toBe('Markdown'); expect(resolveLanguageFromGitAttributes('other/docs/intro.txt', attrs)).toBeUndefined(); }); diff --git a/packages/web/src/lib/gitattributes.ts b/packages/web/src/lib/gitattributes.ts index 07267b215..3ebb14720 100644 --- a/packages/web/src/lib/gitattributes.ts +++ b/packages/web/src/lib/gitattributes.ts @@ -63,11 +63,12 @@ export function parseGitAttributes(content: string): GitAttributes { // is matched against the full path from the repository root. // @see https://git-scm.com/docs/gitattributes#_description function matchesGitAttributesPattern(filePath: string, pattern: string): boolean { + const normalizedFilePath = filePath.replace(/^(?:\.\/)+/, ''); if (!pattern.includes('/')) { - const fileName = filePath.slice(filePath.lastIndexOf('/') + 1); + const fileName = normalizedFilePath.slice(normalizedFilePath.lastIndexOf('/') + 1); return micromatch.isMatch(fileName, pattern, { dot: true }); } - return micromatch.isMatch(filePath, pattern.replace(/^\//, ''), { dot: true }); + return micromatch.isMatch(normalizedFilePath, pattern.replace(/^\//, ''), { dot: true }); } // resolveLanguageFromGitAttributes returns the linguist-language override for From d67fcc798755e2f07a0ef641580bf08555989021 Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:19:00 -0700 Subject: [PATCH 5/5] fix(web): normalize interior dot path segments --- packages/web/src/lib/gitattributes.test.ts | 1 + packages/web/src/lib/gitattributes.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/web/src/lib/gitattributes.test.ts b/packages/web/src/lib/gitattributes.test.ts index 806b47260..ea0e5464d 100644 --- a/packages/web/src/lib/gitattributes.test.ts +++ b/packages/web/src/lib/gitattributes.test.ts @@ -21,6 +21,7 @@ describe('resolveLanguageFromGitAttributes', () => { expect(resolveLanguageFromGitAttributes('docs/intro.txt', attrs)).toBe('Markdown'); expect(resolveLanguageFromGitAttributes('./docs/intro.txt', attrs)).toBe('Markdown'); + expect(resolveLanguageFromGitAttributes('./docs/./intro.txt', attrs)).toBe('Markdown'); expect(resolveLanguageFromGitAttributes('other/docs/intro.txt', attrs)).toBeUndefined(); }); diff --git a/packages/web/src/lib/gitattributes.ts b/packages/web/src/lib/gitattributes.ts index 3ebb14720..0e0f6d9bb 100644 --- a/packages/web/src/lib/gitattributes.ts +++ b/packages/web/src/lib/gitattributes.ts @@ -63,7 +63,10 @@ export function parseGitAttributes(content: string): GitAttributes { // is matched against the full path from the repository root. // @see https://git-scm.com/docs/gitattributes#_description function matchesGitAttributesPattern(filePath: string, pattern: string): boolean { - const normalizedFilePath = filePath.replace(/^(?:\.\/)+/, ''); + const normalizedFilePath = filePath + .split('/') + .filter((pathSegment) => pathSegment !== '.') + .join('/'); if (!pattern.includes('/')) { const fileName = normalizedFilePath.slice(normalizedFilePath.lastIndexOf('/') + 1); return micromatch.isMatch(fileName, pattern, { dot: true });