Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
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) <noreply@anthropic.com>
  • Loading branch information
breken-ai and claude committed Sep 25, 2026
commit 5cb2d5dec973aa4df7d2401b25768b7ab9e5a736
32 changes: 32 additions & 0 deletions packages/web/src/lib/gitattributes.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
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('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');
});
});
14 changes: 13 additions & 1 deletion packages/web/src/lib/gitattributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}
}
Expand Down