Skip to content

Commit 1ba3cad

Browse files
committed
feat(commit-links): clickable auto-links in commit messages (#49)
Turn issue/ticket/PR references in commit messages into clickable links in the Graph, Commit Details, and Reflog views. Supports user-defined regex rules (gitGraphPlus.commitMessageLinks) and built-in GitHub/GitLab auto-detection from the origin remote (gitGraphPlus.autoDetectRepoLinks): !N is linked as a GitLab merge request on any host, #N as an issue on github.com/gitlab.com. Links open in the external browser.
1 parent fe8716b commit 1ba3cad

18 files changed

Lines changed: 522 additions & 6 deletions

File tree

package.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,30 @@
472472
],
473473
"markdownDescription": "Color palette auto-assigned to graph rails. Each entry is a hex color (`#rgb` or `#rrggbb`). When there are more branches than colors, the palette cycles. Invalid entries are ignored; an empty list falls back to the built-in palette. `branchColors` pattern rules, when present, take precedence over this palette."
474474
},
475+
"gitGraphPlus.commitMessageLinks": {
476+
"type": "array",
477+
"default": [],
478+
"items": {
479+
"type": "object",
480+
"properties": {
481+
"pattern": {
482+
"type": "string",
483+
"description": "Regular expression matched against the commit message, e.g. ([A-Z]+-\\d+). Capture groups are referenced in the URL as $1, $2, ..."
484+
},
485+
"url": {
486+
"type": "string",
487+
"description": "URL template. $1, $2, ... are replaced by the corresponding capture groups, e.g. https://my.tracker/browse/$1"
488+
}
489+
},
490+
"required": ["pattern", "url"]
491+
},
492+
"markdownDescription": "Turn matching text in commit messages into clickable links (Graph, Commit Details, and Reflog). Each rule is `{ \"pattern\": <regex>, \"url\": <template> }`; `$1`, `$2`, … in the template are replaced by the pattern's capture groups. Rules are applied in order, before built-in auto-detection. Only `http`/`https` URLs are opened. Tip: put rules in workspace settings (`.vscode/settings.json`) for per-repository trackers."
493+
},
494+
"gitGraphPlus.autoDetectRepoLinks": {
495+
"type": "boolean",
496+
"default": true,
497+
"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."
498+
},
475499
"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." },
476500
"gitGraphPlus.defaults.push.setUpstream": { "type": "boolean", "default": true, "markdownDescription": "Set the upstream tracking reference with `-u` when pushing." },
477501
"gitGraphPlus.defaults.push.allTags": { "type": "boolean", "default": false, "markdownDescription": "Also push all tags (`--tags`)." },
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
parseRemoteHost,
4+
buildBuiltinRules,
5+
resolveCommitLinkRules,
6+
type LinkRule,
7+
} from '../commit-link-rules';
8+
9+
describe('parseRemoteHost', () => {
10+
it('parses ssh shorthand', () => {
11+
expect(parseRemoteHost('git@github.com:owner/repo.git')).toEqual({
12+
host: 'github.com', owner: 'owner', repo: 'repo',
13+
});
14+
});
15+
it('parses https with .git', () => {
16+
expect(parseRemoteHost('https://github.com/owner/repo.git')).toEqual({
17+
host: 'github.com', owner: 'owner', repo: 'repo',
18+
});
19+
});
20+
it('parses https without .git', () => {
21+
expect(parseRemoteHost('https://gitlab.com/grp/proj')).toEqual({
22+
host: 'gitlab.com', owner: 'grp', repo: 'proj',
23+
});
24+
});
25+
it('parses ssh:// url form', () => {
26+
expect(parseRemoteHost('ssh://git@github.com/owner/repo.git')).toEqual({
27+
host: 'github.com', owner: 'owner', repo: 'repo',
28+
});
29+
});
30+
it('returns null for malformed url', () => {
31+
expect(parseRemoteHost('not a url')).toBeNull();
32+
expect(parseRemoteHost('')).toBeNull();
33+
});
34+
});
35+
36+
describe('buildBuiltinRules', () => {
37+
it('builds github issue rule plus the always-on MR rule', () => {
38+
expect(buildBuiltinRules('git@github.com:owner/repo.git')).toEqual<LinkRule[]>([
39+
{ pattern: '!(\\d+)', url: 'https://github.com/owner/repo/-/merge_requests/$1' },
40+
{ pattern: '#(\\d+)', url: 'https://github.com/owner/repo/issues/$1' },
41+
]);
42+
});
43+
it('builds gitlab issue + MR rules', () => {
44+
expect(buildBuiltinRules('https://gitlab.com/grp/proj.git')).toEqual<LinkRule[]>([
45+
{ pattern: '!(\\d+)', url: 'https://gitlab.com/grp/proj/-/merge_requests/$1' },
46+
{ pattern: '#(\\d+)', url: 'https://gitlab.com/grp/proj/-/issues/$1' },
47+
]);
48+
});
49+
it('builds only the MR rule for a self-hosted host (! is GitLab-only)', () => {
50+
expect(buildBuiltinRules('git@git.company.com:owner/repo.git')).toEqual<LinkRule[]>([
51+
{ pattern: '!(\\d+)', url: 'https://git.company.com/owner/repo/-/merge_requests/$1' },
52+
]);
53+
});
54+
it('returns empty for an unparseable remote', () => {
55+
expect(buildBuiltinRules('not a url')).toEqual([]);
56+
});
57+
it('returns empty for null remote', () => {
58+
expect(buildBuiltinRules(null)).toEqual([]);
59+
});
60+
});
61+
62+
describe('resolveCommitLinkRules', () => {
63+
const custom = [{ pattern: '([A-Z]+-\\d+)', url: 'https://jira/browse/$1' }];
64+
65+
it('puts custom rules before built-in rules', () => {
66+
const rules = resolveCommitLinkRules(custom, true, 'git@github.com:o/r.git');
67+
expect(rules).toEqual<LinkRule[]>([
68+
{ pattern: '([A-Z]+-\\d+)', url: 'https://jira/browse/$1' },
69+
{ pattern: '!(\\d+)', url: 'https://github.com/o/r/-/merge_requests/$1' },
70+
{ pattern: '#(\\d+)', url: 'https://github.com/o/r/issues/$1' },
71+
]);
72+
});
73+
it('omits built-in rules when autoDetect is false', () => {
74+
expect(resolveCommitLinkRules(custom, false, 'git@github.com:o/r.git')).toEqual(custom);
75+
});
76+
it('skips invalid custom entries', () => {
77+
const raw = [
78+
{ pattern: '#(\\d+)', url: 'https://x/$1' },
79+
{ pattern: '(', url: 'https://bad' }, // invalid regex
80+
{ pattern: 123, url: 'https://y' }, // non-string pattern
81+
{ nope: true }, // wrong shape
82+
];
83+
expect(resolveCommitLinkRules(raw, false, null)).toEqual([
84+
{ pattern: '#(\\d+)', url: 'https://x/$1' },
85+
]);
86+
});
87+
it('returns empty for empty config and no remote', () => {
88+
expect(resolveCommitLinkRules([], true, null)).toEqual([]);
89+
expect(resolveCommitLinkRules(undefined, true, null)).toEqual([]);
90+
});
91+
});

