fix(worktrees): keep worktrees deletable after sharing a directory

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.
This commit is contained in:
Brennan Benson
2026-07-27 16:59:17 -07:00
parent 5b22698ee1
commit 45dca952e1
4 changed files with 144 additions and 5 deletions
@@ -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')
})
})
@@ -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.
*
+10 -1
View File
@@ -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)
+13 -2
View File
@@ -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)