Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ed60f5c
fix(worktrees): delete removed checkouts in git, not in Orca's file pool
brennanb2025 Sep 29, 2026
7e5acff
fix(worktrees): let Git delete Windows checkouts with long paths enabled
brennanb2025 Sep 29, 2026
97b3778
fix(worktrees): keep an inherited GIT_ASK_YESNO out of the worktree d…
brennanb2025 Sep 29, 2026
09d6707
perf(worktrees): run worktree deletes under their own limit, outside …
brennanb2025 Sep 29, 2026
3d92074
fix(worktrees): delete removed checkouts in the background and mark t…
brennanb2025 Sep 29, 2026
f4ef5bc
fix(worktrees): keep the existing Deleting card until the host's Git …
brennanb2025 Sep 29, 2026
5f5c3c9
test(worktrees): type the removal harness and projection for the node…
brennanb2025 Sep 29, 2026
5a2c9bd
Merge remote-tracking branch 'origin/main' into brennanb2025/worktree…
brennanb2025 Sep 29, 2026
fc75c61
fix(worktrees): don't fail a delete retry with an earlier attempt's b…
brennanb2025 Sep 29, 2026
c462aba
fix(worktrees): let only a gap in host events settle a background del…
brennanb2025 Sep 29, 2026
2992bd8
perf(worktrees): let a bulk delete start each same-repo checkout dele…
brennanb2025 Sep 29, 2026
0c6765d
test(worktrees): pin the host platform in the mocked removal suites s…
brennanb2025 Sep 29, 2026
aeb5c94
test(worktrees): type the blocked git remove result instead of a broa…
brennanb2025 Sep 29, 2026
e72f56c
test(worktrees): clear the changed-code quality gate in the removal s…
brennanb2025 Sep 29, 2026
5e14dc2
fix(worktrees): record each background delete durably and finish it a…
brennanb2025 Sep 29, 2026
2d71eb9
test(worktrees): type the interrupted-removal assertions for the node…
brennanb2025 Sep 29, 2026
09f75c4
fix(worktrees): finish an interrupted delete that already removed the…
brennanb2025 Sep 29, 2026
e6fa1a9
fix(worktrees): let Git finish an interrupted delete, and never take …
brennanb2025 Sep 29, 2026
ab2c4b3
test(worktrees): compare worktree paths by value and tear down with W…
brennanb2025 Sep 29, 2026
5534e95
fix(worktrees): reply to a worktree delete when it has finished, not …
brennanb2025 Sep 29, 2026
c13bece
test(worktrees): type the pending-removal host id in the background-r…
brennanb2025 Sep 29, 2026
aafc7eb
fix(worktrees): answer a delete request even when a concurrent remova…
brennanb2025 Sep 29, 2026
676ac07
Merge remote-tracking branch 'origin/main' into brennanb2025/worktree…
brennanb2025 Sep 29, 2026
032e520
fix(worktrees): run same-repo removal archive hooks and teardown one …
brennanb2025 Sep 29, 2026
52cdae9
fix(runtime): keep waiting worktree deletes out of a host's foregroun…
brennanb2025 Sep 29, 2026
c34bcc2
fix(worktrees): join a same-worktree delete accepted while a removal …
brennanb2025 Sep 29, 2026
8c81fca
fix(worktrees): fence a resumed delete's checkout from startup, and d…
brennanb2025 Sep 29, 2026
567cb50
test(worktrees): keep git's auto-maintenance out of the real-git remo…
brennanb2025 Sep 29, 2026
2755748
fix(worktrees): one archive-hook approval covers a same-repo bulk del…
brennanb2025 Sep 29, 2026
177b7a1
Merge remote-tracking branch 'origin/main' into brennanb2025/worktree…
brennanb2025 Sep 29, 2026
1b3ba5c
Merge remote-tracking branch 'origin/main' into brennanb2025/worktree…
brennanb2025 Sep 30, 2026
e0b2d04
Merge remote-tracking branch 'origin/main' into brennanb2025/worktree…
brennanb2025 Sep 30, 2026
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 config/scripts/ci-shard-timings.json
Original file line number Diff line number Diff line change
Expand Up @@ -1788,7 +1788,6 @@
"src/main/git/worktree-create-preparation-real-wsl.test.ts": 271,
"src/main/git/worktree-created-description-real-git.test.ts": 1340,
"src/main/git/worktree-created-disk-witness.test.ts": 149,
"src/main/git/worktree-deferred-removal-real-git.test.ts": 587,
"src/main/git/worktree-diff-stamp-guest-gitdir.test.ts": 40,
"src/main/git/worktree-diff-stamp-host-paths.test.ts": 55,
"src/main/git/worktree-git-capabilities.test.ts": 248,
Expand All @@ -1805,6 +1804,7 @@
"src/main/git/worktree-preparation-base-oid.test.ts": 99,
"src/main/git/worktree-preparation-cancel-latency.bench.test.ts": 248,
"src/main/git/worktree-removal-large-tree.bench.test.ts": 291,
"src/main/git/worktree-removal-real-git.test.ts": 5000,
"src/main/git/worktree-remove-branch-deletion.test.ts": 194,
"src/main/git/worktree-scan-cache-annotation-reuse.test.ts": 241,
"src/main/git/worktree-scan-cache-sharing.test.ts": 284,
Expand Down
6 changes: 5 additions & 1 deletion src/cli/handlers/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,10 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
})
printHookWarning(result.result, json)
printPreservedBranchWarning(result.result, json)
printResult(result, json, (value) => `removed: ${value.removed}`)
printResult(result, json, (value) =>
value.removing
? `removed: ${value.removed}\nOrca is still deleting the checkout in the background.`
: `removed: ${value.removed}`
)
}
}
14 changes: 14 additions & 0 deletions src/cli/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,20 @@ describe('command aliases dispatch to the canonical handler', () => {
)
})