src/git/commit-link-rules.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
export interface LinkRule {
2+
pattern: string;
3+
url: string;
4+
}
5+
6+
interface RemoteInfo {
7+
host: string;
8+
owner: string;
9+
repo: string;
10+
}
11+
12+
/**
13+
* Parse a git remote URL (ssh shorthand, ssh:// or https://) into host + owner + repo.
14+
* Returns null when the URL is not in a recognized owner/repo form.
15+
*/
16+
export function parseRemoteHost(url: string): RemoteInfo | null {
17+
if (typeof url !== 'string' || url.length === 0) return null;
18+
const trimmed = url.trim();
19+
20+
// ssh shorthand: git@host:owner/repo(.git)
21+
const ssh = trimmed.match(/^[^@\s]+@([^:/\s]+):(.+?)\/([^/\s]+?)(?:\.git)?\/?$/);
22+
if (ssh) return { host: ssh[1], owner: ssh[2], repo: ssh[3] };
23+
24+
// ssh://, https://, http:// forms
25+
const proto = trimmed.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/\s]+@)?([^:/\s]+)(?::\d+)?\/(.+?)\/([^/\s]+?)(?:\.git)?\/?$/i);
26+
if (proto) return { host: proto[1], owner: proto[2], repo: proto[3] };
27+
28+
return null;
29+
}
30+
31+
/**
32+
* Built-in auto-link rules derived from the repo's remote.
33+
*
34+
* `!N` (merge request) is GitLab-only syntax, so an `!N` rule is generated for
35+
* ANY host (including self-hosted GitLab). `#N` (issue) has a forge-specific
36+
* path, so it is only generated for the recognized SaaS hosts github.com and
37+
* gitlab.com; on self-hosted hosts the issue path is ambiguous and users add a
38+
* custom `commitMessageLinks` rule instead.
39+
*/
40+
export function buildBuiltinRules(remoteUrl: string | null): LinkRule[] {
41+
if (!remoteUrl) return [];
42+
const info = parseRemoteHost(remoteUrl);
43+
if (!info) return [];
44+
const base = `https://${info.host}/${info.owner}/${info.repo}`;
45+
const rules: LinkRule[] = [
46+
// `!N` is GitLab-only — safe to link on any host.
47+
{ pattern: '!(\\d+)', url: `${base}/-/merge_requests/$1` },
48+
];
49+
if (info.host === 'github.com') {
50+
// GitHub redirects /issues/N to the PR when N is a PR, so one rule covers both.
51+
rules.push({ pattern: '#(\\d+)', url: `${base}/issues/$1` });
52+
} else if (info.host === 'gitlab.com') {
53+
rules.push({ pattern: '#(\\d+)', url: `${base}/-/issues/$1` });
54+
}
55+
return rules;
56+
}
57+
58+
/**
59+
* Validate + compile user custom rules (skipping bad entries, like
60+
* compileBranchColorRules), then append built-in rules when autoDetect is on.
61+
* Custom rules come first so a user can override the built-in `#N` behaviour.
62+
*/
63+
export function resolveCommitLinkRules(
64+
customRaw: unknown,
65+
autoDetect: boolean,
66+
remoteUrl: string | null,
67+
): LinkRule[] {
68+
const rules: LinkRule[] = [];
69+
if (Array.isArray(customRaw)) {
70+
for (const entry of customRaw) {
71+
if (!entry || typeof entry !== 'object') continue;
72+
const { pattern, url } = entry as Partial<LinkRule>;
73+
if (typeof pattern !== 'string' || typeof url !== 'string') continue;
74+
try {
75+
new RegExp(pattern); // validate; skip if it throws
76+
} catch {
77+
continue;
78+
}
79+
rules.push({ pattern, url });
80+
}
81+
}
82+
if (autoDetect) rules.push(...buildBuiltinRules(remoteUrl));
83+
return rules;
84+
}

