From bc086240ae37cb6e0195676272a0362c955e451f Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 25 Mar 2026 19:06:35 -0700 Subject: [PATCH 1/4] feat(web): support .gitattributes linguist-language overrides in file viewer Co-Authored-By: Claude Sonnet 4.6 --- .../web/src/features/git/getFileSourceApi.ts | 13 +++- packages/web/src/lib/gitattributes.ts | 72 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/lib/gitattributes.ts diff --git a/packages/web/src/features/git/getFileSourceApi.ts b/packages/web/src/features/git/getFileSourceApi.ts index 401461981..2f4fcb8f0 100644 --- a/packages/web/src/features/git/getFileSourceApi.ts +++ b/packages/web/src/features/git/getFileSourceApi.ts @@ -2,6 +2,7 @@ import { sew } from '@/actions'; import { getBrowsePath } from '@/app/[domain]/browse/hooks/utils'; import { getAuditService } from '@/ee/features/audit/factory'; import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants'; +import { parseGitAttributes, resolveLanguageFromGitAttributes } from '@/lib/gitattributes'; import { detectLanguageFromFilename } from '@/lib/languageDetection'; import { ServiceError, notFound, fileNotFound, invalidGitRef, unexpectedError } from '@/lib/serviceError'; import { getCodeHostBrowseFileAtBranchUrl } from '@/lib/utils'; @@ -65,7 +66,17 @@ export const getFileSource = async ({ path: filePath, repo: repoName, ref }: Fil throw error; } - const language = detectLanguageFromFilename(filePath); + let gitattributesContent: string | undefined; + try { + gitattributesContent = await git.raw(['show', `${gitRef}:.gitattributes`]); + } catch { + // No .gitattributes in this repo/ref, that's fine + } + + const language = gitattributesContent + ? (resolveLanguageFromGitAttributes(filePath, parseGitAttributes(gitattributesContent)) ?? detectLanguageFromFilename(filePath)) + : detectLanguageFromFilename(filePath); + const externalWebUrl = getCodeHostBrowseFileAtBranchUrl({ webUrl: repo.webUrl, codeHostType: repo.external_codeHostType, diff --git a/packages/web/src/lib/gitattributes.ts b/packages/web/src/lib/gitattributes.ts new file mode 100644 index 000000000..b729ea296 --- /dev/null +++ b/packages/web/src/lib/gitattributes.ts @@ -0,0 +1,72 @@ +import micromatch from 'micromatch'; + +// GitAttributes holds parsed .gitattributes rules for overriding language detection. +export interface GitAttributes { + rules: GitAttributeRule[]; +} + +interface GitAttributeRule { + pattern: string; + attrs: Record; +} + +// parseGitAttributes parses the content of a .gitattributes file. +// Each non-comment, non-empty line has the form: pattern attr1 attr2=value ... +// Attributes can be: +// - "linguist-vendored" (set/true), "-linguist-vendored" (unset/false) +// - "linguist-language=Go" +// - etc. +export function parseGitAttributes(content: string): GitAttributes { + const rules: GitAttributeRule[] = []; + + for (const raw of content.split('\n')) { + const line = raw.trim(); + if (line === '' || line.startsWith('#')) { + continue; + } + + const fields = line.split(/\s+/); + if (fields.length < 2) { + continue; + } + + const pattern = fields[0]; + const attrs: Record = {}; + + for (const field of fields.slice(1)) { + if (field.startsWith('!')) { + // !attr means unspecified (reset to default) + attrs[field.slice(1)] = 'unspecified'; + } else if (field.startsWith('-')) { + // -attr means unset (false) + attrs[field.slice(1)] = 'false'; + } else { + const eqIdx = field.indexOf('='); + if (eqIdx !== -1) { + // attr=value + attrs[field.slice(0, eqIdx)] = field.slice(eqIdx + 1); + } else { + // attr alone means set (true) + attrs[field] = 'true'; + } + } + } + + rules.push({ pattern, attrs }); + } + + return { rules }; +} + +// 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']; + } + } + return language; +} From 5c2b7ebf6917ca6c05f178e044425a40793beac8 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 25 Mar 2026 19:07:22 -0700 Subject: [PATCH 2/4] chore: update CHANGELOG for #1048 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53df29c37..36c7f29ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- Added support for `.gitattributes` `linguist-language` overrides in the file viewer ([#1048](https://github.com/sourcebot-dev/sourcebot/pull/1048)) + ## [4.16.2] - 2026-03-25 ### Fixed From c3c24eb028223f07a047da2bd55dc49ecf32bd1b Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 25 Mar 2026 19:34:29 -0700 Subject: [PATCH 3/4] s --- packages/web/src/lib/gitattributes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/lib/gitattributes.ts b/packages/web/src/lib/gitattributes.ts index b729ea296..97ba7db3e 100644 --- a/packages/web/src/lib/gitattributes.ts +++ b/packages/web/src/lib/gitattributes.ts @@ -64,7 +64,7 @@ export function parseGitAttributes(content: string): GitAttributes { 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 (micromatch.isMatch(filePath, rule.pattern, { matchBase: true }) && rule.attrs['linguist-language']) { language = rule.attrs['linguist-language']; } } From b24d8131a4c8a6391569da6222f91bae760fa889 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 25 Mar 2026 19:35:39 -0700 Subject: [PATCH 4/4] s --- packages/web/src/lib/gitattributes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/lib/gitattributes.ts b/packages/web/src/lib/gitattributes.ts index 97ba7db3e..b729ea296 100644 --- a/packages/web/src/lib/gitattributes.ts +++ b/packages/web/src/lib/gitattributes.ts @@ -64,7 +64,7 @@ export function parseGitAttributes(content: string): GitAttributes { 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, { matchBase: true }) && rule.attrs['linguist-language']) { + if (micromatch.isMatch(filePath, rule.pattern) && rule.attrs['linguist-language']) { language = rule.attrs['linguist-language']; } }