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 diff --git a/packages/web/src/lib/gitattributes.test.ts b/packages/web/src/lib/gitattributes.test.ts new file mode 100644 index 000000000..ea0e5464d --- /dev/null +++ b/packages/web/src/lib/gitattributes.test.ts @@ -0,0 +1,54 @@ +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('./docs/intro.txt', attrs)).toBe('Markdown'); + 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'); + }); + + 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 b729ea296..0e0f6d9bb 100644 --- a/packages/web/src/lib/gitattributes.ts +++ b/packages/web/src/lib/gitattributes.ts @@ -58,14 +58,31 @@ 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 { + 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 }); + } + return micromatch.isMatch(normalizedFilePath, 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']) { - 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;