From 45dca952e11e65f361e1739caeeadc9b181a29fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:59:17 -0700 Subject: [PATCH] fix(worktrees): keep worktrees deletable after sharing a directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A directory-only ignore rule (`node_modules/`, the common spelling) matches the primary checkout's real directory, so the shared directory resolves and gets symlinked — but it never matches the worktree's symlink, so Git reports that link as untracked. Deletion only tolerated the per-user shared paths, so every worktree in such a repo became permanently dirty: the clean preflight threw "uncommitted or untracked changes" and `git worktree remove` refused without --force. Feed the configured `orca.yaml` shared directories into the same tolerate-and-unlink machinery the per-user shared paths already use, at both deletion call sites. The names are read unfiltered, since the create-time resolver drops exactly the entry deletion needs most. --- .../git/worktree-shared-directories.test.ts | 112 +++++++++++++++++- src/main/git/worktree-shared-directories.ts | 11 ++ src/main/ipc/worktrees.ts | 11 +- src/main/runtime/orca-runtime.ts | 15 ++- 4 files changed, 144 insertions(+), 5 deletions(-) diff --git a/src/main/git/worktree-shared-directories.test.ts b/src/main/git/worktree-shared-directories.test.ts index 2eb5242d2b8..3953c74f006 100644 --- a/src/main/git/worktree-shared-directories.test.ts +++ b/src/main/git/worktree-shared-directories.test.ts @@ -1,9 +1,17 @@ import { execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { resolveWorktreeSharedDirectories } from './worktree-shared-directories' +import { + getConfiguredWorktreeSharedDirectories, + resolveWorktreeSharedDirectories +} from './worktree-shared-directories' +import { + createWorktreeSharedPaths, + findExistingWorktreeSymlinkPaths +} from '../ipc/worktree-symlinks' +import { assertWorktreeCleanForRemoval } from './worktree' const git = (args: string[], cwd: string): void => { execFileSync('git', args, { cwd, stdio: 'ignore' }) @@ -119,3 +127,103 @@ describe('resolveWorktreeSharedDirectories', () => { expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['apps/web/.cache']) }) }) + +describe('getConfiguredWorktreeSharedDirectories', () => { + let repo: string + + beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), 'orca-shared-dirs-config-')) + }) + + afterEach(() => { + rmSync(repo, { recursive: true, force: true }) + }) + + it('returns the configured names without existence or gitignore filtering', () => { + // Why: neither directory exists, yet removal still needs both names to + // recognize and unlink the symlinks a previous creation left behind. + writeFileSync( + join(repo, 'orca.yaml'), + 'worktree:\n sharedDirectories:\n - node_modules\n - .cache\n' + ) + + expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual(['node_modules', '.cache']) + }) + + it('returns [] when orca.yaml is absent or has no worktree key', () => { + expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual([]) + + writeFileSync(join(repo, 'orca.yaml'), 'scripts:\n setup: pnpm install\n') + expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual([]) + }) +}) + +// Why: `node_modules/` is a directory-only ignore rule. It matches the primary's +// real directory, so the shared directory resolves, but never the worktree's +// symlink — Git reports that link as untracked and refuses a non-force removal +// unless deletion is told to tolerate it. +describe('shared directories and worktree removal', () => { + let root: string + let primary: string + let worktree: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-shared-dirs-removal-')) + primary = join(root, 'primary') + worktree = join(root, 'worktree') + mkdirSync(primary) + git(['init', '-q', '-b', 'main'], primary) + git(['config', 'user.email', 'test@example.com'], primary) + git(['config', 'user.name', 'Test'], primary) + writeFileSync(join(primary, '.gitignore'), 'node_modules/\n') + writeFileSync( + join(primary, 'orca.yaml'), + 'worktree:\n sharedDirectories:\n - node_modules\n' + ) + git(['add', '-A'], primary) + git(['commit', '-qm', 'init'], primary) + mkdirSync(join(primary, 'node_modules')) + git(['worktree', 'add', '-q', worktree, '-b', 'feature'], primary) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + it('leaves a worktree removable without force after sharing a directory', async () => { + await createWorktreeSharedPaths( + primary, + worktree, + await resolveWorktreeSharedDirectories(primary) + ) + expect(lstatSync(join(worktree, 'node_modules')).isSymbolicLink()).toBe(true) + + const ignoredLinkedPaths = await findExistingWorktreeSymlinkPaths( + worktree, + getConfiguredWorktreeSharedDirectories(primary) + ) + + expect(ignoredLinkedPaths).toEqual(['node_modules']) + await expect( + assertWorktreeCleanForRemoval(worktree, false, { ignoredUntrackedPaths: ignoredLinkedPaths }) + ).resolves.toBeUndefined() + }) + + it('still refuses removal for real untracked changes next to a shared directory', async () => { + await createWorktreeSharedPaths( + primary, + worktree, + await resolveWorktreeSharedDirectories(primary) + ) + writeFileSync(join(worktree, 'scratch.txt'), 'unsaved work') + + await expect( + assertWorktreeCleanForRemoval(worktree, false, { + ignoredUntrackedPaths: await findExistingWorktreeSymlinkPaths( + worktree, + getConfiguredWorktreeSharedDirectories(primary) + ) + }) + ).rejects.toThrow('uncommitted or untracked') + }) +}) diff --git a/src/main/git/worktree-shared-directories.ts b/src/main/git/worktree-shared-directories.ts index 53c6b3417de..5da95598fdb 100644 --- a/src/main/git/worktree-shared-directories.ts +++ b/src/main/git/worktree-shared-directories.ts @@ -7,6 +7,17 @@ import { loadHooks } from '../hooks' // Why: a fresh worktree has no node_modules/.cache, and copying them is slow and // duplicates disk; `orca.yaml` names the ones every worktree should share instead. +/** The configured `worktree.sharedDirectories` names, before any existence or + * gitignore filtering. + * + * Why deletion can't reuse the resolver below: a directory-only ignore rule + * (`node_modules/`) matches the primary's real directory but never the + * worktree's symlink, so Git reports that symlink as untracked. Removal has to + * tolerate and unlink it, and the resolver would have already dropped it. */ +export function getConfiguredWorktreeSharedDirectories(repoPath: string): string[] { + return loadHooks(repoPath)?.worktree?.sharedDirectories ?? [] +} + /** Resolve `worktree.sharedDirectories` from the repo-root `orca.yaml` to * concrete repo-relative directories to symlink into a new worktree. * diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 43a0fbfbf55..2132a2f18fc 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -99,6 +99,7 @@ import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runti import { killAllProcessesForWorktree } from '../runtime/worktree-teardown' import { clearProviderPtyState, getLocalPtyProvider, getSshPtyProvider } from './pty' import { findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from './worktree-symlinks' +import { getConfiguredWorktreeSharedDirectories } from '../git/worktree-shared-directories' import { track } from '../telemetry/client' import { getCohortAtEmit } from '../telemetry/cohort-classifier' import { workspaceSourceSchema, type WorkspaceSource } from '../../shared/telemetry-events' @@ -1767,7 +1768,15 @@ export function registerWorktreeHandlers( ) } - const linkedPaths = repo.symlinkPaths ?? [] + // Why: `orca.yaml` shared directories are symlinked in too, and a + // directory-only ignore rule leaves those links untracked, so removal must + // tolerate and unlink them exactly like the per-user shared paths. + const linkedPaths = Array.from( + new Set([ + ...(repo.symlinkPaths ?? []), + ...getConfiguredWorktreeSharedDirectories(repo.path) + ]) + ) const ignoredLinkedPaths = args.force ? [] : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9ef783f499a..59ff188825a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -816,7 +816,10 @@ import { } from '../ipc/worktree-symlinks' import { formatWorktreeIncludeCopyWarning } from '../ipc/worktree-include-copy-budget' import { resolveWorktreeIncludePaths } from '../git/worktree-include-file' -import { resolveWorktreeSharedDirectories } from '../git/worktree-shared-directories' +import { + getConfiguredWorktreeSharedDirectories, + resolveWorktreeSharedDirectories +} from '../git/worktree-shared-directories' import { deleteWorktreeHistoryDir } from '../terminal-history' import { cleanupUnusedWorktreePushTargetRemote, @@ -21494,7 +21497,15 @@ export class OrcaRuntimeService { throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, force)) } - const linkedPaths = repo.symlinkPaths ?? [] + // Why: `orca.yaml` shared directories are symlinked in too, and a + // directory-only ignore rule leaves those links untracked, so removal must + // tolerate and unlink them exactly like the per-user shared paths. + const linkedPaths = Array.from( + new Set([ + ...(repo.symlinkPaths ?? []), + ...getConfiguredWorktreeSharedDirectories(repo.path) + ]) + ) const ignoredLinkedPaths = force ? [] : await findExistingWorktreeSymlinkPaths(canonicalWorktreePath, linkedPaths)