Browse Source

Adds "main" flag to worktrees

Disallows deletes of main worktrees & handles errors if attempted
Disallows skip confirmation of worktree create
Fixes issues with worktree folder location choosing and creation
Forces prompting for worktree location by default
main
Eric Amodio 2 years ago
parent
commit
7cb36db450
10 changed files with 229 additions and 101 deletions
  1. +2
    -2
      package.json
  2. +161
    -56
      src/commands/git/worktree.ts
  3. +1
    -0
      src/env/node/git/git.ts
  4. +10
    -26
      src/env/node/git/localGitProvider.ts
  5. +8
    -2
      src/git/errors.ts
  6. +1
    -1
      src/git/gitProvider.ts
  7. +1
    -0
      src/git/models/worktree.ts
  8. +7
    -3
      src/git/parsers/worktreeParser.ts
  9. +12
    -3
      src/quickpicks/items/flags.ts
  10. +26
    -8
      src/views/nodes/worktreeNode.ts

+ 2
- 2
package.json View File

@ -1567,7 +1567,7 @@
},
"gitlens.worktrees.promptForLocation": {
"type": "boolean",
"default": false,
"default": true,
"markdownDescription": "Specifies whether to prompt for a path when creating new worktrees",
"scope": "resource"
},
@ -9543,7 +9543,7 @@
},
{
"command": "gitlens.views.deleteWorktree",
"when": "!gitlens:readonly && viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+active\\b)/",
"when": "!gitlens:readonly && viewItem =~ /gitlens:worktree\\b(?!.*?\\b\\+(active|main)\\b)/",
"group": "6_gitlens_actions@1"
},
{

+ 161
- 56
src/commands/git/worktree.ts View File

@ -10,10 +10,11 @@ import {
import { PremiumFeatures } from '../../git/gitProvider';
import { GitReference, GitWorktree, Repository } from '../../git/models';
import { Messages } from '../../messages';
import { QuickPickItemOfT } from '../../quickpicks/items/common';
import { QuickPickItemOfT, QuickPickSeparator } from '../../quickpicks/items/common';
import { Directive } from '../../quickpicks/items/directive';
import { FlagsQuickPickItem } from '../../quickpicks/items/flags';
import { pad, pluralize } from '../../system/string';
import { basename, isDescendent } from '../../system/path';
import { pad, pluralize, truncateLeft } from '../../system/string';
import { OpenWorkspaceLocation } from '../../system/utils';
import { ViewsWithRepositoryFolders } from '../../views/viewBase';
import { GitActions } from '../gitCommands.actions';
@ -151,7 +152,7 @@ export class WorktreeGitCommand extends QuickCommand {
}
override get canSkipConfirm(): boolean {
return this.subcommand === 'delete' ? false : super.canSkipConfirm;
return false;
}
override get skipConfirmKey() {
@ -298,7 +299,7 @@ export class WorktreeGitCommand extends QuickCommand {
!configuration.get('worktrees.promptForLocation', state.repo.folder) &&
context.defaultUri != null
) {
state.uri = Uri.joinPath(context.defaultUri, state.reference.name);
state.uri = context.defaultUri;
} else {
const result = yield* this.createCommandChoosePathStep(state, context, {
titleContext: ` for ${GitReference.toString(state.reference, {
@ -316,12 +317,13 @@ export class WorktreeGitCommand extends QuickCommand {
// 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;
// if (this.confirm(state.confirm)) {
const result = yield* this.createCommandConfirmStep(state, context);
if (result === StepResult.Break) continue;
state.flags = result;
}
let uri;
[uri, state.flags] = result;
// }
if (state.flags.includes('-b') && state.createBranch == null) {
this.overrideCanConfirm = false;
@ -338,18 +340,20 @@ export class WorktreeGitCommand extends QuickCommand {
if (result === StepResult.Break) continue;
state.createBranch = result;
uri = Uri.joinPath(uri, state.createBranch);
}
QuickCommand.endSteps(state);
const friendlyPath = GitWorktree.getFriendlyPath(uri);
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, {
await state.repo.createWorktree(uri, {
commitish: state.reference?.name,
createBranch: state.flags.includes('-b') ? state.createBranch : undefined,
detach: state.flags.includes('--detach'),
@ -405,7 +409,11 @@ export class WorktreeGitCommand extends QuickCommand {
canSelectMany: false,
defaultUri: state.uri ?? context.defaultUri,
openLabel: 'Select Worktree Location',
title: appendReposToTitle(`${context.title}${options?.titleContext ?? ''}`, state, context),
title: `${appendReposToTitle(
`Choose Worktree Location${options?.titleContext ?? ''}`,
state,
context,
)}`,
});
if (uris == null || uris.length === 0) return Directive.Back;
@ -426,41 +434,134 @@ export class WorktreeGitCommand extends QuickCommand {
return value;
}
private *createCommandConfirmStep(state: CreateStepState, context: Context): StepResultGenerator<CreateFlags[]> {
const friendlyPath = GitWorktree.getFriendlyPath(state.uri);
private *createCommandConfirmStep(
state: CreateStepState,
context: Context,
): StepResultGenerator<[Uri, CreateFlags[]]> {
const chosenUri = state.uri;
const chosenFriendlyPath = truncateLeft(GitWorktree.getFriendlyPath(chosenUri), 60);
let allowCreateInRoot = true;
let chosenWithRepoSubfoldersUri = chosenUri;
const folderUri = state.repo.folder?.uri;
if (folderUri != null) {
if (folderUri.toString() !== chosenUri.toString()) {
const descendent = isDescendent(chosenUri, folderUri);
if (!descendent) {
chosenWithRepoSubfoldersUri = Uri.joinPath(chosenUri, basename(folderUri.path));
}
} else {
allowCreateInRoot = false;
}
}
let chosenWithRepoAndRefSubfoldersUri;
let chosenWithRepoAndRefSubfoldersFriendlyPath: string = undefined!;
if (state.reference != null) {
chosenWithRepoAndRefSubfoldersUri = Uri.joinPath(
chosenWithRepoSubfoldersUri ?? chosenUri,
...state.reference.name.replace(/\\/g, '/').split('/'),
);
chosenWithRepoAndRefSubfoldersFriendlyPath = truncateLeft(
GitWorktree.getFriendlyPath(chosenWithRepoAndRefSubfoldersUri),
65,
);
}
const chosenWithRepoAndNewRefSubfoldersUri = Uri.joinPath(
chosenWithRepoSubfoldersUri ?? chosenUri,
'<new-branch-name>',
);
const chosenWithRepoAndNewRefSubfoldersFriendlyPath = truncateLeft(
GitWorktree.getFriendlyPath(chosenWithRepoAndNewRefSubfoldersUri),
58,
);
const step: QuickPickStep<FlagsQuickPickItem<CreateFlags>> = QuickCommand.createConfirmStep(
const step: QuickPickStep<FlagsQuickPickItem<CreateFlags, Uri>> = QuickCommand.createConfirmStep(
appendReposToTitle(`Confirm ${context.title}`, state, context),
[
FlagsQuickPickItem.create<CreateFlags>(state.flags, [], {
label: context.title,
detail: `Will create a new worktree for ${GitReference.toString(state.reference)} in${pad(
'$(folder)',
2,
2,
)}${friendlyPath}`,
}),
FlagsQuickPickItem.create<CreateFlags>(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<CreateFlags>(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}`,
}),
],
...(chosenWithRepoAndRefSubfoldersUri != null
? [
FlagsQuickPickItem.create<CreateFlags, Uri>(
state.flags,
[],
{
label: context.title,
description: ` for ${GitReference.toString(state.reference)}`,
detail: `Will create worktree in${pad(
'$(folder)',
2,
2,
)}${chosenWithRepoAndRefSubfoldersFriendlyPath}`,
},
chosenWithRepoAndRefSubfoldersUri,
),
]
: []),
...(chosenWithRepoSubfoldersUri != null
? [
FlagsQuickPickItem.create<CreateFlags, Uri>(
state.flags,
['-b'],
{
label: 'Create New Branch and Worktree',
description: ` from ${GitReference.toString(state.reference)}`,
detail: `Will create worktree in${pad(
'$(folder)',
2,
2,
)}${chosenWithRepoAndNewRefSubfoldersFriendlyPath}`,
},
chosenWithRepoSubfoldersUri,
),
]
: []),
QuickPickSeparator.create(),
...(allowCreateInRoot
? [
FlagsQuickPickItem.create<CreateFlags, Uri>(
state.flags,
[],
{
label: `${context.title} (directly in folder)`,
description: ` for ${GitReference.toString(state.reference)}`,
detail: `Will create worktree directly in${pad(
'$(folder)',
2,
2,
)}${chosenFriendlyPath}`,
},
chosenUri,
),
]
: []),
...(allowCreateInRoot
? [
FlagsQuickPickItem.create<CreateFlags, Uri>(
state.flags,
['-b'],
{
label: 'Create New Branch and Worktree (directly in folder)',
description: ` from ${GitReference.toString(state.reference)}`,
detail: `Will create worktree directly in${pad(
'$(folder)',
2,
2,
)}${chosenWithRepoAndNewRefSubfoldersFriendlyPath}`,
},
chosenUri,
),
]
: []),
] as FlagsQuickPickItem<CreateFlags, Uri>[],
context,
);
const selection: StepSelection<typeof step> = yield step;
return QuickCommand.canPickStepContinue(step, state, selection) ? selection[0].item : StepResult.Break;
return QuickCommand.canPickStepContinue(step, state, selection)
? [selection[0].context, selection[0].item]
: StepResult.Break;
}
private async *deleteCommandSteps(state: DeleteStepState, context: Context): StepGenerator {
@ -521,21 +622,25 @@ export class WorktreeGitCommand extends QuickCommand {
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;
if (ex instanceof WorktreeDeleteError) {
if (ex.reason === WorktreeDeleteErrorReason.MainWorkingTree) {
void window.showErrorMessage('Unable to delete the main worktree');
} else if (!force) {
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}.`);

+ 1
- 0
src/env/node/git/git.ts View File

@ -30,6 +30,7 @@ export const GitErrors = {
uncommittedChanges: /contains modified or untracked files/i,
alreadyExists: /already exists/i,
alreadyCheckedOut: /already checked out/i,
mainWorkingTree: /is a main working tree/i,
};
const GitWarnings = {

+ 10
- 26
src/env/node/git/localGitProvider.ts View File

@ -7,8 +7,6 @@ import {
Event,
EventEmitter,
extensions,
FileStat,
FileSystemError,
FileType,
Range,
TextDocument,
@ -3754,27 +3752,13 @@ export class LocalGitProvider implements GitProvider, Disposable {
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);
}
@ -3800,16 +3784,11 @@ export class LocalGitProvider implements GitProvider, Disposable {
return GitWorktreeParser.parse(data, repoPath);
}
// eslint-disable-next-line @typescript-eslint/require-await
@log()
async getWorktreesDefaultUri(repoPath: string): Promise<Uri> {
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');
}
async getWorktreesDefaultUri(repoPath: string): Promise<Uri | undefined> {
let location = configuration.get('worktrees.defaultLocation');
if (location == null) return undefined;
if (location.startsWith('~')) {
location = joinPaths(homedir(), location.slice(1));
@ -3832,6 +3811,11 @@ export class LocalGitProvider implements GitProvider, Disposable {
Logger.error(ex);
const msg = String(ex);
if (GitErrors.mainWorkingTree.test(msg)) {
throw new WorktreeDeleteError(WorktreeDeleteErrorReason.MainWorkingTree, ex);
}
if (GitErrors.uncommittedChanges.test(msg)) {
throw new WorktreeDeleteError(WorktreeDeleteErrorReason.HasChanges, ex);
}

+ 8
- 2
src/git/errors.ts View File

@ -69,6 +69,7 @@ export class WorktreeCreateError extends Error {
export const enum WorktreeDeleteErrorReason {
HasChanges = 1,
MainWorkingTree = 2,
}
export class WorktreeDeleteError extends Error {
@ -87,8 +88,13 @@ export class WorktreeDeleteError extends Error {
reason = undefined;
} else {
reason = messageOrReason;
if (reason === WorktreeDeleteErrorReason.HasChanges) {
message = 'Unable to delete worktree because there are uncommitted changes';
switch (reason) {
case WorktreeDeleteErrorReason.HasChanges:
message = 'Unable to delete worktree because there are uncommitted changes';
break;
case WorktreeDeleteErrorReason.MainWorkingTree:
message = 'Unable to delete worktree because it is a main working tree';
break;
}
}
super(message);

+ 1
- 1
src/git/gitProvider.ts View File

@ -438,7 +438,7 @@ export interface GitProvider extends Disposable {
options?: { commitish?: string; createBranch?: string; detach?: boolean; force?: boolean },
): Promise<void>;
getWorktrees?(repoPath: string): Promise<GitWorktree[]>;
getWorktreesDefaultUri?(repoPath: string): Promise<Uri>;
getWorktreesDefaultUri?(repoPath: string): Promise<Uri | undefined>;
deleteWorktree?(repoPath: string, path: string, options?: { force?: boolean }): Promise<void>;
}

+ 1
- 0
src/git/models/worktree.ts View File

@ -11,6 +11,7 @@ export class GitWorktree {
}
constructor(
public readonly main: boolean,
public readonly type: 'bare' | 'branch' | 'detached',
public readonly repoPath: string,
public readonly uri: Uri,

+ 7
- 3
src/git/parsers/worktreeParser.ts View File

@ -19,7 +19,7 @@ export class GitWorktreeParser {
static parse(data: string, repoPath: string): GitWorktree[] {
if (!data) return [];
if (repoPath !== undefined) {
if (repoPath != null) {
repoPath = normalizePath(repoPath);
}
@ -31,13 +31,15 @@ export class GitWorktreeParser {
let value: string;
let locked: string;
let prunable: string;
let main = true; // the first worktree is the main worktree
for (line of getLines(data)) {
[key, value] = line.split(' ', 2);
if (key.length === 0 && entry !== undefined) {
if (key.length === 0 && entry != null) {
worktrees.push(
new GitWorktree(
main,
entry.bare ? 'bare' : entry.detached ? 'detached' : 'branch',
repoPath,
Uri.file(entry.path!),
@ -47,11 +49,13 @@ export class GitWorktreeParser {
entry.branch,
),
);
entry = undefined;
main = false;
continue;
}
if (entry === undefined) {
if (entry == null) {
entry = {};
}

+ 12
- 3
src/quickpicks/items/flags.ts View File

@ -1,10 +1,19 @@
import { QuickPickItem } from 'vscode';
import { QuickPickItemOfT } from './common';
export type FlagsQuickPickItem<T> = QuickPickItemOfT<T[]>;
export type FlagsQuickPickItem<T, Context = void> = Context extends void
? QuickPickItemOfT<T[]>
: QuickPickItemOfT<T[]> & { context: Context };
export namespace FlagsQuickPickItem {
export function create<T>(flags: T[], item: T[], options: QuickPickItem) {
return { ...options, item: item, picked: hasFlags(flags, item) };
export function create<T>(flags: T[], item: T[], options: QuickPickItem): FlagsQuickPickItem<T>;
export function create<T, Context>(
flags: T[],
item: T[],
options: QuickPickItem,
context: Context,
): FlagsQuickPickItem<T, Context>;
export function create<T, Context = void>(flags: T[], item: T[], options: QuickPickItem, context?: Context): any {
return { ...options, item: item, picked: hasFlags(flags, item), context: context };
}
}
function hasFlags<T>(flags: T[], has?: T | T[]): boolean {

+ 26
- 8
src/views/nodes/worktreeNode.ts View File

@ -75,10 +75,26 @@ export class WorktreeNode extends ViewNode {
const tooltip = new MarkdownString('', true);
let icon: ThemeIcon | undefined;
let hasChanges = false;
const indicators =
this.worktree.main || this.worktree.opened
? `${pad(GlyphChars.Dash, 2, 2)} ${
this.worktree.main
? `_Main${this.worktree.opened ? ', Active_' : '_'}`
: this.worktree.opened
? '_Active_'
: ''
} `
: '';
switch (this.worktree.type) {
case 'bare':
icon = new ThemeIcon('folder');
tooltip.appendMarkdown(`Bare Worktree\\\n\`${this.worktree.friendlyPath}\``);
tooltip.appendMarkdown(
`${this.worktree.main ? '$(pass) ' : ''}Bare Worktree${indicators}\\\n\`${
this.worktree.friendlyPath
}\``,
);
break;
case 'branch': {
const [branch, status] = await Promise.all([
@ -91,9 +107,9 @@ export class WorktreeNode extends ViewNode {
]);
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}\``,
`${this.worktree.main ? '$(pass) ' : ''}Worktree for Branch $(git-branch) ${
branch?.getNameWithoutRemote() ?? this.worktree.branch
}${indicators}\\\n\`${this.worktree.friendlyPath}\``,
);
icon = new ThemeIcon('git-branch');
@ -179,9 +195,9 @@ export class WorktreeNode extends ViewNode {
case 'detached': {
icon = new ThemeIcon('git-commit');
tooltip.appendMarkdown(
`Detached Worktree at $(git-commit) ${GitRevision.shorten(this.worktree.sha)}${
this.worktree.openedspan> ? `${pad(GlyphChars.Dash, 2, 2)} _Active_` : ''
}\\\n\`${this.worktree.friendlyPath}\``,
`${this.worktree.main ? '$(pass) ' : ''}Detached Worktree at $(git-commit) ${GitRevision.shorten(
this.worktree.sha,
)}${indicators}\\\n\`${this.worktree.friendlyPath}\``,
);
const status = await this.worktree.getStatus();
@ -203,7 +219,9 @@ export class WorktreeNode extends ViewNode {
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.contextValue = `${ContextValues.Worktree}${this.worktree.main ? '+main' : ''}${
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;

Loading…
Cancel
Save