diff --git a/docs/troubleshooting/WINDOWS_APP_HANG.md b/docs/troubleshooting/WINDOWS_APP_HANG.md new file mode 100644 index 000000000..4e7ab95fb --- /dev/null +++ b/docs/troubleshooting/WINDOWS_APP_HANG.md @@ -0,0 +1,21 @@ +# Windows freezes on startup or window focus + +A September 3, 2026 report described the latest version as "lagging" and "freezing all the time." The reported investigation found a 250 MB `sessions.db`, approximately 200 MB of terminal history across 92 stopped sessions, an accidental Git repository at the Windows user profile root, and the isolated PTY host disabled. Windows recorded `AppHangTransient`. + +## Prevention + +- Startup loads only logs and browser panels that need restart cleanup. Terminal state is restored on demand when accessed, including history for stopped and archived sessions. +- Workspace summaries exclude raw and serialized terminal buffers in SQLite before returning rows to JavaScript. Summary reads do not replace complete cached panel state or write reduced state back to disk. +- Automatic Git status checks and filesystem watching skip the home directory, log a diagnostic, and report unknown Git status. Windows path comparison ignores case and normalizes separators and dot segments. Choose the actual project folder instead of `C:\Users\`. Existing repository records and `.git` directories are not deleted. +- New configurations no longer use the home directory as an implicit Git repository. Legacy home-directory `gitRepoPath` values are rejected by the migration accessor. +- The isolated PTY host defaults to enabled on Windows, including existing configurations without a `usePtyHost` value. Explicit `false` values are preserved. Enable **Settings > Advanced > Use isolated PTY host** and restart Pane to change an existing opt-out. `PANE_USE_PTY_HOST=1` still forces it on for development. + +Terminal history remains on disk. The existing 21-day retention policy for archived sessions is unchanged, and no full `VACUUM` runs during startup. A large database file does not by itself mean all history is resident in the JavaScript heap. + +## Manual Windows verification + +1. Use an isolated `PANE_DIR` with a copy of a database containing many stopped and archived terminal panels. Launch Pane, inspect responsiveness and heap use, then reopen an old terminal and verify its history. +2. In an isolated test profile with a home-directory Git repository, select a session whose worktree path is that home directory. Repeatedly blur/focus Pane. Confirm no recursive watcher or Git status scan starts, and check the diagnostic in the main-process log. Verify a normal project beneath the profile still refreshes. +3. Start with a config missing `usePtyHost`: confirm the settings toggle is enabled and the supervisor forks on Windows. Repeat with explicit `false`, then enable the setting and restart. + +The automated tests cover persistence, summary projections, path normalization, Git scan entry points, and platform defaults. They do not reproduce Windows `AppHangTransient` or validate a packaged Windows PTY host. diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index ec5651349..347a7b1e3 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -129,7 +129,7 @@ export interface AppConfig { // Use interactive mode for Claude CLI (persistent process with stdin instead of spawn-per-message) useInteractiveMode?: boolean; // Route PTY spawns through an isolated ptyHost UtilityProcess for crash - // isolation. Off by default. Requires app restart; the supervisor is forked + // isolation. On by default on Windows. Requires app restart; the supervisor is forked // once at `app.whenReady`. usePtyHost?: boolean; // PostHog analytics settings diff --git a/main/src/database/database.panel-loading.test.ts b/main/src/database/database.panel-loading.test.ts new file mode 100644 index 000000000..5fb70671b --- /dev/null +++ b/main/src/database/database.panel-loading.test.ts @@ -0,0 +1,51 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { DatabaseService } from './database'; + +describe('panel history loading', () => { + it('keeps terminal history out of startup and workspace summaries while preserving restoration', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pane-panel-loading-')); + const db = new DatabaseService(path.join(tempDir, 'sessions.db')); + try { + db.initialize(); + const history = 'terminal output\r\n'.repeat(65536); + for (let index = 0; index < 12; index++) { + const id = `session-${index}`; + db.createSession({ + id, name: id, initial_prompt: '', worktree_name: id, + worktree_path: tempDir, project_id: null, tool_type: 'none', + }); + db.markSessionsAsStopped([id]); + db.createPanel({ + id: `panel-${index}`, sessionId: id, type: 'terminal', title: 'Terminal', + state: { isActive: false, customState: { + scrollbackBuffer: history, serializedBuffer: history, + cwd: tempDir, isCliPanel: true, agentType: 'claude', + } }, + }); + if (index % 2 === 0) db.archiveSession(id); + } + db.createPanel({ id: 'logs', sessionId: 'session-1', type: 'logs', title: 'Logs' }); + db.createPanel({ id: 'browser', sessionId: 'session-1', type: 'browser', title: 'Browser' }); + + expect(db.getPanelsForStartup().map(panel => panel.id).sort()).toEqual(['browser', 'logs']); + for (let index = 0; index < 12; index++) { + const summary = db.getPanelsForSession(`session-${index}`, false)[0]; + expect(summary.state.customState).toEqual({ cwd: tempDir, isCliPanel: true, agentType: 'claude' }); + expect(db.getPanel(`panel-${index}`)?.state.customState).toMatchObject({ scrollbackBuffer: history }); + } + expect(db.getPanelsForSession('session-0')[0].state.customState).toMatchObject({ serializedBuffer: history }); + db.createPanel({ + id: 'legacy', sessionId: 'session-0', type: 'terminal', title: 'Legacy', + state: JSON.stringify({ isActive: false, customState: { scrollbackBuffer: [history], cwd: tempDir } }), + }); + expect(db.getPanelsForSession('session-0', false).find(panel => panel.id === 'legacy')?.state.customState) + .toEqual({ cwd: tempDir }); + } finally { + db.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/main/src/database/database.ts b/main/src/database/database.ts index 1d1c6a172..394284e5c 100644 --- a/main/src/database/database.ts +++ b/main/src/database/database.ts @@ -4592,11 +4592,18 @@ export class DatabaseService { }; } - getPanelsForSession(sessionId: string): ToolPanel[] { + getPanelsForSession(sessionId: string, includeScrollback = true): ToolPanel[] { // SAFETY: This fixed SQLite query projection matches the declared row type at this database boundary. const rows = this.db .prepare( - "SELECT * FROM tool_panels WHERE session_id = ? ORDER BY created_at", + includeScrollback + ? "SELECT * FROM tool_panels WHERE session_id = ? ORDER BY created_at" + : `SELECT id, session_id, type, title, metadata, created_at, + json_remove( + CASE WHEN json_type(state) = 'text' THEN json_extract(state, '$') ELSE state END, + '$.customState.scrollbackBuffer', '$.customState.serializedBuffer' + ) AS state + FROM tool_panels WHERE session_id = ? ORDER BY created_at`, ) .all(sessionId) as ToolPanelRow[]; @@ -4636,10 +4643,12 @@ export class DatabaseService { }); } - getAllPanels(): ToolPanel[] { + // Only these panel types need restart cleanup. Terminal history stays on disk + // until its session is opened, including stopped and archived sessions. + getPanelsForStartup(): ToolPanel[] { // SAFETY: This fixed SQLite query projection matches the declared row type at this database boundary. const rows = this.db - .prepare("SELECT * FROM tool_panels ORDER BY created_at") + .prepare("SELECT * FROM tool_panels WHERE type IN ('logs', 'browser') ORDER BY created_at") .all() as ToolPanelRow[]; // SAFETY: Panel state and metadata JSON are written by the matching typed panel serializers. diff --git a/main/src/index.ts b/main/src/index.ts index 220077c4e..3a5a05208 100644 --- a/main/src/index.ts +++ b/main/src/index.ts @@ -228,7 +228,7 @@ let paneDaemonHost: PaneDaemonHost | null = null; let powerSaveManager: PowerSaveManager | null = null; // ptyHost supervisor — forked as an Electron UtilityProcess on app ready, -// but only when the `usePtyHost` setting is enabled (default: off). When +// but only when the `usePtyHost` setting is enabled (default: on for Windows). When // disabled, the supervisor is never forked and every manager transparently // falls through to the legacy in-main `pty.spawn` path. let ptyHostSupervisor: PtyHostSupervisor | null = null; @@ -1165,7 +1165,7 @@ if (launchRemoteSetup) { // Start the ptyHost supervisor before the window opens so the renderer's // preload listener for 'ptyHost-port' has a port to receive when the window - // finishes loading. Gated on the `usePtyHost` setting: when off (default), + // finishes loading. Gated on the `usePtyHost` setting: when off, // the supervisor is never forked and every spawn site falls through to the // legacy in-main `pty.spawn` path with zero ptyHost code executing. if (configManager.getUsePtyHost()) { diff --git a/main/src/services/__tests__/gitFileWatcher.test.ts b/main/src/services/__tests__/gitFileWatcher.test.ts index 63ab2e448..d141a63d1 100644 --- a/main/src/services/__tests__/gitFileWatcher.test.ts +++ b/main/src/services/__tests__/gitFileWatcher.test.ts @@ -1,3 +1,4 @@ +import os from 'os'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { GitFileWatcher } from '../gitFileWatcher'; import type { CommandRunner } from '../../utils/commandRunner'; @@ -61,6 +62,15 @@ describe('GitFileWatcher pure logic', () => { internals = watcherInternals(watcher); } + it('does not start a watcher or invoke Git for the home directory', () => { + const exec = vi.fn(() => ''); + build(exec); + watcher.startWatching('home', os.homedir()); + expect(internals.watchedSessions.size).toBe(0); + expect(exec).not.toHaveBeenCalled(); + expect(logger.warns[0]).toContain('home directory'); + }); + describe('isIgnoredEventPath', () => { const none = new Set(); diff --git a/main/src/services/__tests__/gitStatusManager.test.ts b/main/src/services/__tests__/gitStatusManager.test.ts index 6dfff7c5d..03c27c3aa 100644 --- a/main/src/services/__tests__/gitStatusManager.test.ts +++ b/main/src/services/__tests__/gitStatusManager.test.ts @@ -1,3 +1,4 @@ +import os from 'os'; import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; import { GitStatusManager } from '../gitStatusManager'; import type { fastCheckWorkingDirectory as fastCheckWorkingDirectoryImpl, fastGetAheadBehind as fastGetAheadBehindImpl, fastGetDiffStats as fastGetDiffStatsImpl } from '../gitPlumbingCommands'; @@ -5,7 +6,7 @@ import type { SessionManager } from '../sessionManager'; import type { WorktreeManager } from '../worktreeManager'; import type { GitDiffManager } from '../gitDiffManager'; import type { Logger } from '../../utils/logger'; -import type { GitStatus } from '../../types/session'; +import type { GitStatus, Session } from '../../types/session'; import type { GitIndexStatus } from '../gitPlumbingCommands'; import type { CommandRunner } from '../../utils/commandRunner'; import type { DatabaseService } from '../../database/database'; @@ -146,6 +147,20 @@ describe('GitStatusManager', () => { }); describe('fetchGitStatus via getGitStatus (cache miss scenarios)', () => { + it('skips home-directory scans on initial load and focus refresh', async () => { + vi.mocked(mockSessionManager.getSession).mockResolvedValue(partialMock({ + ...mockSession, worktreePath: os.homedir(), + })); + const initial = await managerPrivates(gitStatusManager).fetchGitStatus('test-session'); + expect(initial?.state).toBe('unknown'); + managerPrivates(gitStatusManager).updateCache('test-session', { state: 'clean' }); + const refreshed = await gitStatusManager.refreshSessionGitStatus('test-session'); + expect(refreshed?.state).toBe('unknown'); + expect(fastCheckWorkingDirectory).not.toHaveBeenCalled(); + expect(mockProjectContext.commandRunner.exec).not.toHaveBeenCalled(); + gitStatusManager.stopPolling(); + }); + it('returns clean state when no changes, no ahead/behind, no untracked', async () => { vi.mocked(fastCheckWorkingDirectory).mockReturnValue(cleanIndexStatus); vi.mocked(fastGetAheadBehind).mockReturnValue({ ahead: 0, behind: 0 }); diff --git a/main/src/services/configManager.test.ts b/main/src/services/configManager.test.ts index 60a95b284..3056b885c 100644 --- a/main/src/services/configManager.test.ts +++ b/main/src/services/configManager.test.ts @@ -112,3 +112,47 @@ describe('ConfigManager appearance persistence', () => { rename.mockRestore(); }); }); + + +describe('ConfigManager freeze prevention defaults', () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!; + let paneDir: string; + + beforeEach(async () => { + paneDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pane-freeze-config-')); + vi.stubEnv('PANE_DIR', paneDir); + vi.stubEnv('PANE_USE_PTY_HOST', ''); + }); + + afterEach(async () => { + Object.defineProperty(process, 'platform', originalPlatform); + vi.unstubAllEnvs(); + await fs.rm(paneDir, { recursive: true, force: true }); + }); + + it.each(['win32', 'darwin', 'linux'])('defaults the isolated PTY host appropriately on %s', async (platform) => { + Object.defineProperty(process, 'platform', { value: platform }); + await fs.writeFile(path.join(paneDir, 'config.json'), JSON.stringify({ verbose: false })); + const manager = new ConfigManager(); + await manager.initialize(); + expect(manager.getUsePtyHost()).toBe(platform === 'win32'); + expect(manager.getConfig().usePtyHost).toBe(platform === 'win32'); + expect(manager.getGitRepoPath()).toBe(''); + }); + + it('preserves an explicit Windows opt-out and honors the development override', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + await fs.writeFile(path.join(paneDir, 'config.json'), JSON.stringify({ usePtyHost: false })); + const manager = new ConfigManager(); + await manager.initialize(); + expect(manager.getUsePtyHost()).toBe(false); + vi.stubEnv('PANE_USE_PTY_HOST', '1'); + expect(manager.getUsePtyHost()).toBe(true); + }); + + it('rejects a legacy home-directory Git root but preserves a project path', () => { + expect(new ConfigManager(os.homedir()).getGitRepoPath()).toBe(''); + const repoPath = path.join(os.homedir(), 'project'); + expect(new ConfigManager(repoPath).getGitRepoPath()).toBe(repoPath); + }); +}); diff --git a/main/src/services/configManager.ts b/main/src/services/configManager.ts index 961b7a480..9e9674b61 100644 --- a/main/src/services/configManager.ts +++ b/main/src/services/configManager.ts @@ -8,8 +8,8 @@ import { DEFAULT_WORKTREE_FILE_SYNC_ENTRIES } from '../../../shared/types/worktr import fs from 'fs/promises'; import { watch, type FSWatcher } from 'fs'; import path from 'path'; -import os from 'os'; import { randomUUID } from 'crypto'; +import { HOME_GIT_SCAN_WARNING, isHomeDirectory } from '../utils/gitScanSafety'; import { getAppDirectory } from '../utils/appDirectory'; import { clearShellPathCache } from '../utils/shellPath'; import { boundary, decodeBoundary } from '../../../shared/validation/boundaryDecoder'; @@ -47,7 +47,8 @@ export class ConfigManager extends EventEmitter { this.configDir = getAppDirectory(); this.configPath = path.join(this.configDir, 'config.json'); this.config = { - gitRepoPath: defaultGitPath || os.homedir(), + gitRepoPath: defaultGitPath || '', + usePtyHost: process.platform === 'win32', verbose: false, anthropicApiKey: undefined, falApiKey: undefined, @@ -411,7 +412,12 @@ export class ConfigManager extends EventEmitter { } getGitRepoPath(): string { - return this.config.gitRepoPath || ''; + const repoPath = this.config.gitRepoPath || ''; + if (isHomeDirectory(repoPath)) { + console.warn(`[ConfigManager] ${HOME_GIT_SCAN_WARNING}`); + return ''; + } + return repoPath; } isVerbose(): boolean { @@ -420,7 +426,7 @@ export class ConfigManager extends EventEmitter { /** * Whether PTY spawns should be routed through the isolated ptyHost - * `UtilityProcess`. Off by default. The `PANE_USE_PTY_HOST=1` env var is + * `UtilityProcess`. On by default on Windows. The `PANE_USE_PTY_HOST=1` env var is * honored as a dev override so testing doesn't require flipping the config. */ getUsePtyHost(): boolean { diff --git a/main/src/services/database.ts b/main/src/services/database.ts index 6cf8e7ab6..55ff8fb8c 100644 --- a/main/src/services/database.ts +++ b/main/src/services/database.ts @@ -11,7 +11,7 @@ export const databaseService = new DatabaseService(dbPath); databaseService.initialize(); // Scrollback retention sweep: runs synchronously at module load, which happens -// before panelManager's constructor caches any panels into RAM. Deferring this +// before panelManager restores panels on demand. Deferring this // (e.g. via a setTimeout in app.whenReady) would let the in-memory panel cache // keep the stale scrollback for the whole first launch even after the DB is // trimmed. Result is captured here and logged later once Logger is initialized. diff --git a/main/src/services/gitFileWatcher.ts b/main/src/services/gitFileWatcher.ts index 55ef0d6e9..17b94df88 100644 --- a/main/src/services/gitFileWatcher.ts +++ b/main/src/services/gitFileWatcher.ts @@ -1,3 +1,4 @@ +import { HOME_GIT_SCAN_WARNING, isHomeDirectory } from '../utils/gitScanSafety'; import { EventEmitter } from 'events'; import { watch as chokidarWatch, type FSWatcher } from 'chokidar'; import path from 'path'; @@ -112,6 +113,11 @@ export class GitFileWatcher extends EventEmitter { // Stop existing watcher if any this.stopWatching(sessionId); + if (isHomeDirectory(worktreePath)) { + this.logger?.warn(`[GitFileWatcher] ${HOME_GIT_SCAN_WARNING}`); + return; + } + try { if (this.commandRunner?.wslContext) { if (this.startWSLNativeWatcher(sessionId, worktreePath)) { diff --git a/main/src/services/gitStatusManager.ts b/main/src/services/gitStatusManager.ts index 95eccd250..8135011eb 100644 --- a/main/src/services/gitStatusManager.ts +++ b/main/src/services/gitStatusManager.ts @@ -1,3 +1,4 @@ +import { HOME_GIT_SCAN_WARNING, isHomeDirectory } from '../utils/gitScanSafety'; import { EventEmitter } from 'events'; import type { Logger } from '../utils/logger'; import type { GitStatus } from '../types/session'; @@ -332,7 +333,7 @@ export class GitStatusManager extends EventEmitter { } else { // Other sessions may now be behind main const cached = this.cache[session.id]; - if (cached && session.worktreePath) { + if (cached && session.worktreePath && !isHomeDirectory(session.worktreePath)) { try { // Quick check for new ahead/behind status const ctx = this.sessionManager.getProjectContext(session.id); @@ -382,7 +383,7 @@ export class GitStatusManager extends EventEmitter { } const session = await this.sessionManager.getSession(sessionId); - if (!session || !session.worktreePath) { + if (!session || !session.worktreePath || isHomeDirectory(session.worktreePath)) { return; } @@ -847,6 +848,7 @@ export class GitStatusManager extends EventEmitter { * Returns true if status is different from cached, false if unchanged */ private async hasGitStatusChanged(sessionId: string, worktreePath: string): Promise { + if (isHomeDirectory(worktreePath)) return true; const cached = this.cache[sessionId]; if (!cached) return true; @@ -902,6 +904,12 @@ export class GitStatusManager extends EventEmitter { return null; } + if (isHomeDirectory(session.worktreePath)) { + this.logger?.warn(`[GitStatus] ${HOME_GIT_SCAN_WARNING}`); + this.abortControllers.delete(sessionId); + return { state: 'unknown', lastChecked: new Date().toISOString() }; + } + // Check if operation was cancelled if (abortController.signal.aborted) { this.abortControllers.delete(sessionId); diff --git a/main/src/services/panelManager.ts b/main/src/services/panelManager.ts index 8182057f4..155712355 100644 --- a/main/src/services/panelManager.ts +++ b/main/src/services/panelManager.ts @@ -44,20 +44,20 @@ class PanelManager { } constructor() { - // Load panels from database on startup (but don't initialize processes) + // Clean up restart-only state; other panels are loaded on demand this.loadPanelsFromDatabase(); } private loadPanelsFromDatabase(): void { // This will be called on app startup to restore panel state // But we don't start any processes - that happens lazily - console.log('[PanelManager] Loading panels from database...'); + console.log('[PanelManager] Loading panels requiring restart cleanup...'); - // Load all panels from database - const allPanels = databaseService.getAllPanels(); + // Load only panels requiring restart cleanup; terminal state is loaded on demand. + const startupPanels = databaseService.getPanelsForStartup(); // Clean up any stale running states in logs panels - allPanels.forEach(panel => { + startupPanels.forEach(panel => { if (panel.type === 'logs' && panel.state?.customState) { const logsState = logsPanelState(panel); if (logsState.isRunning) { @@ -397,14 +397,15 @@ class PanelManager { return undefined; } - getPanelsForSession(sessionId: string): ToolPanel[] { + getPanelsForSession(sessionId: string, includeScrollback = true): ToolPanel[] { // Always get fresh from database to ensure consistency - const panels = databaseService.getPanelsForSession(sessionId); + const panels = databaseService.getPanelsForSession(sessionId, includeScrollback); // If this session has been archived in this process, we still // return the panels (callers like the sessions:delete PTY-destroy // loop need them) but we do NOT re-populate this.panels — doing so // would undo the L3 cleanup that cleared them moments earlier. - const shouldCache = !this.archivedSessionIds.has(sessionId); + // Summary reads must never replace complete cached state with missing buffers. + const shouldCache = includeScrollback && !this.archivedSessionIds.has(sessionId); // Fix any panels that have state stored as a string (defensive programming) panels.forEach(panel => { diff --git a/main/src/services/workspaceStateReader.ts b/main/src/services/workspaceStateReader.ts index 3abe4efe8..86dee9ad6 100644 --- a/main/src/services/workspaceStateReader.ts +++ b/main/src/services/workspaceStateReader.ts @@ -63,7 +63,7 @@ export class WorkspaceStateReader { const panelSummaries: RunpaneWorkspacePanelSummary[] = []; - for (const panel of panelManager.getPanelsForSession(session.id)) { + for (const panel of panelManager.getPanelsForSession(session.id, false)) { if (panel.type !== 'terminal') continue; const customState = decodeBoundary(panel.state.customState ?? {}, boundary.object({ isCliPanel: boundary.optional(boundary.boolean), @@ -105,7 +105,7 @@ export class WorkspaceStateReader { for (const session of sessions) { if (session.archived || session.isHidden) continue; const project = this.sessionManager.getProjectForSession(session.id); - for (const panel of panelManager.getPanelsForSession(session.id)) { + for (const panel of panelManager.getPanelsForSession(session.id, false)) { if (panel.type !== 'terminal') continue; const customState = decodeBoundary(panel.state.customState ?? {}, boundary.object({ isCliPanel: boundary.optional(boundary.boolean), diff --git a/main/src/types/config.ts b/main/src/types/config.ts index e79ffff84..308440d83 100644 --- a/main/src/types/config.ts +++ b/main/src/types/config.ts @@ -116,7 +116,7 @@ export interface AppConfig { // Use interactive mode for Claude CLI (persistent process with stdin instead of spawn-per-message) useInteractiveMode?: boolean; // Route PTY spawns through an isolated ptyHost UtilityProcess for crash isolation. - // Off by default. Requires app restart; the supervisor is forked once at `app.whenReady`. + // On by default on Windows. Requires app restart; the supervisor is forked once at `app.whenReady`. usePtyHost?: boolean; // PostHog analytics settings analytics?: { @@ -209,7 +209,7 @@ export interface UpdateConfigRequest { // Use interactive mode for Claude CLI (persistent process with stdin instead of spawn-per-message) useInteractiveMode?: boolean; // Route PTY spawns through an isolated ptyHost UtilityProcess for crash isolation. - // Off by default. Requires app restart to take effect. + // On by default on Windows. Requires app restart to take effect. usePtyHost?: boolean; // PostHog analytics settings analytics?: AppConfig['analytics']; diff --git a/main/src/utils/gitScanSafety.test.ts b/main/src/utils/gitScanSafety.test.ts new file mode 100644 index 000000000..82ad8b325 --- /dev/null +++ b/main/src/utils/gitScanSafety.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { isHomeDirectory } from './gitScanSafety'; + +describe('home directory Git scan safeguard', () => { + it('normalizes Windows casing, separators, trailing slashes and dot segments', () => { + const home = 'C:\\Users\\Alice'; + for (const candidate of [home, 'c:/users/ALICE/', 'C:\\Users\\Alice\\repo\\..']) { + expect(isHomeDirectory(candidate, home, 'win32')).toBe(true); + } + expect(isHomeDirectory('C:\\Users\\Alice\\repo', home, 'win32')).toBe(false); + expect(isHomeDirectory('C:\\Users\\Alice-other', home, 'win32')).toBe(false); + }); + + it('preserves POSIX case sensitivity and allows project folders', () => { + expect(isHomeDirectory('/home/alice/./', '/home/alice', 'linux')).toBe(true); + expect(isHomeDirectory('/home/Alice', '/home/alice', 'linux')).toBe(false); + expect(isHomeDirectory('/home/alice/repo', '/home/alice', 'linux')).toBe(false); + expect(isHomeDirectory('', '/home/alice', 'linux')).toBe(false); + }); +}); diff --git a/main/src/utils/gitScanSafety.ts b/main/src/utils/gitScanSafety.ts new file mode 100644 index 000000000..59ff250fe --- /dev/null +++ b/main/src/utils/gitScanSafety.ts @@ -0,0 +1,19 @@ +import os from 'os'; +import path from 'path'; + +/** Compare paths without invoking Git or walking the user profile. */ +export function isHomeDirectory( + directory: string, + homeDirectory = os.homedir(), + platform: NodeJS.Platform = process.platform, +): boolean { + if (!directory) return false; + const paths = platform === 'win32' ? path.win32 : path.posix; + const normalize = (value: string): string => { + const resolved = paths.resolve(value); + return platform === 'win32' ? resolved.toLowerCase() : resolved; + }; + return normalize(directory) === normalize(homeDirectory); +} + +export const HOME_GIT_SCAN_WARNING = 'Git status scanning is disabled for the home directory to avoid scanning the entire user profile. Select a project repository folder instead.';