diff --git a/images/views/worktrees.svg b/images/views/worktrees.svg
new file mode 100644
index 0000000..5536776
--- /dev/null
+++ b/images/views/worktrees.svg
@@ -0,0 +1,3 @@
+
diff --git a/package.json b/package.json
index 279e23a..d4ce411 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,7 @@
"onView:gitlens.views.tags",
"onView:gitlens.views.contributors",
"onView:gitlens.views.searchAndCompare",
+ "onView:gitlens.views.worktrees",
"onWebviewPanel:gitlens.welcome",
"onWebviewPanel:gitlens.settings",
"onCommand:gitlens.premium.login",
@@ -82,6 +83,7 @@
"onCommand:gitlens.showSettingsPage#search-compare-view",
"onCommand:gitlens.showSettingsPage#stashes-view",
"onCommand:gitlens.showSettingsPage#tags-view",
+ "onCommand:gitlens.showSettingsPage#worktrees-view",
"onCommand:gitlens.showWelcomePage",
"onCommand:gitlens.showBranchesView",
"onCommand:gitlens.showCommitsView",
@@ -93,6 +95,7 @@
"onCommand:gitlens.showSearchAndCompareView",
"onCommand:gitlens.showStashesView",
"onCommand:gitlens.showTagsView",
+ "onCommand:gitlens.showWorktreesView",
"onCommand:gitlens.showWelcomeView",
"onCommand:gitlens.closeWelcomeView",
"onCommand:gitlens.compareWith",
@@ -1018,12 +1021,19 @@
"scope": "window",
"order": 35
},
+ "gitlens.views.repositories.showWorktrees": {
+ "type": "boolean",
+ "default": true,
+ "markdownDescription": "Specifies whether to show the worktrees for each repository in the _Repositories_ view",
+ "scope": "window",
+ "order": 36
+ },
"gitlens.views.repositories.showIncomingActivity": {
"type": "boolean",
"default": false,
"markdownDescription": "Specifies whether to show the experimental incoming activity for each repository in the _Repositories_ view",
"scope": "window",
- "order": 36
+ "order": 37
},
"gitlens.views.repositories.autoRefresh": {
"type": "boolean",
@@ -1545,9 +1555,81 @@
}
},
{
+ "id": "worktrees-view",
+ "title": "Worktrees View",
+ "order": 29,
+ "properties": {
+ "gitlens.worktrees.defaultLocation": {
+ "type": "string",
+ "default": null,
+ "markdownDescription": "Specifies the default path in which new worktrees will be created",
+ "scope": "resource"
+ },
+ "gitlens.worktrees.promptForLocation": {
+ "type": "boolean",
+ "default": false,
+ "markdownDescription": "Specifies whether to prompt for a path when creating new worktrees",
+ "scope": "resource"
+ },
+ "gitlens.views.worktrees.pullRequests.enabled": {
+ "type": "boolean",
+ "default": true,
+ "markdownDescription": "Specifies whether to query for pull requests associated with branches and commits in the _Worktrees_ view. Requires a connection to a supported remote service (e.g. GitHub)",
+ "scope": "window"
+ },
+ "gitlens.views.worktrees.pullRequests.showForCommits": {
+ "type": "boolean",
+ "default": true,
+ "markdownDescription": "Specifies whether to show pull requests (if any) associated with commits in the _Worktrees_ view. Requires a connection to a supported remote service (e.g. GitHub)",
+ "scope": "window"
+ },
+ "gitlens.views.worktrees.files.layout": {
+ "type": "string",
+ "default": "auto",
+ "enum": [
+ "auto",
+ "list",
+ "tree"
+ ],
+ "enumDescriptions": [
+ "Automatically switches between displaying files as a `tree` or `list` based on the `#gitlens.views.worktrees.files.threshold#` value and the number of files at each nesting level",
+ "Displays files as a list",
+ "Displays files as a tree"
+ ],
+ "markdownDescription": "Specifies how the _Worktrees_ view will display files",
+ "scope": "window"
+ },
+ "gitlens.views.worktrees.files.threshold": {
+ "type": "number",
+ "default": 5,
+ "markdownDescription": "Specifies when to switch between displaying files as a `tree` or `list` based on the number of files in a nesting level in the _Worktrees_ view. Only applies when `#gitlens.views.worktrees.files.layout#` is set to `auto`",
+ "scope": "window"
+ },
+ "gitlens.views.worktrees.files.compact": {
+ "type": "boolean",
+ "default": true,
+ "markdownDescription": "Specifies whether to compact (flatten) unnecessary file nesting in the _Worktrees_ view. Only applies when `#gitlens.views.worktrees.files.layout#` is set to `tree` or `auto`",
+ "scope": "window"
+ },
+ "gitlens.views.worktrees.avatars": {
+ "type": "boolean",
+ "default": true,
+ "markdownDescription": "Specifies whether to show avatar images instead of commit (or status) icons in the _Worktrees_ view",
+ "scope": "window"
+ },
+ "gitlens.views.worktrees.reveal": {
+ "type": "boolean",
+ "default": true,
+ "markdownDescription": "Specifies whether to reveal worktrees in the _Worktrees_ view, otherwise they revealed in the _Repositories_ view",
+ "scope": "window",
+ "order": 20
+ }
+ }
+ },
+ {
"id": "contributors-view",
"title": "Contributors View",
- "order": 29,
+ "order": 30,
"properties": {
"gitlens.views.contributors.showAllBranches": {
"type": "boolean",
@@ -1650,7 +1732,7 @@
{
"id": "search-compare-view",
"title": "Search & Compare View",
- "order": 29,
+ "order": 31,
"properties": {
"gitlens.views.searchAndCompare.pullRequests.enabled": {
"type": "boolean",
@@ -3462,6 +3544,15 @@
"dark": "#c74e39",
"highContrast": "#c74e39"
}
+ },
+ {
+ "id": "gitlens.decorations.worktreeView.hasUncommittedChangesForegroundColor",
+ "description": "Specifies the decoration foreground color for worktrees that have uncommitted changes",
+ "defaults": {
+ "light": "#895503",
+ "dark": "#E2C08D",
+ "highContrast": "#E2C08D"
+ }
}
],
"commands": [
@@ -3573,6 +3664,12 @@
"icon": "$(gear)"
},
{
+ "command": "gitlens.showSettingsPage#worktrees-view",
+ "title": "Open View Settings",
+ "category": "GitLens",
+ "icon": "$(gear)"
+ },
+ {
"command": "gitlens.showWelcomePage",
"title": "Welcome (Quick Setup)",
"category": "GitLens"
@@ -3628,6 +3725,11 @@
"category": "GitLens"
},
{
+ "command": "gitlens.showWorktreesView",
+ "title": "Show Worktrees View",
+ "category": "GitLens"
+ },
+ {
"command": "gitlens.showWelcomeView",
"title": "Show Welcome View",
"category": "GitLens"
@@ -3930,6 +4032,11 @@
"category": "GitLens"
},
{
+ "command": "gitlens.gitCommands.worktree",
+ "title": "Git Worktree...",
+ "category": "GitLens"
+ },
+ {
"command": "gitlens.switchMode",
"title": "Switch Mode",
"category": "GitLens"
@@ -4730,6 +4837,30 @@
"icon": "$(person-add)"
},
{
+ "command": "gitlens.views.createWorktree",
+ "title": "Create Worktree...",
+ "category": "GitLens",
+ "icon": "$(add)"
+ },
+ {
+ "command": "gitlens.views.deleteWorktree",
+ "title": "Delete Worktree...",
+ "category": "GitLens",
+ "icon": "$(trash)"
+ },
+ {
+ "command": "gitlens.views.openWorktree",
+ "title": "Open Worktree",
+ "category": "GitLens",
+ "icon": "$(window)"
+ },
+ {
+ "command": "gitlens.views.openWorktreeInNewWindow",
+ "title": "Open Worktree in New Window",
+ "category": "GitLens",
+ "icon": "$(empty-window)"
+ },
+ {
"command": "gitlens.views.cherryPick",
"title": "Cherry Pick Commit...",
"category": "GitLens"
@@ -5425,6 +5556,16 @@
"category": "GitLens"
},
{
+ "command": "gitlens.views.repositories.setShowWorktreesOn",
+ "title": "Show Worktrees",
+ "category": "GitLens"
+ },
+ {
+ "command": "gitlens.views.repositories.setShowWorktreesOff",
+ "title": "Hide Worktrees",
+ "category": "GitLens"
+ },
+ {
"command": "gitlens.views.repositories.setShowUpstreamStatusOn",
"title": "Show Current Branch Status",
"category": "GitLens"
@@ -5633,6 +5774,48 @@
"category": "GitLens"
},
{
+ "command": "gitlens.views.worktrees.copy",
+ "title": "Copy",
+ "category": "GitLens"
+ },
+ {
+ "command": "gitlens.views.worktrees.refresh",
+ "title": "Refresh",
+ "category": "GitLens",
+ "icon": "$(refresh)"
+ },
+ {
+ "command": "gitlens.views.worktrees.setFilesLayoutToAuto",
+ "title": "Toggle Files View: Tree",
+ "category": "GitLens",
+ "icon": "$(list-tree)"
+ },
+ {
+ "command": "gitlens.views.worktrees.setFilesLayoutToList",
+ "title": "Toggle Files View: Auto",
+ "category": "GitLens",
+ "icon": {
+ "dark": "images/dark/icon-view-auto.svg",
+ "light": "images/light/icon-view-auto.svg"
+ }
+ },
+ {
+ "command": "gitlens.views.worktrees.setFilesLayoutToTree",
+ "title": "Toggle Files View: List",
+ "category": "GitLens",
+ "icon": "$(list-flat)"
+ },
+ {
+ "command": "gitlens.views.worktrees.setShowAvatarsOn",
+ "title": "Show Avatars",
+ "category": "GitLens"
+ },
+ {
+ "command": "gitlens.views.worktrees.setShowAvatarsOff",
+ "title": "Hide Avatars",
+ "category": "GitLens"
+ },
+ {
"command": "gitlens.enableDebugLogging",
"title": "Enable Debug Logging",
"category": "GitLens"
@@ -5717,6 +5900,10 @@
"when": "false"
},
{
+ "command": "gitlens.showSettingsPage#worktrees-view",
+ "when": "false"
+ },
+ {
"command": "gitlens.showBranchesView",
"when": "gitlens:enabled"
},
@@ -5757,6 +5944,10 @@
"when": "gitlens:enabled"
},
{
+ "command": "gitlens.showWorktreesView",
+ "when": "gitlens:enabled"
+ },
+ {
"command": "gitlens.showWelcomeView",
"when": "gitlens:enabled"
},
@@ -5937,6 +6128,10 @@
"when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders"
},
{
+ "command": "gitlens.gitCommands.worktree",
+ "when": "!gitlens:disabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders"
+ },
+ {
"command": "gitlens.switchMode",
"when": "gitlens:enabled"
},
@@ -6441,6 +6636,22 @@
"when": "false"
},
{
+ "command": "gitlens.views.createWorktree",
+ "when": "false"
+ },
+ {
+ "command": "gitlens.views.deleteWorktree",
+ "when": "false"
+ },
+ {
+ "command": "gitlens.views.openWorktree",
+ "when": "false"
+ },
+ {
+ "command": "gitlens.views.openWorktreeInNewWindow",
+ "when": "false"
+ },
+ {
"command": "gitlens.views.createBranch",
"when": "false"
},
@@ -6937,6 +7148,14 @@
"when": "false"
},
{
+ "command": "gitlens.views.repositories.setShowWorktreesOn",
+ "when": "false"
+ },
+ {
+ "command": "gitlens.views.repositories.setShowWorktreesOff",
+ "when": "false"
+ },
+ {
"command": "gitlens.views.repositories.setShowUpstreamStatusOn",
"when": "false"
},
@@ -7077,6 +7296,34 @@
"when": "false"
},
{
+ "command": "gitlens.views.worktrees.copy",
+ "when": "false"
+ },
+ {
+ "command": "gitlens.views.worktrees.refresh",
+ "when": "false"
+ },
+ {
+ "command": "gitlens.views.worktrees.setFilesLayoutToAuto",
+ "when": "false"
+ },
+ {
+ "command": "gitlens.views.worktrees.setFilesLayoutToList",
+ "when": "false"
+ },
+ {
+ "command": "gitlens.views.worktrees.setFilesLayoutToTree",
+ "when": "false"
+ },
+ {
+ "command": "gitlens.views.worktrees.setShowAvatarsOn",
+ "when": "false"
+ },
+ {
+ "command": "gitlens.views.worktrees.setShowAvatarsOff",
+ "when": "false"
+ },
+ {
"command": "gitlens.enableDebugLogging",
"when": "config.gitlens.outputLevel != debug"
},
@@ -8025,6 +8272,41 @@
"group": "5_gitlens@0"
},
{
+ "command": "gitlens.views.createWorktree",
+ "when": "view =~ /^gitlens\\.views\\.worktrees/",
+ "group": "navigation@10"
+ },
+ {
+ "command": "gitlens.views.worktrees.refresh",
+ "when": "view =~ /^gitlens\\.views\\.worktrees/",
+ "group": "navigation@99"
+ },
+ {
+ "command": "gitlens.views.worktrees.setFilesLayoutToAuto",
+ "when": "view =~ /^gitlens\\.views\\.worktrees/ && config.gitlens.views.worktrees.files.layout == tree",
+ "group": "3_gitlens@1"
+ },
+ {
+ "command": "gitlens.views.worktrees.setFilesLayoutToList",
+ "when": "view =~ /^gitlens\\.views\\.worktrees/ && config.gitlens.views.worktrees.files.layout == auto",
+ "group": "3_gitlens@1"
+ },
+ {
+ "command": "gitlens.views.worktrees.setFilesLayoutToTree",
+ "when": "view =~ /^gitlens\\.views\\.worktrees/ && config.gitlens.views.worktrees.files.layout == list",
+ "group": "3_gitlens@1"
+ },
+ {
+ "command": "gitlens.views.worktrees.setShowAvatarsOn",
+ "when": "view =~ /^gitlens\\.views\\.worktrees/ && !config.gitlens.views.worktrees.avatars",
+ "group": "5_gitlens@0"
+ },
+ {
+ "command": "gitlens.views.worktrees.setShowAvatarsOff",
+ "when": "view =~ /^gitlens\\.views\\.worktrees/ && config.gitlens.views.worktrees.avatars",
+ "group": "5_gitlens@0"
+ },
+ {
"command": "gitlens.views.setShowRelativeDateMarkersOn",
"when": "view =~ /^gitlens\\.views\\.(branches|commits|fileHistory|lineHistory|remotes|repositories|tags)/ && !config.gitlens.views.showRelativeDateMarkers",
"group": "5_gitlens@3"
@@ -8083,6 +8365,11 @@
"command": "gitlens.showSettingsPage#tags-view",
"when": "view =~ /^gitlens\\.views\\.tags/",
"group": "9_gitlens@1"
+ },
+ {
+ "command": "gitlens.showSettingsPage#worktrees-view",
+ "when": "view =~ /^gitlens\\.views\\.worktrees/",
+ "group": "9_gitlens@1"
}
],
"view/item/context": [
@@ -8261,9 +8548,14 @@
"group": "1_gitlens_actions_@8"
},
{
+ "command": "gitlens.views.createWorktree",
+ "when": "!gitlens:readonly && viewItem =~ /gitlens:branch\\b/",
+ "group": "1_gitlens_actions_@9"
+ },
+ {
"command": "gitlens.views.createPullRequest",
"when": "gitlens:hasRemotes && gitlens:action:createPullRequest && viewItem =~ /gitlens:branch\\b(?=.*?\\b\\+(tracking|remote)\\b)/",
- "group": "1_gitlens_actions_@9"
+ "group": "1_gitlens_actions_@10"
},
{
"command": "gitlens.openBranchOnRemote",
@@ -9208,6 +9500,53 @@
"group": "1_gitlens_actions@3"
},
{
+ "command": "gitlens.views.createWorktree",
+ "when": "!gitlens:readonly && viewItem =~ /gitlens:worktrees\\b/",
+ "group": "inline@1"
+ },
+ {
+ "command": "gitlens.views.createWorktree",
+ "when": "!gitlens:readonly && viewItem =~ /gitlens:worktrees\\b/",
+ "group": "1_gitlens_actions@1"
+ },
+ {
+ "command": "gitlens.views.openWorktree",
+ "when": "viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/",
+ "group": "inline@1",
+ "alt": "gitlens.views.openWorktreeInNewWindow"
+ },
+ {
+ "command": "gitlens.views.openWorktree",
+ "when": "viewItem =~ /gitlens:worktree\\b(?=.*?\\b\\+active\\b)/ && workspaceFolderCount != 1",
+ "group": "inline@1",
+ "alt": "gitlens.views.openWorktreeInNewWindow"
+ },
+ {
+ "command": "gitlens.views.openWorktree",
+ "when": "viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/",
+ "group": "2_gitlens_quickopen@1"
+ },
+ {
+ "command": "gitlens.views.openWorktree",
+ "when": "viewItem =~ /gitlens:worktree\\b(?=.*?\\b\\+active\\b)/ && workspaceFolderCount != 1",
+ "group": "2_gitlens_quickopen@1"
+ },
+ {
+ "command": "gitlens.views.openWorktreeInNewWindow",
+ "when": "viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/",
+ "group": "2_gitlens_quickopen@2"
+ },
+ {
+ "command": "gitlens.views.openWorktreeInNewWindow",
+ "when": "viewItem =~ /gitlens:worktree\\b(?=.*?\\b\\+active\\b)/ && workspaceFolderCount != 1",
+ "group": "2_gitlens_quickopen@2"
+ },
+ {
+ "command": "gitlens.views.deleteWorktree",
+ "when": "!gitlens:readonly && viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/",
+ "group": "6_gitlens_actions@1"
+ },
+ {
"command": "gitlens.views.stageDirectory",
"when": "!gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && viewItem =~ /gitlens:folder\\b(?=.*?\\b\\+working\\b)/",
"group": "inline@1"
@@ -9229,7 +9568,7 @@
},
{
"command": "gitlens.views.copy",
- "when": "viewItem =~ /gitlens:(?=(autolinked:issue|branch|commit|contributor|folder|history:line|pullrequest|remote|repository|repo-folder|stash|tag)\\b)/",
+ "when": "viewItem =~ /gitlens:(?=(autolinked:issue|branch|commit|contributor|folder|history:line|pullrequest|remote|repository|repo-folder|stash|tag|worktree)\\b)/",
"group": "7_gitlens_cutcopypaste@1"
},
{
@@ -9650,14 +9989,24 @@
"group": "2_gitlens@6"
},
{
+ "command": "gitlens.views.repositories.setShowWorktreesOn",
+ "when": "!config.gitlens.views.repositories.showWorktrees",
+ "group": "2_gitlens@7"
+ },
+ {
+ "command": "gitlens.views.repositories.setShowWorktreesOff",
+ "when": "config.gitlens.views.repositories.showWorktrees",
+ "group": "2_gitlens@7"
+ },
+ {
"command": "gitlens.views.repositories.setShowContributorsOn",
"when": "!config.gitlens.views.repositories.showContributors",
- "group": "2_gitlens@7"
+ "group": "2_gitlens@8"
},
{
"command": "gitlens.views.repositories.setShowContributorsOff",
"when": "config.gitlens.views.repositories.showContributors",
- "group": "2_gitlens@7"
+ "group": "2_gitlens@8"
}
],
"gitlens/view/searchAndCompare/new": [
@@ -10036,6 +10385,12 @@
"key": "ctrl+c",
"mac": "cmd+c",
"when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.tags/"
+ },
+ {
+ "command": "gitlens.views.worktrees.copy",
+ "key": "ctrl+c",
+ "mac": "cmd+c",
+ "when": "gitlens:enabled && focusedView =~ /^gitlens\\.views\\.worktrees/"
}
],
"customEditors": [
@@ -10097,6 +10452,30 @@
"view": "gitlens.views.searchAndCompare",
"contents": "Compare a with another \n\n[Compare References...](command:gitlens.views.searchAndCompare.selectForCompare)",
"when": "!gitlens:hasVirtualFolders"
+ },
+ {
+ "view": "gitlens.views.worktrees",
+ "contents": "Worktrees allow you to easily work on different branches of a repository simultaneously. You can create multiple working trees, each of which can be opened in individual windows or all together in a single workspace."
+ },
+ {
+ "view": "gitlens.views.worktrees",
+ "contents": "[Create Worktree...](command:gitlens.views.createWorktree)",
+ "when": "!gitlens:premium:upgradeRequired"
+ },
+ {
+ "view": "gitlens.views.worktrees",
+ "contents": "Worktrees are a premium feature, which require a free account for public code. [Learn more](https://dev.gitkraken.com/gitlens/premium-features).",
+ "when": "gitlens:premium:upgradeRequired == free+"
+ },
+ {
+ "view": "gitlens.views.worktrees",
+ "contents": "Worktrees are a premium feature, which require at least a Pro subscription for private code. [Learn more](https://dev.gitkraken.com/gitlens/premium-features).",
+ "when": "gitlens:premium:upgradeRequired == paid"
+ },
+ {
+ "view": "gitlens.views.worktrees",
+ "contents": "[Unlock Premium Features](command:gitlens.showHomeView)",
+ "when": "gitlens:premium:upgradeRequired"
}
],
"views": {
@@ -10185,6 +10564,14 @@
"visibility": "collapsed"
},
{
+ "id": "gitlens.views.worktrees",
+ "name": "Worktrees",
+ "when": "!gitlens:disabled && !gitlens:hasVirtualFolders",
+ "contextualTitle": "GitLens",
+ "icon": "images/views/worktrees.svg",
+ "visibility": "collapsed"
+ },
+ {
"id": "gitlens.views.contributors",
"name": "Contributors",
"when": "!gitlens:disabled",
diff --git a/src/commands/git/worktree.ts b/src/commands/git/worktree.ts
new file mode 100644
index 0000000..f32c2e2
--- /dev/null
+++ b/src/commands/git/worktree.ts
@@ -0,0 +1,640 @@
+import { MessageItem, QuickInputButtons, Uri, window } from 'vscode';
+import { configuration } from '../../configuration';
+import { Container } from '../../container';
+import {
+ WorktreeCreateError,
+ WorktreeCreateErrorReason,
+ WorktreeDeleteError,
+ WorktreeDeleteErrorReason,
+} from '../../git/errors';
+import { PremiumFeatures } from '../../git/gitProvider';
+import { GitReference, GitWorktree, Repository } from '../../git/models';
+import { Messages } from '../../messages';
+import { QuickPickItemOfT } from '../../quickpicks/items/common';
+import { Directive } from '../../quickpicks/items/directive';
+import { FlagsQuickPickItem } from '../../quickpicks/items/flags';
+import { pad, pluralize } from '../../system/string';
+import { OpenWorkspaceLocation } from '../../system/utils';
+import { ViewsWithRepositoryFolders } from '../../views/viewBase';
+import { GitActions } from '../gitCommands.actions';
+import {
+ appendReposToTitle,
+ AsyncStepResultGenerator,
+ CustomStep,
+ ensureAccessStep,
+ inputBranchNameStep,
+ PartialStepState,
+ pickBranchOrTagStep,
+ pickRepositoryStep,
+ pickWorktreesStep,
+ pickWorktreeStep,
+ QuickCommand,
+ QuickPickStep,
+ StepGenerator,
+ StepResult,
+ StepResultGenerator,
+ StepSelection,
+ StepState,
+} from '../quickCommand';
+
+interface Context {
+ repos: Repository[];
+ associatedView: ViewsWithRepositoryFolders;
+ defaultUri?: Uri;
+ showTags: boolean;
+ title: string;
+ worktrees?: GitWorktree[];
+}
+
+type CreateFlags = '--force' | '-b' | '--detach';
+
+interface CreateState {
+ subcommand: 'create';
+ repo: string | Repository;
+ uri: Uri;
+ reference?: GitReference;
+ createBranch: string;
+ flags: CreateFlags[];
+}
+
+type DeleteFlags = '--force';
+
+interface DeleteState {
+ subcommand: 'delete';
+ repo: string | Repository;
+ uris: Uri[];
+ flags: DeleteFlags[];
+}
+
+type OpenFlags = '--new-window';
+
+interface OpenState {
+ subcommand: 'open';
+ repo: string | Repository;
+ uri: Uri;
+ flags: OpenFlags[];
+}
+
+type State = CreateState | DeleteState | OpenState;
+type WorktreeStepState = SomeNonNullable, 'subcommand'>;
+type CreateStepState = WorktreeStepState>;
+type DeleteStepState = WorktreeStepState>;
+type OpenStepState = WorktreeStepState>;
+
+const subcommandToTitleMap = new Map([
+ ['create', 'Create'],
+ ['delete', 'Delete'],
+ ['open', 'Open'],
+]);
+function getTitle(title: string, subcommand: State['subcommand'] | undefined) {
+ return subcommand == null ? title : `${subcommandToTitleMap.get(subcommand)} ${title}`;
+}
+
+export interface WorktreeGitCommandArgs {
+ readonly command: 'worktree';
+ confirm?: boolean;
+ state?: Partial;
+}
+
+export class WorktreeGitCommand extends QuickCommand {
+ private subcommand: State['subcommand'] | undefined;
+ private overrideCanConfirm: boolean | undefined;
+
+ constructor(container: Container, args?: WorktreeGitCommandArgs) {
+ super(container, 'worktree', 'worktree', 'Worktree', {
+ description: 'open, create, or delete worktrees',
+ });
+
+ let counter = 0;
+ if (args?.state?.subcommand != null) {
+ counter++;
+
+ switch (args.state.subcommand) {
+ case 'create':
+ if (args.state.uri != null) {
+ counter++;
+ }
+
+ if (args.state.reference != null) {
+ counter++;
+ }
+
+ break;
+ case 'delete':
+ if (args.state.uris != null && (!Array.isArray(args.state.uris) || args.state.uris.length !== 0)) {
+ counter++;
+ }
+
+ break;
+ case 'open':
+ if (args.state.uri != null) {
+ counter++;
+ }
+
+ break;
+ }
+ }
+
+ if (args?.state?.repo != null) {
+ counter++;
+ }
+
+ this.initialState = {
+ counter: counter,
+ confirm: args?.confirm,
+ ...args?.state,
+ };
+ }
+
+ override get canConfirm(): boolean {
+ return this.overrideCanConfirm != null ? this.overrideCanConfirm : this.subcommand != null;
+ }
+
+ override get canSkipConfirm(): boolean {
+ return this.subcommand === 'delete' ? false : super.canSkipConfirm;
+ }
+
+ override get skipConfirmKey() {
+ return `${this.key}${this.subcommand == null ? '' : `-${this.subcommand}`}:${this.pickedVia}`;
+ }
+
+ protected async *steps(state: PartialStepState): StepGenerator {
+ const context: Context = {
+ repos: Container.instance.git.openRepositories,
+ associatedView: Container.instance.worktreesView,
+ showTags: false,
+ title: this.title,
+ };
+
+ let skippedStepTwo = false;
+
+ while (this.canStepsContinue(state)) {
+ context.title = this.title;
+
+ if (state.counter < 1 || state.subcommand == null) {
+ this.subcommand = undefined;
+
+ const result = yield* this.pickSubcommandStep(state);
+ // Always break on the first step (so we will go back)
+ if (result === StepResult.Break) break;
+
+ state.subcommand = result;
+ }
+
+ this.subcommand = state.subcommand;
+
+ if (state.counter < 2 || state.repo == null || typeof state.repo === 'string') {
+ skippedStepTwo = false;
+ if (context.repos.length === 1) {
+ skippedStepTwo = true;
+ state.counter++;
+
+ state.repo = context.repos[0];
+ } else {
+ const result = yield* pickRepositoryStep(state, context);
+ if (result === StepResult.Break) continue;
+
+ state.repo = result;
+ }
+ }
+
+ const result = yield* ensureAccessStep(state as any, context, PremiumFeatures.Worktrees);
+ if (result === StepResult.Break) break;
+
+ context.title = getTitle(state.subcommand === 'delete' ? 'Worktrees' : this.title, state.subcommand);
+
+ switch (state.subcommand) {
+ case 'create': {
+ yield* this.createCommandSteps(state as CreateStepState, context);
+ // Clear any chosen path, since we are exiting this subcommand
+ state.uri = undefined;
+ break;
+ }
+ case 'delete': {
+ if (state.uris != null && !Array.isArray(state.uris)) {
+ state.uris = [state.uris];
+ }
+
+ yield* this.deleteCommandSteps(state as DeleteStepState, context);
+ break;
+ }
+ case 'open': {
+ yield* this.openCommandSteps(state as OpenStepState, context);
+ break;
+ }
+ default:
+ QuickCommand.endSteps(state);
+ break;
+ }
+
+ // If we skipped the previous step, make sure we back up past it
+ if (skippedStepTwo) {
+ state.counter--;
+ }
+ }
+
+ return state.counter < 0 ? StepResult.Break : undefined;
+ }
+
+ private *pickSubcommandStep(state: PartialStepState): StepResultGenerator {
+ const step = QuickCommand.createPickStep>({
+ title: this.title,
+ placeholder: `Choose a ${this.label} command`,
+ items: [
+ {
+ label: 'open',
+ description: 'opens the specified worktree',
+ picked: state.subcommand === 'open',
+ item: 'open',
+ },
+ {
+ label: 'create',
+ description: 'creates a new worktree',
+ picked: state.subcommand === 'create',
+ item: 'create',
+ },
+ {
+ label: 'delete',
+ description: 'deletes the specified worktrees',
+ picked: state.subcommand === 'delete',
+ item: 'delete',
+ },
+ ],
+ buttons: [QuickInputButtons.Back],
+ });
+ const selection: StepSelection = yield step;
+ return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break;
+ }
+
+ private async *createCommandSteps(state: CreateStepState, context: Context): AsyncStepResultGenerator {
+ if (context.defaultUri == null) {
+ context.defaultUri = await state.repo.getWorktreesDefaultUri();
+ }
+
+ if (state.flags == null) {
+ state.flags = [];
+ }
+
+ while (this.canStepsContinue(state)) {
+ this.overrideCanConfirm = undefined;
+
+ if (state.counter < 3 || state.reference == null) {
+ const result = yield* pickBranchOrTagStep(state, context, {
+ placeholder: context =>
+ `Choose a branch${context.showTags ? ' or tag' : ''} to create the new worktree for`,
+ picked: state.reference?.ref ?? (await state.repo.getBranch())?.ref,
+ titleContext: ' for',
+ value: GitReference.isRevision(state.reference) ? state.reference.ref : undefined,
+ });
+ // Always break on the first step (so we will go back)
+ if (result === StepResult.Break) break;
+
+ state.reference = result;
+ }
+
+ if (state.counter < 4 || state.uri == null) {
+ if (
+ state.reference != null &&
+ !configuration.get('worktrees.promptForLocation', state.repo.folder) &&
+ context.defaultUri != null
+ ) {
+ state.uri = Uri.joinPath(context.defaultUri, state.reference.name);
+ } else {
+ const result = yield* this.createCommandChoosePathStep(state, context, {
+ titleContext: ` for ${GitReference.toString(state.reference, {
+ capitalize: true,
+ icon: false,
+ label: state.reference.refType !== 'branch',
+ })}`,
+ });
+ if (result === StepResult.Break) continue;
+
+ state.uri = result;
+ }
+ }
+
+ // Clear the flags, since we can backup after the confirm step below (which is non-standard)
+ state.flags = [];
+
+ if (this.confirm(state.confirm)) {
+ const result = yield* this.createCommandConfirmStep(state, context);
+ if (result === StepResult.Break) continue;
+
+ state.flags = result;
+ }
+
+ if (state.flags.includes('-b') && state.createBranch == null) {
+ this.overrideCanConfirm = false;
+
+ const result = yield* inputBranchNameStep(state, context, {
+ placeholder: 'Please provide a name for the new branch',
+ titleContext: ` from ${GitReference.toString(state.reference, {
+ capitalize: true,
+ icon: false,
+ label: state.reference.refType !== 'branch',
+ })}`,
+ value: state.createBranch ?? GitReference.getNameWithoutRemote(state.reference),
+ });
+ if (result === StepResult.Break) continue;
+
+ state.createBranch = result;
+ }
+
+ QuickCommand.endSteps(state);
+
+ let retry = false;
+ do {
+ retry = false;
+ const force = state.flags.includes('--force');
+ const friendlyPath = GitWorktree.getFriendlyPath(state.uri);
+
+ try {
+ await state.repo.createWorktree(state.uri, {
+ commitish: state.reference?.name,
+ createBranch: state.flags.includes('-b') ? state.createBranch : undefined,
+ detach: state.flags.includes('--detach'),
+ force: force,
+ });
+ } catch (ex) {
+ if (
+ !force &&
+ ex instanceof WorktreeCreateError &&
+ ex.reason === WorktreeCreateErrorReason.AlreadyCheckedOut
+ ) {
+ const confirm: MessageItem = { title: 'Create Anyway' };
+ const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true };
+ const result = await window.showWarningMessage(
+ `Unable to create a new worktree in '${friendlyPath}' because ${GitReference.toString(
+ state.reference,
+ { icon: false, quoted: true },
+ )} is already checked out.\n\nWould you like to create this worktree anyway?`,
+ { modal: true },
+ confirm,
+ cancel,
+ );
+
+ if (result === confirm) {
+ state.flags.push('--force');
+ retry = true;
+ }
+ } else if (
+ ex instanceof WorktreeCreateError &&
+ ex.reason === WorktreeCreateErrorReason.AlreadyExists
+ ) {
+ void Messages.showGenericErrorMessage(
+ `Unable to create a new worktree in '${friendlyPath} because that folder already exists.`,
+ );
+ } else {
+ void Messages.showGenericErrorMessage(`Unable to create a new worktree in '${friendlyPath}.`);
+ }
+ }
+ } while (retry);
+ }
+ }
+
+ private async *createCommandChoosePathStep(
+ state: CreateStepState,
+ context: Context,
+ options?: { titleContext?: string },
+ ): AsyncStepResultGenerator {
+ const step = QuickCommand.createCustomStep({
+ show: async (_step: CustomStep) => {
+ const uris = await window.showOpenDialog({
+ canSelectFiles: false,
+ canSelectFolders: true,
+ canSelectMany: false,
+ defaultUri: state.uri ?? context.defaultUri,
+ openLabel: 'Select Worktree Location',
+ title: appendReposToTitle(`${context.title}${options?.titleContext ?? ''}`, state, context),
+ });
+
+ if (uris == null || uris.length === 0) return Directive.Back;
+
+ return uris[0];
+ },
+ });
+
+ const value: StepSelection = yield step;
+
+ if (
+ !QuickCommand.canStepContinue(step, state, value) ||
+ !(await QuickCommand.canInputStepContinue(step, state, value))
+ ) {
+ return StepResult.Break;
+ }
+
+ return value;
+ }
+
+ private *createCommandConfirmStep(state: CreateStepState, context: Context): StepResultGenerator {
+ const friendlyPath = GitWorktree.getFriendlyPath(state.uri);
+
+ const step: QuickPickStep> = QuickCommand.createConfirmStep(
+ appendReposToTitle(`Confirm ${context.title}`, state, context),
+ [
+ FlagsQuickPickItem.create(state.flags, [], {
+ label: context.title,
+ detail: `Will create a new worktree for ${GitReference.toString(state.reference)} in${pad(
+ '$(folder)',
+ 2,
+ 2,
+ )}${friendlyPath}`,
+ }),
+ FlagsQuickPickItem.create(state.flags, ['-b'], {
+ label: 'Create Branch and Worktree', //context.title,
+ description: `-b`,
+ detail: `Will create a new branch and worktree for ${GitReference.toString(
+ state.reference,
+ )} in${pad('$(folder)', 2, 2)}${friendlyPath}`,
+ }),
+ FlagsQuickPickItem.create(state.flags, ['--force'], {
+ label: `Force ${context.title}`,
+ description: `--force`,
+ detail: `Will forcibly create a new worktree for ${GitReference.toString(state.reference)} in${pad(
+ '$(folder)',
+ 2,
+ 2,
+ )}${friendlyPath}`,
+ }),
+ ],
+ context,
+ );
+ const selection: StepSelection = yield step;
+ return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break;
+ }
+
+ private async *deleteCommandSteps(state: DeleteStepState, context: Context): StepGenerator {
+ context.worktrees = await state.repo.getWorktrees();
+
+ if (state.flags == null) {
+ state.flags = [];
+ }
+
+ while (this.canStepsContinue(state)) {
+ if (state.counter < 3 || state.uris == null || state.uris.length === 0) {
+ context.title = getTitle('Worktrees', state.subcommand);
+
+ const result = yield* pickWorktreesStep(state, context, {
+ filter: wt => !wt.opened, // Can't delete an open worktree
+ includeStatus: true,
+ picked: state.uris?.map(uri => uri.toString()),
+ placeholder: 'Choose worktrees to delete',
+ });
+ // Always break on the first step (so we will go back)
+ if (result === StepResult.Break) break;
+
+ state.uris = result.map(w => w.uri);
+ }
+
+ context.title = getTitle(pluralize('Worktree', state.uris.length, { only: true }), state.subcommand);
+
+ const result = yield* this.deleteCommandConfirmStep(state, context);
+ if (result === StepResult.Break) continue;
+
+ state.flags = result;
+
+ QuickCommand.endSteps(state);
+
+ for (const uri of state.uris) {
+ let retry = false;
+ do {
+ retry = false;
+ const force = state.flags.includes('--force');
+
+ try {
+ if (force) {
+ const worktree = context.worktrees.find(wt => wt.uri.toString() === uri.toString());
+ const status = await worktree?.getStatus();
+ if (status?.hasChanges ?? false) {
+ const confirm: MessageItem = { title: 'Force Delete' };
+ const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true };
+ const result = await window.showWarningMessage(
+ `The worktree in '${uri.fsPath}' has uncommitted changes.\n\nDeleting it will cause those changes to be FOREVER LOST.\nThis is IRREVERSIBLE!\n\nAre you sure you still want to delete it?`,
+ { modal: true },
+ confirm,
+ cancel,
+ );
+
+ if (result !== confirm) return;
+ }
+ }
+
+ await state.repo.deleteWorktree(uri, { force: force });
+ } catch (ex) {
+ if (!force && ex instanceof WorktreeDeleteError) {
+ const confirm: MessageItem = { title: 'Force Delete' };
+ const cancel: MessageItem = { title: 'Cancel', isCloseAffordance: true };
+ const result = await window.showErrorMessage(
+ ex.reason === WorktreeDeleteErrorReason.HasChanges
+ ? `Unable to delete worktree because there are UNCOMMITTED changes in '${uri.fsPath}'.\n\nForcibly deleting it will cause those changes to be FOREVER LOST.\nThis is IRREVERSIBLE!\n\nWould you like to forcibly delete it?`
+ : `Unable to delete worktree in '${uri.fsPath}'.\n\nWould you like to try to forcibly delete it?`,
+ { modal: true },
+ confirm,
+ cancel,
+ );
+
+ if (result === confirm) {
+ state.flags.push('--force');
+ retry = true;
+ }
+ } else {
+ void Messages.showGenericErrorMessage(`Unable to delete worktree in '${uri.fsPath}.`);
+ }
+ }
+ } while (retry);
+ }
+ }
+ }
+
+ private *deleteCommandConfirmStep(state: DeleteStepState, context: Context): StepResultGenerator {
+ const step: QuickPickStep> = QuickCommand.createConfirmStep(
+ appendReposToTitle(`Confirm ${context.title}`, state, context),
+ [
+ FlagsQuickPickItem.create(state.flags, [], {
+ label: context.title,
+ detail: `Will delete ${pluralize('worktree', state.uris.length, {
+ only: state.uris.length === 1,
+ })}${
+ state.uris.length === 1
+ ? ` in${pad('$(folder)', 2, 2)}${GitWorktree.getFriendlyPath(state.uris[0])}`
+ : ''
+ }`,
+ }),
+ FlagsQuickPickItem.create(state.flags, ['--force'], {
+ label: `Force ${context.title}`,
+ detail: `Will forcibly delete ${pluralize('worktree', state.uris.length, {
+ only: state.uris.length === 1,
+ })} even with UNCOMMITTED changes${
+ state.uris.length === 1
+ ? ` in${pad('$(folder)', 2, 2)}${GitWorktree.getFriendlyPath(state.uris[0])}`
+ : ''
+ }`,
+ }),
+ ],
+ context,
+ );
+
+ const selection: StepSelection = yield step;
+ return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break;
+ }
+
+ private async *openCommandSteps(state: OpenStepState, context: Context): StepGenerator {
+ context.worktrees = await state.repo.getWorktrees();
+
+ if (state.flags == null) {
+ state.flags = [];
+ }
+
+ while (this.canStepsContinue(state)) {
+ if (state.counter < 3 || state.uri == null) {
+ context.title = getTitle('Worktree', state.subcommand);
+
+ const result = yield* pickWorktreeStep(state, context, {
+ includeStatus: true,
+ picked: state.uri?.toString(),
+ placeholder: 'Choose worktree to open',
+ });
+ // Always break on the first step (so we will go back)
+ if (result === StepResult.Break) break;
+
+ state.uri = result.uri;
+ }
+
+ context.title = getTitle('Worktree', state.subcommand);
+
+ const result = yield* this.openCommandConfirmStep(state, context);
+ if (result === StepResult.Break) continue;
+
+ state.flags = result;
+
+ QuickCommand.endSteps(state);
+
+ const worktree = context.worktrees.find(wt => wt.uri.toString() === state.uri.toString())!;
+ GitActions.Worktree.open(worktree, {
+ location: state.flags.includes('--new-window')
+ ? OpenWorkspaceLocation.NewWindow
+ : OpenWorkspaceLocation.CurrentWindow,
+ });
+ }
+ }
+
+ private *openCommandConfirmStep(state: OpenStepState, context: Context): StepResultGenerator {
+ const step: QuickPickStep> = QuickCommand.createConfirmStep(
+ appendReposToTitle(`Confirm ${context.title}`, state, context),
+ [
+ FlagsQuickPickItem.create(state.flags, [], {
+ label: context.title,
+ detail: `Will open the worktree in ${GitWorktree.getFriendlyPath(state.uri)} in the current window`,
+ }),
+ FlagsQuickPickItem.create(state.flags, ['--new-window'], {
+ label: `${context.title} in New Window`,
+ detail: `Will open the worktree in ${GitWorktree.getFriendlyPath(state.uri)} in a new window`,
+ }),
+ ],
+ context,
+ );
+
+ const selection: StepSelection = yield step;
+ return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break;
+ }
+}
diff --git a/src/commands/gitCommands.actions.ts b/src/commands/gitCommands.actions.ts
index d2f35b5..9027d49 100644
--- a/src/commands/gitCommands.actions.ts
+++ b/src/commands/gitCommands.actions.ts
@@ -21,11 +21,13 @@ import {
GitRevisionReference,
GitStashReference,
GitTagReference,
+ GitWorktree,
Repository,
} from '../git/models';
import { RepositoryPicker } from '../quickpicks/repositoryPicker';
+import { ensure } from '../system/array';
import { executeCommand, executeEditorCommand } from '../system/command';
-import { findOrOpenEditor, findOrOpenEditors } from '../system/utils';
+import { findOrOpenEditor, findOrOpenEditors, openWorkspace, OpenWorkspaceLocation } from '../system/utils';
import { ViewsWithRepositoryFolders } from '../views/viewBase';
import { ResetGitCommandArgs } from './git/reset';
@@ -883,4 +885,35 @@ export namespace GitActions {
return node;
}
}
+
+ export namespace Worktree {
+ export function create(repo?: string | Repository, uri?: Uri, ref?: GitReference) {
+ return executeGitCommand({
+ command: 'worktree',
+ state: { subcommand: 'create', repo: repo, uri: uri, reference: ref },
+ });
+ }
+
+ export function open(worktree: GitWorktree, options?: { location?: OpenWorkspaceLocation }) {
+ return openWorkspace(worktree.uri, options);
+ }
+
+ export function remove(repo?: string | Repository, uri?: Uri) {
+ return executeGitCommand({
+ command: 'worktree',
+ state: { subcommand: 'delete', repo: repo, uris: ensure(uri) },
+ });
+ }
+
+ export async function reveal(
+ worktree: GitWorktree,
+ options?: { select?: boolean; focus?: boolean; expand?: boolean | number },
+ ) {
+ const view = Container.instance.worktreesView;
+ const node = view.canReveal
+ ? await view.revealWorktree(worktree, options)
+ : await Container.instance.repositoriesView.revealWorktree(worktree, options);
+ return node;
+ }
+ }
}
diff --git a/src/commands/gitCommands.ts b/src/commands/gitCommands.ts
index e7fe73c..496bfed 100644
--- a/src/commands/gitCommands.ts
+++ b/src/commands/gitCommands.ts
@@ -25,13 +25,17 @@ import type { StashGitCommandArgs } from './git/stash';
import type { StatusGitCommandArgs } from './git/status';
import type { SwitchGitCommandArgs } from './git/switch';
import type { TagGitCommandArgs } from './git/tag';
+import type { WorktreeGitCommandArgs } from './git/worktree';
import { PickCommandStep } from './gitCommands.utils';
import {
+ CustomStep,
+ isCustomStep,
isQuickInputStep,
isQuickPickStep,
QuickCommand,
QuickInputStep,
QuickPickStep,
+ StepResult,
StepSelection,
} from './quickCommand';
import { QuickCommandButtons, ToggleQuickInputButton } from './quickCommand.buttons';
@@ -56,7 +60,8 @@ export type GitCommandsCommandArgs =
| StashGitCommandArgs
| StatusGitCommandArgs
| SwitchGitCommandArgs
- | TagGitCommandArgs;
+ | TagGitCommandArgs
+ | WorktreeGitCommandArgs;
@command()
export class GitCommandsCommand extends Command {
@@ -73,6 +78,7 @@ export class GitCommandsCommand extends Command {
Commands.GitCommandsRevert,
Commands.GitCommandsSwitch,
Commands.GitCommandsTag,
+ Commands.GitCommandsWorktree,
]);
}
@@ -102,6 +108,9 @@ export class GitCommandsCommand extends Command {
case Commands.GitCommandsTag:
args = { command: 'tag' };
break;
+ case Commands.GitCommandsWorktree:
+ args = { command: 'worktree' };
+ break;
}
return this.execute(args);
@@ -116,7 +125,7 @@ export class GitCommandsCommand extends Command {
let ignoreFocusOut;
- let step;
+ let step: QuickPickStep | QuickInputStep | CustomStep | undefined;
if (command == null) {
step = commandsStep;
} else {
@@ -157,14 +166,23 @@ export class GitCommandsCommand extends Command {
continue;
}
+ if (isCustomStep(step)) {
+ step = await this.showCustomStep(step, commandsStep);
+ if (step?.ignoreFocusOut === true) {
+ ignoreFocusOut = true;
+ }
+
+ continue;
+ }
+
break;
}
}
private async showLoadingIfNeeded(
command: QuickCommand,
- stepPromise: Promise | QuickInputStep | undefined>,
- ): Promise | QuickInputStep | undefined> {
+ stepPromise: Promise | QuickInputStep | CustomStep | undefined>,
+ ): Promise | QuickInputStep | CustomStep | undefined> {
const stepOrTimeout = await Promise.race([
stepPromise,
new Promise(resolve => setTimeout(() => resolve(showLoadingSymbol), 250)),
@@ -179,23 +197,25 @@ export class GitCommandsCommand extends Command {
const disposables: Disposable[] = [];
- let step: QuickPickStep | QuickInputStep | undefined;
+ let step: QuickPickStep | QuickInputStep | CustomStep | undefined;
try {
- // eslint-disable-next-line no-async-promise-executor
- return await new Promise | QuickInputStep | undefined>(async resolve => {
- disposables.push(quickpick.onDidHide(() => resolve(step)));
+ return await new Promise | QuickInputStep | CustomStep | undefined>(
+ // eslint-disable-next-line no-async-promise-executor
+ async resolve => {
+ disposables.push(quickpick.onDidHide(() => resolve(step)));
- quickpick.title = command.title;
- quickpick.placeholder = 'Loading...';
- quickpick.busy = true;
- quickpick.enabled = false;
+ quickpick.title = command.title;
+ quickpick.placeholder = 'Loading...';
+ quickpick.busy = true;
+ quickpick.enabled = false;
- quickpick.show();
+ quickpick.show();
- step = await stepPromise;
+ step = await stepPromise;
- quickpick.hide();
- });
+ quickpick.hide();
+ },
+ );
} finally {
quickpick.dispose();
disposables.forEach(d => d.dispose());
@@ -253,20 +273,55 @@ export class GitCommandsCommand extends Command {
}
private async nextStep(
- quickInput: InputBox | QuickPick,
command: QuickCommand,
value: StepSelection | undefined,
+ quickInput?: InputBox | QuickPick,
) {
- quickInput.busy = true;
- // quickInput.enabled = false;
+ if (quickInput != null) {
+ quickInput.busy = true;
+ // quickInput.enabled = false;
+ }
const next = await command.next(value);
if (next.done) return undefined;
- quickInput.value = '';
+ if (quickInput != null) {
+ quickInput.value = '';
+ }
return next.value;
}
+ private async showCustomStep(step: CustomStep, commandsStep: PickCommandStep) {
+ const result = await step.show(step);
+ if (result === StepResult.Break) return undefined;
+
+ if (Directive.is(result)) {
+ switch (result) {
+ case Directive.Back:
+ return (await commandsStep?.command?.previous()) ?? commandsStep;
+ case Directive.Noop:
+ return commandsStep.command?.retry();
+ case Directive.Cancel:
+ default:
+ return undefined;
+ }
+ } else {
+ return this.nextStep(commandsStep.command!, result);
+ }
+ // switch (result.directive) {
+ // case 'back':
+ // return (await commandsStep?.command?.previous()) ?? commandsStep;
+ // case 'cancel':
+ // return undefined;
+ // case 'next':
+ // return this.nextStep(commandsStep.command!, result.value);
+ // case 'retry':
+ // return commandsStep.command?.retry();
+ // default:
+ // return undefined;
+ // }
+ }
+
private async showInputStep(step: QuickInputStep, commandsStep: PickCommandStep) {
const input = window.createInputBox();
input.ignoreFocusOut = !configuration.get('gitCommands.closeOnFocusOut') ? true : step.ignoreFocusOut ?? false;
@@ -349,7 +404,7 @@ export class GitCommandsCommand extends Command {
input.validationMessage = message;
}),
input.onDidAccept(async () => {
- resolve(await this.nextStep(input, commandsStep.command!, input.value));
+ resolve(await this.nextStep(commandsStep.command!, input.value, input));
}),
);
@@ -453,7 +508,7 @@ export class GitCommandsCommand extends Command {
quickpick.onDidHide(() => resolve(undefined)),
quickpick.onDidTriggerItemButton(async e => {
if ((await step.onDidClickItemButton?.(quickpick, e.button, e.item)) === true) {
- resolve(await this.nextStep(quickpick, commandsStep.command!, [e.item]));
+ resolve(await this.nextStep(commandsStep.command!, [e.item], quickpick));
}
}),
quickpick.onDidTriggerButton(async e => {
@@ -564,7 +619,7 @@ export class GitCommandsCommand extends Command {
items = [item];
}
- resolve(await this.nextStep(quickpick, commandsStep.command!, items));
+ resolve(await this.nextStep(commandsStep.command!, items, quickpick));
return;
}
}
@@ -619,7 +674,7 @@ export class GitCommandsCommand extends Command {
if (step.onDidAccept == null) {
if (step.allowEmpty) {
- resolve(await this.nextStep(quickpick, commandsStep.command!, []));
+ resolve(await this.nextStep(commandsStep.command!, [], quickpick));
}
return;
@@ -628,7 +683,7 @@ export class GitCommandsCommand extends Command {
quickpick.busy = true;
if (await step.onDidAccept(quickpick)) {
- resolve(await this.nextStep(quickpick, commandsStep.command!, value));
+ resolve(await this.nextStep(commandsStep.command!, value, quickpick));
}
quickpick.busy = false;
@@ -655,17 +710,9 @@ export class GitCommandsCommand extends Command {
return;
case Directive.RequiresVerification:
- void Container.instance.subscription.resendVerification();
- resolve(undefined);
- return;
-
case Directive.RequiresFreeSubscription:
- void Container.instance.subscription.loginOrSignUp();
- resolve(undefined);
- return;
-
case Directive.RequiresPaidSubscription:
- void Container.instance.subscription.purchase();
+ void Container.instance.subscription.showHomeView();
resolve(undefined);
return;
}
@@ -691,7 +738,7 @@ export class GitCommandsCommand extends Command {
}
}
- resolve(await this.nextStep(quickpick, commandsStep.command!, items as QuickPickItem[]));
+ resolve(await this.nextStep(commandsStep.command!, items as QuickPickItem[], quickpick));
}),
);
diff --git a/src/commands/gitCommands.utils.ts b/src/commands/gitCommands.utils.ts
index 9d0b937..b1a5b4e 100644
--- a/src/commands/gitCommands.utils.ts
+++ b/src/commands/gitCommands.utils.ts
@@ -20,6 +20,7 @@ import { StashGitCommand } from './git/stash';
import { StatusGitCommand } from './git/status';
import { SwitchGitCommand } from './git/switch';
import { TagGitCommand } from './git/tag';
+import { WorktreeGitCommand } from './git/worktree';
import type { GitCommandsCommandArgs } from './gitCommands';
import type { QuickCommand, QuickPickStep, StepGenerator } from './quickCommand';
@@ -90,6 +91,9 @@ export class PickCommandStep implements QuickPickStep {
args?.command === 'switch' || args?.command === 'checkout' ? args : undefined,
),
readonly ? undefined : new TagGitCommand(container, args?.command === 'tag' ? args : undefined),
+ hasVirtualFolders
+ ? undefined
+ : new WorktreeGitCommand(container, args?.command === 'worktree' ? args : undefined),
].filter((i: T | undefined): i is T => i != null);
if (this.container.config.gitCommands.sortBy === GitCommandSorting.Usage) {
diff --git a/src/commands/quickCommand.buttons.ts b/src/commands/quickCommand.buttons.ts
index 9f90b36..b91f950 100644
--- a/src/commands/quickCommand.buttons.ts
+++ b/src/commands/quickCommand.buttons.ts
@@ -107,6 +107,11 @@ export namespace QuickCommandButtons {
}
};
+ export const OpenInNewWindow: QuickInputButton = {
+ iconPath: new ThemeIcon('empty-window'),
+ tooltip: 'Open in New Window',
+ };
+
export const RevealInSideBar: QuickInputButton = {
iconPath: new ThemeIcon('eye'),
tooltip: 'Reveal in Side Bar',
diff --git a/src/commands/quickCommand.steps.ts b/src/commands/quickCommand.steps.ts
index 68f39ad..19712da 100644
--- a/src/commands/quickCommand.steps.ts
+++ b/src/commands/quickCommand.steps.ts
@@ -19,6 +19,7 @@ import {
GitStatus,
GitTag,
GitTagReference,
+ GitWorktree,
Repository,
TagSortOptions,
} from '../git/models';
@@ -58,6 +59,7 @@ import {
RefQuickPickItem,
RepositoryQuickPickItem,
TagQuickPickItem,
+ WorktreeQuickPickItem,
} from '../quickpicks/items/gitCommands';
import { ReferencesQuickPickItem } from '../quickpicks/referencePicker';
import {
@@ -69,6 +71,7 @@ import { filterMap, intersection, isStringArray } from '../system/array';
import { formatPath } from '../system/formatPath';
import { map } from '../system/iterable';
import { pad, pluralize, truncate } from '../system/string';
+import { OpenWorkspaceLocation } from '../system/utils';
import { ViewsWithRepositoryFolders } from '../views/viewBase';
import { GitActions } from './gitCommands.actions';
import {
@@ -141,6 +144,39 @@ export async function getTags(
}) as Promise;
}
+export async function getWorktrees(
+ repoOrWorktrees: Repository | GitWorktree[],
+ {
+ buttons,
+ filter,
+ includeStatus,
+ picked,
+ }: {
+ buttons?: QuickInputButton[];
+ filter?: (t: GitWorktree) => boolean;
+ includeStatus?: boolean;
+ picked?: string | string[];
+ },
+): Promise {
+ const worktrees = repoOrWorktrees instanceof Repository ? await repoOrWorktrees.getWorktrees() : repoOrWorktrees;
+ return Promise.all([
+ ...worktrees
+ .filter(w => filter == null || filter(w))
+ .map(async w =>
+ WorktreeQuickPickItem.create(
+ w,
+ picked != null &&
+ (typeof picked === 'string' ? w.uri.toString() === picked : picked.includes(w.uri.toString())),
+ {
+ buttons: buttons,
+ ref: true,
+ status: includeStatus ? await w.getStatus() : undefined,
+ },
+ ),
+ ),
+ ]);
+}
+
export async function getBranchesAndOrTags(
repos: Repository | Repository[] | undefined,
include: ('tags' | 'branches')[],
@@ -1395,6 +1431,127 @@ export async function* pickTagsStep<
return QuickCommand.canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResult.Break;
}
+export async function* pickWorktreeStep<
+ State extends PartialStepState & { repo: Repository },
+ Context extends { repos: Repository[]; title: string; worktrees?: GitWorktree[] },
+>(
+ state: State,
+ context: Context,
+ {
+ filter,
+ includeStatus,
+ picked,
+ placeholder,
+ titleContext,
+ }: {
+ filter?: (b: GitWorktree) => boolean;
+ includeStatus?: boolean;
+ picked?: string | string[];
+ placeholder: string;
+ titleContext?: string;
+ },
+): AsyncStepResultGenerator {
+ const worktrees = await getWorktrees(context.worktrees ?? state.repo, {
+ buttons: [QuickCommandButtons.OpenInNewWindow, QuickCommandButtons.RevealInSideBar],
+ filter: filter,
+ includeStatus: includeStatus,
+ picked: picked,
+ });
+
+ const step = QuickCommand.createPickStep({
+ title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context),
+ placeholder: worktrees.length === 0 ? `No worktrees found in ${state.repo.formattedName}` : placeholder,
+ matchOnDetail: true,
+ items:
+ worktrees.length === 0
+ ? [DirectiveQuickPickItem.create(Directive.Back, true), DirectiveQuickPickItem.create(Directive.Cancel)]
+ : worktrees,
+ onDidClickItemButton: (quickpick, button, { item }) => {
+ switch (button) {
+ case QuickCommandButtons.OpenInNewWindow:
+ void GitActions.Worktree.open(item, { location: OpenWorkspaceLocation.NewWindow });
+ break;
+ case QuickCommandButtons.RevealInSideBar:
+ void GitActions.Worktree.reveal(item, { select: true, focus: false, expand: true });
+ break;
+ }
+ },
+ keys: ['right', 'alt+right', 'ctrl+right'],
+ onDidPressKey: async quickpick => {
+ if (quickpick.activeItems.length === 0) return;
+
+ await GitActions.Worktree.reveal(quickpick.activeItems[0].item, {
+ select: true,
+ focus: false,
+ expand: true,
+ });
+ },
+ });
+ const selection: StepSelection = yield step;
+ return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break;
+}
+
+export async function* pickWorktreesStep<
+ State extends PartialStepState & { repo: Repository },
+ Context extends { repos: Repository[]; title: string; worktrees?: GitWorktree[] },
+>(
+ state: State,
+ context: Context,
+ {
+ filter,
+ includeStatus,
+ picked,
+ placeholder,
+ titleContext,
+ }: {
+ filter?: (b: GitWorktree) => boolean;
+ includeStatus?: boolean;
+ picked?: string | string[];
+ placeholder: string;
+ titleContext?: string;
+ },
+): AsyncStepResultGenerator {
+ const worktrees = await getWorktrees(context.worktrees ?? state.repo, {
+ buttons: [QuickCommandButtons.OpenInNewWindow, QuickCommandButtons.RevealInSideBar],
+ filter: filter,
+ includeStatus: includeStatus,
+ picked: picked,
+ });
+
+ const step = QuickCommand.createPickStep({
+ multiselect: worktrees.length !== 0,
+ title: appendReposToTitle(`${context.title}${titleContext ?? ''}`, state, context),
+ placeholder: worktrees.length === 0 ? `No worktrees found in ${state.repo.formattedName}` : placeholder,
+ matchOnDetail: true,
+ items:
+ worktrees.length === 0
+ ? [DirectiveQuickPickItem.create(Directive.Back, true), DirectiveQuickPickItem.create(Directive.Cancel)]
+ : worktrees,
+ onDidClickItemButton: (quickpick, button, { item }) => {
+ switch (button) {
+ case QuickCommandButtons.OpenInNewWindow:
+ void GitActions.Worktree.open(item, { location: OpenWorkspaceLocation.NewWindow });
+ break;
+ case QuickCommandButtons.RevealInSideBar:
+ void GitActions.Worktree.reveal(item, { select: true, focus: false, expand: true });
+ break;
+ }
+ },
+ keys: ['right', 'alt+right', 'ctrl+right'],
+ onDidPressKey: async quickpick => {
+ if (quickpick.activeItems.length === 0) return;
+
+ await GitActions.Worktree.reveal(quickpick.activeItems[0].item, {
+ select: true,
+ focus: false,
+ expand: true,
+ });
+ },
+ });
+ const selection: StepSelection = yield step;
+ return QuickCommand.canPickStepContinue(step, state, selection) ? selection.map(i => i.item) : StepResult.Break;
+}
+
export async function* showCommitOrStashStep<
State extends PartialStepState & { repo: Repository; reference: GitCommit | GitStashCommit },
Context extends { repos: Repository[]; title: string },
diff --git a/src/commands/quickCommand.ts b/src/commands/quickCommand.ts
index 5e53e09..a575ea5 100644
--- a/src/commands/quickCommand.ts
+++ b/src/commands/quickCommand.ts
@@ -6,6 +6,18 @@ import { Directive, DirectiveQuickPickItem } from '../quickpicks/items/directive
export * from './quickCommand.buttons';
export * from './quickCommand.steps';
+export interface CustomStep {
+ ignoreFocusOut?: boolean;
+
+ show(step: CustomStep): Promise>;
+}
+
+export function isCustomStep(
+ step: QuickPickStep | QuickInputStep | CustomStep | typeof StepResult.Break,
+): step is CustomStep {
+ return typeof step === 'object' && (step as CustomStep).show != null;
+}
+
export interface QuickInputStep {
additionalButtons?: QuickInputButton[];
buttons?: QuickInputButton[];
@@ -24,7 +36,7 @@ export interface QuickInputStep {
export function isQuickInputStep(
step: QuickPickStep | QuickInputStep | typeof StepResult.Break,
): step is QuickInputStep {
- return typeof step === 'object' && (step as QuickPickStep).items == null;
+ return typeof step === 'object' && (step as QuickPickStep).items == null && (step as CustomStep).show == null;
}
export interface QuickPickStep {
@@ -59,23 +71,35 @@ export interface QuickPickStep {
validate?(selection: T[]): boolean;
}
-export function isQuickPickStep(step: QuickPickStep | QuickInputStep | typeof StepResult.Break): step is QuickPickStep {
+export function isQuickPickStep(
+ step: QuickPickStep | QuickInputStep | CustomStep | typeof StepResult.Break,
+): step is QuickPickStep {
return typeof step === 'object' && (step as QuickPickStep).items != null;
}
export type StepGenerator =
- | Generator, any | undefined>
- | AsyncGenerator, any | undefined>;
+ | Generator, any | undefined>
+ | AsyncGenerator, any | undefined>;
-export type StepItemType = T extends QuickPickStep ? U[] : T extends QuickInputStep ? string : never;
+export type StepItemType = T extends CustomStep
+ ? U
+ : T extends QuickPickStep
+ ? U[]
+ : T extends QuickInputStep
+ ? string
+ : never;
export type StepNavigationKeys = Exclude;
export namespace StepResult {
export const Break = Symbol('BreakStep');
}
export type StepResult = typeof StepResult.Break | T;
-export type StepResultGenerator = Generator, any | undefined>;
+export type StepResultGenerator = Generator<
+ QuickPickStep | QuickInputStep | CustomStep,
+ StepResult,
+ any | undefined
+>;
export type AsyncStepResultGenerator = AsyncGenerator<
- QuickPickStep | QuickInputStep,
+ QuickPickStep | QuickInputStep | CustomStep,
StepResult,
any | undefined
>;
@@ -83,7 +107,9 @@ export type AsyncStepResultGenerator = AsyncGenerator<
// export type StepResultGenerator =
// | Generator, any | undefined>
// | AsyncGenerator, any | undefined>;
-export type StepSelection = T extends QuickPickStep
+export type StepSelection = T extends CustomStep
+ ? U | Directive
+ : T extends QuickPickStep
? U[] | Directive
: T extends QuickInputStep
? string | Directive
@@ -97,7 +123,7 @@ export abstract class QuickCommand implements QuickPickItem {
protected initialState: PartialStepState | undefined;
- private _currentStep: QuickPickStep | QuickInputStep | undefined;
+ private _currentStep: QuickPickStep | QuickInputStep | CustomStep | undefined;
private _stepsIterator: StepGenerator | undefined;
constructor(
@@ -145,7 +171,7 @@ export abstract class QuickCommand implements QuickPickItem {
return `${this.key}:${this.pickedVia}`;
}
- get value(): QuickPickStep | QuickInputStep | undefined {
+ get value(): QuickPickStep | QuickInputStep | CustomStep | undefined {
return this._currentStep;
}
@@ -176,7 +202,9 @@ export abstract class QuickCommand implements QuickPickItem {
return (await this.next(Directive.Back)).value;
}
- async next(value?: StepSelection): Promise> {
+ async next(
+ value?: StepSelection,
+ ): Promise> {
if (this._stepsIterator == null) {
this._stepsIterator = this.steps(this.getStepState(false));
}
@@ -196,7 +224,7 @@ export abstract class QuickCommand implements QuickPickItem {
return result;
}
- async retry(): Promise {
+ async retry(): Promise {
await this.next(Directive.Noop);
return this.value;
}
@@ -317,6 +345,10 @@ export namespace QuickCommand {
return step;
}
+ export function createCustomStep(step: CustomStep): CustomStep {
+ return step;
+ }
+
export function endSteps(state: PartialStepState) {
state.counter = -1;
}
diff --git a/src/commands/showView.ts b/src/commands/showView.ts
index 7306e65..99e6d21 100644
--- a/src/commands/showView.ts
+++ b/src/commands/showView.ts
@@ -19,6 +19,7 @@ export class ShowViewCommand extends Command {
Commands.ShowSearchAndCompareView,
Commands.ShowStashesView,
Commands.ShowTagsView,
+ Commands.ShowWorktreesView,
Commands.ShowWelcomeView,
Commands.ShowHomeView,
]);
@@ -50,6 +51,8 @@ export class ShowViewCommand extends Command {
return this.container.stashesView.show();
case Commands.ShowTagsView:
return this.container.tagsView.show();
+ case Commands.ShowWorktreesView:
+ return this.container.worktreesView.show();
case Commands.ShowWelcomeView:
await setContext(ContextKeys.ViewsWelcomeVisible, true);
void this.container.storage.store(SyncedStorageKeys.WelcomeViewVisible, true);
diff --git a/src/config.ts b/src/config.ts
index 5a63612..26a8fc2 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -148,6 +148,10 @@ export interface Config {
enabled: boolean;
};
views: ViewsConfig;
+ worktrees: {
+ defaultLocation: string | null;
+ promptForLocation: boolean;
+ };
advanced: AdvancedConfig;
}
@@ -518,6 +522,7 @@ interface ViewsConfigs {
searchAndCompare: SearchAndCompareViewConfig;
stashes: StashesViewConfig;
tags: TagsViewConfig;
+ worktrees: WorktreesViewConfig;
}
export type ViewsConfigKeys = keyof ViewsConfigs;
@@ -532,6 +537,7 @@ export const viewsConfigKeys: ViewsConfigKeys[] = [
'tags',
'contributors',
'searchAndCompare',
+ 'worktrees',
];
export type ViewsConfig = ViewsCommonConfig & ViewsConfigs;
@@ -624,6 +630,7 @@ export interface RepositoriesViewConfig {
showStashes: boolean;
showTags: boolean;
showUpstreamStatus: boolean;
+ showWorktrees: boolean;
}
export interface SearchAndCompareViewConfig {
@@ -649,6 +656,16 @@ export interface TagsViewConfig {
reveal: boolean;
}
+export interface WorktreesViewConfig {
+ avatars: boolean;
+ files: ViewsFilesConfig;
+ pullRequests: {
+ enabled: boolean;
+ showForCommits: boolean;
+ };
+ reveal: boolean;
+}
+
export interface ViewsFilesConfig {
compact: boolean;
layout: ViewFilesLayout;
diff --git a/src/constants.ts b/src/constants.ts
index 7c761da..08c0496 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -134,6 +134,7 @@ export const enum Commands {
GitCommandsRevert = 'gitlens.gitCommands.revert',
GitCommandsSwitch = 'gitlens.gitCommands.switch',
GitCommandsTag = 'gitlens.gitCommands.tag',
+ GitCommandsWorktree = 'gitlens.gitCommands.worktree',
QuickOpenFileHistory = 'gitlens.quickOpenFileHistory',
RefreshHover = 'gitlens.refreshHover',
ResetAvatarCache = 'gitlens.resetAvatarCache',
@@ -175,9 +176,11 @@ export const enum Commands {
ShowSettingsPageAndJumpToSearchAndCompareView = 'gitlens.showSettingsPage#search-compare-view',
ShowSettingsPageAndJumpToStashesView = 'gitlens.showSettingsPage#stashes-view',
ShowSettingsPageAndJumpToTagsView = 'gitlens.showSettingsPage#tags-view',
+ ShowSettingsPageAndJumpToWorkTreesView = 'gitlens.showSettingsPage#worktrees-view',
ShowSettingsPageAndJumpToViews = 'gitlens.showSettingsPage#views',
ShowStashesView = 'gitlens.showStashesView',
ShowTagsView = 'gitlens.showTagsView',
+ ShowWorktreesView = 'gitlens.showWorktreesView',
ShowWelcomePage = 'gitlens.showWelcomePage',
ShowWelcomeView = 'gitlens.showWelcomeView',
StashApply = 'gitlens.stashApply',
diff --git a/src/container.ts b/src/container.ts
index 81770c3..9e4c773 100644
--- a/src/container.ts
+++ b/src/container.ts
@@ -50,6 +50,7 @@ import { StashesView } from './views/stashesView';
import { TagsView } from './views/tagsView';
import { ViewCommands } from './views/viewCommands';
import { ViewFileDecorationProvider } from './views/viewDecorationProvider';
+import { WorktreesView } from './views/worktreesView';
import { VslsController } from './vsls/vsls';
import { HomeWebviewView } from './webviews/premium/home/homeWebviewView';
import { RebaseEditorProvider } from './webviews/rebase/rebaseEditor';
@@ -182,6 +183,7 @@ export class Container {
context.subscriptions.push((this._remotesView = new RemotesView(this)));
context.subscriptions.push((this._stashesView = new StashesView(this)));
context.subscriptions.push((this._tagsView = new TagsView(this)));
+ context.subscriptions.push((this._worktreesView = new WorktreesView(this)));
context.subscriptions.push((this._contributorsView = new ContributorsView(this)));
context.subscriptions.push((this._searchAndCompareView = new SearchAndCompareView(this)));
@@ -507,6 +509,15 @@ export class Container {
return this._welcomeWebview;
}
+ private _worktreesView: WorktreesView | undefined;
+ get worktreesView() {
+ if (this._worktreesView == null) {
+ this._context.subscriptions.push((this._worktreesView = new WorktreesView(this)));
+ }
+
+ return this._worktreesView;
+ }
+
private applyMode(config: Config) {
if (!config.mode.active) return config;
diff --git a/src/env/node/git/git.ts b/src/env/node/git/git.ts
index 83d69c0..1e632d2 100644
--- a/src/env/node/git/git.ts
+++ b/src/env/node/git/git.ts
@@ -27,6 +27,9 @@ export const GitErrors = {
noMergeBase: /no merge base/i,
notAValidObjectName: /Not a valid object name/i,
invalidLineCount: /file .+? has only \d+ lines/i,
+ uncommittedChanges: /contains modified or untracked files/i,
+ alreadyExists: /already exists/i,
+ alreadyCheckedOut: /already checked out/i,
};
const GitWarnings = {
@@ -1492,6 +1495,47 @@ export class Git {
return this.git({ cwd: repoPath }, 'tag', '-l', `--format=${GitTagParser.defaultFormat}`);
}
+ worktree__add(
+ repoPath: string,
+ path: string,
+ {
+ commitish,
+ createBranch,
+ detach,
+ force,
+ }: { commitish?: string; createBranch?: string; detach?: boolean; force?: boolean } = {},
+ ) {
+ const params = ['worktree', 'add'];
+ if (force) {
+ params.push('--force');
+ }
+ if (createBranch) {
+ params.push('-b', createBranch);
+ }
+ if (detach) {
+ params.push('--detach');
+ }
+ params.push(path);
+ if (commitish) {
+ params.push(commitish);
+ }
+ return this.git({ cwd: repoPath }, ...params);
+ }
+
+ worktree__list(repoPath: string) {
+ return this.git({ cwd: repoPath }, 'worktree', 'list', '--porcelain');
+ }
+
+ worktree__remove(repoPath: string, worktree: string, { force }: { force?: boolean } = {}) {
+ const params = ['worktree', 'remove'];
+ if (force) {
+ params.push('--force');
+ }
+ params.push(worktree);
+
+ return this.git({ cwd: repoPath, errors: GitErrorHandling.Throw }, ...params);
+ }
+
async readDotGitFile(
repoPath: string,
paths: string[],
diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts
index a6b21c2..46bf91c 100644
--- a/src/env/node/git/localGitProvider.ts
+++ b/src/env/node/git/localGitProvider.ts
@@ -1,5 +1,5 @@
import { readdir, realpath } from 'fs';
-import { hostname, userInfo } from 'os';
+import { homedir, hostname, userInfo } from 'os';
import { resolve as resolvePath } from 'path';
import {
Disposable,
@@ -7,6 +7,8 @@ import {
Event,
EventEmitter,
extensions,
+ FileStat,
+ FileSystemError,
FileType,
Range,
TextDocument,
@@ -26,7 +28,14 @@ import type {
import { configuration } from '../../../configuration';
import { CoreGitConfiguration, GlyphChars, Schemes } from '../../../constants';
import type { Container } from '../../../container';
-import { StashApplyError, StashApplyErrorReason } from '../../../git/errors';
+import {
+ StashApplyError,
+ StashApplyErrorReason,
+ WorktreeCreateError,
+ WorktreeCreateErrorReason,
+ WorktreeDeleteError,
+ WorktreeDeleteErrorReason,
+} from '../../../git/errors';
import {
Features,
GitProvider,
@@ -74,6 +83,7 @@ import {
GitTag,
GitTreeEntry,
GitUser,
+ GitWorktree,
isUserMatch,
Repository,
RepositoryChange,
@@ -92,6 +102,7 @@ import {
GitStatusParser,
GitTagParser,
GitTreeParser,
+ GitWorktreeParser,
LogType,
} from '../../../git/parsers';
import { RemoteProviderFactory, RemoteProviders } from '../../../git/remotes/factory';
@@ -110,6 +121,7 @@ import {
getBestPath,
isAbsolute,
isFolderGlob,
+ joinPaths,
maybeUri,
normalizePath,
relative,
@@ -3675,11 +3687,7 @@ export class LocalGitProvider implements GitProvider, Disposable {
if (ex instanceof Error) {
const msg: string = ex.message ?? '';
if (msg.includes('Your local changes to the following files would be overwritten by merge')) {
- throw new StashApplyError(
- 'Unable to apply stash. Your working tree changes would be overwritten. Please commit or stash your changes before trying again',
- StashApplyErrorReason.WorkingChanges,
- ex,
- );
+ throw new StashApplyError(StashApplyErrorReason.WorkingChanges, ex);
}
if (
@@ -3693,14 +3701,10 @@ export class LocalGitProvider implements GitProvider, Disposable {
return;
}
- throw new StashApplyError(
- `Unable to apply stash \u2014 ${msg.trim().replace(/\n+?/g, '; ')}`,
- undefined,
- ex,
- );
+ throw new StashApplyError(`Unable to apply stash \u2014 ${msg.trim().replace(/\n+?/g, '; ')}`, ex);
}
- throw new StashApplyError(`Unable to apply stash \u2014 ${String(ex)}`, undefined, ex);
+ throw new StashApplyError(`Unable to apply stash \u2014 ${String(ex)}`, ex);
}
}
@@ -3744,6 +3748,98 @@ export class LocalGitProvider implements GitProvider, Disposable {
});
}
+ @log()
+ async createWorktree(
+ repoPath: string,
+ path: string,
+ options?: { commitish?: string; createBranch?: string; detach?: boolean; force?: boolean },
+ ) {
+ const uri = Uri.file(path);
+
+ let stat: FileStat | undefined;
+ try {
+ stat = await workspace.fs.stat(uri);
+ } catch (ex) {
+ if (!(ex instanceof FileSystemError && ex.code === FileSystemError.FileNotFound().code)) {
+ throw ex;
+ }
+ }
+
+ if (stat?.type !== FileType.Directory) {
+ await workspace.fs.createDirectory(uri);
+ }
+
+ try {
+ await this.git.worktree__add(repoPath, path, options);
+ } catch (ex) {
+ Logger.error(ex);
+
+ const msg = String(ex);
+ if (GitErrors.alreadyCheckedOut.test(msg)) {
+ throw new WorktreeCreateError(WorktreeCreateErrorReason.AlreadyCheckedOut, ex);
+ }
+
+ if (GitErrors.alreadyExists.test(msg)) {
+ throw new WorktreeCreateError(WorktreeCreateErrorReason.AlreadyExists, ex);
+ }
+
+ throw new WorktreeCreateError(undefined, ex);
+ }
+ }
+
+ @gate()
+ @log()
+ async getWorktrees(repoPath: string): Promise {
+ await this.ensureGitVersion(
+ '2.7.6',
+ 'Displaying worktrees',
+ ' Please install a more recent version of Git and try again.',
+ );
+
+ const data = await this.git.worktree__list(repoPath);
+ return GitWorktreeParser.parse(data, repoPath);
+ }
+
+ @log()
+ async getWorktreesDefaultUri(repoPath: string): Promise {
+ let location = configuration.get(
+ 'worktrees.defaultLocation',
+ workspace.getWorkspaceFolder(this.getAbsoluteUri(repoPath, repoPath)),
+ );
+ if (location == null) {
+ const dotGit = await this.getGitDir(repoPath);
+ return Uri.joinPath(Uri.file(dotGit), '.worktrees');
+ }
+
+ if (location.startsWith('~')) {
+ location = joinPaths(homedir(), location.slice(1));
+ }
+
+ return this.getAbsoluteUri(location, repoPath); //isAbsolute(location) ? GitUri.file(location) : ;
+ }
+
+ @log()
+ async deleteWorktree(repoPath: string, path: string, options?: { force?: boolean }) {
+ await this.ensureGitVersion(
+ '2.17.0',
+ 'Deleting worktrees',
+ ' Please install a more recent version of Git and try again.',
+ );
+
+ try {
+ await this.git.worktree__remove(repoPath, path, options);
+ } catch (ex) {
+ Logger.error(ex);
+
+ const msg = String(ex);
+ if (GitErrors.uncommittedChanges.test(msg)) {
+ throw new WorktreeDeleteError(WorktreeDeleteErrorReason.HasChanges, ex);
+ }
+
+ throw new WorktreeDeleteError(undefined, ex);
+ }
+ }
+
private _scmGitApi: Promise | undefined;
private async getScmGitApi(): Promise {
return this._scmGitApi ?? (this._scmGitApi = this.getScmGitApiCore());
diff --git a/src/git/errors.ts b/src/git/errors.ts
index 93a2dc6..61c9105 100644
--- a/src/git/errors.ts
+++ b/src/git/errors.ts
@@ -3,13 +3,98 @@ export const enum StashApplyErrorReason {
}
export class StashApplyError extends Error {
- constructor(
- message: string,
- public readonly reason: StashApplyErrorReason | undefined,
- public readonly original?: Error,
- ) {
+ readonly original?: Error;
+ readonly reason: StashApplyErrorReason | undefined;
+
+ constructor(reason?: StashApplyErrorReason, original?: Error);
+ constructor(message?: string, original?: Error);
+ constructor(messageOrReason: string | StashApplyErrorReason | undefined, original?: Error) {
+ let message;
+ let reason: StashApplyErrorReason | undefined;
+ if (messageOrReason == null) {
+ message = 'Unable to apply stash';
+ } else if (typeof messageOrReason === 'string') {
+ message = messageOrReason;
+ reason = undefined;
+ } else {
+ reason = messageOrReason;
+ message =
+ 'Unable to apply stash. Your working tree changes would be overwritten. Please commit or stash your changes before trying again';
+ }
super(message);
+ this.original = original;
+ this.reason = reason;
Error.captureStackTrace?.(this, StashApplyError);
}
}
+
+export const enum WorktreeCreateErrorReason {
+ AlreadyCheckedOut = 1,
+ AlreadyExists = 2,
+}
+
+export class WorktreeCreateError extends Error {
+ readonly original?: Error;
+ readonly reason: WorktreeCreateErrorReason | undefined;
+
+ constructor(reason?: WorktreeCreateErrorReason, original?: Error);
+ constructor(message?: string, original?: Error);
+ constructor(messageOrReason: string | WorktreeCreateErrorReason | undefined, original?: Error) {
+ let message;
+ let reason: WorktreeCreateErrorReason | undefined;
+ if (messageOrReason == null) {
+ message = 'Unable to create worktree';
+ } else if (typeof messageOrReason === 'string') {
+ message = messageOrReason;
+ reason = undefined;
+ } else {
+ reason = messageOrReason;
+ switch (reason) {
+ case WorktreeCreateErrorReason.AlreadyCheckedOut:
+ message = 'Unable to create worktree because it is already checked out';
+ break;
+ case WorktreeCreateErrorReason.AlreadyExists:
+ message = 'Unable to create worktree because it already exists';
+ break;
+ }
+ }
+ super(message);
+
+ this.original = original;
+ this.reason = reason;
+ Error.captureStackTrace?.(this, WorktreeCreateError);
+ }
+}
+
+export const enum WorktreeDeleteErrorReason {
+ HasChanges = 1,
+}
+
+export class WorktreeDeleteError extends Error {
+ readonly original?: Error;
+ readonly reason: WorktreeDeleteErrorReason | undefined;
+
+ constructor(reason?: WorktreeDeleteErrorReason, original?: Error);
+ constructor(message?: string, original?: Error);
+ constructor(messageOrReason: string | WorktreeDeleteErrorReason | undefined, original?: Error) {
+ let message;
+ let reason: WorktreeDeleteErrorReason | undefined;
+ if (messageOrReason == null) {
+ message = 'Unable to delete worktree';
+ } else if (typeof messageOrReason === 'string') {
+ message = messageOrReason;
+ reason = undefined;
+ } else {
+ reason = messageOrReason;
+ if (reason === WorktreeDeleteErrorReason.HasChanges) {
+ message = 'Unable to delete worktree because there are uncommitted changes';
+ }
+ }
+ super(message);
+
+ this.original = original;
+ this.reason = reason;
+ Error.captureStackTrace?.(this, WorktreeDeleteError);
+ }
+}
diff --git a/src/git/gitProvider.ts b/src/git/gitProvider.ts
index cc0646a..25dc021 100644
--- a/src/git/gitProvider.ts
+++ b/src/git/gitProvider.ts
@@ -27,6 +27,7 @@ import {
GitTag,
GitTreeEntry,
GitUser,
+ GitWorktree,
Repository,
RepositoryChangeEvent,
TagSortOptions,
@@ -89,9 +90,13 @@ export interface RepositoryOpenEvent {
readonly uri: Uri;
}
-export const enum Features {}
+export const enum Features {
+ Worktrees = 'worktrees',
+}
-export const enum PremiumFeatures {}
+export const enum PremiumFeatures {
+ Worktrees = 'worktrees',
+}
export const enum RepositoryVisibility {
Private = 'private',
@@ -426,6 +431,15 @@ export interface GitProvider extends Disposable {
uris?: Uri[],
options?: { includeUntracked?: boolean | undefined; keepIndex?: boolean | undefined },
): Promise;
+
+ createWorktree?(
+ repoPath: string,
+ path: string,
+ options?: { commitish?: string; createBranch?: string; detach?: boolean; force?: boolean },
+ ): Promise;
+ getWorktrees?(repoPath: string): Promise;
+ getWorktreesDefaultUri?(repoPath: string): Promise;
+ deleteWorktree?(repoPath: string, path: string, options?: { force?: boolean }): Promise;
}
export interface RevisionUriData {
diff --git a/src/git/gitProviderService.ts b/src/git/gitProviderService.ts
index 73291f7..d63947d 100644
--- a/src/git/gitProviderService.ts
+++ b/src/git/gitProviderService.ts
@@ -81,6 +81,7 @@ import {
GitTag,
GitTreeEntry,
GitUser,
+ GitWorktree,
PullRequest,
PullRequestState,
Repository,
@@ -1798,7 +1799,6 @@ export class GitProviderService implements Disposable {
getBestRepository(uri?: Uri): Repository | undefined;
getBestRepository(editor?: TextEditor): Repository | undefined;
getBestRepository(uri?: TextEditor | Uri, editor?: TextEditor): Repository | undefined;
-
@log({ exit: r => `returned ${r?.path}` })
getBestRepository(editorOrUri?: TextEditor | Uri, editor?: TextEditor): Repository | undefined {
if (this.repositoryCount === 0) return undefined;
@@ -2157,6 +2157,33 @@ export class GitProviderService implements Disposable {
}
@log()
+ createWorktree(
+ repoPath: string | Uri,
+ path: string,
+ options?: { commitish?: string; createBranch?: string; detach?: boolean; force?: boolean },
+ ): Promise {
+ const { provider, path: rp } = this.getProvider(repoPath);
+ return Promise.resolve(provider.createWorktree?.(rp, path, options));
+ }
+
+ @log()
+ async getWorktrees(repoPath: string | Uri): Promise {
+ const { provider, path } = this.getProvider(repoPath);
+ return (await provider.getWorktrees?.(path)) ?? [];
+ }
+
+ @log()
+ async getWorktreesDefaultUri(path: string | Uri): Promise {
+ const { provider, path: rp } = this.getProvider(path);
+ return provider.getWorktreesDefaultUri?.(rp);
+ }
+
+ @log()
+ deleteWorktree(repoPath: string | Uri, path: string, options?: { force?: boolean }): Promise {
+ const { provider, path: rp } = this.getProvider(repoPath);
+ return Promise.resolve(provider.deleteWorktree?.(rp, path, options));
+ }
+ @log()
async getOpenScmRepositories(): Promise {
const results = await Promise.allSettled([...this._providers.values()].map(p => p.getOpenScmRepositories()));
const repositories = flatMap, ScmRepository>(
diff --git a/src/git/models.ts b/src/git/models.ts
index 1f997d9..512bb59 100644
--- a/src/git/models.ts
+++ b/src/git/models.ts
@@ -22,3 +22,4 @@ export * from './models/status';
export * from './models/tag';
export * from './models/tree';
export * from './models/user';
+export * from './models/worktree';
diff --git a/src/git/models/remote.ts b/src/git/models/remote.ts
index ff20bdc..c69218b 100644
--- a/src/git/models/remote.ts
+++ b/src/git/models/remote.ts
@@ -9,7 +9,7 @@ export const enum GitRemoteType {
}
export class GitRemote {
- static getHighlanderProviders(remotes: GitRemote[]) {
+ static getHighlanderProviders(remotes: GitRemote[]) {
if (remotes.length === 0) return undefined;
const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default);
@@ -21,7 +21,7 @@ export class GitRemote[]) {
+ static getHighlanderProviderName(remotes: GitRemote[]) {
if (remotes.length === 0) return undefined;
const remote = remotes.length === 1 ? remotes[0] : remotes.find(r => r.default);
diff --git a/src/git/models/repository.ts b/src/git/models/repository.ts
index 4eee5ec..d097311 100644
--- a/src/git/models/repository.ts
+++ b/src/git/models/repository.ts
@@ -43,6 +43,7 @@ import { GitRemote } from './remote';
import { GitStash } from './stash';
import { GitStatus } from './status';
import { GitTag, TagSortOptions } from './tag';
+import { GitWorktree } from './worktree';
const millisecondsPerMinute = 60 * 1000;
const millisecondsPerHour = 60 * 60 * 1000;
@@ -72,6 +73,7 @@ export const enum RepositoryChange {
*/
Status = 'status',
Tags = 'tags',
+ Worktrees = 'worktrees',
}
export const enum RepositoryChangeComparisonMode {
@@ -247,6 +249,7 @@ export class Repository implements Disposable {
**/.git/refs/**,\
**/.git/rebase-merge/**,\
**/.git/sequencer/**,\
+**/.git/worktrees/**,\
**/.gitignore\
}',
),
@@ -306,7 +309,7 @@ export class Repository implements Disposable {
const match =
uri != null
- ? /(?\/\.gitignore)|\.git\/(?config|index|HEAD|FETCH_HEAD|ORIG_HEAD|CHERRY_PICK_HEAD|MERGE_HEAD|REBASE_HEAD|rebase-merge|refs\/(?:heads|remotes|stash|tags))/.exec(
+ ? /(?\/\.gitignore)|\.git\/(?config|index|HEAD|FETCH_HEAD|ORIG_HEAD|CHERRY_PICK_HEAD|MERGE_HEAD|REBASE_HEAD|rebase-merge|refs\/(?:heads|remotes|stash|tags)|worktrees)/.exec(
uri.path,
)
: undefined;
@@ -368,6 +371,10 @@ export class Repository implements Disposable {
case 'refs/tags':
this.fireChange(RepositoryChange.Tags);
return;
+
+ case 'worktrees':
+ this.fireChange(RepositoryChange.Worktrees);
+ return;
}
}
@@ -603,6 +610,25 @@ export class Repository implements Disposable {
return this.container.git.getTags(this.path, options);
}
+ createWorktree(
+ uri: Uri,
+ options?: { commitish?: string; createBranch?: string; detach?: boolean; force?: boolean },
+ ): Promise {
+ return this.container.git.createWorktree(this.path, uri.fsPath, options);
+ }
+
+ getWorktrees(): Promise {
+ return this.container.git.getWorktrees(this.path);
+ }
+
+ async getWorktreesDefaultUri(): Promise {
+ return this.container.git.getWorktreesDefaultUri(this.path);
+ }
+
+ deleteWorktree(uri: Uri, options?: { force?: boolean }): Promise {
+ return this.container.git.deleteWorktree(this.path, uri.fsPath, options);
+ }
+
async hasRemotes(): Promise {
const remotes = await this.getRemotes();
return remotes?.length > 0;
diff --git a/src/git/models/worktree.ts b/src/git/models/worktree.ts
new file mode 100644
index 0000000..f07cc40
--- /dev/null
+++ b/src/git/models/worktree.ts
@@ -0,0 +1,66 @@
+import { Uri, workspace, WorkspaceFolder } from 'vscode';
+import { Container } from '../../container';
+import { memoize } from '../../system/decorators/memoize';
+import { normalizePath, relative } from '../../system/path';
+import { GitRevision } from './reference';
+import type { GitStatus } from './status';
+
+export class GitWorktree {
+ static is(worktree: any): worktree is GitWorktree {
+ return worktree instanceof GitWorktree;
+ }
+
+ constructor(
+ public readonly type: 'bare' | 'branch' | 'detached',
+ public readonly repoPath: string,
+ public readonly uri: Uri,
+ public readonly locked: boolean | string,
+ public readonly prunable: boolean | string,
+ public readonly sha?: string,
+ public readonly branch?: string,
+ ) {}
+
+ get opened(): boolean {
+ return this.workspaceFolder?.uri.toString() === this.uri.toString();
+ }
+
+ get name(): string {
+ switch (this.type) {
+ case 'bare':
+ return '(bare)';
+ case 'detached':
+ return GitRevision.shorten(this.sha);
+ default:
+ return this.branch || this.friendlyPath;
+ }
+ }
+
+ @memoize()
+ get friendlyPath(): string {
+ const path = GitWorktree.getFriendlyPath(this.uri);
+ return path;
+ }
+
+ @memoize()
+ get workspaceFolder(): WorkspaceFolder | undefined {
+ return workspace.getWorkspaceFolder(this.uri);
+ }
+
+ private _status: Promise | undefined;
+ getStatus(options?: { force?: boolean }): Promise {
+ if (this.type === 'bare') return Promise.resolve(undefined);
+
+ if (this._status == null || options?.force) {
+ this._status = Container.instance.git.getStatusForRepo(this.uri.fsPath);
+ }
+ return this._status;
+ }
+
+ static getFriendlyPath(uri: Uri): string {
+ const folder = workspace.getWorkspaceFolder(uri);
+ if (folder == null) return normalizePath(uri.fsPath);
+
+ const relativePath = normalizePath(relative(folder.uri.fsPath, uri.fsPath));
+ return relativePath.length === 0 ? folder.name : relativePath;
+ }
+}
diff --git a/src/git/parsers.ts b/src/git/parsers.ts
index 5e2bbba..b470c6b 100644
--- a/src/git/parsers.ts
+++ b/src/git/parsers.ts
@@ -8,3 +8,4 @@ export * from './parsers/stashParser';
export * from './parsers/statusParser';
export * from './parsers/tagParser';
export * from './parsers/treeParser';
+export * from './parsers/worktreeParser';
diff --git a/src/git/parsers/worktreeParser.ts b/src/git/parsers/worktreeParser.ts
new file mode 100644
index 0000000..52ec69b
--- /dev/null
+++ b/src/git/parsers/worktreeParser.ts
@@ -0,0 +1,88 @@
+import { Uri } from 'vscode';
+import { debug } from '../../system/decorators/log';
+import { normalizePath } from '../../system/path';
+import { getLines } from '../../system/string';
+import { GitWorktree } from '../models/worktree';
+
+interface WorktreeEntry {
+ path: string;
+ sha?: string;
+ branch?: string;
+ bare: boolean;
+ detached: boolean;
+ locked?: boolean | string;
+ prunable?: boolean | string;
+}
+
+export class GitWorktreeParser {
+ @debug({ args: false, singleLine: true })
+ static parse(data: string, repoPath: string): GitWorktree[] {
+ if (!data) return [];
+
+ if (repoPath !== undefined) {
+ repoPath = normalizePath(repoPath);
+ }
+
+ const worktrees: GitWorktree[] = [];
+
+ let entry: Partial | undefined = undefined;
+ let line: string;
+ let key: string;
+ let value: string;
+ let locked: string;
+ let prunable: string;
+
+ for (line of getLines(data)) {
+ [key, value] = line.split(' ', 2);
+
+ if (key.length === 0 && entry !== undefined) {
+ worktrees.push(
+ new GitWorktree(
+ entry.bare ? 'bare' : entry.detached ? 'detached' : 'branch',
+ repoPath,
+ Uri.file(entry.path!),
+ entry.locked ?? false,
+ entry.prunable ?? false,
+ entry.sha,
+ entry.branch,
+ ),
+ );
+ entry = undefined;
+ continue;
+ }
+
+ if (entry === undefined) {
+ entry = {};
+ }
+
+ switch (key) {
+ case 'worktree':
+ entry.path = value;
+ break;
+ case 'bare':
+ entry.bare = true;
+ break;
+ case 'HEAD':
+ entry.sha = value;
+ break;
+ case 'branch':
+ // Strip off refs/heads/
+ entry.branch = value.substr(11);
+ break;
+ case 'detached':
+ entry.detached = true;
+ break;
+ case 'locked':
+ [, locked] = value.split(' ', 2);
+ entry.locked = locked?.trim() || true;
+ break;
+ case 'prunable':
+ [, prunable] = value.split(' ', 2);
+ entry.prunable = prunable?.trim() || true;
+ break;
+ }
+ }
+
+ return worktrees;
+ }
+}
diff --git a/src/quickpicks/items/gitCommands.ts b/src/quickpicks/items/gitCommands.ts
index c8aec7e..b2a6791 100644
--- a/src/quickpicks/items/gitCommands.ts
+++ b/src/quickpicks/items/gitCommands.ts
@@ -11,7 +11,9 @@ import {
GitReference,
GitRemoteType,
GitRevision,
+ GitStatus,
GitTag,
+ GitWorktree,
Repository,
} from '../../git/models';
import { fromNow } from '../../system/date';
@@ -438,3 +440,71 @@ export namespace TagQuickPickItem {
return item;
}
}
+
+export interface WorktreeQuickPickItem extends QuickPickItemOfT {
+ readonly opened: boolean;
+ readonly hasChanges: boolean | undefined;
+}
+
+export namespace WorktreeQuickPickItem {
+ export function create(
+ worktree: GitWorktree,
+ picked?: boolean,
+ options: {
+ alwaysShow?: boolean;
+ buttons?: QuickInputButton[];
+ checked?: boolean;
+ message?: boolean;
+ ref?: boolean;
+ type?: boolean;
+ status?: GitStatus;
+ } = {},
+ ) {
+ let description = '';
+ if (options.type) {
+ description = 'worktree';
+ }
+
+ if (options.status != null) {
+ description += options.status.hasChanges
+ ? pad(`Uncommited Changes (${options.status.getFormattedDiffStatus()})`, description ? 2 : 0, 0)
+ : pad('No Changes', description ? 2 : 0, 0);
+ }
+
+ if (options.ref) {
+ description += `${description ? pad(GlyphChars.Dot, 2, 2) : ''}${worktree.friendlyPath}`;
+ }
+
+ let icon;
+ let label;
+ switch (worktree.type) {
+ case 'bare':
+ label = '(bare)';
+ icon = '$(folder)';
+ break;
+ case 'branch':
+ label = worktree.branch!;
+ icon = '$(git-branch)';
+ break;
+ case 'detached':
+ label = GitRevision.shorten(worktree.sha);
+ icon = '$(git-commit)';
+ break;
+ }
+
+ const item: WorktreeQuickPickItem = {
+ label: `${pad(icon, 0, 2)}${label}${
+ options.checked ? `${GlyphChars.Space.repeat(2)}$(check)${GlyphChars.Space}` : ''
+ }`,
+ description: description,
+ alwaysShow: options.alwaysShow,
+ buttons: options.buttons,
+ picked: picked,
+ item: worktree,
+ opened: worktree.opened,
+ hasChanges: options.status?.hasChanges,
+ };
+
+ return item;
+ }
+}
diff --git a/src/views/nodes.ts b/src/views/nodes.ts
index 6bb01c1..cad7ac1 100644
--- a/src/views/nodes.ts
+++ b/src/views/nodes.ts
@@ -42,3 +42,5 @@ export * from './nodes/statusFileNode';
export * from './nodes/statusFilesNode';
export * from './nodes/tagsNode';
export * from './nodes/tagNode';
+export * from './nodes/worktreeNode';
+export * from './nodes/worktreesNode';
diff --git a/src/views/nodes/UncommittedFileNode.ts b/src/views/nodes/UncommittedFileNode.ts
new file mode 100644
index 0000000..e7106f0
--- /dev/null
+++ b/src/views/nodes/UncommittedFileNode.ts
@@ -0,0 +1,123 @@
+import { Command, TreeItem, TreeItemCollapsibleState } from 'vscode';
+import { DiffWithPreviousCommandArgs } from '../../commands';
+import { Commands } from '../../constants';
+import { StatusFileFormatter } from '../../git/formatters';
+import { GitUri } from '../../git/gitUri';
+import { GitFile } from '../../git/models';
+import { dirname, joinPaths } from '../../system/path';
+import { ViewsWithCommits } from '../viewBase';
+import { FileNode } from './folderNode';
+import { ContextValues, ViewNode } from './viewNode';
+
+export class UncommittedFileNode extends ViewNode implements FileNode {
+ public readonly file: GitFile;
+ public readonly repoPath: string;
+
+ constructor(view: ViewsWithCommits, parent: ViewNode, repoPath: string, file: GitFile) {
+ super(GitUri.fromFile(file, repoPath), view, parent);
+
+ this.repoPath = repoPath;
+ this.file = file;
+ }
+
+ override toClipboard(): string {
+ return this.path;
+ }
+
+ get path(): string {
+ return this.file.path;
+ }
+
+ getChildren(): ViewNode[] {
+ return [];
+ }
+
+ getTreeItem(): TreeItem {
+ const item = new TreeItem(this.label, TreeItemCollapsibleState.None);
+ item.contextValue = ContextValues.File;
+ item.description = this.description;
+ // Use the file icon and decorations
+ item.resourceUri = this.view.container.git.getAbsoluteUri(this.file.path, this.repoPath);
+
+ const icon = GitFile.getStatusIcon(this.file.status);
+ item.iconPath = {
+ dark: this.view.container.context.asAbsolutePath(joinPaths('images', 'dark', icon)),
+ light: this.view.container.context.asAbsolutePath(joinPaths('images', 'light', icon)),
+ };
+
+ item.tooltip = StatusFileFormatter.fromTemplate(
+ `\${file}\n\${directory}/\n\n\${status}\${ (originalPath)}`,
+ this.file,
+ );
+
+ item.command = this.getCommand();
+
+ // Only cache the label/description for a single refresh
+ this._label = undefined;
+ this._description = undefined;
+
+ return item;
+ }
+
+ private _description: string | undefined;
+ get description() {
+ if (this._description == null) {
+ this._description = StatusFileFormatter.fromTemplate(
+ this.view.config.formats.files.description,
+ { ...this.file },
+ { relativePath: this.relativePath },
+ );
+ }
+ return this._description;
+ }
+
+ private _folderName: string | undefined;
+ get folderName() {
+ if (this._folderName == null) {
+ this._folderName = dirname(this.uri.relativePath);
+ }
+ return this._folderName;
+ }
+
+ private _label: string | undefined;
+ get label() {
+ if (this._label == null) {
+ this._label = StatusFileFormatter.fromTemplate(
+ `\${file}`,
+ { ...this.file },
+ { relativePath: this.relativePath },
+ );
+ }
+ return this._label;
+ }
+
+ get priority(): number {
+ return 0;
+ }
+
+ private _relativePath: string | undefined;
+ get relativePath(): string | undefined {
+ return this._relativePath;
+ }
+ set relativePath(value: string | undefined) {
+ this._relativePath = value;
+ this._label = undefined;
+ this._description = undefined;
+ }
+
+ override getCommand(): Command | undefined {
+ const commandArgs: DiffWithPreviousCommandArgs = {
+ uri: GitUri.fromFile(this.file, this.repoPath),
+ line: 0,
+ showOptions: {
+ preserveFocus: true,
+ preview: true,
+ },
+ };
+ return {
+ title: 'Open Changes with Previous Revision',
+ command: Commands.DiffWithPrevious,
+ arguments: [undefined, commandArgs],
+ };
+ }
+}
diff --git a/src/views/nodes/UncommittedFilesNode.ts b/src/views/nodes/UncommittedFilesNode.ts
new file mode 100644
index 0000000..485ae5d
--- /dev/null
+++ b/src/views/nodes/UncommittedFilesNode.ts
@@ -0,0 +1,143 @@
+'use strict';
+import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode';
+import { ViewFilesLayout } from '../../config';
+import { GitUri } from '../../git/gitUri';
+import {
+ GitCommit,
+ GitCommitIdentity,
+ GitFileChange,
+ GitFileWithCommit,
+ GitRevision,
+ GitStatus,
+ GitStatusFile,
+ GitTrackingState,
+} from '../../git/models';
+import { groupBy, makeHierarchical } from '../../system/array';
+import { flatMap } from '../../system/iterable';
+import { joinPaths, normalizePath } from '../../system/path';
+import { RepositoriesView } from '../repositoriesView';
+import { WorktreesView } from '../worktreesView';
+import { FileNode, FolderNode } from './folderNode';
+import { RepositoryNode } from './repositoryNode';
+import { UncommittedFileNode } from './UncommittedFileNode';
+import { ContextValues, ViewNode } from './viewNode';
+
+export class UncommittedFilesNode extends ViewNode {
+ static key = ':uncommitted-files';
+ static getId(repoPath: string): string {
+ return `${RepositoryNode.getId(repoPath)}${this.key}`;
+ }
+
+ readonly repoPath: string;
+
+ constructor(
+ view: RepositoriesView | WorktreesView,
+ parent: ViewNode,
+ public readonly status:
+ | GitStatus
+ | {
+ readonly repoPath: string;
+ readonly files: GitStatusFile[];
+ readonly state: GitTrackingState;
+ readonly upstream?: string;
+ },
+ public readonly range: string | undefined,
+ ) {
+ super(GitUri.fromRepoPath(status.repoPath), view, parent);
+ this.repoPath = status.repoPath;
+ }
+
+ override get id(): string {
+ return UncommittedFilesNode.getId(this.repoPath);
+ }
+
+ getChildren(): ViewNode[] {
+ const repoPath = this.repoPath;
+
+ const files: GitFileWithCommit[] = [
+ ...flatMap(this.status.files, f => {
+ if (f.workingTreeStatus != null && f.indexStatus != null) {
+ // Decrements the date to guarantee this entry will be sorted after the previous entry (most recent first)
+ const older = new Date();
+ older.setMilliseconds(older.getMilliseconds() - 1);
+
+ return [
+ this.getFileWithPseudoCommit(f, GitRevision.uncommitted, GitRevision.uncommittedStaged),
+ this.getFileWithPseudoCommit(f, GitRevision.uncommittedStaged, 'HEAD', older),
+ ];
+ } else if (f.indexStatus != null) {
+ return [this.getFileWithPseudoCommit(f, GitRevision.uncommittedStaged, 'HEAD')];
+ }
+
+ return [this.getFileWithPseudoCommit(f, GitRevision.uncommitted, 'HEAD')];
+ }),
+ ];
+
+ files.sort((a, b) => b.commit.date.getTime() - a.commit.date.getTime());
+
+ const groups = groupBy(files, f => f.path);
+
+ let children: FileNode[] = Object.values(groups).map(
+ files => new UncommittedFileNode(this.view, this, repoPath, files[files.length - 1]),
+ );
+
+ if (this.view.config.files.layout !== ViewFilesLayout.List) {
+ const hierarchy = makeHierarchical(
+ children,
+ n => n.uri.relativePath.split('/'),
+ (...parts: string[]) => normalizePath(joinPaths(...parts)),
+ this.view.config.files.compact,
+ );
+
+ const root = new FolderNode(this.view, this, repoPath, '', hierarchy, true);
+ children = root.getChildren() as FileNode[];
+ } else {
+ children.sort(
+ (a, b) =>
+ a.priority - b.priority ||
+ a.label!.localeCompare(b.label!, undefined, { numeric: true, sensitivity: 'base' }),
+ );
+ }
+
+ return children;
+ }
+
+ getTreeItem(): TreeItem {
+ const item = new TreeItem('Uncommitted changes', TreeItemCollapsibleState.Collapsed);
+ item.id = this.id;
+ item.contextValue = ContextValues.UncommittedFiles;
+ item.iconPath = new ThemeIcon('folder');
+
+ return item;
+ }
+
+ private getFileWithPseudoCommit(
+ file: GitStatusFile,
+ ref: string,
+ previousRef: string,
+ date?: Date,
+ ): GitFileWithCommit {
+ date = date ?? new Date();
+ return {
+ status: file.status,
+ repoPath: file.repoPath,
+ indexStatus: file.indexStatus,
+ workingTreeStatus: file.workingTreeStatus,
+ path: file.path,
+ originalPath: file.originalPath,
+ commit: new GitCommit(
+ this.view.container,
+ file.repoPath,
+ ref,
+ new GitCommitIdentity('You', undefined, date),
+ new GitCommitIdentity('You', undefined, date),
+ 'Uncommitted changes',
+ [previousRef],
+ 'Uncommitted changes',
+ new GitFileChange(file.repoPath, file.path, file.status, file.originalPath, previousRef),
+ undefined,
+ [],
+ ),
+ };
+ }
+}
diff --git a/src/views/nodes/repositoryNode.ts b/src/views/nodes/repositoryNode.ts
index e4e2f7b..256a279 100644
--- a/src/views/nodes/repositoryNode.ts
+++ b/src/views/nodes/repositoryNode.ts
@@ -31,6 +31,7 @@ import { StashesNode } from './stashesNode';
import { StatusFilesNode } from './statusFilesNode';
import { TagsNode } from './tagsNode';
import { ContextValues, SubscribeableViewNode, ViewNode } from './viewNode';
+import { WorktreesNode } from './worktreesNode';
export class RepositoryNode extends SubscribeableViewNode {
static key = ':repository';
@@ -159,6 +160,10 @@ export class RepositoryNode extends SubscribeableViewNode {
children.push(new TagsNode(this.uri, this.view, this, this.repo));
}
+ if (this.view.config.showWorktrees) {
+ children.push(new WorktreesNode(this.uri, this.view, this, this.repo));
+ }
+
if (this.view.config.showContributors) {
children.push(new ContributorsNode(this.uri, this.view, this, this.repo));
}
diff --git a/src/views/nodes/statusFilesNode.ts b/src/views/nodes/statusFilesNode.ts
index 4255ee2..e8fd34b 100644
--- a/src/views/nodes/statusFilesNode.ts
+++ b/src/views/nodes/statusFilesNode.ts
@@ -7,12 +7,13 @@ import { filter, flatMap, map } from '../../system/iterable';
import { joinPaths, normalizePath } from '../../system/path';
import { pluralize, sortCompare } from '../../system/string';
import { RepositoriesView } from '../repositoriesView';
+import { WorktreesView } from '../worktreesView';
import { FileNode, FolderNode } from './folderNode';
import { RepositoryNode } from './repositoryNode';
import { StatusFileNode } from './statusFileNode';
import { ContextValues, ViewNode } from './viewNode';
-export class StatusFilesNode extends ViewNode {
+export class StatusFilesNode extends ViewNode {
static key = ':status-files';
static getId(repoPath: string): string {
return `${RepositoryNode.getId(repoPath)}${this.key}`;
@@ -21,7 +22,7 @@ export class StatusFilesNode extends ViewNode {
readonly repoPath: string;
constructor(
- view: RepositoriesView,
+ view: RepositoriesView | WorktreesView,
parent: ViewNode,
public readonly status:
| GitStatus
@@ -66,7 +67,10 @@ export class StatusFilesNode extends ViewNode {
}
}
- if (this.view.config.includeWorkingTree && this.status.files.length !== 0) {
+ if (
+ (this.view instanceof WorktreesView || this.view.config.includeWorkingTree) &&
+ this.status.files.length !== 0
+ ) {
files.splice(
0,
0,
@@ -109,7 +113,8 @@ export class StatusFilesNode extends ViewNode {
}
async getTreeItem(): Promise {
- let files = this.view.config.includeWorkingTree ? this.status.files.length : 0;
+ let files =
+ this.view instanceof WorktreesView || this.view.config.includeWorkingTree ? this.status.files.length : 0;
if (this.range != null) {
if (this.status.upstream != null && this.status.state.ahead > 0) {
diff --git a/src/views/nodes/viewNode.ts b/src/views/nodes/viewNode.ts
index b45a519..5434c2f 100644
--- a/src/views/nodes/viewNode.ts
+++ b/src/views/nodes/viewNode.ts
@@ -84,6 +84,9 @@ export const enum ContextValues {
StatusSameAsUpstream = 'gitlens:status:upstream:same',
Tag = 'gitlens:tag',
Tags = 'gitlens:tags',
+ UncommittedFiles = 'gitlens:uncommitted:files',
+ Worktree = 'gitlens:worktree',
+ Worktrees = 'gitlens:worktrees',
}
export interface ViewNode {
diff --git a/src/views/nodes/worktreeNode.ts b/src/views/nodes/worktreeNode.ts
new file mode 100644
index 0000000..5c48627
--- /dev/null
+++ b/src/views/nodes/worktreeNode.ts
@@ -0,0 +1,256 @@
+import { MarkdownString, ThemeIcon, TreeItem, TreeItemCollapsibleState, Uri, window } from 'vscode';
+import { GlyphChars } from '../../constants';
+import { GitUri } from '../../git/gitUri';
+import { GitLog, GitRemote, GitRemoteType, GitRevision, GitWorktree } from '../../git/models';
+import { gate } from '../../system/decorators/gate';
+import { debug } from '../../system/decorators/log';
+import { map } from '../../system/iterable';
+import { pad } from '../../system/string';
+import { RepositoriesView } from '../repositoriesView';
+import { WorktreesView } from '../worktreesView';
+import { CommitNode } from './commitNode';
+import { LoadMoreNode, MessageNode } from './common';
+import { insertDateMarkers } from './helpers';
+import { RepositoryNode } from './repositoryNode';
+import { UncommittedFilesNode } from './UncommittedFilesNode';
+import { ContextValues, ViewNode } from './viewNode';
+
+export class WorktreeNode extends ViewNode {
+ static key = ':worktree';
+ static getId(repoPath: string, uri: Uri): string {
+ return `${RepositoryNode.getId(repoPath)}${this.key}(${uri.path})`;
+ }
+
+ constructor(
+ uri: GitUri,
+ view: WorktreesView | RepositoriesView,
+ parent: ViewNode,
+ public readonly worktree: GitWorktree,
+ ) {
+ super(uri, view, parent);
+ }
+
+ override toClipboard(): string {
+ return this.worktree.uri.fsPath;
+ }
+
+ override get id(): string {
+ return WorktreeNode.getId(this.worktree.repoPath, this.worktree.uri);
+ }
+
+ get repoPath(): string {
+ return this.uri.repoPath!;
+ }
+
+ async getChildren(): Promise {
+ const log = await this.getLog();
+ if (log == null) return [new MessageNode(this.view, this, 'No commits could be found.')];
+
+ const getBranchAndTagTips = await this.view.container.git.getBranchesAndTagsTipsFn(this.uri.repoPath);
+ const children = [
+ ...insertDateMarkers(
+ map(
+ log.commits.values(),
+ c => new CommitNode(this.view, this, c, undefined, undefined, getBranchAndTagTips),
+ ),
+ this,
+ ),
+ ];
+
+ if (log.hasMore) {
+ children.push(new LoadMoreNode(this.view, this, children[children.length - 1]));
+ }
+
+ const status = await this.worktree.getStatus();
+ if (status?.hasChanges) {
+ children.splice(0, 0, new UncommittedFilesNode(this.view, this, status, undefined));
+ }
+ return children;
+ }
+
+ async getTreeItem(): Promise {
+ this.splatted = false;
+
+ let description = '';
+ const tooltip = new MarkdownString('', true);
+ let icon: ThemeIcon | undefined;
+ let hasChanges = false;
+ switch (this.worktree.type) {
+ case 'bare':
+ icon = new ThemeIcon('folder');
+ tooltip.appendMarkdown(`Bare Worktree\\\n\`${this.worktree.friendlyPath}\``);
+ break;
+ case 'branch': {
+ const [branch, status] = await Promise.all([
+ this.worktree.branch
+ ? this.view.container.git
+ .getBranches(this.uri.repoPath, { filter: b => b.name === this.worktree.branch })
+ .then(b => b.values[0])
+ : undefined,
+ this.worktree.getStatus(),
+ ]);
+
+ tooltip.appendMarkdown(
+ `Worktree for Branch $(git-branch) ${branch?.getNameWithoutRemote() ?? this.worktree.branch}${
+ this.worktree.opened ? `${pad(GlyphChars.Dash, 2, 2)} _Active_ ` : ''
+ }\\\n\`${this.worktree.friendlyPath}\``,
+ );
+ icon = new ThemeIcon('git-branch');
+
+ if (status != null) {
+ hasChanges = status.hasChanges;
+ tooltip.appendMarkdown(
+ `\n\n${status.getFormattedDiffStatus({
+ prefix: 'Has Uncommitted Changes\\\n',
+ empty: 'No Uncommitted Changes',
+ expand: true,
+ })}`,
+ );
+ }
+
+ if (branch != null) {
+ tooltip.appendMarkdown(`\n\nBranch $(git-branch) ${branch.getNameWithoutRemote()}`);
+
+ if (!branch.remote) {
+ if (branch.upstream != null) {
+ let arrows = GlyphChars.Dash;
+
+ const remote = await branch.getRemote();
+ if (!branch.upstream.missing) {
+ if (remote != null) {
+ let left;
+ let right;
+ for (const { type } of remote.urls) {
+ if (type === GitRemoteType.Fetch) {
+ left = true;
+
+ if (right) break;
+ } else if (type === GitRemoteType.Push) {
+ right = true;
+
+ if (left) break;
+ }
+ }
+
+ if (left && right) {
+ arrows = GlyphChars.ArrowsRightLeft;
+ } else if (right) {
+ arrows = GlyphChars.ArrowRight;
+ } else if (left) {
+ arrows = GlyphChars.ArrowLeft;
+ }
+ }
+ } else {
+ arrows = GlyphChars.Warning;
+ }
+
+ description = `${branch.getTrackingStatus({
+ empty: pad(arrows, 0, 2),
+ suffix: pad(arrows, 2, 2),
+ })}${branch.upstream.name}`;
+
+ tooltip.appendMarkdown(
+ ` is ${branch.getTrackingStatus({
+ empty: branch.upstream.missing
+ ? `missing upstream $(git-branch) ${branch.upstream.name}`
+ : `up to date with $(git-branch) ${branch.upstream.name}${
+ remote?.provider?.name ? ` on ${remote.provider.name}` : ''
+ }`,
+ expand: true,
+ icons: true,
+ separator: ', ',
+ suffix: ` $(git-branch) ${branch.upstream.name}${
+ remote?.provider?.name ? ` on ${remote.provider.name}` : ''
+ }`,
+ })}`,
+ );
+ } else {
+ const providerName = GitRemote.getHighlanderProviderName(
+ await this.view.container.git.getRemotesWithProviders(branch.repoPath),
+ );
+
+ tooltip.appendMarkdown(` hasn't been published to ${providerName ?? 'a remote'}`);
+ }
+ }
+ }
+
+ break;
+ }
+ case 'detached': {
+ icon = new ThemeIcon('git-commit');
+ tooltip.appendMarkdown(
+ `Detached Worktree at $(git-commit) ${GitRevision.shorten(this.worktree.sha)}${
+ this.worktree.opened ? `${pad(GlyphChars.Dash, 2, 2)} _Active_` : ''
+ }\\\n\`${this.worktree.friendlyPath}\``,
+ );
+
+ const status = await this.worktree.getStatus();
+ if (status != null) {
+ hasChanges = status.hasChanges;
+ tooltip.appendMarkdown(
+ `\n\n${status.getFormattedDiffStatus({
+ prefix: 'Has Uncommitted Changes',
+ empty: 'No Uncommitted Changes',
+ expand: true,
+ })}`,
+ );
+ }
+
+ break;
+ }
+ }
+
+ const item = new TreeItem(this.worktree.name, TreeItemCollapsibleState.Collapsed);
+ item.id = this.id;
+ item.description = description;
+ item.contextValue = `${ContextValues.Worktree}${this.worktree.opened ? '+active' : ''}`;
+ item.iconPath = this.worktree.opened ? new ThemeIcon('check') : icon;
+ item.tooltip = tooltip;
+ item.resourceUri = hasChanges ? Uri.parse('gitlens-view://worktree/changes') : undefined;
+ return item;
+ }
+
+ @gate()
+ @debug()
+ override refresh(reset?: boolean) {
+ if (reset) {
+ this._log = undefined;
+ }
+ }
+
+ private _log: GitLog | undefined;
+ private async getLog() {
+ if (this._log == null) {
+ this._log = await this.view.container.git.getLog(this.uri.repoPath!, {
+ ref: this.worktree.sha,
+ limit: this.limit ?? this.view.config.defaultItemLimit,
+ });
+ }
+
+ return this._log;
+ }
+
+ get hasMore() {
+ return this._log?.hasMore ?? true;
+ }
+
+ limit: number | undefined = this.view.getNodeLastKnownLimit(this);
+ @gate()
+ async loadMore(limit?: number | { until?: any }) {
+ let log = await window.withProgress(
+ {
+ location: { viewId: this.view.id },
+ },
+ () => this.getLog(),
+ );
+ if (log == null || !log.hasMore) return;
+
+ log = await log.more?.(limit ?? this.view.config.pageItemLimit);
+ if (this._log === log) return;
+
+ this._log = log;
+ this.limit = log?.count;
+
+ void this.triggerChange(false);
+ }
+}
diff --git a/src/views/nodes/worktreesNode.ts b/src/views/nodes/worktreesNode.ts
new file mode 100644
index 0000000..0e3bd5a
--- /dev/null
+++ b/src/views/nodes/worktreesNode.ts
@@ -0,0 +1,63 @@
+import { ThemeIcon, TreeItem, TreeItemCollapsibleState } from 'vscode';
+import { GitUri } from '../../git/gitUri';
+import { Repository } from '../../git/models';
+import { gate } from '../../system/decorators/gate';
+import { debug } from '../../system/decorators/log';
+import { RepositoriesView } from '../repositoriesView';
+import { WorktreesView } from '../worktreesView';
+import { MessageNode } from './common';
+import { RepositoryNode } from './repositoryNode';
+import { ContextValues, ViewNode } from './viewNode';
+import { WorktreeNode } from './worktreeNode';
+
+export class WorktreesNode extends ViewNode {
+ static key = ':worktrees';
+ static getId(repoPath: string): string {
+ return `${RepositoryNode.getId(repoPath)}${this.key}`;
+ }
+
+ private _children: WorktreeNode[] | undefined;
+
+ constructor(
+ uri: GitUri,
+ view: WorktreesView | RepositoriesView,
+ parent: ViewNode,
+ public readonly repo: Repository,
+ ) {
+ super(uri, view, parent);
+ }
+
+ override get id(): string {
+ return WorktreesNode.getId(this.repo.path);
+ }
+
+ get repoPath(): string {
+ return this.repo.path;
+ }
+
+ async getChildren(): Promise {
+ if (this._children == null) {
+ const worktrees = await this.repo.getWorktrees();
+ if (worktrees.length === 0) return [new MessageNode(this.view, this, 'No worktrees could be found.')];
+
+ this._children = worktrees.map(c => new WorktreeNode(this.uri, this.view, this, c));
+ }
+
+ return this._children;
+ }
+
+ getTreeItem(): TreeItem {
+ const item = new TreeItem('Worktrees', TreeItemCollapsibleState.Collapsed);
+ item.id = this.id;
+ item.contextValue = ContextValues.Worktrees;
+ // TODO@eamodio `folder` icon won't work here for some reason
+ item.iconPath = new ThemeIcon('folder-opened');
+ return item;
+ }
+
+ @gate()
+ @debug()
+ override refresh() {
+ this._children = undefined;
+ }
+}
diff --git a/src/views/repositoriesView.ts b/src/views/repositoriesView.ts
index f8b6cdb..e89caf7 100644
--- a/src/views/repositoriesView.ts
+++ b/src/views/repositoriesView.ts
@@ -28,6 +28,7 @@ import {
GitRevisionReference,
GitStashReference,
GitTagReference,
+ GitWorktree,
} from '../git/models';
import { WorkspaceStorageKeys } from '../storage';
import { executeCommand } from '../system/command';
@@ -48,6 +49,8 @@ import {
StashesNode,
StashNode,
TagsNode,
+ WorktreeNode,
+ WorktreesNode,
} from './nodes';
import { ViewBase } from './viewBase';
@@ -217,6 +220,16 @@ export class RepositoriesView extends ViewBase this.toggleSection('showWorktrees', true),
+ this,
+ ),
+ commands.registerCommand(
+ this.getQualifiedCommand('setShowWorktreesOff'),
+ () => this.toggleSection('showWorktrees', false),
+ this,
+ ),
+ commands.registerCommand(
this.getQualifiedCommand('setShowUpstreamStatusOn'),
() => this.toggleSection('showUpstreamStatus', true),
this,
@@ -239,7 +252,8 @@ export class RepositoriesView extends ViewBase this.toggleSectionByNode(node, false),
this,
),
@@ -483,6 +497,25 @@ export class RepositoriesView extends ViewBase {
+ // Only search for worktree nodes in the same repo within WorktreesNode
+ if (n instanceof RepositoriesNode) return true;
+
+ if (n instanceof RepositoryNode || n instanceof WorktreesNode) {
+ return n.id.startsWith(repoNodeId);
+ }
+
+ return false;
+ },
+ token: token,
+ });
+ }
+
@gate(() => '')
revealBranch(
branch: GitBranchReference,
@@ -770,6 +803,64 @@ export class RepositoriesView extends ViewBase '')
+ revealWorktree(
+ worktree: GitWorktree,
+ options?: {
+ select?: boolean;
+ focus?: boolean;
+ expand?: boolean | number;
+ },
+ ) {
+ return window.withProgress(
+ {
+ location: ProgressLocation.Notification,
+ title: `Revealing worktree '${worktree.name}' in the side bar...`,
+ cancellable: true,
+ },
+ async (progress, token) => {
+ const node = await this.findWorktree(worktree, token);
+ if (node == null) return undefined;
+
+ await this.ensureRevealNode(node, options);
+
+ return node;
+ },
+ );
+ }
+
+ @gate(() => '')
+ async revealWorktrees(
+ repoPath: string,
+ options?: {
+ select?: boolean;
+ focus?: boolean;
+ expand?: boolean | number;
+ },
+ ) {
+ const repoNodeId = RepositoryNode.getId(repoPath);
+
+ const node = await this.findNode(WorktreesNode.getId(repoPath), {
+ maxDepth: 2,
+ canTraverse: n => {
+ // Only search for worktrees nodes in the same repo
+ if (n instanceof RepositoriesNode) return true;
+
+ if (n instanceof RepositoryNode) {
+ return n.id.startsWith(repoNodeId);
+ }
+
+ return false;
+ },
+ });
+
+ if (node !== undefined) {
+ await this.reveal(node, options);
+ }
+
+ return node;
+ }
+
private async setAutoRefresh(enabled: boolean, workspaceEnabled?: boolean) {
if (enabled) {
if (workspaceEnabled === undefined) {
@@ -825,6 +916,7 @@ export class RepositoriesView extends ViewBase;
export type ViewsWithRepositoryFolders = Exclude;
@@ -79,7 +82,8 @@ export abstract class ViewBase<
| RepositoriesViewConfig
| SearchAndCompareViewConfig
| StashesViewConfig
- | TagsViewConfig,
+ | TagsViewConfig
+ | WorktreesViewConfig,
> implements TreeDataProvider, Disposable
{
protected _onDidChangeTreeData = new EventEmitter();
diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts
index 05bfd5c..4a0df22 100644
--- a/src/views/viewCommands.ts
+++ b/src/views/viewCommands.ts
@@ -21,6 +21,7 @@ import {
executeEditorCommand,
} from '../system/command';
import { debug } from '../system/decorators/log';
+import { OpenWorkspaceLocation } from '../system/utils';
import { runGitCommandInTerminal } from '../terminal';
import {
BranchesNode,
@@ -56,6 +57,7 @@ import {
ViewNode,
ViewRefFileNode,
ViewRefNode,
+ WorktreeNode,
} from './nodes';
interface CompareSelectedInfo {
@@ -226,6 +228,15 @@ export class ViewCommands {
commands.registerCommand('gitlens.views.createPullRequest', this.createPullRequest, this);
commands.registerCommand('gitlens.views.openPullRequest', this.openPullRequest, this);
+
+ commands.registerCommand('gitlens.views.createWorktree', this.createWorktree, this);
+ commands.registerCommand('gitlens.views.deleteWorktree', this.deleteWorktree, this);
+ commands.registerCommand('gitlens.views.openWorktree', this.openWorktree, this);
+ commands.registerCommand(
+ 'gitlens.views.openWorktreeInNewWindow',
+ n => this.openWorktree(n, { location: OpenWorkspaceLocation.NewWindow }),
+ this,
+ );
}
@debug()
@@ -304,6 +315,27 @@ export class ViewCommands {
}
@debug()
+ private async createWorktree(node?: BranchNode) {
+ if (node !== undefined && !(node instanceof BranchNode)) return undefined;
+
+ return GitActions.Worktree.create(node?.repoPath, undefined, node?.ref);
+ }
+
+ @debug()
+ private openWorktree(node: WorktreeNode, options?: { location?: OpenWorkspaceLocation }) {
+ if (!(node instanceof WorktreeNode)) return undefined;
+
+ return GitActions.Worktree.open(node.worktree, options);
+ }
+
+ @debug()
+ private async deleteWorktree(node: WorktreeNode) {
+ if (!(node instanceof WorktreeNode)) return undefined;
+
+ return GitActions.Worktree.remove(node.repoPath, node.worktree.uri);
+ }
+
+ @debug()
private async createPullRequest(node: BranchNode | BranchTrackingStatusNode) {
if (!(node instanceof BranchNode) && !(node instanceof BranchTrackingStatusNode)) {
return Promise.resolve();
diff --git a/src/views/worktreesView.ts b/src/views/worktreesView.ts
new file mode 100644
index 0000000..bf74c6e
--- /dev/null
+++ b/src/views/worktreesView.ts
@@ -0,0 +1,263 @@
+import {
+ CancellationToken,
+ commands,
+ ConfigurationChangeEvent,
+ Disposable,
+ ProgressLocation,
+ ThemeColor,
+ TreeItem,
+ TreeItemCollapsibleState,
+ window,
+} from 'vscode';
+import { configuration, ViewFilesLayout, WorktreesViewConfig } from '../configuration';
+import { Container } from '../container';
+import { PremiumFeatures } from '../git/gitProvider';
+import { GitUri } from '../git/gitUri';
+import { GitWorktree, RepositoryChange, RepositoryChangeComparisonMode, RepositoryChangeEvent } from '../git/models';
+import { gate } from '../system/decorators/gate';
+import {
+ RepositoriesSubscribeableNode,
+ RepositoryFolderNode,
+ RepositoryNode,
+ ViewNode,
+ WorktreeNode,
+ WorktreesNode,
+} from './nodes';
+import { ViewBase } from './viewBase';
+
+export class WorktreesRepositoryNode extends RepositoryFolderNode {
+ getChildren(): Promise {
+ if (this.child == null) {
+ this.child = new WorktreesNode(this.uri, this.view, this, this.repo);
+ }
+
+ return this.child.getChildren();
+ }
+
+ protected changed(e: RepositoryChangeEvent) {
+ return e.changed(
+ RepositoryChange.Config,
+ RepositoryChange.Worktrees,
+ RepositoryChange.Unknown,
+ RepositoryChangeComparisonMode.Any,
+ );
+ }
+}
+
+export class WorktreesViewNode extends RepositoriesSubscribeableNode {
+ async getChildren(): Promise {
+ const access = await this.view.container.git.access(PremiumFeatures.Worktrees);
+ if (!access.allowed) return [];
+
+ if (this.children == null) {
+ const repositories = this.view.container.git.openRepositories;
+ if (repositories.length === 0) {
+ this.view.message = 'No worktrees could be found.';
+
+ return [];
+ }
+
+ this.view.message = undefined;
+
+ const splat = repositories.length === 1;
+ this.children = repositories.map(
+ r => new WorktreesRepositoryNode(GitUri.fromRepoPath(r.path), this.view, this, r, splat),
+ );
+ }
+
+ if (this.children.length === 1) {
+ const [child] = this.children;
+
+ const children = await child.getChildren();
+ if (children.length <= 1) {
+ this.view.message = undefined;
+ this.view.title = 'Worktrees';
+
+ void child.ensureSubscription();
+
+ return [];
+ }
+
+ this.view.message = undefined;
+ this.view.title = `Worktrees (${children.length})`;
+
+ return children;
+ }
+
+ return this.children;
+ }
+
+ getTreeItem(): TreeItem {
+ const item = new TreeItem('Worktrees', TreeItemCollapsibleState.Expanded);
+ return item;
+ }
+}
+
+export class WorktreesView extends ViewBase {
+ protected readonly configKey = 'worktrees';
+
+ constructor(container: Container) {
+ super('gitlens.views.worktrees', 'Worktrees', container);
+
+ this.disposables.push(
+ window.registerFileDecorationProvider({
+ provideFileDecoration: (uri, _token) => {
+ if (
+ uri.scheme !== 'gitlens-view' ||
+ uri.authority !== 'worktree' ||
+ !uri.path.includes('/changes')
+ ) {
+ return undefined;
+ }
+
+ return {
+ badge: '●',
+ color: new ThemeColor('gitlens.decorations.worktreeView.hasUncommittedChangesForegroundColor'),
+ tooltip: 'Has Uncommitted Changes',
+ };
+ },
+ }),
+ );
+ }
+
+ override get canReveal(): boolean {
+ return this.config.reveal || !configuration.get('views.repositories.showWorktrees');
+ }
+
+ protected getRoot() {
+ return new WorktreesViewNode(this);
+ }
+
+ protected registerCommands(): Disposable[] {
+ void this.container.viewCommands;
+
+ return [
+ commands.registerCommand(
+ this.getQualifiedCommand('copy'),
+ () => commands.executeCommand('gitlens.views.copy', this.selection),
+ this,
+ ),
+ commands.registerCommand(
+ this.getQualifiedCommand('refresh'),
+ async () => {
+ // this.container.git.resetCaches('worktrees');
+ return this.refresh(true);
+ },
+ this,
+ ),
+ commands.registerCommand(
+ this.getQualifiedCommand('setFilesLayoutToAuto'),
+ () => this.setFilesLayout(ViewFilesLayout.Auto),
+ this,
+ ),
+ commands.registerCommand(
+ this.getQualifiedCommand('setFilesLayoutToList'),
+ () => this.setFilesLayout(ViewFilesLayout.List),
+ this,
+ ),
+ commands.registerCommand(
+ this.getQualifiedCommand('setFilesLayoutToTree'),
+ () => this.setFilesLayout(ViewFilesLayout.Tree),
+ this,
+ ),
+
+ commands.registerCommand(
+ this.getQualifiedCommand('setShowAvatarsOn'),
+ () => this.setShowAvatars(true),
+ this,
+ ),
+ commands.registerCommand(
+ this.getQualifiedCommand('setShowAvatarsOff'),
+ () => this.setShowAvatars(false),
+ this,
+ ),
+ ];
+ }
+
+ protected override filterConfigurationChanged(e: ConfigurationChangeEvent) {
+ const changed = super.filterConfigurationChanged(e);
+ if (
+ !changed &&
+ !configuration.changed(e, 'defaultDateFormat') &&
+ !configuration.changed(e, 'defaultDateShortFormat') &&
+ !configuration.changed(e, 'defaultDateSource') &&
+ !configuration.changed(e, 'defaultDateStyle') &&
+ !configuration.changed(e, 'defaultGravatarsStyle') &&
+ !configuration.changed(e, 'defaultTimeFormat')
+ // !configuration.changed(e, 'sortWorktreesBy')
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ findWorktree(worktree: GitWorktree, token?: CancellationToken) {
+ const repoNodeId = RepositoryNode.getId(worktree.repoPath);
+
+ return this.findNode(WorktreeNode.getId(worktree.repoPath, worktree.uri), {
+ maxDepth: 2,
+ canTraverse: n => {
+ if (n instanceof WorktreesViewNode) return true;
+
+ if (n instanceof WorktreesRepositoryNode) {
+ return n.id.startsWith(repoNodeId);
+ }
+
+ return false;
+ },
+ token: token,
+ });
+ }
+
+ @gate(() => '')
+ async revealRepository(
+ repoPath: string,
+ options?: { select?: boolean; focus?: boolean; expand?: boolean | number },
+ ) {
+ const node = await this.findNode(RepositoryFolderNode.getId(repoPath), {
+ maxDepth: 1,
+ canTraverse: n => n instanceof WorktreesViewNode || n instanceof RepositoryFolderNode,
+ });
+
+ if (node !== undefined) {
+ await this.reveal(node, options);
+ }
+
+ return node;
+ }
+
+ @gate(() => '')
+ revealWorktree(
+ worktree: GitWorktree,
+ options?: {
+ select?: boolean;
+ focus?: boolean;
+ expand?: boolean | number;
+ },
+ ) {
+ return window.withProgress(
+ {
+ location: ProgressLocation.Notification,
+ title: `Revealing worktree '${worktree.name}' in the side bar...`,
+ cancellable: true,
+ },
+ async (progress, token) => {
+ const node = await this.findWorktree(worktree, token);
+ if (node == null) return undefined;
+
+ await this.ensureRevealNode(node, options);
+
+ return node;
+ },
+ );
+ }
+
+ private setFilesLayout(layout: ViewFilesLayout) {
+ return configuration.updateEffective(`views.${this.configKey}.files.layout` as const, layout);
+ }
+
+ private setShowAvatars(enabled: boolean) {
+ return configuration.updateEffective(`views.${this.configKey}.avatars` as const, enabled);
+ }
+}
diff --git a/src/webviews/settings/settingsWebview.ts b/src/webviews/settings/settingsWebview.ts
index 9b7982a..31af35a 100644
--- a/src/webviews/settings/settingsWebview.ts
+++ b/src/webviews/settings/settingsWebview.ts
@@ -32,6 +32,7 @@ export class SettingsWebview extends WebviewWithConfigBase {
Commands.ShowSettingsPageAndJumpToSearchAndCompareView,
Commands.ShowSettingsPageAndJumpToStashesView,
Commands.ShowSettingsPageAndJumpToTagsView,
+ Commands.ShowSettingsPageAndJumpToWorkTreesView,
Commands.ShowSettingsPageAndJumpToViews,
].map(c => {
// The show and jump commands are structured to have a # separating the base command from the anchor