it('says the checkout is still being deleted when the host accepted a background removal', async () => {
queueFixtures(
callMock,
okFixture('req_show', { worktree: { hostId: 'local' } }),
okFixture('req', { removed: true, removing: true })
)

await main(['worktree', 'rm', '--worktree', 'id:wt-1'], '/tmp/repo')

expect(logSpy).toHaveBeenCalledWith(
'removed: true\nOrca is still deleting the checkout in the background.'
)
})

it('fails closed when worktree removal cannot resolve a host', async () => {
queueFixtures(callMock, okFixture('req_show', { worktree: { id: 'wt-1' } }))
const priorExitCode = process.exitCode
Expand Down
22 changes: 3 additions & 19 deletions src/main/git/add-sparse-worktree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,14 @@ const {
translateWslOutputPathsMock,
statMock,
readFileMock,
resolveGitDirMock,
moveWorktreeDirectoryToTrashMock,
restoreWorktreeDirectoryFromTrashMock,
scheduleWorktreeTrashDeletionMock
resolveGitDirMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileSyncMock: vi.fn(),
translateWslOutputPathsMock: vi.fn((output: string) => output),
statMock: vi.fn(),
readFileMock: vi.fn(),
resolveGitDirMock: vi.fn(),
moveWorktreeDirectoryToTrashMock: vi.fn(),
restoreWorktreeDirectoryFromTrashMock: vi.fn(),
scheduleWorktreeTrashDeletionMock: vi.fn()
}))

vi.mock('../worktree-trash', () => ({
moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock,
restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock,
scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock
resolveGitDirMock: vi.fn()
}))

vi.mock('./runner', () => ({
Expand Down Expand Up @@ -59,11 +47,7 @@ const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock)
const getGitCalls = createGitCallReader(gitExecFileAsyncMock)

beforeEach(() => {
resetWorktreeRemovalState({
moveWorktreeDirectoryToTrashMock,
restoreWorktreeDirectoryFromTrashMock,
scheduleWorktreeTrashDeletionMock
})
resetWorktreeRemovalState()
})

describe('addSparseWorktree', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/main/git/command-runner/git-admission-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export const NETWORK_HEADROOM = 1
export const ROUTE_CAP = 2
export const ROUTE_HEADROOM = 1
export const GIT_ADMISSION_AGING_MS = 15_000
// General 4+2 and network 3+1 are disjoint, so at most ten git children run globally.
// General 4+2 and network 3+1 are disjoint, so at most ten admitted git children run globally;
// worktree deletes bypass admission under their own limit (WORKTREE_DELETE_CONCURRENCY).
export const MAX_GIT_CHILDREN = 10

export type AdmissionClass = 'general' | 'network'
Expand Down
62 changes: 62 additions & 0 deletions src/main/git/command-runner/git-exec-admission-exempt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { EventEmitter } from 'node:events'
import type { ChildProcess } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }))

vi.mock('node:child_process', async (importOriginal) => ({
...(await importOriginal()),
execFile: execFileMock
}))

import { gitExecFileAsync } from './git-exec-file'
import {
GitAdmissionScheduler,
_gitAdmissionSnapshotForTests,
_resetGitAdmissionForTests
} from './git-subprocess-admission'

