diff --git a/README.md b/README.md
index b63b59c..cfe75df 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,8 @@ In bigger projects with many files it also provides **context**, it gives you a
- Working tree comparison against any chosen branch, tag, or commit
+- Compare GitHub Pull Requests
+
- Switch between tree and list view
- 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
+## Compare GitHub Pull Requests
+
+You can quickly view GitHub PR changes directly in VS Code using the **Compare GitHub Pull Request** command:
+
+1. Click the "..." menu button in the Git Tree Compare view title bar
+2. Select **Compare GitHub Pull Request...**
+3. Enter the GitHub PR URL (e.g., `https://github.com/owner/repo/pull/123`)
+4. Authenticate with GitHub if prompted (uses VS Code's built-in GitHub authentication)
+5. The extension will:
+ - Fetch the PR's head commit
+ - Checkout the PR branch as `pr///`
+ - Compare it against the PR's base branch
+ - Display all changes in the tree view
+
+This feature works with both PRs from the same repository and PRs from forks.
+
## Settings
`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).
diff --git a/package.json b/package.json
index cfd140f..663386b 100644
--- a/package.json
+++ b/package.json
@@ -164,6 +164,12 @@
"title": "Copy Relative Path",
"category": "Git Tree Compare"
},
+ {
+ "command": "gitTreeCompare.compareGitHubPullRequest",
+ "title": "Compare GitHub Pull Request...",
+ "icon": "$(github)",
+ "category": "Git Tree Compare"
+ },
{
"command": "gitTreeCompare.sortByName",
"title": "Sort by Name",
@@ -247,6 +253,11 @@
"when": "view == gitTreeCompare",
"group": "1_state"
},
+ {
+ "command": "gitTreeCompare.compareGitHubPullRequest",
+ "when": "view == gitTreeCompare",
+ "group": "1_state"
+ },
{
"command": "gitTreeCompare.openAllChanges",
"when": "view == gitTreeCompare",
@@ -582,6 +593,7 @@
"webpack-cli": "^4.2.0"
},
"dependencies": {
+ "@octokit/rest": "^22.0.1",
"@vscode/iconv-lite-umd": "0.7.0",
"byline": "^5.0.0",
"file-type": "^7.2.0",
diff --git a/src/extension.ts b/src/extension.ts
index c1293fc..13b4332 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -60,6 +60,11 @@ export function activate(context: ExtensionContext) {
provider!.promptChangeBase();
});
});
+ commands.registerCommand(NAMESPACE + '.compareGitHubPullRequest', () => {
+ runAfterInit(() => {
+ provider!.compareGitHubPullRequest();
+ });
+ });
commands.registerCommand(NAMESPACE + '.refresh', () => {
runAfterInit(() => {
provider!.manualRefresh();
diff --git a/src/gitHelper.ts b/src/gitHelper.ts
index ef7fe96..928e880 100644
--- a/src/gitHelper.ts
+++ b/src/gitHelper.ts
@@ -237,8 +237,13 @@ export async function diffIndex(repo: Repository, ref: string, refreshIndex: boo
return statuses;
}
-export async function hasUncommittedChanges(repo: Repository, path: string): Promise {
- const result = await repo.exec(['status', '-z', path]);
+export async function hasUncommittedChanges(repo: Repository, path: string, ignoreUntracked: boolean = false): Promise {
+ const args = ['status', '-z'];
+ if (ignoreUntracked) {
+ args.push('-uno');
+ }
+ args.push(path);
+ const result = await repo.exec(args);
return result.stdout.trim() !== '';
}
diff --git a/src/treeProvider.ts b/src/treeProvider.ts
index bff888a..fd4cca6 100644
--- a/src/treeProvider.ts
+++ b/src/treeProvider.ts
@@ -5,7 +5,7 @@ import * as fs from 'fs'
import { TreeDataProvider, TreeItem, TreeItemCollapsibleState,
Uri, Disposable, EventEmitter, TextDocumentShowOptions,
QuickPickItem, ProgressLocation, Memento, OutputChannel,
- workspace, commands, window, env, WorkspaceFoldersChangeEvent, TreeView, ThemeIcon, TreeItemCheckboxState, TreeCheckboxChangeEvent } from 'vscode'
+ workspace, commands, window, env, WorkspaceFoldersChangeEvent, TreeView, ThemeIcon, TreeItemCheckboxState, TreeCheckboxChangeEvent, authentication } from 'vscode'
import { NAMESPACE } from './constants'
import { Repository, Git } from './git/git'
import { Ref, RefType } from './git/api/git'
@@ -16,6 +16,8 @@ import { getDefaultBranch, getHeadModificationDate, getBranchCommit,
import { debounce, throttle } from './git/decorators'
import { normalizePath } from './fsUtils';
import { API as GitAPI, Repository as GitAPIRepository } from './typings/git';
+import { Octokit } from '@octokit/rest';
+
type SortOrder = 'name' | 'path' | 'status' | 'recentlyModified';
@@ -1254,6 +1256,193 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos
});
}
+ async compareGitHubPullRequest() {
+ if (!this.repository) {
+ window.showErrorMessage('No repository selected');
+ return;
+ }
+
+ const repository = this.repository;
+
+ // Check for uncommitted changes (ignoring untracked files)
+ try {
+ if (await hasUncommittedChanges(repository, repository.root, true)) {
+ window.showErrorMessage(
+ 'Please commit your changes or stash them before continuing.',
+ { modal: true }
+ );
+ return;
+ }
+ } catch (e: any) {
+ this.log('Error checking for uncommitted changes', e);
+ // Continue anyway
+ }
+
+ // Prompt for PR URL
+ const prUrl = await window.showInputBox({
+ prompt: 'Enter GitHub Pull Request URL',
+ placeHolder: 'https://github.com/owner/repo/pull/123',
+ validateInput: (value: string) => {
+ const match = value.match(/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/);
+ if (!match) {
+ return 'Invalid GitHub PR URL. Expected format: https://github.com/owner/repo/pull/123';
+ }
+ return null;
+ }
+ });
+
+ if (!prUrl) {
+ return;
+ }
+
+ // Parse the PR URL
+ const match = prUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\/pull\/(\d+)/);
+ if (!match) {
+ window.showErrorMessage('Invalid GitHub PR URL format');
+ return;
+ }
+
+ const [, owner, repo, prNumberStr] = match;
+ const prNumber = parseInt(prNumberStr, 10);
+
+ await window.withProgress({
+ location: ProgressLocation.Notification,
+ title: `Fetching PR #${prNumber} from ${owner}/${repo}`,
+ cancellable: false
+ }, async () => {
+ try {
+ // Authenticate with GitHub
+ const session = await authentication.getSession('github', ['repo'], { createIfNone: true });
+ const octokit = new Octokit({ auth: session.accessToken });
+
+ // Fetch PR details
+ this.log(`Fetching PR details for ${owner}/${repo}#${prNumber}`);
+ const { data: pr } = await octokit.pulls.get({
+ owner,
+ repo,
+ pull_number: prNumber
+ });
+
+ // Extract base and head information
+ const baseRef = pr.base.ref;
+ const headRef = pr.head.ref;
+ const headSha = pr.head.sha;
+
+ this.log(`PR #${prNumber}: base=${baseRef}, head=${headRef}, sha=${headSha}`);
+
+ // Fetch the PR branch if it's from a fork
+ const headRepo = pr.head.repo;
+ if (!headRepo) {
+ window.showErrorMessage('Cannot access PR head repository. It may have been deleted.');
+ return;
+ }
+
+ const headRepoUrl = headRepo.clone_url;
+ const isFork = headRepo.full_name !== pr.base.repo.full_name;
+
+ // Extract head owner for branch naming
+ const headOwner = pr.head.user?.login || pr.head.repo?.owner.login;
+ if (!headOwner) {
+ window.showErrorMessage('Could not determine PR head owner.');
+ return;
+ }
+
+ // Create a local branch name for the PR with owner and ref name
+ const localBranchName = `pr/${prNumber}/${headOwner}/${headRef}`;
+
+ // Fetch and create/update local branch for the PR
+ try {
+ if (isFork) {
+ // For forks, add a remote with pr-fork- prefix
+ const forkRemoteName = `pr-fork-${headOwner}`;
+
+ this.log(`Fetching PR #${prNumber} from fork owned by ${headOwner}: ${headRepoUrl}`);
+
+ // Check if remote already exists, if not add it
+ try {
+ const existingUrl = (await repository.exec(['remote', 'get-url', forkRemoteName])).stdout.trim();
+ // Update URL if it's different
+ if (existingUrl !== headRepoUrl) {
+ await repository.exec(['remote', 'set-url', forkRemoteName, headRepoUrl]);
+ this.log(`Updated remote ${forkRemoteName} URL to ${headRepoUrl}`);
+ }
+ } catch {
+ await repository.exec(['remote', 'add', forkRemoteName, headRepoUrl]);
+ this.log(`Added remote ${forkRemoteName}`);
+ }
+
+ // Fetch the head ref from the fork
+ await repository.fetch({ remote: forkRemoteName, ref: headRef });
+
+ // Create/update local branch pointing to the fetched commit
+ try {
+ // Try to create new branch
+ await repository.exec(['branch', localBranchName, headSha]);
+ } catch {
+ // Branch exists, force update it
+ await repository.exec(['branch', '-f', localBranchName, headSha]);
+ }
+
+ // Set upstream to the fork remote
+ await repository.exec(['branch', '--set-upstream-to', `${forkRemoteName}/${headRef}`, localBranchName]);
+
+ this.log(`Created local branch ${localBranchName} tracking ${forkRemoteName}/${headRef}`);
+ } else {
+ // For same repo, use GitHub's pull//head refspec
+ this.log(`Fetching PR #${prNumber} from origin`);
+ await repository.exec(['fetch', 'origin', `pull/${prNumber}/head:${localBranchName}`]);
+
+ // Set upstream to origin/ if the branch exists there
+ try {
+ // Fetch the actual head ref to update the remote tracking branch
+ await repository.fetch({ remote: 'origin', ref: headRef });
+ await repository.exec(['branch', '--set-upstream-to', `origin/${headRef}`, localBranchName]);
+ this.log(`Created local branch ${localBranchName} tracking origin/${headRef}`);
+ } catch {
+ this.log(`Created local branch ${localBranchName} (no upstream - origin/${headRef} not found)`);
+ }
+ }
+ } catch (e: any) {
+ let msg = 'Failed to fetch and create PR branch';
+ this.log(msg, e);
+ window.showErrorMessage(`${msg}: ${e.message}`);
+ return;
+ }
+
+ // Checkout the local PR branch
+ try {
+ this.log(`Checking out branch: ${localBranchName}`);
+ await repository.checkout(localBranchName, []);
+ } catch (e: any) {
+ let msg = 'Failed to checkout PR branch';
+ this.log(msg, e);
+ window.showErrorMessage(`${msg}: ${e.message}`);
+ return;
+ }
+
+ // Update the comparison base to the PR base branch (use origin/* to avoid stale refs)
+ try {
+ const originBaseRef = `origin/${baseRef}`;
+ this.log(`Updating base to: ${originBaseRef}`);
+ await this.updateRefs(originBaseRef);
+ await this.updateDiff(false);
+ this.log('Refreshing tree');
+ this._onDidChangeTreeData.fire();
+ window.showInformationMessage(`Now comparing PR #${prNumber}: ${pr.title}`);
+ } catch (e: any) {
+ let msg = 'Failed to update comparison base';
+ this.log(msg, e);
+ window.showErrorMessage(`${msg}: ${e.message}`);
+ return;
+ }
+ } catch (e: any) {
+ let msg = 'Failed to fetch GitHub PR';
+ this.log(msg, e);
+ window.showErrorMessage(`${msg}: ${e.message || e}`);
+ }
+ });
+ }
+
async manualRefresh() {
window.withProgress({ location: ProgressLocation.Window, title: 'Updating Tree' }, async _ => {
try {