Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cspell.json
Original file line number Diff line number Diff line change
@@ -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"]
}
1 change: 0 additions & 1 deletion packages/e2e/src/git-api-create-worktree.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable @cspell/spellchecker */
import type { Test } from '@lvce-editor/test-with-playwright'

export const name = 'git.createWorktree'
Expand Down
1 change: 0 additions & 1 deletion packages/e2e/src/git-api-delete-worktree.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable @cspell/spellchecker */
import type { Test } from '@lvce-editor/test-with-playwright'

export const name = 'git.deleteWorktree'
Expand Down
46 changes: 46 additions & 0 deletions packages/e2e/src/git.quick-pick-delete-worktree.ts
Original file line number Diff line number Diff line change
@@ -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<readonly { readonly name: string }[]> },
parentDir: string,
folderName: string,
): Promise<void> => {
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,
},
])
}
6 changes: 5 additions & 1 deletion packages/extension/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -137,7 +138,7 @@
"label": "Git: Create Tag"
},
{
"id": "git.deleteWorktree",
"id": "git.deleteWorktreeSelect",
"label": "Git: Delete Worktree"
},
{
Expand Down Expand Up @@ -245,6 +246,9 @@
{
"id": "git.checkoutRef"
},
{
"id": "git.deleteWorktreeSelect"
},
{
"id": "git.stash"
}
Expand Down
1 change: 1 addition & 0 deletions packages/extension/src/parts/CommandId/CommandId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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<readonly string[]> => {
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')
}
}
2 changes: 2 additions & 0 deletions packages/git-requests/src/parts/Main/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
38 changes: 38 additions & 0 deletions packages/git-requests/test/GitRequestsGetWorktrees.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
const exec = jest.fn<GitExec>(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<void> => {
const exec = jest.fn<GitExec>(async () => {
throw new Error('Failed')
})

await expect(
GitRequestsGetWorktrees.getWorktrees({
cwd: '/test/workspace',
exec,
gitPath: '/test/git',
}),
).rejects.toThrow('Failed')
})
Original file line number Diff line number Diff line change
@@ -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<string | undefined> => {
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
}
2 changes: 2 additions & 0 deletions packages/git-worker/src/parts/CommandMap/CommandMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
89 changes: 89 additions & 0 deletions packages/git-worker/test/CommandDeleteWorktreeSelect.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof GitRepositories.getCurrent>()
const mockExecute = jest.fn<typeof GitRepositoriesRequests.execute>()
const mockInvoke = jest.fn<typeof Rpc.invoke>()

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<void> => {
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<void> => {
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<void> => {
mockExecute.mockResolvedValue(['/test/workspace', '/test/feature'])
mockInvoke.mockResolvedValue(undefined)

await expect(CommandDeleteWorktreeSelect.commandDeleteWorktreeSelect()).resolves.toBeUndefined()
expect(mockExecute).toHaveBeenCalledTimes(1)
})
Loading