function mockChild(pid: number): ChildProcess {
const child = Object.assign(new EventEmitter(), {
pid,
kill: vi.fn(() => true),
stdin: Object.assign(new EventEmitter(), { end: vi.fn() }),
stdout: new EventEmitter(),
stderr: new EventEmitter()
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the exec wrapper only reads pid, kill, stdio and events.
return child as unknown as ChildProcess
}

describe('worktree deletes and general git admission', () => {
beforeEach(() => {
vi.useFakeTimers()
execFileMock.mockReset()
// The smallest general cap a host can have: deletes holding it would stall every status read.
_resetGitAdmissionForTests(new GitAdmissionScheduler({ generalCap: 2, generalHeadroom: 0 }))
})

afterEach(() => {
vi.useRealTimers()
_resetGitAdmissionForTests()
})

it('runs a status read while deletes that fill the general cap are still running', async () => {
let pid = 100
execFileMock.mockImplementation(() => mockChild(pid++))

for (const path of ['/wt-a', '/wt-b', '/wt-c']) {
void gitExecFileAsync(['worktree', 'remove', path], { cwd: '/repo', admissionExempt: true })
}
await vi.advanceTimersByTimeAsync(0)
expect(execFileMock).toHaveBeenCalledTimes(3)
expect(_gitAdmissionSnapshotForTests().budgets.general?.baseUsed ?? 0).toBe(0)

void gitExecFileAsync(['status', '--porcelain'], { cwd: '/repo' })
await vi.advanceTimersByTimeAsync(0)

expect(execFileMock).toHaveBeenCalledTimes(4)
expect(execFileMock.mock.calls[3]?.[1]).toContain('status')
expect(_gitAdmissionSnapshotForTests().queued).toBe(0)
})
})
16 changes: 9 additions & 7 deletions src/main/git/command-runner/git-exec-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,15 @@ async function gitExecFileAsyncUnlocked(
const policy = effectiveOptions.useConfiguredSshCommandForNetwork
? await buildNetworkSshPolicyEnv(effectiveOptions)
: { env: nonInteractiveGitEnv(effectiveOptions.env), mode: 'default' as const }
const grant = await acquireGitAdmission({
args,
cwd: options.cwd,
wslDistro: options.wslDistro,
tier: options.admissionTier,
signal: options.signal
})
const grant = options.admissionExempt
? { queueWaitMs: 0, release: () => {} }
: await acquireGitAdmission({
args,
cwd: options.cwd,
wslDistro: options.wslDistro,
tier: options.admissionTier,
signal: options.signal
})
span?.setAttribute('git.queue_wait_ms', grant.queueWaitMs)
const timeoutMs = gitCommandTimeoutMs(args, options.timeout, options.timeoutMsForTest)
const terminationState: { current: Promise<void> | null } = { current: null }
Expand Down
2 changes: 2 additions & 0 deletions src/main/git/command-runner/git-exec-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ export type GitExecOptions = {
captureWslLoginShellOutput?: boolean
/** Scheduler priority for this child; status is the safe default. */
admissionTier?: GitAdmissionTier
/** Skips general admission; only for a caller that bounds its own concurrency (worktree deletes). */
admissionExempt?: true
}
60 changes: 40 additions & 20 deletions src/main/git/remove-worktree-branch-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,20 @@
import type * as FsPromises from 'node:fs/promises'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'

const {
gitExecFileAsyncMock,
gitExecFileSyncMock,
translateWslOutputPathsMock,
statMock,
readFileMock,
resolveGitDirMock,
moveWorktreeDirectoryToTrashMock,
restoreWorktreeDirectoryFromTrashMock,
scheduleWorktreeTrashDeletionMock
resolveGitDirMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileSyncMock: vi.fn(),
translateWslOutputPathsMock: vi.fn((output: string) => output),
statMock: vi.fn(),
readFileMock: vi.fn(),
resolveGitDirMock: vi.fn(),
moveWorktreeDirectoryToTrashMock: vi.fn(),
restoreWorktreeDirectoryFromTrashMock: vi.fn(),
scheduleWorktreeTrashDeletionMock: vi.fn()
}))

vi.mock('../worktree-trash', () => ({
moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock,
restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock,
scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock
resolveGitDirMock: vi.fn()
}))

vi.mock('./runner', () => ({
Expand Down Expand Up @@ -58,12 +46,16 @@ import { forceDeleteLocalBranch, removeWorktree } from './worktree'
const mockGitCommands = createGitCommandMocker(gitExecFileAsyncMock)
const getGitCalls = createGitCallReader(gitExecFileAsyncMock)

// Why: removal argv carries core.longpaths on Windows; pin a non-Windows default for exact argv.
let platformSpy: MockInstance<() => NodeJS.Platform>

beforeEach(() => {
resetWorktreeRemovalState({
moveWorktreeDirectoryToTrashMock,
restoreWorktreeDirectoryFromTrashMock,
scheduleWorktreeTrashDeletionMock
})
resetWorktreeRemovalState()
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
})

afterEach(() => {
platformSpy.mockRestore()
})

describe('removeWorktree', () => {
Expand Down Expand Up @@ -460,4 +452,32 @@ branch refs/heads/feature/test
)
expect(getGitCalls()).not.toContain('git config --remove-section branch.feature/test')
})

it('runs branch cleanup for one repo one removal at a time, not the checkout deletes', async () => {
let releaseFirstBranchDelete: () => void = () => {}
const firstBranchDelete = new Promise<void>((resolve) => {
releaseFirstBranchDelete = resolve
})
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args.join(' ') === 'branch -d -- feature/a') {
await firstBranchDelete
}
return { stdout: '', stderr: '' }
})

const first = removeWorktree('/repo', '/repo-a', false, {
knownRemovedWorktree: { branch: 'refs/heads/feature/a', head: 'aaa', locked: false }
})
await vi.waitFor(() => expect(getGitCalls()).toContain('git branch -d -- feature/a'))
const second = removeWorktree('/repo', '/repo-b', false, {
knownRemovedWorktree: { branch: 'refs/heads/feature/b', head: 'bbb', locked: false }
})
await vi.waitFor(() => expect(getGitCalls()).toContain('git worktree remove /repo-b'))
await new Promise((resolve) => setTimeout(resolve, 20))
expect(getGitCalls()).not.toContain('git branch -d -- feature/b')

releaseFirstBranchDelete()
await Promise.all([first, second])
expect(getGitCalls()).toContain('git branch -d -- feature/b')
})
})
22 changes: 3 additions & 19 deletions src/main/git/remove-worktree-clean-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,14 @@ const {
translateWslOutputPathsMock,
statMock,
readFileMock,
resolveGitDirMock,
moveWorktreeDirectoryToTrashMock,
restoreWorktreeDirectoryFromTrashMock,
scheduleWorktreeTrashDeletionMock
resolveGitDirMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
gitExecFileSyncMock: vi.fn(),
translateWslOutputPathsMock: vi.fn((output: string) => output),
statMock: vi.fn(),
readFileMock: vi.fn(),
resolveGitDirMock: vi.fn(),
moveWorktreeDirectoryToTrashMock: vi.fn(),
restoreWorktreeDirectoryFromTrashMock: vi.fn(),
scheduleWorktreeTrashDeletionMock: vi.fn()
}))

