Skip to content

Commit 4576aa4

Browse files
Enable viewing of worktrees under Repositories (microsoft#254992)
* init * fix: correct configuration key for detecting Git worktrees * Fixed misspelling of worktrees and populated worktree list * exported parseGitWorktrees * Replaced git commands with file system reader * Removed trailing './git' from path * Prevent errors when accessing empty worktree directory * Enable worktree deletion * code clean up * Remove exposure to git extension * labeling and minor fixes * throw error when no worktrees created * Error handling when getting worktrees --------- Co-authored-by: bhavyaus <[email protected]>
1 parent c068082 commit 4576aa4

File tree

7 files changed

+188
-5
lines changed

7 files changed

+188
-5
lines changed

extensions/git/package.json

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,12 @@
539539
"category": "Git",
540540
"enablement": "!operationInProgress"
541541
},
542+
{
543+
"command": "git.deleteWorktree",
544+
"title": "%command.deleteWorktree%",
545+
"category": "Git",
546+
"enablement": "!operationInProgress"
547+
},
542548
{
543549
"command": "git.graph.deleteTag",
544550
"title": "%command.graphDeleteTag%",
@@ -1289,6 +1295,10 @@
12891295
"command": "git.deleteTag",
12901296
"when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"
12911297
},
1298+
{
1299+
"command": "git.deleteWorktree",
1300+
"when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"
1301+
},
12921302
{
12931303
"command": "git.deleteRemoteTag",
12941304
"when": "config.git.enabled && !git.missing && gitOpenRepositoryCount != 0"
@@ -1609,6 +1619,11 @@
16091619
"group": "2_main@7",
16101620
"when": "scmProvider == git"
16111621
},
1622+
{
1623+
"submenu": "git.worktrees",
1624+
"group": "2_main@8",
1625+
"when": "scmProvider == git"
1626+
},
16121627
{
16131628
"command": "git.showOutput",
16141629
"group": "3_footer",
@@ -2538,6 +2553,12 @@
25382553
"command": "git.deleteRemoteTag",
25392554
"group": "tags@3"
25402555
}
2556+
],
2557+
"git.worktrees": [
2558+
{
2559+
"command": "git.deleteWorktree",
2560+
"group": "worktrees@1"
2561+
}
25412562
]
25422563
},
25432564
"submenus": [
@@ -2568,6 +2589,10 @@
25682589
{
25692590
"id": "git.tags",
25702591
"label": "%submenu.tags%"
2592+
},
2593+
{
2594+
"id": "git.worktrees",
2595+
"label": "%submenu.worktrees%"
25712596
}
25722597
],
25732598
"configuration": {
@@ -2977,6 +3002,18 @@
29773002
"default": 10,
29783003
"description": "%config.detectSubmodulesLimit%"
29793004
},
3005+
"git.detectWorktrees": {
3006+
"type": "boolean",
3007+
"scope": "resource",
3008+
"default": false,
3009+
"description": "%config.detectWorktrees%"
3010+
},
3011+
"git.detectWorktreesLimit": {
3012+
"type": "number",
3013+
"scope": "resource",
3014+
"default": 10,
3015+
"description": "%config.detectWorktreesLimit%"
3016+
},
29803017
"git.alwaysShowStagedChangesResourceGroup": {
29813018
"type": "boolean",
29823019
"scope": "resource",

extensions/git/package.nls.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"command.rebase": "Rebase Branch...",
7676
"command.createTag": "Create Tag...",
7777
"command.deleteTag": "Delete Tag...",
78+
"command.deleteWorktree": "Delete Worktree...",
7879
"command.deleteRemoteTag": "Delete Remote Tag...",
7980
"command.fetch": "Fetch",
8081
"command.fetchPrune": "Fetch (Prune)",
@@ -217,6 +218,8 @@
217218
"config.inputValidationSubjectLength": "Controls the commit message subject length threshold for showing a warning. Unset it to inherit the value of `#git.inputValidationLength#`.",
218219
"config.detectSubmodules": "Controls whether to automatically detect Git submodules.",
219220
"config.detectSubmodulesLimit": "Controls the limit of Git submodules detected.",
221+
"config.detectWorktrees": "Controls whether to automatically detect Git worktrees.",
222+
"config.detectWorktreesLimit": "Controls the limit of Git worktrees detected.",
220223
"config.alwaysShowStagedChangesResourceGroup": "Always show the Staged Changes resource group.",
221224
"config.alwaysSignOff": "Controls the signoff flag for all commits.",
222225
"config.ignoreSubmodules": "Ignore modifications to submodules in the file tree.",
@@ -301,6 +304,7 @@
301304
"submenu.remotes": "Remote",
302305
"submenu.stash": "Stash",
303306
"submenu.tags": "Tags",
307+
"submenu.worktrees": "Worktrees",
304308
"colors.added": "Color for added resources.",
305309
"colors.modified": "Color for modified resources.",
306310
"colors.stageModified": "Color for modified resources which have been staged.",

extensions/git/src/commands.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { Command, commands, Disposable, MessageOptions, Position, ProgressLocati
99
import TelemetryReporter from '@vscode/extension-telemetry';
1010
import { uniqueNamesGenerator, adjectives, animals, colors, NumberDictionary } from '@joaomoreno/unique-names-generator';
1111
import { ForcePushMode, GitErrorCodes, RefType, Status, CommitOptions, RemoteSourcePublisher, Remote, Branch, Ref } from './api/git';
12-
import { Git, Stash } from './git';
12+
import { Git, Stash, Worktree } from './git';
1313
import { Model } from './model';
1414
import { GitResourceGroup, Repository, Resource, ResourceGroupType } from './repository';
1515
import { DiffEditorSelectionHunkToolbarContext, LineChange, applyLineChanges, getIndexDiffInformation, getModifiedRange, getWorkingTreeDiffInformation, intersectDiffWithRange, invertLineChange, toLineChanges, toLineRanges, compareLineChanges } from './staging';
@@ -57,6 +57,19 @@ class RefItemSeparator implements QuickPickItem {
5757
constructor(private readonly refType: RefType) { }
5858
}
5959

60+
class WorktreeItem implements QuickPickItem {
61+
62+
get label(): string {
63+
return `$(list-tree) ${this.worktree.name}`;
64+
}
65+
66+
get description(): string {
67+
return this.worktree.path;
68+
}
69+
70+
constructor(readonly worktree: Worktree) { }
71+
}
72+
6073
class RefItem implements QuickPickItem {
6174

6275
get label(): string {
@@ -215,6 +228,14 @@ class RemoteTagDeleteItem extends RefItem {
215228
}
216229
}
217230

231+
class WorktreeDeleteItem extends WorktreeItem {
232+
async run(repository: Repository): Promise<void> {
233+
if (this.worktree.path) {
234+
await repository.deleteWorktree(this.worktree.path);
235+
}
236+
}
237+
}
238+
218239
class MergeItem extends BranchItem {
219240

220241
async run(repository: Repository): Promise<void> {
@@ -3315,6 +3336,23 @@ export class CommandCenter {
33153336
}
33163337
}
33173338

3339+
@command('git.deleteWorktree', { repository: true })
3340+
async deleteWorktree(repository: Repository): Promise<void> {
3341+
const worktreePicks = async (): Promise<WorktreeDeleteItem[] | QuickPickItem[]> => {
3342+
const worktrees = await repository.getWorktrees();
3343+
return worktrees.length === 0
3344+
? [{ label: l10n.t('$(info) This repository has no worktrees.') }]
3345+
: worktrees.map(worktree => new WorktreeDeleteItem(worktree));
3346+
};
3347+
3348+
const placeHolder = l10n.t('Select a worktree to delete');
3349+
const choice = await this.pickRef<WorktreeDeleteItem | QuickPickItem>(worktreePicks(), placeHolder);
3350+
3351+
if (choice instanceof WorktreeDeleteItem) {
3352+
await choice.run(repository);
3353+
}
3354+
}
3355+
33183356
@command('git.graph.deleteTag', { repository: true })
33193357
async deleteTag2(repository: Repository, historyItem?: SourceControlHistoryItem, historyItemRefId?: string): Promise<void> {
33203358
const historyItemRef = historyItem?.references?.find(r => r.id === historyItemRefId);

extensions/git/src/git.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,11 @@ export class GitStatusParser {
860860
}
861861
}
862862

863+
export interface Worktree {
864+
readonly name: string;
865+
readonly path: string;
866+
}
867+
863868
export interface Submodule {
864869
name: string;
865870
path: string;
@@ -2028,6 +2033,11 @@ export class Repository {
20282033
await this.exec(args);
20292034
}
20302035

2036+
async deleteWorktree(path: string): Promise<void> {
2037+
const args = ['worktree', 'remove', path];
2038+
await this.exec(args);
2039+
}
2040+
20312041
async deleteRemoteRef(remoteName: string, refName: string, options?: { force?: boolean }): Promise<void> {
20322042
const args = ['push', remoteName, '--delete'];
20332043

@@ -2740,6 +2750,50 @@ export class Repository {
27402750
return parseGitStashes(result.stdout.trim());
27412751
}
27422752

2753+
async getWorktrees(): Promise<Worktree[]> {
2754+
return await this.getWorktreesFS();
2755+
}
2756+
2757+
private async getWorktreesFS(): Promise<Worktree[]> {
2758+
const worktreesPath = path.join(this.repositoryRoot, '.git', 'worktrees');
2759+
2760+
try {
2761+
// List all worktree folder names
2762+
const dirents = await fs.readdir(worktreesPath, { withFileTypes: true });
2763+
const result: Worktree[] = [];
2764+
2765+
for (const dirent of dirents) {
2766+
if (!dirent.isDirectory()) {
2767+
continue;
2768+
}
2769+
2770+
const gitdirPath = path.join(worktreesPath, dirent.name, 'gitdir');
2771+
2772+
try {
2773+
const gitdirContent = (await fs.readFile(gitdirPath, 'utf8')).trim();
2774+
// Remove trailing '/.git'
2775+
const gitdirTrimmed = gitdirContent.replace(/\.git.*$/, '');
2776+
result.push({ name: dirent.name, path: gitdirTrimmed });
2777+
} catch (err) {
2778+
if (/ENOENT/.test(err.message)) {
2779+
continue;
2780+
}
2781+
2782+
throw err;
2783+
}
2784+
}
2785+
2786+
return result;
2787+
}
2788+
catch (err) {
2789+
if (/ENOENT/.test(err.message)) {
2790+
return [];
2791+
}
2792+
2793+
throw err;
2794+
}
2795+
}
2796+
27432797
async getRemotes(): Promise<Remote[]> {
27442798
const remotes: MutableRemote[] = [];
27452799

extensions/git/src/model.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,14 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi
724724
.getConfiguration('git', Uri.file(repository.root))
725725
.get<number>('detectSubmodulesLimit') as number;
726726

727+
const shouldDetectWorktrees = workspace
728+
.getConfiguration('git', Uri.file(repository.root))
729+
.get<boolean>('detectWorktrees') as boolean;
730+
731+
const worktreesLimit = workspace
732+
.getConfiguration('git', Uri.file(repository.root))
733+
.get<number>('detectWorktreesLimit') as number;
734+
727735
const checkForSubmodules = () => {
728736
if (!shouldDetectSubmodules) {
729737
this.logger.trace('[Model][open] Automatic detection of git submodules is not enabled.');
@@ -744,6 +752,25 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi
744752
});
745753
};
746754

755+
const checkForWorktrees = () => {
756+
if (!shouldDetectWorktrees) {
757+
this.logger.trace('[Model][open] Automatic detection of git worktrees is not enabled.');
758+
return;
759+
}
760+
761+
if (repository.worktrees.length > worktreesLimit) {
762+
window.showWarningMessage(l10n.t('The "{0}" repository has {1} worktrees which won\'t be opened automatically. You can still open each one individually by opening a file within.', path.basename(repository.root), repository.worktrees.length));
763+
statusListener.dispose();
764+
}
765+
766+
repository.worktrees
767+
.slice(0, worktreesLimit)
768+
.forEach(w => {
769+
this.logger.trace(`[Model][open] Opening worktree: '${w.path}'`);
770+
this.eventuallyScanPossibleGitRepository(w.path);
771+
});
772+
};
773+
747774
const updateMergeChanges = () => {
748775
// set mergeChanges context
749776
const mergeChanges: Uri[] = [];
@@ -757,10 +784,12 @@ export class Model implements IRepositoryResolver, IBranchProtectionProviderRegi
757784

758785
const statusListener = repository.onDidRunGitStatus(() => {
759786
checkForSubmodules();
787+
checkForWorktrees();
760788
updateMergeChanges();
761789
this.onDidChangeActiveTextEditor();
762790
});
763791
checkForSubmodules();
792+
checkForWorktrees();
764793
this.onDidChangeActiveTextEditor();
765794

766795
const updateOperationInProgressContext = () => {

extensions/git/src/operation.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const enum OperationKind {
2222
DeleteRef = 'DeleteRef',
2323
DeleteRemoteRef = 'DeleteRemoteRef',
2424
DeleteTag = 'DeleteTag',
25+
DeleteWorktree = 'DeleteWorktree',
2526
Diff = 'Diff',
2627
Fetch = 'Fetch',
2728
FindTrackingBranches = 'GetTracking',
@@ -31,6 +32,7 @@ export const enum OperationKind {
3132
GetObjectDetails = 'GetObjectDetails',
3233
GetObjectFiles = 'GetObjectFiles',
3334
GetRefs = 'GetRefs',
35+
GetWorktrees = 'GetWorktrees',
3436
GetRemoteRefs = 'GetRemoteRefs',
3537
HashObject = 'HashObject',
3638
Ignore = 'Ignore',
@@ -66,8 +68,8 @@ export const enum OperationKind {
6668

6769
export type Operation = AddOperation | ApplyOperation | BlameOperation | BranchOperation | CheckIgnoreOperation | CherryPickOperation |
6870
CheckoutOperation | CheckoutTrackingOperation | CleanOperation | CommitOperation | ConfigOperation | DeleteBranchOperation |
69-
DeleteRefOperation | DeleteRemoteRefOperation | DeleteTagOperation | DiffOperation | FetchOperation | FindTrackingBranchesOperation |
70-
GetBranchOperation | GetBranchesOperation | GetCommitTemplateOperation | GetObjectDetailsOperation | GetObjectFilesOperation | GetRefsOperation |
71+
DeleteRefOperation | DeleteRemoteRefOperation | DeleteTagOperation | DeleteWorktreeOperation | DiffOperation | FetchOperation | FindTrackingBranchesOperation |
72+
GetBranchOperation | GetBranchesOperation | GetCommitTemplateOperation | GetObjectDetailsOperation | GetObjectFilesOperation | GetRefsOperation | GetWorktreesOperation |
7173
GetRemoteRefsOperation | HashObjectOperation | IgnoreOperation | LogOperation | LogFileOperation | MergeOperation | MergeAbortOperation |
7274
MergeBaseOperation | MoveOperation | PostCommitCommandOperation | PullOperation | PushOperation | RemoteOperation | RenameBranchOperation |
7375
RemoveOperation | ResetOperation | RebaseOperation | RebaseAbortOperation | RebaseContinueOperation | RefreshOperation | RevertFilesOperation |
@@ -90,6 +92,7 @@ export type DeleteBranchOperation = BaseOperation & { kind: OperationKind.Delete
9092
export type DeleteRefOperation = BaseOperation & { kind: OperationKind.DeleteRef };
9193
export type DeleteRemoteRefOperation = BaseOperation & { kind: OperationKind.DeleteRemoteRef };
9294
export type DeleteTagOperation = BaseOperation & { kind: OperationKind.DeleteTag };
95+
export type DeleteWorktreeOperation = BaseOperation & { kind: OperationKind.DeleteWorktree };
9396
export type DiffOperation = BaseOperation & { kind: OperationKind.Diff };
9497
export type FetchOperation = BaseOperation & { kind: OperationKind.Fetch };
9598
export type FindTrackingBranchesOperation = BaseOperation & { kind: OperationKind.FindTrackingBranches };
@@ -99,6 +102,7 @@ export type GetCommitTemplateOperation = BaseOperation & { kind: OperationKind.G
99102
export type GetObjectDetailsOperation = BaseOperation & { kind: OperationKind.GetObjectDetails };
100103
export type GetObjectFilesOperation = BaseOperation & { kind: OperationKind.GetObjectFiles };
101104
export type GetRefsOperation = BaseOperation & { kind: OperationKind.GetRefs };
105+
export type GetWorktreesOperation = BaseOperation & { kind: OperationKind.GetWorktrees };
102106
export type GetRemoteRefsOperation = BaseOperation & { kind: OperationKind.GetRemoteRefs };
103107
export type HashObjectOperation = BaseOperation & { kind: OperationKind.HashObject };
104108
export type IgnoreOperation = BaseOperation & { kind: OperationKind.Ignore };
@@ -147,6 +151,7 @@ export const Operation = {
147151
DeleteRef: { kind: OperationKind.DeleteRef, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as DeleteRefOperation,
148152
DeleteRemoteRef: { kind: OperationKind.DeleteRemoteRef, blocking: false, readOnly: false, remote: true, retry: false, showProgress: true } as DeleteRemoteRefOperation,
149153
DeleteTag: { kind: OperationKind.DeleteTag, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as DeleteTagOperation,
154+
DeleteWorktree: { kind: OperationKind.DeleteWorktree, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as DeleteWorktreeOperation,
150155
Diff: { kind: OperationKind.Diff, blocking: false, readOnly: true, remote: false, retry: false, showProgress: false } as DiffOperation,
151156
Fetch: (showProgress: boolean) => ({ kind: OperationKind.Fetch, blocking: false, readOnly: false, remote: true, retry: true, showProgress } as FetchOperation),
152157
FindTrackingBranches: { kind: OperationKind.FindTrackingBranches, blocking: false, readOnly: true, remote: false, retry: false, showProgress: true } as FindTrackingBranchesOperation,
@@ -156,6 +161,7 @@ export const Operation = {
156161
GetObjectDetails: { kind: OperationKind.GetObjectDetails, blocking: false, readOnly: true, remote: false, retry: false, showProgress: false } as GetObjectDetailsOperation,
157162
GetObjectFiles: { kind: OperationKind.GetObjectFiles, blocking: false, readOnly: true, remote: false, retry: false, showProgress: false } as GetObjectFilesOperation,
158163
GetRefs: { kind: OperationKind.GetRefs, blocking: false, readOnly: true, remote: false, retry: false, showProgress: false } as GetRefsOperation,
164+
GetWorktrees: { kind: OperationKind.GetWorktrees, blocking: false, readOnly: true, remote: false, retry: false, showProgress: false } as GetWorktreesOperation,
159165
GetRemoteRefs: { kind: OperationKind.GetRemoteRefs, blocking: false, readOnly: true, remote: true, retry: false, showProgress: false } as GetRemoteRefsOperation,
160166
HashObject: { kind: OperationKind.HashObject, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as HashObjectOperation,
161167
Ignore: { kind: OperationKind.Ignore, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as IgnoreOperation,

0 commit comments

Comments
 (0)