fix(workspace-cleanup): warn before deleting a workspace's ignored local files like .env (#41446)

* fix(workspace-cleanup): warn before deleting a workspace's ignored local files

git worktree remove deletes ignored files such as .env and local config,
and git status never reports them, so cleanup showed "Clean git" and the
delete dialog said nothing. Read them with a bounded ls-files listing,
skip regenerable output, symlinks and byte-identical copies of the main
checkout, and surface the rest as an approval-only cleanup blocker and a
warning in the delete confirmation.

* fix(workspace-cleanup): show an unchecked warning when the ignored-files check fails

* style(workspace-cleanup): format merged fact status list
This commit is contained in:
Kelvin Amoaba
2026-11-12 09:23:46 +00:00
committed by GitHub
parent 318bf64769
commit 0b516b8a9d
31 changed files with 838 additions and 47 deletions
@@ -0,0 +1,87 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, realpathSync } from 'node:fs'
import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { readWorktreeAtRiskLocalFiles } from './worktree-at-risk-local-files'
function git(args: string[], cwd: string): void {
execFileSync('git', args, { cwd, stdio: 'pipe' })
}
describe('readWorktreeAtRiskLocalFiles (real git)', () => {
let scratch = ''
let mainPath = ''
let worktreePath = ''
beforeEach(async () => {
scratch = realpathSync(mkdtempSync(join(tmpdir(), 'orca-at-risk-files-')))
mainPath = join(scratch, 'main')
worktreePath = join(scratch, 'feature')
await mkdir(mainPath)
git(['init', '-q'], mainPath)
git(['config', 'user.email', 'test@example.com'], mainPath)
git(['config', 'user.name', 'Test User'], mainPath)
await writeFile(join(mainPath, '.gitignore'), '.env*\nnode_modules/\nlocal/\n*.log\n')
git(['add', '.'], mainPath)
git(['commit', '-qm', 'init'], mainPath)
git(['worktree', 'add', '-q', '-b', 'feature', worktreePath], mainPath)
await writeFile(join(mainPath, '.env'), 'TOKEN=main\n')
})
afterEach(async () => {
await rm(scratch, { recursive: true, force: true })
})
const read = () => readWorktreeAtRiskLocalFiles({ worktreePath, mainCheckoutPath: mainPath })
it('reports an ignored .env that differs from the main checkout', async () => {
await writeFile(join(worktreePath, '.env'), 'TOKEN=feature\n')
await mkdir(join(worktreePath, 'local', 'notes'), { recursive: true })
await writeFile(join(worktreePath, 'local', 'notes', 'todo.txt'), 'mine\n')
await expect(read()).resolves.toEqual({
count: 2,
samplePaths: ['.env', 'local/'],
complete: true
})
})
// File symlinks need Developer Mode or admin rights on Windows.
it.skipIf(process.platform === 'win32')(
'ignores regenerable output, identical copies and symlinks',
async () => {
await mkdir(join(worktreePath, 'node_modules', 'pkg'), { recursive: true })
await writeFile(join(worktreePath, 'node_modules', 'pkg', 'index.js'), 'x\n')
await writeFile(join(worktreePath, 'debug.log'), 'x\n')
await writeFile(join(worktreePath, '.env'), 'TOKEN=main\n')
await writeFile(join(mainPath, '.env.local'), 'LOCAL=1\n')
await symlink(join(mainPath, '.env.local'), join(worktreePath, '.env.local'))
await expect(read()).resolves.toEqual({ count: 0, samplePaths: [], complete: true })
}
)
it.skipIf(process.platform === 'win32')(
'does not count a main-checkout symlink into the worktree as a copy',
async () => {
await rm(join(mainPath, '.env'))
await writeFile(join(worktreePath, '.env'), 'TOKEN=feature\n')
await symlink(join(worktreePath, '.env'), join(mainPath, '.env'))
await expect(read()).resolves.toEqual({ count: 1, samplePaths: ['.env'], complete: true })
}
)
it('reports a directory past its comparison budget as unchecked, not safe', async () => {
await mkdir(join(worktreePath, 'local'))
await mkdir(join(mainPath, 'local'))
for (let index = 0; index < 1_001; index += 1) {
await writeFile(join(worktreePath, 'local', `${index}.txt`), 'same\n')
await writeFile(join(mainPath, 'local', `${index}.txt`), 'same\n')
}
await expect(read()).resolves.toEqual({ count: 0, samplePaths: [], complete: false })
})
})
@@ -0,0 +1,328 @@
import { lstat, opendir, readFile, realpath, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
import { isDefinitiveAbsence } from '../../../../shared/definitive-filesystem-absence'
import { abortSignalWithTimeout } from '../../../lib/abort-signal-with-timeout'
import type { WorktreeAtRiskLocalFiles } from '../../../../shared/worktree/at-risk-local-files'
import { gitOptionalLocksDisabledEnv } from '../../command-runner/git-environment/git-process-env'
import type { GitRuntimeOptions } from '../../command-runner/git-runtime-options'
import { gitStreamStdout } from '../../command-runner/git-stream-stdout'
import { createAbortError } from '../../command-runner/process-capture/abort-error'
// --directory collapses wholly ignored directories (node_modules/) into one row.
const LIST_IGNORED_ARGS = [
'ls-files',
'-z',
'--others',
'--ignored',
'--exclude-standard',
'--directory',
'--no-empty-directory'
]
const DEFAULT_DEADLINE_MS = 8_000
const MAX_LISTED_PATHS = 5_000
const MAX_VISITED_ENTRIES = 1_000
const MAX_COMPARED_BYTES = 32 * 1024 * 1024
const MAX_COMPARED_FILE_BYTES = 4 * 1024 * 1024
const SAMPLE_PATH_LIMIT = 3
// Dependency, build and cache output that a fresh checkout regenerates.
const REGENERABLE_DIRECTORY_NAMES: ReadonlySet<string> = new Set([
'node_modules',
'bower_components',
'.pnpm-store',
'.yarn',
'dist',
'build',
'out',
'.next',
'.nuxt',
'.svelte-kit',
'.angular',
'.expo',
'.output',
'.vite',
'.turbo',
'.parcel-cache',
'.cache',
'target',
'.gradle',
'.dart_tool',
'Pods',
'vendor',
'_build',
'.terraform',
'DerivedData',
'.build',
'.venv',
'venv',
'__pycache__',
'.pytest_cache',
'.mypy_cache',
'.ruff_cache',
'.tox',
'coverage',
'.nyc_output'
])
const DISPOSABLE_FILE_NAMES: ReadonlySet<string> = new Set([
'.DS_Store',
'Thumbs.db',
'desktop.ini',
'.eslintcache'
])
const DISPOSABLE_FILE_SUFFIXES = ['.pyc', '.pyo', '.tsbuildinfo', '.log']
/** saved: a copy survives removal; unsaved: removal loses it; unknown: a budget or deadline cut the check. */
type EntryVerdict = 'saved' | 'unsaved' | 'unknown'
type ComparisonBudget = {
entries: number
bytes: number
signal: AbortSignal
worktreeRealPath: string
}
/**
* Ignored files `git worktree remove` would delete that the main checkout has no identical copy of.
* Regenerable output and symlinks are never at risk; the git listing and the file comparisons each get the deadline.
*/
export async function readWorktreeAtRiskLocalFiles(args: {
worktreePath: string
/** Identical copies here are recoverable; null counts every ignored file. */
mainCheckoutPath: string | null
gitOptions?: GitRuntimeOptions
deadlineMs?: number
}): Promise<WorktreeAtRiskLocalFiles> {
const parentSignal = args.gitOptions?.signal
if (parentSignal?.aborted) {
throw createAbortError()
}
const deadlineMs = args.deadlineMs ?? DEFAULT_DEADLINE_MS
// Git's own timer starts after admission, so queueing behind other git work cannot cut the listing short.
const listing = await listIgnoredPaths(args.worktreePath, args.gitOptions, deadlineMs)
if (args.mainCheckoutPath === null) {
return {
count: listing.paths.length,
samplePaths: pickNotablePaths(listing.paths),
complete: listing.complete
}
}
const signal = abortSignalWithTimeout(parentSignal, deadlineMs)
// File reads cannot all take a signal; racing the deadline keeps a stalled mount from hanging the scan.
const deadline = new Promise<'unknown'>((resolve) => {
if (signal.aborted) {
resolve('unknown')
}
signal.addEventListener('abort', () => resolve('unknown'), { once: true })
})
const worktreeRealPath = await Promise.race([
realpath(args.worktreePath).catch(() => args.worktreePath),
deadline
])
const budget: ComparisonBudget = {
entries: MAX_VISITED_ENTRIES,
bytes: MAX_COMPARED_BYTES,
signal,
worktreeRealPath: worktreeRealPath === 'unknown' ? args.worktreePath : worktreeRealPath
}
const atRiskPaths: string[] = []
let unverified = false
for (const relativePath of listing.paths) {
if (!hasBudgetLeft(budget)) {
unverified = true
break
}
const verdict = await Promise.race([
readEntryVerdict(
join(args.worktreePath, relativePath),
join(args.mainCheckoutPath, relativePath),
budget
),
deadline
])
if (verdict === 'unsaved') {
atRiskPaths.push(relativePath)
} else if (verdict === 'unknown') {
unverified = true
}
}
if (parentSignal?.aborted) {
throw createAbortError()
}
return {
count: atRiskPaths.length,
samplePaths: pickNotablePaths(atRiskPaths),
complete: listing.complete && !unverified
}
}
async function listIgnoredPaths(
worktreePath: string,
options: GitRuntimeOptions = {},
timeoutMs: number
): Promise<{ paths: string[]; complete: boolean }> {
const paths: string[] = []
const addPath = (relativePath: string): boolean => {
if (relativePath.length > 0 && !isRegenerablePath(relativePath)) {
paths.push(relativePath)
}
return paths.length >= MAX_LISTED_PATHS
}
let carry = ''
try {
const result = await gitStreamStdout(LIST_IGNORED_ARGS, {
cwd: worktreePath,
wslDistro: options.wslDistro,
admissionTier: options.admissionTier ?? 'background',
preferWslDirectGit: true,
env: gitOptionalLocksDisabledEnv(),
...(options.signal ? { signal: options.signal } : {}),
timeoutMs,
onStdout: (chunk) => {
const records = (carry + chunk).split('\0')
carry = records.pop() ?? ''
return records.some(addPath)
}
})
if (!result.stoppedEarly && carry.length > 0) {
addPath(carry)
}
return { paths, complete: !result.stoppedEarly }
} catch (error) {
if (options.signal?.aborted) {
throw error
}
// A deadline, byte cap or git failure leaves the unlisted rest unknown, never safe.
return { paths, complete: false }
}
}
function isRegenerablePath(relativePath: string): boolean {
const isDirectory = relativePath.endsWith('/')
const segments = relativePath.split('/').filter((segment) => segment.length > 0)
const name = segments.pop() ?? ''
return (
segments.some((segment) => REGENERABLE_DIRECTORY_NAMES.has(segment)) ||
isRegenerableEntry(name, isDirectory)
)
}
function isRegenerableEntry(name: string, isDirectory: boolean): boolean {
return isDirectory ? REGENERABLE_DIRECTORY_NAMES.has(name) : isDisposableFileName(name)
}
function isDisposableFileName(name: string): boolean {
return (
DISPOSABLE_FILE_NAMES.has(name) ||
DISPOSABLE_FILE_SUFFIXES.some((suffix) => name.endsWith(suffix))
)
}
async function readEntryVerdict(
worktreeEntry: string,
mainEntry: string,
budget: ComparisonBudget
): Promise<EntryVerdict> {
if (!spendBudgetEntry(budget)) {
return 'unknown'
}
let info
try {
info = await lstat(worktreeEntry)
} catch {
// Git or the directory walk just saw it; a miss means a race or a path UTF-8 decoding mangled.
return 'unknown'
}
// Removal unlinks a symlink without touching its target.
if (info.isSymbolicLink()) {
return 'saved'
}
if (info.isDirectory()) {
return readDirectoryVerdict(worktreeEntry, mainEntry, budget)
}
return info.isFile() ? readFileVerdict(worktreeEntry, info.size, mainEntry, budget) : 'saved'
}
async function readDirectoryVerdict(
worktreeDirectory: string,
mainDirectory: string,
budget: ComparisonBudget
): Promise<EntryVerdict> {
try {
// Streamed so a huge directory costs at most the entry budget, never a full listing.
for await (const child of await opendir(worktreeDirectory)) {
if (isRegenerableEntry(child.name, child.isDirectory())) {
if (!spendBudgetEntry(budget)) {
return 'unknown'
}
continue
}
const verdict = await readEntryVerdict(
join(worktreeDirectory, child.name),
join(mainDirectory, child.name),
budget
)
if (verdict !== 'saved') {
return verdict
}
}
return 'saved'
} catch {
return 'unknown'
}
}
async function readFileVerdict(
worktreeFile: string,
size: number,
mainFile: string,
budget: ComparisonBudget
): Promise<EntryVerdict> {
let resolvedMainFile
try {
resolvedMainFile = await realpath(mainFile)
} catch (error) {
return isDefinitiveAbsence(error) ? 'unsaved' : 'unknown'
}
// A main-checkout symlink into this worktree is no copy: removal deletes what it points at.
if (isPathInsideOrEqual(budget.worktreeRealPath, resolvedMainFile)) {
return 'unsaved'
}
try {
const mainInfo = await stat(resolvedMainFile)
if (!mainInfo.isFile() || mainInfo.size !== size) {
return 'unsaved'
}
if (size > MAX_COMPARED_FILE_BYTES || size * 2 > budget.bytes) {
return 'unknown'
}
budget.bytes -= size * 2
const [worktreeContent, mainContent] = await Promise.all([
readFile(worktreeFile, { signal: budget.signal }),
readFile(resolvedMainFile, { signal: budget.signal })
])
return worktreeContent.equals(mainContent) ? 'saved' : 'unsaved'
} catch {
return 'unknown'
}
}
function spendBudgetEntry(budget: ComparisonBudget): boolean {
budget.entries -= 1
return budget.entries >= 0 && !budget.signal.aborted
}
function hasBudgetLeft(budget: ComparisonBudget): boolean {
return budget.entries > 0 && !budget.signal.aborted
}
function pickNotablePaths(paths: readonly string[]): string[] {
const rank = (relativePath: string): number => (/(^|\/)\.env[^/]*\/?$/.test(relativePath) ? 0 : 1)
return [...paths]
.sort(
(left, right) =>
rank(left) - rank(right) ||
left.split('/').length - right.split('/').length ||
left.localeCompare(right)
)
.slice(0, SAMPLE_PATH_LIMIT)
}
@@ -19,6 +19,8 @@ import { registerFilesystemGitHistoryHandlers } from './filesystem-git-history-h
import { isGitWorktreeMutationInFlight } from '../../git/command-runner/git-worktree-mutation-state'
import type { GitMutationInFlight } from '../../../shared/git/commands/git-mutation-in-flight'
import { checkIgnoredPaths } from '../../git/source-control/check-ignored-paths'
import { readWorktreeAtRiskLocalFiles } from '../../git/worktrees/removal/worktree-at-risk-local-files'
import type { WorktreeAtRiskLocalFiles } from '../../../shared/worktree/at-risk-local-files'
import {
appendFolderToGitignore,
findKnownHugeFolderPathsToIgnore
@@ -171,6 +173,29 @@ export function registerFilesystemGitStatusHandlers(context: FilesystemHandlerCo
}
)
ipcMain.handle(
'git:atRiskLocalFiles',
async (
_event,
args: { worktreePath: string; connectionId?: string }
): Promise<WorktreeAtRiskLocalFiles> => {
requireReachableGitRoute(getConnectionExecutionHostId(args.connectionId))
return readWorktreeGitUnlessWslStopped(
store,
args.worktreePath,
false,
(worktreePath, gitOptions) =>
readWorktreeAtRiskLocalFiles({
worktreePath,
mainCheckoutPath:
getLocalRepoForRegisteredWorktree(store, args.worktreePath, worktreePath)?.path ??
null,
gitOptions
})
)
}
)
ipcMain.handle(
'git:findHugeFoldersToIgnore',
async (_event, args: { worktreePath: string }): Promise<string[]> =>
@@ -83,7 +83,8 @@ export async function buildWorkspaceCleanupCandidate(args: {
upstreamAhead: gitEvidence.upstreamAhead,
upstreamBehind: gitEvidence.upstreamBehind,
checkedAt: gitEvidence.checkedAt,
...(gitEvidence.merge ? { merge: gitEvidence.merge } : {})
...(gitEvidence.merge ? { merge: gitEvidence.merge } : {}),
atRiskLocalFiles: gitEvidence.atRiskLocalFiles
},
fingerprint: ''
}
@@ -11,9 +11,15 @@ import {
type WorkspaceCleanupMergeEvidence
} from '../../../shared/workspace-cleanup/workspace-cleanup'
import { readWorkspaceCleanupMergeEvidence } from './workspace-cleanup-merge-evidence'
import {
hasWorktreeAtRiskLocalFiles,
type WorktreeAtRiskLocalFiles
} from '../../../shared/worktree/at-risk-local-files'
import { readWorktreeAtRiskLocalFiles } from '../../git/worktrees/removal/worktree-at-risk-local-files'
import {
WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS,
WorkspaceCleanupScanCancelledError,
throwIfWorkspaceCleanupScanAborted,
withWorkspaceCleanupTimeout
} from './workspace-cleanup-scan-primitives'
import { getWorktreeSharedLinkPaths } from '../../git/worktrees/content-paths/worktree-shared-directories'
@@ -28,6 +34,7 @@ export type WorkspaceCleanupGitEvidence = {
upstreamBehind: number | null
checkedAt: number | null
merge?: WorkspaceCleanupMergeEvidence
atRiskLocalFiles: WorktreeAtRiskLocalFiles | null
blockers: WorkspaceCleanupBlocker[]
}
@@ -37,6 +44,7 @@ export function createEmptyWorkspaceCleanupGitEvidence(): WorkspaceCleanupGitEvi
upstreamAhead: null,
upstreamBehind: null,
checkedAt: null,
atRiskLocalFiles: null,
blockers: []
}
}
@@ -119,16 +127,40 @@ export async function readWorkspaceCleanupGitEvidence(
}
}
const atRiskLocalFiles = await readCleanupAtRiskLocalFiles(worktree, repo, signal)
if (hasWorktreeAtRiskLocalFiles(atRiskLocalFiles)) {
blockers.push('ignored-files')
}
return {
clean,
upstreamAhead,
upstreamBehind,
checkedAt,
...(merge ? { merge } : {}),
atRiskLocalFiles,
blockers: uniqueWorkspaceCleanupGitBlockers(blockers)
}
}
async function readCleanupAtRiskLocalFiles(
worktree: Worktree,
repo: Repo,
signal?: AbortSignal
): Promise<WorktreeAtRiskLocalFiles> {
try {
return await readWorktreeAtRiskLocalFiles({
worktreePath: worktree.path,
mainCheckoutPath: repo.path,
gitOptions: signal ? { signal } : {},
deadlineMs: WORKSPACE_CLEANUP_GIT_READ_TIMEOUT_MS
})
} catch (error) {
throwIfWorkspaceCleanupScanAborted(signal)
throw error
}
}
async function readUnpushedCommitCount(
worktree: Worktree,
signal?: AbortSignal
@@ -13,6 +13,7 @@ export class RuntimeGitCommands {
readonly getRuntimeGitHistory: RuntimeGitStatusCommands['getRuntimeGitHistory']
readonly getRuntimeGitConflictOperation: RuntimeGitStatusCommands['getRuntimeGitConflictOperation']
readonly getRuntimeGitMutationInFlight: RuntimeGitStatusCommands['getRuntimeGitMutationInFlight']
readonly readRuntimeWorktreeAtRiskLocalFiles: RuntimeGitStatusCommands['readRuntimeWorktreeAtRiskLocalFiles']
readonly switchRuntimeGitBranch: RuntimeGitStatusCommands['switchRuntimeGitBranch']
readonly checkoutRuntimeGitBranch: RuntimeGitStatusCommands['checkoutRuntimeGitBranch']
readonly listRuntimeGitLocalBranches: RuntimeGitStatusCommands['listRuntimeGitLocalBranches']
@@ -66,6 +67,8 @@ export class RuntimeGitCommands {
this.getRuntimeGitHistory = status.getRuntimeGitHistory.bind(status)
this.getRuntimeGitConflictOperation = status.getRuntimeGitConflictOperation.bind(status)
this.getRuntimeGitMutationInFlight = status.getRuntimeGitMutationInFlight.bind(status)
this.readRuntimeWorktreeAtRiskLocalFiles =
status.readRuntimeWorktreeAtRiskLocalFiles.bind(status)
this.switchRuntimeGitBranch = status.switchRuntimeGitBranch.bind(status)
this.checkoutRuntimeGitBranch = status.checkoutRuntimeGitBranch.bind(status)
this.listRuntimeGitLocalBranches = status.listRuntimeGitLocalBranches.bind(status)
@@ -8,6 +8,7 @@ type RuntimeGitCommandName =
| 'getRuntimeGitHistory'
| 'getRuntimeGitConflictOperation'
| 'getRuntimeGitMutationInFlight'
| 'readRuntimeWorktreeAtRiskLocalFiles'
| 'abortRuntimeGitMerge'
| 'abortRuntimeGitRebase'
| 'switchRuntimeGitBranch'
@@ -61,6 +62,8 @@ export function installRuntimeGitCommandSurface(
getRuntimeGitHistory: commands.getRuntimeGitHistory.bind(commands),
getRuntimeGitConflictOperation: commands.getRuntimeGitConflictOperation.bind(commands),
getRuntimeGitMutationInFlight: commands.getRuntimeGitMutationInFlight.bind(commands),
readRuntimeWorktreeAtRiskLocalFiles:
commands.readRuntimeWorktreeAtRiskLocalFiles.bind(commands),
abortRuntimeGitMerge: commands.abortRuntimeGitMerge.bind(commands),
abortRuntimeGitRebase: commands.abortRuntimeGitRebase.bind(commands),
switchRuntimeGitBranch: commands.switchRuntimeGitBranch.bind(commands),
@@ -28,6 +28,8 @@ import {
type RuntimeGitTarget
} from './runtime-git-command-target'
import { assertRuntimeReadWslDistroRunning } from './runtime-wsl-background-read-gate'
import type { WorktreeAtRiskLocalFiles } from '../../../../shared/worktree/at-risk-local-files'
import { readWorktreeAtRiskLocalFiles } from '../../../git/worktrees/removal/worktree-at-risk-local-files'
// Followers nobody is looking at: a stopped distro must stay stopped for them (E9).
async function resolveGatedGitTarget(
@@ -104,6 +106,19 @@ export class RuntimeGitStatusCommands {
}
}
async readRuntimeWorktreeAtRiskLocalFiles(
worktreeSelector: string,
signal?: AbortSignal
): Promise<WorktreeAtRiskLocalFiles> {
const target = await resolveGatedGitTarget(this.host, worktreeSelector)
requireLocalRuntimeGitRoute(target)
return readWorktreeAtRiskLocalFiles({
worktreePath: target.worktree.path,
mainCheckoutPath: target.repo?.path ?? null,
gitOptions: { ...localGitOptionsForTarget(target), ...(signal ? { signal } : {}) }
})
}
async checkoutRuntimeGitBranch(
worktreeSelector: string,
branch: string,
@@ -73,6 +73,13 @@ export const GIT_STATUS_METHODS = [
}
}
}),
defineMethod({
name: 'git.atRiskLocalFiles',
permission: 'workspace',
params: WorktreeSelector,
handler: async (params, { runtime, signal }) =>
runtime.readRuntimeWorktreeAtRiskLocalFiles(params.worktree, signal)
}),
defineMethod({
name: 'git.conflictOperation',
permission: 'workspace',
+5
View File
@@ -10,6 +10,7 @@ import type {
import type { GitStagingArea, GitUpstreamStatus } from '../../shared/git/status/git-status-types'
import type { ConflictResolution } from '../../shared/source-control/listing/source-control-entry-actions'
import type { GitPushTarget } from '../../shared/worktree/types'
import type { WorktreeAtRiskLocalFiles } from '../../shared/worktree/at-risk-local-files'
import type { GitStageWorktreeScope } from '../../shared/git/paths/git-stage-worktree-scope'
import type {
GitHistoryItemRef,
@@ -50,6 +51,10 @@ export const gitApi = {
paths: string[]
connectionId?: string
}): Promise<string[]> => ipcRenderer.invoke('git:checkIgnored', args),
atRiskLocalFiles: (args: {
worktreePath: string
connectionId?: string
}): Promise<WorktreeAtRiskLocalFiles> => ipcRenderer.invoke('git:atRiskLocalFiles', args),
findHugeFoldersToIgnore: (args: { worktreePath: string }): Promise<string[]> =>
ipcRenderer.invoke('git:findHugeFoldersToIgnore', args),
appendGitignore: (args: { worktreePath: string; folderName: string }): Promise<boolean> =>
+5
View File
@@ -11,6 +11,7 @@ import type {
GitUpstreamStatus
} from '../../shared/git/status/git-status-types'
import type { GitPushTarget } from '../../shared/worktree/types'
import type { WorktreeAtRiskLocalFiles } from '../../shared/worktree/at-risk-local-files'
import type {
GitHistoryItemRef,
GitHistoryOptions,
@@ -56,6 +57,10 @@ export type GitInspectionApi = {
paths: string[]
connectionId?: string
}) => Promise<string[]>
atRiskLocalFiles: (args: {
worktreePath: string
connectionId?: string
}) => Promise<WorktreeAtRiskLocalFiles>
findHugeFoldersToIgnore: (args: { worktreePath: string }) => Promise<string[]>
refs: (args: {
worktreePath: string
@@ -33,6 +33,7 @@ import { useTranslate } from '@/i18n/use-translate'
import { useUiLanguageRevision } from '@/i18n/use-ui-language-revision'
import type { WorktreeRemovalTarget } from '../../../../../../shared/worktree/removal'
import { useDeleteWorktreeStatusHydration } from './use-delete-worktree-status-hydration'
import { useDeleteWorktreeAtRiskLocalFiles } from './use-delete-worktree-at-risk-local-files'
import { useConfirmedWorktreeDeleteTargets } from './use-confirmed-worktree-delete-targets'
import { runLineageDeleteAll } from './delete-worktree-lineage-delete-all'
import { runDialogForceDelete } from './delete-worktree-dialog-force-delete'
@@ -193,6 +194,11 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
visibleTargets: worktrees,
repoMap
})
const atRiskLocalFilesByIdentity = useDeleteWorktreeAtRiskLocalFiles({
isOpen,
deleteTargets,
repoMap
})
const dirtyChanges = useMemo(() => {
const statusInput = { deleteTargets, gitStatusByWorktree, gitStatusByWorktreeIdentity, repoMap }
return {
@@ -348,6 +354,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
changeCheckStatesByWorktreeId={dirtyChanges.checkStates}
dirtyChangeCountsByWorktreeId={dirtyChanges.counts}
dirtyChangePreviewsByWorktreeId={dirtyChanges.previews}
atRiskLocalFilesByIdentity={atRiskLocalFilesByIdentity}
/>
<RemovalUnsavedFilesNotice targets={unsavedTargets} enabled={isOpen} />
@@ -360,6 +367,7 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
changeCheckStatesByWorktreeId={dirtyChanges.checkStates}
dirtyChangeCountsByWorktreeId={dirtyChanges.counts}
dirtyChangePreviewsByWorktreeId={dirtyChanges.previews}
atRiskLocalFilesByIdentity={atRiskLocalFilesByIdentity}
preserveChildren={preserveChildrenSelected}
canPreserveChildren={canPreserveChildren}
disabled={isDeleting}
@@ -9,6 +9,8 @@ import {
import type { Worktree } from '../../../../../../shared/worktree/types'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import { DeleteWorktreeDirtyChangeHint } from './DeleteWorktreeDirtyChangeHint'
import { DeleteWorktreeLocalFilesHint } from './DeleteWorktreeLocalFilesHint'
import type { WorktreeAtRiskLocalFiles } from '../../../../../../shared/worktree/at-risk-local-files'
import type {
DeleteWorktreeChangeCheckState,
DeleteWorktreeDirtyChangePreview
@@ -23,6 +25,7 @@ type DeleteWorktreeLineageNoticeProps = {
dirtyChangeCountsByWorktreeId: ReadonlyMap<string, number>
dirtyChangePreviewsByWorktreeId: ReadonlyMap<string, DeleteWorktreeDirtyChangePreview>
changeCheckStatesByWorktreeId?: ReadonlyMap<string, DeleteWorktreeChangeCheckState>
atRiskLocalFilesByIdentity?: ReadonlyMap<string, WorktreeAtRiskLocalFiles>
preserveChildren: boolean
canPreserveChildren: boolean
disabled: boolean
@@ -36,6 +39,7 @@ export function DeleteWorktreeLineageNotice({
dirtyChangeCountsByWorktreeId,
dirtyChangePreviewsByWorktreeId,
changeCheckStatesByWorktreeId,
atRiskLocalFilesByIdentity,
preserveChildren,
canPreserveChildren,
disabled,
@@ -118,17 +122,22 @@ export function DeleteWorktreeLineageNotice({
</div>
<div className="truncate text-muted-foreground">{child.path}</div>
{!preserveChildren && (
<DeleteWorktreeDirtyChangeHint
checkState={changeCheckStatesByWorktreeId?.get(
child.hostId ? getWorktreeHostIdentity(child) : child.id
)}
changeCount={dirtyChangeCountsByWorktreeId.get(
child.hostId ? getWorktreeHostIdentity(child) : child.id
)}
preview={dirtyChangePreviewsByWorktreeId.get(
child.hostId ? getWorktreeHostIdentity(child) : child.id
)}
/>
<>
<DeleteWorktreeDirtyChangeHint
checkState={changeCheckStatesByWorktreeId?.get(
child.hostId ? getWorktreeHostIdentity(child) : child.id
)}
changeCount={dirtyChangeCountsByWorktreeId.get(
child.hostId ? getWorktreeHostIdentity(child) : child.id
)}
preview={dirtyChangePreviewsByWorktreeId.get(
child.hostId ? getWorktreeHostIdentity(child) : child.id
)}
/>
<DeleteWorktreeLocalFilesHint
files={atRiskLocalFilesByIdentity?.get(getWorktreeHostIdentity(child))}
/>
</>
)}
</div>
))}
@@ -0,0 +1,31 @@
import type { JSX } from 'react'
import { AlertTriangle } from 'lucide-react'
import { useTranslate } from '@/i18n/use-translate'
import { formatAtRiskLocalFileNames } from '@/lib/workspace/display/at-risk-local-files-label'
import type { WorktreeAtRiskLocalFiles } from '../../../../../../shared/worktree/at-risk-local-files'
export function DeleteWorktreeLocalFilesHint({
files
}: {
files: WorktreeAtRiskLocalFiles | undefined
}): JSX.Element | null {
const t = useTranslate()
if (!files) {
return null
}
const names = formatAtRiskLocalFileNames(files, t)
const label = names
? t('components.workspace.delete.ignoredFilesLost', 'Ignored files will be lost: {{names}}', {
names
})
: t(
'components.workspace.delete.ignoredFilesUnchecked',
'Ignored files could not be fully checked'
)
return (
<div className="mt-0.5 flex h-4 min-w-0 items-center gap-1.5 text-destructive" title={label}>
<AlertTriangle className="size-3 shrink-0" />
<span className="min-w-0 truncate font-medium">{label}</span>
</div>
)
}
@@ -4,6 +4,8 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import type { Worktree } from '../../../../../../shared/worktree/types'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import { DeleteWorktreeDirtyChangeHint } from './DeleteWorktreeDirtyChangeHint'
import { DeleteWorktreeLocalFilesHint } from './DeleteWorktreeLocalFilesHint'
import type { WorktreeAtRiskLocalFiles } from '../../../../../../shared/worktree/at-risk-local-files'
import type {
DeleteWorktreeChangeCheckState,
DeleteWorktreeDirtyChangePreview
@@ -52,7 +54,8 @@ export function DeleteWorktreeTargetPreview({
deleteStateByWorktreeId,
dirtyChangeCountsByWorktreeId,
dirtyChangePreviewsByWorktreeId,
changeCheckStatesByWorktreeId
changeCheckStatesByWorktreeId,
atRiskLocalFilesByIdentity
}: {
isBatchDelete: boolean
worktree: Worktree | null
@@ -63,6 +66,7 @@ export function DeleteWorktreeTargetPreview({
dirtyChangeCountsByWorktreeId: ReadonlyMap<string, number>
dirtyChangePreviewsByWorktreeId: ReadonlyMap<string, DeleteWorktreeDirtyChangePreview>
changeCheckStatesByWorktreeId?: ReadonlyMap<string, DeleteWorktreeChangeCheckState>
atRiskLocalFilesByIdentity?: ReadonlyMap<string, WorktreeAtRiskLocalFiles>
}): JSX.Element | null {
const t = useTranslate()
const targetIdPrefix = useId()
@@ -112,6 +116,9 @@ export function DeleteWorktreeTargetPreview({
item.hostId ? getWorktreeHostIdentity(item) : item.id
)}
/>
<DeleteWorktreeLocalFilesHint
files={atRiskLocalFilesByIdentity?.get(getWorktreeHostIdentity(item))}
/>
{itemDeleteError ? (
<div className="mt-1 whitespace-pre-wrap break-all text-destructive">
{itemDeleteError}
@@ -168,6 +175,9 @@ export function DeleteWorktreeTargetPreview({
worktree.hostId ? getWorktreeHostIdentity(worktree) : worktree.id
)}
/>
<DeleteWorktreeLocalFilesHint
files={atRiskLocalFilesByIdentity?.get(getWorktreeHostIdentity(worktree))}
/>
</div>
)
}
@@ -0,0 +1,81 @@
import { useEffect, useEffectEvent, useState } from 'react'
import { useAppStore } from '@/store'
import { getRuntimeWorktreeAtRiskLocalFiles } from '@/runtime/source-control/git/runtime-git-status-client'
import { RuntimeRpcCallError } from '@/runtime/rpc/calls/runtime-rpc-result'
import type { Repo } from '../../../../../../shared/repo-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
import {
hasWorktreeAtRiskLocalFiles,
type WorktreeAtRiskLocalFiles
} from '../../../../../../shared/worktree/at-risk-local-files'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import { isFolderWorkspaceDelete } from './delete-worktree-dialog-copy'
import { resolveDeleteTargetGitContext } from './use-delete-worktree-status-hydration'
/** Ignored files each delete target would lose, by host identity; hosts that can't tell stay absent. */
export function useDeleteWorktreeAtRiskLocalFiles({
isOpen,
deleteTargets,
repoMap
}: {
isOpen: boolean
deleteTargets: readonly Worktree[]
repoMap: ReadonlyMap<string, Repo>
}): ReadonlyMap<string, WorktreeAtRiskLocalFiles> {
const repos = useAppStore((state) => state.repos)
const generation = isOpen ? deleteTargets.map(getWorktreeHostIdentity).join('\n') : ''
const [result, setResult] = useState<{
generation: string
files: Map<string, WorktreeAtRiskLocalFiles>
}>({ generation: '', files: new Map() })
const startScans = useEffectEvent((signal: AbortSignal) => {
for (const target of deleteTargets) {
if (target.isMainWorktree || isFolderWorkspaceDelete(repoMap, target)) {
continue
}
const gitContext = resolveDeleteTargetGitContext(target, repos)
if (!gitContext) {
continue
}
const identity = getWorktreeHostIdentity(target)
const publish = (files: WorktreeAtRiskLocalFiles): void =>
setResult((current) => ({
generation,
files: new Map(current.generation === generation ? current.files : []).set(
identity,
files
)
}))
void getRuntimeWorktreeAtRiskLocalFiles(gitContext, signal)
.then((files) => {
if (!signal.aborted && hasWorktreeAtRiskLocalFiles(files)) {
publish(files)
}
})
.catch((error: unknown) => {
// Older hosts lack the method and keep today's dialog; any other failure is an unchecked result.
const unsupported =
error instanceof RuntimeRpcCallError && error.code === 'method_not_found'
if (!signal.aborted && !unsupported) {
publish({ count: 0, samplePaths: [], complete: false })
}
})
}
})
// Store updates re-render the dialog with new array identities; each scan walks files, so run once per target set.
useEffect(() => {
if (!isOpen) {
return
}
const controller = new AbortController()
startScans(controller.signal)
return () => {
controller.abort()
}
}, [generation, isOpen])
return result.generation === generation ? result.files : EMPTY_FILES
}
const EMPTY_FILES = new Map<string, WorktreeAtRiskLocalFiles>()
@@ -4,6 +4,7 @@ import { getConnectionId } from '@/lib/workspace/ownership/connection-context'
import { runtimeTargetForWorkspaceOwner } from '@/lib/workspace/ownership/resolve-owner'
import { ipcOrRuntimeTargetForHostId } from '@/runtime/rpc/targets/runtime-client-target'
import { getRuntimeGitStatus } from '@/runtime/source-control/git/runtime-git-status-client'
import type { RuntimeGitContext } from '@/runtime/source-control/git/runtime-git-client-context'
import { findRepoForHost } from '@/store/repos/repo-host-identity'
import type { Repo } from '../../../../../../shared/repo-types'
import type { Worktree } from '../../../../../../shared/worktree/types'
@@ -12,6 +13,30 @@ import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-
import { isFolderWorkspaceDelete } from './delete-worktree-dialog-copy'
import { orderDeleteWorktreeStatusHydrationTargets } from './delete-worktree-dirty-change-counts'
/** Routes a delete target's git reads to its owning host; null when no host can serve it. */
export function resolveDeleteTargetGitContext(
target: Worktree,
repos: readonly Repo[]
): RuntimeGitContext | null {
const owner = target.hostId
? findRepoForHost(repos, target.repoId, { hostId: target.hostId })
: undefined
const ownerTarget = target.hostId
? ipcOrRuntimeTargetForHostId(target.hostId)
: runtimeTargetForWorkspaceOwner(useAppStore.getState(), { workspaceId: target.id })
if (!ownerTarget) {
return null
}
return {
target: ownerTarget,
worktreeId: target.id,
worktreePath: target.path,
connectionId: target.hostId
? (owner?.connectionId ?? undefined)
: (getConnectionId(target.id) ?? undefined)
}
}
const EMPTY_STATUS_BY_IDENTITY = new Map<string, GitStatusResult['entries'] | null>()
export function useDeleteWorktreeStatusHydration({
@@ -58,27 +83,16 @@ export function useDeleteWorktreeStatusHydration({
setStatusByIdentity((current) => new Map(current).set(identity, existingStatus))
continue
}
const owner = target.hostId
? findRepoForHost(repos, target.repoId, { hostId: target.hostId })
: undefined
const ownerTarget = target.hostId
? ipcOrRuntimeTargetForHostId(target.hostId)
: runtimeTargetForWorkspaceOwner(currentState, { workspaceId: target.id })
if (!ownerTarget) {
const gitContext = resolveDeleteTargetGitContext(target, repos)
if (!gitContext) {
setStatusByIdentity((current) => new Map(current).set(identity, null))
continue
}
void getRuntimeGitStatus(
{
target: ownerTarget,
worktreeId: target.id,
worktreePath: target.path,
connectionId: target.hostId
? (owner?.connectionId ?? undefined)
: (getConnectionId(target.id) ?? undefined)
},
{ admissionTier: 'background', includeLineStats: false, signal: controller.signal }
)
void getRuntimeGitStatus(gitContext, {
admissionTier: 'background',
includeLineStats: false,
signal: controller.signal
})
.then((status) => {
if (!controller.signal.aborted && generationRef.current === generation) {
setStatusByIdentity((current) => new Map(current).set(identity, status.entries))
@@ -2,6 +2,8 @@ import { translate } from '@/i18n/i18n'
import type { Translate } from '@/i18n/use-translate'
import type { WorkspaceCleanupBlocker } from '../../../../shared/workspace-cleanup/workspace-cleanup'
import type { WorkspaceCleanupGitState } from '../../../../shared/workspace-cleanup/workspace-cleanup-filter-model'
import type { WorktreeAtRiskLocalFiles } from '../../../../shared/worktree/at-risk-local-files'
import { formatAtRiskLocalFileNames } from '@/lib/workspace/display/at-risk-local-files-label'
type ContextDetailKind = 'terminal' | 'editor' | 'browser' | 'diff' | 'agent'
@@ -64,6 +66,11 @@ export function getWorkspaceCleanupBlockerLabel(blocker: WorkspaceCleanupBlocker
'auto.components.workspace.cleanup.candidateRow.dirtyFilesBlocker',
'Changed files'
)
case 'ignored-files':
return translate(
'components.workspace.cleanup.candidateRow.ignoredFilesBlocker',
'Ignored local files'
)
case 'unpushed-commits':
return getUnpushedCommitsLabel()
case 'unknown-base':
@@ -76,6 +83,27 @@ export function getWorkspaceCleanupBlockerLabel(blocker: WorkspaceCleanupBlocker
}
}
export function formatWorkspaceCleanupIgnoredFilesLabel(
files: WorktreeAtRiskLocalFiles | null | undefined,
t: Translate = translate
): string {
const names = files ? formatAtRiskLocalFileNames(files, t) : null
if (names) {
return t(
'components.workspace.cleanup.candidateRow.ignoredFilesNamed',
'Ignored files: {{value0}}',
{ value0: names }
)
}
if (files && !files.complete) {
return t(
'components.workspace.cleanup.candidateRow.ignoredFilesUnchecked',
'Ignored files not fully checked'
)
}
return getWorkspaceCleanupBlockerLabel('ignored-files')
}
export function formatWorkspaceCleanupGitStatusLabel(
state: WorkspaceCleanupGitState,
t: Translate = translate
@@ -14,7 +14,8 @@ import {
getNoUnpushedCommitsLabel,
getUncommittedChangesLabel,
getUnpushedCommitsLabel,
getWorkspaceCleanupBlockerLabel
getWorkspaceCleanupBlockerLabel,
formatWorkspaceCleanupIgnoredFilesLabel
} from './workspace-cleanup-candidate-labels'
export type StatusPillTone = 'neutral' | 'ready' | 'review' | 'destructive'
@@ -36,7 +37,10 @@ export function getCandidateFactStatuses(
): CandidateFactStatus[] {
const landed = getLandedFactStatus(candidate, t)
if (candidate.blockers.length > 0) {
return [...candidate.blockers.map(getBlockerFactStatus), ...(landed ? [landed] : [])]
return [
...candidate.blockers.map((blocker) => getBlockerFactStatus(candidate, blocker, t)),
...(landed ? [landed] : [])
]
}
if (landed) {
return [landed]
@@ -103,24 +107,28 @@ function getLandedFactStatus(
const GIT_SAFETY_BLOCKERS: ReadonlySet<WorkspaceCleanupBlocker> = new Set([
'dirty-files',
'ignored-files',
'unpushed-commits',
'unknown-base',
'git-status-error'
])
function getBlockerFactStatus(blocker: WorkspaceCleanupBlocker): CandidateFactStatus {
function getBlockerFactStatus(
candidate: WorkspaceCleanupCandidate,
blocker: WorkspaceCleanupBlocker,
t: Translate
): CandidateFactStatus {
return {
label:
blocker === 'dismissed'
? translate(
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7',
'Ignored'
)
: getWorkspaceCleanupBlockerLabel(blocker),
? t('auto.components.workspace.cleanup.WorkspaceCleanupDialog.e8b3741ff7', 'Ignored')
: blocker === 'ignored-files'
? formatWorkspaceCleanupIgnoredFilesLabel(candidate.git.atRiskLocalFiles, t)
: getWorkspaceCleanupBlockerLabel(blocker),
tone:
blocker === 'git-status-error' || blocker === 'unknown-base'
? 'destructive'
: blocker === 'dirty-files' || blocker === 'unpushed-commits'
: blocker === 'dirty-files' || blocker === 'ignored-files' || blocker === 'unpushed-commits'
? 'review'
: 'neutral',
git: GIT_SAFETY_BLOCKERS.has(blocker)
@@ -8,7 +8,7 @@ import { cn } from '@/lib/class-names'
import { translate } from '@/i18n/i18n'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import {
shouldForceWorkspaceCleanupRemoval,
needsWorkspaceCleanupRemovalApproval,
type WorkspaceCleanupCandidate
} from '../../../../shared/workspace-cleanup/workspace-cleanup'
import type { WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal'
@@ -67,7 +67,7 @@ export function WorkspaceCleanupConfirmRemove({
const t = useTranslate()
const [scrollElement, setScrollElement] = React.useState<HTMLDivElement | null>(null)
const count = candidates.length
const riskCount = candidates.filter(shouldForceWorkspaceCleanupRemoval).length
const riskCount = candidates.filter(needsWorkspaceCleanupRemovalApproval).length
const deleting = progress !== null
const progressValue = progress
? Math.min(100, Math.max(0, (progress.processedCount / progress.totalCount) * 100))
@@ -20,6 +20,7 @@ const GIT_SORT_FIELDS: ReadonlySet<WorkspaceCleanupSortField> = new Set(['git',
const GIT_DERIVED_BLOCKERS: ReadonlySet<WorkspaceCleanupBlocker> = new Set([
'dirty-files',
'ignored-files',
'unpushed-commits',
'git-status-error',
'unknown-base'
+11 -2
View File
@@ -21749,7 +21749,10 @@
"dirty": "Dirty",
"unpushed": "Unpushed",
"unknown": "Unknown"
}
},
"ignoredFilesBlocker": "Ignored local files",
"ignoredFilesNamed": "Ignored files: {{value0}}",
"ignoredFilesUnchecked": "Ignored files not fully checked"
},
"confirm": {
"deletingTitle_one": "Deleting {{count}} workspace",
@@ -21783,7 +21786,13 @@
"closedToast_other": "Closed {{count}} files with unsaved edits from {{workspace}}",
"reopen": "Reopen",
"reopenFailed": "Some files couldn't be reopened. Open a workspace and try Reopen again."
}
},
"ignoredFilesLost": "Ignored files will be lost: {{names}}",
"ignoredFilesUnchecked": "Ignored files could not be fully checked"
},
"atRiskLocalFiles": {
"namesWithCount": "{{names}} +{{value0}}",
"namesAndMore": "{{names}} and more"
}
},
"agentSessionContinuation": {
@@ -0,0 +1,24 @@
import { translate } from '@/i18n/i18n'
import type { Translate } from '@/i18n/use-translate'
import type { WorktreeAtRiskLocalFiles } from '../../../../../shared/worktree/at-risk-local-files'
/** ".env, local/ +2", or null when the check found no names to show. */
export function formatAtRiskLocalFileNames(
files: WorktreeAtRiskLocalFiles,
t: Translate = translate
): string | null {
if (files.samplePaths.length === 0) {
return null
}
const names = files.samplePaths.join(', ')
if (!files.complete) {
return t('components.workspace.atRiskLocalFiles.namesAndMore', '{{names}} and more', { names })
}
const remaining = files.count - files.samplePaths.length
return remaining > 0
? t('components.workspace.atRiskLocalFiles.namesWithCount', '{{names}} +{{value0}}', {
names,
value0: remaining
})
: names
}
@@ -9,6 +9,7 @@ import type {
GitStagingArea,
GitStatusResult
} from '../../../../../shared/git/status/git-status-types'
import type { WorktreeAtRiskLocalFiles } from '../../../../../shared/worktree/at-risk-local-files'
import { resolveLocalWorktreePath, type RuntimeGitContext } from './runtime-git-client-context'
import { callRuntimeRpc } from '../../rpc/calls/runtime-rpc-call'
import { toRuntimeWorktreeSelector } from '../../rpc/targets/runtime-worktree-selector'
@@ -173,6 +174,24 @@ export async function getRuntimeGitIgnoredPaths(
)
}
export async function getRuntimeWorktreeAtRiskLocalFiles(
context: RuntimeGitContext,
signal?: AbortSignal
): Promise<WorktreeAtRiskLocalFiles> {
if (context.target.kind === 'local' || !context.worktreeId) {
return window.api.git.atRiskLocalFiles({
worktreePath: resolveLocalWorktreePath(context),
connectionId: context.connectionId
})
}
return callRuntimeRpc<WorktreeAtRiskLocalFiles>(
context.target,
'git.atRiskLocalFiles',
{ worktree: toRuntimeWorktreeSelector(context.worktreeId) },
{ timeoutMs: 15_000, ...(signal ? { signal } : {}) }
)
}
export async function getRuntimeGitRefs(
context: RuntimeGitContext,
query?: string
@@ -119,4 +119,8 @@ export function getWorkspaceCleanupGitUnavailableFailure(
}
// Unlike unknown-base and git-status-error, these facts prove known work is at risk.
const WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS = ['dirty-files', 'unpushed-commits'] as const
const WORKSPACE_CLEANUP_CONCRETE_RISK_BLOCKERS = [
'dirty-files',
'ignored-files',
'unpushed-commits'
] as const
@@ -3,7 +3,7 @@ import type { AppState } from '../../types'
import type { ExecutionHostId } from '../../../../../shared/execution-host'
import {
canQueueWorkspaceCleanupCandidate,
shouldForceWorkspaceCleanupRemoval,
needsWorkspaceCleanupRemovalApproval,
WORKSPACE_CLEANUP_TARGET_BATCH_LIMIT,
type WorkspaceCleanupCandidate,
type WorkspaceCleanupScanError,
@@ -282,7 +282,7 @@ export function evaluateWorkspaceCleanupPreflight(
failure: getWorkspaceCleanupGitUnavailableFailure(target, candidate)
}
}
if (!target.approvedCandidate && shouldForceWorkspaceCleanupRemoval(candidate)) {
if (!target.approvedCandidate && needsWorkspaceCleanupRemovalApproval(candidate)) {
return failure(
translate(
'auto.store.slices.workspace.cleanup.forceNeedsApproval',
@@ -88,6 +88,12 @@ export function createGitApi(): NonNullable<Partial<PreloadApi>['git']> {
paths
})
},
atRiskLocalFiles: async ({ worktreePath }) => {
const worktree = await resolveRuntimeWorktreeByPath(worktreePath)
return callRuntimeResult('git.atRiskLocalFiles', {
worktree: toRuntimeWorktreeSelector(worktree.id)
})
},
// Why: the "add huge folder to .gitignore" flow is desktop-only; the web runtime makes no offer, so return no candidates.
findHugeFoldersToIgnore: async () => [],
appendGitignore: async () => false,
+1
View File
@@ -1106,6 +1106,7 @@ export const RPC_PARAMS_BY_METHOD = {
'folderWorkspace.update': FolderWorkspaceUpdate,
'git.abortMerge': WorktreeSelectorOfGitParams,
'git.abortRebase': WorktreeSelectorOfGitParams,
'git.atRiskLocalFiles': WorktreeSelectorOfGitParams,
'git.branchCompare': GitBranchCompare,
'git.branchDiff': GitBranchDiff,
'git.bulkDiscard': GitBulkPaths,
@@ -26,6 +26,7 @@ const BLOCKER_SEVERITY: Record<WorkspaceCleanupBlocker, number> = {
'ssh-disconnected': 50,
'unpushed-commits': 45,
'dirty-files': 40,
'ignored-files': 38,
'unknown-base': 35,
'git-status-error': 30,
dismissed: 10
@@ -3,6 +3,7 @@ import type { WorkspaceCleanupBrowseState } from './workspace-cleanup-browse-sta
import type { ExecutionHostId } from '../execution-host'
import type { HostedReviewProvider } from '../source-control/hosted-review/hosted-review-contracts'
import { getWorkspaceCleanupCandidateHostId } from './workspace-cleanup-host-identity'
import type { WorktreeAtRiskLocalFiles } from '../worktree/at-risk-local-files'
export const WORKSPACE_CLEANUP_CLASSIFIER_VERSION = 2
export const WORKSPACE_CLEANUP_ARCHIVED_IDLE_MS = 7 * 24 * 60 * 60 * 1000
@@ -42,6 +43,7 @@ export type WorkspaceCleanupBlocker =
| 'ssh-disconnected'
| 'git-status-error'
| 'dirty-files'
| 'ignored-files'
| 'unpushed-commits'
| 'unknown-base'
| 'dismissed'
@@ -89,6 +91,8 @@ export type WorkspaceCleanupCandidate = {
checkedAt: number | null
/** Absent when Git was not read or the base ref could not be resolved. */
merge?: WorkspaceCleanupMergeEvidence
/** Absent from older hosts, which never checked ignored files. */
atRiskLocalFiles?: WorktreeAtRiskLocalFiles | null
}
fingerprint: string
}
@@ -182,6 +186,15 @@ export function shouldForceWorkspaceCleanupRemoval(
)
}
/** Rows the user must review before removal; ignored files never make git refuse, so they need no force. */
export function needsWorkspaceCleanupRemovalApproval(
candidate: Pick<WorkspaceCleanupCandidate, 'blockers' | 'git'>
): boolean {
return (
shouldForceWorkspaceCleanupRemoval(candidate) || candidate.blockers.includes('ignored-files')
)
}
export function createWorkspaceCleanupFingerprint(args: {
branch: string
head: string
@@ -0,0 +1,13 @@
/** Git-ignored files `git worktree remove` would delete that exist nowhere else (.env, local config). */
export type WorktreeAtRiskLocalFiles = {
/** Ignored files or whole ignored directories holding content the main checkout lacks. */
count: number
/** A few notable worktree-relative paths, `.env*` first; directories end in `/`. */
samplePaths: string[]
/** False when a time or size cap or a failure cut the check short, so more files may be at risk. */
complete: boolean
}
export function hasWorktreeAtRiskLocalFiles(files: WorktreeAtRiskLocalFiles): boolean {
return files.count > 0 || !files.complete
}