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 Moving of Git Tree Compare view between containers +## 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 {