Skip to content

Commit f350fb2

Browse files
Copilotletmaik
andauthored
Add GitHub PR comparison command (#130)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: letmaik <530988+letmaik@users.noreply.github.com> Co-authored-by: Maik Riechert <letmaik@outlook.com>
1 parent 41a4393 commit f350fb2

5 files changed

Lines changed: 232 additions & 3 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ In bigger projects with many files it also provides **context**, it gives you a
1212

1313
- Working tree comparison against any chosen branch, tag, or commit
1414

15+
- Compare GitHub Pull Requests
16+
1517
- Switch between tree and list view
1618

1719
- Compare in merge or full mode
@@ -34,6 +36,22 @@ By default, the tree view is located in its own container accessible from the ac
3436

3537
<img src="screenshots/move-view.gif" alt="Moving of Git Tree Compare view between containers" width="256" />
3638

39+
## Compare GitHub Pull Requests
40+
41+
You can quickly view GitHub PR changes directly in VS Code using the **Compare GitHub Pull Request** command:
42+
43+
1. Click the "..." menu button in the Git Tree Compare view title bar
44+
2. Select **Compare GitHub Pull Request...**
45+
3. Enter the GitHub PR URL (e.g., `https://github.com/owner/repo/pull/123`)
46+
4. Authenticate with GitHub if prompted (uses VS Code's built-in GitHub authentication)
47+
5. The extension will:
48+
- Fetch the PR's head commit
49+
- Checkout the PR branch as `pr/<number>/<headOwner>/<headRefName>`
50+
- Compare it against the PR's base branch
51+
- Display all changes in the tree view
52+
53+
This feature works with both PRs from the same repository and PRs from forks.
54+
3755
## Settings
3856

3957
`gitTreeCompare.diffMode` Determines how the comparison is performed, either by computing a merge base commit first and then comparing against that (equivalent to pull request diffs, default), or by comparing directly to the given base (useful to see the exact diff).

package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@
164164
"title": "Copy Relative Path",
165165
"category": "Git Tree Compare"
166166
},
167+
{
168+
"command": "gitTreeCompare.compareGitHubPullRequest",
169+
"title": "Compare GitHub Pull Request...",
170+
"icon": "$(github)",
171+
"category": "Git Tree Compare"
172+
},
167173
{
168174
"command": "gitTreeCompare.sortByName",
169175
"title": "Sort by Name",
@@ -247,6 +253,11 @@
247253
"when": "view == gitTreeCompare",
248254
"group": "1_state"
249255
},
256+
{
257+
"command": "gitTreeCompare.compareGitHubPullRequest",
258+
"when": "view == gitTreeCompare",
259+
"group": "1_state"
260+
},
250261
{
251262
"command": "gitTreeCompare.openAllChanges",
252263
"when": "view == gitTreeCompare",
@@ -582,6 +593,7 @@
582593
"webpack-cli": "^4.2.0"
583594
},
584595
"dependencies": {
596+
"@octokit/rest": "^22.0.1",
585597
"@vscode/iconv-lite-umd": "0.7.0",
586598
"byline": "^5.0.0",
587599
"file-type": "^7.2.0",

src/extension.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ export function activate(context: ExtensionContext) {
6060
provider!.promptChangeBase();
6161
});
6262
});
63+
commands.registerCommand(NAMESPACE + '.compareGitHubPullRequest', () => {
64+
runAfterInit(() => {
65+
provider!.compareGitHubPullRequest();
66+
});
67+
});
6368
commands.registerCommand(NAMESPACE + '.refresh', () => {
6469
runAfterInit(() => {
6570
provider!.manualRefresh();

src/gitHelper.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,8 +237,13 @@ export async function diffIndex(repo: Repository, ref: string, refreshIndex: boo
237237
return statuses;
238238
}
239239

240-
export async function hasUncommittedChanges(repo: Repository, path: string): Promise<boolean> {
241-
const result = await repo.exec(['status', '-z', path]);
240+
export async function hasUncommittedChanges(repo: Repository, path: string, ignoreUntracked: boolean = false): Promise<boolean> {
241+
const args = ['status', '-z'];
242+
if (ignoreUntracked) {
243+
args.push('-uno');
244+
}
245+
args.push(path);
246+
const result = await repo.exec(args);
242247
return result.stdout.trim() !== '';
243248
}
244249

src/treeProvider.ts

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as fs from 'fs'
55
import { TreeDataProvider, TreeItem, TreeItemCollapsibleState,
66
Uri, Disposable, EventEmitter, TextDocumentShowOptions,
77
QuickPickItem, ProgressLocation, Memento, OutputChannel,
8-
workspace, commands, window, env, WorkspaceFoldersChangeEvent, TreeView, ThemeIcon, TreeItemCheckboxState, TreeCheckboxChangeEvent } from 'vscode'
8+
workspace, commands, window, env, WorkspaceFoldersChangeEvent, TreeView, ThemeIcon, TreeItemCheckboxState, TreeCheckboxChangeEvent, authentication } from 'vscode'
99
import { NAMESPACE } from './constants'
1010
import { Repository, Git } from './git/git'
1111
import { Ref, RefType } from './git/api/git'
@@ -16,6 +16,8 @@ import { getDefaultBranch, getHeadModificationDate, getBranchCommit,
1616
import { debounce, throttle } from './git/decorators'
1717
import { normalizePath } from './fsUtils';
1818
import { API as GitAPI, Repository as GitAPIRepository } from './typings/git';
19+
import { Octokit } from '@octokit/rest';
20+
1921

2022
type SortOrder = 'name' | 'path' | 'status' | 'recentlyModified';
2123

@@ -1254,6 +1256,193 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, Dispos
12541256
});
12551257
}
12561258

1259+
async compareGitHubPullRequest() {
1260+
if (!this.repository) {
1261+
window.showErrorMessage('No repository selected');
1262+
return;
1263+
}
1264+
1265+
const repository = this.repository;
1266+
1267+
// Check for uncommitted changes (ignoring untracked files)
1268+
try {
1269+
if (await hasUncommittedChanges(repository, repository.root, true)) {
1270+
window.showErrorMessage(
1271+
'Please commit your changes or stash them before continuing.',
1272+
{ modal: true }
1273+
);
1274+
return;
1275+
}
1276+
} catch (e: any) {
1277+
this.log('Error checking for uncommitted changes', e);
1278+
// Continue anyway
1279+
}
1280+
1281+
// Prompt for PR URL
1282+
const prUrl = await window.showInputBox({
1283+
prompt: 'Enter GitHub Pull Request URL',
1284+
placeHolder: 'https://github.com/owner/repo/pull/123',
1285+
validateInput: (value: string) => {
1286+
const match = value.match(/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/);
1287+
if (!match) {
1288+
return 'Invalid GitHub PR URL. Expected format: https://github.com/owner/repo/pull/123';
1289+
}
1290+
return null;
1291+
}
1292+
});
1293+
1294+
if (!prUrl) {
1295+
return;
1296+
}
1297+
1298+
// Parse the PR URL
1299+
const match = prUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/);
1300+
if (!match) {
1301+
window.showErrorMessage('Invalid GitHub PR URL format');
1302+
return;
1303+
}
1304+
1305+
const [, owner, repo, prNumberStr] = match;
1306+
const prNumber = parseInt(prNumberStr, 10);
1307+
1308+
await window.withProgress({
1309+
location: ProgressLocation.Notification,
1310+
title: `Fetching PR #${prNumber} from ${owner}/${repo}`,
1311+
cancellable: false
1312+
}, async () => {
1313+
try {
1314+
// Authenticate with GitHub
1315+
const session = await authentication.getSession('github', ['repo'], { createIfNone: true });
1316+
const octokit = new Octokit({ auth: session.accessToken });
1317+
1318+
// Fetch PR details
1319+
this.log(`Fetching PR details for ${owner}/${repo}#${prNumber}`);
1320+
const { data: pr } = await octokit.pulls.get({
1321+
owner,
1322+
repo,
1323+
pull_number: prNumber
1324+
});
1325+
1326+
// Extract base and head information
1327+
const baseRef = pr.base.ref;
1328+
const headRef = pr.head.ref;
1329+
const headSha = pr.head.sha;
1330+
1331+
this.log(`PR #${prNumber}: base=${baseRef}, head=${headRef}, sha=${headSha}`);
1332+
1333+
// Fetch the PR branch if it's from a fork
1334+
const headRepo = pr.head.repo;
1335+
if (!headRepo) {
1336+
window.showErrorMessage('Cannot access PR head repository. It may have been deleted.');
1337+
return;
1338+
}
1339+
1340+
const headRepoUrl = headRepo.clone_url;
1341+
const isFork = headRepo.full_name !== pr.base.repo.full_name;
1342+
1343+
// Extract head owner for branch naming
1344+
const headOwner = pr.head.user?.login || pr.head.repo?.owner.login;
1345+
if (!headOwner) {
1346+
window.showErrorMessage('Could not determine PR head owner.');
1347+
return;
1348+
}
1349+
1350+
// Create a local branch name for the PR with owner and ref name
1351+
const localBranchName = `pr/${prNumber}/${headOwner}/${headRef}`;
1352+
1353+
// Fetch and create/update local branch for the PR
1354+
try {
1355+
if (isFork) {
1356+
// For forks, add a remote with pr-fork- prefix
1357+
const forkRemoteName = `pr-fork-${headOwner}`;
1358+
1359+
this.log(`Fetching PR #${prNumber} from fork owned by ${headOwner}: ${headRepoUrl}`);
1360+
1361+
// Check if remote already exists, if not add it
1362+
try {
1363+
const existingUrl = (await repository.exec(['remote', 'get-url', forkRemoteName])).stdout.trim();
1364+
// Update URL if it's different
1365+
if (existingUrl !== headRepoUrl) {
1366+
await repository.exec(['remote', 'set-url', forkRemoteName, headRepoUrl]);
1367+
this.log(`Updated remote ${forkRemoteName} URL to ${headRepoUrl}`);
1368+
}
1369+
} catch {
1370+
await repository.exec(['remote', 'add', forkRemoteName, headRepoUrl]);
1371+
this.log(`Added remote ${forkRemoteName}`);
1372+
}
1373+
1374+
// Fetch the head ref from the fork
1375+
await repository.fetch({ remote: forkRemoteName, ref: headRef });
1376+
1377+
// Create/update local branch pointing to the fetched commit
1378+
try {
1379+
// Try to create new branch
1380+
await repository.exec(['branch', localBranchName, headSha]);
1381+
} catch {
1382+
// Branch exists, force update it
1383+
await repository.exec(['branch', '-f', localBranchName, headSha]);
1384+
}
1385+
1386+
// Set upstream to the fork remote
1387+
await repository.exec(['branch', '--set-upstream-to', `${forkRemoteName}/${headRef}`, localBranchName]);
1388+
1389+
this.log(`Created local branch ${localBranchName} tracking ${forkRemoteName}/${headRef}`);
1390+
} else {
1391+
// For same repo, use GitHub's pull/<id>/head refspec
1392+
this.log(`Fetching PR #${prNumber} from origin`);
1393+
await repository.exec(['fetch', 'origin', `pull/${prNumber}/head:${localBranchName}`]);
1394+
1395+
// Set upstream to origin/<headRef> if the branch exists there
1396+
try {
1397+
// Fetch the actual head ref to update the remote tracking branch
1398+
await repository.fetch({ remote: 'origin', ref: headRef });
1399+
await repository.exec(['branch', '--set-upstream-to', `origin/${headRef}`, localBranchName]);
1400+
this.log(`Created local branch ${localBranchName} tracking origin/${headRef}`);
1401+
} catch {
1402+
this.log(`Created local branch ${localBranchName} (no upstream - origin/${headRef} not found)`);
1403+
}
1404+
}
1405+
} catch (e: any) {
1406+
let msg = 'Failed to fetch and create PR branch';
1407+
this.log(msg, e);
1408+
window.showErrorMessage(`${msg}: ${e.message}`);
1409+
return;
1410+
}
1411+
1412+
// Checkout the local PR branch
1413+
try {
1414+
this.log(`Checking out branch: ${localBranchName}`);
1415+
await repository.checkout(localBranchName, []);
1416+
} catch (e: any) {
1417+
let msg = 'Failed to checkout PR branch';
1418+
this.log(msg, e);
1419+
window.showErrorMessage(`${msg}: ${e.message}`);
1420+
return;
1421+
}
1422+
1423+
// Update the comparison base to the PR base branch (use origin/* to avoid stale refs)
1424+
try {
1425+
const originBaseRef = `origin/${baseRef}`;
1426+
this.log(`Updating base to: ${originBaseRef}`);
1427+
await this.updateRefs(originBaseRef);
1428+
await this.updateDiff(false);
1429+
this.log('Refreshing tree');
1430+
this._onDidChangeTreeData.fire();
1431+
window.showInformationMessage(`Now comparing PR #${prNumber}: ${pr.title}`);
1432+
} catch (e: any) {
1433+
let msg = 'Failed to update comparison base';
1434+
this.log(msg, e);
1435+
window.showErrorMessage(`${msg}: ${e.message}`);
1436+
return;
1437+
}
1438+
} catch (e: any) {
1439+
let msg = 'Failed to fetch GitHub PR';
1440+
this.log(msg, e);
1441+
window.showErrorMessage(`${msg}: ${e.message || e}`);
1442+
}
1443+
});
1444+
}
1445+
12571446
async manualRefresh() {
12581447
window.withProgress({ location: ProgressLocation.Window, title: 'Updating Tree' }, async _ => {
12591448
try {

0 commit comments

Comments
 (0)