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
23 changes: 20 additions & 3 deletions src/main/ipc/worktree-logic.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { join } from 'path'
import { join, resolve } from 'path'
import { describe, expect, it } from 'vitest'
import {
sanitizeWorktreeName,
Expand All @@ -9,7 +9,8 @@ import {
mergeWorktree,
parseWorktreeId,
formatWorktreeRemovalError,
isOrphanedWorktreeError
isOrphanedWorktreeError,
areWorktreePathsEqual
} from './worktree-logic'

describe('sanitizeWorktreeName', () => {
Expand Down Expand Up @@ -53,7 +54,7 @@ describe('sanitizeWorktreeName', () => {
describe('ensurePathWithinWorkspace', () => {
it('returns resolved path when within workspace', () => {
const result = ensurePathWithinWorkspace('/workspace/feature', '/workspace')
expect(result).toBe('/workspace/feature')
expect(result).toBe(resolve('/workspace/feature'))
})

it('throws when path traverses outside workspace', () => {
Expand Down Expand Up @@ -120,6 +121,22 @@ describe('computeWorktreePath', () => {
})
})

describe('areWorktreePathsEqual', () => {
it('treats Windows slash and casing differences as the same path', () => {
expect(
areWorktreePathsEqual(
'C:\\Workspaces\\Improve-Dashboard',
'c:/workspaces/improve-dashboard',
'win32'
)
).toBe(true)
})

it('keeps POSIX path comparison case-sensitive', () => {
expect(areWorktreePathsEqual('/tmp/Worktree', '/tmp/worktree', 'linux')).toBe(false)
})
})

describe('shouldSetDisplayName', () => {
it('returns false when requestedName matches both branchName and sanitizedName', () => {
expect(shouldSetDisplayName('feature', 'feature', 'feature')).toBe(false)
Expand Down
25 changes: 24 additions & 1 deletion src/main/ipc/worktree-logic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { basename, join, resolve, relative, isAbsolute } from 'path'
import { basename, join, resolve, relative, isAbsolute, posix, win32 } from 'path'
import type { GitWorktreeInfo, Worktree, WorktreeMeta } from '../../shared/types'

/**
Expand Down Expand Up @@ -69,6 +69,29 @@ export function computeWorktreePath(
return join(settings.workspaceDir, sanitizedName)
}

export function areWorktreePathsEqual(
leftPath: string,
rightPath: string,
platform = process.platform
): boolean {
if (platform === 'win32' || looksLikeWindowsPath(leftPath) || looksLikeWindowsPath(rightPath)) {
const left = win32.normalize(win32.resolve(leftPath))
const right = win32.normalize(win32.resolve(rightPath))
// Why: `git worktree list` can report the same Windows path with different
// slash styles or drive-letter casing than the path we computed before
// creation. Orca must treat those as the same worktree or a successful
// create spuriously fails until the next full reload repopulates state.
return left.toLowerCase() === right.toLowerCase()
}
const left = posix.normalize(posix.resolve(leftPath))
const right = posix.normalize(posix.resolve(rightPath))
return left === right
}

function looksLikeWindowsPath(pathValue: string): boolean {
return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\')
}

/**
* Determine whether a display name should be persisted.
* A display name is set only when the user's requested name differs from
Expand Down
148 changes: 146 additions & 2 deletions src/main/ipc/worktrees.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ const {
getEffectiveHooksMock,
runHookMock,
hasHooksFileMock,
loadHooksMock
loadHooksMock,
computeWorktreePathMock,
ensurePathWithinWorkspaceMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
Expand All @@ -27,7 +29,9 @@ const {
getEffectiveHooksMock: vi.fn(),
runHookMock: vi.fn(),
hasHooksFileMock: vi.fn(),
loadHooksMock: vi.fn()
loadHooksMock: vi.fn(),
computeWorktreePathMock: vi.fn(),
ensurePathWithinWorkspaceMock: vi.fn()
}))

vi.mock('electron', () => ({
Expand Down Expand Up @@ -60,6 +64,15 @@ vi.mock('../hooks', () => ({
hasHooksFile: hasHooksFileMock
}))

vi.mock('./worktree-logic', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
computeWorktreePath: computeWorktreePathMock,
ensurePathWithinWorkspace: ensurePathWithinWorkspaceMock
}
})

import { registerWorktreeHandlers } from './worktrees'

type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
Expand Down Expand Up @@ -95,6 +108,8 @@ describe('registerWorktreeHandlers', () => {
runHookMock.mockReset()
hasHooksFileMock.mockReset()
loadHooksMock.mockReset()
computeWorktreePathMock.mockReset()
ensurePathWithinWorkspaceMock.mockReset()
mainWindow.webContents.send.mockReset()
store.getRepos.mockReset()
store.getRepo.mockReset()
Expand Down Expand Up @@ -131,6 +146,20 @@ describe('registerWorktreeHandlers', () => {
getBranchConflictKindMock.mockResolvedValue(null)
getPRForBranchMock.mockResolvedValue(null)
getEffectiveHooksMock.mockReturnValue(null)
computeWorktreePathMock.mockImplementation(
(
sanitizedName: string,
repoPath: string,
settings: { nestWorkspaces: boolean; workspaceDir: string }
) => {
if (settings.nestWorkspaces) {
const repoName = repoPath.split(/[\\/]/).at(-1)?.replace(/\.git$/, '') ?? 'repo'
return `${settings.workspaceDir}/${repoName}/${sanitizedName}`
}
return `${settings.workspaceDir}/${sanitizedName}`
}
)
ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath)
listWorktreesMock.mockResolvedValue([])

registerWorktreeHandlers(mainWindow as never, store as never)
Expand Down Expand Up @@ -174,4 +203,119 @@ describe('registerWorktreeHandlers', () => {

expect(addWorktreeMock).not.toHaveBeenCalled()
})

it('accepts a newly created Windows worktree when git lists the same path with different separators', async () => {
store.getRepo.mockReturnValue({
id: 'repo-1',
path: 'C:\\repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
worktreeBaseRef: null
})
store.getSettings.mockReturnValue({
branchPrefix: 'none',
nestWorkspaces: false,
workspaceDir: 'C:\\workspaces'
})
computeWorktreePathMock.mockReturnValue('C:\\workspaces\\improve-dashboard')
ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\improve-dashboard')
listWorktreesMock.mockResolvedValue([
{
path: 'C:/workspaces/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
])

const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})

expect(addWorktreeMock).toHaveBeenCalledWith(
'C:\\repo',
'C:\\workspaces\\improve-dashboard',
'improve-dashboard',
'origin/main'
)
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
'repo-1::C:/workspaces/improve-dashboard',
expect.objectContaining({
lastActivityAt: expect.any(Number)
})
)
expect(result).toMatchObject({
id: 'repo-1::C:/workspaces/improve-dashboard',
path: 'C:/workspaces/improve-dashboard',
branch: 'refs/heads/improve-dashboard'
})
})

it('preserves create-time metadata on the next list when Windows path formatting differs', async () => {
store.getRepo.mockReturnValue({
id: 'repo-1',
path: 'C:\\repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
worktreeBaseRef: null
})
store.getSettings.mockReturnValue({
branchPrefix: 'none',
nestWorkspaces: false,
workspaceDir: 'C:\\workspaces'
})
computeWorktreePathMock.mockReturnValue('C:\\workspaces\\improve-dashboard')
ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\improve-dashboard')
listWorktreesMock
.mockResolvedValueOnce([
{
path: 'C:/workspaces/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
.mockResolvedValueOnce([
{
path: 'C:/workspaces/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
store.setWorktreeMeta.mockReturnValue({
lastActivityAt: 123,
displayName: 'Improve Dashboard'
})
store.getWorktreeMeta.mockImplementation((worktreeId: string) =>
worktreeId === 'repo-1::C:/workspaces/improve-dashboard'
? {
lastActivityAt: 123,
displayName: 'Improve Dashboard'
}
: undefined
)

await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'Improve Dashboard'
})
const listed = await handlers['worktrees:list'](null, {
repoId: 'repo-1'
})

expect(listed).toMatchObject([
{
id: 'repo-1::C:/workspaces/improve-dashboard',
displayName: 'Improve Dashboard',
lastActivityAt: 123
}
])
})
})
5 changes: 3 additions & 2 deletions src/main/ipc/worktrees.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
shouldSetDisplayName,
mergeWorktree,
parseWorktreeId,
areWorktreePathsEqual,
formatWorktreeRemovalError,
isOrphanedWorktreeError
} from './worktree-logic'
Expand Down Expand Up @@ -123,12 +124,12 @@ export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store

// Re-list to get the freshly created worktree info
const gitWorktrees = await listWorktrees(repo.path)
const created = gitWorktrees.find((gw) => gw.path === worktreePath)
const created = gitWorktrees.find((gw) => areWorktreePathsEqual(gw.path, worktreePath))
if (!created) {
throw new Error('Worktree created but not found in listing')
}

const worktreeId = `${repo.id}::${worktreePath}`
const worktreeId = `${repo.id}::${created.path}`
const metaUpdates: Partial<WorktreeMeta> = {
// Stamp activity so the worktree sorts into its final position
// immediately — prevents scroll-to-reveal racing with a later
Expand Down
Loading
Loading