import type { Repo } from '../../shared/repo-types' import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { splitWorktreeId } from '../../shared/worktree/id' import type { GitPushTarget } from '../../shared/worktree/types' export type RuntimeWorktreeRemovalTarget = { id: string repoId: string path: string pushTarget?: GitPushTarget } export function gitStatusErrorMeansNotRepository(error: unknown): boolean { const message = error instanceof Error ? error.message : error && typeof error === 'object' && 'message' in error ? String((error as { message: unknown }).message) : typeof error === 'string' ? error : '' const stderr = error && typeof error === 'object' && 'stderr' in error ? String((error as { stderr: unknown }).stderr) : '' return /not a git repository/i.test(`${message}\n${stderr}`) } /** * Options for `removeManagedWorktree`. Named rather than positional on purpose: three of the * four are interchangeable booleans that each waive a different safety check on a destructive * delete, so a transposition would silently delete a checkout the caller meant to protect. */ export type RemoveManagedWorktreeOptions = { force?: boolean runHooks?: boolean /** Waives proof that every PTY stopped (#11960). Set by explicit Force Delete only. */ allowUnverifiedPtyStop?: boolean /** Waives a FAILED archive hook (#19334). Never implied by `force`, never by `runHooks`. */ allowFailedArchiveHook?: boolean hostId?: string /** Reply with the finished delete's result; otherwise a background delete replies on acceptance. */ waitForBackgroundRemoval?: boolean } export function getRuntimeWorktreeRemovalOptionsKey( options: Pick< RemoveManagedWorktreeOptions, 'force' | 'runHooks' | 'allowUnverifiedPtyStop' | 'allowFailedArchiveHook' > ): string { // Why: a forced retry must not coalesce onto the in-flight attempt that just // failed the PTY gate — it would inherit that failure instead of retrying. const ptyKey = options.allowUnverifiedPtyStop ? 'allow-unverified-pty' : 'require-pty-stop' // Same reason for the archive waiver: a retry that waives the failed hook must not coalesce // onto the in-flight attempt that is about to refuse on it. const archiveKey = options.allowFailedArchiveHook ? 'allow-failed-archive' : 'require-archive' const hooksKey = options.runHooks ? 'run-hooks' : 'skip-hooks' return `${options.force ? 'force' : 'normal'}:${hooksKey}:${ptyKey}:${archiveKey}` } // Null executionHostId means host-unaware: path-only callers match any repo, and the first runtime // host can adopt a legacy (unstamped) repo. A repo that names a host in *either* spelling matches // only that host — including its own ssh:, which an executionHostId-only comparison // used to reject, so an unstamped SSH repo failed to dedupe against itself. export function runtimeRepoMatchesExecutionHost( repo: Pick, executionHostId?: ExecutionHostId | null ): boolean { if (executionHostId == null) { return true } if (repo.executionHostId == null && repo.connectionId == null) { return true } return getRepoExecutionHostId(repo) === executionHostId } export function parseExactWorktreeIdSelector( selector: string ): RuntimeWorktreeRemovalTarget | null { const worktreeId = selector.startsWith('id:') ? selector.slice(3) : selector const parsed = splitWorktreeId(worktreeId) if (!parsed || !parsed.repoId || !parsed.worktreePath) { return null } return { id: worktreeId, repoId: parsed.repoId, path: parsed.worktreePath } } export function normalizeLocalBranchName(branchName: string | undefined): string { return branchName?.replace(/^refs\/heads\//, '') ?? '' } export function getExplicitWorktreeIdSelector(selector: string | undefined): string | null { if (!selector?.startsWith('id:')) { return null } const id = selector.slice(3) return id.length > 0 ? id : null } export function hasLocalGitOptions(gitOptions: { wslDistro?: string }): boolean { return Object.keys(gitOptions).length > 0 }