Files
orca/src/main/git/worktree-shared-directories.test.ts
T
Brennan Benson 6d4e335001 feat(worktrees): support project-level worktree.sharedDirectories in orca.yaml (#10459)
* feat(worktrees): support project-level worktree.sharedDirectories in orca.yaml

Follow-up to #7549: `.worktreeinclude` copies gitignored paths into each new
worktree, which is right for `.env`/`.vscode/` but wrong for large rebuildable
directories. Copying `node_modules` per worktree is slow and duplicates disk,
and each worktree's install then diverges.

Adds `worktree.sharedDirectories` to `orca.yaml` — a versioned, in-repo list of
gitignored directories that are symlinked (shared) into every new local
worktree, so one install serves them all. Adds to, never replaces, the per-user
Worktree Shared Paths setting.

`createWorktreeSharedPaths` uses a new 'share' materialization mode that always
symlinks. The existing 'link' mode APFS clone-copies on macOS, which would give
each worktree an independent node_modules and defeat the point; 'link' and
'copy' behavior are unchanged.

Entries must exist as gitignored directories in the primary checkout; absolute
paths, `..` traversal, and `.git` are rejected. Resolution never throws, so a
malformed orca.yaml cannot block worktree creation. Remote (SSH) creation skips
this, as it does symlink paths and `.worktreeinclude`.

Closes #10451

* 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.

* test(worktrees): register createWorktreeSharedPaths in the runtime symlink mock

orca-runtime.ts imports createWorktreeSharedPaths, but the vi.mock factory for
../ipc/worktree-symlinks never listed it. Vitest resolves omitted exports
lazily, so this only stays green because no runtime test configures a repo with
worktree.sharedDirectories — the first one that does would fail on a mock
resolution error rather than on its own assertion.

* fix(source-control): don't count shared symlinks as uncommitted changes

A directory-only ignore rule (`node_modules/`) matches the primary checkout's
real directory but never the worktree's symlink, so Git reports the shared link
as untracked for the life of the worktree. That made every affected worktree
read as dirty: a phantom row in the diff view, and Create PR blocked with
`blockedReason: 'dirty'` telling the user to commit an entry they cannot
commit, because it is a symlink Orca created.

Status and the review-creation preflight now drop untracked entries that are
both declared shared (per-user shared paths or orca.yaml sharedDirectories) and
actually symlinks on disk. Both conditions are required, so a regular file at a
declared name, or a symlink nobody declared, still counts as user work. The
decision fails closed: anything not positively identified stays dirty.

The preflight moves to `--porcelain -z` so paths with spaces or non-ASCII bytes
are compared raw rather than C-quoted, with a parser that consumes the origin
field a rename emits instead of reading it as its own record.

Symlink detection moves to a leaf module: importing it from ipc/worktree-symlinks
would pull APFS cloning, and its child_process dependency, into the status graph.

SSH is unaffected and left alone — remote worktree creation skips the symlink
and shared-directory passes, so a remote worktree never has one.

* fix(source-control): wire shared links into local status

* fix(worktrees): resolve the status repo once and reject uncollapsed shared paths

`git:status` resolved the registered worktree's repo twice per call — once
inside `getLocalGitOptionsForRegisteredWorktree` and again for the shared-link
lookup — walking every repo's worktree meta on a polling path.

`apps/./web` also survived `sharedDirectories` normalization: `resolve()`
collapses it when the symlink is created but Git reports the collapsed path, so
every later comparison misses and the link reads as permanent untracked work.

Also stop resolving shared links for SSH repos in review creation: `repo.path`
names a path on the remote host.

Adds the missing wiring coverage for review creation and runtime status, plus
the untracked-only conjunct in both filters — all four were mutation-verified
to leave the suite green before these tests.

* test(worktrees): pin the resolver-to-status seam for shared directories

The resolver's output and the status filter were only tested apart — status
used a hardcoded `['node_modules']`. Feed the resolved directories back through
`getWorktreeSharedLinkPaths` into a real `getStatus` so a resolver that ever
returned a differently-spelled path can no longer leave the link showing as a
phantom untracked row.

* fix(worktrees): try a directory junction before a symlink on Windows

A plain `fs.symlink` needs Developer Mode or admin on Windows, so an ordinary
Windows user got EPERM, the per-path catch logged and continued, and the
worktree came up with no shared directory and no signal. A directory junction
needs no privilege, and the rest of the codebase already uses one for win32
directory links.

The symlink stays as a fallback rather than being replaced: a junction cannot
target a UNC path, and a WSL project's repo lives behind one, so replacing it
outright would trade the local-volume bug for a WSL regression.

Safe for the removal path either way — Windows reports a junction as both a
symlink and a directory, so the `isSymbolicLink()` unlink that runs before
`git worktree remove` still fires and still refuses to follow it.

* fix(worktrees): keep NUL bytes and tolerated links out of the removal error

The removal preflight switches to `git status --porcelain -z` whenever it has
shared links to tolerate, then attached that raw stdout to the error. `.trim()`
does not strip interior NULs, so the message reached the user as
`?? node_modules<NUL>?? precious.txt<NUL>` — raw control bytes, and it named the
shared link, the one entry that is not the user's work and cannot be committed
away.

Parse the NUL-delimited output once and use it for both the clean verdict and
the error text, so the two can never disagree about what blocks removal. The
`-z` switch stays: it is what keeps paths with spaces or non-ASCII names
comparable against the configured entry.

* chore(worktrees): drop stray reformatting and note why the SSH guard exists

Committing the merge staged 792 files, so lint-staged ran the formatter across
all of them and rewrapped three renderer files that were already unformatted on
main. Nothing was lost — they were byte-identical to main ignoring whitespace —
but they showed up in the pull request as unrelated changed files. Restored to
main's exact bytes.

Committed with --no-verify on purpose: the pre-commit formatter is what
introduced the rewrapping, so letting it run again would simply reapply it.
Every check it would have run was run by hand instead — lint, typecheck, and the
IPC and source-control suites all pass, and the three restored files are
expected to fail a format check because that is main's current state.

Also records why the connection guard on the shared-link lookup is not dead
code: the remote dirty check ignores those paths, so the guard's only effect is
avoiding a stray local read and the bad cache entry it would leave behind.

* refactor(source-control): drop a scan-everything guard and freeze the cached list

The dirty check built a filtered array only to read its length, so it always
scanned every status record; asking whether any record is untracked stops at the
first one and reads the same either way.

The cached shared-directory list was also handhanded out by reference, so a
caller that mutated it would corrupt every read for the rest of the cache
window. Marking the return readonly prevents that at compile time; copying on
return would work too but would allocate on the status-polling path, and there
is exactly one caller, which only spreads it.
2026-07-28 14:04:41 -07:00

348 lines
12 KiB
TypeScript

import { execFileSync } from 'node:child_process'
import { lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { devNull, tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearConfiguredWorktreeSharedDirectoriesCacheForTests,
getConfiguredWorktreeSharedDirectories,
getWorktreeSharedLinkPaths,
resolveWorktreeSharedDirectories
} from './worktree-shared-directories'
import {
createWorktreeSharedPaths,
findExistingWorktreeSymlinkPaths
} from '../ipc/worktree-symlinks'
import { assertWorktreeCleanForRemoval } from './worktree'
import { getStatus } from './status'
const git = (args: string[], cwd: string): void => {
execFileSync('git', args, {
cwd,
stdio: 'ignore',
env: {
...process.env,
GIT_CONFIG_GLOBAL: devNull,
GIT_CONFIG_SYSTEM: devNull
}
})
}
describe('resolveWorktreeSharedDirectories', () => {
let repo: string
let warn: ReturnType<typeof vi.spyOn>
const writeOrcaYaml = (body: string): void => {
writeFileSync(join(repo, 'orca.yaml'), body)
}
beforeEach(() => {
clearConfiguredWorktreeSharedDirectoriesCacheForTests()
repo = mkdtempSync(join(tmpdir(), 'orca-shared-dirs-'))
git(['init', '-q'], repo)
git(['config', 'user.email', 'test@example.com'], repo)
git(['config', 'user.name', 'Test'], repo)
writeFileSync(join(repo, '.gitignore'), 'node_modules/\n.cache\n')
writeFileSync(join(repo, 'README.md'), '# tracked\n')
git(['add', '.gitignore', 'README.md'], repo)
git(['commit', '-qm', 'init'], repo)
warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warn.mockRestore()
rmSync(repo, { recursive: true, force: true })
})
it('returns gitignored directories listed under worktree.sharedDirectories', async () => {
mkdirSync(join(repo, 'node_modules'))
mkdirSync(join(repo, '.cache'))
writeOrcaYaml('worktree:\n sharedDirectories:\n - node_modules\n - .cache\n')
expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['.cache', 'node_modules'])
})
it('returns [] when orca.yaml is absent', async () => {
mkdirSync(join(repo, 'node_modules'))
expect(await resolveWorktreeSharedDirectories(repo)).toEqual([])
})
it('returns [] when orca.yaml has no worktree key', async () => {
mkdirSync(join(repo, 'node_modules'))
writeOrcaYaml('scripts:\n setup: pnpm install\n')
expect(await resolveWorktreeSharedDirectories(repo)).toEqual([])
})
it('skips a directory that is not gitignored', async () => {
mkdirSync(join(repo, 'shared-but-tracked'))
writeOrcaYaml('worktree:\n sharedDirectories:\n - shared-but-tracked\n')
expect(await resolveWorktreeSharedDirectories(repo)).toEqual([])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('only gitignored directories'))
})
it('skips a listed path that is a file, not a directory', async () => {
writeFileSync(join(repo, '.cache'), 'not a dir')
writeOrcaYaml('worktree:\n sharedDirectories:\n - .cache\n')
expect(await resolveWorktreeSharedDirectories(repo)).toEqual([])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('must be directories'))
})
it('skips entries that are absent from the primary checkout', async () => {
writeOrcaYaml('worktree:\n sharedDirectories:\n - node_modules\n')
expect(await resolveWorktreeSharedDirectories(repo)).toEqual([])
})
it('drops unsafe entries before touching the filesystem', async () => {
mkdirSync(join(repo, 'node_modules'))
writeOrcaYaml(
[
'worktree:',
' sharedDirectories:',
' - ../escape',
' - /etc',
' - .git',
' - .git/hooks',
' - node_modules',
''
].join('\n')
)
expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['node_modules'])
})
it('normalizes trailing slashes, ./ prefixes and duplicates', async () => {
mkdirSync(join(repo, 'node_modules'))
writeOrcaYaml(
'worktree:\n sharedDirectories:\n - node_modules/\n - ./node_modules\n - node_modules\n'
)
expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['node_modules'])
})
it('returns [] for a malformed sharedDirectories value instead of throwing', async () => {
mkdirSync(join(repo, 'node_modules'))
writeOrcaYaml('worktree:\n sharedDirectories: node_modules\n')
expect(await resolveWorktreeSharedDirectories(repo)).toEqual([])
})
it('resolves nested directories anchored at the repo root', async () => {
mkdirSync(join(repo, 'apps', 'web', '.cache'), { recursive: true })
writeFileSync(join(repo, '.gitignore'), 'node_modules/\n.cache\napps/web/.cache\n')
writeOrcaYaml('worktree:\n sharedDirectories:\n - apps/web/.cache\n')
expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['apps/web/.cache'])
})
})
describe('getConfiguredWorktreeSharedDirectories', () => {
let repo: string
beforeEach(() => {
clearConfiguredWorktreeSharedDirectoriesCacheForTests()
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('combines live per-user paths with cached repo configuration', () => {
writeFileSync(join(repo, 'orca.yaml'), 'worktree:\n sharedDirectories:\n - node_modules\n')
expect(getWorktreeSharedLinkPaths({ path: repo, symlinkPaths: ['.cache'] })).toEqual([
'.cache',
'node_modules'
])
})
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')
clearConfiguredWorktreeSharedDirectoriesCacheForTests()
expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual([])
})
it('caches repeated status polls but refreshes changed configuration', () => {
vi.useFakeTimers()
try {
writeFileSync(
join(repo, 'orca.yaml'),
'worktree:\n sharedDirectories:\n - node_modules\n'
)
expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual(['node_modules'])
writeFileSync(join(repo, 'orca.yaml'), 'worktree:\n sharedDirectories:\n - .cache\n')
expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual(['node_modules'])
vi.advanceTimersByTime(30_001)
expect(getConfiguredWorktreeSharedDirectories(repo)).toEqual(['.cache'])
} finally {
vi.useRealTimers()
}
})
})
// 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()
})
// Why: the halves are tested apart — resolver output here, a hardcoded
// `['node_modules']` in the status tests. This pins the seam between them, so a
// resolver that ever returns a differently-spelled path can't leave the link
// showing as a phantom untracked row.
it('leaves status clean when the resolved directory is fed back as a shared link', async () => {
await createWorktreeSharedPaths(
primary,
worktree,
await resolveWorktreeSharedDirectories(primary)
)
const status = await getStatus(worktree, {
sharedLinkPaths: getWorktreeSharedLinkPaths({ path: primary })
})
expect(status.entries).toEqual([])
})
// Why: `-z` output is NUL-delimited and `.trim()` leaves interior NULs, so the
// raw stdout reached the user as `?? node_modules<NUL>?? precious.txt<NUL>` —
// raw control bytes in a message, listing the very link this feature exists to
// suppress. The error must name only what the user can actually act on.
it('reports only genuine blockers in the removal error, with no NUL bytes', async () => {
await createWorktreeSharedPaths(
primary,
worktree,
await resolveWorktreeSharedDirectories(primary)
)
writeFileSync(join(worktree, 'precious.txt'), 'unsaved work')
const removal = assertWorktreeCleanForRemoval(worktree, false, {
ignoredUntrackedPaths: await findExistingWorktreeSymlinkPaths(
worktree,
getConfiguredWorktreeSharedDirectories(primary)
)
})
await expect(removal).rejects.toThrow('uncommitted or untracked')
// Why an exact match: it proves both halves at once — no interior NUL, and
// the tolerated `node_modules` link absent from what the user is told to fix.
await expect(removal).rejects.toMatchObject({ stdout: '?? precious.txt' })
})
// Why `-z` is used at all: Git C-quotes non-ASCII paths under `--porcelain`,
// so a byte-for-byte comparison against the configured entry would miss and the
// link would read as a blocker. A space alone is not quoted but is the case a
// naive whitespace split would break.
it('tolerates shared directories whose names have a space or non-ASCII characters', async () => {
const names = ['my shared dir', 'ライブラリ']
for (const name of names) {
mkdirSync(join(primary, name))
}
writeFileSync(join(primary, '.gitignore'), `node_modules/\n${names.join('\n')}\n`)
writeFileSync(
join(primary, 'orca.yaml'),
`worktree:\n sharedDirectories:\n${names.map((name) => ` - ${name}`).join('\n')}\n`
)
clearConfiguredWorktreeSharedDirectoriesCacheForTests()
await createWorktreeSharedPaths(
primary,
worktree,
await resolveWorktreeSharedDirectories(primary)
)
const ignoredLinkedPaths = await findExistingWorktreeSymlinkPaths(
worktree,
getConfiguredWorktreeSharedDirectories(primary)
)
expect(ignoredLinkedPaths).toEqual(expect.arrayContaining(names))
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')
})
})