diff --git a/README.md b/README.md index dfe48d6..d470528 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,20 @@ In bigger projects with many files it also provides **context**, it gives you a - Search within changed files +- View diffs for linked git worktrees via **Change Worktree...**, switch back via **Switch to Working Tree** + ## Location By default, the tree view is located in its own container accessible from the activity bar on the left. However, it can be freely moved to any other location like Source Control or Explorer by dragging and dropping. Moving of Git Tree Compare view between containers +## Git worktrees + +If you use [git worktrees](https://git-scm.com/docs/git-worktree), you can switch the tree view to another linked worktree with **Change Worktree...** from the view title bar. To return to your workspace checkout, use **Switch to Working Tree**. It appears as a dedicated button whenever you're viewing a different worktree, and as the first option in the **Change Worktree...** menu. + +Worktrees must be within your workspace folder (or open the worktree folder as your workspace) for the extension to display them. + ## Compare GitHub Pull Requests You can quickly view GitHub PR changes directly in VS Code using the **Compare GitHub Pull Request** command: diff --git a/package.json b/package.json index 685e03e..2d11c0e 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,18 @@ "icon": "$(repo)", "category": "Git Tree Compare" }, + { + "command": "gitTreeCompare.changeWorktree", + "title": "Change Worktree...", + "icon": "$(git-branch)", + "category": "Git Tree Compare" + }, + { + "command": "gitTreeCompare.switchToWorkingTree", + "title": "Switch to Working Tree", + "icon": "$(home)", + "category": "Git Tree Compare" + }, { "command": "gitTreeCompare.openChanges", "title": "Open Changes", @@ -262,6 +274,16 @@ } ], "view/title": [ + { + "command": "gitTreeCompare.switchToWorkingTree", + "when": "view == gitTreeCompare && gitTreeCompare.viewingWorktree", + "group": "navigation@1" + }, + { + "command": "gitTreeCompare.changeWorktree", + "when": "view == gitTreeCompare && gitTreeCompare.hasWorktrees", + "group": "1_state" + }, { "command": "gitTreeCompare.changeRepository", "when": "view == gitTreeCompare && gitOpenRepositoryCount != 1", diff --git a/src/extension.ts b/src/extension.ts index 0a5de5c..79e0ee9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -55,6 +55,16 @@ export function activate(context: ExtensionContext) { provider!.promptChangeRepository(); }); }); + commands.registerCommand(NAMESPACE + '.changeWorktree', () => { + runAfterInit(() => { + provider!.promptChangeWorktree(); + }); + }); + commands.registerCommand(NAMESPACE + '.switchToWorkingTree', () => { + runAfterInit(() => { + provider!.switchToWorkingTree(); + }); + }); commands.registerCommand(NAMESPACE + '.changeBase', () => { runAfterInit(() => { provider!.promptChangeBase(); @@ -141,6 +151,8 @@ export function activate(context: ExtensionContext) { commands.executeCommand('setContext', NAMESPACE + '.viewAsList', false); commands.executeCommand('setContext', NAMESPACE + '.hideCheckedFiles', false); commands.executeCommand('setContext', NAMESPACE + '.isFiltered', false); + commands.executeCommand('setContext', NAMESPACE + '.viewingWorktree', false); + commands.executeCommand('setContext', NAMESPACE + '.hasWorktrees', false); provider = new GitTreeCompareProvider(git, gitApi, outputChannel, context.globalState, context.asAbsolutePath); diff --git a/src/gitHelper.ts b/src/gitHelper.ts index 86ce508..13ccb2e 100644 --- a/src/gitHelper.ts +++ b/src/gitHelper.ts @@ -59,6 +59,46 @@ export async function getAbsGitCommonDir(repo: Repository): Promise { return dir; } +export interface IWorktreeInfo { + path: string; + head: string; + branch: string | undefined; +} + +export async function listWorktrees(repo: Repository): Promise { + const result = await repo.exec(['worktree', 'list', '--porcelain']); + const worktrees: IWorktreeInfo[] = []; + let currentPath: string | undefined; + let currentHead: string | undefined; + let currentBranch: string | undefined; + + const flush = () => { + if (currentPath && currentHead) { + worktrees.push({ + path: normalizePath(currentPath), + head: currentHead, + branch: currentBranch, + }); + } + currentPath = undefined; + currentHead = undefined; + currentBranch = undefined; + }; + + for (const line of result.stdout.split('\n')) { + if (line.startsWith('worktree ')) { + flush(); + currentPath = line.slice('worktree '.length); + } else if (line.startsWith('HEAD ')) { + currentHead = line.slice('HEAD '.length); + } else if (line.startsWith('branch refs/heads/')) { + currentBranch = line.slice('branch refs/heads/'.length); + } + } + flush(); + return worktrees; +} + export async function getDefaultBranch(repo: Repository, head: Ref): Promise { // determine which remote HEAD is tracking let remote: string diff --git a/src/treeProvider.ts b/src/treeProvider.ts index 53fbc38..a98d3f0 100644 --- a/src/treeProvider.ts +++ b/src/treeProvider.ts @@ -12,7 +12,8 @@ import { Ref, RefType } from './git/api/git' import { anyEvent, filterEvent, eventToPromise } from './git/util' import { getDefaultBranch, getHeadModificationDate, getBranchCommit, diffIndex, IDiffStatus, IDiffStats, StatusCode, getAbsGitDir, - getWorkspaceFolders, getGitRepositoryFolders, hasUncommittedChanges, rmFile } from './gitHelper' + getWorkspaceFolders, getGitRepositoryFolders, hasUncommittedChanges, rmFile, + listWorktrees, IWorktreeInfo } from './gitHelper' import { tryDeepenForMergeBase } from './deepenHelper' import { debounce, throttle } from './git/decorators' import { normalizePath } from './fsUtils'; @@ -100,13 +101,38 @@ class ChangeBaseCommitItem implements QuickPickItem { get description(): string { return ""; } } -class ChangeRepositoryItem implements QuickPickItem { +interface RepositoryPickItem extends QuickPickItem { + repositoryPath: string; +} + +class ChangeRepositoryItem implements RepositoryPickItem { constructor(public repositoryRoot: string) { } + get repositoryPath(): string { return normalizePath(this.repositoryRoot); } get label(): string { return path.basename(this.repositoryRoot); } get description(): string { return this.repositoryRoot; } } +class WorkingTreePickItem implements RepositoryPickItem { + constructor(public repositoryPath: string) { } + + get label(): string { return '$(home) Working Tree'; } + get description(): string { return this.repositoryPath; } +} + +class ChangeWorktreeItem implements RepositoryPickItem { + constructor(public worktree: IWorktreeInfo) { } + + get repositoryPath(): string { return this.worktree.path; } + get label(): string { + if (this.worktree.branch) { + return `$(git-branch) ${this.worktree.branch}`; + } + return `$(git-commit) ${this.worktree.head.substr(0, 8)}`; + } + get description(): string { return this.worktree.path; } +} + type FolderAbsPath = string; export class GitTreeCompareProvider implements TreeDataProvider, Disposable { @@ -238,26 +264,45 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos const workspaceFolders = getWorkspaceFolders(repoRoot); if (workspaceFolders.length == 0) { - throw new Error(`Could not find any workspace folder for ${repositoryRoot}`); + const worktrees = this.repository ? await listWorktrees(this.repository) : []; + const isLinkedWorktree = worktrees.some(wt => wt.path === repoRoot); + if (!isLinkedWorktree) { + throw new Error(`Could not find any workspace folder for ${repositoryRoot}`); + } + this.workspaceFolder = repoRoot; + } else { + // Sort descending by folder depth + workspaceFolders.sort((a, b) => { + const aDepth = a.uri.fsPath.split(path.sep).length; + const bDepth = b.uri.fsPath.split(path.sep).length; + return bDepth - aDepth; + }); + // If repo appears in multiple workspace folders, pick the deepest one. + // TODO let the user choose which one + this.workspaceFolder = normalizePath(workspaceFolders[0].uri.fsPath); } this.repository = repository; this.absGitDir = absGitDir; this.repoRoot = repoRoot; - - // Sort descending by folder depth - workspaceFolders.sort((a, b) => { - const aDepth = a.uri.fsPath.split(path.sep).length; - const bDepth = b.uri.fsPath.split(path.sep).length; - return bDepth - aDepth; - }); - // If repo appears in multiple workspace folders, pick the deepest one. - // TODO let the user choose which one - this.workspaceFolder = normalizePath(workspaceFolders[0].uri.fsPath); this.updateTreeRootFolder(); this.log('Using repository: ' + this.repoRoot); this.updateTreeTitle(); + this.updateWorktreeContext(); + } + + private updateWorktreeContext() { + const workspaceRoot = this.getWorkspaceRepositoryRoot(); + const viewingWorktree = workspaceRoot !== undefined && this.repoRoot !== workspaceRoot; + commands.executeCommand('setContext', NAMESPACE + '.viewingWorktree', viewingWorktree); + if (this.repository) { + listWorktrees(this.repository).then(worktrees => { + commands.executeCommand('setContext', NAMESPACE + '.hasWorktrees', worktrees.length > 1); + }); + } else { + commands.executeCommand('setContext', NAMESPACE + '.hasWorktrees', false); + } } private updateTreeTitle() { @@ -298,6 +343,26 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos this.fireTreeDataChange(); } + private getWorkspaceRepositoryRoot(): string | undefined { + const repos = getGitRepositoryFolders(this.gitApi, true); + if (repos.length > 0) { + return normalizePath(repos[0]); + } + return undefined; + } + + async switchToWorkingTree() { + const workspaceRoot = this.getWorkspaceRepositoryRoot(); + if (!workspaceRoot) { + window.showErrorMessage('No workspace repository found'); + return; + } + if (workspaceRoot === this.repoRoot) { + return; + } + await this.changeRepository(workspaceRoot); + } + async promptChangeRepository() { const gitRepos = getGitRepositoryFolders(this.gitApi); const gitReposWithoutCurrent = gitRepos.filter(w => this.repoRoot !== w); @@ -312,6 +377,36 @@ export class GitTreeCompareProvider implements TreeDataProvider, Dispos await this.changeRepository(choice.repositoryRoot); } + async promptChangeWorktree() { + if (!this.repository) { + window.showErrorMessage('No repository selected'); + return; + } + + const workspaceRoot = this.getWorkspaceRepositoryRoot(); + const worktrees = await listWorktrees(this.repository); + let picks: RepositoryPickItem[] = worktrees + .filter(wt => wt.path !== this.repoRoot && wt.path !== workspaceRoot) + .map(wt => new ChangeWorktreeItem(wt)); + + picks.sort((a, b) => a.label.localeCompare(b.label)); + + if (workspaceRoot && workspaceRoot !== this.repoRoot) { + picks = [new WorkingTreePickItem(workspaceRoot), ...picks]; + } + + if (picks.length === 0) { + window.showInformationMessage('No other worktrees available'); + return; + } + + const choice = await window.showQuickPick(picks, { placeHolder: 'Select a worktree' }); + if (!choice) { + return; + } + await this.changeRepository(choice.repositoryPath); + } + private async handleRepositoryOpened(repository: GitAPIRepository) { if (this.repository === undefined) { await this.changeRepository(repository.rootUri.fsPath);