From 0b516b8a9dca92d8252767679feedfa0ce755c5c Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:23:46 +0000 Subject: [PATCH] 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 --- ...ktree-at-risk-local-files-real-git.test.ts | 87 +++++ .../removal/worktree-at-risk-local-files.ts | 328 ++++++++++++++++++ .../filesystem-git-status-handlers.ts | 25 ++ .../workspace-cleanup-candidate.ts | 3 +- .../workspace-cleanup-git-evidence.ts | 32 ++ .../integrations/git/orca-runtime-git.ts | 3 + .../git/runtime-git-command-surface.ts | 3 + .../git/runtime-git-status-commands.ts | 15 + .../source-control/git/git-status-methods.ts | 7 + src/preload/api/git-bridge.ts | 5 + src/preload/api/git-inspection-api.ts | 5 + .../deletion/DeleteWorktreeDialog.tsx | 8 + .../deletion/DeleteWorktreeLineageNotice.tsx | 31 +- .../deletion/DeleteWorktreeLocalFilesHint.tsx | 31 ++ .../deletion/DeleteWorktreeTargetPreview.tsx | 12 +- ...use-delete-worktree-at-risk-local-files.ts | 81 +++++ .../use-delete-worktree-status-hydration.ts | 50 ++- .../workspace-cleanup-candidate-labels.ts | 28 ++ .../workspace-cleanup-candidate-row-data.ts | 26 +- .../workspace-cleanup-confirm-remove.tsx | 4 +- .../workspace-cleanup-git-evidence.ts | 1 + src/renderer/src/i18n/locales/en.json | 13 +- .../display/at-risk-local-files-label.ts | 24 ++ .../git/runtime-git-status-client.ts | 19 + .../workspace-cleanup-preflight-failures.ts | 6 +- .../workspace-cleanup-removal-targets.ts | 4 +- .../src/web/preload-api/web-git-api.ts | 6 + .../rpc-params-catalog.generated.ts | 1 + .../workspace-cleanup-facet-rankings.ts | 1 + .../workspace-cleanup/workspace-cleanup.ts | 13 + src/shared/worktree/at-risk-local-files.ts | 13 + 31 files changed, 838 insertions(+), 47 deletions(-) create mode 100644 src/main/git/worktrees/removal/worktree-at-risk-local-files-real-git.test.ts create mode 100644 src/main/git/worktrees/removal/worktree-at-risk-local-files.ts create mode 100644 src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeLocalFilesHint.tsx create mode 100644 src/renderer/src/components/sidebar/workspace-actions/deletion/use-delete-worktree-at-risk-local-files.ts create mode 100644 src/renderer/src/lib/workspace/display/at-risk-local-files-label.ts create mode 100644 src/shared/worktree/at-risk-local-files.ts diff --git a/src/main/git/worktrees/removal/worktree-at-risk-local-files-real-git.test.ts b/src/main/git/worktrees/removal/worktree-at-risk-local-files-real-git.test.ts new file mode 100644 index 00000000000..b9d428a03dd --- /dev/null +++ b/src/main/git/worktrees/removal/worktree-at-risk-local-files-real-git.test.ts @@ -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 }) + }) +}) diff --git a/src/main/git/worktrees/removal/worktree-at-risk-local-files.ts b/src/main/git/worktrees/removal/worktree-at-risk-local-files.ts new file mode 100644 index 00000000000..c3afad70979 --- /dev/null +++ b/src/main/git/worktrees/removal/worktree-at-risk-local-files.ts @@ -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 = 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 = 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 { + 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 { + 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 { + 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 { + 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) +} diff --git a/src/main/ipc/filesystem/filesystem-git-status-handlers.ts b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts index 994063a9db8..dc2199f2d70 100644 --- a/src/main/ipc/filesystem/filesystem-git-status-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-git-status-handlers.ts @@ -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 => { + 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 => diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup-candidate.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup-candidate.ts index 7029c442dc1..3c4f006f471 100644 --- a/src/main/ipc/workspace-cleanup/workspace-cleanup-candidate.ts +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup-candidate.ts @@ -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: '' } diff --git a/src/main/ipc/workspace-cleanup/workspace-cleanup-git-evidence.ts b/src/main/ipc/workspace-cleanup/workspace-cleanup-git-evidence.ts index 52ea3035b8a..0c786bcab1e 100644 --- a/src/main/ipc/workspace-cleanup/workspace-cleanup-git-evidence.ts +++ b/src/main/ipc/workspace-cleanup/workspace-cleanup-git-evidence.ts @@ -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 { + 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 diff --git a/src/main/runtime/integrations/git/orca-runtime-git.ts b/src/main/runtime/integrations/git/orca-runtime-git.ts index 51576ad17d0..ca6cdcdc8eb 100644 --- a/src/main/runtime/integrations/git/orca-runtime-git.ts +++ b/src/main/runtime/integrations/git/orca-runtime-git.ts @@ -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) diff --git a/src/main/runtime/integrations/git/runtime-git-command-surface.ts b/src/main/runtime/integrations/git/runtime-git-command-surface.ts index f0d8a68568b..0d88773cbd8 100644 --- a/src/main/runtime/integrations/git/runtime-git-command-surface.ts +++ b/src/main/runtime/integrations/git/runtime-git-command-surface.ts @@ -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), diff --git a/src/main/runtime/integrations/git/runtime-git-status-commands.ts b/src/main/runtime/integrations/git/runtime-git-status-commands.ts index cac22baf689..22ece336c80 100644 --- a/src/main/runtime/integrations/git/runtime-git-status-commands.ts +++ b/src/main/runtime/integrations/git/runtime-git-status-commands.ts @@ -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 { + 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, diff --git a/src/main/runtime/rpc/methods/source-control/git/git-status-methods.ts b/src/main/runtime/rpc/methods/source-control/git/git-status-methods.ts index d1bd1df21d6..c8727ba7da7 100644 --- a/src/main/runtime/rpc/methods/source-control/git/git-status-methods.ts +++ b/src/main/runtime/rpc/methods/source-control/git/git-status-methods.ts @@ -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', diff --git a/src/preload/api/git-bridge.ts b/src/preload/api/git-bridge.ts index 58607ebc9e1..e2d8ed47d92 100644 --- a/src/preload/api/git-bridge.ts +++ b/src/preload/api/git-bridge.ts @@ -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 => ipcRenderer.invoke('git:checkIgnored', args), + atRiskLocalFiles: (args: { + worktreePath: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:atRiskLocalFiles', args), findHugeFoldersToIgnore: (args: { worktreePath: string }): Promise => ipcRenderer.invoke('git:findHugeFoldersToIgnore', args), appendGitignore: (args: { worktreePath: string; folderName: string }): Promise => diff --git a/src/preload/api/git-inspection-api.ts b/src/preload/api/git-inspection-api.ts index 7fa8d767a28..939cc7a9a88 100644 --- a/src/preload/api/git-inspection-api.ts +++ b/src/preload/api/git-inspection-api.ts @@ -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 + atRiskLocalFiles: (args: { + worktreePath: string + connectionId?: string + }) => Promise findHugeFoldersToIgnore: (args: { worktreePath: string }) => Promise refs: (args: { worktreePath: string diff --git a/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeDialog.tsx index b3cb6097272..12ae8afa370 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeDialog.tsx @@ -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} /> @@ -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} diff --git a/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeLineageNotice.tsx b/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeLineageNotice.tsx index eaae20644d4..554b1beb274 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeLineageNotice.tsx +++ b/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeLineageNotice.tsx @@ -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 dirtyChangePreviewsByWorktreeId: ReadonlyMap changeCheckStatesByWorktreeId?: ReadonlyMap + atRiskLocalFilesByIdentity?: ReadonlyMap 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({
{child.path}
{!preserveChildren && ( - + <> + + + )} ))} diff --git a/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeLocalFilesHint.tsx b/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeLocalFilesHint.tsx new file mode 100644 index 00000000000..069d6c6cc29 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeLocalFilesHint.tsx @@ -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 ( +
+ + {label} +
+ ) +} diff --git a/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeTargetPreview.tsx b/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeTargetPreview.tsx index ac505efcac9..bdea98788ab 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeTargetPreview.tsx +++ b/src/renderer/src/components/sidebar/workspace-actions/deletion/DeleteWorktreeTargetPreview.tsx @@ -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 dirtyChangePreviewsByWorktreeId: ReadonlyMap changeCheckStatesByWorktreeId?: ReadonlyMap + atRiskLocalFilesByIdentity?: ReadonlyMap }): JSX.Element | null { const t = useTranslate() const targetIdPrefix = useId() @@ -112,6 +116,9 @@ export function DeleteWorktreeTargetPreview({ item.hostId ? getWorktreeHostIdentity(item) : item.id )} /> + {itemDeleteError ? (
{itemDeleteError} @@ -168,6 +175,9 @@ export function DeleteWorktreeTargetPreview({ worktree.hostId ? getWorktreeHostIdentity(worktree) : worktree.id )} /> +
) } diff --git a/src/renderer/src/components/sidebar/workspace-actions/deletion/use-delete-worktree-at-risk-local-files.ts b/src/renderer/src/components/sidebar/workspace-actions/deletion/use-delete-worktree-at-risk-local-files.ts new file mode 100644 index 00000000000..dffe9badd25 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-actions/deletion/use-delete-worktree-at-risk-local-files.ts @@ -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 +}): ReadonlyMap { + const repos = useAppStore((state) => state.repos) + const generation = isOpen ? deleteTargets.map(getWorktreeHostIdentity).join('\n') : '' + const [result, setResult] = useState<{ + generation: string + files: Map + }>({ 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() diff --git a/src/renderer/src/components/sidebar/workspace-actions/deletion/use-delete-worktree-status-hydration.ts b/src/renderer/src/components/sidebar/workspace-actions/deletion/use-delete-worktree-status-hydration.ts index 4e17cab05c1..278f9e55013 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/deletion/use-delete-worktree-status-hydration.ts +++ b/src/renderer/src/components/sidebar/workspace-actions/deletion/use-delete-worktree-status-hydration.ts @@ -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() 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)) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts index 5e38d71185c..cda2061e688 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-labels.ts @@ -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 diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts index dbe52220953..a28ff7532bd 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-candidate-row-data.ts @@ -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 = 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) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx index 9c25dd941ec..2e87a8408d7 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-confirm-remove.tsx @@ -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(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)) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts index 8abeffee720..71e497f865e 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-git-evidence.ts @@ -20,6 +20,7 @@ const GIT_SORT_FIELDS: ReadonlySet = new Set(['git', const GIT_DERIVED_BLOCKERS: ReadonlySet = new Set([ 'dirty-files', + 'ignored-files', 'unpushed-commits', 'git-status-error', 'unknown-base' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 388b28d671d..946a70663c4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/lib/workspace/display/at-risk-local-files-label.ts b/src/renderer/src/lib/workspace/display/at-risk-local-files-label.ts new file mode 100644 index 00000000000..cfe72f1f0ce --- /dev/null +++ b/src/renderer/src/lib/workspace/display/at-risk-local-files-label.ts @@ -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 +} diff --git a/src/renderer/src/runtime/source-control/git/runtime-git-status-client.ts b/src/renderer/src/runtime/source-control/git/runtime-git-status-client.ts index 00fec77b85d..0f1fa5e4cad 100644 --- a/src/renderer/src/runtime/source-control/git/runtime-git-status-client.ts +++ b/src/renderer/src/runtime/source-control/git/runtime-git-status-client.ts @@ -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 { + if (context.target.kind === 'local' || !context.worktreeId) { + return window.api.git.atRiskLocalFiles({ + worktreePath: resolveLocalWorktreePath(context), + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + context.target, + 'git.atRiskLocalFiles', + { worktree: toRuntimeWorktreeSelector(context.worktreeId) }, + { timeoutMs: 15_000, ...(signal ? { signal } : {}) } + ) +} + export async function getRuntimeGitRefs( context: RuntimeGitContext, query?: string diff --git a/src/renderer/src/store/slices/workspace-cleanup/workspace-cleanup-preflight-failures.ts b/src/renderer/src/store/slices/workspace-cleanup/workspace-cleanup-preflight-failures.ts index ef8d7a4e3fc..c9dc788fd27 100644 --- a/src/renderer/src/store/slices/workspace-cleanup/workspace-cleanup-preflight-failures.ts +++ b/src/renderer/src/store/slices/workspace-cleanup/workspace-cleanup-preflight-failures.ts @@ -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 diff --git a/src/renderer/src/store/slices/workspace-cleanup/workspace-cleanup-removal-targets.ts b/src/renderer/src/store/slices/workspace-cleanup/workspace-cleanup-removal-targets.ts index f7d9fddf53a..6bac9e2dece 100644 --- a/src/renderer/src/store/slices/workspace-cleanup/workspace-cleanup-removal-targets.ts +++ b/src/renderer/src/store/slices/workspace-cleanup/workspace-cleanup-removal-targets.ts @@ -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', diff --git a/src/renderer/src/web/preload-api/web-git-api.ts b/src/renderer/src/web/preload-api/web-git-api.ts index 84ae7eefd54..7e9a5b5c5e6 100644 --- a/src/renderer/src/web/preload-api/web-git-api.ts +++ b/src/renderer/src/web/preload-api/web-git-api.ts @@ -88,6 +88,12 @@ export function createGitApi(): NonNullable['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, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 7030dc2f6a4..f7cbf1b55ce 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -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, diff --git a/src/shared/workspace-cleanup/workspace-cleanup-facet-rankings.ts b/src/shared/workspace-cleanup/workspace-cleanup-facet-rankings.ts index b87242d0e99..8f721651f71 100644 --- a/src/shared/workspace-cleanup/workspace-cleanup-facet-rankings.ts +++ b/src/shared/workspace-cleanup/workspace-cleanup-facet-rankings.ts @@ -26,6 +26,7 @@ const BLOCKER_SEVERITY: Record = { 'ssh-disconnected': 50, 'unpushed-commits': 45, 'dirty-files': 40, + 'ignored-files': 38, 'unknown-base': 35, 'git-status-error': 30, dismissed: 10 diff --git a/src/shared/workspace-cleanup/workspace-cleanup.ts b/src/shared/workspace-cleanup/workspace-cleanup.ts index 9d0846dd82b..8dd4a5dbf69 100644 --- a/src/shared/workspace-cleanup/workspace-cleanup.ts +++ b/src/shared/workspace-cleanup/workspace-cleanup.ts @@ -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 +): boolean { + return ( + shouldForceWorkspaceCleanupRemoval(candidate) || candidate.blockers.includes('ignored-files') + ) +} + export function createWorkspaceCleanupFingerprint(args: { branch: string head: string diff --git a/src/shared/worktree/at-risk-local-files.ts b/src/shared/worktree/at-risk-local-files.ts new file mode 100644 index 00000000000..d4c065f749a --- /dev/null +++ b/src/shared/worktree/at-risk-local-files.ts @@ -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 +}