Files
orca/src/main/git/worktree-removal-preflight.ts
Neil 037cc2e531 Split Git worktree responsibilities (#17253)
* Split speech session lifecycle

* Split terminal output scheduler pipeline

* Split mobile browser pane modules

* Prune resolved max-lines suppressions

* Split pane tree equalization logic

* Extract mobile troubleshoot screen styles

* Split external automation manager

* Split main window service attachments

* Split hosted review creation checks

* Split automation dispatch event handling

* Split settings navigation metadata

* Split daemon initialization lifecycle

* Split GitLab item dialog

* Split relay dispatcher layers

* Split mobile host screen

* Retarget mobile view settings source test

* Split runtime file client layers

* Split ports panel layers

* Split runtime environments pane layers

* Split local PTY provider responsibilities

* Split CDP bridge responsibilities

* Split relay Git handler responsibilities

* Track moved relay Git fetch audit

* Split Linear item drawer responsibilities

* Split telemetry event schema responsibilities

* Split resource usage status responsibilities

* Split remote terminal multiplexer responsibilities

* Split Git worktree responsibilities

* Fix F3-speech for #17123

* Fix F1-cycle for #17131

* Fix F4-navtest for #17157

* Fix F2-allowlist for #17161
2026-08-29 20:20:55 -07:00

69 lines
2.4 KiB
TypeScript

import { gitExecFileAsync } from './runner'
import type { WorktreeRemovalPreflightOptions } from './worktree-operation-options'
import { WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS, gitExecOptions } from './worktree-operation-options'
/**
* Assert a worktree is clean enough for non-force removal.
*/
export async function assertWorktreeCleanForRemoval(
worktreePath: string,
force = false,
options: WorktreeRemovalPreflightOptions = {}
): Promise<void> {
if (force) {
return
}
const { ignoredUntrackedPaths = [], ...gitOptions } = options
const useNullTerminatedStatus = ignoredUntrackedPaths.length > 0
const { stdout } = await gitExecFileAsync(
['status', '--porcelain', ...(useNullTerminatedStatus ? ['-z'] : []), '--untracked-files=all'],
{
...gitExecOptions(worktreePath, gitOptions),
timeout: gitOptions.timeout ?? WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS
}
)
// Why one parse feeds both: the clean verdict and the error text must never
// disagree about which entries block removal.
const blockingEntries = useNullTerminatedStatus
? getBlockingUntrackedStatusEntries(stdout, ignoredUntrackedPaths)
: null
if (blockingEntries ? blockingEntries.length === 0 : !stdout.trim()) {
return
}
const error = new Error('Worktree has uncommitted or untracked changes.')
// Why not the raw stdout: `-z` output is NUL-delimited and `.trim()` leaves
// interior NULs, so attaching it verbatim put raw control bytes into the
// user-facing removal error — and listed the tolerated shared link, the one
// entry that is not the user's work and cannot be committed away.
;(error as Error & { stdout?: string }).stdout = blockingEntries
? blockingEntries.join('\n')
: stdout
throw error
}
/** The `git status --porcelain -z` entries that genuinely block removal:
* everything except the untracked shared links the caller tolerates. */
function getBlockingUntrackedStatusEntries(
status: string,
ignoredUntrackedPaths: readonly string[]
): string[] {
const ignored = new Set(
ignoredUntrackedPaths
.map((entry) =>
entry
.trim()
.replace(/^[\\/]+/, '')
.replace(/\\/g, '/')
)
.filter((entry) => entry && !entry.split('/').includes('..'))
)
return status
.split('\0')
.filter(Boolean)
.filter(
(entry) => !(entry.startsWith('?? ') && ignored.has(entry.slice(3).replace(/\\/g, '/')))
)
}