Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

## 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:
Expand Down
22 changes: 22 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand Down
40 changes: 40 additions & 0 deletions src/gitHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,46 @@ export async function getAbsGitCommonDir(repo: Repository): Promise<string> {
return dir;
}

export interface IWorktreeInfo {
path: string;
head: string;
branch: string | undefined;
}

export async function listWorktrees(repo: Repository): Promise<IWorktreeInfo[]> {
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<string | undefined> {
// determine which remote HEAD is tracking
let remote: string
Expand Down
121 changes: 108 additions & 13 deletions src/treeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Element>, Disposable {
Expand Down Expand Up @@ -238,26 +264,45 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, 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() {
Expand Down Expand Up @@ -298,6 +343,26 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, 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);
Expand All @@ -312,6 +377,36 @@ export class GitTreeCompareProvider implements TreeDataProvider<Element>, 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);
Expand Down
Loading