mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
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:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user