mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
* 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.
148 lines
6.0 KiB
TypeScript
148 lines
6.0 KiB
TypeScript
import { execFileSync } from 'node:child_process'
|
|
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { getStatus } from './status'
|
|
|
|
// Why: `node_modules/` is a directory-only ignore rule. It matches the primary
|
|
// checkout's real directory but never the worktree's symlink, so Git reports the
|
|
// link as untracked forever — a phantom row in the diff and a permanently dirty
|
|
// worktree. Status has to drop it; nothing else can.
|
|
const git = (args: string[], cwd: string): string =>
|
|
execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
|
|
describe('getStatus shared symlink exclusion', () => {
|
|
let root: string
|
|
let primary: string
|
|
let worktree: string
|
|
|
|
beforeEach(() => {
|
|
root = mkdtempSync(join(tmpdir(), 'orca-status-shared-'))
|
|
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, 'README.md'), '# tracked\n')
|
|
writeFileSync(join(primary, 'OTHER.md'), '# other\n')
|
|
symlinkSync('README.md', join(primary, 'tracked-link'))
|
|
git(['add', '-A'], primary)
|
|
git(['commit', '-qm', 'init'], primary)
|
|
mkdirSync(join(primary, 'node_modules'))
|
|
git(['worktree', 'add', '-q', worktree, '-b', 'feature'], primary)
|
|
symlinkSync(join(primary, 'node_modules'), join(worktree, 'node_modules'), 'dir')
|
|
})
|
|
|
|
afterEach(() => {
|
|
rmSync(root, { recursive: true, force: true })
|
|
})
|
|
|
|
// Why: guards the premise of the whole fix. If Git ever reported the symlink
|
|
// as `node_modules/` (as it does for a real untracked directory) the path
|
|
// comparison below would silently miss and the exclusion would do nothing.
|
|
it('Git reports the shared symlink as untracked, without a trailing slash', async () => {
|
|
const status = await getStatus(worktree)
|
|
|
|
expect(status.entries).toEqual([
|
|
expect.objectContaining({ path: 'node_modules', area: 'untracked' })
|
|
])
|
|
})
|
|
|
|
it('drops the shared symlink when it is declared shared', async () => {
|
|
const status = await getStatus(worktree, { sharedLinkPaths: ['node_modules'] })
|
|
|
|
expect(status.entries).toEqual([])
|
|
})
|
|
|
|
// The negative control that matters: real work must never be hidden.
|
|
it('still reports a genuine untracked file alongside a shared symlink', async () => {
|
|
writeFileSync(join(worktree, 'scratch.txt'), 'unsaved work\n')
|
|
|
|
const status = await getStatus(worktree, { sharedLinkPaths: ['node_modules'] })
|
|
|
|
expect(status.entries).toEqual([
|
|
expect.objectContaining({ path: 'scratch.txt', area: 'untracked' })
|
|
])
|
|
})
|
|
|
|
it('still reports a modified tracked file alongside a shared symlink', async () => {
|
|
writeFileSync(join(worktree, 'README.md'), '# edited\n')
|
|
|
|
const status = await getStatus(worktree, { sharedLinkPaths: ['node_modules'] })
|
|
|
|
expect(status.entries).toEqual([
|
|
expect.objectContaining({ path: 'README.md', area: 'unstaged' })
|
|
])
|
|
})
|
|
|
|
// Why: the configured name alone must not hide anything — only a real symlink.
|
|
// `vendor` is not gitignored, so Git genuinely reports it and the filter is
|
|
// the only thing that could wrongly drop it.
|
|
it('keeps a regular directory the user created at a configured shared name', async () => {
|
|
mkdirSync(join(worktree, 'vendor'))
|
|
writeFileSync(join(worktree, 'vendor', 'real.txt'), 'user work\n')
|
|
|
|
const status = await getStatus(worktree, {
|
|
sharedLinkPaths: ['node_modules', 'vendor']
|
|
})
|
|
|
|
expect(status.entries).toEqual([
|
|
expect.objectContaining({ path: 'vendor/real.txt', area: 'untracked' })
|
|
])
|
|
})
|
|
|
|
// Why: the exact discriminator for "configured AND really a symlink". The path
|
|
// matches a declared name exactly, so only the symlink check keeps the user's
|
|
// file visible.
|
|
it('keeps a regular file the user created at a configured shared name', async () => {
|
|
writeFileSync(join(worktree, 'notes'), 'user work\n')
|
|
|
|
const status = await getStatus(worktree, {
|
|
sharedLinkPaths: ['node_modules', 'notes']
|
|
})
|
|
|
|
expect(status.entries).toEqual([expect.objectContaining({ path: 'notes', area: 'untracked' })])
|
|
})
|
|
|
|
// Why: only *untracked* entries are Orca's artifacts. A symlink Git tracks is
|
|
// versioned content, so an edit to it is the user's work even when the path is
|
|
// declared shared — dropping it would hide a committable change.
|
|
it('keeps a modified tracked symlink at a declared shared path', async () => {
|
|
unlinkSync(join(worktree, 'tracked-link'))
|
|
symlinkSync('OTHER.md', join(worktree, 'tracked-link'))
|
|
|
|
const status = await getStatus(worktree, {
|
|
sharedLinkPaths: ['node_modules', 'tracked-link']
|
|
})
|
|
|
|
expect(status.entries).toEqual([
|
|
expect.objectContaining({ path: 'tracked-link', area: 'unstaged' })
|
|
])
|
|
})
|
|
|
|
// Why: these are the names a byte-for-byte path comparison is most likely to
|
|
// get wrong — a space breaks naive whitespace splitting, and non-ASCII is what
|
|
// Git C-quotes unless the reader opts out.
|
|
it('drops shared symlinks whose names have a space or non-ASCII characters', async () => {
|
|
const names = ['my shared dir', 'ライブラリ']
|
|
for (const name of names) {
|
|
symlinkSync(join(primary, 'node_modules'), join(worktree, name), 'dir')
|
|
}
|
|
|
|
const status = await getStatus(worktree, { sharedLinkPaths: ['node_modules', ...names] })
|
|
|
|
expect(status.entries).toEqual([])
|
|
})
|
|
|
|
it('keeps a symlink at a path that was never declared shared', async () => {
|
|
symlinkSync(join(primary, 'node_modules'), join(worktree, 'vendor'), 'dir')
|
|
|
|
const status = await getStatus(worktree, { sharedLinkPaths: ['node_modules'] })
|
|
|
|
expect(status.entries).toEqual([expect.objectContaining({ path: 'vendor', area: 'untracked' })])
|
|
})
|
|
})
|