diff --git a/cspell.json b/cspell.json index f146ffed..3e07e4f6 100644 --- a/cspell.json +++ b/cspell.json @@ -1,4 +1,4 @@ { "version": "0.2", - "words": ["worktree", "gitdir", "lszomoru", "objectname", "pathspec", "symref", "uall", "unstash"] + "words": ["worktree", "worktrees", "gitdir", "lszomoru", "objectname", "pathspec", "symref", "uall", "unstash"] } diff --git a/packages/e2e/src/git-api-create-worktree.ts b/packages/e2e/src/git-api-create-worktree.ts index 9bd97493..2fdd0321 100644 --- a/packages/e2e/src/git-api-create-worktree.ts +++ b/packages/e2e/src/git-api-create-worktree.ts @@ -1,4 +1,3 @@ -/* eslint-disable @cspell/spellchecker */ import type { Test } from '@lvce-editor/test-with-playwright' export const name = 'git.createWorktree' diff --git a/packages/e2e/src/git-api-delete-worktree.ts b/packages/e2e/src/git-api-delete-worktree.ts index 460c1a56..00d1c84d 100644 --- a/packages/e2e/src/git-api-delete-worktree.ts +++ b/packages/e2e/src/git-api-delete-worktree.ts @@ -1,4 +1,3 @@ -/* eslint-disable @cspell/spellchecker */ import type { Test } from '@lvce-editor/test-with-playwright' export const name = 'git.deleteWorktree' diff --git a/packages/e2e/src/git.quick-pick-delete-worktree.ts b/packages/e2e/src/git.quick-pick-delete-worktree.ts new file mode 100644 index 00000000..dd8b4fc1 --- /dev/null +++ b/packages/e2e/src/git.quick-pick-delete-worktree.ts @@ -0,0 +1,46 @@ +import type { Test } from '@lvce-editor/test-with-playwright' + +export const name = 'git.quick-pick-delete-worktree' + +const waitForFolderRemoval = async ( + FileSystem: { readDir: (uri: string) => Promise }, + parentDir: string, + folderName: string, +): Promise => { + for (let i = 0; i < 20; i++) { + const entries = await FileSystem.readDir(parentDir) + if (entries.every((dirent) => dirent.name !== folderName)) { + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error(`expected ${folderName} folder to be removed`) +} + +export const test: Test = async ({ Command, expect, FileSystem, Git, Locator, QuickPick, Workspace }) => { + // arrange + const tmpDir = await FileSystem.getTmpDir({ scheme: 'file' }) + const workspaceDir = `${tmpDir}/workspace` + + await Workspace.setPath(tmpDir) + const fixtureUrl = import.meta.resolve('../fixtures/git-api-delete-worktree') + await Command.execute('ExtensionHost.executeCommand', 'git.loadFixture', fixtureUrl) + await Workspace.setPath(workspaceDir) + + // act + await QuickPick.open() + await QuickPick.setValue('>Git: Delete Worktree') + await QuickPick.selectItem('Git: Delete Worktree', { waitUntil: 'quickPick' }) + const worktreeItem = Locator('#QuickPick text=feature-worktree') + await expect(worktreeItem).toBeVisible() + await QuickPick.selectItem('feature-worktree') + + // assert + await waitForFolderRemoval(FileSystem, tmpDir, 'feature-worktree') + await Git.shouldHaveInvocations([ + { + command: ['git', 'worktree', 'list', '--porcelain', '-z'], + cwd: workspaceDir, + }, + ]) +} diff --git a/packages/extension/extension.json b/packages/extension/extension.json index aad660a9..d01b4ec2 100644 --- a/packages/extension/extension.json +++ b/packages/extension/extension.json @@ -44,6 +44,7 @@ "onCommand:git.createWorktree", "onCommand:git.createTag", "onCommand:git.deleteWorktree", + "onCommand:git.deleteWorktreeSelect", "onCommand:git.deleteRemoteTag", "onCommand:git.deleteTag", "onCommand:git.sync", @@ -137,7 +138,7 @@ "label": "Git: Create Tag" }, { - "id": "git.deleteWorktree", + "id": "git.deleteWorktreeSelect", "label": "Git: Delete Worktree" }, { @@ -245,6 +246,9 @@ { "id": "git.checkoutRef" }, + { + "id": "git.deleteWorktreeSelect" + }, { "id": "git.stash" } diff --git a/packages/extension/src/parts/CommandId/CommandId.ts b/packages/extension/src/parts/CommandId/CommandId.ts index 5c7940bc..a7a4a809 100644 --- a/packages/extension/src/parts/CommandId/CommandId.ts +++ b/packages/extension/src/parts/CommandId/CommandId.ts @@ -16,6 +16,7 @@ export const GitDeleteBranch = 'git.deleteBranch' export const GitDeleteRemoteTag = 'git.deleteRemoteTag' export const GitDeleteTag = 'git.deleteTag' export const GitDeleteWorktree = 'git.deleteWorktree' +export const GitDeleteWorktreeSelect = 'git.deleteWorktreeSelect' export const GitCheckout = 'git.checkout' export const GitCheckoutRef = 'git.checkoutRef' export const GitCleanAll = 'git.cleanAll' diff --git a/packages/extension/src/parts/ExtensionHostCommand/ExtensionHostCommand.ts b/packages/extension/src/parts/ExtensionHostCommand/ExtensionHostCommand.ts index df6baaf7..9edb754c 100644 --- a/packages/extension/src/parts/ExtensionHostCommand/ExtensionHostCommand.ts +++ b/packages/extension/src/parts/ExtensionHostCommand/ExtensionHostCommand.ts @@ -14,6 +14,7 @@ export * as GitDeleteBranch from './ExtensionHostCommandGitDeleteBranch.ts' export * as GitDeleteRemoteTag from './ExtensionHostCommandGitDeleteRemoteTag.ts' export * as GitDeleteTag from './ExtensionHostCommandGitDeleteTag.ts' export * as GitDeleteWorktree from './ExtensionHostCommandGitDeleteWorktree.ts' +export * as GitDeleteWorktreeSelect from './ExtensionHostCommandGitDeleteWorktreeSelect.ts' export * as GitCheckout from './ExtensionHostCommandGitCheckout.ts' export * as GitCheckoutRef from './ExtensionHostCommandGitCheckoutRef.ts' export * as GitCleanAll from './ExtensionHostCommandGitCleanAll.ts' diff --git a/packages/extension/src/parts/ExtensionHostCommand/ExtensionHostCommandGitDeleteWorktreeSelect.ts b/packages/extension/src/parts/ExtensionHostCommand/ExtensionHostCommandGitDeleteWorktreeSelect.ts new file mode 100644 index 00000000..5b24de93 --- /dev/null +++ b/packages/extension/src/parts/ExtensionHostCommand/ExtensionHostCommandGitDeleteWorktreeSelect.ts @@ -0,0 +1,9 @@ +import * as CommandId from '../CommandId/CommandId.ts' +import * as GitWorker from '../GitWorker/GitWorker.ts' +import * as GitWorkerCommandType from '../GitWorkerCommandType/GitWorkerCommandType.ts' + +export const id = CommandId.GitDeleteWorktreeSelect + +export const execute = async () => { + return GitWorker.invoke(GitWorkerCommandType.CommandDeleteWorktreeSelect) +} diff --git a/packages/extension/src/parts/GitWorkerCommandType/GitWorkerCommandType.ts b/packages/extension/src/parts/GitWorkerCommandType/GitWorkerCommandType.ts index 2bb1ae4d..73329c0b 100644 --- a/packages/extension/src/parts/GitWorkerCommandType/GitWorkerCommandType.ts +++ b/packages/extension/src/parts/GitWorkerCommandType/GitWorkerCommandType.ts @@ -15,6 +15,7 @@ export const GitDeleteBranch = 'Git.deleteBranch' export const GitDeleteRemoteTag = 'Git.deleteRemoteTag' export const GitDeleteTag = 'Git.deleteTag' export const GitDeleteWorktree = 'Git.deleteWorktree' +export const CommandDeleteWorktreeSelect = 'Command.gitDeleteWorktreeSelect' export const GitDiscard = 'Git.discard' export const GitFetch = 'Git.fetch' export const GitGetAddedFiles = 'Git.getAddedFiles' diff --git a/packages/git-requests/src/parts/GitRequestsGetWorktrees/GitRequestsGetWorktrees.ts b/packages/git-requests/src/parts/GitRequestsGetWorktrees/GitRequestsGetWorktrees.ts new file mode 100644 index 00000000..b463ee46 --- /dev/null +++ b/packages/git-requests/src/parts/GitRequestsGetWorktrees/GitRequestsGetWorktrees.ts @@ -0,0 +1,21 @@ +import type { GitRequestContext } from '../Types/Types.ts' +import { GitError } from '../GitError/GitError.ts' + +const worktreePrefix = 'worktree ' + +export const getWorktrees = async ({ cwd, exec, gitPath }: GitRequestContext): Promise => { + try { + const gitResult = await exec({ + args: ['worktree', 'list', '--porcelain', '-z'], + cwd, + gitPath, + name: 'getWorktrees', + }) + return gitResult.stdout + .split('\0') + .filter((line) => line.startsWith(worktreePrefix)) + .map((line) => line.slice(worktreePrefix.length)) + } catch (error) { + throw new GitError(error, 'getWorktrees') + } +} diff --git a/packages/git-requests/src/parts/Main/Main.ts b/packages/git-requests/src/parts/Main/Main.ts index 11d4a288..1b3eac5c 100644 --- a/packages/git-requests/src/parts/Main/Main.ts +++ b/packages/git-requests/src/parts/Main/Main.ts @@ -27,6 +27,7 @@ export * from '../GitRequestsGetGroups/GitRequestsGetGroups.ts' export * from '../GitRequestsGetModifiedFiles/GitRequestsGetModifiedFiles.ts' export * from '../GitRequestsGetRefs/GitRequestsGetRefs.ts' export * from '../GitRequestsGetRemote/GitRequestsGetRemote.ts' +export * from '../GitRequestsGetWorktrees/GitRequestsGetWorktrees.ts' export * from '../GitRequestsInit/GitRequestsInit.ts' export * from '../GitRequestsPull/GitRequestsPull.ts' export * from '../GitRequestsPullAndRebase/GitRequestPullAndRebase.ts' @@ -42,3 +43,4 @@ export * from '../GitRequestsUnstash/GitRequestsUnstash.ts' export * from '../GitRequestsUnstage/GitRequestsUnstage.ts' export * from '../GitRequestsUnstageAll/GitRequestsUnstageAll.ts' export * from '../GitRequestsVersion/GitRequestsVersion.ts' +export * from '../ToFileSystemPath/ToFileSystemPath.ts' diff --git a/packages/git-requests/test/GitRequestsGetWorktrees.test.ts b/packages/git-requests/test/GitRequestsGetWorktrees.test.ts new file mode 100644 index 00000000..3a78f06b --- /dev/null +++ b/packages/git-requests/test/GitRequestsGetWorktrees.test.ts @@ -0,0 +1,38 @@ +import { expect, jest, test } from '@jest/globals' +import type { GitExec } from '../src/parts/Types/Types.ts' +import * as GitRequestsGetWorktrees from '../src/parts/GitRequestsGetWorktrees/GitRequestsGetWorktrees.ts' + +test('getWorktrees - returns worktree paths', async (): Promise => { + const exec = jest.fn(async () => ({ + stderr: '', + stdout: 'worktree /test/workspace\0HEAD 123\0branch refs/heads/main\0\0worktree /test/feature worktree\0HEAD 456\0branch refs/heads/feature\0\0', + })) + + await expect( + GitRequestsGetWorktrees.getWorktrees({ + cwd: '/test/workspace', + exec, + gitPath: '/test/git', + }), + ).resolves.toEqual(['/test/workspace', '/test/feature worktree']) + expect(exec).toHaveBeenCalledWith({ + args: ['worktree', 'list', '--porcelain', '-z'], + cwd: '/test/workspace', + gitPath: '/test/git', + name: 'getWorktrees', + }) +}) + +test('getWorktrees - error - unknown git error', async (): Promise => { + const exec = jest.fn(async () => { + throw new Error('Failed') + }) + + await expect( + GitRequestsGetWorktrees.getWorktrees({ + cwd: '/test/workspace', + exec, + gitPath: '/test/git', + }), + ).rejects.toThrow('Failed') +}) diff --git a/packages/git-worker/src/parts/CommandDeleteWorktreeSelect/CommandDeleteWorktreeSelect.ts b/packages/git-worker/src/parts/CommandDeleteWorktreeSelect/CommandDeleteWorktreeSelect.ts new file mode 100644 index 00000000..dfd657b2 --- /dev/null +++ b/packages/git-worker/src/parts/CommandDeleteWorktreeSelect/CommandDeleteWorktreeSelect.ts @@ -0,0 +1,62 @@ +import * as Git from '../Git/Git.ts' +import * as Repositories from '../GitRepositories/GitRepositories.ts' +import * as GitRepositoriesRequests from '../GitRepositoriesRequests/GitRepositoriesRequests.ts' +import * as GitRequests from '../GitRequests/GitRequests.ts' +import * as Rpc from '../Rpc/Rpc.ts' + +type WorktreePick = { + readonly description: string + readonly label: string + readonly worktreePath: string +} + +const getBaseName = (path: string): string => { + let normalizedPath = path.replaceAll('\\', '/') + while (normalizedPath.endsWith('/')) { + normalizedPath = normalizedPath.slice(0, -1) + } + const index = normalizedPath.lastIndexOf('/') + return normalizedPath.slice(index + 1) +} + +const getWorktreePicks = (worktrees: readonly string[], currentWorktree: string): readonly WorktreePick[] => { + return worktrees + .filter((worktreePath) => worktreePath !== currentWorktree) + .map((worktreePath) => ({ + description: worktreePath, + label: getBaseName(worktreePath), + worktreePath, + })) +} + +export const commandDeleteWorktreeSelect = async (): Promise => { + const repository = await Repositories.getCurrent() + const { gitPath, path } = repository + const worktrees = await GitRepositoriesRequests.execute({ + args: { + cwd: path, + exec: Git.exec, + gitPath, + }, + fn: GitRequests.getWorktrees, + id: 'getWorktrees', + }) + const currentWorktree = GitRequests.toFileSystemPath(path) + const picks = getWorktreePicks(worktrees, currentWorktree) + const selectedPick = await Rpc.invoke('QuickPick.show', picks) + if (!selectedPick) { + return + } + const { worktreePath } = selectedPick + await GitRepositoriesRequests.execute({ + args: { + cwd: path, + exec: Git.exec, + gitPath, + worktreePath, + }, + fn: GitRequests.deleteWorktree, + id: 'deleteWorktree', + }) + return worktreePath +} diff --git a/packages/git-worker/src/parts/CommandMap/CommandMap.ts b/packages/git-worker/src/parts/CommandMap/CommandMap.ts index 0cf349b6..3e0a8858 100644 --- a/packages/git-worker/src/parts/CommandMap/CommandMap.ts +++ b/packages/git-worker/src/parts/CommandMap/CommandMap.ts @@ -4,6 +4,7 @@ import * as CommandAddAll from '../CommandAddAll/CommandAddAll.ts' import * as CommandApplyStash from '../CommandApplyStash/CommandApplyStash.ts' import * as CommandCheckout from '../CommandCheckout/CommandCheckout.ts' import * as CommandCleanAll from '../CommandCleanAll/CommandCleanAll.ts' +import * as CommandDeleteWorktreeSelect from '../CommandDeleteWorktreeSelect/CommandDeleteWorktreeSelect.ts' import * as CommandDiscard from '../CommandDiscard/CommandDiscard.ts' import * as CommandFetch from '../CommandFetch/CommandFetch.ts' import * as CommandFetchPrune from '../CommandFetchPrune/CommandFetchPrune.ts' @@ -34,6 +35,7 @@ export const commandMap = { [GitWorkerCommandType.CommandApplyStash]: CommandApplyStash.commandApplyStash, [GitWorkerCommandType.CommandCheckoutRef]: CommandCheckout.commandCheckout, [GitWorkerCommandType.CommandCleanAll]: CommandCleanAll.commandCleanAll, + [GitWorkerCommandType.CommandDeleteWorktreeSelect]: CommandDeleteWorktreeSelect.commandDeleteWorktreeSelect, [GitWorkerCommandType.CommandDiscard]: CommandDiscard.commandDiscard, [GitWorkerCommandType.CommandFetch]: CommandFetch.commandFetch, [GitWorkerCommandType.CommandFetchPrune]: CommandFetchPrune.commandFetchPrune, diff --git a/packages/git-worker/src/parts/GitWorkerCommandType/GitWorkerCommandType.ts b/packages/git-worker/src/parts/GitWorkerCommandType/GitWorkerCommandType.ts index 4ad4fff4..4ce3c8a3 100644 --- a/packages/git-worker/src/parts/GitWorkerCommandType/GitWorkerCommandType.ts +++ b/packages/git-worker/src/parts/GitWorkerCommandType/GitWorkerCommandType.ts @@ -5,6 +5,7 @@ export const CommandApplyStash = 'Command.gitApplyStash' export const CommandCheckoutRef = 'Command.gitCheckoutRef' export const CommandCleanAll = 'Command.gitCleanAll' export const CommandDiscard = 'Command.gitDiscard' +export const CommandDeleteWorktreeSelect = 'Command.gitDeleteWorktreeSelect' export const CommandFetch = 'Command.gitFetch' export const CommandFetchPrune = 'Command.gitFetchPrune' export const CommandInit = 'Command.gitInit' diff --git a/packages/git-worker/test/CommandDeleteWorktreeSelect.test.ts b/packages/git-worker/test/CommandDeleteWorktreeSelect.test.ts new file mode 100644 index 00000000..5857af73 --- /dev/null +++ b/packages/git-worker/test/CommandDeleteWorktreeSelect.test.ts @@ -0,0 +1,89 @@ +/* eslint-disable jest/no-restricted-jest-methods */ +import { jest } from '@jest/globals' +import type * as GitRepositories from '../src/parts/GitRepositories/GitRepositories.ts' +import type * as GitRepositoriesRequests from '../src/parts/GitRepositoriesRequests/GitRepositoriesRequests.ts' +import type * as Rpc from '../src/parts/Rpc/Rpc.ts' + +const mockGetCurrent = jest.fn() +const mockExecute = jest.fn() +const mockInvoke = jest.fn() + +jest.unstable_mockModule('../src/parts/GitRepositories/GitRepositories.ts', () => ({ + getCurrent: mockGetCurrent, +})) + +jest.unstable_mockModule('../src/parts/GitRepositoriesRequests/GitRepositoriesRequests.ts', () => ({ + execute: mockExecute, +})) + +jest.unstable_mockModule('../src/parts/Rpc/Rpc.ts', () => ({ + invoke: mockInvoke, +})) + +const CommandDeleteWorktreeSelect = await import('../src/parts/CommandDeleteWorktreeSelect/CommandDeleteWorktreeSelect.ts') +const Git = await import('../src/parts/Git/Git.ts') +const GitRequests = await import('../src/parts/GitRequests/GitRequests.ts') + +beforeEach(() => { + jest.resetAllMocks() + mockGetCurrent.mockResolvedValue({ + gitPath: '/test/git', + gitVersion: '2.39.2', + path: '/test/workspace', + }) +}) + +test('deletes selected worktree', async (): Promise => { + const worktrees = ['/test/workspace', '/test/feature worktree'] + const pick = { + description: '/test/feature worktree', + label: 'feature worktree', + worktreePath: '/test/feature worktree', + } + mockExecute.mockResolvedValueOnce(worktrees).mockResolvedValueOnce(undefined) + mockInvoke.mockResolvedValue(pick) + + await expect(CommandDeleteWorktreeSelect.commandDeleteWorktreeSelect()).resolves.toBe('/test/feature worktree') + expect(mockInvoke).toHaveBeenCalledWith('QuickPick.show', [pick]) + expect(mockExecute).toHaveBeenNthCalledWith(1, { + args: { + cwd: '/test/workspace', + exec: Git.exec, + gitPath: '/test/git', + }, + fn: GitRequests.getWorktrees, + id: 'getWorktrees', + }) + expect(mockExecute).toHaveBeenNthCalledWith(2, { + args: { + cwd: '/test/workspace', + exec: Git.exec, + gitPath: '/test/git', + worktreePath: '/test/feature worktree', + }, + fn: GitRequests.deleteWorktree, + id: 'deleteWorktree', + }) +}) + +test('does not delete the current worktree', async (): Promise => { + mockGetCurrent.mockResolvedValue({ + gitPath: '/test/git', + gitVersion: '2.39.2', + path: 'file:///test/workspace', + }) + mockExecute.mockResolvedValue(['/test/workspace']) + mockInvoke.mockResolvedValue(undefined) + + await expect(CommandDeleteWorktreeSelect.commandDeleteWorktreeSelect()).resolves.toBeUndefined() + expect(mockInvoke).toHaveBeenCalledWith('QuickPick.show', []) + expect(mockExecute).toHaveBeenCalledTimes(1) +}) + +test('does not delete when quick pick is canceled', async (): Promise => { + mockExecute.mockResolvedValue(['/test/workspace', '/test/feature']) + mockInvoke.mockResolvedValue(undefined) + + await expect(CommandDeleteWorktreeSelect.commandDeleteWorktreeSelect()).resolves.toBeUndefined() + expect(mockExecute).toHaveBeenCalledTimes(1) +})