mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
refactor(relay): delete the SSH relay server, its build assets and tests (#27215)
* wip: delete the SSH relay server and its build, assets and tests * wip: drop main/relay plugin conformance; WSL reader lookup test * fix: drop the emptied relay bench; prune baselines * test: keep the native halves of tests that also covered the relay * chore(ci): drop shard timings for the deleted relay server tests * fix: keep the directory listing budget that darwin terminal names now import --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -4,7 +4,6 @@ import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { quotePosixShell } from '../../shared/wsl-login-shell-command'
|
||||
import { createGitHandlerRelay } from '../../relay/git-handler-test-harness'
|
||||
import { gitExecFileAsync, gitSpawnAfterWindowsEnvironmentReady } from './runner'
|
||||
|
||||
let root = ''
|
||||
@@ -73,25 +72,6 @@ async function expectIdentity(identity: string): Promise<void> {
|
||||
}
|
||||
|
||||
describe('network SSH configuration with real Git', () => {
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'%s honors repository SSH wrappers on ordinary fetches',
|
||||
async (host) => {
|
||||
await git(['config', 'core.sshCommand', sshCommand('configured identity')])
|
||||
if (host === 'native') {
|
||||
await expect(gitExecFileAsync(['fetch', 'origin'], { cwd: repo, env })).rejects.toThrow()
|
||||
} else {
|
||||
const { dispatcher, handler } = createGitHandlerRelay()
|
||||
try {
|
||||
await expect(
|
||||
dispatcher.callRequest('git.fetch', { worktreePath: repo })
|
||||
).rejects.toThrow()
|
||||
} finally {
|
||||
handler.dispose()
|
||||
}
|
||||
}
|
||||
await expectIdentity('configured identity')
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves command-line SSH configuration ahead of repository configuration', async () => {
|
||||
await git(['config', 'core.sshCommand', sshCommand('repository')])
|
||||
@@ -104,28 +84,6 @@ describe('network SSH configuration with real Git', () => {
|
||||
await expectIdentity('command-line')
|
||||
})
|
||||
|
||||
it.each(['native', 'relay'] as const)('%s preserves an explicit SSH command', async (host) => {
|
||||
await git(['config', 'core.sshCommand', sshCommand('repository')])
|
||||
const command = sshCommand('environment')
|
||||
if (host === 'native') {
|
||||
await expect(
|
||||
gitExecFileAsync(['fetch', 'origin'], {
|
||||
cwd: repo,
|
||||
env: { ...env, GIT_SSH_COMMAND: command }
|
||||
})
|
||||
).rejects.toThrow()
|
||||
} else {
|
||||
vi.stubEnv('GIT_SSH_COMMAND', command)
|
||||
const { dispatcher, handler } = createGitHandlerRelay()
|
||||
try {
|
||||
await expect(dispatcher.callRequest('git.fetch', { worktreePath: repo })).rejects.toThrow()
|
||||
} finally {
|
||||
handler.dispose()
|
||||
}
|
||||
}
|
||||
await expectIdentity('environment')
|
||||
})
|
||||
|
||||
it('honors configured global SSH wrappers for streaming clones from a folder', async () => {
|
||||
await git(['config', '--global', 'core.sshCommand', sshCommand('clone identity')])
|
||||
const child = await gitSpawnAfterWindowsEnvironmentReady(
|
||||
|
||||
@@ -1,36 +1,39 @@
|
||||
import { readFile, unlink, utimes, writeFile } from 'node:fs/promises'
|
||||
import { readFile, rm, unlink, utimes, writeFile } from 'node:fs/promises'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup'
|
||||
import {
|
||||
createGitHandlerRelay,
|
||||
createGitTempDir,
|
||||
removeGitTempDir
|
||||
} from '../../../relay/git-handler-test-harness'
|
||||
import type { GitHandler } from '../../../relay/git-handler'
|
||||
import { isMissingGitBlobPath } from '../../../shared/git-blob-absence'
|
||||
import { readGitBlobAtIndexPath } from './git-blob-read'
|
||||
import { getDiff } from './file-diff'
|
||||
import { invalidateGitReadCaches, settledDiffCache } from './git-read-cache-invalidation'
|
||||
import { readWorktreeDiffStamp } from './worktree-diff-stamp'
|
||||
|
||||
function gitInit(dir: string): void {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' })
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'pipe' })
|
||||
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: dir, stdio: 'pipe' })
|
||||
}
|
||||
|
||||
function gitCommit(dir: string, message: string): void {
|
||||
execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' })
|
||||
execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' })
|
||||
}
|
||||
|
||||
describe('real Git missing paths and failed blob reads', () => {
|
||||
let repo: string
|
||||
let dispatcher: MockDispatcher
|
||||
let handler: GitHandler
|
||||
|
||||
beforeEach(() => {
|
||||
repo = createGitTempDir()
|
||||
;({ dispatcher, handler } = createGitHandlerRelay())
|
||||
repo = mkdtempSync(path.join(tmpdir(), 'orca-real-git-'))
|
||||
gitInit(repo)
|
||||
invalidateGitReadCaches()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
handler.dispose()
|
||||
invalidateGitReadCaches()
|
||||
await removeGitTempDir(repo)
|
||||
await rm(repo, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function git(args: string[], input?: string): Promise<string> {
|
||||
@@ -39,75 +42,60 @@ describe('real Git missing paths and failed blob reads', () => {
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function diff(host: 'native' | 'relay', staged = false): Promise<unknown> {
|
||||
return host === 'native'
|
||||
? getDiff(repo, 'file.txt', staged)
|
||||
: dispatcher.callRequest('git.diff', { worktreePath: repo, filePath: 'file.txt', staged })
|
||||
function diff(staged = false): Promise<unknown> {
|
||||
return getDiff(repo, 'file.txt', staged)
|
||||
}
|
||||
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'does not substitute HEAD for a corrupt index blob on %s',
|
||||
async (host) => {
|
||||
await writeFile(path.join(repo, 'file.txt'), 'committed\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await writeFile(path.join(repo, 'file.txt'), 'staged\n')
|
||||
await git(['add', 'file.txt'])
|
||||
const oid = (await git(['rev-parse', ':file.txt'])).trim()
|
||||
await writeFile(path.join(repo, 'file.txt'), 'working\n')
|
||||
const old = new Date(Date.now() - 10_000)
|
||||
await Promise.all(
|
||||
['.git/index', 'file.txt'].map((file) => utimes(path.join(repo, file), old, old))
|
||||
)
|
||||
const before = await readWorktreeDiffStamp(repo, 'file.txt', true)
|
||||
await unlink(path.join(repo, '.git', 'objects', oid.slice(0, 2), oid.slice(2)))
|
||||
expect(await readGitBlobAtIndexPath(repo, 'file.txt')).toMatchObject({
|
||||
exists: false,
|
||||
failed: true
|
||||
})
|
||||
it('does not substitute HEAD for a corrupt index blob', async () => {
|
||||
await writeFile(path.join(repo, 'file.txt'), 'committed\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await writeFile(path.join(repo, 'file.txt'), 'staged\n')
|
||||
await git(['add', 'file.txt'])
|
||||
const oid = (await git(['rev-parse', ':file.txt'])).trim()
|
||||
await writeFile(path.join(repo, 'file.txt'), 'working\n')
|
||||
const old = new Date(Date.now() - 10_000)
|
||||
await Promise.all(
|
||||
['.git/index', 'file.txt'].map((file) => utimes(path.join(repo, file), old, old))
|
||||
)
|
||||
const before = await readWorktreeDiffStamp(repo, 'file.txt', true)
|
||||
await unlink(path.join(repo, '.git', 'objects', oid.slice(0, 2), oid.slice(2)))
|
||||
expect(await readGitBlobAtIndexPath(repo, 'file.txt')).toMatchObject({
|
||||
exists: false,
|
||||
failed: true
|
||||
})
|
||||
|
||||
expect(await diff(host)).toMatchObject({ originalContent: '', modifiedContent: 'working\n' })
|
||||
if (host === 'native') {
|
||||
expect(settledDiffCache.stats().entries).toBe(0)
|
||||
}
|
||||
await git(['hash-object', '-w', '--stdin'], 'staged\n')
|
||||
const after = await readWorktreeDiffStamp(repo, 'file.txt', true)
|
||||
expect(after?.value).toBe(before?.value)
|
||||
expect(await diff()).toMatchObject({ originalContent: '', modifiedContent: 'working\n' })
|
||||
expect(settledDiffCache.stats().entries).toBe(0)
|
||||
await git(['hash-object', '-w', '--stdin'], 'staged\n')
|
||||
const after = await readWorktreeDiffStamp(repo, 'file.txt', true)
|
||||
expect(after?.value).toBe(before?.value)
|
||||
|
||||
expect(await diff(host)).toMatchObject({
|
||||
originalContent: 'staged\n',
|
||||
modifiedContent: 'working\n'
|
||||
})
|
||||
if (host === 'native') {
|
||||
expect(settledDiffCache.stats().entries).toBe(1)
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(await diff()).toMatchObject({
|
||||
originalContent: 'staged\n',
|
||||
modifiedContent: 'working\n'
|
||||
})
|
||||
expect(settledDiffCache.stats().entries).toBe(1)
|
||||
})
|
||||
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'retains added paths on an unborn branch on %s',
|
||||
async (host) => {
|
||||
await writeFile(path.join(repo, 'file.txt'), 'new file\n')
|
||||
await git(['add', 'file.txt'])
|
||||
expect(await diff(host, true)).toMatchObject({
|
||||
originalContent: '',
|
||||
modifiedContent: 'new file\n'
|
||||
})
|
||||
}
|
||||
)
|
||||
it('retains added paths on an unborn branch', async () => {
|
||||
await writeFile(path.join(repo, 'file.txt'), 'new file\n')
|
||||
await git(['add', 'file.txt'])
|
||||
expect(await diff(true)).toMatchObject({
|
||||
originalContent: '',
|
||||
modifiedContent: 'new file\n'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'retains additions absent from a valid HEAD tree on %s',
|
||||
async (host) => {
|
||||
await writeFile(path.join(repo, 'seed.txt'), 'seed\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await writeFile(path.join(repo, 'file.txt'), 'new file\n')
|
||||
await git(['add', 'file.txt'])
|
||||
expect(await diff(host, true)).toMatchObject({
|
||||
originalContent: '',
|
||||
modifiedContent: 'new file\n'
|
||||
})
|
||||
}
|
||||
)
|
||||
it('retains additions absent from a valid HEAD tree', async () => {
|
||||
await writeFile(path.join(repo, 'seed.txt'), 'seed\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await writeFile(path.join(repo, 'file.txt'), 'new file\n')
|
||||
await git(['add', 'file.txt'])
|
||||
expect(await diff(true)).toMatchObject({
|
||||
originalContent: '',
|
||||
modifiedContent: 'new file\n'
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes actual index and tree absence without accepting corrupt objects', async () => {
|
||||
await writeFile(path.join(repo, 'seed.txt'), 'seed\n')
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup'
|
||||
import {
|
||||
createGitHandlerRelay,
|
||||
createGitTempDir,
|
||||
removeGitTempDir
|
||||
} from '../../../relay/git-handler-test-harness'
|
||||
import type { GitHandler } from '../../../relay/git-handler'
|
||||
import { bulkStageFiles, bulkUnstageFiles } from './staging'
|
||||
import { bulkDiscardChanges } from './discard-changes'
|
||||
|
||||
describe('bulk pathspec stdin on execution hosts', () => {
|
||||
let repo: string
|
||||
let dispatcher: MockDispatcher
|
||||
let handler: GitHandler
|
||||
|
||||
beforeEach(() => {
|
||||
repo = createGitTempDir()
|
||||
;({ dispatcher, handler } = createGitHandlerRelay())
|
||||
gitInit(repo)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
handler.dispose()
|
||||
await removeGitTempDir(repo)
|
||||
})
|
||||
|
||||
async function changedFiles(staged: boolean): Promise<string[]> {
|
||||
const result = await runProcess({
|
||||
program: 'git',
|
||||
args: ['diff', ...(staged ? ['--cached'] : []), '--name-only', '-z'],
|
||||
cwd: repo
|
||||
})
|
||||
expect(result.code).toBe(0)
|
||||
return result.stdout.split('\0').filter(Boolean).sort()
|
||||
}
|
||||
|
||||
function mutate(
|
||||
host: 'native' | 'relay',
|
||||
action: 'stage' | 'unstage' | 'discard',
|
||||
filePaths: string[]
|
||||
): Promise<unknown> {
|
||||
if (host === 'relay') {
|
||||
const methods = {
|
||||
stage: 'git.bulkStage',
|
||||
unstage: 'git.bulkUnstage',
|
||||
discard: 'git.bulkDiscard'
|
||||
}
|
||||
return dispatcher.callRequest(methods[action], { worktreePath: repo, filePaths })
|
||||
}
|
||||
const operations = {
|
||||
stage: bulkStageFiles,
|
||||
unstage: bulkUnstageFiles,
|
||||
discard: bulkDiscardChanges
|
||||
}
|
||||
return operations[action](repo, filePaths)
|
||||
}
|
||||
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'stages, unstages and discards over 100 literal paths on %s',
|
||||
async (host) => {
|
||||
const selected = Array.from({ length: 105 }, (_, index) => `file-${index}.txt`)
|
||||
selected.push('[k]eep.log', 'space name.txt', '-option.txt')
|
||||
if (process.platform !== 'win32') {
|
||||
selected.push('line\nname.txt', ':(magic).txt', 'back\\slash.txt')
|
||||
}
|
||||
const allPaths = [...selected, 'keep.log']
|
||||
await Promise.all(
|
||||
allPaths.map((filePath) => writeFile(path.join(repo, filePath), 'original\n'))
|
||||
)
|
||||
gitCommit(repo, 'initial')
|
||||
await Promise.all(
|
||||
allPaths.map((filePath) => writeFile(path.join(repo, filePath), 'modified\n'))
|
||||
)
|
||||
|
||||
await mutate(host, 'stage', [])
|
||||
expect(await changedFiles(true)).toEqual([])
|
||||
await mutate(host, 'stage', selected)
|
||||
expect(await changedFiles(true)).toEqual([...selected].sort())
|
||||
await mutate(host, 'unstage', [])
|
||||
expect(await changedFiles(true)).toEqual([...selected].sort())
|
||||
await mutate(host, 'unstage', selected)
|
||||
expect(await changedFiles(true)).toEqual([])
|
||||
await mutate(host, 'discard', [])
|
||||
expect(await changedFiles(false)).toEqual([...allPaths].sort())
|
||||
await mutate(host, 'discard', selected)
|
||||
expect(await changedFiles(false)).toEqual(['keep.log'])
|
||||
expect(await readFile(path.join(repo, 'keep.log'), 'utf8')).toBe('modified\n')
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['native', 'relay'] as const)('rejects NUL path injection on %s', async (host) => {
|
||||
await writeFile(path.join(repo, 'one.txt'), 'original\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await writeFile(path.join(repo, 'one.txt'), 'modified\n')
|
||||
|
||||
await expect(mutate(host, 'stage', ['one.txt\0:(top)**'])).rejects.toThrow('NUL')
|
||||
expect(await changedFiles(true)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,38 +1,36 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import { gitCommit, gitInit, type MockDispatcher } from '../../../relay/git-handler-test-setup'
|
||||
import {
|
||||
createGitHandlerRelay,
|
||||
createGitTempDir,
|
||||
removeGitTempDir
|
||||
} from '../../../relay/git-handler-test-harness'
|
||||
import type { GitHandler } from '../../../relay/git-handler'
|
||||
import { bulkUnstageFiles, unstageFile } from './staging'
|
||||
import { bulkDiscardChanges, discardChanges } from './discard-changes'
|
||||
|
||||
const modes = [
|
||||
{ host: 'native', bulk: false },
|
||||
{ host: 'native', bulk: true },
|
||||
{ host: 'relay', bulk: false },
|
||||
{ host: 'relay', bulk: true }
|
||||
] as const
|
||||
function gitInit(dir: string): void {
|
||||
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' })
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'pipe' })
|
||||
execFileSync('git', ['config', 'user.name', 'Test User'], { cwd: dir, stdio: 'pipe' })
|
||||
}
|
||||
|
||||
function gitCommit(dir: string, message: string): void {
|
||||
execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' })
|
||||
execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' })
|
||||
}
|
||||
|
||||
const modes = [{ bulk: false }, { bulk: true }] as const
|
||||
|
||||
describe('staging and discard preserve index authority', () => {
|
||||
let repo: string
|
||||
let dispatcher: MockDispatcher
|
||||
let handler: GitHandler
|
||||
|
||||
beforeEach(() => {
|
||||
repo = createGitTempDir()
|
||||
;({ dispatcher, handler } = createGitHandlerRelay())
|
||||
repo = mkdtempSync(path.join(tmpdir(), 'orca-real-git-'))
|
||||
gitInit(repo)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
handler.dispose()
|
||||
await removeGitTempDir(repo)
|
||||
await rm(repo, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function git(args: string[], input?: string): Promise<string> {
|
||||
@@ -46,19 +44,13 @@ describe('staging and discard preserve index authority', () => {
|
||||
action: 'unstage' | 'discard',
|
||||
filePaths: string[]
|
||||
): Promise<unknown> {
|
||||
if (mode.host === 'relay') {
|
||||
return dispatcher.callRequest(
|
||||
`git.${mode.bulk ? (action === 'unstage' ? 'bulkUnstage' : 'bulkDiscard') : action}`,
|
||||
{ worktreePath: repo, ...(mode.bulk ? { filePaths } : { filePath: filePaths[0] }) }
|
||||
)
|
||||
}
|
||||
if (mode.bulk) {
|
||||
return (action === 'unstage' ? bulkUnstageFiles : bulkDiscardChanges)(repo, filePaths)
|
||||
}
|
||||
return (action === 'unstage' ? unstageFile : discardChanges)(repo, filePaths[0])
|
||||
}
|
||||
|
||||
it.each(modes)('preserves staged MM content during $host bulk=$bulk discard', async (mode) => {
|
||||
it.each(modes)('preserves staged MM content during bulk=$bulk discard', async (mode) => {
|
||||
await writeFile(path.join(repo, 'file.txt'), 'committed\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await writeFile(path.join(repo, 'file.txt'), 'staged\n')
|
||||
@@ -73,7 +65,7 @@ describe('staging and discard preserve index authority', () => {
|
||||
expect(await git(['diff', '--cached', '--name-only'])).toBe('file.txt\n')
|
||||
})
|
||||
|
||||
it.each(modes)('restores staged additions during $host bulk=$bulk AM discard', async (mode) => {
|
||||
it.each(modes)('restores staged additions during bulk=$bulk AM discard', async (mode) => {
|
||||
await writeFile(path.join(repo, 'initial.txt'), 'committed\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await writeFile(path.join(repo, 'new.txt'), 'staged addition\n')
|
||||
@@ -87,7 +79,7 @@ describe('staging and discard preserve index authority', () => {
|
||||
expect(await git(['diff', '--name-only'])).toBe('')
|
||||
})
|
||||
|
||||
it.each(modes)('unstages selected unborn paths on $host bulk=$bulk', async (mode) => {
|
||||
it.each(modes)('unstages selected unborn paths on bulk=$bulk', async (mode) => {
|
||||
await writeFile(path.join(repo, 'selected.txt'), 'selected\n')
|
||||
await writeFile(path.join(repo, 'keep.txt'), 'keep\n')
|
||||
await git(['add', '.'])
|
||||
@@ -98,7 +90,7 @@ describe('staging and discard preserve index authority', () => {
|
||||
expect(await readFile(path.join(repo, 'selected.txt'), 'utf8')).toBe('selected\n')
|
||||
})
|
||||
|
||||
it.each(['native', 'relay'] as const)('unstages both rename paths on %s', async (host) => {
|
||||
it('unstages both rename paths', async () => {
|
||||
await writeFile(path.join(repo, 'old.txt'), 'rename content\n')
|
||||
await writeFile(path.join(repo, 'keep.txt'), 'keep\n')
|
||||
gitCommit(repo, 'initial')
|
||||
@@ -106,32 +98,29 @@ describe('staging and discard preserve index authority', () => {
|
||||
await writeFile(path.join(repo, 'keep.txt'), 'keep staged\n')
|
||||
await git(['add', 'keep.txt'])
|
||||
|
||||
await mutate({ host, bulk: true }, 'unstage', ['new.txt', 'old.txt'])
|
||||
await mutate({ bulk: true }, 'unstage', ['new.txt', 'old.txt'])
|
||||
|
||||
expect(await git(['diff', '--cached', '--name-only'])).toBe('keep.txt\n')
|
||||
expect(await git(['ls-files', '-z'])).toBe('keep.txt\0old.txt\0')
|
||||
expect(await readFile(path.join(repo, 'new.txt'), 'utf8')).toBe('rename content\n')
|
||||
})
|
||||
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'discards a staged rename after unstaging both paths on %s',
|
||||
async (host) => {
|
||||
await writeFile(path.join(repo, 'old.txt'), 'rename content\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await git(['mv', 'old.txt', 'new.txt'])
|
||||
it('discards a staged rename after unstaging both paths', async () => {
|
||||
await writeFile(path.join(repo, 'old.txt'), 'rename content\n')
|
||||
gitCommit(repo, 'initial')
|
||||
await git(['mv', 'old.txt', 'new.txt'])
|
||||
|
||||
await mutate({ host, bulk: true }, 'unstage', ['new.txt', 'old.txt'])
|
||||
await mutate({ host, bulk: true }, 'discard', ['new.txt', 'old.txt'])
|
||||
await mutate({ bulk: true }, 'unstage', ['new.txt', 'old.txt'])
|
||||
await mutate({ bulk: true }, 'discard', ['new.txt', 'old.txt'])
|
||||
|
||||
expect(await git(['status', '--porcelain'])).toBe('')
|
||||
expect(await readFile(path.join(repo, 'old.txt'), 'utf8')).toBe('rename content\n')
|
||||
await expect(readFile(path.join(repo, 'new.txt'), 'utf8')).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
}
|
||||
)
|
||||
expect(await git(['status', '--porcelain'])).toBe('')
|
||||
expect(await readFile(path.join(repo, 'old.txt'), 'utf8')).toBe('rename content\n')
|
||||
await expect(readFile(path.join(repo, 'new.txt'), 'utf8')).rejects.toMatchObject({
|
||||
code: 'ENOENT'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(modes)('rejects an unresolved conflict on $host bulk=$bulk', async (mode) => {
|
||||
it.each(modes)('rejects an unresolved conflict on bulk=$bulk', async (mode) => {
|
||||
await writeFile(path.join(repo, 'file.txt'), 'base\n')
|
||||
gitCommit(repo, 'initial')
|
||||
const blobs = await Promise.all(
|
||||
|
||||
@@ -4,8 +4,6 @@ import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getBranchCompare } from './status'
|
||||
import { branchCompare } from '../../relay/git-handler-ops'
|
||||
import { gitChangeListArgs, parseGitChangeList } from '../../shared/git-change-list'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
@@ -17,22 +15,12 @@ function git(repo: string, args: string[]): string {
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function relayCompare(repo: string, baseRef: string) {
|
||||
return branchCompare(
|
||||
async (args, cwd) => ({ stdout: git(cwd, args), stderr: '' }),
|
||||
repo,
|
||||
baseRef,
|
||||
async (mergeBase, headOid) =>
|
||||
parseGitChangeList(git(repo, gitChangeListArgs(mergeBase, headOid)))
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('getBranchCompare real refs', () => {
|
||||
it('reports equal commits as empty on native and relay despite staged and unstaged changes', async () => {
|
||||
it('reports equal commits as empty despite staged and unstaged changes', async () => {
|
||||
const repo = await mkdtemp(path.join(tmpdir(), 'orca-equal-branch-compare-'))
|
||||
tempRoots.push(repo)
|
||||
git(repo, ['init', '-q'])
|
||||
@@ -46,37 +34,18 @@ describe('getBranchCompare real refs', () => {
|
||||
git(repo, ['add', 'changes.txt'])
|
||||
await writeFile(path.join(repo, 'changes.txt'), 'unstaged\n')
|
||||
|
||||
for (const result of await Promise.all([
|
||||
getBranchCompare(repo, 'base'),
|
||||
relayCompare(repo, 'base')
|
||||
])) {
|
||||
expect(result).toEqual({
|
||||
summary: {
|
||||
baseRef: 'base',
|
||||
baseOid: oid,
|
||||
compareRef: 'feature',
|
||||
headOid: oid,
|
||||
mergeBase: oid,
|
||||
changedFiles: 0,
|
||||
commitsAhead: 0,
|
||||
commitsBehind: 0,
|
||||
status: 'ready'
|
||||
},
|
||||
entries: []
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the merge-base failure for identical blob tips on the relay', async () => {
|
||||
const repo = await mkdtemp(path.join(tmpdir(), 'orca-blob-branch-compare-'))
|
||||
tempRoots.push(repo)
|
||||
git(repo, ['init', '-q'])
|
||||
await writeFile(path.join(repo, 'blob.txt'), 'not a commit\n')
|
||||
const oid = git(repo, ['hash-object', '-w', 'blob.txt'])
|
||||
await writeFile(path.join(repo, '.git', 'HEAD'), `${oid}\n`)
|
||||
|
||||
await expect(relayCompare(repo, oid)).resolves.toMatchObject({
|
||||
summary: { headOid: oid, baseOid: oid, status: 'no-merge-base', mergeBase: null },
|
||||
expect(await getBranchCompare(repo, 'base')).toEqual({
|
||||
summary: {
|
||||
baseRef: 'base',
|
||||
baseOid: oid,
|
||||
compareRef: 'feature',
|
||||
headOid: oid,
|
||||
mergeBase: oid,
|
||||
changedFiles: 0,
|
||||
commitsAhead: 0,
|
||||
commitsBehind: 0,
|
||||
status: 'ready'
|
||||
},
|
||||
entries: []
|
||||
})
|
||||
})
|
||||
@@ -109,19 +78,14 @@ describe('getBranchCompare real refs', () => {
|
||||
])
|
||||
expect(rawOid).not.toBe(peeledOid)
|
||||
|
||||
for (const result of await Promise.all([
|
||||
getBranchCompare(client, 'origin/tagbase'),
|
||||
relayCompare(client, 'origin/tagbase')
|
||||
])) {
|
||||
expect(result.summary).toMatchObject({
|
||||
baseOid: rawOid,
|
||||
headOid: peeledOid,
|
||||
mergeBase: peeledOid,
|
||||
changedFiles: 0,
|
||||
commitsAhead: 0,
|
||||
commitsBehind: 0,
|
||||
status: 'ready'
|
||||
})
|
||||
}
|
||||
expect((await getBranchCompare(client, 'origin/tagbase')).summary).toMatchObject({
|
||||
baseOid: rawOid,
|
||||
headOid: peeledOid,
|
||||
mergeBase: peeledOid,
|
||||
changedFiles: 0,
|
||||
commitsAhead: 0,
|
||||
commitsBehind: 0,
|
||||
status: 'ready'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* Main and relay must publish the same `branchLineTotal` for the same fixture
|
||||
* repo. Both call sites
|
||||
* share `src/shared/git-branch-line-total.ts`; this is the test that catches one
|
||||
* of them wiring it up differently — different flags, a different untracked
|
||||
* source, a different completeness gate.
|
||||
*/
|
||||
import { execFile, execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { getStatus } from './status'
|
||||
import type { GitExec } from '../../relay/git-handler-ops'
|
||||
import { getStatusOp } from '../../relay/git-handler-status-ops'
|
||||
import type { RelayGitStreamExec } from '../../relay/git-stdout-stream'
|
||||
import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total'
|
||||
import { clearGitStatusLineStatsCache } from '../../shared/git-status-line-stats-cache'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// Fork point → working tree for the fixture below:
|
||||
// tracked.txt +2 (branch commit only)
|
||||
// partial.txt +1 (staged +2 then one of those lines removed unstaged)
|
||||
// flip.txt 0 (added in the branch commit, removed again in the worktree)
|
||||
// moved.txt 0 (pure rename)
|
||||
// fresh.txt +3 (untracked)
|
||||
// No fixture path here looks like test or generated code, so it is all source.
|
||||
const EXPECTED_TOTAL = {
|
||||
added: 6,
|
||||
removed: 0,
|
||||
test: { added: 0, removed: 0 },
|
||||
generated: { added: 0, removed: 0 }
|
||||
}
|
||||
// Summing the per-area status rows instead would give this — the wrong answer
|
||||
// the shared module exists to avoid.
|
||||
const AREA_ROW_SUM = { added: 5, removed: 2 }
|
||||
|
||||
const relayGit: GitExec = async (args, cwd, opts) => {
|
||||
const { stdout, stderr } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
...(opts?.signal ? { signal: opts.signal } : {}),
|
||||
...(opts?.timeout ? { timeout: opts.timeout } : {})
|
||||
})
|
||||
return { stdout, stderr }
|
||||
}
|
||||
|
||||
const relayStreamGit: RelayGitStreamExec = async (args, cwd, options) => {
|
||||
const { stdout } = await relayGit(args, cwd, {
|
||||
disableOptionalLocks: options.disableOptionalLocks,
|
||||
signal: options.signal
|
||||
})
|
||||
return { stoppedEarly: options.onStdout(stdout) === true }
|
||||
}
|
||||
|
||||
function runFixtureGit(repo: string, args: string[]): string {
|
||||
return execFileSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'user.email=test@test.com',
|
||||
'-c',
|
||||
'user.name=Test',
|
||||
'-c',
|
||||
'commit.gpgSign=false',
|
||||
...args
|
||||
],
|
||||
{ cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
).trim()
|
||||
}
|
||||
|
||||
/** Returns the merge-base OID the chip is measured against. */
|
||||
async function seedParityFixture(repo: string): Promise<string> {
|
||||
execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' })
|
||||
await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\n')
|
||||
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\n')
|
||||
await writeFile(path.join(repo, 'renamed.txt'), 'stable\n')
|
||||
await writeFile(path.join(repo, 'flip.txt'), 'p\n')
|
||||
runFixtureGit(repo, ['add', '.'])
|
||||
runFixtureGit(repo, ['commit', '-m', 'base'])
|
||||
const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD'])
|
||||
|
||||
runFixtureGit(repo, ['checkout', '-q', '-b', 'feature'])
|
||||
await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\n')
|
||||
await writeFile(path.join(repo, 'flip.txt'), 'p\nq\n')
|
||||
runFixtureGit(repo, ['add', '-A'])
|
||||
runFixtureGit(repo, ['commit', '-m', 'branch commit'])
|
||||
|
||||
runFixtureGit(repo, ['mv', 'renamed.txt', 'moved.txt'])
|
||||
// Staged and unstaged hunks land on the same added lines, so an area sum
|
||||
// double-counts them.
|
||||
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\nbaz\n')
|
||||
runFixtureGit(repo, ['add', 'partial.txt'])
|
||||
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\n')
|
||||
// The branch commit's line, taken back out in the worktree: net zero.
|
||||
await writeFile(path.join(repo, 'flip.txt'), 'p\n')
|
||||
await writeFile(path.join(repo, 'fresh.txt'), 'n1\nn2\nn3\n')
|
||||
return mergeBase
|
||||
}
|
||||
|
||||
function sumAreaRows(entries: readonly { added?: number; removed?: number }[]): {
|
||||
added: number
|
||||
removed: number
|
||||
} {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
for (const entry of entries) {
|
||||
added += entry.added ?? 0
|
||||
removed += entry.removed ?? 0
|
||||
}
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
describe('branch line total parity between main and relay', () => {
|
||||
let repo: string
|
||||
|
||||
beforeEach(async () => {
|
||||
clearGitStatusLineStatsCache()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
repo = await mkdtemp(path.join(tmpdir(), 'branch-line-total-parity-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearGitStatusLineStatsCache()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
await rm(repo, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('produces identical totals for the same fixture repo', async () => {
|
||||
const mergeBase = await seedParityFixture(repo)
|
||||
|
||||
const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
clearGitStatusLineStatsCache()
|
||||
const relayStatus = await getStatusOp(relayGit, relayStreamGit, {
|
||||
worktreePath: repo,
|
||||
branchLineTotalMergeBase: mergeBase
|
||||
})
|
||||
|
||||
expect(mainStatus.branchLineTotal).toEqual({ ...EXPECTED_TOTAL, mergeBase })
|
||||
expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal)
|
||||
// Both sides model the same worktree, so a parity pass on a mismatched
|
||||
// entry list would be meaningless.
|
||||
expect(relayStatus.entries.map((entry) => entry.path).sort()).toEqual(
|
||||
mainStatus.entries.map((entry) => entry.path).sort()
|
||||
)
|
||||
})
|
||||
|
||||
it('agrees on a number no per-area row sum could produce', async () => {
|
||||
const mergeBase = await seedParityFixture(repo)
|
||||
|
||||
const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
clearGitStatusLineStatsCache()
|
||||
const relayStatus = await getStatusOp(relayGit, relayStreamGit, {
|
||||
worktreePath: repo,
|
||||
branchLineTotalMergeBase: mergeBase
|
||||
})
|
||||
|
||||
expect(sumAreaRows(mainStatus.entries)).toEqual(AREA_ROW_SUM)
|
||||
expect(sumAreaRows(relayStatus.entries as { added?: number; removed?: number }[])).toEqual(
|
||||
AREA_ROW_SUM
|
||||
)
|
||||
expect(mainStatus.branchLineTotal).not.toMatchObject(AREA_ROW_SUM)
|
||||
expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal)
|
||||
})
|
||||
|
||||
it('omits the total on both sides when no merge base is requested', async () => {
|
||||
await seedParityFixture(repo)
|
||||
|
||||
const mainStatus = await getStatus(repo)
|
||||
clearGitStatusLineStatsCache()
|
||||
const relayStatus = await getStatusOp(relayGit, relayStreamGit, { worktreePath: repo })
|
||||
|
||||
expect(Object.hasOwn(mainStatus, 'branchLineTotal')).toBe(false)
|
||||
expect(Object.hasOwn(relayStatus, 'branchLineTotal')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,250 +0,0 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import type * as GitRunner from './runner'
|
||||
import type { GitExec } from '../../relay/git-handler-ops'
|
||||
import type { RelayGitStreamExec } from '../../relay/git-stdout-stream'
|
||||
|
||||
const { gitStreamStdoutMock, readFileMock } = vi.hoisted(() => ({
|
||||
gitStreamStdoutMock: vi.fn(),
|
||||
readFileMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFsPromises>()
|
||||
return { ...actual, readFile: readFileMock }
|
||||
})
|
||||
|
||||
vi.mock('./runner', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof GitRunner>()
|
||||
return { ...actual, gitStreamStdout: gitStreamStdoutMock }
|
||||
})
|
||||
|
||||
import { getStatus } from './status'
|
||||
import { getStatusOp } from '../../relay/git-handler-status-ops'
|
||||
|
||||
const BENCH_DELAY_MS = 25
|
||||
const BENCH_SAMPLES = 31
|
||||
const BENCH_WARMUPS = 5
|
||||
const describeBench = process.env.ORCA_GIT_STATUS_OVERLAP_BENCH === '1' ? describe : describe.skip
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
}
|
||||
|
||||
type BenchmarkResult = {
|
||||
path: string
|
||||
medianMs: number
|
||||
p95Ms: number
|
||||
minMs: number
|
||||
maxMs: number
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function wait(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
|
||||
function percentile(samples: number[], fraction: number): number {
|
||||
const sorted = [...samples].sort((a, b) => a - b)
|
||||
return sorted[Math.ceil(sorted.length * fraction) - 1]!
|
||||
}
|
||||
|
||||
describe('git status conflict-read overlap', () => {
|
||||
const relayGit: GitExec = async () => ({ stdout: '', stderr: '' })
|
||||
|
||||
beforeEach(() => {
|
||||
readFileMock.mockReset()
|
||||
gitStreamStdoutMock.mockReset()
|
||||
readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n')
|
||||
gitStreamStdoutMock.mockResolvedValue({ stoppedEarly: false })
|
||||
})
|
||||
|
||||
it('starts native status before conflict-marker I/O settles', async () => {
|
||||
const markerRead = deferred<string>()
|
||||
const statusStarted = deferred<void>()
|
||||
readFileMock.mockReturnValue(markerRead.promise)
|
||||
gitStreamStdoutMock.mockImplementation(async () => {
|
||||
statusStarted.resolve()
|
||||
return { stoppedEarly: false }
|
||||
})
|
||||
|
||||
const resultPromise = getStatus('/repo')
|
||||
await statusStarted.promise
|
||||
expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8')
|
||||
|
||||
markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n')
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
entries: [],
|
||||
conflictOperation: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('starts relay status before conflict-marker I/O settles', async () => {
|
||||
const markerRead = deferred<string>()
|
||||
const statusStarted = deferred<void>()
|
||||
readFileMock.mockReturnValue(markerRead.promise)
|
||||
const relayStreamGit: RelayGitStreamExec = async () => {
|
||||
statusStarted.resolve()
|
||||
return { stoppedEarly: false }
|
||||
}
|
||||
|
||||
const resultPromise = getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' })
|
||||
await statusStarted.promise
|
||||
expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8')
|
||||
|
||||
markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n')
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
entries: [],
|
||||
conflictOperation: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('observes an early native status rejection while marker I/O remains pending', async () => {
|
||||
const markerRead = deferred<string>()
|
||||
readFileMock.mockReturnValue(markerRead.promise)
|
||||
gitStreamStdoutMock.mockRejectedValue(new Error('status failed first'))
|
||||
let settled = false
|
||||
|
||||
const resultPromise = getStatus('/repo').finally(() => {
|
||||
settled = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n')
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
entries: [],
|
||||
conflictOperation: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a synchronous native status failure fail-soft', async () => {
|
||||
const statusError = new Error('status threw')
|
||||
gitStreamStdoutMock.mockImplementation(() => {
|
||||
throw statusError
|
||||
})
|
||||
|
||||
await expect(getStatus('/repo')).resolves.toMatchObject({
|
||||
entries: [],
|
||||
conflictOperation: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['throw', 'reject'] as const)('keeps relay %s failures fail-soft', async (mode) => {
|
||||
const statusError = new Error(`status ${mode}`)
|
||||
const relayStreamGit = (() => {
|
||||
if (mode === 'throw') {
|
||||
throw statusError
|
||||
}
|
||||
return Promise.reject(statusError)
|
||||
}) as RelayGitStreamExec
|
||||
|
||||
await expect(
|
||||
getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' })
|
||||
).resolves.toMatchObject({ entries: [], conflictOperation: 'unknown' })
|
||||
})
|
||||
|
||||
it('rethrows the original relay status failure after cancellation', async () => {
|
||||
const controller = new AbortController()
|
||||
const statusError = new Error('cancelled status')
|
||||
const relayStreamGit: RelayGitStreamExec = async () => {
|
||||
controller.abort(statusError)
|
||||
throw statusError
|
||||
}
|
||||
|
||||
await expect(
|
||||
getStatusOp(
|
||||
relayGit,
|
||||
relayStreamGit,
|
||||
{ worktreePath: '/repo' },
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
).rejects.toBe(statusError)
|
||||
})
|
||||
|
||||
it('surfaces detector errors without waiting for a hung status read', async () => {
|
||||
const statusStarted = deferred<void>()
|
||||
const relayStreamGit: RelayGitStreamExec = () => {
|
||||
statusStarted.resolve()
|
||||
return new Promise(() => {})
|
||||
}
|
||||
const invalidPath = { toString: () => '/repo' }
|
||||
|
||||
const resultPromise = getStatusOp(relayGit, relayStreamGit, {
|
||||
worktreePath: invalidPath
|
||||
})
|
||||
const rejection = expect(resultPromise).rejects.toThrow(TypeError)
|
||||
await statusStarted.promise
|
||||
await rejection
|
||||
})
|
||||
|
||||
it('keeps detector errors ahead of concurrent status failures', async () => {
|
||||
const statusError = new Error('status failed too')
|
||||
const relayStreamGit = (() => {
|
||||
throw statusError
|
||||
}) as RelayGitStreamExec
|
||||
const invalidPath = { toString: () => '/repo' }
|
||||
|
||||
const result = getStatusOp(relayGit, relayStreamGit, { worktreePath: invalidPath })
|
||||
await expect(result).rejects.toBeInstanceOf(TypeError)
|
||||
})
|
||||
})
|
||||
|
||||
describeBench('git status conflict-read overlap benchmark', () => {
|
||||
it('measures native and relay orchestration with matched independent latency', async () => {
|
||||
readFileMock.mockImplementation(async () => {
|
||||
await wait(BENCH_DELAY_MS)
|
||||
return 'gitdir: /repo/.git/worktrees/feature\n'
|
||||
})
|
||||
gitStreamStdoutMock.mockImplementation(async () => {
|
||||
await wait(BENCH_DELAY_MS)
|
||||
return { stoppedEarly: false }
|
||||
})
|
||||
const relayGit: GitExec = async () => ({ stdout: '', stderr: '' })
|
||||
const relayStreamGit: RelayGitStreamExec = async () => {
|
||||
await wait(BENCH_DELAY_MS)
|
||||
return { stoppedEarly: false }
|
||||
}
|
||||
const cases = [
|
||||
{ name: 'native', run: () => getStatus('/repo') },
|
||||
{
|
||||
name: 'relay',
|
||||
run: () => getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' })
|
||||
}
|
||||
]
|
||||
|
||||
const results: BenchmarkResult[] = []
|
||||
for (const benchmarkCase of cases) {
|
||||
for (let index = 0; index < BENCH_WARMUPS; index += 1) {
|
||||
await benchmarkCase.run()
|
||||
}
|
||||
const samples: number[] = []
|
||||
for (let index = 0; index < BENCH_SAMPLES; index += 1) {
|
||||
const startedAt = performance.now()
|
||||
await benchmarkCase.run()
|
||||
samples.push(performance.now() - startedAt)
|
||||
}
|
||||
results.push({
|
||||
path: benchmarkCase.name,
|
||||
medianMs: Number(percentile(samples, 0.5).toFixed(3)),
|
||||
p95Ms: Number(percentile(samples, 0.95).toFixed(3)),
|
||||
minMs: Number(Math.min(...samples).toFixed(3)),
|
||||
maxMs: Number(Math.max(...samples).toFixed(3))
|
||||
})
|
||||
}
|
||||
|
||||
console.table(results)
|
||||
expect(results).toHaveLength(2)
|
||||
}, 10_000)
|
||||
})
|
||||
@@ -253,7 +253,6 @@ async function performAddWorktree(
|
||||
await persistWorktreeCreationBase(worktreePath, branch, effectiveBase, options)
|
||||
}
|
||||
|
||||
// SSH parity: relay's addWorktreeOp (src/relay/git-handler-worktree-ops.ts) mirrors this — change both in lockstep.
|
||||
// Why: --no-track leaves no upstream until first push; push.autoSetupRemote=true lets a plain
|
||||
// `git push` create+set origin/<branch> (git >=2.37; older clients ignore it). `--local` on a
|
||||
// linked worktree writes the shared common-dir config (whole repo) — intentional and idempotent,
|
||||
|
||||
@@ -10,7 +10,6 @@ vi.mock('./local-repo-ref-maintenance', () => ({
|
||||
}))
|
||||
|
||||
import { forceDeleteLocalBranch } from './worktree-branch-removal'
|
||||
import { forceDeletePreservedRelayBranch } from '../../relay/git-handler-branch-cleanup'
|
||||
|
||||
const image = process.env.ORCA_GIT_COMPAT_IMAGE
|
||||
const binary = process.env.ORCA_GIT_COMPAT_BINARY ?? 'git'
|
||||
@@ -82,10 +81,10 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('rebase branch reservations with real Git', () => {
|
||||
it.for(['native', 'relay'] as const)(
|
||||
it.for(['native'] as const)(
|
||||
'%s retains auxiliary branches reserved by a paused real rebase --update-refs',
|
||||
{ timeout: 120_000 },
|
||||
async (host, context) => {
|
||||
async (_host, context) => {
|
||||
await git(['worktree', 'add', '-q', '-b', 'feature', checkout])
|
||||
await writeFile(join(checkout, 'first'), 'first\n')
|
||||
await git(['add', 'first'], checkout)
|
||||
@@ -117,10 +116,7 @@ describe('rebase branch reservations with real Git', () => {
|
||||
/checked out|used by worktree/
|
||||
)
|
||||
const cleanupGit = vi.fn((args: string[], cwd: string) => git(args, cwd))
|
||||
const remove = () =>
|
||||
host === 'native'
|
||||
? forceDeleteLocalBranch(repo, 'auxiliary', expected, cleanupGit)
|
||||
: forceDeletePreservedRelayBranch(cleanupGit, repo, 'auxiliary', expected)
|
||||
const remove = () => forceDeleteLocalBranch(repo, 'auxiliary', expected, cleanupGit)
|
||||
await expect(remove()).rejects.toThrow('checked out in another worktree')
|
||||
expect(cleanupGit.mock.calls.map(([args]) => args)).toEqual([
|
||||
['worktree', 'list', '--porcelain']
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { GitCapabilityCache } from '../../shared/git-capability-cache'
|
||||
import { isWorktreeCreatePreparation } from '../../shared/worktree/create-preparation'
|
||||
import { parseWorktreeList } from '../../shared/git-worktree-porcelain-parser'
|
||||
import { annotateWorktreeLocksFromAdmin } from '../../shared/git-worktree-admin'
|
||||
import type { GitExec } from '../../relay/git-handler-ops'
|
||||
|
||||
const { runner } = vi.hoisted(() => ({ runner: vi.fn() }))
|
||||
vi.mock('./runner', () => ({
|
||||
@@ -18,14 +13,8 @@ vi.mock('./runner', () => ({
|
||||
}))
|
||||
vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: (run: () => unknown) => run() }))
|
||||
|
||||
import { removeWorktree } from './worktree-removal'
|
||||
import { forceDeleteLocalBranch } from './worktree-branch-removal'
|
||||
import { readWorktreeList } from './worktree-list-reader'
|
||||
import { getBranchConflictKind, getBranchConflictKindViaExec } from './repo-branch-conflict'
|
||||
import { clearGitCapabilityStateForTests } from './git-capability-state'
|
||||
import { removeWorktreeOp } from '../../relay/git-handler-worktree-remove'
|
||||
import { forceDeletePreservedRelayBranch } from '../../relay/git-handler-branch-cleanup'
|
||||
import { readRelayWorktreeList } from '../../relay/git-handler-worktree-list'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const image = process.env.ORCA_GIT_COMPAT_IMAGE
|
||||
@@ -37,7 +26,6 @@ const dockerUser =
|
||||
: []
|
||||
let root = ''
|
||||
let repo = ''
|
||||
let checkout = ''
|
||||
|
||||
async function git(args: string[], cwd = repo): Promise<{ stdout: string; stderr: string }> {
|
||||
return image
|
||||
@@ -66,8 +54,6 @@ async function git(args: string[], cwd = repo): Promise<{ stdout: string; stderr
|
||||
})
|
||||
}
|
||||
|
||||
const relay: GitExec = (args, cwd) => git(args, cwd)
|
||||
|
||||
async function initializeRepo(cwd: string): Promise<void> {
|
||||
await mkdir(cwd, { recursive: true })
|
||||
await git(['init', '-q', '-b', 'main'], cwd).catch(() => git(['init', '-q'], cwd))
|
||||
@@ -92,7 +78,6 @@ beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'orca-worktree-safety-')))
|
||||
repo = join(root, 'repo')
|
||||
checkout = join(root, 'checkout')
|
||||
await initializeRepo(repo)
|
||||
clearGitCapabilityStateForTests()
|
||||
runner.mockImplementation((args: string[], options: { cwd?: string }) => git(args, options.cwd))
|
||||
@@ -103,99 +88,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('worktree safety with the real Git binary', () => {
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'%s preserves unpublished submodule objects without implicit force',
|
||||
async (host) => {
|
||||
const origin = join(root, 'sub-origin')
|
||||
await initializeRepo(origin)
|
||||
await git(['-c', 'protocol.file.allow=always', 'submodule', 'add', origin, 'sub'])
|
||||
await git(['commit', '-qam', 'submodule'])
|
||||
await git(['worktree', 'add', '-q', '-b', 'feature', checkout])
|
||||
await git(['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init'], checkout)
|
||||
const sub = join(checkout, 'sub')
|
||||
await git(['checkout', '-qb', 'unpublished'], sub)
|
||||
await writeFile(join(sub, 'local'), 'unpublished work\n')
|
||||
await git(['add', 'local'], sub)
|
||||
await git(
|
||||
[
|
||||
'-c',
|
||||
'user.name=Safety',
|
||||
'-c',
|
||||
'user.email=safety@example.invalid',
|
||||
'-c',
|
||||
'commit.gpgSign=false',
|
||||
'commit',
|
||||
'-qm',
|
||||
'local'
|
||||
],
|
||||
sub
|
||||
)
|
||||
const local = (await git(['rev-parse', 'HEAD'], sub)).stdout.trim()
|
||||
await git(['checkout', '--detach', 'HEAD~1'], sub)
|
||||
expect(
|
||||
(await git(['status', '--porcelain', '--ignore-submodules=none'], checkout)).stdout
|
||||
).toBe('')
|
||||
|
||||
const remove = (force: boolean) =>
|
||||
host === 'native'
|
||||
? removeWorktree(repo, checkout, force, {
|
||||
deleteBranch: false,
|
||||
knownRemovedWorktree: { branch: 'refs/heads/feature', head: '', locked: false }
|
||||
})
|
||||
: removeWorktreeOp(
|
||||
relay,
|
||||
{ worktreePath: checkout, force, deleteBranch: false },
|
||||
new GitCapabilityCache()
|
||||
)
|
||||
await expect(remove(false)).rejects.toThrow(/submodules cannot be moved or removed/)
|
||||
expect((await git(['cat-file', '-t', local], sub)).stdout.trim()).toBe('commit')
|
||||
expect((await git(['worktree', 'list', '--porcelain'])).stdout).toContain(checkout)
|
||||
await expect(remove(true)).resolves.toEqual({})
|
||||
await expect(readFile(join(checkout, '.git'))).rejects.toThrow()
|
||||
},
|
||||
120_000
|
||||
)
|
||||
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'%s reads preparation ownership from admin metadata on old porcelain',
|
||||
async (host) => {
|
||||
await git(['worktree', 'add', '-q', '--detach', checkout])
|
||||
const reason = 'orca-create-preparation:v1:12345:retained'
|
||||
await git(['worktree', 'lock', '--reason', reason, checkout])
|
||||
const oldList = (await git(['worktree', 'list', '--porcelain'])).stdout
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('locked'))
|
||||
.join('\n')
|
||||
const capabilities = new GitCapabilityCache()
|
||||
capabilities.rememberUnsupported('worktree-list-z')
|
||||
runner.mockImplementation(async (args: string[], options: { cwd?: string }) => {
|
||||
if (args[0] === 'worktree' && args[1] === 'list') {
|
||||
if (args.includes('-z')) {
|
||||
throw Object.assign(new Error("unknown switch `z'"), {
|
||||
code: 129,
|
||||
stderr: "error: unknown switch `z'"
|
||||
})
|
||||
}
|
||||
return { stdout: oldList, stderr: '' }
|
||||
}
|
||||
return git(args, options.cwd)
|
||||
})
|
||||
const entries =
|
||||
host === 'native'
|
||||
? await readWorktreeList(repo)
|
||||
: await readRelayWorktreeList((args, cwd) => runner(args, { cwd }), repo, capabilities)
|
||||
const prepared = entries.find((entry) => entry.path === checkout)
|
||||
expect(prepared).toMatchObject({ locked: true, lockReason: reason })
|
||||
expect(prepared && isWorktreeCreatePreparation(prepared)).toBe(true)
|
||||
await rm(checkout, { recursive: true })
|
||||
const annotated = await annotateWorktreeLocksFromAdmin(repo, parseWorktreeList(oldList))
|
||||
expect(annotated.find((entry) => entry.path === checkout)).toMatchObject({
|
||||
locked: true,
|
||||
lockReason: reason
|
||||
})
|
||||
},
|
||||
90_000
|
||||
)
|
||||
|
||||
it.each([
|
||||
['feature', 'Feature'],
|
||||
@@ -226,59 +118,4 @@ describe('worktree safety with the real Git binary', () => {
|
||||
90_000
|
||||
)
|
||||
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'%s retains detached branches reserved by rebase or bisect',
|
||||
async (host) => {
|
||||
await git(['worktree', 'add', '-q', '-b', 'feature', checkout])
|
||||
const expected = (await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()
|
||||
await git(['checkout', '--detach'], checkout)
|
||||
const gitDir = (await git(['rev-parse', '--absolute-git-dir'], checkout)).stdout.trim()
|
||||
const remove = () =>
|
||||
host === 'native'
|
||||
? forceDeleteLocalBranch(repo, 'feature', expected, git)
|
||||
: forceDeletePreservedRelayBranch(relay, repo, 'feature', expected)
|
||||
for (const marker of ['rebase-merge/head-name', 'rebase-apply/head-name', 'BISECT_START']) {
|
||||
const file = join(gitDir, ...marker.split('/'))
|
||||
await mkdir(join(file, '..'), { recursive: true })
|
||||
await writeFile(file, marker === 'BISECT_START' ? 'feature\n' : 'refs/heads/feature\n')
|
||||
if (marker === 'BISECT_START') {
|
||||
await writeFile(join(gitDir, 'BISECT_LOG'), '')
|
||||
}
|
||||
await expect(git(['branch', '-D', 'feature'])).rejects.toThrow(
|
||||
/checked out|used by worktree/
|
||||
)
|
||||
await expect(remove()).rejects.toThrow('checked out in another worktree')
|
||||
expect((await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()).toBe(expected)
|
||||
await rm(file)
|
||||
if (marker === 'BISECT_START') {
|
||||
await rm(join(gitDir, 'BISECT_LOG'))
|
||||
}
|
||||
}
|
||||
await expect(remove()).resolves.toBeUndefined()
|
||||
},
|
||||
120_000
|
||||
)
|
||||
it.each(['native', 'relay'] as const)(
|
||||
'%s restores a deleted ref when detached branch use starts during deletion',
|
||||
async (host) => {
|
||||
await git(['worktree', 'add', '-q', '-b', 'feature', checkout])
|
||||
const expected = (await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()
|
||||
await git(['checkout', '--detach'], checkout)
|
||||
const gitDir = (await git(['rev-parse', '--absolute-git-dir'], checkout)).stdout.trim()
|
||||
const racingGit = async (args: string[], cwd: string) => {
|
||||
const result = await git(args, cwd)
|
||||
if (args[0] === 'update-ref' && args[1] === '-d') {
|
||||
await writeFile(join(gitDir, 'BISECT_START'), 'feature\n')
|
||||
}
|
||||
return result
|
||||
}
|
||||
const remove = () =>
|
||||
host === 'native'
|
||||
? forceDeleteLocalBranch(repo, 'feature', expected, racingGit)
|
||||
: forceDeletePreservedRelayBranch(racingGit, repo, 'feature', expected)
|
||||
await expect(remove()).rejects.toThrow('checked out in another worktree')
|
||||
expect((await git(['rev-parse', 'refs/heads/feature'])).stdout.trim()).toBe(expected)
|
||||
},
|
||||
90_000
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user