vi.mock('../worktree-trash', () => ({
moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock,
restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock,
scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock
resolveGitDirMock: vi.fn()
}))

vi.mock('./runner', () => ({
Expand All @@ -50,11 +38,7 @@ import { resetWorktreeRemovalState } from './remove-worktree-test-harness'
import { assertWorktreeCleanForRemoval, WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS } from './worktree'

beforeEach(() => {
resetWorktreeRemovalState({
moveWorktreeDirectoryToTrashMock,
restoreWorktreeDirectoryFromTrashMock,
scheduleWorktreeTrashDeletionMock
})
resetWorktreeRemovalState()
})

describe('assertWorktreeCleanForRemoval', () => {
Expand Down
16 changes: 2 additions & 14 deletions src/main/git/remove-worktree-test-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,23 +80,11 @@ export function mockSparseCheckoutEnabledConfig(readFileMock: Mock): void {
})
}

export type WorktreeTrashMocks = {
moveWorktreeDirectoryToTrashMock: Mock
restoreWorktreeDirectoryFromTrashMock: Mock
scheduleWorktreeTrashDeletionMock: Mock
}

/** Per-test reset shared by every worktree suite: caches cleared, trash rename unavailable. */
export function resetWorktreeRemovalState(trashMocks: WorktreeTrashMocks): void {
/** Per-test reset shared by every worktree suite: caches cleared. */
export function resetWorktreeRemovalState(): void {
clearGitCapabilityStateForTests()
_resetWorktreeScanCacheForTests()
__resetSparseCheckoutStateCacheForTests()
// Default: the checkout cannot be renamed aside, so removal deletes it in place.
trashMocks.moveWorktreeDirectoryToTrashMock.mockReset()
trashMocks.moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined)
trashMocks.restoreWorktreeDirectoryFromTrashMock.mockReset()
trashMocks.restoreWorktreeDirectoryFromTrashMock.mockResolvedValue(true)
trashMocks.scheduleWorktreeTrashDeletionMock.mockReset()
}

export type WorktreeGitMocks = {
Expand Down
Loading
Loading