mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
#14397 split `shared/types.ts` into 46 per-domain modules but kept the path as a re-export barrel so the import sites did not have to change. This removes the barrel: every consumer now imports from the module that actually declares the type, and `src/shared/types.ts` is deleted. Barrels hide where a type lives, make every consumer look like it depends on the whole domain, and let an unrelated edit invalidate a module that ~2,000 files transitively import. 2,323 import declarations across 2,321 files. Rewritten mechanically: each specifier was resolved to an absolute path via the TypeScript AST and recomputed, rather than string-substituted, so alias forms (`@/../../shared/ types`) and per-specifier `type` modifiers survive. Four cases the mechanical pass had to handle, each found by a gate rather than by reading the diff: - Modules inside `src/shared` import the barrel as `./types`, not `shared/types`. A pre-filter on the latter string skipped 176 of them and left imports dangling at a deleted file, which surfaced as confusing `Property 'x' is optional in type 'Repo' but required in Pick<Repo, ...>` errors rather than "module not found". - The barrel RENAMED one type on the way through (`WorkspaceSource as WorkspaceCreateTelemetrySource`), so the original name in the owning module has to be re-aliased at each consumer. - Three test files put `;(globalThis as ...)` on the line after the import. TypeScript parses that `;` as the import statement's terminator, so replacing through `statement.getEnd()` deletes it and breaks ASI. The rewrite now stops at the module specifier. - A file that already imported directly from a module got a SECOND import from it, because the barrel re-exported those same names — which trips `import/no-duplicates` under `--deny-warnings`. A post-pass merges declarations sharing a specifier and type-only-ness; the `import type` plus `import` pair from one module is left alone, since that form is allowed. Splitting one barrel import into several genuinely adds lines, which pushed `terminal-layout-pty-ownership.ts` to 301 counted lines: its 107-character import must wrap, and neither local type collapses onto one line (101 and 116 characters). Rather than contort a type declaration to fit a line budget, `collectLeafIds` and `pruneLeaves` move to `terminal-pane-layout-tree.ts` — they are pure structural operations on the layout tree and independent of PTY ownership. `visible-worktrees.ts` similarly loses its own mini-barrel re-export of `isDefaultBranchWorkspace`, with the four real consumers repointed at the declaring module. No `max-lines` bypass added. Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted first — these projects are `composite: true` and reuse stale caches); the full `pnpm lint` green, not just bare oxlint — the narrower local check is what let the duplicate imports reach CI; max-lines ratchet OK at 344.
230 lines
7.5 KiB
TypeScript
230 lines
7.5 KiB
TypeScript
import * as path from 'node:path'
|
|
import type { RemoveWorktreeResult } from '../shared/worktree/create-types'
|
|
import { assertWorktreeUnlockedForRemoval } from '../shared/worktree/removal'
|
|
import { isSubmoduleWorktreeRemovalRefusal } from '../shared/worktree/submodule-removal'
|
|
import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup'
|
|
import type { GitExec } from './git-handler-ops'
|
|
import type { GitCapabilityCache } from '../shared/git-capability-cache'
|
|
import { readRelayWorktreeList } from './git-handler-worktree-list'
|
|
|
|
function getErrorText(error: unknown): string {
|
|
if (typeof error === 'object' && error !== null) {
|
|
const parts: string[] = []
|
|
if ('message' in error && typeof error.message === 'string') {
|
|
parts.push(error.message)
|
|
}
|
|
if ('stderr' in error && typeof error.stderr === 'string') {
|
|
parts.push(error.stderr)
|
|
}
|
|
if ('stdout' in error && typeof error.stdout === 'string') {
|
|
parts.push(error.stdout)
|
|
}
|
|
return parts.join('\n')
|
|
}
|
|
return String(error)
|
|
}
|
|
|
|
function isBranchCheckedOutInWorktreeError(error: unknown): boolean {
|
|
return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test(
|
|
getErrorText(error)
|
|
)
|
|
}
|
|
|
|
function normalizeLocalBranchRef(branch: string): string {
|
|
return branch.replace(/^refs\/heads\//, '')
|
|
}
|
|
|
|
function isPosixAbsolutePath(value: string): boolean {
|
|
return value.startsWith('/')
|
|
}
|
|
|
|
function isWindowsAbsolutePath(value: string): boolean {
|
|
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\')
|
|
}
|
|
|
|
function resolveRelayRepoPath(worktreePath: string, commonDir: string): string {
|
|
if (isPosixAbsolutePath(worktreePath) || isPosixAbsolutePath(commonDir)) {
|
|
// Why: tests can run on Windows while the relay operates on SSH/POSIX
|
|
// paths; the default path API would reinterpret "/repo" as "G:\repo".
|
|
return path.posix.resolve(worktreePath, commonDir, '..')
|
|
}
|
|
if (isWindowsAbsolutePath(worktreePath) || isWindowsAbsolutePath(commonDir)) {
|
|
return path.win32.resolve(worktreePath, commonDir, '..')
|
|
}
|
|
return path.resolve(worktreePath, commonDir, '..')
|
|
}
|
|
|
|
function normalizeRelayWorktreePathForCompare(value: string): string {
|
|
if (isPosixAbsolutePath(value)) {
|
|
return path.posix.normalize(path.posix.resolve(value))
|
|
}
|
|
if (isWindowsAbsolutePath(value)) {
|
|
return path.win32.normalize(path.win32.resolve(value))
|
|
}
|
|
return path.normalize(path.resolve(value))
|
|
}
|
|
|
|
function areRelayWorktreePathsEqual(leftPath: string, rightPath: string): boolean {
|
|
const left = normalizeRelayWorktreePathForCompare(leftPath)
|
|
const right = normalizeRelayWorktreePathForCompare(rightPath)
|
|
const compareCaseInsensitive = isWindowsAbsolutePath(leftPath) && isWindowsAbsolutePath(rightPath)
|
|
return compareCaseInsensitive ? left.toLowerCase() === right.toLowerCase() : left === right
|
|
}
|
|
|
|
async function listRelayWorktreesForRemoval(
|
|
git: GitExec,
|
|
repoPath: string,
|
|
capabilities: GitCapabilityCache
|
|
) {
|
|
try {
|
|
return await readRelayWorktreeList(git, repoPath, capabilities)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
async function deleteRelayBranchAfterWorktreeRemoval(
|
|
git: GitExec,
|
|
repoPath: string,
|
|
branchName: string,
|
|
forceBranchDelete: boolean
|
|
): Promise<'deleted' | 'checked-out'> {
|
|
const deleteFlag = forceBranchDelete ? '-D' : '-d'
|
|
try {
|
|
await git(['branch', deleteFlag, '--', branchName], repoPath)
|
|
return 'deleted'
|
|
} catch (error) {
|
|
if (!isBranchCheckedOutInWorktreeError(error)) {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Why: branch deletion is the cheap live-checkout guard. Only prune when
|
|
// Git reports a checked-out branch, which may be stale worktree metadata.
|
|
await git(['worktree', 'prune'], repoPath)
|
|
} catch (error) {
|
|
console.warn(
|
|
`relay removeWorktree: failed to prune worktrees before deleting branch "${branchName}"`,
|
|
error
|
|
)
|
|
return 'checked-out'
|
|
}
|
|
|
|
try {
|
|
await git(['branch', deleteFlag, '--', branchName], repoPath)
|
|
return 'deleted'
|
|
} catch (error) {
|
|
if (isBranchCheckedOutInWorktreeError(error)) {
|
|
return 'checked-out'
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function removeWorktreeOp(
|
|
git: GitExec,
|
|
params: Record<string, unknown>,
|
|
capabilities: GitCapabilityCache
|
|
): Promise<RemoveWorktreeResult> {
|
|
const worktreePath = params.worktreePath as string
|
|
const force = params.force as boolean | undefined
|
|
const deleteBranch = params.deleteBranch !== false
|
|
const forceBranchDelete = params.forceBranchDelete === true
|
|
|
|
let repoPath = worktreePath
|
|
try {
|
|
const { stdout } = await git(['rev-parse', '--git-common-dir'], worktreePath)
|
|
const commonDir = stdout.trim()
|
|
if (commonDir && commonDir !== '.git') {
|
|
repoPath = resolveRelayRepoPath(worktreePath, commonDir)
|
|
}
|
|
} catch {
|
|
// fall through with worktreePath as repo
|
|
}
|
|
|
|
const worktreesBeforeRemoval = await listRelayWorktreesForRemoval(git, repoPath, capabilities)
|
|
const removedWorktree = worktreesBeforeRemoval.find((worktree) =>
|
|
areRelayWorktreePathsEqual(worktree.path, worktreePath)
|
|
)
|
|
const branchName = normalizeLocalBranchRef(removedWorktree?.branch ?? '')
|
|
const branchHead = removedWorktree?.head ?? ''
|
|
|
|
assertWorktreeUnlockedForRemoval(removedWorktree)
|
|
|
|
const args = ['worktree', 'remove']
|
|
if (force) {
|
|
args.push('--force')
|
|
}
|
|
args.push(worktreePath)
|
|
try {
|
|
await git(args, repoPath)
|
|
} catch (error) {
|
|
if (force || !isSubmoduleWorktreeRemovalRefusal(error)) {
|
|
throw error
|
|
}
|
|
// Why: Git refuses non-force removal of any worktree with an initialised
|
|
// submodule even when everything is clean. Re-prove cleanliness (parent
|
|
// status reports dirty submodule content as ` M <sub>`), then --force.
|
|
const { stdout } = await git(['status', '--porcelain', '--untracked-files=all'], worktreePath)
|
|
if (stdout.trim()) {
|
|
const dirtyError = new Error('Worktree has uncommitted or untracked changes.')
|
|
;(dirtyError as Error & { stdout?: string }).stdout = stdout
|
|
throw dirtyError
|
|
}
|
|
await git(['worktree', 'remove', '--force', worktreePath], repoPath)
|
|
}
|
|
|
|
if (!branchName) {
|
|
return {}
|
|
}
|
|
if (!deleteBranch) {
|
|
return {}
|
|
}
|
|
|
|
// Why: SSH worktree deletion should mirror local deletion. Dropping the
|
|
// branch also removes its upstream config, which lets fork-remotes cleanup
|
|
// after the last PR review worktree is gone.
|
|
try {
|
|
// Why: use `-d` (not `-D`) to mirror the local removeWorktree fix.
|
|
const branchDeleteResult = await deleteRelayBranchAfterWorktreeRemoval(
|
|
git,
|
|
repoPath,
|
|
branchName,
|
|
forceBranchDelete
|
|
)
|
|
if (branchDeleteResult === 'checked-out') {
|
|
return {}
|
|
}
|
|
return {}
|
|
} catch (error) {
|
|
if (!forceBranchDelete && branchHead) {
|
|
try {
|
|
if (
|
|
await deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure(
|
|
git,
|
|
repoPath,
|
|
branchName,
|
|
branchHead,
|
|
capabilities
|
|
)
|
|
) {
|
|
return {}
|
|
}
|
|
} catch (alreadyMergedDeleteError) {
|
|
// Why: worktree is gone; preserve branch recovery on cleanup races.
|
|
console.warn(
|
|
`relay removeWorktree: failed to delete already-merged local branch "${branchName}" after removing worktree`,
|
|
alreadyMergedDeleteError
|
|
)
|
|
}
|
|
}
|
|
// Expected when the branch still has unmerged/unpublished commits: keep it.
|
|
console.warn(
|
|
`relay removeWorktree: preserved local branch "${branchName}" after removing worktree (not fully merged)`,
|
|
error
|
|
)
|
|
return { preservedBranch: { branchName, ...(branchHead ? { head: branchHead } : {}) } }
|
|
}
|
|
}
|