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