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
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,14 @@
"default": true,
"markdownDescription": "Automatically link references based on the `origin` remote. `!123` (GitLab merge requests) is linked for **any** host, including self-hosted GitLab. `#123` (issues) is linked for **github.com** and **gitlab.com** only — on a self-hosted host the issue path can't be determined, so add a `gitGraphPlus.commitMessageLinks` rule for those."
},
"gitGraphPlus.avatarOverrides": {
"type": "object",
"default": {},
"additionalProperties": {
"type": "string"
},
"markdownDescription": "Map commit-author emails to explicit avatar image URLs. Used when an email has neither a Gravatar account nor a GitHub `noreply` address, so the author still shows a real avatar. Keys are matched case-insensitively. Example: `{ \"you@example.com\": \"https://avatars.githubusercontent.com/u/12345\" }`. Entries whose value is not an `https://` URL are ignored."
},
"gitGraphPlus.defaults.push.force": { "type": "string", "enum": ["none", "with-lease", "force"], "default": "none", "markdownDescription": "⚠️ `with-lease`/`force` pre-checks a force push, which can overwrite remote history." },
"gitGraphPlus.defaults.push.setUpstream": { "type": "boolean", "default": true, "markdownDescription": "Set the upstream tracking reference with `-u` when pushing." },
"gitGraphPlus.defaults.push.allTags": { "type": "boolean", "default": false, "markdownDescription": "Also push all tags (`--tags`)." },
Expand Down
6 changes: 4 additions & 2 deletions src/panels/MainPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { GitService, GitError } from '../git/git-service';
import { formatGitError, isAuthFailure, transportFromRemoteUrl } from '../git/git-error-formatter';
import { splitUpstreamRef } from '../git/git-parser';
import { samePath } from '../utils/path';
import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount, readInteractiveRebaseMode } from '../utils/config';
import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount, readInteractiveRebaseMode, readAvatarOverrides } from '../utils/config';
import { buildClassicRebaseCommand } from '../git/classic-rebase';
import { buildFullGraph } from '../git/git-graph-builder';
import { compileBranchColorRules, makeBranchColorResolver } from '../git/branch-color-resolver';
Expand Down Expand Up @@ -90,7 +90,9 @@ export class MainPanel {

private static getAvatarCache(): AvatarCache {
if (!this.avatarCache) {
this.avatarCache = new AvatarCache(this.avatarCacheDir ?? null);
this.avatarCache = new AvatarCache(this.avatarCacheDir ?? null, undefined, {
avatarOverrides: readAvatarOverrides(),
});
}
return this.avatarCache;
}
Expand Down
79 changes: 79 additions & 0 deletions src/services/__tests__/avatar-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,85 @@ describe('AvatarCache', () => {
expect(calls[0]).toContain('s=32');
});

it('resolves a GitHub <id>+<login> noreply email to avatars.githubusercontent.com', async () => {
const { fetcher, calls } = makeFetcher();
const cache = new AvatarCache(null, fetcher);

await cache.get('73097985+the0807@users.noreply.github.com', 32);

expect(calls).toHaveLength(1);
expect(calls[0]).toBe('https://avatars.githubusercontent.com/u/73097985?s=32');
});

it('resolves a bare GitHub <login> noreply email to avatars.githubusercontent.com', async () => {
const { fetcher, calls } = makeFetcher();
const cache = new AvatarCache(null, fetcher);

await cache.get('the0807@users.noreply.github.com', 48);

expect(calls).toHaveLength(1);
expect(calls[0]).toBe('https://avatars.githubusercontent.com/the0807?s=48');
});

it('resolves noreply emails case-insensitively after normalization', async () => {
const { fetcher, calls } = makeFetcher();
const cache = new AvatarCache(null, fetcher);

await cache.get(' 73097985+The0807@Users.Noreply.GitHub.com ', 32);

expect(calls).toHaveLength(1);
expect(calls[0]).toBe('https://avatars.githubusercontent.com/u/73097985?s=32');
});

it('falls back to Gravatar for a non-noreply email', async () => {
const { fetcher, calls } = makeFetcher();
const cache = new AvatarCache(null, fetcher);

await cache.get('alice@example.com', 32);

expect(calls).toHaveLength(1);
expect(calls[0]).toContain('https://www.gravatar.com/avatar/');
expect(calls[0]).toContain('s=32');
});

it('uses an explicit override URL for a matching email', async () => {
const { fetcher, calls } = makeFetcher();
const cache = new AvatarCache(null, fetcher, {
avatarOverrides: { 'alice@example.com': 'https://example.com/alice.png' },
});

await cache.get('alice@example.com', 32);

expect(calls).toHaveLength(1);
expect(calls[0]).toBe('https://example.com/alice.png');
});

it('matches override keys case-insensitively after email normalization', async () => {
const { fetcher, calls } = makeFetcher();
const cache = new AvatarCache(null, fetcher, {
avatarOverrides: { 'alice@example.com': 'https://example.com/alice.png' },
});

await cache.get(' Alice@Example.com ', 32);

expect(calls).toHaveLength(1);
expect(calls[0]).toBe('https://example.com/alice.png');
});

it('lets an override take priority over a GitHub noreply email', async () => {
const { fetcher, calls } = makeFetcher();
const cache = new AvatarCache(null, fetcher, {
avatarOverrides: {
'73097985+the0807@users.noreply.github.com': 'https://example.com/custom.png',
},
});

await cache.get('73097985+the0807@users.noreply.github.com', 32);

expect(calls).toHaveLength(1);
expect(calls[0]).toBe('https://example.com/custom.png');
});

it('serves repeat requests from memory without re-fetching', async () => {
const { fetcher, calls } = makeFetcher();
const cache = new AvatarCache(null, fetcher);
Expand Down
41 changes: 35 additions & 6 deletions src/services/avatar-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,52 @@ function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}

/** Caches Gravatar avatars in the extension host (memory + disk) and serves
* them to the webview as base64 data URIs. This keeps the webview renderer
* from opening a fresh connection to gravatar.com on every render/scroll/
* window — the root cause of the socket exhaustion in issue #38. */
/** Matches GitHub's private "noreply" emails: either `<id>+<login>` or a bare
* `<login>`, both at users.noreply.github.com. Authors committing through
* GitHub's "keep my email private" option get exactly these addresses. */
const GITHUB_NOREPLY_RE = /^(?:(\d+)\+)?([^@]+)@users\.noreply\.github\.com$/i;

/** Returns the avatar URL for a normalized commit-author email. An explicit
* override wins first; then GitHub noreply emails resolve to
* avatars.githubusercontent.com (where the real avatar lives); everything
* else falls back to Gravatar, whose `d=retro` parameter generates a
* placeholder for emails without a Gravatar account. */
function avatarUrlForEmail(normEmail: string, size: number, overrides: Record<string, string>): string {
const override = overrides[normEmail];
if (override) return override;

const m = GITHUB_NOREPLY_RE.exec(normEmail);
if (m) {
const [, id, login] = m;
return id !== undefined
? `https://avatars.githubusercontent.com/u/${id}?s=${size}`
: `https://avatars.githubusercontent.com/${login}?s=${size}`;
}
const hash = md5(normEmail);
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=retro`;
}

/** Caches commit-author avatars in the extension host (memory + disk) and
* serves them to the webview as base64 data URIs. GitHub "noreply" emails
* resolve to avatars.githubusercontent.com; everything else uses Gravatar.
* Caching keeps the webview renderer from opening a fresh connection on
* every render/scroll/window — the root cause of the socket exhaustion in
* issue #38. */
export class AvatarCache {
private memory = new Map<string, string>(); // key -> data URI
private inflight = new Map<string, Promise<string | null>>();
private maxDiskEntries: number;
private ttlMs: number;
private overrides: Record<string, string>;

constructor(
private cacheDir: string | null = null,
private fetcher: AvatarFetcher = defaultFetcher,
opts?: { maxDiskEntries?: number; ttlMs?: number },
opts?: { maxDiskEntries?: number; ttlMs?: number; avatarOverrides?: Record<string, string> },
) {
this.maxDiskEntries = opts?.maxDiskEntries ?? DEFAULT_MAX_DISK_ENTRIES;
this.ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;
this.overrides = opts?.avatarOverrides ?? {};
}

/** Returns a base64 data URI for the avatar, or null if it cannot be loaded. */
Expand Down Expand Up @@ -88,7 +117,7 @@ export class AvatarCache {
}
}

const url = `https://www.gravatar.com/avatar/${hash}?s=${size}&d=retro`;
const url = avatarUrlForEmail(normEmail, size, this.overrides);
const res = await this.fetcher(url);
if (!res) {
// Refresh failed (e.g. offline). Fall back to the stale copy if we have
Expand Down
36 changes: 35 additions & 1 deletion src/utils/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ vi.mock('vscode', () => ({
},
}));

import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount } from '../config';
import { readTimeoutMs, readInitialCommitCount, readLoadMoreCommitCount, readAvatarOverrides } from '../config';

describe('readTimeoutMs', () => {
// Back-compat alias so the existing timeout cases below read naturally.
Expand Down Expand Up @@ -101,3 +101,37 @@ describe('readLoadMoreCommitCount', () => {
expect(readLoadMoreCommitCount()).toBe(50);
});
});

describe('readAvatarOverrides', () => {
beforeEach(() => { h.values = {}; });

it('returns an empty map when the setting is unset', () => {
expect(readAvatarOverrides()).toEqual({});
});

it('returns https URL entries with normalized keys', () => {
h.values.avatarOverrides = {
' Alice@Example.com ': ' https://example.com/a.png ',
'bob@example.com': 'https://example.com/b.png',
};
expect(readAvatarOverrides()).toEqual({
'alice@example.com': 'https://example.com/a.png',
'bob@example.com': 'https://example.com/b.png',
});
});

it('ignores non-string, empty, and non-https values', () => {
h.values.avatarOverrides = {
'a@example.com': 'http://example.com/a.png', // wrong protocol
'b@example.com': '',
'c@example.com': 42,
'd@example.com': null,
};
expect(readAvatarOverrides()).toEqual({});
});

it('ignores a non-object setting', () => {
h.values.avatarOverrides = 'not-an-object';
expect(readAvatarOverrides()).toEqual({});
});
});
22 changes: 22 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,25 @@ export function readInteractiveRebaseMode(): InteractiveRebaseMode {
vscode.workspace.getConfiguration('gitGraphPlus').get<string>('interactiveRebase.mode', 'ui'),
);
}

/**
* Reads `gitGraphPlus.avatarOverrides` — an object mapping commit-author emails
* to explicit https avatar URLs. It lets an author whose email has neither a
* Gravatar account nor a GitHub noreply address still show a real avatar.
* Keys are normalized (trimmed + lowercased); entries whose value is empty or
* not an https URL are ignored (the fetch is https-only).
*/
export function readAvatarOverrides(): Record<string, string> {
const raw = vscode.workspace
.getConfiguration('gitGraphPlus')
.get<Record<string, unknown>>('avatarOverrides', {});
const out: Record<string, string> = {};
if (raw && typeof raw === 'object') {
for (const [email, url] of Object.entries(raw)) {
if (typeof url === 'string' && url.trim().startsWith('https://')) {
out[email.trim().toLowerCase()] = url.trim();
}
}
}
return out;
}
Loading