diff --git a/src/commands/git/switch.ts b/src/commands/git/switch.ts index 8c4374da035c5..79e487d68f428 100644 --- a/src/commands/git/switch.ts +++ b/src/commands/git/switch.ts @@ -38,7 +38,7 @@ interface State { reference: GitReference; createBranch?: string; fastForwardTo?: GitReference; - skipWorktreeConfirmations?: boolean; + worktreeDefaultOpen?: 'new' | 'current'; } type ConfirmationChoice = @@ -223,7 +223,7 @@ export class SwitchGitCommand extends QuickCommand { openOnly: true, overrides: { disallowBack: true, - confirmation: state.skipWorktreeConfirmations + confirmation: state.worktreeDefaultOpen ? undefined : { title: `Confirm Switch to Worktree \u2022 ${getReferenceLabel( @@ -241,12 +241,12 @@ export class SwitchGitCommand extends QuickCommand { }, onWorkspaceChanging: state.onWorkspaceChanging, repo: state.repos[0], - skipWorktreeConfirmations: state.skipWorktreeConfirmations, + worktreeDefaultOpen: state.worktreeDefaultOpen, }, }, this.pickedVia, ); - if (worktreeResult === StepResultBreak && !state.skipWorktreeConfirmations) continue; + if (worktreeResult === StepResultBreak && !state.worktreeDefaultOpen) continue; endSteps(state); return; @@ -263,7 +263,7 @@ export class SwitchGitCommand extends QuickCommand { state.createBranch = undefined; context.promptToCreateBranch = false; - if (state.skipWorktreeConfirmations) { + if (state.worktreeDefaultOpen) { state.reference = context.canSwitchToLocalBranch; continue outer; } @@ -273,7 +273,7 @@ export class SwitchGitCommand extends QuickCommand { } if ( - state.skipWorktreeConfirmations || + state.worktreeDefaultOpen || this.confirm(context.promptToCreateBranch || context.canSwitchToLocalBranch ? true : state.confirm) ) { const result = yield* this.confirmStep(state as SwitchStepState, context); @@ -330,12 +330,12 @@ export class SwitchGitCommand extends QuickCommand { result === 'switchToNewBranchViaWorktree' ? state.createBranch : undefined, repo: state.repos[0], onWorkspaceChanging: state.onWorkspaceChanging, - skipWorktreeConfirmations: state.skipWorktreeConfirmations, + worktreeDefaultOpen: state.worktreeDefaultOpen, }, }, this.pickedVia, ); - if (worktreeResult === StepResultBreak && !state.skipWorktreeConfirmations) continue outer; + if (worktreeResult === StepResultBreak && !state.worktreeDefaultOpen) continue outer; endSteps(state); return; @@ -355,7 +355,7 @@ export class SwitchGitCommand extends QuickCommand { const isRemoteBranch = isBranchReference(state.reference) && state.reference.remote; type StepType = QuickPickItemOfT; - if (state.skipWorktreeConfirmations && state.repos.length === 1) { + if (state.worktreeDefaultOpen && state.repos.length === 1) { if (isLocalBranch) { return 'switchViaWorktree'; } else if (!state.createBranch && context.canSwitchToLocalBranch != null) { diff --git a/src/commands/git/worktree.ts b/src/commands/git/worktree.ts index e9fc71b3b37ee..fbd12cc9d2873 100644 --- a/src/commands/git/worktree.ts +++ b/src/commands/git/worktree.ts @@ -103,7 +103,7 @@ interface CreateState { }; onWorkspaceChanging?: ((isNewWorktree?: boolean) => Promise) | ((isNewWorktree?: boolean) => void); - skipWorktreeConfirmations?: boolean; + worktreeDefaultOpen?: 'new' | 'current'; } type DeleteFlags = '--force' | '--delete-branches'; @@ -141,7 +141,7 @@ interface OpenState { onWorkspaceChanging?: ((isNewWorktree?: boolean) => Promise) | ((isNewWorktree?: boolean) => void); isNewWorktree?: boolean; - skipWorktreeConfirmations?: boolean; + worktreeDefaultOpen?: 'new' | 'current'; } interface CopyChangesState { @@ -632,7 +632,7 @@ export class WorktreeGitCommand extends QuickCommand { openOnly: true, overrides: { disallowBack: true }, isNewWorktree: true, - skipWorktreeConfirmations: state.skipWorktreeConfirmations, + worktreeDefaultOpen: state.worktreeDefaultOpen, onWorkspaceChanging: state.onWorkspaceChanging, } satisfies OpenStepState, context, @@ -739,7 +739,7 @@ export class WorktreeGitCommand extends QuickCommand { const confirmations: StepType[] = []; if (!createDirectlyInFolder) { - if (state.skipWorktreeConfirmations) { + if (state.worktreeDefaultOpen) { return [defaultOption.context, defaultOption.item]; } @@ -1101,15 +1101,21 @@ export class WorktreeGitCommand extends QuickCommand { detail: 'Will open the worktree in a new window', }); - if (state.skipWorktreeConfirmations) { + const currentWindowItem = createFlagsQuickPickItem(state.flags, [], { + label: 'Open Worktree', + detail: 'Will open the worktree in the current window', + }); + + if (state.worktreeDefaultOpen === 'new') { return newWindowItem.item; } + if (state.worktreeDefaultOpen === 'current') { + return currentWindowItem.item; + } + const confirmations: StepType[] = [ - createFlagsQuickPickItem(state.flags, [], { - label: 'Open Worktree', - detail: 'Will open the worktree in the current window', - }), + currentWindowItem, newWindowItem, createFlagsQuickPickItem(state.flags, ['--add-to-workspace'], { label: `Add Worktree to Workspace`, diff --git a/src/constants.commands.ts b/src/constants.commands.ts index 4b14aea516723..c24bbfd61bef7 100644 --- a/src/constants.commands.ts +++ b/src/constants.commands.ts @@ -148,6 +148,8 @@ type InternalGraphWebviewCommands = | 'gitlens.graph.skipPausedOperation'; type InternalHomeWebviewCommands = + | 'gitlens.home.deleteBranchOrWorktree' + | 'gitlens.home.pushBranch' | 'gitlens.home.openMergeTargetComparison' | 'gitlens.home.openPullRequestChanges' | 'gitlens.home.openPullRequestComparison' diff --git a/src/env/node/git/sub-providers/branches.ts b/src/env/node/git/sub-providers/branches.ts index 9c525c8e3c183..137bd3cf8c9a3 100644 --- a/src/env/node/git/sub-providers/branches.ts +++ b/src/env/node/git/sub-providers/branches.ts @@ -420,12 +420,39 @@ export class BranchesGitSubProvider implements GitBranchesSubProvider { } catch {} // Cherry-pick detection (handles cherry-picks, rebases, etc) - const data = await this.git.exec({ cwd: repoPath }, 'cherry', '--abbrev', '-v', into.name, branch.name); + let data = await this.git.exec({ cwd: repoPath }, 'cherry', '--abbrev', '-v', into.name, branch.name); // Check if there are no lines or all lines startwith a `-` (i.e. likely merged) - if (!data || data.split('\n').every(l => l.startsWith('-'))) { + if ( + !data || + data + .trim() + .split('\n') + .every(l => l.startsWith('-')) + ) { return { merged: true, confidence: 'high' }; } + // Attempt to detect squash merges by checking if the diff of the branch can be cleanly removed from the target + const mergeBase = await this.provider.refs.getMergeBase(repoPath, into.name, branch.name); + data = await this.git.exec({ cwd: repoPath }, 'diff', mergeBase, branch.name); + if (data?.length) { + // Create a temporary index file + await using disposableIndex = await this.provider.staging!.createTemporaryIndex(repoPath, into.name); + const { env } = disposableIndex; + + data = await this.git.exec( + { cwd: repoPath, env: env, stdin: data }, + 'apply', + '--cached', + '--reverse', + '--check', + '-', + ); + if (!data?.trim().length) { + return { merged: true, confidence: 'medium' }; + } + } + return { merged: false }; } catch (ex) { Logger.error(ex, scope); diff --git a/src/plus/launchpad/launchpad.ts b/src/plus/launchpad/launchpad.ts index 58af70ff2b233..b7d7f824f0745 100644 --- a/src/plus/launchpad/launchpad.ts +++ b/src/plus/launchpad/launchpad.ts @@ -391,7 +391,7 @@ export class LaunchpadCommand extends QuickCommand { void this.container.launchpad.switchTo(state.item); break; case 'open-worktree': - void this.container.launchpad.switchTo(state.item, { skipWorktreeConfirmations: true }); + void this.container.launchpad.switchTo(state.item, { openInWorktree: true }); break; case 'switch-and-code-suggest': case 'code-suggest': @@ -900,7 +900,7 @@ export class LaunchpadCommand extends QuickCommand { case OpenWorktreeInNewWindowQuickInputButton: this.sendItemActionTelemetry('open-worktree', item, group, context); - await this.container.launchpad.switchTo(item, { skipWorktreeConfirmations: true }); + await this.container.launchpad.switchTo(item, { openInWorktree: true }); break; } diff --git a/src/plus/launchpad/launchpadProvider.ts b/src/plus/launchpad/launchpadProvider.ts index 3dd752cec9846..0235f8c54b194 100644 --- a/src/plus/launchpad/launchpadProvider.ts +++ b/src/plus/launchpad/launchpadProvider.ts @@ -467,7 +467,7 @@ export class LaunchpadProvider implements Disposable { @log({ args: { 0: i => `${i.id} (${i.provider.name} ${i.type})` } }) async switchTo( item: LaunchpadItem, - options?: { skipWorktreeConfirmations?: boolean; startCodeSuggestion?: boolean }, + options?: { openInWorktree?: boolean; startCodeSuggestion?: boolean }, ): Promise { if (item.openRepository?.localBranch?.current) { void showInspectView({ @@ -483,7 +483,7 @@ export class LaunchpadProvider implements Disposable { item, options?.startCodeSuggestion ? DeepLinkActionType.SwitchToAndSuggestPullRequest - : options?.skipWorktreeConfirmations + : options?.openInWorktree ? DeepLinkActionType.SwitchToPullRequestWorktree : DeepLinkActionType.SwitchToPullRequest, ); diff --git a/src/uris/deepLinks/deepLink.ts b/src/uris/deepLinks/deepLink.ts index c3ac5b7a85d2f..7e3a16093bbaf 100644 --- a/src/uris/deepLinks/deepLink.ts +++ b/src/uris/deepLinks/deepLink.ts @@ -45,6 +45,7 @@ export const DeepLinkCommandTypeToCommand = new Map @@ -301,6 +321,29 @@ export class GlMergeTargetStatus extends LitElement { ${this.mergedStatus.confidence !== 'highest' ? 'likely ' : ''}been merged into its merge target's local branch ${renderBranchName(this.mergedStatus.localBranchOnly.name)}.

+
+ Push ${renderBranchName(this.mergedStatus.localBranchOnly.name)} + Delete + ${this.branch.worktree != null && !this.branch.worktree.isDefault + ? 'Worktree' + : 'Branch'} + ${renderBranchName(this.branch.name, this.branch.worktree != null)} +
`; } @@ -318,6 +361,18 @@ export class GlMergeTargetStatus extends LitElement { ${this.mergedStatus.confidence !== 'highest' ? 'likely ' : ''}been merged into its merge target ${target}.

+
+ Delete + ${this.branch.worktree != null && !this.branch.worktree.isDefault ? 'Worktree' : 'Branch'} + ${renderBranchName(this.branch.name, this.branch.worktree != null)} +
`; } diff --git a/src/webviews/home/homeWebview.ts b/src/webviews/home/homeWebview.ts index 2ef3336780db9..13440ab7533d7 100644 --- a/src/webviews/home/homeWebview.ts +++ b/src/webviews/home/homeWebview.ts @@ -1,5 +1,5 @@ import type { ConfigurationChangeEvent } from 'vscode'; -import { Disposable, Uri, window, workspace } from 'vscode'; +import { Disposable, env, Uri, window, workspace } from 'vscode'; import type { CreatePullRequestActionContext } from '../../api/gitlens'; import type { EnrichedAutolink } from '../../autolinks/models/autolinks'; import { getAvatarUriFromGravatarEmail } from '../../avatars'; @@ -35,6 +35,7 @@ import { getBranchTargetInfo } from '../../git/utils/-webview/branch.utils'; import { getReferenceFromBranch } from '../../git/utils/-webview/reference.utils'; import { sortBranches } from '../../git/utils/-webview/sorting'; import { getOpenedWorktreesByBranch, groupWorktreesByBranch } from '../../git/utils/-webview/worktree.utils'; +import { getBranchNameWithoutRemote } from '../../git/utils/branch.utils'; import { getComparisonRefsForPullRequest } from '../../git/utils/pullRequest.utils'; import { createRevisionRange } from '../../git/utils/revision.utils'; import type { AIModelChangeEvent } from '../../plus/ai/aiProviderService'; @@ -64,6 +65,8 @@ import { debounce } from '../../system/function/debounce'; import { filterMap } from '../../system/iterable'; import { getSettledValue } from '../../system/promise'; import { SubscriptionManager } from '../../system/subscriptionManager'; +import type { UriTypes } from '../../uris/deepLinks/deepLink'; +import { DeepLinkServiceState, DeepLinkType } from '../../uris/deepLinks/deepLink'; import type { ShowInCommitGraphCommandArgs } from '../plus/graph/protocol'; import type { Change } from '../plus/patchDetails/protocol'; import type { IpcMessage } from '../protocol'; @@ -318,6 +321,8 @@ export class HomeWebviewProvider implements WebviewProvider this.container.subscription.validate({ force: true }, src), this, ), + registerCommand('gitlens.home.deleteBranchOrWorktree', this.deleteBranchOrWorktree, this), + registerCommand('gitlens.home.pushBranch', this.pushBranch, this), registerCommand('gitlens.home.openMergeTargetComparison', this.mergeTargetCompare, this), registerCommand('gitlens.home.openPullRequestChanges', this.pullRequestChanges, this), registerCommand('gitlens.home.openPullRequestComparison', this.pullRequestCompare, this), @@ -1167,6 +1172,94 @@ export class HomeWebviewProvider implements WebviewProvider b.id === ref.branchId); + if (branch == null) { + branch = await repo?.repo.git.branches().getBranch(ref.branchId); + } + + const worktree = branch ? repo?.worktreesByBranch.get(branch.id) : undefined; + if (branch == null) return; + + if (branch.current && mergeTarget != null && (!worktree || worktree.isDefault)) { + const mergeTargetLocalBranchName = getBranchNameWithoutRemote(mergeTarget.branchName); + const confirm = await window.showWarningMessage( + `Before deleting the current branch '${branch.name}', you will be switched to '${mergeTargetLocalBranchName}'.`, + { modal: true }, + { title: 'Continue' }, + ); + + if (confirm == null || confirm.title !== 'Continue') return; + + await this.container.git.checkout(ref.repoPath, mergeTargetLocalBranchName); + + void executeGitCommand({ + command: 'branch', + state: { + subcommand: 'delete', + repo: ref.repoPath, + references: branch, + }, + }); + } else if (repo != null && branch != null && worktree != null && !worktree.isDefault) { + const commonRepo = await repo.repo.getCommonRepository(); + const defaultWorktree = [...repo.worktreesByBranch.values()].find(w => w.isDefault); + if (defaultWorktree == null || commonRepo == null) return; + + const confirm = await window.showWarningMessage( + `Before deleting the worktree for '${branch.name}', you will be switched to the default worktree.`, + { modal: true }, + { title: 'Continue' }, + ); + + if (confirm == null || confirm.title !== 'Continue') return; + + const schemeOverride = configuration.get('deepLinks.schemeOverride'); + const scheme = typeof schemeOverride === 'string' ? schemeOverride : env.uriScheme; + const deleteBranchDeepLink = { + url: `${scheme}://${this.container.context.extension.id}/${'link' satisfies UriTypes}/${ + DeepLinkType.Repository + }/-/${DeepLinkType.Branch}/${encodeURIComponent(branch.name)}?path=${encodeURIComponent( + commonRepo.path, + )}&action=delete-branch`, + repoPath: commonRepo.path, + useProgress: false, + state: DeepLinkServiceState.GoToTarget, + }; + + void executeGitCommand({ + command: 'worktree', + state: { + subcommand: 'open', + repo: defaultWorktree.repoPath, + worktree: defaultWorktree, + onWorkspaceChanging: async (_isNewWorktree?: boolean) => + this.container.storage.store('deepLinks:pending', deleteBranchDeepLink), + worktreeDefaultOpen: 'current', + }, + }); + } + } + + private pushBranch(ref: BranchRef) { + void this.container.git.push(ref.repoPath, { + reference: { + name: ref.branchName, + ref: ref.branchId, + refType: 'branch', + remote: false, + repoPath: ref.repoPath, + upstream: ref.branchUpstreamName + ? { + name: ref.branchUpstreamName, + missing: false, + } + : undefined, + }, + }); + } + private mergeTargetCompare(ref: BranchAndTargetRefs) { return this.container.views.searchAndCompare.compare(ref.repoPath, ref.branchName, ref.mergeTargetName); } @@ -1401,7 +1494,7 @@ function getOverviewBranchesCore( timestamp: timestamp, status: branch.status, upstream: branch.upstream, - worktree: wt ? { name: wt.name, uri: wt.uri.toString() } : undefined, + worktree: wt ? { name: wt.name, uri: wt.uri.toString(), isDefault: wt.isDefault } : undefined, }); } diff --git a/src/webviews/home/protocol.ts b/src/webviews/home/protocol.ts index ce5a8a03dc992..0106b72bf03d7 100644 --- a/src/webviews/home/protocol.ts +++ b/src/webviews/home/protocol.ts @@ -181,6 +181,7 @@ export interface GetOverviewBranch { worktree?: { name: string; uri: string; + isDefault: boolean; }; } @@ -320,6 +321,11 @@ export interface BranchRef { repoPath: string; branchId: string; branchName: string; + branchUpstreamName?: string; + worktree?: { + name: string; + isDefault: boolean; + }; } export interface BranchAndTargetRefs extends BranchRef { diff --git a/src/webviews/plus/graph/graphWebview.ts b/src/webviews/plus/graph/graphWebview.ts index 20d33a16d2812..96ba2ac0d7499 100644 --- a/src/webviews/plus/graph/graphWebview.ts +++ b/src/webviews/plus/graph/graphWebview.ts @@ -3891,7 +3891,7 @@ export class GraphWebviewProvider implements WebviewProvider