mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
337 lines
12 KiB
JavaScript
337 lines
12 KiB
JavaScript
import {
|
|
chmod,
|
|
link,
|
|
lstat,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
rename,
|
|
rm,
|
|
symlink,
|
|
utimes,
|
|
writeFile
|
|
} from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { afterEach, expect, it, vi } from 'vitest'
|
|
import * as processHost from '@orca/process-host'
|
|
import { runProcess } from '@orca/process-host'
|
|
import { reclaimWorktreeGeneratedFiles } from './reclaim-worktree-generated-files.mjs'
|
|
|
|
const roots = []
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks()
|
|
for (const root of roots.splice(0)) {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
async function fixture() {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-generated-reclaim-'))
|
|
roots.push(root)
|
|
const repoRoot = join(root, 'main')
|
|
const sibling = join(root, 'sibling')
|
|
await mkdir(repoRoot)
|
|
const git = async (...args) => {
|
|
const result = await runProcess({
|
|
program: 'git',
|
|
args: [
|
|
'-C',
|
|
repoRoot,
|
|
'-c',
|
|
'user.name=Test',
|
|
'-c',
|
|
'user.email=test@example.invalid',
|
|
'-c',
|
|
'commit.gpgsign=false',
|
|
...args
|
|
],
|
|
env: {
|
|
...process.env,
|
|
GIT_CONFIG_GLOBAL: join(root, 'no-global-config'),
|
|
GIT_CONFIG_NOSYSTEM: '1'
|
|
},
|
|
timeoutMs: 10_000,
|
|
terminationBarrier: true
|
|
})
|
|
expect(result.code, result.stderr).toBe(0)
|
|
}
|
|
await git('init', '--quiet')
|
|
await writeFile(join(repoRoot, '.gitignore'), '**/node_modules/\n**/out/\n**/dist/\n**/build/\n')
|
|
for (const packagePath of ['', 'mobile', 'native/addon', 'src/packages/example']) {
|
|
const path = join(repoRoot, packagePath)
|
|
await mkdir(path, { recursive: true })
|
|
await writeFile(join(path, 'package.json'), '{"private":true}')
|
|
}
|
|
await writeFile(join(repoRoot, 'source.js'), 'original source')
|
|
await git('add', '.')
|
|
await git('commit', '--quiet', '-m', 'fixture')
|
|
await git('worktree', 'add', '--quiet', '--detach', sibling)
|
|
return { root, repoRoot, sibling, git }
|
|
}
|
|
|
|
async function generated(root, path = 'node_modules', days = 40) {
|
|
const directory = join(root, path)
|
|
await mkdir(directory, { recursive: true })
|
|
const file = join(directory, 'generated.txt')
|
|
await writeFile(file, 'generated bytes')
|
|
const time = new Date(Date.now() - days * 86_400_000)
|
|
await utimes(directory, time, time)
|
|
return file
|
|
}
|
|
|
|
it('previews and removes old root/nested generated files while preserving dirty source and shared pnpm data', async () => {
|
|
const f = await fixture()
|
|
const protectedFile = await generated(f.repoRoot)
|
|
const paths = [
|
|
'node_modules',
|
|
'mobile/node_modules',
|
|
'native/addon/build',
|
|
'src/packages/example/dist',
|
|
'out'
|
|
]
|
|
const files = await Promise.all(paths.map((path) => generated(f.sibling, path)))
|
|
const store = join(f.root, 'shared-pnpm-store')
|
|
await mkdir(store)
|
|
await writeFile(join(store, 'package.js'), 'shared package')
|
|
await symlink(store, join(f.sibling, 'node_modules', 'shared-package'), 'junction')
|
|
await utimes(join(f.sibling, 'node_modules'), new Date(0), new Date(0))
|
|
await writeFile(join(f.sibling, 'source.js'), 'uncommitted source')
|
|
await mkdir(join(f.sibling, '.context'))
|
|
await writeFile(join(f.sibling, '.context', 'report.md'), 'keep this report')
|
|
const options = { repoRoot: f.repoRoot, report: () => {} }
|
|
const preview = await reclaimWorktreeGeneratedFiles(options)
|
|
expect(preview.entries).toBe(5)
|
|
for (const file of files) {
|
|
expect(await readFile(file, 'utf8')).toBe('generated bytes')
|
|
}
|
|
await reclaimWorktreeGeneratedFiles({ ...options, apply: true })
|
|
for (const file of files) {
|
|
await expect(lstat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
}
|
|
expect(await readFile(protectedFile, 'utf8')).toBe('generated bytes')
|
|
expect(await readFile(join(store, 'package.js'), 'utf8')).toBe('shared package')
|
|
expect(await readFile(join(f.sibling, 'source.js'), 'utf8')).toBe('uncommitted source')
|
|
expect(await readFile(join(f.sibling, '.context', 'report.md'), 'utf8')).toBe('keep this report')
|
|
expect((await lstat(join(f.sibling, '.git'))).isFile()).toBe(true)
|
|
})
|
|
|
|
it('retains recent directories, tracked output, and ignored files outside recognized package roots', async () => {
|
|
const f = await fixture()
|
|
const recent = await generated(f.sibling, 'node_modules', 1)
|
|
const tracked = await generated(f.sibling, 'dist')
|
|
const unknown = await generated(f.sibling, 'personal/build')
|
|
await f.git('-C', f.sibling, 'add', '-f', 'dist/generated.txt')
|
|
await reclaimWorktreeGeneratedFiles({
|
|
repoRoot: f.repoRoot,
|
|
apply: true,
|
|
report: () => {}
|
|
})
|
|
for (const file of [recent, tracked, unknown]) {
|
|
expect(await readFile(file, 'utf8')).toBe('generated bytes')
|
|
}
|
|
})
|
|
|
|
it('protects tracked files under package names containing Git pathspec metacharacters', async () => {
|
|
const f = await fixture()
|
|
const root = join(f.sibling, 'native', 'addon[1]')
|
|
const file = await generated(root, 'build')
|
|
await writeFile(join(root, 'package.json'), '{}')
|
|
await f.git(
|
|
'--literal-pathspecs',
|
|
'-C',
|
|
f.sibling,
|
|
'add',
|
|
'-f',
|
|
'native/addon[1]/build/generated.txt'
|
|
)
|
|
await reclaimWorktreeGeneratedFiles({
|
|
repoRoot: f.repoRoot,
|
|
apply: true,
|
|
report: () => {}
|
|
})
|
|
expect(await readFile(file, 'utf8')).toBe('generated bytes')
|
|
})
|
|
|
|
it('skips symlinked targets and package ancestors, and nested repositories', async () => {
|
|
const f = await fixture()
|
|
const external = join(f.root, 'external')
|
|
const outside = await generated(external)
|
|
await writeFile(join(external, 'package.json'), '{}')
|
|
await symlink(external, join(f.sibling, 'node_modules'), 'junction')
|
|
await rm(join(f.sibling, 'native', 'addon'), { recursive: true })
|
|
await symlink(external, join(f.sibling, 'native', 'addon'), 'junction')
|
|
const nested = await generated(f.sibling, 'mobile/node_modules')
|
|
await writeFile(join(f.sibling, 'mobile', '.git'), 'gitdir: elsewhere')
|
|
const preview = await reclaimWorktreeGeneratedFiles({ repoRoot: f.repoRoot, report: () => {} })
|
|
expect(preview.entries).toBe(0)
|
|
expect(await readFile(outside, 'utf8')).toBe('generated bytes')
|
|
expect(await readFile(nested, 'utf8')).toBe('generated bytes')
|
|
})
|
|
|
|
it.each(['directory', 'file'])(
|
|
'preserves uncommitted source in a nested repository with a .git %s beneath output',
|
|
async (kind) => {
|
|
const f = await fixture()
|
|
const file = await generated(f.sibling, 'out')
|
|
const nested = join(dirname(file), 'local-checkout')
|
|
await mkdir(nested)
|
|
await (kind === 'directory'
|
|
? f.git('-C', nested, 'init', '--quiet')
|
|
: writeFile(join(nested, '.git'), 'gitdir: elsewhere'))
|
|
const source = join(nested, 'source.js')
|
|
await writeFile(source, 'uncommitted source')
|
|
await utimes(dirname(file), new Date(0), new Date(0))
|
|
for (const apply of [false, true]) {
|
|
const result = await reclaimWorktreeGeneratedFiles({
|
|
repoRoot: f.repoRoot,
|
|
apply,
|
|
report: () => {}
|
|
})
|
|
expect(result.entries).toBe(0)
|
|
expect(result.protected).toBe(1)
|
|
expect(await readFile(source, 'utf8')).toBe('uncommitted source')
|
|
expect(await readFile(file, 'utf8')).toBe('generated bytes')
|
|
}
|
|
}
|
|
)
|
|
|
|
it('preserves bare repositories and their refs beneath ignored output', async () => {
|
|
const f = await fixture()
|
|
const file = await generated(f.sibling, 'out')
|
|
const nested = join(dirname(file), 'saved.git')
|
|
await f.git('clone', '--quiet', '--bare', f.repoRoot, nested)
|
|
const head = await readFile(join(nested, 'HEAD'), 'utf8')
|
|
await utimes(dirname(file), new Date(0), new Date(0))
|
|
const result = await reclaimWorktreeGeneratedFiles({
|
|
repoRoot: f.repoRoot,
|
|
apply: true,
|
|
report: () => {}
|
|
})
|
|
expect(result.entries).toBe(0)
|
|
expect(result.protected).toBe(1)
|
|
expect(await readFile(join(nested, 'HEAD'), 'utf8')).toBe(head)
|
|
expect((await lstat(join(nested, 'objects'))).isDirectory()).toBe(true)
|
|
})
|
|
|
|
it('resolves worktree exclusions through filesystem aliases', async () => {
|
|
const f = await fixture()
|
|
const file = await generated(f.sibling)
|
|
const alias = join(f.root, 'alias')
|
|
await symlink(f.sibling, alias, 'junction')
|
|
await reclaimWorktreeGeneratedFiles({
|
|
repoRoot: f.repoRoot,
|
|
excludeWorktree: alias,
|
|
apply: true,
|
|
report: () => {}
|
|
})
|
|
expect(await readFile(file, 'utf8')).toBe('generated bytes')
|
|
})
|
|
|
|
it('retains a replacement directory introduced while the final Git check is running', async () => {
|
|
const f = await fixture()
|
|
const file = await generated(f.sibling)
|
|
const target = dirname(file)
|
|
const saved = join(f.sibling, 'saved-original')
|
|
const execute = processHost.runProcess
|
|
let checks = 0
|
|
vi.spyOn(processHost, 'runProcess').mockImplementation(async (spec) => {
|
|
const result = await execute(spec)
|
|
if (
|
|
spec.args.includes('check-ignore') &&
|
|
spec.args.at(-1) === 'node_modules/' &&
|
|
++checks === 2
|
|
) {
|
|
await rename(target, saved)
|
|
await generated(f.sibling)
|
|
}
|
|
return result
|
|
})
|
|
const result = await reclaimWorktreeGeneratedFiles({
|
|
repoRoot: f.repoRoot,
|
|
apply: true,
|
|
report: () => {}
|
|
})
|
|
expect(checks).toBe(2)
|
|
expect(result.protected).toBe(1)
|
|
expect(await readFile(file, 'utf8')).toBe('generated bytes')
|
|
expect(await readFile(join(saved, 'generated.txt'), 'utf8')).toBe('generated bytes')
|
|
})
|
|
|
|
it('supports a retention override and rejects invalid ages', async () => {
|
|
const f = await fixture()
|
|
const file = await generated(f.sibling, 'mobile/node_modules', 2)
|
|
await reclaimWorktreeGeneratedFiles({
|
|
repoRoot: f.repoRoot,
|
|
apply: true,
|
|
generatedMinAgeDays: 1,
|
|
report: () => {}
|
|
})
|
|
await expect(lstat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
await expect(
|
|
reclaimWorktreeGeneratedFiles({ repoRoot: f.repoRoot, generatedMinAgeDays: Number.NaN })
|
|
).rejects.toThrow('--generated-min-age-days')
|
|
})
|
|
|
|
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
|
'reclaims read-only generated directories without chmodding hardlinked package files',
|
|
async () => {
|
|
const f = await fixture()
|
|
const shared = join(f.root, 'shared-package.js')
|
|
await writeFile(shared, 'shared package', { mode: 0o444 })
|
|
const originalMode = (await lstat(shared)).mode & 0o777
|
|
const file = await generated(f.sibling)
|
|
await link(shared, join(dirname(file), 'package.js'))
|
|
await utimes(dirname(file), new Date(0), new Date(0))
|
|
await chmod(dirname(file), 0o555)
|
|
await reclaimWorktreeGeneratedFiles({
|
|
repoRoot: f.repoRoot,
|
|
apply: true,
|
|
report: () => {}
|
|
})
|
|
await expect(lstat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
expect(await readFile(shared, 'utf8')).toBe('shared package')
|
|
expect((await lstat(shared)).mode & 0o777).toBe(originalMode)
|
|
}
|
|
)
|
|
|
|
it.each(['manual', 'automatic'])(
|
|
'includes generated cleanup in %s CLI preview and apply',
|
|
async (mode) => {
|
|
const f = await fixture()
|
|
const protectedFile = await generated(f.repoRoot)
|
|
const file = await generated(f.sibling, 'mobile/node_modules')
|
|
const store = join(f.root, 'global-pnpm-store')
|
|
await mkdir(store)
|
|
const shared = join(store, 'package.js')
|
|
await writeFile(shared, 'shared package')
|
|
await link(shared, join(dirname(file), 'package.js'))
|
|
await symlink(store, join(dirname(file), 'shared-package'), 'junction')
|
|
await utimes(dirname(file), new Date(0), new Date(0))
|
|
const run = (args) =>
|
|
runProcess({
|
|
program: process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.execPath,
|
|
args: [
|
|
resolve(import.meta.dirname, 'reclaim-worktree-space.mjs'),
|
|
'--repo',
|
|
f.repoRoot,
|
|
...(mode === 'automatic' ? ['--automatic'] : []),
|
|
...args
|
|
],
|
|
cwd: tmpdir(),
|
|
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
|
|
timeoutMs: 30_000,
|
|
terminationBarrier: true
|
|
})
|
|
const preview = await run([])
|
|
expect(preview.code, preview.stderr).toBe(0)
|
|
expect(preview.stdout).toContain('would remove generated directory')
|
|
expect(await readFile(file, 'utf8')).toBe('generated bytes')
|
|
const applied = await run(['--apply'])
|
|
expect(applied.code, applied.stderr).toBe(0)
|
|
await expect(lstat(file)).rejects.toMatchObject({ code: 'ENOENT' })
|
|
expect(await readFile(protectedFile, 'utf8')).toBe('generated bytes')
|
|
expect(await readFile(shared, 'utf8')).toBe('shared package')
|
|
}
|
|
)
|