Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion packages/web/src/features/git/getFileSourceApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
72 changes: 72 additions & 0 deletions packages/web/src/lib/gitattributes.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}

// 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<string, string> = {};

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;
Comment thread
brendan-kellam marked this conversation as resolved.
}
Loading