src/panels/MainPanel.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { AvatarCache } from '../services/avatar-cache';
1515
import { resolveGitDirs, shouldRefreshGraph } from '../services/file-watcher-helpers';
1616
import { RepoDiscoveryService, RepoInfo } from '../services/repo-discovery';
1717
import type { WebviewMessage, ModalDefaults } from '../utils/message-bus';
18+
import { resolveCommitLinkRules, type LinkRule } from '../git/commit-link-rules';
1819
import {
1920
resolveRepoRelativePath as resolveRepoRelativePathUtil,
2021
assertSafeArgPath as assertSafeArgPathUtil,
@@ -161,6 +162,28 @@ export class MainPanel {
161162
return resolveGraphColors(raw);
162163
}
163164

165+
// Resolve commit-message link rules from settings + the origin remote URL.
166+
private async getCommitLinkRules(): Promise<LinkRule[]> {
167+
const cfg = vscode.workspace.getConfiguration('gitGraphPlus');
168+
const custom = cfg.get('commitMessageLinks');
169+
const autoDetect = cfg.get<boolean>('autoDetectRepoLinks', true);
170+
let remoteUrl: string | null = null;
171+
if (autoDetect) {
172+
try {
173+
remoteUrl = await this.gitService.getRemoteUrl('origin');
174+
} catch {
175+
// No origin (or no remote) — built-in detection simply yields nothing.
176+
remoteUrl = null;
177+
}
178+
}
179+
return resolveCommitLinkRules(custom, autoDetect, remoteUrl);
180+
}
181+
182+
private async postCommitLinkRules(): Promise<void> {
183+
const rules = await this.getCommitLinkRules();
184+
this.post({ type: 'setCommitLinkRules', payload: { rules } });
185+
}
186+
164187
private constructor(
165188
panel: vscode.WebviewPanel,
166189
extensionUri: vscode.Uri,
@@ -202,6 +225,12 @@ export class MainPanel {
202225
if (e.affectsConfiguration('gitGraphPlus.graphColors')) {
203226
this.post({ type: 'setGraphColors', payload: { colors: this.readGraphColors() } });
204227
}
228+
if (
229+
e.affectsConfiguration('gitGraphPlus.commitMessageLinks') ||
230+
e.affectsConfiguration('gitGraphPlus.autoDetectRepoLinks')
231+
) {
232+
void this.postCommitLinkRules();
233+
}
205234
if (e.affectsConfiguration('gitGraphPlus.timeout')) {
206235
this.gitService.setDefaultTimeout(readTimeoutMs());
207236
}
@@ -218,6 +247,7 @@ export class MainPanel {
218247
this.post({ type: 'setDefaults', payload: this.readModalDefaults() });
219248
this.post({ type: 'setBadgeBarThickness', payload: { width: this.readBadgeBarWidth() } });
220249
this.post({ type: 'setGraphColors', payload: { colors: this.readGraphColors() } });
250+
void this.postCommitLinkRules();
221251

222252
this.panel.webview.onDidReceiveMessage(
223253
(message: WebviewMessage) => this.handleMessage(message),
@@ -703,6 +733,13 @@ export class MainPanel {
703733
}
704734
break;
705735
}
736+
case 'openExternalUrl': {
737+
const url = message.payload?.url;
738+
if (typeof url === 'string' && /^https?:\/\//i.test(url)) {
739+
await vscode.env.openExternal(vscode.Uri.parse(url));
740+
}
741+
break;
742+
}
706743
case 'amendCommit': {
707744
await this.gitService.amendCommit(message.payload);
708745
// Optional follow-up: amend rewrites HEAD, so the push force-pushes
@@ -1763,6 +1800,7 @@ export class MainPanel {
17631800
if (shouldRefreshGraph(what)) {
17641801
await this.refreshAll();
17651802
}
1803+
void this.postCommitLinkRules();
17661804

17671805
// Skip the conflict + operation state probe (2 git subprocess spawns)
17681806
// when we already know no operation is in progress: nothing in memory

src/utils/message-bus.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { CommitGraphData, BranchData, DiffData, Commit, WorktreeInfo, CommitSignature } from '../git/types';
22

3+
export interface LinkRule {
4+
pattern: string;
5+
url: string;
6+
}
7+
38
export interface ModalDefaults {
49
push: { force: 'none' | 'with-lease' | 'force'; setUpstream: boolean; allTags: boolean };
510
pull: { rebase: boolean; stash: boolean };
@@ -114,7 +119,8 @@ export type WebviewMessage =
114119
| { type: 'getUncommittedDiff' }
115120
| { type: 'getUncommittedFileDiff'; payload: { file: string; staged: boolean } }
116121
| { type: 'getMultiCommitSections'; payload: { hashes: string[] } }
117-
| { type: 'getAvatar'; payload: { email: string; size: number } };
122+
| { type: 'getAvatar'; payload: { email: string; size: number } }
123+
| { type: 'openExternalUrl'; payload: { url: string } };
118124

119125
// Messages from Extension → Webview
120126
export type ExtensionMessage =
@@ -145,6 +151,7 @@ export type ExtensionMessage =
145151
| { type: 'setDefaults'; payload: ModalDefaults }
146152
| { type: 'setBadgeBarThickness'; payload: { width: number } }
147153
| { type: 'setGraphColors'; payload: { colors: string[] } }
154+
| { type: 'setCommitLinkRules'; payload: { rules: LinkRule[] } }
148155
| { type: 'repoList'; payload: { repos: Array<{ path: string; name: string; type: 'root' | 'submodule' | 'nested' }>; active: string } }
149156
| { type: 'worktreeData'; payload: WorktreeInfo[] }
150157
| { type: 'uncommittedDiffData'; payload: { staged: Array<{ path: string; status: string }>; unstaged: Array<{ path: string; status: string }> } }

webview-ui/src/App.svelte

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ import AmendModal from './components/modals/AmendModal.svelte';
3838
import { modalStore } from './lib/stores/modals.svelte';
3939
import { defaultsStore } from './lib/stores/defaults.svelte';
4040
import { graphColorsStore } from './lib/stores/graph-colors.svelte';
41-
import { avatarStore } from './lib/stores/avatars.svelte';
41+
import { commitLinkRulesStore } from './lib/stores/commit-link-rules.svelte';
42+
import { avatarStore } from './lib/stores/avatars.svelte';
4243
import SetUpstreamModal from './components/modals/SetUpstreamModal.svelte';
4344
import FlowInitModal from './components/modals/FlowInitModal.svelte';
4445
import FlowStartModal from './components/modals/FlowStartModal.svelte';
@@ -110,6 +111,9 @@ import { avatarStore } from './lib/stores/avatars.svelte';
110111
case 'setGraphColors':
111112
graphColorsStore.set(msg.payload.colors);
112113
break;
114+
case 'setCommitLinkRules':
115+
commitLinkRulesStore.set(msg.payload.rules);
116+
break;
113117
case 'avatarData':
114118
avatarStore.receive(msg.payload.email, msg.payload.size, msg.payload.dataUri);
115119
break;

webview-ui/src/components/commit/CommitDetails.svelte

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import CommitHoverCard from '../common/CommitHoverCard.svelte';
1414
import { tooltip } from '../../lib/actions/tooltip';
1515
import { modalStore } from '../../lib/stores/modals.svelte';
16+
import LinkifiedText from '../common/LinkifiedText.svelte';
1617
1718
interface Props {
1819
commit?: Commit;
@@ -698,9 +699,9 @@
698699

699700
<!-- Commit message -->
700701
<div class="message-section">
701-
<div class="message-subject">{commit.subject}</div>
702+
<div class="message-subject"><LinkifiedText text={commit.subject} /></div>
702703
{#if commit.body}
703-
<div class="message-body">{commit.body}</div>
704+
<div class="message-body"><LinkifiedText text={commit.body} /></div>
704705
{/if}
705706
</div>
706707
</div>

0 commit comments

Comments
 (0)