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 +}