Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 54 additions & 0 deletions packages/web/src/lib/gitattributes.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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');
});
});
21 changes: 19 additions & 2 deletions packages/web/src/lib/gitattributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down